kaether 0.1.0__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.
aether_sdk/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ from aether_sdk.client import Aether, AsyncAether
2
+ from aether_sdk.exceptions import AetherError, AetherHTTPError, AetherJobError
3
+ from aether_sdk.models import Forecast, GhostResult, IngestResult, TimelineFrame
4
+ from aether_sdk.pressure import pressure_band, pressure_color
5
+
6
+ __all__ = [
7
+ "Aether",
8
+ "AetherError",
9
+ "AetherHTTPError",
10
+ "AetherJobError",
11
+ "AsyncAether",
12
+ "Forecast",
13
+ "GhostResult",
14
+ "IngestResult",
15
+ "TimelineFrame",
16
+ "pressure_band",
17
+ "pressure_color",
18
+ ]
aether_sdk/client.py ADDED
@@ -0,0 +1,277 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections.abc import Callable
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from aether_sdk.exceptions import AetherHTTPError, AetherJobError
10
+ from aether_sdk.models import (
11
+ ChangeSet,
12
+ Forecast,
13
+ GraphSlice,
14
+ IngestResult,
15
+ JobAccepted,
16
+ JobStatus,
17
+ UniverseSummary,
18
+ )
19
+
20
+ ProgressFn = Callable[[int, str], None]
21
+
22
+
23
+ def _raise_for_status(response: httpx.Response) -> None:
24
+ if response.is_success:
25
+ return
26
+ detail = response.text
27
+ try:
28
+ payload = response.json()
29
+ if isinstance(payload, dict):
30
+ detail = str(payload.get("detail", payload))
31
+ except ValueError:
32
+ pass
33
+ raise AetherHTTPError(response.status_code, detail)
34
+
35
+
36
+ class Aether:
37
+ """Sync client for the Aether engine HTTP API."""
38
+
39
+ def __init__(
40
+ self,
41
+ base_url: str = "http://127.0.0.1:8000",
42
+ *,
43
+ timeout: float = 60.0,
44
+ headers: dict[str, str] | None = None,
45
+ transport: httpx.BaseTransport | None = None,
46
+ ) -> None:
47
+ self.base_url = base_url.rstrip("/")
48
+ self._client = httpx.Client(
49
+ base_url=self.base_url,
50
+ timeout=timeout,
51
+ headers=headers,
52
+ transport=transport,
53
+ )
54
+
55
+ def close(self) -> None:
56
+ self._client.close()
57
+
58
+ def __enter__(self) -> Aether:
59
+ return self
60
+
61
+ def __exit__(self, *exc: object) -> None:
62
+ self.close()
63
+
64
+ def health(self) -> dict[str, Any]:
65
+ return self._get("/health")
66
+
67
+ def universes(self) -> list[UniverseSummary]:
68
+ rows = self._get("/v1/universes")
69
+ return [UniverseSummary.model_validate(row) for row in rows]
70
+
71
+ def ingest(
72
+ self,
73
+ *,
74
+ path: str = "",
75
+ url: str = "",
76
+ pr_ref: str = "",
77
+ velocity: float | None = None,
78
+ wait: bool = True,
79
+ poll_interval: float = 0.35,
80
+ on_progress: ProgressFn | None = None,
81
+ ) -> IngestResult | JobAccepted:
82
+ """Start an ingest job. By default waits until the forecast is ready."""
83
+ body = {
84
+ "path": path,
85
+ "url": url,
86
+ "pr_ref": pr_ref,
87
+ "velocity_override": velocity,
88
+ }
89
+ accepted = JobAccepted.model_validate(self._post("/v1/jobs", body))
90
+ if not wait:
91
+ return accepted
92
+ return self.wait_job(accepted.job_id, poll_interval=poll_interval, on_progress=on_progress)
93
+
94
+ def ingest_now(
95
+ self,
96
+ *,
97
+ path: str = "",
98
+ url: str = "",
99
+ pr_ref: str = "",
100
+ velocity: float | None = None,
101
+ ) -> IngestResult:
102
+ """Blocking ingest on the request thread (small repos)."""
103
+ return IngestResult.model_validate(
104
+ self._post(
105
+ "/v1/universes",
106
+ {
107
+ "path": path,
108
+ "url": url,
109
+ "pr_ref": pr_ref,
110
+ "velocity_override": velocity,
111
+ },
112
+ )
113
+ )
114
+
115
+ def job(self, job_id: str) -> JobStatus:
116
+ return JobStatus.model_validate(self._get(f"/v1/jobs/{job_id}"))
117
+
118
+ def wait_job(
119
+ self,
120
+ job_id: str,
121
+ *,
122
+ poll_interval: float = 0.35,
123
+ on_progress: ProgressFn | None = None,
124
+ ) -> IngestResult:
125
+ while True:
126
+ status = self.job(job_id)
127
+ if on_progress:
128
+ on_progress(status.percent, status.stage)
129
+ if status.failed:
130
+ raise AetherJobError(job_id, status.error or "ingest failed")
131
+ if status.done and status.result:
132
+ return status.result
133
+ time.sleep(poll_interval)
134
+
135
+ def forecast(self, universe_id: str, horizon_months: int = 24) -> Forecast:
136
+ return Forecast.model_validate(
137
+ self._get(f"/v1/universes/{universe_id}/forecast", {"horizon_months": horizon_months})
138
+ )
139
+
140
+ def graph(self, universe_id: str, t: int = 0) -> GraphSlice:
141
+ return GraphSlice.model_validate(self._get(f"/v1/universes/{universe_id}/graph", {"t": t}))
142
+
143
+ def attach_changeset(self, universe_id: str, changeset: ChangeSet | dict[str, Any]) -> ChangeSet:
144
+ payload = changeset.model_dump() if isinstance(changeset, ChangeSet) else changeset
145
+ return ChangeSet.model_validate(self._post(f"/v1/universes/{universe_id}/changesets", payload))
146
+
147
+ def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
148
+ response = self._client.get(path, params=params)
149
+ _raise_for_status(response)
150
+ return response.json()
151
+
152
+ def _post(self, path: str, body: dict[str, Any]) -> Any:
153
+ response = self._client.post(path, json=body)
154
+ _raise_for_status(response)
155
+ return response.json()
156
+
157
+
158
+ class AsyncAether:
159
+ """Async client for the Aether engine HTTP API."""
160
+
161
+ def __init__(
162
+ self,
163
+ base_url: str = "http://127.0.0.1:8000",
164
+ *,
165
+ timeout: float = 60.0,
166
+ headers: dict[str, str] | None = None,
167
+ transport: httpx.AsyncBaseTransport | None = None,
168
+ ) -> None:
169
+ self.base_url = base_url.rstrip("/")
170
+ self._client = httpx.AsyncClient(
171
+ base_url=self.base_url,
172
+ timeout=timeout,
173
+ headers=headers,
174
+ transport=transport,
175
+ )
176
+
177
+ async def aclose(self) -> None:
178
+ await self._client.aclose()
179
+
180
+ async def __aenter__(self) -> AsyncAether:
181
+ return self
182
+
183
+ async def __aexit__(self, *exc: object) -> None:
184
+ await self.aclose()
185
+
186
+ async def health(self) -> dict[str, Any]:
187
+ return await self._get("/health")
188
+
189
+ async def universes(self) -> list[UniverseSummary]:
190
+ rows = await self._get("/v1/universes")
191
+ return [UniverseSummary.model_validate(row) for row in rows]
192
+
193
+ async def ingest(
194
+ self,
195
+ *,
196
+ path: str = "",
197
+ url: str = "",
198
+ pr_ref: str = "",
199
+ velocity: float | None = None,
200
+ wait: bool = True,
201
+ poll_interval: float = 0.35,
202
+ on_progress: ProgressFn | None = None,
203
+ ) -> IngestResult | JobAccepted:
204
+ body = {
205
+ "path": path,
206
+ "url": url,
207
+ "pr_ref": pr_ref,
208
+ "velocity_override": velocity,
209
+ }
210
+ accepted = JobAccepted.model_validate(await self._post("/v1/jobs", body))
211
+ if not wait:
212
+ return accepted
213
+ return await self.wait_job(accepted.job_id, poll_interval=poll_interval, on_progress=on_progress)
214
+
215
+ async def ingest_now(
216
+ self,
217
+ *,
218
+ path: str = "",
219
+ url: str = "",
220
+ pr_ref: str = "",
221
+ velocity: float | None = None,
222
+ ) -> IngestResult:
223
+ return IngestResult.model_validate(
224
+ await self._post(
225
+ "/v1/universes",
226
+ {
227
+ "path": path,
228
+ "url": url,
229
+ "pr_ref": pr_ref,
230
+ "velocity_override": velocity,
231
+ },
232
+ )
233
+ )
234
+
235
+ async def job(self, job_id: str) -> JobStatus:
236
+ return JobStatus.model_validate(await self._get(f"/v1/jobs/{job_id}"))
237
+
238
+ async def wait_job(
239
+ self,
240
+ job_id: str,
241
+ *,
242
+ poll_interval: float = 0.35,
243
+ on_progress: ProgressFn | None = None,
244
+ ) -> IngestResult:
245
+ import asyncio
246
+
247
+ while True:
248
+ status = await self.job(job_id)
249
+ if on_progress:
250
+ on_progress(status.percent, status.stage)
251
+ if status.failed:
252
+ raise AetherJobError(job_id, status.error or "ingest failed")
253
+ if status.done and status.result:
254
+ return status.result
255
+ await asyncio.sleep(poll_interval)
256
+
257
+ async def forecast(self, universe_id: str, horizon_months: int = 24) -> Forecast:
258
+ return Forecast.model_validate(
259
+ await self._get(f"/v1/universes/{universe_id}/forecast", {"horizon_months": horizon_months})
260
+ )
261
+
262
+ async def graph(self, universe_id: str, t: int = 0) -> GraphSlice:
263
+ return GraphSlice.model_validate(await self._get(f"/v1/universes/{universe_id}/graph", {"t": t}))
264
+
265
+ async def attach_changeset(self, universe_id: str, changeset: ChangeSet | dict[str, Any]) -> ChangeSet:
266
+ payload = changeset.model_dump() if isinstance(changeset, ChangeSet) else changeset
267
+ return ChangeSet.model_validate(await self._post(f"/v1/universes/{universe_id}/changesets", payload))
268
+
269
+ async def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
270
+ response = await self._client.get(path, params=params)
271
+ _raise_for_status(response)
272
+ return response.json()
273
+
274
+ async def _post(self, path: str, body: dict[str, Any]) -> Any:
275
+ response = await self._client.post(path, json=body)
276
+ _raise_for_status(response)
277
+ return response.json()
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class AetherError(Exception):
5
+ """Base SDK error."""
6
+
7
+
8
+ class AetherHTTPError(AetherError):
9
+ def __init__(self, status_code: int, detail: str) -> None:
10
+ self.status_code = status_code
11
+ self.detail = detail
12
+ super().__init__(f"{status_code}: {detail}")
13
+
14
+
15
+ class AetherJobError(AetherError):
16
+ def __init__(self, job_id: str, error: str) -> None:
17
+ self.job_id = job_id
18
+ self.error = error
19
+ super().__init__(f"job {job_id} failed: {error}")
aether_sdk/models.py ADDED
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from aether_sdk.pressure import Band, pressure_band
8
+
9
+
10
+ class IngestResult(BaseModel):
11
+ universe_id: str
12
+ repo_path: str
13
+ license: str = ""
14
+ snapshots: int = 0
15
+ velocity_commits_per_week: float = 1.0
16
+ warnings: list[str] = Field(default_factory=list)
17
+
18
+
19
+ class JobAccepted(BaseModel):
20
+ job_id: str
21
+ status: str
22
+ percent: int = 0
23
+ stage: str = "queued"
24
+
25
+
26
+ class JobStatus(BaseModel):
27
+ job_id: str
28
+ status: str
29
+ percent: int = 0
30
+ stage: str = ""
31
+ error: str = ""
32
+ result: IngestResult | None = None
33
+
34
+ @property
35
+ def done(self) -> bool:
36
+ return self.status == "done"
37
+
38
+ @property
39
+ def failed(self) -> bool:
40
+ return self.status == "error"
41
+
42
+
43
+ class UniverseSummary(BaseModel):
44
+ id: str
45
+ repo_path: str
46
+ license: str = ""
47
+ velocity: float = 1.0
48
+
49
+
50
+ class NodeMetrics(BaseModel):
51
+ node_id: str
52
+ kind: str
53
+ label: str
54
+ path: str = ""
55
+ lang: str = ""
56
+ mass: float
57
+ velocity: float
58
+ momentum: float
59
+ pressure: float
60
+ dependents: int = 0
61
+
62
+ @property
63
+ def band(self) -> Band:
64
+ return pressure_band(self.pressure)
65
+
66
+
67
+ class Collision(BaseModel):
68
+ a: str
69
+ b: str
70
+ reason: str
71
+ intensity: float
72
+
73
+
74
+ class TimelineFrame(BaseModel):
75
+ t_index: int
76
+ months_ahead: float
77
+ label: str
78
+ cells: list[NodeMetrics] = Field(default_factory=list)
79
+ collisions: list[Collision] = Field(default_factory=list)
80
+ narrative: str = ""
81
+ narrative_kind: Literal["rising_pressure", "bottleneck", "stable"] = "stable"
82
+
83
+ def hottest(self, n: int = 8) -> list[NodeMetrics]:
84
+ return sorted(self.cells, key=lambda c: c.pressure, reverse=True)[:n]
85
+
86
+ def by_band(self) -> dict[Band, list[NodeMetrics]]:
87
+ out: dict[Band, list[NodeMetrics]] = {"calm": [], "watch": [], "high-pressure": []}
88
+ for cell in self.cells:
89
+ out[cell.band].append(cell)
90
+ return out
91
+
92
+
93
+ class ImpactedNode(BaseModel):
94
+ node_id: str
95
+ label: str
96
+ kind: str
97
+ intensity: float
98
+ path_type: str = ""
99
+
100
+
101
+ class ButterflyFrame(BaseModel):
102
+ months: float
103
+ origin_ids: list[str] = Field(default_factory=list)
104
+ impacted: list[ImpactedNode] = Field(default_factory=list)
105
+
106
+
107
+ class GhostResult(BaseModel):
108
+ intent: str
109
+ title: str
110
+ verdict: Literal["pass", "warn", "fail"]
111
+ extensibility: float
112
+ files_touched: list[str] = Field(default_factory=list)
113
+ core_mass_hits: list[str] = Field(default_factory=list)
114
+ new_cycles: int = 0
115
+ contract_breaks: list[str] = Field(default_factory=list)
116
+ note: str = ""
117
+
118
+
119
+ class CostBand(BaseModel):
120
+ month: int
121
+ compute: float
122
+ storage: float
123
+ egress: float
124
+ drivers: list[str] = Field(default_factory=list)
125
+
126
+
127
+ class Forecast(BaseModel):
128
+ universe_id: str
129
+ repo_path: str
130
+ ir_version: int = 1
131
+ horizon_months: int = 24
132
+ velocity_commits_per_week: float = 1.0
133
+ timeline: list[TimelineFrame] = Field(default_factory=list)
134
+ butterflies: list[ButterflyFrame] = Field(default_factory=list)
135
+ ghosts: list[GhostResult] = Field(default_factory=list)
136
+ costs: list[CostBand] = Field(default_factory=list)
137
+ warnings: list[str] = Field(default_factory=list)
138
+ heuristic: bool = True
139
+ license: str = ""
140
+
141
+ def frame(self, months: float = 8) -> TimelineFrame | None:
142
+ if not self.timeline:
143
+ return None
144
+ return min(self.timeline, key=lambda f: abs(f.months_ahead - months))
145
+
146
+ def hottest(self, n: int = 8, months: float = 8) -> list[NodeMetrics]:
147
+ frame = self.frame(months)
148
+ return frame.hottest(n) if frame else []
149
+
150
+ def storms(self, months: float = 8) -> list[Collision]:
151
+ frame = self.frame(months)
152
+ return list(frame.collisions) if frame else []
153
+
154
+
155
+ class Mutation(BaseModel):
156
+ op: Literal["add", "remove", "retarget"]
157
+ node: dict[str, Any] | None = None
158
+ edge: dict[str, Any] | None = None
159
+ retarget_src: str = ""
160
+ retarget_dst: str = ""
161
+
162
+
163
+ class ChangeSet(BaseModel):
164
+ id: str = ""
165
+ universe_id: str = ""
166
+ label: str = "changeset"
167
+ summary: str = ""
168
+ mutations: list[Mutation] = Field(default_factory=list)
169
+ touched_paths: list[str] = Field(default_factory=list)
170
+
171
+
172
+ class GraphSlice(BaseModel):
173
+ commit_sha: str = ""
174
+ authored_at: str = ""
175
+ nodes: list[dict[str, Any]] = Field(default_factory=list)
176
+ edges: list[dict[str, Any]] = Field(default_factory=list)
177
+ contracts: list[dict[str, Any]] = Field(default_factory=list)
aether_sdk/pressure.py ADDED
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ Band = Literal["calm", "watch", "high-pressure"]
6
+
7
+
8
+ def pressure_band(pressure: float) -> Band:
9
+ if pressure < 0.6:
10
+ return "calm"
11
+ if pressure < 1.2:
12
+ return "watch"
13
+ return "high-pressure"
14
+
15
+
16
+ def pressure_color(pressure: float) -> str:
17
+ if pressure < 0.6:
18
+ return "#3d9ee0"
19
+ if pressure < 1.2:
20
+ return "#d4a017"
21
+ return "#e05a4f"
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: kaether
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Aether software physics engine
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.27.0
7
+ Requires-Dist: pydantic>=2.9.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8.3.0; extra == "dev"
@@ -0,0 +1,9 @@
1
+ aether_sdk/__init__.py,sha256=E4T8HK5w607wBmzOjXKIpac0fh70SNpMGZDwFTGS4DA,503
2
+ aether_sdk/client.py,sha256=_vVZvFjqMam6gHtwEdxY_nwd3e0H32KBJ7KLDxUwC5w,8852
3
+ aether_sdk/exceptions.py,sha256=SQa4x1O5XRfb6YVRGvO0YDA5rY1PHRM0-X9lTKZ_PbE,523
4
+ aether_sdk/models.py,sha256=a7c4jIkXFz0hDt8Wi9MTvoE-_eKi6wgwBcCrTwKvvXc,4560
5
+ aether_sdk/pressure.py,sha256=_7b2GKc-6imJ13gF3WtY7_S4TCyD4Z4Mzy1Un0rVTyw,440
6
+ kaether-0.1.0.dist-info/METADATA,sha256=7EfUfo9X4ueiybkv77pjwzUTJnn5D1A1LpbqKTLNx4s,259
7
+ kaether-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ kaether-0.1.0.dist-info/top_level.txt,sha256=uov1M5b-1qQVZ7HLAXfNaJ58ZlW1nAfz_rjidGiRVSY,11
9
+ kaether-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ aether_sdk