chemetrian 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chemetrian
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.1
2
+ Name: chemetrian
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Chemetrian API (Bayesian Optimization, and more).
5
+ Home-page: https://www.chemetrian.com
6
+ License: MIT
7
+ Keywords: chemetrian,bayesian-optimization,chemistry,api-client
8
+ Author: Chemetrian
9
+ Author-email: dev@chemetrian.com
10
+ Requires-Python: >=3.9,<4.0
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
21
+ Requires-Dist: pandas (>=1.5)
22
+ Requires-Dist: requests (>=2.28,<3.0)
23
+ Project-URL: Repository, https://gitlab.com/patel.simd/chemetrian-python-sdk
24
+ Description-Content-Type: text/markdown
25
+
26
+ # chemetrian
27
+
28
+ Official Python SDK for the Chemetrian API. Drive Bayesian Optimization (and future
29
+ services) with pandas DataFrames and one API key.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install chemetrian
35
+ ```
36
+
37
+ ## Quickstart
38
+
39
+ ```python
40
+ from chemetrian import Chemetrian
41
+
42
+ client = Chemetrian(api_key="sk_live_…") # or set CHEMETRIAN_API_KEY
43
+ # QA: Chemetrian(api_key="sk_live_…", base_url="https://qa.chemetrian.com")
44
+
45
+ features = [
46
+ {"feature_name": "temp", "feature_type": "CONTINUOUS_NUMERICAL",
47
+ "lower_bound": 20, "upper_bound": 120, "step_size": 10},
48
+ {"feature_name": "catalyst", "feature_type": "CATEGORICAL",
49
+ "feature_values": ["Pd", "Ni", "Cu"]},
50
+ ]
51
+ targets = [{"target_name": "yield", "goal": "maximize", "threshold": 0}]
52
+
53
+ # 1. Start a campaign → DataFrame of suggested experiments (priority == 1)
54
+ df = client.bayesian_optimization.initialize(
55
+ project="amide-coupling", workflow_name="run-1",
56
+ features=features, targets=targets, batch_size=4,
57
+ )
58
+
59
+ # 2. Run the suggested rows, fill in the measured target, submit → next batch
60
+ df.loc[df["priority"] == 1, "yield"] = [72.4, 55.1, 38.9, 80.2]
61
+ df = client.bayesian_optimization.run_round("amide-coupling", "run-1", df, batch_size=4)
62
+
63
+ # 3. (Optional) start from existing data instead of step 1
64
+ df = client.bayesian_optimization.initialize_with_experiments(
65
+ project="amide-coupling", workflow_name="run-2",
66
+ experiments=historical_df, targets=targets, batch_size=4, # features auto-detected
67
+ )
68
+ ```
69
+
70
+ Each call submits a job and blocks until it finishes (polling under the hood), then returns
71
+ a DataFrame. `priority`: `1` = suggested (run it), `-1` = completed.
72
+
73
+ ## Projects & workflows
74
+
75
+ ```python
76
+ client.list_projects()
77
+ client.create_project("amide-coupling", description="Q3 scouting")
78
+ client.bayesian_optimization.list_workflows("amide-coupling")
79
+ ```
80
+
81
+ ## Errors
82
+
83
+ All raise subclasses of `chemetrian.ChemetrianError`: `ValidationError` (client-side),
84
+ `AuthError` (401), `InsufficientCreditsError` (402), `NotFoundError` (404),
85
+ `RateLimitError` (429, auto-retried), `JobFailedError`, `JobTimeoutError`.
86
+
87
+ ## Configuration
88
+
89
+ | arg / env | purpose |
90
+ |---|---|
91
+ | `api_key` / `CHEMETRIAN_API_KEY` | your key |
92
+ | `base_url` / `CHEMETRIAN_API_URL` | API host (QA vs prod) |
93
+ | `poll_interval`, `poll_timeout` | how long the SDK waits for a job |
94
+
@@ -0,0 +1,68 @@
1
+ # chemetrian
2
+
3
+ Official Python SDK for the Chemetrian API. Drive Bayesian Optimization (and future
4
+ services) with pandas DataFrames and one API key.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install chemetrian
10
+ ```
11
+
12
+ ## Quickstart
13
+
14
+ ```python
15
+ from chemetrian import Chemetrian
16
+
17
+ client = Chemetrian(api_key="sk_live_…") # or set CHEMETRIAN_API_KEY
18
+ # QA: Chemetrian(api_key="sk_live_…", base_url="https://qa.chemetrian.com")
19
+
20
+ features = [
21
+ {"feature_name": "temp", "feature_type": "CONTINUOUS_NUMERICAL",
22
+ "lower_bound": 20, "upper_bound": 120, "step_size": 10},
23
+ {"feature_name": "catalyst", "feature_type": "CATEGORICAL",
24
+ "feature_values": ["Pd", "Ni", "Cu"]},
25
+ ]
26
+ targets = [{"target_name": "yield", "goal": "maximize", "threshold": 0}]
27
+
28
+ # 1. Start a campaign → DataFrame of suggested experiments (priority == 1)
29
+ df = client.bayesian_optimization.initialize(
30
+ project="amide-coupling", workflow_name="run-1",
31
+ features=features, targets=targets, batch_size=4,
32
+ )
33
+
34
+ # 2. Run the suggested rows, fill in the measured target, submit → next batch
35
+ df.loc[df["priority"] == 1, "yield"] = [72.4, 55.1, 38.9, 80.2]
36
+ df = client.bayesian_optimization.run_round("amide-coupling", "run-1", df, batch_size=4)
37
+
38
+ # 3. (Optional) start from existing data instead of step 1
39
+ df = client.bayesian_optimization.initialize_with_experiments(
40
+ project="amide-coupling", workflow_name="run-2",
41
+ experiments=historical_df, targets=targets, batch_size=4, # features auto-detected
42
+ )
43
+ ```
44
+
45
+ Each call submits a job and blocks until it finishes (polling under the hood), then returns
46
+ a DataFrame. `priority`: `1` = suggested (run it), `-1` = completed.
47
+
48
+ ## Projects & workflows
49
+
50
+ ```python
51
+ client.list_projects()
52
+ client.create_project("amide-coupling", description="Q3 scouting")
53
+ client.bayesian_optimization.list_workflows("amide-coupling")
54
+ ```
55
+
56
+ ## Errors
57
+
58
+ All raise subclasses of `chemetrian.ChemetrianError`: `ValidationError` (client-side),
59
+ `AuthError` (401), `InsufficientCreditsError` (402), `NotFoundError` (404),
60
+ `RateLimitError` (429, auto-retried), `JobFailedError`, `JobTimeoutError`.
61
+
62
+ ## Configuration
63
+
64
+ | arg / env | purpose |
65
+ |---|---|
66
+ | `api_key` / `CHEMETRIAN_API_KEY` | your key |
67
+ | `base_url` / `CHEMETRIAN_API_URL` | API host (QA vs prod) |
68
+ | `poll_interval`, `poll_timeout` | how long the SDK waits for a job |
@@ -0,0 +1,43 @@
1
+ [tool.poetry]
2
+ name = "chemetrian"
3
+ version = "0.1.0"
4
+ description = "Official Python SDK for the Chemetrian API (Bayesian Optimization, and more)."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = ["Chemetrian <dev@chemetrian.com>"]
8
+ homepage = "https://www.chemetrian.com"
9
+ repository = "https://gitlab.com/patel.simd/chemetrian-python-sdk"
10
+ keywords = ["chemetrian", "bayesian-optimization", "chemistry", "api-client"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Science/Research",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Topic :: Scientific/Engineering :: Chemistry",
17
+ ]
18
+ packages = [{ include = "chemetrian", from = "src" }]
19
+
20
+ [tool.poetry.dependencies]
21
+ python = "^3.9"
22
+ requests = "^2.28"
23
+ pandas = ">=1.5"
24
+
25
+ # Dev-only — NOT installed for partners (kept out of the published package). `jupyter` is a
26
+ # meta-package that pulls in the whole notebook stack; it belongs here, not in runtime deps.
27
+ [tool.poetry.group.dev.dependencies]
28
+ pytest = "^7"
29
+ responses = "^0.23"
30
+ jupyter = "^1.1.1"
31
+
32
+ [build-system]
33
+ requires = ["poetry-core>=1.8.0"]
34
+ build-backend = "poetry.core.masonry.api"
35
+
36
+ [tool.pytest.ini_options]
37
+ # Default run (`poetry run pytest` from the SDK root) is the MOCKED suite only. e2e/ is a
38
+ # LIVE suite with its own e2e/pytest.ini (its own rootdir) and must never be collected here.
39
+ testpaths = ["tests"]
40
+ markers = [
41
+ "e2e: LIVE end-to-end test against a real backend (defined for the e2e/ suite).",
42
+ "slow: submits REAL compute and polls for minutes (e2e/ suite).",
43
+ ]
@@ -0,0 +1,31 @@
1
+ """Official Python SDK for the Chemetrian API."""
2
+
3
+ from .client import Chemetrian
4
+ from .exceptions import (
5
+ ChemetrianError,
6
+ ValidationError,
7
+ APIError,
8
+ AuthError,
9
+ InsufficientCreditsError,
10
+ NotFoundError,
11
+ ConflictError,
12
+ RateLimitError,
13
+ JobFailedError,
14
+ JobTimeoutError,
15
+ )
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ __all__ = [
20
+ "Chemetrian",
21
+ "ChemetrianError",
22
+ "ValidationError",
23
+ "APIError",
24
+ "AuthError",
25
+ "InsufficientCreditsError",
26
+ "NotFoundError",
27
+ "ConflictError",
28
+ "RateLimitError",
29
+ "JobFailedError",
30
+ "JobTimeoutError",
31
+ ]
@@ -0,0 +1,112 @@
1
+ """Internal HTTP transport for the SDK.
2
+
3
+ Not part of the public API — it just knows the base URL, attaches the Bearer key, maps
4
+ error responses to typed exceptions, retries on 429 (honoring Retry-After), and polls jobs.
5
+ """
6
+
7
+ import os
8
+ import time
9
+
10
+ import requests
11
+
12
+ from .exceptions import (
13
+ APIError,
14
+ AuthError,
15
+ InsufficientCreditsError,
16
+ NotFoundError,
17
+ ConflictError,
18
+ RateLimitError,
19
+ JobFailedError,
20
+ JobTimeoutError,
21
+ )
22
+
23
+ DEFAULT_BASE_URL = "https://www.chemetrian.com" # prod; QA is https://qa.chemetrian.com
24
+ _API_PREFIX = "/api/v1"
25
+
26
+
27
+ class HttpClient:
28
+ def __init__(
29
+ self,
30
+ api_key: str,
31
+ base_url: str | None = None,
32
+ timeout: float = 30.0,
33
+ max_retries: int = 5,
34
+ ):
35
+ self.api_key = api_key
36
+ self.base_url = (
37
+ base_url or os.environ.get("CHEMETRIAN_API_URL") or DEFAULT_BASE_URL
38
+ ).rstrip("/")
39
+ self.timeout = timeout
40
+ self.max_retries = max_retries
41
+ self.session = requests.Session()
42
+ self.session.headers.update(
43
+ {"Authorization": f"Bearer {api_key}", "Accept": "application/json"}
44
+ )
45
+
46
+ def request(self, method: str, path: str, json=None, params=None) -> dict:
47
+ url = f"{self.base_url}{_API_PREFIX}{path}"
48
+ attempt = 0
49
+ while True:
50
+ resp = self.session.request(
51
+ method, url, json=json, params=params, timeout=self.timeout
52
+ )
53
+ # Auto-backoff on rate limiting so honest callers don't have to.
54
+ if resp.status_code == 429 and attempt < self.max_retries:
55
+ time.sleep(self._retry_after(resp, default=1.0))
56
+ attempt += 1
57
+ continue
58
+ return self._handle(resp)
59
+
60
+ def poll_job(
61
+ self, job_id: str, interval: float = 2.0, timeout: float = 1800.0
62
+ ) -> dict:
63
+ """Poll GET /jobs/{id} until it finishes; return the result dict."""
64
+ start = time.monotonic()
65
+ while True:
66
+ result = self.request("GET", f"/jobs/{job_id}")
67
+ status = result.get("status")
68
+ if status == "finished":
69
+ return result
70
+ if status == "failed":
71
+ raise JobFailedError(f"Job {job_id} finished in a failed state.")
72
+ if time.monotonic() - start > timeout:
73
+ raise JobTimeoutError(
74
+ f"Job {job_id} did not finish within {timeout:.0f}s."
75
+ )
76
+ time.sleep(interval)
77
+
78
+ # ── internals ────────────────────────────────────────────────────────────
79
+ def _handle(self, resp) -> dict:
80
+ if 200 <= resp.status_code < 300:
81
+ return resp.json() if resp.content else {}
82
+ detail = self._detail(resp)
83
+ code = resp.status_code
84
+ if code == 401:
85
+ raise AuthError(detail, 401)
86
+ if code == 402:
87
+ raise InsufficientCreditsError(detail, 402)
88
+ if code == 404:
89
+ raise NotFoundError(detail, 404)
90
+ if code == 409:
91
+ raise ConflictError(detail, 409)
92
+ if code == 429:
93
+ raise RateLimitError(detail, retry_after=self._retry_after(resp))
94
+ raise APIError(detail, code)
95
+
96
+ @staticmethod
97
+ def _retry_after(resp, default: float = 0.0) -> float:
98
+ try:
99
+ return float(resp.headers.get("Retry-After", default) or default)
100
+ except (TypeError, ValueError):
101
+ return default
102
+
103
+ @staticmethod
104
+ def _detail(resp) -> str:
105
+ try:
106
+ data = resp.json()
107
+ d = data.get("detail")
108
+ if isinstance(d, str):
109
+ return d
110
+ return str(d) if d else (resp.text or f"HTTP {resp.status_code}")
111
+ except ValueError:
112
+ return resp.text or f"HTTP {resp.status_code}"
@@ -0,0 +1,60 @@
1
+ """Client-side validation — fail fast on obvious mistakes before hitting the network.
2
+
3
+ This is a convenience/UX layer (better errors, less wasted traffic for honest callers).
4
+ It is NOT a security boundary — the server validates everything independently.
5
+ """
6
+
7
+ from .exceptions import ValidationError
8
+
9
+ VALID_FEATURE_TYPES = {"DISCRETE_NUMERICAL", "CONTINUOUS_NUMERICAL", "CATEGORICAL"}
10
+ VALID_GOALS = {"maximize", "minimize"}
11
+
12
+
13
+ def validate_features(features: list) -> None:
14
+ if not features:
15
+ raise ValidationError("At least one feature is required.")
16
+ for f in features:
17
+ name = f.get("feature_name")
18
+ if not name:
19
+ raise ValidationError("Every feature needs a 'feature_name'.")
20
+ ftype = f.get("feature_type")
21
+ if ftype not in VALID_FEATURE_TYPES:
22
+ raise ValidationError(
23
+ f"Feature '{name}': feature_type must be one of "
24
+ f"{sorted(VALID_FEATURE_TYPES)}."
25
+ )
26
+ if ftype == "CONTINUOUS_NUMERICAL":
27
+ for k in ("lower_bound", "upper_bound", "step_size"):
28
+ if f.get(k) is None:
29
+ raise ValidationError(
30
+ f"Feature '{name}' (CONTINUOUS_NUMERICAL) needs '{k}'."
31
+ )
32
+ if f["lower_bound"] >= f["upper_bound"]:
33
+ raise ValidationError(
34
+ f"Feature '{name}': lower_bound must be < upper_bound."
35
+ )
36
+ if f["step_size"] <= 0:
37
+ raise ValidationError(f"Feature '{name}': step_size must be > 0.")
38
+ else: # DISCRETE_NUMERICAL / CATEGORICAL
39
+ if not f.get("feature_values"):
40
+ raise ValidationError(
41
+ f"Feature '{name}' ({ftype}) needs non-empty 'feature_values'."
42
+ )
43
+
44
+
45
+ def validate_targets(targets: list) -> None:
46
+ if not targets:
47
+ raise ValidationError("At least one target is required.")
48
+ for t in targets:
49
+ if not t.get("target_name"):
50
+ raise ValidationError("Every target needs a 'target_name'.")
51
+ if (t.get("goal") or "").lower() not in VALID_GOALS:
52
+ raise ValidationError(
53
+ f"Target '{t.get('target_name')}': goal must be "
54
+ "'maximize' or 'minimize'."
55
+ )
56
+
57
+
58
+ def validate_batch_size(batch_size) -> None:
59
+ if not isinstance(batch_size, int) or batch_size <= 0:
60
+ raise ValidationError("batch_size must be a positive integer.")
@@ -0,0 +1,213 @@
1
+ """Bayesian Optimization service — the `client.bayesian_optimization` namespace.
2
+
3
+ Each optimization call submits a job and blocks (polling) until it finishes, then returns
4
+ a pandas DataFrame of experiments. The async job/poll is hidden from the caller.
5
+ """
6
+
7
+ import time
8
+
9
+ import pandas as pd
10
+
11
+ from . import _validation
12
+ from .exceptions import JobFailedError, JobTimeoutError
13
+
14
+
15
+ def _staged_poll_delay(elapsed: float) -> float:
16
+ """Backoff cadence for long-running jobs (mirrors the web app's polling): fast early,
17
+ gentle later. elapsed is seconds since the poll started."""
18
+ if elapsed <= 10:
19
+ return 1.0
20
+ if elapsed <= 30:
21
+ return 2.0
22
+ if elapsed <= 210:
23
+ return 5.0
24
+ if elapsed <= 1800:
25
+ return 30.0
26
+ return 180.0
27
+
28
+
29
+ def _to_records(experiments) -> list:
30
+ """Accept a DataFrame or a list of row-dicts; return a list of row-dicts."""
31
+ if isinstance(experiments, pd.DataFrame):
32
+ return experiments.to_dict(orient="records")
33
+ if isinstance(experiments, list):
34
+ return experiments
35
+ raise _validation.ValidationError(
36
+ "experiments must be a pandas DataFrame or a list of row dicts."
37
+ )
38
+
39
+
40
+ class BayesianOptimizationService:
41
+ def __init__(
42
+ self, http, poll_interval: float | None = None, poll_timeout: float = 1800.0
43
+ ):
44
+ self._http = http
45
+ # None -> staged backoff (fast early, gentle later). A number -> fixed interval.
46
+ self._poll_interval = poll_interval
47
+ self._poll_timeout = poll_timeout
48
+
49
+ def initialize(
50
+ self,
51
+ project: str,
52
+ workflow_name: str,
53
+ features: list,
54
+ targets: list,
55
+ batch_size: int,
56
+ exploit_explore_strategy: str = "balanced",
57
+ wait: bool = True,
58
+ ):
59
+ """Start a campaign. With wait=True (default) block until the first batch is ready
60
+ and return it as a DataFrame; with wait=False return the submit handle."""
61
+ _validation.validate_features(features)
62
+ _validation.validate_targets(targets)
63
+ _validation.validate_batch_size(batch_size)
64
+ body = {
65
+ "workflow_name": workflow_name,
66
+ "features": features,
67
+ "targets": targets,
68
+ "batch_size": batch_size,
69
+ "exploit_explore_strategy": exploit_explore_strategy,
70
+ }
71
+ submit = self._http.request(
72
+ "POST", f"/projects/{project}/bo/initialize", json=body
73
+ )
74
+ return self._finish(project, workflow_name, submit, wait)
75
+
76
+ def initialize_with_experiments(
77
+ self,
78
+ project: str,
79
+ workflow_name: str,
80
+ experiments,
81
+ targets: list,
82
+ batch_size: int,
83
+ features: list | None = None,
84
+ exploit_explore_strategy: str = "balanced",
85
+ wait: bool = True,
86
+ ):
87
+ """Seed a campaign from existing measured experiments. Omit `features` to
88
+ auto-detect the parameter space from the data. See `wait`."""
89
+ _validation.validate_targets(targets)
90
+ _validation.validate_batch_size(batch_size)
91
+ if features is not None:
92
+ _validation.validate_features(features)
93
+ body = {
94
+ "workflow_name": workflow_name,
95
+ "targets": targets,
96
+ "batch_size": batch_size,
97
+ "experiments": _to_records(experiments),
98
+ "features": features,
99
+ "exploit_explore_strategy": exploit_explore_strategy,
100
+ }
101
+ submit = self._http.request(
102
+ "POST",
103
+ f"/projects/{project}/bo/initialize-with-experiments",
104
+ json=body,
105
+ )
106
+ return self._finish(project, workflow_name, submit, wait)
107
+
108
+ def run_round(
109
+ self,
110
+ project: str,
111
+ workflow_name: str,
112
+ experiments,
113
+ batch_size: int,
114
+ wait: bool = True,
115
+ ):
116
+ """Submit the returned frame with measured target values filled in; get the next
117
+ suggested batch back. See `wait`."""
118
+ _validation.validate_batch_size(batch_size)
119
+ body = {"experiments": _to_records(experiments), "batch_size": batch_size}
120
+ submit = self._http.request(
121
+ "POST",
122
+ f"/projects/{project}/workflows/{workflow_name}/rounds",
123
+ json=body,
124
+ )
125
+ return self._finish(project, workflow_name, submit, wait)
126
+
127
+ def list_workflows(self, project: str, job_type: str | None = None) -> list:
128
+ """List campaigns in a project, grouped by job type."""
129
+ params = {"job_type": job_type} if job_type else None
130
+ resp = self._http.request(
131
+ "GET", f"/projects/{project}/workflows", params=params
132
+ )
133
+ return resp.get("workflows", [])
134
+
135
+ def get_global_shap(self, project: str, workflow_name: str) -> pd.DataFrame:
136
+ """Global feature importance for the latest completed round, as signed
137
+ contributions in [-1, 1] (sign = direction, magnitude = strength). Returns a
138
+ DataFrame indexed by feature, one column per target. Empty for a round with no
139
+ model yet (a pure initialize)."""
140
+ resp = self._http.request(
141
+ "GET", f"/projects/{project}/workflows/{workflow_name}/global-shap"
142
+ )
143
+ return pd.DataFrame(resp.get("globalShap") or {})
144
+
145
+ def get_workflow_results(
146
+ self,
147
+ project: str,
148
+ workflow_name: str,
149
+ include_all: bool = False,
150
+ include_shap: bool = False,
151
+ ):
152
+ """The single poller + recovery call. Returns the latest round's DataFrame if it
153
+ has finished, else a status dict `{"status": ..., "round": ...}`.
154
+
155
+ include_all=True returns EVERY candidate (priority 0/-1/1) with predictions, so you
156
+ can pick or update an experiment outside the suggested batch. Off by default because
157
+ it can be large for big design spaces.
158
+ include_shap=True keeps the per-row SHAP columns (per-experiment feature
159
+ attributions), dropped by default. For aggregate importance use get_global_shap."""
160
+ raw = self._fetch_workflow_result(
161
+ project, workflow_name, include_all, include_shap
162
+ )
163
+ if raw.get("status") == "finished":
164
+ return pd.DataFrame(raw.get("experiments") or [])
165
+ return {"status": raw.get("status"), "round": raw.get("round")}
166
+
167
+ # ── internals ────────────────────────────────────────────────────────────
168
+ def _fetch_workflow_result(
169
+ self,
170
+ project: str,
171
+ workflow_name: str,
172
+ include_all: bool = False,
173
+ include_shap: bool = False,
174
+ ) -> dict:
175
+ params = {}
176
+ if include_all:
177
+ params["include_all"] = "true"
178
+ if include_shap:
179
+ params["include_shap"] = "true"
180
+ return self._http.request(
181
+ "GET",
182
+ f"/projects/{project}/workflows/{workflow_name}/results",
183
+ params=params or None,
184
+ )
185
+
186
+ def _finish(self, project: str, workflow_name: str, submit: dict, wait: bool):
187
+ if not wait:
188
+ return submit
189
+ return self._poll_workflow(project, workflow_name)
190
+
191
+ def _poll_workflow(self, project: str, workflow_name: str) -> pd.DataFrame:
192
+ start = time.monotonic()
193
+ while True:
194
+ raw = self._fetch_workflow_result(project, workflow_name)
195
+ status = raw.get("status")
196
+ if status == "finished":
197
+ return pd.DataFrame(raw.get("experiments") or [])
198
+ if status == "failed":
199
+ raise JobFailedError(
200
+ f"The latest round of '{workflow_name}' finished in a failed state."
201
+ )
202
+ elapsed = time.monotonic() - start
203
+ if elapsed > self._poll_timeout:
204
+ raise JobTimeoutError(
205
+ f"'{workflow_name}' did not finish within "
206
+ f"{self._poll_timeout:.0f}s."
207
+ )
208
+ delay = (
209
+ self._poll_interval
210
+ if self._poll_interval is not None
211
+ else _staged_poll_delay(elapsed)
212
+ )
213
+ time.sleep(delay)
@@ -0,0 +1,59 @@
1
+ """The top-level Chemetrian client.
2
+
3
+ Multi-service by design: each service hangs off the client (`client.bayesian_optimization`),
4
+ so new services slot in later without changing existing code. Cross-service resources
5
+ (projects) live directly on the client.
6
+ """
7
+
8
+ import os
9
+
10
+ from ._http import HttpClient
11
+ from .bayesian_optimization import BayesianOptimizationService
12
+ from .exceptions import ValidationError
13
+
14
+
15
+ class Chemetrian:
16
+ def __init__(
17
+ self,
18
+ api_key: str | None = None,
19
+ base_url: str | None = None,
20
+ timeout: float = 30.0,
21
+ poll_interval: float | None = None,
22
+ poll_timeout: float = 1800.0,
23
+ ):
24
+ """
25
+ Args:
26
+ api_key: your `sk_live_…` key. Falls back to the CHEMETRIAN_API_KEY env var.
27
+ base_url: API host. Defaults to production; pass a QA host or set
28
+ CHEMETRIAN_API_URL to point elsewhere.
29
+ timeout: per-request timeout (seconds).
30
+ poll_interval: seconds between polls while waiting for a job. Default (None)
31
+ uses a staged backoff (1s early → up to 3min for long jobs); pass a number
32
+ for a fixed interval.
33
+ poll_timeout: give up waiting after this many seconds.
34
+ """
35
+ api_key = api_key or os.environ.get("CHEMETRIAN_API_KEY")
36
+ if not api_key:
37
+ raise ValidationError(
38
+ "An API key is required — pass api_key=… or set CHEMETRIAN_API_KEY."
39
+ )
40
+ if not api_key.startswith("sk_"):
41
+ raise ValidationError(
42
+ "API key looks malformed (expected it to start with 'sk_')."
43
+ )
44
+
45
+ self._http = HttpClient(api_key, base_url=base_url, timeout=timeout)
46
+
47
+ # Services
48
+ self.bayesian_optimization = BayesianOptimizationService(
49
+ self._http, poll_interval=poll_interval, poll_timeout=poll_timeout
50
+ )
51
+
52
+ # ── projects (cross-service) ─────────────────────────────────────────────
53
+ def list_projects(self) -> list:
54
+ return self._http.request("GET", "/projects").get("projects", [])
55
+
56
+ def create_project(self, name: str, description: str = "") -> dict:
57
+ return self._http.request(
58
+ "POST", "/projects", json={"name": name, "description": description}
59
+ )
@@ -0,0 +1,52 @@
1
+ """Exception hierarchy for the Chemetrian SDK.
2
+
3
+ All SDK errors derive from ChemetrianError, so callers can catch broadly or narrowly.
4
+ """
5
+
6
+
7
+ class ChemetrianError(Exception):
8
+ """Base class for all SDK errors."""
9
+
10
+
11
+ class ValidationError(ChemetrianError):
12
+ """A request failed client-side validation before it was ever sent."""
13
+
14
+
15
+ class APIError(ChemetrianError):
16
+ """The API returned an error response."""
17
+
18
+ def __init__(self, message: str, status_code: int | None = None):
19
+ super().__init__(message)
20
+ self.status_code = status_code
21
+
22
+
23
+ class AuthError(APIError):
24
+ """401 — missing / invalid / expired / revoked API key."""
25
+
26
+
27
+ class InsufficientCreditsError(APIError):
28
+ """402 — the owner account is out of credits."""
29
+
30
+
31
+ class NotFoundError(APIError):
32
+ """404 — project / workflow / job not found in this workspace."""
33
+
34
+
35
+ class ConflictError(APIError):
36
+ """409 — already exists (e.g. a campaign name is taken)."""
37
+
38
+
39
+ class RateLimitError(APIError):
40
+ """429 — rate limit or concurrency cap exceeded."""
41
+
42
+ def __init__(self, message: str, retry_after: float | None = None):
43
+ super().__init__(message, status_code=429)
44
+ self.retry_after = retry_after
45
+
46
+
47
+ class JobFailedError(ChemetrianError):
48
+ """A submitted optimization job finished in a failed state."""
49
+
50
+
51
+ class JobTimeoutError(ChemetrianError):
52
+ """A job did not finish within the client's poll timeout."""