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.
Files changed (26) hide show
  1. methodic_research-0.1.2/PKG-INFO +19 -0
  2. methodic_research-0.1.2/README.md +36 -0
  3. methodic_research-0.1.2/pyproject.toml +38 -0
  4. methodic_research-0.1.2/setup.cfg +4 -0
  5. methodic_research-0.1.2/src/methodic/__init__.py +79 -0
  6. methodic_research-0.1.2/src/methodic/assets.py +143 -0
  7. methodic_research-0.1.2/src/methodic/chronicle.py +88 -0
  8. methodic_research-0.1.2/src/methodic/errors.py +70 -0
  9. methodic_research-0.1.2/src/methodic/experiments.py +342 -0
  10. methodic_research-0.1.2/src/methodic/reports.py +294 -0
  11. methodic_research-0.1.2/src/methodic/runs.py +306 -0
  12. methodic_research-0.1.2/src/methodic/search.py +78 -0
  13. methodic_research-0.1.2/src/methodic/transport.py +91 -0
  14. methodic_research-0.1.2/src/methodic/types.py +344 -0
  15. methodic_research-0.1.2/src/methodic/upload_tracker.py +181 -0
  16. methodic_research-0.1.2/src/methodic/variations.py +166 -0
  17. methodic_research-0.1.2/src/methodic_research.egg-info/PKG-INFO +19 -0
  18. methodic_research-0.1.2/src/methodic_research.egg-info/SOURCES.txt +24 -0
  19. methodic_research-0.1.2/src/methodic_research.egg-info/dependency_links.txt +1 -0
  20. methodic_research-0.1.2/src/methodic_research.egg-info/requires.txt +12 -0
  21. methodic_research-0.1.2/src/methodic_research.egg-info/top_level.txt +1 -0
  22. methodic_research-0.1.2/tests/test_client.py +147 -0
  23. methodic_research-0.1.2/tests/test_experiments.py +494 -0
  24. methodic_research-0.1.2/tests/test_reports.py +222 -0
  25. methodic_research-0.1.2/tests/test_search.py +144 -0
  26. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)