stackgen-sdk 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. stackgen/__init__.py +52 -0
  2. stackgen/_http.py +96 -0
  3. stackgen/_version.py +24 -0
  4. stackgen/aiden/__init__.py +9 -0
  5. stackgen/aiden/namespace.py +26 -0
  6. stackgen/aiden/runs.py +36 -0
  7. stackgen/aiden/webhook.py +185 -0
  8. stackgen/cli.py +33 -0
  9. stackgen/client.py +31 -0
  10. stackgen/config.py +88 -0
  11. stackgen/errors.py +41 -0
  12. stackgen/generated/.openapi-generator/FILES +141 -0
  13. stackgen/generated/.openapi-generator/VERSION +1 -0
  14. stackgen/generated/.openapi-generator-ignore +23 -0
  15. stackgen/generated/__init__.py +131 -0
  16. stackgen/generated/api/__init__.py +6 -0
  17. stackgen/generated/api/aiden_api.py +1806 -0
  18. stackgen/generated/api/sre_api.py +1618 -0
  19. stackgen/generated/api_client.py +801 -0
  20. stackgen/generated/api_response.py +21 -0
  21. stackgen/generated/configuration.py +600 -0
  22. stackgen/generated/exceptions.py +216 -0
  23. stackgen/generated/models/__init__.py +57 -0
  24. stackgen/generated/models/alert_analysis_status.py +40 -0
  25. stackgen/generated/models/alert_attention.py +39 -0
  26. stackgen/generated/models/alert_categorization_summary.py +97 -0
  27. stackgen/generated/models/alert_role.py +38 -0
  28. stackgen/generated/models/alert_sort_by.py +37 -0
  29. stackgen/generated/models/alert_status.py +38 -0
  30. stackgen/generated/models/alert_summary.py +95 -0
  31. stackgen/generated/models/alert_sync_run_status.py +38 -0
  32. stackgen/generated/models/alert_v1.py +213 -0
  33. stackgen/generated/models/artifact_info.py +98 -0
  34. stackgen/generated/models/error_response.py +91 -0
  35. stackgen/generated/models/investigate_alert_request.py +91 -0
  36. stackgen/generated/models/investigate_response.py +91 -0
  37. stackgen/generated/models/investigation.py +167 -0
  38. stackgen/generated/models/investigation_evidence.py +114 -0
  39. stackgen/generated/models/investigation_evidence_kind.py +44 -0
  40. stackgen/generated/models/investigation_evidence_source.py +39 -0
  41. stackgen/generated/models/investigation_hypothesis.py +112 -0
  42. stackgen/generated/models/investigation_plain_summary.py +104 -0
  43. stackgen/generated/models/investigation_prior_incident.py +109 -0
  44. stackgen/generated/models/investigation_recommended_next_step.py +101 -0
  45. stackgen/generated/models/investigation_ref.py +118 -0
  46. stackgen/generated/models/investigation_status.py +42 -0
  47. stackgen/generated/models/investigation_structured_hypothesis_entry.py +115 -0
  48. stackgen/generated/models/investigation_structured_limitation_entry.py +120 -0
  49. stackgen/generated/models/investigation_structured_rca.py +139 -0
  50. stackgen/generated/models/investigation_triage_metadata.py +134 -0
  51. stackgen/generated/models/json_error.py +91 -0
  52. stackgen/generated/models/list_alerts_response.py +119 -0
  53. stackgen/generated/models/list_investigations_response.py +101 -0
  54. stackgen/generated/models/pagination.py +91 -0
  55. stackgen/generated/models/schedule_run_status.py +39 -0
  56. stackgen/generated/models/schedule_target_type.py +38 -0
  57. stackgen/generated/models/session.py +141 -0
  58. stackgen/generated/models/session_responder_kind.py +40 -0
  59. stackgen/generated/models/signal_severity.py +40 -0
  60. stackgen/generated/models/sync_response.py +136 -0
  61. stackgen/generated/models/trigger_webhook202_response.py +98 -0
  62. stackgen/generated/models/webhook_run.py +129 -0
  63. stackgen/generated/models/webhook_run_detail.py +136 -0
  64. stackgen/generated/models/webhook_run_list_response.py +97 -0
  65. stackgen/generated/rest.py +258 -0
  66. stackgen/sre/__init__.py +5 -0
  67. stackgen/sre/namespace.py +57 -0
  68. stackgen/vault/__init__.py +5 -0
  69. stackgen/vault/namespace.py +17 -0
  70. stackgen_sdk-0.1.1.dist-info/METADATA +60 -0
  71. stackgen_sdk-0.1.1.dist-info/RECORD +73 -0
  72. stackgen_sdk-0.1.1.dist-info/WHEEL +4 -0
  73. stackgen_sdk-0.1.1.dist-info/entry_points.txt +2 -0
