content-sdk 0.2.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.
@@ -0,0 +1,71 @@
1
+ """Content SDK — the official Python client for the Content engine.
2
+
3
+ The single client every consumer (CLI, MCP, applications) speaks through. It
4
+ fully encapsulates the REST API; nothing else duplicates the engine's rules.
5
+
6
+ from content_sdk import ContentClient
7
+ from content_sdk import outputs
8
+
9
+ with ContentClient("http://localhost:8010") as client:
10
+ analysis = client.analyze(outputs.url_source("https://…")) # sources or id
11
+ caps = client.get_capabilities(analysis.id)
12
+ job = client.generate(analysis.id, [outputs.audio_output()])
13
+ job.wait()
14
+ for artifact in job.artifacts:
15
+ print(artifact.filename)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from . import models as outputs # builders live in models; alias for ergonomics
21
+ from .aio import AsyncAnalysis, AsyncContentClient, AsyncJob
22
+ from .client import ContentClient
23
+ from .errors import (
24
+ APIError,
25
+ Conflict,
26
+ ContentError,
27
+ Gone,
28
+ NotFound,
29
+ TransportError,
30
+ ValidationError,
31
+ )
32
+ from .models import (
33
+ AnalysisData,
34
+ AnalyzedSource,
35
+ ArtifactData,
36
+ CapabilitiesData,
37
+ Capability,
38
+ Event,
39
+ JobData,
40
+ SourceCapabilities,
41
+ )
42
+ from .resources import Analysis, Job
43
+
44
+ __version__ = "0.2.0"
45
+
46
+ __all__ = [
47
+ "APIError",
48
+ "Analysis",
49
+ # data models
50
+ "AnalysisData",
51
+ "AnalyzedSource",
52
+ "ArtifactData",
53
+ "AsyncAnalysis",
54
+ "AsyncContentClient",
55
+ "AsyncJob",
56
+ "CapabilitiesData",
57
+ "Capability",
58
+ "Conflict",
59
+ "ContentClient",
60
+ # errors
61
+ "ContentError",
62
+ "Event",
63
+ "Gone",
64
+ "Job",
65
+ "JobData",
66
+ "NotFound",
67
+ "SourceCapabilities",
68
+ "TransportError",
69
+ "ValidationError",
70
+ "outputs",
71
+ ]
@@ -0,0 +1,167 @@
1
+ """HTTP transport: one httpx-based layer for both the sync and async clients.
2
+
3
+ Responsibilities: build `/api/v1` URLs, map non-2xx → typed exceptions, and
4
+ apply a **conservative** retry policy. Retries happen ONLY on transport-level
5
+ failures (the request provably did not get a response) and on 5xx for **safe**
6
+ methods (GET). Creations (`POST /analyses`, `POST /jobs`) are never retried
7
+ unless the caller explicitly opts in via an idempotency mechanism.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import os
14
+ import time
15
+ from dataclasses import dataclass
16
+
17
+ import httpx
18
+
19
+ from .errors import TransportError, error_for
20
+
21
+ DEFAULT_BASE_URL = "http://localhost:8010"
22
+ DEFAULT_TIMEOUT = 130.0
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class RetryConfig:
27
+ retries: int = 2 # extra attempts after the first
28
+ backoff: float = 0.2 # seconds; doubled each attempt
29
+
30
+
31
+ def resolve_base_url(base_url: str | None) -> str:
32
+ return (base_url or os.getenv("CONTENT_API_URL", DEFAULT_BASE_URL)).rstrip("/")
33
+
34
+
35
+ def _api_url(base_url: str, path: str) -> str:
36
+ return f"{base_url}/api/v1{path}"
37
+
38
+
39
+ def _raise_for(resp: httpx.Response) -> None:
40
+ if resp.is_success:
41
+ return
42
+ try:
43
+ body: object = resp.json()
44
+ except ValueError:
45
+ body = resp.text
46
+ raise error_for(resp.status_code, body)
47
+
48
+
49
+ def _attempts(method: str, retry: RetryConfig, idempotent: bool) -> int:
50
+ safe = method.upper() == "GET"
51
+ return retry.retries if (safe or idempotent) else 0
52
+
53
+
54
+ def _retry_status(status: int) -> bool:
55
+ return status >= 500
56
+
57
+
58
+ class SyncTransport:
59
+ def __init__(
60
+ self,
61
+ base_url: str,
62
+ timeout: float,
63
+ retry: RetryConfig,
64
+ client: httpx.Client | None = None,
65
+ ):
66
+ self.base_url = base_url
67
+ self._retry = retry
68
+ self._client = client or httpx.Client(timeout=timeout)
69
+ self._owns_client = client is None
70
+
71
+ def close(self) -> None:
72
+ if self._owns_client:
73
+ self._client.close()
74
+
75
+ def request(
76
+ self,
77
+ method: str,
78
+ path: str,
79
+ *,
80
+ json: dict | None = None,
81
+ params: dict | None = None,
82
+ idempotent: bool = False,
83
+ ) -> httpx.Response:
84
+ url = _api_url(self.base_url, path)
85
+ attempts = _attempts(method, self._retry, idempotent)
86
+ for attempt in range(attempts + 1):
87
+ try:
88
+ resp = self._client.request(method, url, json=json, params=params)
89
+ except httpx.TransportError as exc:
90
+ if attempt < attempts:
91
+ time.sleep(self._retry.backoff * (2**attempt))
92
+ continue
93
+ raise TransportError(str(exc)) from exc
94
+ if _retry_status(resp.status_code) and attempt < attempts:
95
+ time.sleep(self._retry.backoff * (2**attempt))
96
+ continue
97
+ _raise_for(resp)
98
+ return resp
99
+ raise TransportError("retries exhausted") # pragma: no cover
100
+
101
+ def get(self, path: str, params: dict | None = None) -> object:
102
+ return self.request("GET", path, params=params).json()
103
+
104
+ def post(
105
+ self, path: str, json: dict | None = None, idempotent: bool = False
106
+ ) -> object:
107
+ return self.request("POST", path, json=json, idempotent=idempotent).json()
108
+
109
+ def content(self, path: str) -> bytes:
110
+ return self.request("GET", path).content
111
+
112
+
113
+ class AsyncTransport:
114
+ def __init__(
115
+ self,
116
+ base_url: str,
117
+ timeout: float,
118
+ retry: RetryConfig,
119
+ client: httpx.AsyncClient | None = None,
120
+ ):
121
+ self.base_url = base_url
122
+ self._retry = retry
123
+ self._client = client or httpx.AsyncClient(timeout=timeout)
124
+ self._owns_client = client is None
125
+
126
+ async def aclose(self) -> None:
127
+ if self._owns_client:
128
+ await self._client.aclose()
129
+
130
+ async def request(
131
+ self,
132
+ method: str,
133
+ path: str,
134
+ *,
135
+ json: dict | None = None,
136
+ params: dict | None = None,
137
+ idempotent: bool = False,
138
+ ) -> httpx.Response:
139
+ url = _api_url(self.base_url, path)
140
+ attempts = _attempts(method, self._retry, idempotent)
141
+ for attempt in range(attempts + 1):
142
+ try:
143
+ resp = await self._client.request(method, url, json=json, params=params)
144
+ except httpx.TransportError as exc:
145
+ if attempt < attempts:
146
+ await asyncio.sleep(self._retry.backoff * (2**attempt))
147
+ continue
148
+ raise TransportError(str(exc)) from exc
149
+ if _retry_status(resp.status_code) and attempt < attempts:
150
+ await asyncio.sleep(self._retry.backoff * (2**attempt))
151
+ continue
152
+ _raise_for(resp)
153
+ return resp
154
+ raise TransportError("retries exhausted") # pragma: no cover
155
+
156
+ async def get(self, path: str, params: dict | None = None) -> object:
157
+ return (await self.request("GET", path, params=params)).json()
158
+
159
+ async def post(
160
+ self, path: str, json: dict | None = None, idempotent: bool = False
161
+ ) -> object:
162
+ return (
163
+ await self.request("POST", path, json=json, idempotent=idempotent)
164
+ ).json()
165
+
166
+ async def content(self, path: str) -> bytes:
167
+ return (await self.request("GET", path)).content
content_sdk/aio.py ADDED
@@ -0,0 +1,255 @@
1
+ """The asynchronous Content client — the async mirror of ContentClient.
2
+
3
+ Same surface, awaitable. `AsyncAnalysis`/`AsyncJob` are the behavioural objects;
4
+ `AsyncJob.wait()` polls until the job is terminal. Use as an async context
5
+ manager so the underlying httpx client is closed cleanly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from typing import Any, Self
12
+
13
+ from ._transport import DEFAULT_TIMEOUT, AsyncTransport, RetryConfig, resolve_base_url
14
+ from .client import _source_body, _sources_list
15
+ from .models import (
16
+ SCHEMA_VERSION,
17
+ AnalysisData,
18
+ AnalyzedSource,
19
+ ArtifactData,
20
+ CapabilitiesData,
21
+ Event,
22
+ JobData,
23
+ )
24
+ from .resources import TERMINAL_STATUSES
25
+
26
+
27
+ class AsyncAnalysis:
28
+ def __init__(self, data: AnalysisData, client: AsyncContentClient):
29
+ self.data = data
30
+ self._client = client
31
+
32
+ @property
33
+ def id(self) -> str:
34
+ return self.data.analysis_id
35
+
36
+ @property
37
+ def sources(self) -> list[AnalyzedSource]:
38
+ return self.data.sources
39
+
40
+ @property
41
+ def expires_at(self) -> str | None:
42
+ return self.data.expires_at
43
+
44
+ async def capabilities(self, constraints: dict[str, Any] | None = None):
45
+ return await self._client.get_capabilities(self.id, constraints=constraints)
46
+
47
+ async def generate(self, outputs, **kwargs) -> AsyncJob:
48
+ return await self._client.generate(self.id, outputs, **kwargs)
49
+
50
+
51
+ class AsyncJob:
52
+ def __init__(self, data: JobData, client: AsyncContentClient):
53
+ self.data = data
54
+ self._client = client
55
+
56
+ @property
57
+ def id(self) -> str:
58
+ return self.data.job_id
59
+
60
+ @property
61
+ def status(self) -> str:
62
+ return self.data.status
63
+
64
+ @property
65
+ def is_terminal(self) -> bool:
66
+ return self.data.status in TERMINAL_STATUSES
67
+
68
+ @property
69
+ def succeeded(self) -> bool:
70
+ return self.data.status in ("succeeded", "partially_succeeded")
71
+
72
+ async def refresh(self) -> AsyncJob:
73
+ self.data = (await self._client.get_job(self.id)).data
74
+ return self
75
+
76
+ async def wait(
77
+ self, *, timeout: float = 600.0, poll_interval: float = 1.0
78
+ ) -> AsyncJob:
79
+ loop = asyncio.get_event_loop()
80
+ deadline = loop.time() + timeout
81
+ while True:
82
+ await self.refresh()
83
+ if self.is_terminal:
84
+ return self
85
+ if loop.time() >= deadline:
86
+ raise TimeoutError(
87
+ f"job {self.id} still {self.status!r} after {timeout}s"
88
+ )
89
+ await asyncio.sleep(poll_interval)
90
+
91
+ async def artifacts(self) -> list[ArtifactData]:
92
+ return await self._client.artifacts(self.id)
93
+
94
+ async def events(self, after_sequence: int = 0) -> list[Event]:
95
+ return await self._client.events(self.id, after_sequence=after_sequence)
96
+
97
+ async def cancel(self) -> dict[str, Any]:
98
+ return await self._client.cancel(self.id)
99
+
100
+ async def retry(self) -> AsyncJob:
101
+ return await self._client.retry(self.id)
102
+
103
+
104
+ class AsyncContentClient:
105
+ def __init__(
106
+ self,
107
+ base_url: str | None = None,
108
+ *,
109
+ timeout: float = DEFAULT_TIMEOUT,
110
+ retry: RetryConfig | None = None,
111
+ http_client=None,
112
+ ):
113
+ self._t = AsyncTransport(
114
+ resolve_base_url(base_url),
115
+ timeout,
116
+ retry or RetryConfig(),
117
+ client=http_client,
118
+ )
119
+
120
+ @property
121
+ def base_url(self) -> str:
122
+ return self._t.base_url
123
+
124
+ async def aclose(self) -> None:
125
+ await self._t.aclose()
126
+
127
+ async def __aenter__(self) -> Self:
128
+ return self
129
+
130
+ async def __aexit__(self, *exc) -> None:
131
+ await self.aclose()
132
+
133
+ # --- system / observability ------------------------------------------------
134
+
135
+ async def health(self) -> dict[str, Any]:
136
+ return await self._t.get("/health")
137
+
138
+ async def system(self) -> dict[str, Any]:
139
+ return await self._t.get("/system")
140
+
141
+ async def config(self) -> dict[str, Any]:
142
+ return await self._t.get("/config")
143
+
144
+ async def catalog(self) -> dict[str, Any]:
145
+ return await self._t.get("/catalog")
146
+
147
+ async def storage(self) -> dict[str, Any]:
148
+ return await self._t.get("/storage")
149
+
150
+ async def cache(self) -> dict[str, Any]:
151
+ return await self._t.get("/cache")
152
+
153
+ async def purge_cache(self) -> dict[str, Any]:
154
+ return await self._t.post("/cache/purge")
155
+
156
+ async def folders(self) -> list[str]:
157
+ return (await self._t.get("/folders")).get("folders", [])
158
+
159
+ # --- analysis / capabilities -----------------------------------------------
160
+
161
+ async def analyze(self, sources: Any) -> AsyncAnalysis:
162
+ data = await self._t.post("/analyses", {"sources": _sources_list(sources)})
163
+ return AsyncAnalysis(AnalysisData.model_validate(data), self)
164
+
165
+ async def get_analysis(self, analysis_id: str) -> AsyncAnalysis:
166
+ data = await self._t.get(f"/analyses/{analysis_id}")
167
+ return AsyncAnalysis(AnalysisData.model_validate(data), self)
168
+
169
+ async def get_capabilities(
170
+ self, target: Any, constraints: dict[str, Any] | None = None
171
+ ) -> CapabilitiesData:
172
+ body = _source_body(
173
+ target if not isinstance(target, AsyncAnalysis) else target.id
174
+ )
175
+ if constraints:
176
+ body["constraints"] = constraints
177
+ data = await self._t.post("/capabilities", body)
178
+ return CapabilitiesData.model_validate(data)
179
+
180
+ async def generate(
181
+ self,
182
+ target: Any,
183
+ outputs: Any,
184
+ *,
185
+ preferences: dict[str, Any] | None = None,
186
+ constraints: dict[str, Any] | None = None,
187
+ execution: dict[str, Any] | None = None,
188
+ metadata: dict[str, Any] | None = None,
189
+ ) -> AsyncJob:
190
+ resolved = target.id if isinstance(target, AsyncAnalysis) else target
191
+ body: dict[str, Any] = {
192
+ "schema_version": SCHEMA_VERSION,
193
+ "outputs": [outputs] if isinstance(outputs, dict) else list(outputs),
194
+ **_source_body(resolved),
195
+ }
196
+ for key, value in (
197
+ ("preferences", preferences),
198
+ ("constraints", constraints),
199
+ ("execution", execution),
200
+ ("metadata", metadata),
201
+ ):
202
+ if value:
203
+ body[key] = value
204
+ idempotent = bool(execution and execution.get("idempotency_key"))
205
+ data = await self._t.post("/jobs", body, idempotent=idempotent)
206
+ return AsyncJob(JobData.model_validate(data), self)
207
+
208
+ async def submit(self, request: dict[str, Any]) -> AsyncJob:
209
+ """Submit a pre-built GenerationRequest (raw contract dict)."""
210
+ idempotent = bool((request.get("execution") or {}).get("idempotency_key"))
211
+ data = await self._t.post("/jobs", request, idempotent=idempotent)
212
+ return AsyncJob(JobData.model_validate(data), self)
213
+
214
+ # --- jobs / artifacts -------------------------------------------------------
215
+
216
+ async def list_jobs(
217
+ self, *, status: str | None = None, limit: int = 30
218
+ ) -> list[JobData]:
219
+ params: dict[str, Any] = {"limit": limit}
220
+ if status:
221
+ params["status"] = status
222
+ rows = await self._t.get("/jobs", params=params)
223
+ return [JobData.model_validate(r) for r in rows]
224
+
225
+ async def get_job(self, job_id: str) -> AsyncJob:
226
+ data = await self._t.get(f"/jobs/{job_id}")
227
+ return AsyncJob(JobData.model_validate(data), self)
228
+
229
+ async def cancel(self, job_id: str) -> dict[str, Any]:
230
+ return await self._t.post(f"/jobs/{job_id}/cancel")
231
+
232
+ async def retry(self, job_id: str) -> AsyncJob:
233
+ data = await self._t.post(f"/jobs/{job_id}/retry")
234
+ return AsyncJob(JobData.model_validate(data), self)
235
+
236
+ async def events(self, job_id: str, after_sequence: int = 0) -> list[Event]:
237
+ rows = await self._t.get(
238
+ f"/jobs/{job_id}/events", params={"after_sequence": after_sequence}
239
+ )
240
+ return [Event.model_validate(r) for r in rows]
241
+
242
+ async def logs(self, job_id: str, tail: int = 400) -> dict[str, Any]:
243
+ return await self._t.get(f"/jobs/{job_id}/logs", params={"tail": tail})
244
+
245
+ async def artifacts(self, job_id: str) -> list[ArtifactData]:
246
+ rows = await self._t.get(f"/jobs/{job_id}/artifacts")
247
+ return [ArtifactData.model_validate(r) for r in rows]
248
+
249
+ async def get_artifact(self, artifact_id: str) -> ArtifactData:
250
+ return ArtifactData.model_validate(
251
+ await self._t.get(f"/artifacts/{artifact_id}")
252
+ )
253
+
254
+ async def artifact_bytes(self, artifact_id: str) -> bytes:
255
+ return await self._t.content(f"/artifacts/{artifact_id}/content")
content_sdk/client.py ADDED
@@ -0,0 +1,193 @@
1
+ """The synchronous Content client — the official way to speak to the engine.
2
+
3
+ Wraps every `/api/v1` endpoint, returns natural Python objects, and maps errors
4
+ to typed exceptions. `analyze/get_capabilities/generate` accept **either** an
5
+ `analysis_id`/`Analysis` **or** inline sources (ADR 0014).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Self
11
+
12
+ from ._transport import DEFAULT_TIMEOUT, RetryConfig, SyncTransport, resolve_base_url
13
+ from .models import (
14
+ SCHEMA_VERSION,
15
+ AnalysisData,
16
+ ArtifactData,
17
+ CapabilitiesData,
18
+ Event,
19
+ JobData,
20
+ )
21
+ from .resources import Analysis, Job
22
+
23
+ # Accepts an Analysis, an analysis_id string, a single source dict, or a list of
24
+ # source dicts — resolved to the request fragment the API expects.
25
+ SourceInput = "Analysis | str | dict[str, Any] | list[dict[str, Any]]"
26
+
27
+
28
+ def _sources_list(sources: Any) -> list[dict[str, Any]]:
29
+ return [sources] if isinstance(sources, dict) else list(sources)
30
+
31
+
32
+ def _source_body(target: Any) -> dict[str, Any]:
33
+ """Map a target to the `sources` XOR `analysis_id` request fragment."""
34
+ if isinstance(target, Analysis):
35
+ return {"analysis_id": target.id}
36
+ if isinstance(target, str):
37
+ return {"analysis_id": target}
38
+ return {"sources": _sources_list(target)}
39
+
40
+
41
+ class ContentClient:
42
+ def __init__(
43
+ self,
44
+ base_url: str | None = None,
45
+ *,
46
+ timeout: float = DEFAULT_TIMEOUT,
47
+ retry: RetryConfig | None = None,
48
+ http_client=None,
49
+ ):
50
+ self._t = SyncTransport(
51
+ resolve_base_url(base_url),
52
+ timeout,
53
+ retry or RetryConfig(),
54
+ client=http_client,
55
+ )
56
+
57
+ @property
58
+ def base_url(self) -> str:
59
+ return self._t.base_url
60
+
61
+ def close(self) -> None:
62
+ self._t.close()
63
+
64
+ def __enter__(self) -> Self:
65
+ return self
66
+
67
+ def __exit__(self, *exc) -> None:
68
+ self.close()
69
+
70
+ # --- system / observability ------------------------------------------------
71
+
72
+ def health(self) -> dict[str, Any]:
73
+ return self._t.get("/health")
74
+
75
+ def system(self) -> dict[str, Any]:
76
+ return self._t.get("/system")
77
+
78
+ def config(self) -> dict[str, Any]:
79
+ return self._t.get("/config")
80
+
81
+ def catalog(self) -> dict[str, Any]:
82
+ return self._t.get("/catalog")
83
+
84
+ def storage(self) -> dict[str, Any]:
85
+ return self._t.get("/storage")
86
+
87
+ def cache(self) -> dict[str, Any]:
88
+ return self._t.get("/cache")
89
+
90
+ def purge_cache(self) -> dict[str, Any]:
91
+ return self._t.post("/cache/purge")
92
+
93
+ def notifications(self) -> list[dict[str, Any]]:
94
+ """What the instance wants to tell its operator (a newer release, a
95
+ stale yt-dlp). The engine decides what is worth saying; see
96
+ ``content_sdk.notifications`` for the UI-side helpers."""
97
+ return self._t.get("/notifications").get("notifications", [])
98
+
99
+ def folders(self) -> list[str]:
100
+ return self._t.get("/folders").get("folders", [])
101
+
102
+ # --- analysis / capabilities -----------------------------------------------
103
+
104
+ def analyze(self, sources: Any) -> Analysis:
105
+ data = self._t.post("/analyses", {"sources": _sources_list(sources)})
106
+ return Analysis(AnalysisData.model_validate(data), self)
107
+
108
+ def get_analysis(self, analysis_id: str) -> Analysis:
109
+ data = self._t.get(f"/analyses/{analysis_id}")
110
+ return Analysis(AnalysisData.model_validate(data), self)
111
+
112
+ def get_capabilities(
113
+ self, target: Any, constraints: dict[str, Any] | None = None
114
+ ) -> CapabilitiesData:
115
+ body = _source_body(target)
116
+ if constraints:
117
+ body["constraints"] = constraints
118
+ data = self._t.post("/capabilities", body)
119
+ return CapabilitiesData.model_validate(data)
120
+
121
+ def generate(
122
+ self,
123
+ target: Any,
124
+ outputs: Any,
125
+ *,
126
+ preferences: dict[str, Any] | None = None,
127
+ constraints: dict[str, Any] | None = None,
128
+ execution: dict[str, Any] | None = None,
129
+ metadata: dict[str, Any] | None = None,
130
+ ) -> Job:
131
+ body: dict[str, Any] = {
132
+ "schema_version": SCHEMA_VERSION,
133
+ "outputs": [outputs] if isinstance(outputs, dict) else list(outputs),
134
+ **_source_body(target),
135
+ }
136
+ for key, value in (
137
+ ("preferences", preferences),
138
+ ("constraints", constraints),
139
+ ("execution", execution),
140
+ ("metadata", metadata),
141
+ ):
142
+ if value:
143
+ body[key] = value
144
+ # Retry POST /jobs only when the caller made it idempotent (refinement 4).
145
+ idempotent = bool(execution and execution.get("idempotency_key"))
146
+ data = self._t.post("/jobs", body, idempotent=idempotent)
147
+ return Job(JobData.model_validate(data), self)
148
+
149
+ def submit(self, request: dict[str, Any]) -> Job:
150
+ """Submit a pre-built GenerationRequest (raw contract dict) — the escape
151
+ hatch for full control; `generate()` is the ergonomic path."""
152
+ idempotent = bool((request.get("execution") or {}).get("idempotency_key"))
153
+ data = self._t.post("/jobs", request, idempotent=idempotent)
154
+ return Job(JobData.model_validate(data), self)
155
+
156
+ # --- jobs -------------------------------------------------------------------
157
+
158
+ def list_jobs(self, *, status: str | None = None, limit: int = 30) -> list[JobData]:
159
+ params: dict[str, Any] = {"limit": limit}
160
+ if status:
161
+ params["status"] = status
162
+ rows = self._t.get("/jobs", params=params)
163
+ return [JobData.model_validate(r) for r in rows]
164
+
165
+ def get_job(self, job_id: str) -> Job:
166
+ return Job(JobData.model_validate(self._t.get(f"/jobs/{job_id}")), self)
167
+
168
+ def cancel(self, job_id: str) -> dict[str, Any]:
169
+ return self._t.post(f"/jobs/{job_id}/cancel")
170
+
171
+ def retry(self, job_id: str) -> Job:
172
+ return Job(JobData.model_validate(self._t.post(f"/jobs/{job_id}/retry")), self)
173
+
174
+ def events(self, job_id: str, after_sequence: int = 0) -> list[Event]:
175
+ rows = self._t.get(
176
+ f"/jobs/{job_id}/events", params={"after_sequence": after_sequence}
177
+ )
178
+ return [Event.model_validate(r) for r in rows]
179
+
180
+ def logs(self, job_id: str, tail: int = 400) -> dict[str, Any]:
181
+ return self._t.get(f"/jobs/{job_id}/logs", params={"tail": tail})
182
+
183
+ # --- artifacts --------------------------------------------------------------
184
+
185
+ def artifacts(self, job_id: str) -> list[ArtifactData]:
186
+ rows = self._t.get(f"/jobs/{job_id}/artifacts")
187
+ return [ArtifactData.model_validate(r) for r in rows]
188
+
189
+ def get_artifact(self, artifact_id: str) -> ArtifactData:
190
+ return ArtifactData.model_validate(self._t.get(f"/artifacts/{artifact_id}"))
191
+
192
+ def artifact_bytes(self, artifact_id: str) -> bytes:
193
+ return self._t.content(f"/artifacts/{artifact_id}/content")