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.
- o41_metrics_api/__init__.py +44 -0
- o41_metrics_api/_auth.py +87 -0
- o41_metrics_api/_transport.py +302 -0
- o41_metrics_api/client.py +677 -0
- o41_metrics_api/errors.py +43 -0
- o41_metrics_api/models.py +142 -0
- o41_metrics_api/py.typed +1 -0
- o41_metrics_api-0.1.0.dist-info/METADATA +134 -0
- o41_metrics_api-0.1.0.dist-info/RECORD +11 -0
- o41_metrics_api-0.1.0.dist-info/WHEEL +4 -0
- o41_metrics_api-0.1.0.dist-info/licenses/LICENSE +176 -0
|
@@ -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
|
+
]
|
o41_metrics_api/_auth.py
ADDED
|
@@ -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")
|