adaptorch-client 0.1.0__tar.gz

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,14 @@
1
+ Copyright (c) 2026 ClassicMate. All rights reserved.
2
+
3
+ This software and associated documentation files (the "Software") are proprietary
4
+ and confidential. Unauthorized copying, distribution, modification, public
5
+ display, or any use of the Software other than as expressly authorized by ClassicMate
6
+ is strictly prohibited.
7
+
8
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
9
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
10
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
11
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
12
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
13
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
14
+ SOFTWARE.
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: adaptorch-client
3
+ Version: 0.1.0
4
+ Summary: Typed standard-library client for the AdaptOrch API
5
+ Author: ClassicMate
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Homepage, https://adaptorch.com
8
+ Project-URL: Repository, https://github.com/dmae97/Adaptorch-MCP
9
+ Project-URL: Issues, https://github.com/dmae97/Adaptorch-MCP/issues
10
+ Project-URL: Documentation, https://github.com/dmae97/Adaptorch-MCP#readme
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Provides-Extra: dev
19
+ Requires-Dist: build>=1.2; extra == "dev"
20
+ Requires-Dist: mypy>=1.10; extra == "dev"
21
+ Requires-Dist: pytest>=8.2; extra == "dev"
22
+ Requires-Dist: ruff>=0.6; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ # adaptorch-client
26
+
27
+ Typed, standard-library Python client for the AdaptOrch User API.
28
+
29
+ ## Install
30
+
31
+ The package is currently distributed from this repository rather than PyPI. Install
32
+ it from the package subdirectory:
33
+
34
+ ```bash
35
+ pip install "adaptorch-client @ git+https://github.com/dmae97/Adaptorch-MCP.git#subdirectory=packages/adaptorch-client"
36
+ ```
37
+
38
+ ## First run
39
+
40
+ Create an `ado_live_*` key at <https://adaptorch.com/app/api-keys>. The raw key is
41
+ shown once; keep it in an environment variable and never commit it.
42
+
43
+ ```python
44
+ import os
45
+
46
+ from adaptorch_client import AdaptOrchClient, ClientConfig
47
+
48
+ client = AdaptOrchClient(
49
+ ClientConfig(
50
+ api_url="https://adaptorch.com",
51
+ api_key=os.environ["ADAPTORCH_API_KEY"],
52
+ )
53
+ )
54
+ run = client.submit_run(
55
+ {
56
+ "subtasks": [{"id": "check", "description": "Reply with the word ready"}],
57
+ "dependencies": [],
58
+ },
59
+ idempotency_key="11111111-1111-4111-8111-111111111111",
60
+ )
61
+ print(run.run_id, run.status)
62
+ ```
63
+
64
+ `api_url` is the origin only; do not append `/v1`. The client sends tenant keys
65
+ as `X-API-Key`, includes a UUID idempotency key on submissions, and exposes
66
+ `capabilities()`, `whoami()`, run listing/detail/cancel, evidence, and artifacts.
67
+
68
+ See [`../../docs/adaptorchctl-usage.ko.md`](../../docs/adaptorchctl-usage.ko.md) for the current CLI and API usage guide.
69
+
70
+ ## License
71
+
72
+ Proprietary — Copyright ClassicMate. All rights reserved. See [LICENSE](https://github.com/dmae97/Adaptorch-MCP/blob/main/LICENSE).
@@ -0,0 +1,48 @@
1
+ # adaptorch-client
2
+
3
+ Typed, standard-library Python client for the AdaptOrch User API.
4
+
5
+ ## Install
6
+
7
+ The package is currently distributed from this repository rather than PyPI. Install
8
+ it from the package subdirectory:
9
+
10
+ ```bash
11
+ pip install "adaptorch-client @ git+https://github.com/dmae97/Adaptorch-MCP.git#subdirectory=packages/adaptorch-client"
12
+ ```
13
+
14
+ ## First run
15
+
16
+ Create an `ado_live_*` key at <https://adaptorch.com/app/api-keys>. The raw key is
17
+ shown once; keep it in an environment variable and never commit it.
18
+
19
+ ```python
20
+ import os
21
+
22
+ from adaptorch_client import AdaptOrchClient, ClientConfig
23
+
24
+ client = AdaptOrchClient(
25
+ ClientConfig(
26
+ api_url="https://adaptorch.com",
27
+ api_key=os.environ["ADAPTORCH_API_KEY"],
28
+ )
29
+ )
30
+ run = client.submit_run(
31
+ {
32
+ "subtasks": [{"id": "check", "description": "Reply with the word ready"}],
33
+ "dependencies": [],
34
+ },
35
+ idempotency_key="11111111-1111-4111-8111-111111111111",
36
+ )
37
+ print(run.run_id, run.status)
38
+ ```
39
+
40
+ `api_url` is the origin only; do not append `/v1`. The client sends tenant keys
41
+ as `X-API-Key`, includes a UUID idempotency key on submissions, and exposes
42
+ `capabilities()`, `whoami()`, run listing/detail/cancel, evidence, and artifacts.
43
+
44
+ See [`../../docs/adaptorchctl-usage.ko.md`](../../docs/adaptorchctl-usage.ko.md) for the current CLI and API usage guide.
45
+
46
+ ## License
47
+
48
+ Proprietary — Copyright ClassicMate. All rights reserved. See [LICENSE](https://github.com/dmae97/Adaptorch-MCP/blob/main/LICENSE).
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "adaptorch-client"
7
+ version = "0.1.0"
8
+ description = "Typed standard-library client for the AdaptOrch API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "LicenseRef-Proprietary"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "ClassicMate" }]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Typing :: Typed",
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.urls]
23
+ Homepage = "https://adaptorch.com"
24
+ Repository = "https://github.com/dmae97/Adaptorch-MCP"
25
+ Issues = "https://github.com/dmae97/Adaptorch-MCP/issues"
26
+ Documentation = "https://github.com/dmae97/Adaptorch-MCP#readme"
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["build>=1.2", "mypy>=1.10", "pytest>=8.2", "ruff>=0.6"]
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
33
+
34
+ [tool.setuptools.package-data]
35
+ adaptorch_client = ["py.typed"]
36
+
37
+ [tool.ruff]
38
+ line-length = 100
39
+ target-version = "py311"
40
+ src = ["src"]
41
+
42
+ [tool.ruff.lint]
43
+ select = ["E", "F", "I", "UP", "B"]
44
+
45
+ [tool.mypy]
46
+ python_version = "3.11"
47
+ strict = true
48
+ files = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,38 @@
1
+ """Public AdaptOrch API client."""
2
+
3
+ from adaptorch_client.client import AdaptOrchClient
4
+ from adaptorch_client.config import ClientConfig, validate_api_url
5
+ from adaptorch_client.errors import AdaptOrchAPIError
6
+ from adaptorch_client.models import (
7
+ Artifact,
8
+ CapabilitySet,
9
+ EvidenceCheck,
10
+ JSONMapping,
11
+ JSONValue,
12
+ PayloadResult,
13
+ Principal,
14
+ Run,
15
+ )
16
+ from adaptorch_client.responses import (
17
+ ArtifactListResponse,
18
+ EvidenceReport,
19
+ RunListResponse,
20
+ )
21
+
22
+ __all__ = [
23
+ "AdaptOrchAPIError",
24
+ "AdaptOrchClient",
25
+ "Artifact",
26
+ "ArtifactListResponse",
27
+ "CapabilitySet",
28
+ "ClientConfig",
29
+ "EvidenceCheck",
30
+ "EvidenceReport",
31
+ "JSONMapping",
32
+ "JSONValue",
33
+ "PayloadResult",
34
+ "Principal",
35
+ "Run",
36
+ "RunListResponse",
37
+ "validate_api_url",
38
+ ]
@@ -0,0 +1,122 @@
1
+ """Synchronous AdaptOrch API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from urllib.parse import quote, urlencode
8
+
9
+ from adaptorch_client.config import ClientConfig
10
+ from adaptorch_client.models import CapabilitySet, JSONValue, Principal, Run
11
+ from adaptorch_client.responses import (
12
+ ArtifactListResponse,
13
+ EvidenceReport,
14
+ RunListResponse,
15
+ )
16
+ from adaptorch_client.transport import HTTPTransport, RequestSpec
17
+
18
+ _IDEMPOTENCY_KEY_RE = re.compile(
19
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
20
+ )
21
+
22
+
23
+ class AdaptOrchClient:
24
+ """Small synchronous client for the AdaptOrch v1 API."""
25
+
26
+ __slots__ = ("_transport",)
27
+
28
+ def __init__(self, config: ClientConfig) -> None:
29
+ self._transport = HTTPTransport(config)
30
+
31
+ def capabilities(self) -> CapabilitySet:
32
+ """Return API capabilities."""
33
+ return CapabilitySet.from_payload(
34
+ self._transport.request(RequestSpec("GET", "/v1/capabilities"))
35
+ )
36
+
37
+ def whoami(self) -> Principal:
38
+ """Return the authenticated principal."""
39
+ return Principal.from_payload(
40
+ self._transport.request(RequestSpec("GET", "/v1/whoami"))
41
+ )
42
+
43
+ def submit_run(
44
+ self,
45
+ spec: Mapping[str, JSONValue],
46
+ idempotency_key: str,
47
+ ) -> Run:
48
+ """Submit one run without retrying the POST request."""
49
+ self._require_idempotency_key(idempotency_key)
50
+ return Run.from_payload(
51
+ self._transport.request(
52
+ RequestSpec(
53
+ "POST",
54
+ "/v1/runs",
55
+ payload=spec,
56
+ headers={"Idempotency-Key": idempotency_key},
57
+ )
58
+ )
59
+ )
60
+
61
+ def list_runs(
62
+ self,
63
+ status: str | None = None,
64
+ project_id: str | None = None,
65
+ ) -> RunListResponse:
66
+ """List runs, optionally filtered by status and project."""
67
+ filters: list[tuple[str, str]] = []
68
+ if status is not None:
69
+ filters.append(("status", status))
70
+ if project_id is not None:
71
+ filters.append(("project_id", project_id))
72
+ query = f"?{urlencode(filters)}" if filters else ""
73
+ return RunListResponse.from_payload(
74
+ self._transport.request(RequestSpec("GET", f"/v1/runs{query}"))
75
+ )
76
+
77
+ def get_run(self, run_id: str) -> Run:
78
+ """Return one run."""
79
+ return Run.from_payload(
80
+ self._transport.request(RequestSpec("GET", f"/v1/runs/{self._segment(run_id)}"))
81
+ )
82
+
83
+ def cancel_run(self, run_id: str, reason: str | None = None) -> Run:
84
+ """Cancel one run with an optional reason."""
85
+ payload: dict[str, JSONValue] = {} if reason is None else {"reason": reason}
86
+ return Run.from_payload(
87
+ self._transport.request(
88
+ RequestSpec(
89
+ "PUT",
90
+ f"/v1/runs/{self._segment(run_id)}/cancel",
91
+ payload=payload,
92
+ )
93
+ )
94
+ )
95
+
96
+ def get_evidence(self, run_id: str) -> EvidenceReport:
97
+ """Return the evidence report for one run."""
98
+ return EvidenceReport.from_payload(
99
+ self._transport.request(
100
+ RequestSpec("GET", f"/v1/runs/{self._segment(run_id)}/evidence")
101
+ )
102
+ )
103
+
104
+ def list_artifacts(self, run_id: str) -> ArtifactListResponse:
105
+ """List artifacts for one run."""
106
+ return ArtifactListResponse.from_payload(
107
+ self._transport.request(
108
+ RequestSpec("GET", f"/v1/runs/{self._segment(run_id)}/artifacts")
109
+ )
110
+ )
111
+
112
+ @staticmethod
113
+ def _segment(value: str) -> str:
114
+ if not value:
115
+ raise ValueError("path segment must not be empty")
116
+ encoded = quote(value, safe="")
117
+ return encoded.replace(".", "%2E") if value in {".", ".."} else encoded
118
+
119
+ @staticmethod
120
+ def _require_idempotency_key(value: str) -> None:
121
+ if not _IDEMPOTENCY_KEY_RE.fullmatch(value):
122
+ raise ValueError("idempotency_key must be a hyphenated UUID")
@@ -0,0 +1,72 @@
1
+ """Validated client configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass, field
8
+ from urllib.parse import urlsplit
9
+
10
+ _LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
11
+ _UNSAFE_URL_CHARACTERS = frozenset({"\\", "?", "#"})
12
+
13
+
14
+ def validate_api_url(api_url: str) -> str:
15
+ """Validate an AdaptOrch API base URL and return it without a trailing slash.
16
+
17
+ Raise ``ValueError`` for surrounding whitespace, control characters,
18
+ backslashes, embedded credentials, a path, query, or fragment, port 0,
19
+ a missing host, or plain HTTP on a non-loopback host.
20
+ """
21
+ if api_url != api_url.strip() or any(
22
+ character in _UNSAFE_URL_CHARACTERS or ord(character) < 32 or ord(character) == 127
23
+ for character in api_url
24
+ ):
25
+ raise ValueError("api_url contains unsafe characters")
26
+
27
+ try:
28
+ parsed = urlsplit(api_url)
29
+ hostname = parsed.hostname
30
+ port = parsed.port
31
+ except ValueError as error:
32
+ raise ValueError("api_url is invalid") from error
33
+
34
+ if hostname is None or not parsed.netloc:
35
+ raise ValueError("api_url must include a host")
36
+ if port == 0:
37
+ raise ValueError("api_url port must be positive")
38
+ if parsed.username is not None or parsed.password is not None:
39
+ raise ValueError("api_url must not include credentials")
40
+ if parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
41
+ raise ValueError("api_url must not include a path, query, or fragment")
42
+ if parsed.scheme == "http" and hostname not in _LOOPBACK_HOSTS:
43
+ raise ValueError("HTTP is allowed only for exact loopback hosts")
44
+ if parsed.scheme not in {"http", "https"}:
45
+ raise ValueError("api_url must use HTTPS or loopback HTTP")
46
+ return api_url.rstrip("/")
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class ClientConfig:
51
+ """Connection settings for the AdaptOrch API."""
52
+
53
+ api_url: str
54
+ api_key: str = field(repr=False)
55
+ timeout_seconds: float = 30.0
56
+
57
+ def __post_init__(self) -> None:
58
+ object.__setattr__(self, "api_url", validate_api_url(self.api_url))
59
+ if not self.api_key.strip() or any(
60
+ ord(character) < 32 or ord(character) == 127 or ord(character) > 255
61
+ for character in self.api_key
62
+ ):
63
+ raise ValueError("api_key must be a non-empty HTTP header value")
64
+ if not math.isfinite(self.timeout_seconds) or self.timeout_seconds <= 0:
65
+ raise ValueError("timeout_seconds must be positive and finite")
66
+
67
+ @property
68
+ def auth_headers(self) -> Mapping[str, str]:
69
+ """Return the sole authentication header valid for this credential."""
70
+ if self.api_key.startswith("ado_"):
71
+ return {"X-API-Key": self.api_key}
72
+ return {"Authorization": f"Bearer {self.api_key}"}
@@ -0,0 +1,23 @@
1
+ """Sanitized public client errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class AdaptOrchAPIError(RuntimeError):
7
+ """A sanitized API, transport, or response failure."""
8
+
9
+ __slots__ = ("code", "status_code")
10
+
11
+ code: str | None
12
+ status_code: int | None
13
+
14
+ def __init__(
15
+ self,
16
+ message: str,
17
+ *,
18
+ status_code: int | None = None,
19
+ code: str | None = None,
20
+ ) -> None:
21
+ super().__init__(message)
22
+ self.status_code = status_code
23
+ self.code = code
@@ -0,0 +1,223 @@
1
+ """Typed v1 contract records parsed from direct (envelope-free) JSON responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from copy import deepcopy
7
+ from dataclasses import dataclass
8
+ from typing import Self, TypeAlias
9
+
10
+ from adaptorch_client.errors import AdaptOrchAPIError
11
+
12
+ JSONValue: TypeAlias = bool | int | float | str | list["JSONValue"] | dict[str, "JSONValue"] | None
13
+ JSONMapping: TypeAlias = dict[str, JSONValue]
14
+
15
+
16
+ def contract_error(path: str, expected: str) -> AdaptOrchAPIError:
17
+ """Return a sanitized contract-violation error naming only the field path."""
18
+ return AdaptOrchAPIError(f"AdaptOrch response field {path!r} must be {expected}")
19
+
20
+
21
+ def field_path(path: str, key: str) -> str:
22
+ """Compose a dotted field path that stays readable at the response root."""
23
+ return f"{path}.{key}" if path else key
24
+
25
+
26
+ def require_object(value: JSONValue, path: str) -> JSONMapping:
27
+ """Return ``value`` when it is a JSON object, else raise a contract error."""
28
+ if not isinstance(value, dict):
29
+ raise contract_error(path, "a JSON object")
30
+ return value
31
+
32
+
33
+ def require_array(value: JSONValue, path: str) -> list[JSONValue]:
34
+ """Return ``value`` when it is a JSON array, else raise a contract error."""
35
+ if not isinstance(value, list):
36
+ raise contract_error(path, "a JSON array")
37
+ return value
38
+
39
+
40
+ def stored_copy(payload: Mapping[str, JSONValue]) -> JSONMapping:
41
+ """Return an independent deep copy of a raw response payload."""
42
+ return deepcopy(dict(payload))
43
+
44
+
45
+ def string_at(record: JSONMapping, key: str, path: str) -> str:
46
+ """Return the required string field ``key``, else raise a contract error."""
47
+ value = record.get(key)
48
+ if not isinstance(value, str):
49
+ raise contract_error(field_path(path, key), "a string")
50
+ return value
51
+
52
+
53
+ def optional_string_at(record: JSONMapping, key: str, path: str) -> str | None:
54
+ """Return the optional string field ``key``; absent or null becomes ``None``."""
55
+ if record.get(key) is None:
56
+ return None
57
+ return string_at(record, key, path)
58
+
59
+
60
+ def _string_tuple_at(record: JSONMapping, key: str, path: str) -> tuple[str, ...]:
61
+ values: list[str] = []
62
+ array_path = field_path(path, key)
63
+ for index, item in enumerate(require_array(record.get(key), array_path)):
64
+ if not isinstance(item, str):
65
+ raise contract_error(f"{array_path}[{index}]", "a string")
66
+ values.append(item)
67
+ return tuple(values)
68
+
69
+
70
+ def _optional_size_at(record: JSONMapping, key: str, path: str) -> int | None:
71
+ value = record.get(key)
72
+ if value is None:
73
+ return None
74
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
75
+ raise contract_error(field_path(path, key), "a non-negative integer")
76
+ return value
77
+
78
+
79
+ def _optional_links_at(record: JSONMapping, key: str, path: str) -> dict[str, str] | None:
80
+ value = record.get(key)
81
+ if value is None:
82
+ return None
83
+ links_path = field_path(path, key)
84
+ links: dict[str, str] = {}
85
+ for relation, target in require_object(value, links_path).items():
86
+ if not isinstance(target, str):
87
+ raise contract_error(f"{links_path}.{relation}", "a string")
88
+ links[relation] = target
89
+ return links
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class PayloadResult:
94
+ """A record that keeps its raw JSON payload, unknown additive fields included."""
95
+
96
+ _payload: JSONMapping
97
+
98
+ def to_payload(self) -> JSONMapping:
99
+ """Return an independent JSON-compatible payload."""
100
+ return deepcopy(self._payload)
101
+
102
+
103
+ @dataclass(frozen=True, slots=True)
104
+ class CapabilitySet(PayloadResult):
105
+ """Direct ``GET /v1/capabilities`` record; unknown run kinds are preserved."""
106
+
107
+ api_version: str
108
+ run_types: tuple[str, ...]
109
+ features: tuple[str, ...]
110
+
111
+ @classmethod
112
+ def from_payload(cls, payload: Mapping[str, JSONValue]) -> Self:
113
+ """Validate one raw capability response against the v1 contract."""
114
+ record = stored_copy(payload)
115
+ return cls(
116
+ record,
117
+ api_version=string_at(record, "api_version", ""),
118
+ run_types=_string_tuple_at(record, "run_types", "") if "run_types" in record else (),
119
+ features=_string_tuple_at(record, "features", ""),
120
+ )
121
+
122
+
123
+ @dataclass(frozen=True, slots=True)
124
+ class Principal(PayloadResult):
125
+ """Direct ``GET /v1/whoami`` record from the v1 contract."""
126
+
127
+ subject_id: str
128
+ project_id: str | None
129
+
130
+ @classmethod
131
+ def from_payload(cls, payload: Mapping[str, JSONValue]) -> Self:
132
+ """Validate one raw principal response against the v1 contract."""
133
+ record = stored_copy(payload)
134
+ return cls(
135
+ record,
136
+ subject_id=string_at(record, "subject_id", ""),
137
+ project_id=optional_string_at(record, "project_id", ""),
138
+ )
139
+
140
+
141
+ @dataclass(frozen=True, slots=True)
142
+ class Run(PayloadResult):
143
+ """Direct ``Run`` record; unknown ``status`` enum strings are preserved."""
144
+
145
+ run_id: str
146
+ status: str
147
+ # status is liveness: did the run finish. result_status is the outcome: was
148
+ # the answer accepted. Both are absent while a run is still queued, and a
149
+ # finished run can carry a non-OK result, so they must not be conflated.
150
+ result_status: str | None
151
+ topology: str | None
152
+ kind: str | None
153
+ phase: str | None
154
+ created_at: str | None
155
+ policy_version: str | None
156
+ links: dict[str, str] | None
157
+
158
+ @classmethod
159
+ def from_payload(cls, payload: Mapping[str, JSONValue]) -> Self:
160
+ """Validate one raw run response against the v1 contract."""
161
+ return cls._parse(stored_copy(payload), "")
162
+
163
+ @classmethod
164
+ def parse_at(cls, value: JSONValue, path: str) -> Self:
165
+ """Parse one nested run record located at ``path``."""
166
+ return cls._parse(deepcopy(require_object(value, path)), path)
167
+
168
+ @classmethod
169
+ def _parse(cls, record: JSONMapping, path: str) -> Self:
170
+ return cls(
171
+ record,
172
+ run_id=string_at(record, "run_id", path),
173
+ status=string_at(record, "status", path),
174
+ result_status=optional_string_at(record, "result_status", path),
175
+ topology=optional_string_at(record, "topology", path),
176
+ kind=optional_string_at(record, "kind", path),
177
+ phase=optional_string_at(record, "phase", path),
178
+ created_at=optional_string_at(record, "created_at", path),
179
+ policy_version=optional_string_at(record, "policy_version", path),
180
+ links=_optional_links_at(record, "links", path),
181
+ )
182
+
183
+
184
+ @dataclass(frozen=True, slots=True)
185
+ class EvidenceCheck:
186
+ """One evidence check; unknown ``status`` strings are preserved."""
187
+
188
+ name: str
189
+ status: str
190
+
191
+ @classmethod
192
+ def parse_at(cls, value: JSONValue, path: str) -> Self:
193
+ """Parse one contract record located at ``path``."""
194
+ record = require_object(value, path)
195
+ return cls(
196
+ name=string_at(record, "name", path),
197
+ status=string_at(record, "status", path),
198
+ )
199
+
200
+
201
+ @dataclass(frozen=True, slots=True)
202
+ class Artifact:
203
+ """``Artifact`` record; only ``artifact_id`` and ``name`` are required."""
204
+
205
+ artifact_id: str
206
+ name: str
207
+ media_type: str | None
208
+ size_bytes: int | None
209
+ sha256: str | None
210
+ download_url: str | None
211
+
212
+ @classmethod
213
+ def parse_at(cls, value: JSONValue, path: str) -> Self:
214
+ """Parse one contract record located at ``path``."""
215
+ record = require_object(value, path)
216
+ return cls(
217
+ artifact_id=string_at(record, "artifact_id", path),
218
+ name=string_at(record, "name", path),
219
+ media_type=optional_string_at(record, "media_type", path),
220
+ size_bytes=_optional_size_at(record, "size_bytes", path),
221
+ sha256=optional_string_at(record, "sha256", path),
222
+ download_url=optional_string_at(record, "download_url", path),
223
+ )
File without changes