methodic-research 0.1.2__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.
- methodic_research-0.1.2/PKG-INFO +19 -0
- methodic_research-0.1.2/README.md +36 -0
- methodic_research-0.1.2/pyproject.toml +38 -0
- methodic_research-0.1.2/setup.cfg +4 -0
- methodic_research-0.1.2/src/methodic/__init__.py +79 -0
- methodic_research-0.1.2/src/methodic/assets.py +143 -0
- methodic_research-0.1.2/src/methodic/chronicle.py +88 -0
- methodic_research-0.1.2/src/methodic/errors.py +70 -0
- methodic_research-0.1.2/src/methodic/experiments.py +342 -0
- methodic_research-0.1.2/src/methodic/reports.py +294 -0
- methodic_research-0.1.2/src/methodic/runs.py +306 -0
- methodic_research-0.1.2/src/methodic/search.py +78 -0
- methodic_research-0.1.2/src/methodic/transport.py +91 -0
- methodic_research-0.1.2/src/methodic/types.py +344 -0
- methodic_research-0.1.2/src/methodic/upload_tracker.py +181 -0
- methodic_research-0.1.2/src/methodic/variations.py +166 -0
- methodic_research-0.1.2/src/methodic_research.egg-info/PKG-INFO +19 -0
- methodic_research-0.1.2/src/methodic_research.egg-info/SOURCES.txt +24 -0
- methodic_research-0.1.2/src/methodic_research.egg-info/dependency_links.txt +1 -0
- methodic_research-0.1.2/src/methodic_research.egg-info/requires.txt +12 -0
- methodic_research-0.1.2/src/methodic_research.egg-info/top_level.txt +1 -0
- methodic_research-0.1.2/tests/test_client.py +147 -0
- methodic_research-0.1.2/tests/test_experiments.py +494 -0
- methodic_research-0.1.2/tests/test_reports.py +222 -0
- methodic_research-0.1.2/tests/test_search.py +144 -0
- methodic_research-0.1.2/tests/test_upload_tracker.py +214 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: methodic-research
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Python client for the Chronicle experiment platform
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Documentation, https://docs.methodiclabs.ai
|
|
7
|
+
Project-URL: Source, https://github.com/methodic-research/methodic/tree/main/conductor
|
|
8
|
+
Project-URL: Issues, https://github.com/methodic-research/methodic/issues
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: requests
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-timeout; extra == "dev"
|
|
14
|
+
Requires-Dist: requests-mock; extra == "dev"
|
|
15
|
+
Provides-Extra: docs
|
|
16
|
+
Requires-Dist: mkdocs>=1.6; extra == "docs"
|
|
17
|
+
Requires-Dist: mkdocs-material>=9.5; extra == "docs"
|
|
18
|
+
Requires-Dist: mkdocstrings[python]>=0.27; extra == "docs"
|
|
19
|
+
Requires-Dist: ruff; extra == "docs"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# methodic
|
|
2
|
+
|
|
3
|
+
Python client for the [Chronicle](https://github.com/methodic-research/methodic) experiment platform.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install methodic-research
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from methodic import Client
|
|
11
|
+
|
|
12
|
+
client = Client(
|
|
13
|
+
server_url="https://chronicle.example.com",
|
|
14
|
+
experiment_id="...",
|
|
15
|
+
variation=1,
|
|
16
|
+
run=1,
|
|
17
|
+
api_key="sk_agent_...",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
client.start_run()
|
|
21
|
+
client.upload_asset(asset_type="research_report", content={"summary": "..."})
|
|
22
|
+
client.succeed_run()
|
|
23
|
+
client.close()
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Full documentation: <https://docs.methodiclabs.ai>. Source for the protocol details (auth, run lifecycle, asset upload flow) lives in [`docs/design.md`](docs/design.md).
|
|
27
|
+
|
|
28
|
+
## Releasing
|
|
29
|
+
|
|
30
|
+
The package is published to PyPI by the `methodic-lib-publish.yml` workflow via PyPI trusted publishing (OIDC, no API token). To cut a release, bump `version` in `pyproject.toml` and push a matching tag:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
git tag methodic-lib-v0.1.1 && git push --tags
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The tag prefix is `methodic-lib-v*` rather than `methodic-v*` to keep the `methodic-*` namespace reserved in case the Chronicle platform itself is renamed to `methodic` later. The workflow validates that the tag version matches `pyproject.toml` before publishing.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=75.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "methodic-research"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "Python client for the Chronicle experiment platform"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"requests",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Documentation = "https://docs.methodiclabs.ai"
|
|
17
|
+
Source = "https://github.com/methodic-research/methodic/tree/main/conductor"
|
|
18
|
+
Issues = "https://github.com/methodic-research/methodic/issues"
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=8.0",
|
|
23
|
+
"pytest-timeout",
|
|
24
|
+
"requests-mock",
|
|
25
|
+
]
|
|
26
|
+
docs = [
|
|
27
|
+
"mkdocs>=1.6",
|
|
28
|
+
"mkdocs-material>=9.5",
|
|
29
|
+
"mkdocstrings[python]>=0.27",
|
|
30
|
+
"ruff",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
38
|
+
timeout = 30
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Public API for the methodic SDK."""
|
|
2
|
+
|
|
3
|
+
from methodic.assets import AssetsAPI, AssetUploadInfo
|
|
4
|
+
from methodic.chronicle import Chronicle
|
|
5
|
+
from methodic.errors import (
|
|
6
|
+
APIError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
BadRequestError,
|
|
9
|
+
ChronicleError,
|
|
10
|
+
ConflictError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
PermissionDeniedError,
|
|
13
|
+
ServerError,
|
|
14
|
+
)
|
|
15
|
+
from methodic.experiments import Experiment, ExperimentsAPI
|
|
16
|
+
from methodic.reports import Report, ReportsAPI
|
|
17
|
+
from methodic.runs import Run, RunsAPI
|
|
18
|
+
from methodic.search import SearchAPI
|
|
19
|
+
from methodic.types import (
|
|
20
|
+
CreateExperimentResponse,
|
|
21
|
+
Experiment as ExperimentData,
|
|
22
|
+
ExperimentDetail,
|
|
23
|
+
ExperimentListPage,
|
|
24
|
+
ExperimentSummary,
|
|
25
|
+
GitBranch,
|
|
26
|
+
GitStatus,
|
|
27
|
+
GitToken,
|
|
28
|
+
LineageResponse,
|
|
29
|
+
SearchFilters,
|
|
30
|
+
SearchResponse,
|
|
31
|
+
SearchResult,
|
|
32
|
+
UpstreamRetraction,
|
|
33
|
+
UpstreamRetractionsResponse,
|
|
34
|
+
Variation as VariationData,
|
|
35
|
+
VariationSummary,
|
|
36
|
+
)
|
|
37
|
+
from methodic.upload_tracker import PendingUpload, UploadTracker
|
|
38
|
+
from methodic.variations import Variation, VariationsAPI
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"APIError",
|
|
42
|
+
"AssetUploadInfo",
|
|
43
|
+
"AssetsAPI",
|
|
44
|
+
"AuthenticationError",
|
|
45
|
+
"BadRequestError",
|
|
46
|
+
"Chronicle",
|
|
47
|
+
"ChronicleError",
|
|
48
|
+
"ConflictError",
|
|
49
|
+
"CreateExperimentResponse",
|
|
50
|
+
"Experiment",
|
|
51
|
+
"ExperimentData",
|
|
52
|
+
"ExperimentDetail",
|
|
53
|
+
"ExperimentListPage",
|
|
54
|
+
"ExperimentSummary",
|
|
55
|
+
"ExperimentsAPI",
|
|
56
|
+
"GitBranch",
|
|
57
|
+
"GitStatus",
|
|
58
|
+
"GitToken",
|
|
59
|
+
"LineageResponse",
|
|
60
|
+
"NotFoundError",
|
|
61
|
+
"PendingUpload",
|
|
62
|
+
"PermissionDeniedError",
|
|
63
|
+
"Report",
|
|
64
|
+
"ReportsAPI",
|
|
65
|
+
"Run",
|
|
66
|
+
"RunsAPI",
|
|
67
|
+
"SearchAPI",
|
|
68
|
+
"SearchFilters",
|
|
69
|
+
"SearchResponse",
|
|
70
|
+
"SearchResult",
|
|
71
|
+
"ServerError",
|
|
72
|
+
"UploadTracker",
|
|
73
|
+
"UpstreamRetraction",
|
|
74
|
+
"UpstreamRetractionsResponse",
|
|
75
|
+
"Variation",
|
|
76
|
+
"VariationData",
|
|
77
|
+
"VariationSummary",
|
|
78
|
+
"VariationsAPI",
|
|
79
|
+
]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Asset primitives.
|
|
2
|
+
|
|
3
|
+
The full researcher-facing AssetsAPI lands later (deprecate, invalidate,
|
|
4
|
+
list, search-tied operations). This module currently exposes the low-level
|
|
5
|
+
operations every other namespace needs: create-with-presigned-URLs, upload
|
|
6
|
+
components, finalize, fetch metadata, presign, stream download.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from methodic.transport import Transport
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class AssetUploadInfo:
|
|
23
|
+
"""Result of `AssetsAPI.create_with_presigned`: where to put each component."""
|
|
24
|
+
|
|
25
|
+
asset_id: str
|
|
26
|
+
asset_uri: str
|
|
27
|
+
upload_urls: dict[str, str] # component name → presigned PUT URL
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AssetsAPI:
|
|
31
|
+
"""Asset operations.
|
|
32
|
+
|
|
33
|
+
Output-of linking (which experiment/variation/run produced this asset) is
|
|
34
|
+
passed explicitly by callers — `Run` populates it from its bound context,
|
|
35
|
+
while researcher-level uploads pass it directly or omit it for shared assets.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, transport: Transport) -> None:
|
|
39
|
+
self._t = transport
|
|
40
|
+
|
|
41
|
+
def create_inline(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
asset_type: str,
|
|
45
|
+
content: Any,
|
|
46
|
+
name: str | None = None,
|
|
47
|
+
content_type: str = "application/json",
|
|
48
|
+
output_of: dict[str, Any] | None = None,
|
|
49
|
+
asset_config: dict[str, Any] | None = None,
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
"""Upload a small inline asset. Chronicle auto-finalizes."""
|
|
52
|
+
payload: dict[str, Any] = {
|
|
53
|
+
"name": name or asset_type,
|
|
54
|
+
"asset_type": asset_type,
|
|
55
|
+
"content_type": content_type,
|
|
56
|
+
"content": content,
|
|
57
|
+
}
|
|
58
|
+
if output_of is not None:
|
|
59
|
+
payload["output_of"] = output_of
|
|
60
|
+
if asset_config is not None:
|
|
61
|
+
payload["asset_config"] = asset_config
|
|
62
|
+
logger.info("Creating inline %s asset", asset_type)
|
|
63
|
+
return self._t.post("/assets", json=payload)
|
|
64
|
+
|
|
65
|
+
def create_with_presigned(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
asset_type: str,
|
|
69
|
+
components: list[str],
|
|
70
|
+
name: str | None = None,
|
|
71
|
+
content_type: str = "application/octet-stream",
|
|
72
|
+
output_of: dict[str, Any] | None = None,
|
|
73
|
+
) -> AssetUploadInfo:
|
|
74
|
+
"""Register a new asset and get presigned PUT URLs for each component."""
|
|
75
|
+
payload: dict[str, Any] = {
|
|
76
|
+
"name": name or asset_type,
|
|
77
|
+
"asset_type": asset_type,
|
|
78
|
+
"presign": True,
|
|
79
|
+
"components": components,
|
|
80
|
+
"content_type": content_type,
|
|
81
|
+
}
|
|
82
|
+
if output_of is not None:
|
|
83
|
+
payload["output_of"] = output_of
|
|
84
|
+
data = self._t.post("/assets", json=payload)
|
|
85
|
+
asset = data["asset"]
|
|
86
|
+
upload_urls = {
|
|
87
|
+
(u.get("component") or "default"): u["url"] for u in (data.get("upload_urls") or [])
|
|
88
|
+
}
|
|
89
|
+
return AssetUploadInfo(
|
|
90
|
+
asset_id=asset["id"],
|
|
91
|
+
asset_uri=asset["uri"],
|
|
92
|
+
upload_urls=upload_urls,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def upload_component(
|
|
96
|
+
self, upload_url: str, local_path: Path, content_type: str
|
|
97
|
+
) -> None:
|
|
98
|
+
"""PUT one component to its presigned URL."""
|
|
99
|
+
logger.debug("Uploading %s via presigned URL", local_path.name)
|
|
100
|
+
with open(local_path, "rb") as f:
|
|
101
|
+
self._t.put_to_presigned(upload_url, data=f, content_type=content_type)
|
|
102
|
+
|
|
103
|
+
def finalize(self, asset_id: str) -> None:
|
|
104
|
+
"""Mark a presigned-upload asset as ready (immutable) once all components are up."""
|
|
105
|
+
self._t.put(f"/assets/{asset_id}/finalize")
|
|
106
|
+
|
|
107
|
+
def get(self, asset_id: str, *, include_presigned: bool = False) -> dict[str, Any]:
|
|
108
|
+
"""Fetch asset metadata. With `include_presigned=True`, includes read URLs."""
|
|
109
|
+
params = {"include_presigned": "true"} if include_presigned else None
|
|
110
|
+
return self._t.get(f"/assets/{asset_id}", params=params)
|
|
111
|
+
|
|
112
|
+
def presign(
|
|
113
|
+
self,
|
|
114
|
+
asset_id: str,
|
|
115
|
+
*,
|
|
116
|
+
operation: str = "read",
|
|
117
|
+
components: list[str] | None = None,
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
"""Request presigned URLs for an asset's components."""
|
|
120
|
+
payload: dict[str, Any] = {"operation": operation}
|
|
121
|
+
if components:
|
|
122
|
+
payload["components"] = components
|
|
123
|
+
resp = self._t.post(f"/assets/{asset_id}/presign", json=payload)
|
|
124
|
+
return resp.get("presigned_urls", {})
|
|
125
|
+
|
|
126
|
+
def download(self, asset_id: str, local_dir: Path) -> Path:
|
|
127
|
+
"""Download all components of an asset to a local directory."""
|
|
128
|
+
asset = self.get(asset_id, include_presigned=True)
|
|
129
|
+
local_dir.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
|
|
131
|
+
for component, url_info in asset.get("presigned_urls", {}).items():
|
|
132
|
+
local_path = local_dir / component
|
|
133
|
+
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
logger.info("Downloading %s → %s", component, local_path)
|
|
135
|
+
resp = self._t.get_streaming(url_info["url"])
|
|
136
|
+
try:
|
|
137
|
+
with open(local_path, "wb") as f:
|
|
138
|
+
for chunk in resp.iter_content(chunk_size=8192):
|
|
139
|
+
f.write(chunk)
|
|
140
|
+
finally:
|
|
141
|
+
resp.close()
|
|
142
|
+
|
|
143
|
+
return local_dir
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Top-level entry point for the methodic SDK.
|
|
2
|
+
|
|
3
|
+
`Chronicle` owns the HTTP transport and a shared upload thread pool, then
|
|
4
|
+
wires up every namespace (`runs`, `assets`, …; experiments/variations/search
|
|
5
|
+
land in subsequent phases). Most users construct one `Chronicle` per
|
|
6
|
+
process and reuse it across runs and researcher operations.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
|
|
15
|
+
from methodic.assets import AssetsAPI
|
|
16
|
+
from methodic.experiments import Experiment, ExperimentsAPI
|
|
17
|
+
from methodic.reports import ReportsAPI
|
|
18
|
+
from methodic.runs import Run, RunsAPI
|
|
19
|
+
from methodic.search import SearchAPI
|
|
20
|
+
from methodic.transport import Transport
|
|
21
|
+
from methodic.variations import Variation, VariationsAPI
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Chronicle:
|
|
27
|
+
"""Client for the Chronicle REST API.
|
|
28
|
+
|
|
29
|
+
Construct once with the server URL + API key, then call into namespaces:
|
|
30
|
+
|
|
31
|
+
chronicle = Chronicle(server_url="https://api.methodiclabs.ai", api_key="sk_...")
|
|
32
|
+
|
|
33
|
+
# Researcher
|
|
34
|
+
exp = chronicle.experiments.create(hypothesis_summary="...", config_yaml="...")
|
|
35
|
+
exp.commit().variations.create(config_yaml="...")
|
|
36
|
+
|
|
37
|
+
# Worker
|
|
38
|
+
run = chronicle.run(experiment_id, variation, run_idx)
|
|
39
|
+
run.start().heartbeat()
|
|
40
|
+
run.upload_asset(asset_type="research_report", content={"summary": "..."})
|
|
41
|
+
run.succeed()
|
|
42
|
+
|
|
43
|
+
Use as a context manager to guarantee the executor and HTTP session are closed.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
server_url: str,
|
|
49
|
+
api_key: str,
|
|
50
|
+
timeout: int = 30,
|
|
51
|
+
max_upload_workers: int = 2,
|
|
52
|
+
) -> None:
|
|
53
|
+
self._transport = Transport(server_url, api_key, timeout)
|
|
54
|
+
self._executor = ThreadPoolExecutor(max_workers=max_upload_workers)
|
|
55
|
+
self.assets = AssetsAPI(self._transport)
|
|
56
|
+
self.runs = RunsAPI(self._transport, self.assets, self._executor)
|
|
57
|
+
self.variations = VariationsAPI(self._transport, self)
|
|
58
|
+
self.experiments = ExperimentsAPI(self._transport, self)
|
|
59
|
+
self.search = SearchAPI(self._transport, self)
|
|
60
|
+
self.reports = ReportsAPI(self._transport, self)
|
|
61
|
+
|
|
62
|
+
def run(self, experiment_id: str, variation: int, run: int) -> Run:
|
|
63
|
+
"""Construct a `Run` resource handle bound to one (experiment, variation, run)."""
|
|
64
|
+
return Run(self.runs, experiment_id, variation, run)
|
|
65
|
+
|
|
66
|
+
def experiment(self, experiment_id: str) -> Experiment:
|
|
67
|
+
"""Get a handle for an existing experiment by id (lazy — no fetch until accessed)."""
|
|
68
|
+
return Experiment(self, experiment_id)
|
|
69
|
+
|
|
70
|
+
def variation(self, experiment_id: str, variation: int) -> Variation:
|
|
71
|
+
"""Get a handle for an existing variation by (experiment_id, variation)."""
|
|
72
|
+
return Variation(self, experiment_id, variation)
|
|
73
|
+
|
|
74
|
+
def close(self) -> None:
|
|
75
|
+
"""Shut down the upload pool and HTTP session. Idempotent."""
|
|
76
|
+
self._executor.shutdown(wait=True)
|
|
77
|
+
self._transport.close()
|
|
78
|
+
|
|
79
|
+
def __enter__(self) -> Chronicle:
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def __exit__(
|
|
83
|
+
self,
|
|
84
|
+
exc_type: type[BaseException] | None,
|
|
85
|
+
exc: BaseException | None,
|
|
86
|
+
tb: TracebackType | None,
|
|
87
|
+
) -> None:
|
|
88
|
+
self.close()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Exception hierarchy for the methodic SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ChronicleError(Exception):
|
|
9
|
+
"""Base class for every error raised by the methodic client."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class APIError(ChronicleError):
|
|
13
|
+
"""HTTP error from the Chronicle API."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, status_code: int, message: str, response: requests.Response | None = None):
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
self.message = message
|
|
18
|
+
self.response = response
|
|
19
|
+
super().__init__(f"{status_code}: {message}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AuthenticationError(APIError):
|
|
23
|
+
"""401 — missing or invalid credentials."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PermissionDeniedError(APIError):
|
|
27
|
+
"""403 — caller lacks the required ACL grants."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NotFoundError(APIError):
|
|
31
|
+
"""404 — resource does not exist or is hidden by RBAC."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BadRequestError(APIError):
|
|
35
|
+
"""400/422 — malformed request body or invalid arguments."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ConflictError(APIError):
|
|
39
|
+
"""409 — state conflict (e.g., commit on already-committed experiment)."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ServerError(APIError):
|
|
43
|
+
"""5xx — Chronicle is unreachable, misconfigured, or buggy."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def raise_for_response(resp: requests.Response) -> None:
|
|
47
|
+
"""Translate a non-2xx HTTP response into the right `APIError` subclass."""
|
|
48
|
+
if resp.ok:
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
body = resp.text or ""
|
|
52
|
+
status = resp.status_code
|
|
53
|
+
|
|
54
|
+
cls: type[APIError]
|
|
55
|
+
if status == 400 or status == 422:
|
|
56
|
+
cls = BadRequestError
|
|
57
|
+
elif status == 401:
|
|
58
|
+
cls = AuthenticationError
|
|
59
|
+
elif status == 403:
|
|
60
|
+
cls = PermissionDeniedError
|
|
61
|
+
elif status == 404:
|
|
62
|
+
cls = NotFoundError
|
|
63
|
+
elif status == 409:
|
|
64
|
+
cls = ConflictError
|
|
65
|
+
elif 500 <= status < 600:
|
|
66
|
+
cls = ServerError
|
|
67
|
+
else:
|
|
68
|
+
cls = APIError
|
|
69
|
+
|
|
70
|
+
raise cls(status, body, resp)
|