stackgen/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """StackGen Python SDK.
2
+
3
+ Public entrypoint::
4
+
5
+ from stackgen import StackgenClient, StackgenConfig
6
+
7
+ client = StackgenClient.from_env()
8
+ result = client.aiden.run_webhook_and_download_report(payload)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from stackgen.client import StackgenClient
14
+ from stackgen.config import StackgenConfig
15
+ from stackgen.errors import HttpError, NotAllowlistedError, StackgenError, TimeoutError
16
+
17
+ __all__ = [
18
+ "HttpError",
19
+ "NotAllowlistedError",
20
+ "StackgenClient",
21
+ "StackgenConfig",
22
+ "StackgenError",
23
+ "TimeoutError",
24
+ ]
25
+
26
+
27
+ def _resolve_version() -> str:
28
+ try:
29
+ from importlib.metadata import PackageNotFoundError, version
30
+ except ImportError: # pragma: no cover
31
+ from importlib_metadata import PackageNotFoundError, version # type: ignore
32
+
33
+ try:
34
+ return version("stackgen-sdk")
35
+ except PackageNotFoundError:
36
+ try:
37
+ return version("stackgen")
38
+ except PackageNotFoundError:
39
+ try:
40
+ from stackgen._version import __version__ as vcs_version
41
+
42
+ return vcs_version
43
+ except ImportError:
44
+ try:
45
+ from stackgen._version import version as vcs_version
46
+
47
+ return vcs_version
48
+ except ImportError:
49
+ return "0.0.0"
50
+
51
+
52
+ __version__ = _resolve_version()
stackgen/_http.py ADDED
@@ -0,0 +1,96 @@
1
+ """Shared HTTP helpers (stdlib)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import ssl
7
+ import time
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from typing import Any, Callable
12
+
13
+ from stackgen.errors import HttpError, TimeoutError
14
+
15
+ _RETRYABLE = {429, 502, 503, 504}
16
+
17
+
18
+ def query(org_id: str) -> str:
19
+ if not org_id:
20
+ return ""
21
+ return "?" + urllib.parse.urlencode({"orgId": org_id})
22
+
23
+
24
+ def request(
25
+ url: str,
26
+ token: str,
27
+ *,
28
+ method: str = "GET",
29
+ body: bytes | None = None,
30
+ content_type: str | None = None,
31
+ expect_json: bool = True,
32
+ retries: int = 3,
33
+ ) -> Any:
34
+ """Bearer HTTP call with bounded retries on transient GET failures."""
35
+ headers = {"Authorization": f"Bearer {token}"}
36
+ if content_type:
37
+ headers["Content-Type"] = content_type
38
+
39
+ attempt = 0
40
+ while True:
41
+ attempt += 1
42
+ req = urllib.request.Request(url, data=body, headers=headers, method=method)
43
+ try:
44
+ with urllib.request.urlopen(req, context=ssl.create_default_context()) as resp:
45
+ raw = resp.read()
46
+ if not expect_json:
47
+ return raw
48
+ if not raw:
49
+ return {}
50
+ return json.loads(raw.decode("utf-8"))
51
+ except urllib.error.HTTPError as err:
52
+ detail = err.read().decode("utf-8", errors="replace")
53
+ if method == "GET" and err.code in _RETRYABLE and attempt < retries:
54
+ time.sleep(min(2**attempt, 8))
55
+ continue
56
+ raise HttpError(
57
+ f"{method} {url} -> HTTP {err.code}: {detail}",
58
+ status_code=err.code,
59
+ ) from err
60
+ except urllib.error.URLError as err:
61
+ if method == "GET" and attempt < retries:
62
+ time.sleep(min(2**attempt, 8))
63
+ continue
64
+ raise HttpError(f"{method} {url} -> {err}") from err
65
+
66
+
67
+ def as_list(payload: Any) -> list[dict[str, Any]]:
68
+ if isinstance(payload, list):
69
+ return [item for item in payload if isinstance(item, dict)]
70
+ if isinstance(payload, dict):
71
+ items = payload.get("items")
72
+ if isinstance(items, list):
73
+ return [item for item in items if isinstance(item, dict)]
74
+ return []
75
+
76
+
77
+ def text(value: Any) -> str:
78
+ if value is None:
79
+ return ""
80
+ return str(value).strip()
81
+
82
+
83
+ def poll_until(
84
+ deadline: float,
85
+ interval: float,
86
+ tick: Callable[[], Any | None],
87
+ *,
88
+ label: str,
89
+ timeout_seconds: int,
90
+ ) -> Any:
91
+ while time.monotonic() < deadline:
92
+ result = tick()
93
+ if result is not None:
94
+ return result
95
+ time.sleep(interval)
96
+ raise TimeoutError(label, timeout_seconds)
stackgen/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.1'
22
+ __version_tuple__ = version_tuple = (0, 1, 1)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,9 @@
1
+ """Aiden (platform) namespace — webhooks, sessions, artifacts."""
2
+
3
+ from stackgen.aiden.namespace import AidenNamespace
4
+ from stackgen.aiden.webhook import WebhookReportResult
5
+
6
+ __all__ = [
7
+ "AidenNamespace",
8
+ "WebhookReportResult",
9
+ ]
@@ -0,0 +1,26 @@
1
+ """Aiden namespace — public methods for platform APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from stackgen.aiden.webhook import WebhookReportJourney, WebhookReportResult
6
+ from stackgen.config import StackgenConfig
7
+
8
+
9
+ class AidenNamespace:
10
+ """Aiden platform APIs (sessions, webhooks, artifacts)."""
11
+
12
+ def __init__(self, config: StackgenConfig) -> None:
13
+ self._config = config
14
+ self._webhook = WebhookReportJourney(config)
15
+
16
+ def run_webhook_and_download_report(
17
+ self,
18
+ payload: str,
19
+ *,
20
+ artifact_name: str | None = None,
21
+ output_path: str | None = None,
22
+ ) -> WebhookReportResult:
23
+ """Trigger a webhook, wait for the session artifact, download it."""
24
+ return self._webhook.run(
25
+ payload, artifact_name=artifact_name, output_path=output_path
26
+ )
stackgen/aiden/runs.py ADDED
@@ -0,0 +1,36 @@
1
+ """Pure helpers for webhook run selection (unit-testable without HTTP)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from stackgen import _http
8
+
9
+
10
+ def pick_run(
11
+ runs: list[dict[str, Any]],
12
+ dispatch_run_id: str,
13
+ known_run_ids: frozenset[str],
14
+ ) -> dict[str, Any] | None:
15
+ """Choose the webhook run that corresponds to a new trigger.
16
+
17
+ Preference order:
18
+ 1. Exact match on ``id`` / ``run_id`` to ``dispatch_run_id``
19
+ 2. First run whose id is not in ``known_run_ids`` (new since pre-trigger snapshot)
20
+ 3. If no known ids and runs exist, the first run (best-effort on older motherships)
21
+ """
22
+ if dispatch_run_id:
23
+ for item in runs:
24
+ if dispatch_run_id in {
25
+ _http.text(item.get("id")),
26
+ _http.text(item.get("run_id")),
27
+ }:
28
+ return item
29
+ for item in runs:
30
+ if _http.text(item.get("id")) not in known_run_ids:
31
+ return item
32
+ if not runs:
33
+ return None
34
+ if not known_run_ids:
35
+ return runs[0]
36
+ return None
@@ -0,0 +1,185 @@
1
+ """Aiden webhook → session-report journey."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import urllib.parse
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from stackgen import _http
13
+ from stackgen.aiden.runs import pick_run
14
+ from stackgen.config import StackgenConfig
15
+ from stackgen.errors import StackgenError, TimeoutError
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class WebhookReportResult:
20
+ """Result of trigger → wait → download session-report."""
21
+
22
+ webhook_id: str
23
+ invocation_id: str
24
+ session_id: str
25
+ artifacts: tuple[dict[str, Any], ...]
26
+ output_path: Path
27
+
28
+
29
+ class WebhookReportJourney:
30
+ """Trigger a webhook, wait for the session artifact, and download it.
31
+
32
+ Uses ``webhook_token`` for POST trigger and ``api_token`` for poll/download.
33
+ """
34
+
35
+ def __init__(self, config: StackgenConfig) -> None:
36
+ self._config = config
37
+
38
+ def run(
39
+ self,
40
+ payload: str,
41
+ *,
42
+ artifact_name: str | None = None,
43
+ output_path: str | None = None,
44
+ ) -> WebhookReportResult:
45
+ cfg = self._config
46
+ if not cfg.webhook_token:
47
+ raise ValueError("webhook_token is required for run_webhook_and_download_report")
48
+
49
+ artifact = artifact_name or cfg.artifact_name
50
+ dest = output_path or cfg.output_path
51
+
52
+ known_run_ids: frozenset[str] = frozenset()
53
+ if cfg.webhook_id:
54
+ known_run_ids = frozenset(
55
+ _http.text(item.get("id"))
56
+ for item in self._list_runs(cfg.webhook_id)
57
+ if _http.text(item.get("id"))
58
+ )
59
+
60
+ trigger = self._trigger(payload)
61
+ webhook_id = _http.text(trigger.get("webhook_id")) or _http.text(cfg.webhook_id)
62
+ invocation_id = _http.text(trigger.get("invocation_id"))
63
+ session_id = _http.text(trigger.get("session_id"))
64
+
65
+ if not session_id and not webhook_id:
66
+ raise StackgenError(
67
+ "trigger 202 did not include session_id or webhook_id; "
68
+ "set webhook_id for older motherships. "
69
+ f"body={json.dumps(trigger)}"
70
+ )
71
+
72
+ if not session_id:
73
+ session_id = self._wait_for_session(
74
+ webhook_id,
75
+ invocation_id,
76
+ _http.text(trigger.get("run_id")),
77
+ known_run_ids,
78
+ )
79
+
80
+ artifacts = self._wait_for_artifact(session_id, artifact)
81
+ path = self._download_artifact(session_id, artifact, dest)
82
+ return WebhookReportResult(
83
+ webhook_id=webhook_id,
84
+ invocation_id=invocation_id,
85
+ session_id=session_id,
86
+ artifacts=tuple(artifacts),
87
+ output_path=path,
88
+ )
89
+
90
+ def _trigger(self, payload: str) -> dict[str, Any]:
91
+ url = f"{self._config.aiden_url()}/api/v1/webhooks/trigger{_http.query(self._config.org_id)}"
92
+ data = _http.request(
93
+ url,
94
+ self._config.webhook_token,
95
+ method="POST",
96
+ body=payload.encode("utf-8"),
97
+ content_type="text/plain",
98
+ )
99
+ if not isinstance(data, dict):
100
+ raise StackgenError(f"unexpected trigger payload: {data!r}")
101
+ return data
102
+
103
+ def _list_runs(self, webhook_id: str) -> list[dict[str, Any]]:
104
+ url = (
105
+ f"{self._config.aiden_url()}/api/v1/webhooks/{webhook_id}/runs"
106
+ f"{_http.query(self._config.org_id)}"
107
+ )
108
+ return _http.as_list(_http.request(url, self._config.api_token))
109
+
110
+ def _get_run(self, webhook_id: str, run_id: str) -> dict[str, Any]:
111
+ url = (
112
+ f"{self._config.aiden_url()}/api/v1/webhooks/{webhook_id}/runs/{run_id}"
113
+ f"{_http.query(self._config.org_id)}"
114
+ )
115
+ data = _http.request(url, self._config.api_token)
116
+ if not isinstance(data, dict):
117
+ raise StackgenError(f"unexpected run payload: {data!r}")
118
+ return data
119
+
120
+ def _wait_for_session(
121
+ self,
122
+ webhook_id: str,
123
+ invocation_id: str,
124
+ dispatch_run_id: str,
125
+ known_run_ids: frozenset[str],
126
+ ) -> str:
127
+ cfg = self._config
128
+ deadline = time.monotonic() + cfg.timeout_seconds
129
+ run_id_hint = invocation_id or dispatch_run_id
130
+ while time.monotonic() < deadline:
131
+ run: dict[str, Any] | None = None
132
+ if run_id_hint:
133
+ try:
134
+ run = self._get_run(webhook_id, run_id_hint)
135
+ except StackgenError:
136
+ run = None
137
+ if run is None:
138
+ run = pick_run(self._list_runs(webhook_id), dispatch_run_id, known_run_ids)
139
+ if run is None:
140
+ time.sleep(cfg.poll_interval_seconds)
141
+ continue
142
+ if not run_id_hint:
143
+ run_id_hint = _http.text(run.get("id")) or _http.text(run.get("run_id"))
144
+ status = _http.text(run.get("status")).casefold()
145
+ session_id = _http.text(run.get("session_id"))
146
+ if status in {"failed", "skipped"}:
147
+ raise StackgenError(
148
+ f"webhook invocation ended with status {status}: "
149
+ f"{run.get('error') or 'no error detail'}"
150
+ )
151
+ if session_id:
152
+ return session_id
153
+ time.sleep(cfg.poll_interval_seconds)
154
+ raise TimeoutError("webhook session", cfg.timeout_seconds)
155
+
156
+ def _wait_for_artifact(
157
+ self, session_id: str, artifact_name: str
158
+ ) -> list[dict[str, Any]]:
159
+ cfg = self._config
160
+ deadline = time.monotonic() + cfg.timeout_seconds
161
+ url = (
162
+ f"{cfg.aiden_url()}/api/v1/sessions/{session_id}/artifacts"
163
+ f"{_http.query(cfg.org_id)}"
164
+ )
165
+ want = artifact_name.casefold()
166
+ artifacts: list[dict[str, Any]] = []
167
+ while time.monotonic() < deadline:
168
+ artifacts = _http.as_list(_http.request(url, cfg.api_token))
169
+ if any(_http.text(item.get("name")).casefold() == want for item in artifacts):
170
+ return artifacts
171
+ time.sleep(cfg.poll_interval_seconds)
172
+ raise TimeoutError(artifact_name, cfg.timeout_seconds)
173
+
174
+ def _download_artifact(
175
+ self, session_id: str, artifact_name: str, output_path: str
176
+ ) -> Path:
177
+ encoded = urllib.parse.quote(artifact_name, safe="")
178
+ url = (
179
+ f"{self._config.aiden_url()}/api/v1/sessions/{session_id}/artifacts/"
180
+ f"{encoded}/download{_http.query(self._config.org_id)}"
181
+ )
182
+ raw = _http.request(url, self._config.api_token, expect_json=False)
183
+ dest = Path(output_path)
184
+ dest.write_bytes(raw)
185
+ return dest
stackgen/cli.py ADDED
@@ -0,0 +1,33 @@
1
+ """CLI: trigger webhook and download session-report.md."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from stackgen.client import StackgenClient
9
+ from stackgen.config import StackgenConfig
10
+ from stackgen.errors import StackgenError
11
+
12
+
13
+ def main(argv: list[str] | None = None) -> None:
14
+ args = list(sys.argv[1:] if argv is None else argv)
15
+ payload = "{}"
16
+ if args:
17
+ path = Path(args[0])
18
+ if not path.is_file():
19
+ print(f"error: payload file does not exist: {path}", file=sys.stderr)
20
+ sys.exit(1)
21
+ payload = path.read_text(encoding="utf-8")
22
+ try:
23
+ client = StackgenClient(StackgenConfig.from_env())
24
+ result = client.aiden.run_webhook_and_download_report(payload)
25
+ except (StackgenError, ValueError) as err:
26
+ print(f"error: {err}", file=sys.stderr)
27
+ sys.exit(1)
28
+ print(f"session_id={result.session_id}")
29
+ print(f"output_path={result.output_path}")
30
+
31
+
32
+ if __name__ == "__main__":
33
+ main()
stackgen/client.py ADDED
@@ -0,0 +1,31 @@
1
+ """StackgenClient — one entrypoint for Aiden, SRE, and Vault."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from stackgen.aiden import AidenNamespace
6
+ from stackgen.config import StackgenConfig
7
+ from stackgen.sre import SRENamespace
8
+ from stackgen.vault import VaultNamespace
9
+
10
+
11
+ class StackgenClient:
12
+ """Product SDK client.
13
+
14
+ Namespaces:
15
+ - ``aiden`` — platform automations, sessions, session-report download
16
+ - ``sre`` — alerts and investigations
17
+ - ``vault`` — secrets (allowlisted separately)
18
+ """
19
+
20
+ def __init__(self, config: StackgenConfig):
21
+ if not config.api_token:
22
+ raise ValueError("api_token is required")
23
+ self.config = config
24
+ self.aiden = AidenNamespace(config)
25
+ self.sre = SRENamespace(config)
26
+ self.vault = VaultNamespace(config)
27
+
28
+ @classmethod
29
+ def from_env(cls) -> StackgenClient:
30
+ """Construct from environment variables (see StackgenConfig.from_env)."""
31
+ return cls(StackgenConfig.from_env())
stackgen/config.py ADDED
@@ -0,0 +1,88 @@
1
+ """Configuration for StackgenClient."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class StackgenConfig:
11
+ """Connection and auth settings for the StackGen mothership.
12
+
13
+ ``base_url`` is the mothership host without a product suffix
14
+ (for example ``https://azure-eu.cloud.stackgen.com``). Aiden routes live
15
+ under ``/guild``; SRE under ``/app/sre``.
16
+ """
17
+
18
+ base_url: str
19
+ api_token: str
20
+ webhook_token: str = ""
21
+ org_id: str = ""
22
+ webhook_id: str = ""
23
+ artifact_name: str = "session-report.md"
24
+ output_path: str = "session-report.md"
25
+ poll_interval_seconds: int = 5
26
+ timeout_seconds: int = 1800
27
+
28
+ def mothership(self) -> str:
29
+ """Normalized mothership URL without trailing slash or ``/guild``."""
30
+ base = self.base_url.rstrip("/")
31
+ if base.endswith("/guild"):
32
+ return base[: -len("/guild")]
33
+ return base
34
+
35
+ def aiden_url(self) -> str:
36
+ """HTTP prefix for Aiden APIs."""
37
+ return self.mothership() + "/guild"
38
+
39
+ def sre_url(self) -> str:
40
+ """HTTP prefix for the SRE app."""
41
+ return self.mothership() + "/app/sre"
42
+
43
+ def vault_url(self) -> str:
44
+ """HTTP prefix for Vault on the mothership edge."""
45
+ return self.mothership() + "/vault/v1"
46
+
47
+ @classmethod
48
+ def from_env(cls) -> StackgenConfig:
49
+ """Build config from environment variables.
50
+
51
+ Recognized variables:
52
+ STACKGEN_URL / STACKGEN_BASE_URL
53
+ STACKGEN_TOKEN / STACKGEN_API_TOKEN
54
+ WEBHOOK_TOKEN
55
+ ORG_ID / STACKGEN_ORG_ID / STACKGEN_PROJECT
56
+ WEBHOOK_ID
57
+ ARTIFACT_NAME, OUTPUT_PATH, POLL_INTERVAL_SECONDS, TIMEOUT_SECONDS
58
+ """
59
+ base = (
60
+ os.environ.get("STACKGEN_URL")
61
+ or os.environ.get("STACKGEN_BASE_URL")
62
+ or ""
63
+ ).strip()
64
+ token = (
65
+ os.environ.get("STACKGEN_TOKEN")
66
+ or os.environ.get("STACKGEN_API_TOKEN")
67
+ or ""
68
+ ).strip()
69
+ if not base:
70
+ raise ValueError("STACKGEN_URL (or STACKGEN_BASE_URL) is required")
71
+ if not token:
72
+ raise ValueError("STACKGEN_TOKEN (or STACKGEN_API_TOKEN) is required")
73
+ return cls(
74
+ base_url=base,
75
+ api_token=token,
76
+ webhook_token=os.environ.get("WEBHOOK_TOKEN", "").strip(),
77
+ org_id=(
78
+ os.environ.get("ORG_ID")
79
+ or os.environ.get("STACKGEN_ORG_ID")
80
+ or os.environ.get("STACKGEN_PROJECT")
81
+ or ""
82
+ ).strip(),
83
+ webhook_id=os.environ.get("WEBHOOK_ID", "").strip(),
84
+ artifact_name=os.environ.get("ARTIFACT_NAME", "session-report.md"),
85
+ output_path=os.environ.get("OUTPUT_PATH", "session-report.md"),
86
+ poll_interval_seconds=int(os.environ.get("POLL_INTERVAL_SECONDS", "5")),
87
+ timeout_seconds=int(os.environ.get("TIMEOUT_SECONDS", "1800")),
88
+ )
stackgen/errors.py ADDED
@@ -0,0 +1,41 @@
1
+ """SDK errors — typed hierarchy (fail closed, catch at boundaries)."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class StackgenError(RuntimeError):
7
+ """Base class for all StackGen SDK errors."""
8
+
9
+
10
+ class HttpError(StackgenError):
11
+ """An HTTP call to StackGen failed.
12
+
13
+ ``status_code`` is set when the server returned an HTTP status; it is
14
+ ``None`` for transport failures (DNS, connection reset, TLS).
15
+ """
16
+
17
+ def __init__(self, message: str, *, status_code: int | None = None) -> None:
18
+ self.status_code = status_code
19
+ super().__init__(message)
20
+
21
+
22
+ class TimeoutError(StackgenError):
23
+ """A poll/wait loop exceeded the configured timeout."""
24
+
25
+ def __init__(self, label: str, timeout_seconds: int) -> None:
26
+ self.label = label
27
+ self.timeout_seconds = timeout_seconds
28
+ super().__init__(f"timed out waiting for {label} after {timeout_seconds}s")
29
+
30
+
31
+ class NotAllowlistedError(StackgenError):
32
+ """Caller requested an operation that is not in the public SDK surface."""
33
+
34
+ def __init__(self, namespace: str, operation: str) -> None:
35
+ self.namespace = namespace
36
+ self.operation = operation
37
+ super().__init__(
38
+ f"{namespace}.{operation} is not allowlisted in this SDK release. "
39
+ "Upstream teams publish APIs via allowlists/external.yaml "
40
+ "(see docs/UPSTREAM.md)."
41
+ )