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.
- content_sdk/__init__.py +71 -0
- content_sdk/_transport.py +167 -0
- content_sdk/aio.py +255 -0
- content_sdk/client.py +193 -0
- content_sdk/compat.py +126 -0
- content_sdk/errors.py +79 -0
- content_sdk/legal.py +54 -0
- content_sdk/models.py +210 -0
- content_sdk/notifications.py +259 -0
- content_sdk/resources.py +112 -0
- content_sdk/status.py +117 -0
- content_sdk-0.2.0.dist-info/METADATA +119 -0
- content_sdk-0.2.0.dist-info/RECORD +16 -0
- content_sdk-0.2.0.dist-info/WHEEL +4 -0
- content_sdk-0.2.0.dist-info/licenses/LICENSE +683 -0
- content_sdk-0.2.0.dist-info/licenses/NOTICE +65 -0
content_sdk/__init__.py
ADDED
|
@@ -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")
|