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.
- adaptorch_client-0.1.0/LICENSE +14 -0
- adaptorch_client-0.1.0/PKG-INFO +72 -0
- adaptorch_client-0.1.0/README.md +48 -0
- adaptorch_client-0.1.0/pyproject.toml +48 -0
- adaptorch_client-0.1.0/setup.cfg +4 -0
- adaptorch_client-0.1.0/src/adaptorch_client/__init__.py +38 -0
- adaptorch_client-0.1.0/src/adaptorch_client/client.py +122 -0
- adaptorch_client-0.1.0/src/adaptorch_client/config.py +72 -0
- adaptorch_client-0.1.0/src/adaptorch_client/errors.py +23 -0
- adaptorch_client-0.1.0/src/adaptorch_client/models.py +223 -0
- adaptorch_client-0.1.0/src/adaptorch_client/py.typed +0 -0
- adaptorch_client-0.1.0/src/adaptorch_client/responses.py +81 -0
- adaptorch_client-0.1.0/src/adaptorch_client/transport.py +207 -0
- adaptorch_client-0.1.0/src/adaptorch_client.egg-info/PKG-INFO +72 -0
- adaptorch_client-0.1.0/src/adaptorch_client.egg-info/SOURCES.txt +20 -0
- adaptorch_client-0.1.0/src/adaptorch_client.egg-info/dependency_links.txt +1 -0
- adaptorch_client-0.1.0/src/adaptorch_client.egg-info/requires.txt +6 -0
- adaptorch_client-0.1.0/src/adaptorch_client.egg-info/top_level.txt +1 -0
- adaptorch_client-0.1.0/tests/test_http_contract.py +221 -0
- adaptorch_client-0.1.0/tests/test_http_hardening.py +177 -0
- adaptorch_client-0.1.0/tests/test_models.py +302 -0
- adaptorch_client-0.1.0/tests/test_public_api.py +156 -0
|
@@ -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,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
|