o41-metrics-api 0.1.0__py3-none-any.whl

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,44 @@
1
+ """Python client for the 041 Metrics v2 API.
2
+
3
+ Use :class:`Metrics` to find and organize folders and experiments, read one
4
+ experiment's record, catalog, annotations, and series, and send any v2 org
5
+ event. For analysis across many experiments use ``o41-metrics-duckdb``; to
6
+ record training and evaluation runs use ``o41-metrics-collector``.
7
+ """
8
+
9
+ from o41_metrics_api.client import Experiment, ExperimentRun, Folder, Metrics
10
+ from o41_metrics_api.errors import (
11
+ MetricsAuthError,
12
+ MetricsError,
13
+ MetricsNotFound,
14
+ )
15
+ from o41_metrics_api.models import (
16
+ Annotation,
17
+ AuthOrganization,
18
+ AuthStatus,
19
+ CatalogSeries,
20
+ ExperimentError,
21
+ ExperimentInfo,
22
+ ExperimentStatus,
23
+ MetricPoint,
24
+ Series,
25
+ )
26
+
27
+ __all__ = [
28
+ "Annotation",
29
+ "AuthOrganization",
30
+ "AuthStatus",
31
+ "CatalogSeries",
32
+ "Experiment",
33
+ "ExperimentError",
34
+ "ExperimentInfo",
35
+ "ExperimentRun",
36
+ "ExperimentStatus",
37
+ "Folder",
38
+ "MetricPoint",
39
+ "Metrics",
40
+ "MetricsAuthError",
41
+ "MetricsError",
42
+ "MetricsNotFound",
43
+ "Series",
44
+ ]
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from urllib.parse import urlparse
8
+
9
+ from o41_metrics_api.errors import MetricsAuthError
10
+
11
+ DEFAULT_HOST = "https://metrics.041.io"
12
+ API_KEY_ENV = "O41_METRICS_API_KEY"
13
+ HOST_ENV = "O41_METRICS_HOST"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Credentials:
18
+ api_key: str
19
+ host: str
20
+
21
+
22
+ def _env(name: str) -> str:
23
+ """Read an ``O41_METRICS_`` variable, else its pre-rename ``SYVAIN_METRICS_``
24
+ name."""
25
+ legacy = name.replace("O41_METRICS_", "SYVAIN_METRICS_", 1)
26
+ return os.environ.get(name, "").strip() or os.environ.get(legacy, "").strip()
27
+
28
+
29
+ def cli_auth_path() -> Path:
30
+ """Return the file written by ``o41-metrics auth login``, else the one the
31
+ older ``syvain-metrics`` CLI wrote."""
32
+ config_home = os.environ.get("XDG_CONFIG_HOME")
33
+ root = Path(config_home).expanduser() if config_home else Path.home() / ".config"
34
+ path = root / "o41-metrics" / "auth.json"
35
+ legacy = root / "syvain-metrics" / "auth.json"
36
+ return legacy if not path.exists() and legacy.exists() else path
37
+
38
+
39
+ def normalize_host(value: str) -> str:
40
+ """Return a plain HTTP(S) origin with no path, query, or fragment."""
41
+ parsed = urlparse(value.strip())
42
+ if (
43
+ parsed.scheme not in {"http", "https"}
44
+ or parsed.netloc == ""
45
+ or parsed.username is not None
46
+ or parsed.password is not None
47
+ or parsed.path not in {"", "/"}
48
+ or parsed.params
49
+ or parsed.query
50
+ or parsed.fragment
51
+ ):
52
+ raise MetricsAuthError(
53
+ f"Metrics host must be a plain HTTP(S) origin: {value!r}"
54
+ )
55
+ return f"{parsed.scheme}://{parsed.netloc}"
56
+
57
+
58
+ def _read_cli_login() -> tuple[str, str]:
59
+ path = cli_auth_path()
60
+ try:
61
+ raw = json.loads(path.read_text(encoding="utf-8"))
62
+ except FileNotFoundError as error:
63
+ raise MetricsAuthError(
64
+ f"No Metrics API key: pass api_key, set {API_KEY_ENV}, "
65
+ "or run `o41-metrics auth login`."
66
+ ) from error
67
+ except json.JSONDecodeError as error:
68
+ raise MetricsAuthError(
69
+ f"Metrics CLI login is not valid JSON: {path}"
70
+ ) from error
71
+ api_key = raw.get("apiKey") if isinstance(raw, dict) else None
72
+ if not isinstance(api_key, str) or not api_key:
73
+ raise MetricsAuthError(f"Metrics CLI login has no apiKey: {path}")
74
+ host = raw.get("host")
75
+ return api_key, host if isinstance(host, str) else ""
76
+
77
+
78
+ def resolve_credentials(api_key: str | None, host: str | None) -> Credentials:
79
+ """Resolve like the CLI: argument, then environment, then the saved login."""
80
+ key = (api_key or "").strip() or _env(API_KEY_ENV)
81
+ login_host = ""
82
+ if not key:
83
+ key, login_host = _read_cli_login()
84
+ chosen_host = (
85
+ (host or "").strip() or _env(HOST_ENV) or login_host.strip() or DEFAULT_HOST
86
+ )
87
+ return Credentials(api_key=key, host=normalize_host(chosen_host))
@@ -0,0 +1,302 @@
1
+ """HTTP transport for the v2 event API: envelopes, replies, streams, retries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from collections.abc import Callable, Iterable, Iterator, Mapping
8
+ from importlib.metadata import PackageNotFoundError, version as package_version
9
+ from typing import Protocol, cast
10
+ from uuid import uuid4
11
+
12
+ import niquests
13
+ from pydantic import JsonValue
14
+
15
+ from o41_metrics_api._auth import Credentials
16
+ from o41_metrics_api.errors import MetricsError
17
+
18
+ type JsonObject = dict[str, JsonValue]
19
+
20
+ CLIENT_NAME = "o41-metrics-api"
21
+ RETRY_ATTEMPTS = 4
22
+ RETRY_BACKOFF_SECONDS = 0.5
23
+
24
+
25
+ def _package_version() -> str:
26
+ try:
27
+ return package_version(CLIENT_NAME)
28
+ except PackageNotFoundError:
29
+ return "0+unknown"
30
+
31
+
32
+ CLIENT_VERSION = _package_version()
33
+
34
+
35
+ class HttpResponse(Protocol):
36
+ @property
37
+ def status_code(self) -> int | None: ...
38
+
39
+ def json(self) -> object: ...
40
+
41
+ def iter_lines(self) -> Iterable[bytes | str]: ...
42
+
43
+ def close(self) -> None: ...
44
+
45
+
46
+ type HttpRequest = Callable[..., HttpResponse]
47
+
48
+
49
+ def envelope(event: str, data: Mapping[str, JsonValue]) -> JsonObject:
50
+ return {"messageId": str(uuid4()), "event": event, "data": dict(data)}
51
+
52
+
53
+ def invalid_response(message: str) -> MetricsError:
54
+ return MetricsError("invalid_response", message)
55
+
56
+
57
+ def reply_error(reply: JsonObject) -> MetricsError | None:
58
+ """Turns a ``syvain.v2.error`` reply into an exception, otherwise None."""
59
+ if reply.get("event") != "syvain.v2.error":
60
+ return None
61
+ data = reply.get("data")
62
+ if not isinstance(data, dict):
63
+ return invalid_response("Error reply has no data.")
64
+ status = data.get("status")
65
+ details = data.get("details")
66
+ return MetricsError(
67
+ str(data.get("code", "error")),
68
+ str(data.get("message", "")),
69
+ status=status if isinstance(status, int) else None,
70
+ retryable=data.get("retryable") is True,
71
+ details=details if isinstance(details, dict) else None,
72
+ )
73
+
74
+
75
+ def reply_data(reply: JsonObject, expected: str) -> JsonObject:
76
+ """Returns the data of an expected reply or raises its error."""
77
+ error = reply_error(reply)
78
+ if error is not None:
79
+ raise error
80
+ if reply.get("event") != expected:
81
+ raise invalid_response(f"Expected {expected}, got {reply.get('event')}.")
82
+ data = reply.get("data")
83
+ if not isinstance(data, dict):
84
+ raise invalid_response(f"{expected} reply has no data object.")
85
+ return data
86
+
87
+
88
+ def with_retry[T](operation: Callable[[], T]) -> T:
89
+ """Runs an operation that is safe to repeat, retrying retryable failures."""
90
+ for attempt in range(RETRY_ATTEMPTS):
91
+ try:
92
+ return operation()
93
+ except MetricsError as error:
94
+ if not error.retryable or attempt == RETRY_ATTEMPTS - 1:
95
+ raise
96
+ time.sleep(RETRY_BACKOFF_SECONDS * 2**attempt)
97
+ raise AssertionError("unreachable")
98
+
99
+
100
+ def _json_body(response: HttpResponse, what: str) -> object:
101
+ """Parses a 2xx body; a garbled body is treated as a transient failure."""
102
+ try:
103
+ return response.json()
104
+ except ValueError as error:
105
+ raise MetricsError(
106
+ "transport_error",
107
+ f"{what} returned malformed JSON: {error}",
108
+ retryable=True,
109
+ ) from error
110
+
111
+
112
+ class Transport:
113
+ def __init__(
114
+ self,
115
+ credentials: Credentials,
116
+ *,
117
+ timeout: float,
118
+ request: HttpRequest | None,
119
+ ) -> None:
120
+ self.api_key = credentials.api_key
121
+ self.host = credentials.host
122
+ self.timeout = timeout
123
+ self._session: niquests.Session | None = None
124
+ if request is None:
125
+ self._session = niquests.Session()
126
+ self._request = cast(HttpRequest, self._session.request)
127
+ else:
128
+ self._request = request
129
+
130
+ def close(self) -> None:
131
+ if self._session is not None:
132
+ self._session.close()
133
+ self._session = None
134
+
135
+ def _send(
136
+ self,
137
+ method: str,
138
+ path: str,
139
+ *,
140
+ bearer: str | None,
141
+ body: object = None,
142
+ accept: str = "application/json",
143
+ stream: bool = False,
144
+ ) -> HttpResponse:
145
+ headers = {
146
+ "authorization": f"Bearer {bearer or self.api_key}",
147
+ "accept": accept,
148
+ "user-agent": f"{CLIENT_NAME}/{CLIENT_VERSION}",
149
+ "x-syvain-client": CLIENT_NAME,
150
+ "x-syvain-client-version": CLIENT_VERSION,
151
+ }
152
+ if body is not None:
153
+ headers["content-type"] = "application/json"
154
+ try:
155
+ response = self._request(
156
+ method,
157
+ f"{self.host}{path}",
158
+ headers=headers,
159
+ data=None if body is None else json.dumps(body, allow_nan=False),
160
+ timeout=self.timeout,
161
+ stream=stream,
162
+ )
163
+ except niquests.exceptions.RequestException as error:
164
+ raise MetricsError(
165
+ "transport_error", f"{method} {path}: {error}", retryable=True
166
+ ) from error
167
+ status = response.status_code or 0
168
+ if 200 <= status < 300:
169
+ return response
170
+ try:
171
+ payload = response.json()
172
+ except ValueError:
173
+ payload = None
174
+ finally:
175
+ response.close()
176
+ message = "request failed"
177
+ if isinstance(payload, dict) and isinstance(payload.get("error"), dict):
178
+ error_body = payload["error"]
179
+ message = str(error_body.get("message", message))
180
+ if error_body.get("details"):
181
+ message = f"{message}: {error_body['details']}"
182
+ raise MetricsError(
183
+ "http_error",
184
+ f"{method} {path} returned HTTP {status}: {message}",
185
+ status=status,
186
+ retryable=status == 429 or status >= 500,
187
+ )
188
+
189
+ def get(self, path: str) -> JsonValue:
190
+ """GETs a plain REST route and unwraps its ``{data, error}`` body."""
191
+ payload = _json_body(self._send("GET", path, bearer=None), f"GET {path}")
192
+ if not isinstance(payload, dict) or "data" not in payload:
193
+ raise invalid_response(f"GET {path} returned no data envelope.")
194
+ return cast(JsonValue, payload["data"])
195
+
196
+ def post_events(
197
+ self, path: str, events: list[JsonObject], *, bearer: str | None = None
198
+ ) -> list[JsonObject]:
199
+ """POSTs a batch of envelopes; replies come back in request order."""
200
+ response = self._send("POST", path, bearer=bearer, body=events)
201
+ replies = _json_body(response, f"POST {path}")
202
+ if not isinstance(replies, list) or len(replies) != len(events):
203
+ raise invalid_response(f"POST {path} returned the wrong number of replies.")
204
+ for event, reply in zip(events, replies, strict=True):
205
+ if (
206
+ not isinstance(reply, dict)
207
+ or reply.get("correlationId") != event["messageId"]
208
+ ):
209
+ raise invalid_response(f"POST {path} returned an uncorrelated reply.")
210
+ return cast(list[JsonObject], replies)
211
+
212
+ def _stream_replies(self, request: JsonObject) -> Iterator[JsonObject]:
213
+ """Yields the correlated NDJSON replies of one series query stream.
214
+
215
+ Raises a retryable ``transport_error`` when the stream ends; callers
216
+ return once they see their final record.
217
+ """
218
+ response = self._send(
219
+ "POST",
220
+ "/api/v2/query/series",
221
+ bearer=None,
222
+ body=request,
223
+ accept="application/x-ndjson",
224
+ stream=True,
225
+ )
226
+ try:
227
+ for raw_line in response.iter_lines():
228
+ line = raw_line.decode() if isinstance(raw_line, bytes) else raw_line
229
+ if not line.strip():
230
+ continue
231
+ try:
232
+ reply = json.loads(line)
233
+ except ValueError as error:
234
+ raise MetricsError(
235
+ "transport_error",
236
+ f"Series stream returned a malformed record: {error}",
237
+ retryable=True,
238
+ ) from error
239
+ if (
240
+ not isinstance(reply, dict)
241
+ or reply.get("correlationId") != request["messageId"]
242
+ ):
243
+ raise invalid_response(
244
+ "Series stream returned an uncorrelated record."
245
+ )
246
+ yield cast(JsonObject, reply)
247
+ except niquests.exceptions.RequestException as error:
248
+ raise MetricsError(
249
+ "transport_error", f"Series stream failed: {error}", retryable=True
250
+ ) from error
251
+ finally:
252
+ response.close()
253
+ raise MetricsError(
254
+ "transport_error", "Series stream ended before completion.", retryable=True
255
+ )
256
+
257
+ def stream_experiment_series(self, data: JsonObject) -> dict[str, list[JsonObject]]:
258
+ """Reads many series of one experiment; returns data records by series name.
259
+
260
+ Every series the server reports is present, possibly with no records.
261
+ Partial data is never returned: an error record, EOF before ``done``, or
262
+ counts that disagree with the records raise.
263
+ """
264
+ request = envelope("syvain.v2.query.experimentseries", data)
265
+ records: dict[str, list[JsonObject]] = {}
266
+ points: dict[str, int] = {}
267
+ finished: set[str] = set()
268
+ for reply in self._stream_replies(request):
269
+ event = reply.get("event")
270
+ if event == "syvain.v2.query.experimentseries.done":
271
+ done = reply_data(reply, "syvain.v2.query.experimentseries.done")
272
+ if (
273
+ done.get("series") != len(finished)
274
+ or finished != set(records)
275
+ or done.get("batches") != sum(map(len, records.values()))
276
+ or done.get("points") != sum(points.values())
277
+ ):
278
+ raise invalid_response(
279
+ "Series stream counts do not match its data."
280
+ )
281
+ return records
282
+ if event == "syvain.v2.query.experimentseries.series":
283
+ summary = reply_data(reply, "syvain.v2.query.experimentseries.series")
284
+ name = str(summary["seriesName"])
285
+ if (
286
+ name in finished
287
+ or summary.get("batches") != len(records.setdefault(name, []))
288
+ or summary.get("points") != points.get(name, 0)
289
+ ):
290
+ raise invalid_response(
291
+ f"Series stream counts for {name!r} do not match its data."
292
+ )
293
+ finished.add(name)
294
+ continue
295
+ batch = reply_data(reply, "syvain.v2.query.series.data")
296
+ name = str(batch["seriesName"])
297
+ if name in finished:
298
+ raise invalid_response(f"Series {name!r} has data after its summary.")
299
+ values = cast(dict[str, list[JsonValue]], batch["data"])["value"]
300
+ points[name] = points.get(name, 0) + len(values)
301
+ records.setdefault(name, []).append(batch)
302
+ raise AssertionError("unreachable")