juniper-data-client 0.4.0__tar.gz → 0.4.1__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.
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/PKG-INFO +4 -1
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client/__init__.py +5 -2
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client/client.py +269 -86
- juniper_data_client-0.4.1/juniper_data_client/constants.py +217 -0
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client/testing/fake_client.py +175 -68
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client/testing/generators.py +54 -26
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client.egg-info/PKG-INFO +4 -1
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client.egg-info/SOURCES.txt +9 -1
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client.egg-info/requires.txt +4 -0
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/pyproject.toml +14 -1
- juniper_data_client-0.4.1/tests/test_create_dataset_tags_ttl.py +140 -0
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/tests/test_fake_client.py +16 -7
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/tests/test_fake_client_batch.py +3 -3
- juniper_data_client-0.4.1/tests/test_generator_parity.py +135 -0
- juniper_data_client-0.4.1/tests/test_malformed_json_response.py +72 -0
- juniper_data_client-0.4.1/tests/test_npzfile_resource_lifecycle.py +138 -0
- juniper_data_client-0.4.1/tests/test_on_request_hook.py +210 -0
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/tests/test_performance.py +7 -7
- juniper_data_client-0.4.1/tests/test_retry_policy.py +54 -0
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/tests/test_versioning.py +2 -2
- juniper_data_client-0.4.1/tests/test_x_request_id_propagation.py +119 -0
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/LICENSE +0 -0
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/README.md +0 -0
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client/exceptions.py +0 -0
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client/py.typed +0 -0
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client/testing/__init__.py +0 -0
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client.egg-info/dependency_links.txt +0 -0
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client.egg-info/top_level.txt +0 -0
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/setup.cfg +0 -0
- {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/tests/test_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: juniper-data-client
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: HTTP client for the JuniperData dataset generation service
|
|
5
5
|
Author: Paul Calnon
|
|
6
6
|
License-Expression: MIT
|
|
@@ -31,6 +31,9 @@ Requires-Dist: pytest>=7.0.0; extra == "test"
|
|
|
31
31
|
Requires-Dist: pytest-cov>=4.0.0; extra == "test"
|
|
32
32
|
Requires-Dist: pytest-timeout>=2.2.0; extra == "test"
|
|
33
33
|
Requires-Dist: responses>=0.23.0; extra == "test"
|
|
34
|
+
Requires-Dist: juniper-observability>=0.1.1; extra == "test"
|
|
35
|
+
Provides-Extra: observability
|
|
36
|
+
Requires-Dist: juniper-observability>=0.1.1; extra == "observability"
|
|
34
37
|
Provides-Extra: dev
|
|
35
38
|
Requires-Dist: juniper-data-client[test]; extra == "dev"
|
|
36
39
|
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
@@ -4,10 +4,10 @@ This package provides a simple, robust client for interacting with the JuniperDa
|
|
|
4
4
|
dataset generation service, used by both JuniperCascor and juniper-canopy.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from juniper_data_client.client import JuniperDataClient
|
|
7
|
+
from juniper_data_client.client import JuniperDataClient, RequestHook
|
|
8
8
|
from juniper_data_client.exceptions import JuniperDataClientError, JuniperDataConfigurationError, JuniperDataConnectionError, JuniperDataNotFoundError, JuniperDataTimeoutError, JuniperDataValidationError
|
|
9
9
|
|
|
10
|
-
__version__ = "0.4.
|
|
10
|
+
__version__ = "0.4.1"
|
|
11
11
|
|
|
12
12
|
__all__ = [
|
|
13
13
|
"JuniperDataClient",
|
|
@@ -17,5 +17,8 @@ __all__ = [
|
|
|
17
17
|
"JuniperDataNotFoundError",
|
|
18
18
|
"JuniperDataTimeoutError",
|
|
19
19
|
"JuniperDataValidationError",
|
|
20
|
+
# METRICS-MON R4.3 / seed-13: instrumentation hook type alias
|
|
21
|
+
# exported so consumers can type their hook closures.
|
|
22
|
+
"RequestHook",
|
|
20
23
|
"__version__",
|
|
21
24
|
]
|
|
@@ -5,9 +5,10 @@ for JuniperCascor and juniper-canopy applications.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import io
|
|
8
|
+
import logging
|
|
8
9
|
import os
|
|
9
10
|
import time
|
|
10
|
-
from typing import Any, Dict, List, Optional
|
|
11
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
11
12
|
from urllib.parse import urlparse
|
|
12
13
|
|
|
13
14
|
import numpy as np
|
|
@@ -15,8 +16,89 @@ import requests
|
|
|
15
16
|
from requests.adapters import HTTPAdapter
|
|
16
17
|
from urllib3.util.retry import Retry
|
|
17
18
|
|
|
19
|
+
from juniper_data_client.constants import (
|
|
20
|
+
API_KEY_ENV_VAR,
|
|
21
|
+
API_KEY_HEADER_NAME,
|
|
22
|
+
API_VERSION_PATH_SUFFIX,
|
|
23
|
+
DEFAULT_BACKOFF_FACTOR,
|
|
24
|
+
DEFAULT_BASE_URL,
|
|
25
|
+
DEFAULT_LIST_LIMIT,
|
|
26
|
+
DEFAULT_LIST_OFFSET,
|
|
27
|
+
DEFAULT_PREVIEW_N,
|
|
28
|
+
DEFAULT_READY_POLL_INTERVAL,
|
|
29
|
+
DEFAULT_READY_TIMEOUT,
|
|
30
|
+
DEFAULT_RETRIES,
|
|
31
|
+
DEFAULT_TIMEOUT,
|
|
32
|
+
DEFAULT_URL_SCHEME_PREFIX,
|
|
33
|
+
ENDPOINT_BATCH_CREATE,
|
|
34
|
+
ENDPOINT_BATCH_DELETE,
|
|
35
|
+
ENDPOINT_BATCH_EXPORT,
|
|
36
|
+
ENDPOINT_BATCH_TAGS,
|
|
37
|
+
ENDPOINT_DATASET_ARTIFACT_TEMPLATE,
|
|
38
|
+
ENDPOINT_DATASET_BY_ID_TEMPLATE,
|
|
39
|
+
ENDPOINT_DATASET_PREVIEW_TEMPLATE,
|
|
40
|
+
ENDPOINT_DATASETS,
|
|
41
|
+
ENDPOINT_DATASETS_LATEST,
|
|
42
|
+
ENDPOINT_DATASETS_VERSIONS,
|
|
43
|
+
ENDPOINT_GENERATOR_SCHEMA_TEMPLATE,
|
|
44
|
+
ENDPOINT_GENERATORS,
|
|
45
|
+
ENDPOINT_HEALTH,
|
|
46
|
+
ENDPOINT_HEALTH_READY,
|
|
47
|
+
GENERATOR_SPIRAL,
|
|
48
|
+
HEALTH_READY_STATUS,
|
|
49
|
+
HTTP_400_BAD_REQUEST,
|
|
50
|
+
HTTP_404_NOT_FOUND,
|
|
51
|
+
HTTP_422_UNPROCESSABLE_ENTITY,
|
|
52
|
+
HTTP_POOL_CONNECTIONS,
|
|
53
|
+
HTTP_POOL_MAXSIZE,
|
|
54
|
+
RETRY_ALLOWED_METHODS,
|
|
55
|
+
RETRYABLE_STATUS_CODES,
|
|
56
|
+
SPIRAL_ALGORITHM_DEFAULT,
|
|
57
|
+
SPIRAL_N_POINTS_PER_SPIRAL_DEFAULT,
|
|
58
|
+
SPIRAL_N_SPIRALS_DEFAULT,
|
|
59
|
+
SPIRAL_NOISE_DEFAULT,
|
|
60
|
+
SPIRAL_TRAIN_RATIO_DEFAULT,
|
|
61
|
+
URL_SCHEME_PREFIXES,
|
|
62
|
+
)
|
|
18
63
|
from juniper_data_client.exceptions import JuniperDataClientError, JuniperDataConnectionError, JuniperDataNotFoundError, JuniperDataTimeoutError, JuniperDataValidationError
|
|
19
64
|
|
|
65
|
+
logger = logging.getLogger("juniper_data_client.client")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# METRICS-MON R4.3 / seed-13: type alias for the optional instrumentation
|
|
69
|
+
# hook. Consumers (canopy, cascor) pass a callable matching this shape;
|
|
70
|
+
# default in :class:`JuniperDataClient` is no-op so standalone users
|
|
71
|
+
# (notebooks, ad-hoc scripts) pay nothing for an unused hook.
|
|
72
|
+
#
|
|
73
|
+
# Fields:
|
|
74
|
+
# method — HTTP method ("GET", "POST", ...).
|
|
75
|
+
# url — absolute URL the request hit (post-base_url-join).
|
|
76
|
+
# status — final HTTP status code, or None if no response was
|
|
77
|
+
# received (transport failure / timeout).
|
|
78
|
+
# duration_ms— wall-clock from issue to outcome, milliseconds.
|
|
79
|
+
# error — exception instance on failure paths, or None on success.
|
|
80
|
+
# ``error is None`` is the canonical success signal —
|
|
81
|
+
# ``status`` may be set even on the error path (for
|
|
82
|
+
# JuniperDataValidationError, JuniperDataNotFoundError,
|
|
83
|
+
# etc.) so it's not a reliable success indicator.
|
|
84
|
+
RequestHook = Callable[[str, str, Optional[int], float, Optional[BaseException]], None]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _noop_request_hook(
|
|
88
|
+
method: str,
|
|
89
|
+
url: str,
|
|
90
|
+
status: Optional[int],
|
|
91
|
+
duration_ms: float,
|
|
92
|
+
error: Optional[BaseException],
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Default :data:`RequestHook` — does nothing.
|
|
95
|
+
|
|
96
|
+
Used when the consumer doesn't pass an ``on_request`` kwarg. The
|
|
97
|
+
no-op exists as a named symbol so test code (and IDE introspection)
|
|
98
|
+
can confirm the default is genuinely a callable that matches the
|
|
99
|
+
:data:`RequestHook` signature, not ``None``.
|
|
100
|
+
"""
|
|
101
|
+
|
|
20
102
|
|
|
21
103
|
class JuniperDataClient:
|
|
22
104
|
"""Client for interacting with the JuniperData REST API.
|
|
@@ -31,17 +113,14 @@ class JuniperDataClient:
|
|
|
31
113
|
>>> X_train, y_train = arrays["X_train"], arrays["y_train"]
|
|
32
114
|
"""
|
|
33
115
|
|
|
34
|
-
DEFAULT_TIMEOUT = 30
|
|
35
|
-
DEFAULT_RETRIES = 3
|
|
36
|
-
DEFAULT_BACKOFF_FACTOR = 0.5
|
|
37
|
-
|
|
38
116
|
def __init__(
|
|
39
117
|
self,
|
|
40
|
-
base_url: str =
|
|
118
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
41
119
|
timeout: int = DEFAULT_TIMEOUT,
|
|
42
120
|
retries: int = DEFAULT_RETRIES,
|
|
43
121
|
backoff_factor: float = DEFAULT_BACKOFF_FACTOR,
|
|
44
122
|
api_key: Optional[str] = None,
|
|
123
|
+
on_request: Optional[RequestHook] = None,
|
|
45
124
|
):
|
|
46
125
|
"""Initialize the JuniperData client.
|
|
47
126
|
|
|
@@ -52,16 +131,29 @@ class JuniperDataClient:
|
|
|
52
131
|
backoff_factor: Backoff factor for retry delays (default: 0.5)
|
|
53
132
|
api_key: API key for authentication. If not provided, reads from
|
|
54
133
|
JUNIPER_DATA_API_KEY environment variable.
|
|
134
|
+
on_request: METRICS-MON R4.3 / seed-13 — optional
|
|
135
|
+
instrumentation hook invoked once per HTTP call with
|
|
136
|
+
``(method, url, status, duration_ms, error)``. Default
|
|
137
|
+
is a no-op so standalone use pays nothing. Consumers
|
|
138
|
+
that need Prometheus / OpenTelemetry / structured-log
|
|
139
|
+
emission supply a closure (see canopy's adoption for
|
|
140
|
+
the canonical pattern). Hook exceptions are caught and
|
|
141
|
+
logged at WARNING — instrumentation must never crash a
|
|
142
|
+
production HTTP path.
|
|
55
143
|
"""
|
|
56
144
|
self.base_url = self._normalize_url(base_url)
|
|
57
145
|
self.timeout = timeout
|
|
58
146
|
self.retries = retries
|
|
59
147
|
self.backoff_factor = backoff_factor
|
|
60
148
|
self.session = self._create_session()
|
|
149
|
+
# METRICS-MON R4.3: store hook (defaulting to no-op rather than
|
|
150
|
+
# ``None`` so call sites don't need ``if self._on_request: ...``
|
|
151
|
+
# guards — the no-op call is a single attribute load + return).
|
|
152
|
+
self._on_request: RequestHook = on_request or _noop_request_hook
|
|
61
153
|
|
|
62
|
-
resolved_api_key = api_key or os.environ.get(
|
|
154
|
+
resolved_api_key = api_key or os.environ.get(API_KEY_ENV_VAR)
|
|
63
155
|
if resolved_api_key:
|
|
64
|
-
self.session.headers[
|
|
156
|
+
self.session.headers[API_KEY_HEADER_NAME] = resolved_api_key
|
|
65
157
|
|
|
66
158
|
def _normalize_url(self, url: str) -> str:
|
|
67
159
|
"""Normalize the base URL for consistent API calls.
|
|
@@ -74,15 +166,15 @@ class JuniperDataClient:
|
|
|
74
166
|
"""
|
|
75
167
|
url = url.strip()
|
|
76
168
|
|
|
77
|
-
if not url.startswith(
|
|
78
|
-
url = f"
|
|
169
|
+
if not url.startswith(URL_SCHEME_PREFIXES):
|
|
170
|
+
url = f"{DEFAULT_URL_SCHEME_PREFIX}{url}"
|
|
79
171
|
|
|
80
172
|
parsed = urlparse(url)
|
|
81
173
|
normalized = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
|
|
82
174
|
normalized = normalized.rstrip("/")
|
|
83
175
|
|
|
84
|
-
if normalized.endswith(
|
|
85
|
-
normalized = normalized[
|
|
176
|
+
if normalized.endswith(API_VERSION_PATH_SUFFIX):
|
|
177
|
+
normalized = normalized[: -len(API_VERSION_PATH_SUFFIX)]
|
|
86
178
|
|
|
87
179
|
return normalized
|
|
88
180
|
|
|
@@ -97,22 +189,22 @@ class JuniperDataClient:
|
|
|
97
189
|
retry_strategy = Retry(
|
|
98
190
|
total=self.retries,
|
|
99
191
|
backoff_factor=self.backoff_factor,
|
|
100
|
-
status_forcelist=
|
|
101
|
-
allowed_methods=
|
|
192
|
+
status_forcelist=RETRYABLE_STATUS_CODES,
|
|
193
|
+
allowed_methods=RETRY_ALLOWED_METHODS,
|
|
102
194
|
)
|
|
103
195
|
|
|
104
196
|
adapter = HTTPAdapter(
|
|
105
197
|
max_retries=retry_strategy,
|
|
106
|
-
pool_connections=
|
|
107
|
-
pool_maxsize=
|
|
198
|
+
pool_connections=HTTP_POOL_CONNECTIONS,
|
|
199
|
+
pool_maxsize=HTTP_POOL_MAXSIZE,
|
|
108
200
|
)
|
|
109
201
|
|
|
110
|
-
|
|
111
|
-
|
|
202
|
+
for scheme in URL_SCHEME_PREFIXES:
|
|
203
|
+
session.mount(scheme, adapter)
|
|
112
204
|
|
|
113
205
|
return session
|
|
114
206
|
|
|
115
|
-
def _request(self, method: str, endpoint: str, **kwargs: Any) -> requests.Response:
|
|
207
|
+
def _request(self, method: str, endpoint: str, **kwargs: Any) -> requests.Response: # noqa: C901
|
|
116
208
|
"""Make an HTTP request with error handling.
|
|
117
209
|
|
|
118
210
|
Args:
|
|
@@ -133,34 +225,102 @@ class JuniperDataClient:
|
|
|
133
225
|
url = f"{self.base_url}{endpoint}"
|
|
134
226
|
kwargs.setdefault("timeout", self.timeout)
|
|
135
227
|
|
|
228
|
+
# METRICS-MON R4.6: opt-in propagation of the caller's request-id
|
|
229
|
+
# via the shared ``juniper-observability`` ContextVar. When the
|
|
230
|
+
# calling thread has a non-empty ``request_id_var``, copy it into
|
|
231
|
+
# an outbound ``X-Request-ID`` header so juniper-data can correlate
|
|
232
|
+
# the inbound request back to the caller's chain (canopy/cascor →
|
|
233
|
+
# data-client → data). The check is best-effort: if
|
|
234
|
+
# ``juniper-observability`` is not installed (e.g. data-client
|
|
235
|
+
# used standalone in a notebook), the propagation silently no-ops
|
|
236
|
+
# — data-side ``RequestIdMiddleware`` will generate a fresh id.
|
|
237
|
+
# Caller-supplied headers always win — if ``kwargs["headers"]``
|
|
238
|
+
# already contains ``X-Request-ID``, do not overwrite it.
|
|
239
|
+
headers = dict(kwargs.get("headers") or {})
|
|
240
|
+
if "X-Request-ID" not in headers:
|
|
241
|
+
try:
|
|
242
|
+
from juniper_observability import request_id_var # noqa: PLC0415
|
|
243
|
+
|
|
244
|
+
rid = request_id_var.get()
|
|
245
|
+
if rid:
|
|
246
|
+
headers["X-Request-ID"] = rid
|
|
247
|
+
kwargs["headers"] = headers
|
|
248
|
+
except (ImportError, LookupError):
|
|
249
|
+
# ImportError: juniper-observability not installed (standalone use).
|
|
250
|
+
# LookupError: request_id_var ContextVar has no value in this thread.
|
|
251
|
+
# Both are expected; silently fall through.
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
# METRICS-MON R4.3: instrumentation hook fires once per call,
|
|
255
|
+
# in the ``finally`` block so every outcome path is observed
|
|
256
|
+
# (success, transport error, HTTP non-2xx, all five typed
|
|
257
|
+
# exception branches). ``status`` carries the HTTP code when
|
|
258
|
+
# available; ``error`` carries the raised exception (or None on
|
|
259
|
+
# success). Hook exceptions are swallowed so instrumentation
|
|
260
|
+
# never crashes a production HTTP call.
|
|
261
|
+
start = time.monotonic()
|
|
262
|
+
response: Optional[requests.Response] = None
|
|
263
|
+
outgoing_error: Optional[BaseException] = None
|
|
136
264
|
try:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if "detail" in error_json:
|
|
152
|
-
error_detail = error_json["detail"]
|
|
153
|
-
except (ValueError, KeyError):
|
|
154
|
-
# If the response body is not valid JSON or lacks a 'detail' field,
|
|
155
|
-
# fall back to using the raw response text as the error detail.
|
|
156
|
-
error_detail = response.text
|
|
265
|
+
try:
|
|
266
|
+
response = self.session.request(method, url, **kwargs)
|
|
267
|
+
except requests.exceptions.ConnectionError as e:
|
|
268
|
+
outgoing_error = JuniperDataConnectionError(f"Failed to connect to JuniperData at {self.base_url}: {e}")
|
|
269
|
+
raise outgoing_error from e
|
|
270
|
+
except requests.exceptions.Timeout as e:
|
|
271
|
+
outgoing_error = JuniperDataTimeoutError(f"Request to {url} timed out after {self.timeout}s: {e}")
|
|
272
|
+
raise outgoing_error from e
|
|
273
|
+
except requests.exceptions.RequestException as e:
|
|
274
|
+
outgoing_error = JuniperDataClientError(f"Request failed: {e}")
|
|
275
|
+
raise outgoing_error from e
|
|
276
|
+
|
|
277
|
+
if response.ok:
|
|
278
|
+
return response
|
|
157
279
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
280
|
+
error_detail = response.text
|
|
281
|
+
try:
|
|
282
|
+
error_json = response.json()
|
|
283
|
+
if "detail" in error_json:
|
|
284
|
+
error_detail = error_json["detail"]
|
|
285
|
+
except (ValueError, KeyError):
|
|
286
|
+
# If the response body is not valid JSON or lacks a 'detail' field,
|
|
287
|
+
# fall back to using the raw response text as the error detail.
|
|
288
|
+
error_detail = response.text
|
|
289
|
+
|
|
290
|
+
if response.status_code == HTTP_404_NOT_FOUND:
|
|
291
|
+
outgoing_error = JuniperDataNotFoundError(f"Resource not found: {error_detail}")
|
|
292
|
+
raise outgoing_error
|
|
293
|
+
elif response.status_code in (HTTP_400_BAD_REQUEST, HTTP_422_UNPROCESSABLE_ENTITY):
|
|
294
|
+
outgoing_error = JuniperDataValidationError(f"Validation error: {error_detail}")
|
|
295
|
+
raise outgoing_error
|
|
296
|
+
else:
|
|
297
|
+
outgoing_error = JuniperDataClientError(f"Request failed ({response.status_code}): {error_detail}")
|
|
298
|
+
raise outgoing_error
|
|
299
|
+
finally:
|
|
300
|
+
duration_ms = (time.monotonic() - start) * 1000.0
|
|
301
|
+
status = response.status_code if response is not None else None
|
|
302
|
+
try:
|
|
303
|
+
self._on_request(method, url, status, duration_ms, outgoing_error)
|
|
304
|
+
except Exception: # noqa: BLE001 — instrumentation must not crash production paths
|
|
305
|
+
logger.warning(
|
|
306
|
+
"on_request hook raised; suppressed to keep request path resilient",
|
|
307
|
+
exc_info=True,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
@staticmethod
|
|
311
|
+
def _parse_json(response: requests.Response) -> Any:
|
|
312
|
+
"""Parse a successful response body as JSON.
|
|
313
|
+
|
|
314
|
+
ERR-01 (Phase 4C): callers previously invoked ``response.json()`` directly,
|
|
315
|
+
which raises ``requests.exceptions.JSONDecodeError`` (a ``ValueError`` subclass)
|
|
316
|
+
on malformed bodies. Surface a typed ``JuniperDataClientError`` instead so the
|
|
317
|
+
public API never leaks a raw ``json.JSONDecodeError``.
|
|
318
|
+
"""
|
|
319
|
+
try:
|
|
320
|
+
return response.json()
|
|
321
|
+
except ValueError as e:
|
|
322
|
+
preview = (response.text or "")[:200]
|
|
323
|
+
raise JuniperDataClientError(f"Malformed JSON response from {response.url}: {e}: {preview!r}") from e
|
|
164
324
|
|
|
165
325
|
def health_check(self) -> Dict[str, Any]:
|
|
166
326
|
"""Check if the JuniperData service is healthy.
|
|
@@ -171,8 +331,8 @@ class JuniperDataClient:
|
|
|
171
331
|
Raises:
|
|
172
332
|
JuniperDataConnectionError: If service is unreachable
|
|
173
333
|
"""
|
|
174
|
-
response = self._request("GET",
|
|
175
|
-
return
|
|
334
|
+
response = self._request("GET", ENDPOINT_HEALTH)
|
|
335
|
+
return self._parse_json(response)
|
|
176
336
|
|
|
177
337
|
def is_ready(self) -> bool:
|
|
178
338
|
"""Check if the JuniperData service is ready to accept requests.
|
|
@@ -181,12 +341,12 @@ class JuniperDataClient:
|
|
|
181
341
|
True if service is ready, False otherwise
|
|
182
342
|
"""
|
|
183
343
|
try:
|
|
184
|
-
response = self._request("GET",
|
|
185
|
-
return
|
|
344
|
+
response = self._request("GET", ENDPOINT_HEALTH_READY)
|
|
345
|
+
return self._parse_json(response).get("status") == HEALTH_READY_STATUS
|
|
186
346
|
except JuniperDataClientError:
|
|
187
347
|
return False
|
|
188
348
|
|
|
189
|
-
def wait_for_ready(self, timeout: float =
|
|
349
|
+
def wait_for_ready(self, timeout: float = DEFAULT_READY_TIMEOUT, poll_interval: float = DEFAULT_READY_POLL_INTERVAL) -> bool:
|
|
190
350
|
"""Wait for the JuniperData service to become ready.
|
|
191
351
|
|
|
192
352
|
Args:
|
|
@@ -209,8 +369,8 @@ class JuniperDataClient:
|
|
|
209
369
|
Returns:
|
|
210
370
|
List of generator information dictionaries
|
|
211
371
|
"""
|
|
212
|
-
response = self._request("GET",
|
|
213
|
-
return
|
|
372
|
+
response = self._request("GET", ENDPOINT_GENERATORS)
|
|
373
|
+
return self._parse_json(response)
|
|
214
374
|
|
|
215
375
|
def get_generator_schema(self, name: str) -> Dict[str, Any]:
|
|
216
376
|
"""Get the parameter schema for a generator.
|
|
@@ -224,8 +384,8 @@ class JuniperDataClient:
|
|
|
224
384
|
Raises:
|
|
225
385
|
JuniperDataNotFoundError: If generator not found
|
|
226
386
|
"""
|
|
227
|
-
response = self._request("GET",
|
|
228
|
-
return
|
|
387
|
+
response = self._request("GET", ENDPOINT_GENERATOR_SCHEMA_TEMPLATE.format(name=name))
|
|
388
|
+
return self._parse_json(response)
|
|
229
389
|
|
|
230
390
|
def create_dataset(
|
|
231
391
|
self,
|
|
@@ -236,6 +396,8 @@ class JuniperDataClient:
|
|
|
236
396
|
description: Optional[str] = None,
|
|
237
397
|
created_by: Optional[str] = None,
|
|
238
398
|
parent_dataset_id: Optional[str] = None,
|
|
399
|
+
tags: Optional[List[str]] = None,
|
|
400
|
+
ttl_seconds: Optional[int] = None,
|
|
239
401
|
) -> Dict[str, Any]:
|
|
240
402
|
"""Create a new dataset via the JuniperData API.
|
|
241
403
|
|
|
@@ -251,6 +413,13 @@ class JuniperDataClient:
|
|
|
251
413
|
description: Optional human-readable description of the dataset.
|
|
252
414
|
created_by: Optional identifier for the creator (user or system).
|
|
253
415
|
parent_dataset_id: Optional ID of the parent dataset this was derived from.
|
|
416
|
+
tags: Optional list of tag strings to attach to the dataset. Forwarded
|
|
417
|
+
to the server's ``CreateDatasetRequest.tags`` field. Server-side
|
|
418
|
+
tags are searchable via ``list_datasets`` filters.
|
|
419
|
+
ttl_seconds: Optional time-to-live in seconds (must be positive when
|
|
420
|
+
provided). After the TTL elapses the server is free to expire the
|
|
421
|
+
dataset via the cleanup-expired route. Mirrors
|
|
422
|
+
``CreateDatasetRequest.ttl_seconds``.
|
|
254
423
|
|
|
255
424
|
Returns:
|
|
256
425
|
Parsed JSON response containing dataset_id, generator, meta, and artifact_url
|
|
@@ -272,9 +441,16 @@ class JuniperDataClient:
|
|
|
272
441
|
payload["created_by"] = created_by
|
|
273
442
|
if parent_dataset_id is not None:
|
|
274
443
|
payload["parent_dataset_id"] = parent_dataset_id
|
|
444
|
+
# XREPO-09 (2026-04-24): previously dropped by the client; the
|
|
445
|
+
# server's CreateDatasetRequest has accepted these fields since
|
|
446
|
+
# juniper-data v0.6.0.
|
|
447
|
+
if tags is not None:
|
|
448
|
+
payload["tags"] = list(tags)
|
|
449
|
+
if ttl_seconds is not None:
|
|
450
|
+
payload["ttl_seconds"] = ttl_seconds
|
|
275
451
|
|
|
276
|
-
response = self._request("POST",
|
|
277
|
-
return
|
|
452
|
+
response = self._request("POST", ENDPOINT_DATASETS, json=payload)
|
|
453
|
+
return self._parse_json(response)
|
|
278
454
|
|
|
279
455
|
def list_versions(self, name: str) -> Dict[str, Any]:
|
|
280
456
|
"""List all versions of a named dataset.
|
|
@@ -285,8 +461,8 @@ class JuniperDataClient:
|
|
|
285
461
|
Returns:
|
|
286
462
|
Dict with dataset_name, versions list, total count, and latest_version.
|
|
287
463
|
"""
|
|
288
|
-
response = self._request("GET",
|
|
289
|
-
return
|
|
464
|
+
response = self._request("GET", ENDPOINT_DATASETS_VERSIONS, params={"name": name})
|
|
465
|
+
return self._parse_json(response)
|
|
290
466
|
|
|
291
467
|
def get_latest(self, name: str) -> Dict[str, Any]:
|
|
292
468
|
"""Get the latest version of a named dataset.
|
|
@@ -300,10 +476,10 @@ class JuniperDataClient:
|
|
|
300
476
|
Raises:
|
|
301
477
|
JuniperDataNotFoundError: If no versions exist for the given name.
|
|
302
478
|
"""
|
|
303
|
-
response = self._request("GET",
|
|
304
|
-
return
|
|
479
|
+
response = self._request("GET", ENDPOINT_DATASETS_LATEST, params={"name": name})
|
|
480
|
+
return self._parse_json(response)
|
|
305
481
|
|
|
306
|
-
def list_datasets(self, limit: int =
|
|
482
|
+
def list_datasets(self, limit: int = DEFAULT_LIST_LIMIT, offset: int = DEFAULT_LIST_OFFSET) -> List[str]:
|
|
307
483
|
"""List dataset IDs.
|
|
308
484
|
|
|
309
485
|
Args:
|
|
@@ -313,8 +489,8 @@ class JuniperDataClient:
|
|
|
313
489
|
Returns:
|
|
314
490
|
List of dataset ID strings
|
|
315
491
|
"""
|
|
316
|
-
response = self._request("GET",
|
|
317
|
-
return
|
|
492
|
+
response = self._request("GET", ENDPOINT_DATASETS, params={"limit": limit, "offset": offset})
|
|
493
|
+
return self._parse_json(response)
|
|
318
494
|
|
|
319
495
|
def get_dataset_metadata(self, dataset_id: str) -> Dict[str, Any]:
|
|
320
496
|
"""Get metadata for a specific dataset.
|
|
@@ -328,8 +504,8 @@ class JuniperDataClient:
|
|
|
328
504
|
Raises:
|
|
329
505
|
JuniperDataNotFoundError: If dataset not found
|
|
330
506
|
"""
|
|
331
|
-
response = self._request("GET",
|
|
332
|
-
return
|
|
507
|
+
response = self._request("GET", ENDPOINT_DATASET_BY_ID_TEMPLATE.format(dataset_id=dataset_id))
|
|
508
|
+
return self._parse_json(response)
|
|
333
509
|
|
|
334
510
|
def download_artifact_bytes(self, dataset_id: str) -> bytes:
|
|
335
511
|
"""Download the raw NPZ artifact bytes for a dataset.
|
|
@@ -343,7 +519,7 @@ class JuniperDataClient:
|
|
|
343
519
|
Raises:
|
|
344
520
|
JuniperDataNotFoundError: If dataset not found
|
|
345
521
|
"""
|
|
346
|
-
response = self._request("GET",
|
|
522
|
+
response = self._request("GET", ENDPOINT_DATASET_ARTIFACT_TEMPLATE.format(dataset_id=dataset_id))
|
|
347
523
|
return response.content
|
|
348
524
|
|
|
349
525
|
def download_artifact_npz(self, dataset_id: str) -> Dict[str, np.ndarray]:
|
|
@@ -366,10 +542,17 @@ class JuniperDataClient:
|
|
|
366
542
|
JuniperDataNotFoundError: If dataset not found
|
|
367
543
|
"""
|
|
368
544
|
content = self.download_artifact_bytes(dataset_id)
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
545
|
+
# CC-07 (Phase 4E): np.load returns an NpzFile that holds an open
|
|
546
|
+
# ZipFile backed by the BytesIO. Without context-managing it the
|
|
547
|
+
# underlying file handle leaks until garbage collection runs, which
|
|
548
|
+
# in long-running clients accumulates ResourceWarning entries and
|
|
549
|
+
# can exhaust file-descriptor limits. Materialising the arrays
|
|
550
|
+
# inside the with-block lets us hand back plain numpy arrays while
|
|
551
|
+
# the NpzFile is closed deterministically.
|
|
552
|
+
with np.load(io.BytesIO(content)) as npz_file:
|
|
553
|
+
return {key: np.asarray(npz_file[key]) for key in npz_file.files}
|
|
554
|
+
|
|
555
|
+
def get_preview(self, dataset_id: str, n: int = DEFAULT_PREVIEW_N) -> Dict[str, Any]:
|
|
373
556
|
"""Get a preview of dataset samples as JSON.
|
|
374
557
|
|
|
375
558
|
Args:
|
|
@@ -382,8 +565,8 @@ class JuniperDataClient:
|
|
|
382
565
|
Raises:
|
|
383
566
|
JuniperDataNotFoundError: If dataset not found
|
|
384
567
|
"""
|
|
385
|
-
response = self._request("GET",
|
|
386
|
-
return
|
|
568
|
+
response = self._request("GET", ENDPOINT_DATASET_PREVIEW_TEMPLATE.format(dataset_id=dataset_id), params={"n": n})
|
|
569
|
+
return self._parse_json(response)
|
|
387
570
|
|
|
388
571
|
def delete_dataset(self, dataset_id: str) -> bool:
|
|
389
572
|
"""Delete a dataset.
|
|
@@ -397,17 +580,17 @@ class JuniperDataClient:
|
|
|
397
580
|
Raises:
|
|
398
581
|
JuniperDataNotFoundError: If dataset not found
|
|
399
582
|
"""
|
|
400
|
-
self._request("DELETE",
|
|
583
|
+
self._request("DELETE", ENDPOINT_DATASET_BY_ID_TEMPLATE.format(dataset_id=dataset_id))
|
|
401
584
|
return True
|
|
402
585
|
|
|
403
586
|
def create_spiral_dataset(
|
|
404
587
|
self,
|
|
405
|
-
n_spirals: int =
|
|
406
|
-
n_points_per_spiral: int =
|
|
407
|
-
noise: float =
|
|
588
|
+
n_spirals: int = SPIRAL_N_SPIRALS_DEFAULT,
|
|
589
|
+
n_points_per_spiral: int = SPIRAL_N_POINTS_PER_SPIRAL_DEFAULT,
|
|
590
|
+
noise: float = SPIRAL_NOISE_DEFAULT,
|
|
408
591
|
seed: Optional[int] = None,
|
|
409
|
-
algorithm: str =
|
|
410
|
-
train_ratio: float =
|
|
592
|
+
algorithm: str = SPIRAL_ALGORITHM_DEFAULT,
|
|
593
|
+
train_ratio: float = SPIRAL_TRAIN_RATIO_DEFAULT,
|
|
411
594
|
**kwargs: Any,
|
|
412
595
|
) -> Dict[str, Any]:
|
|
413
596
|
"""Convenience method for creating spiral datasets.
|
|
@@ -435,7 +618,7 @@ class JuniperDataClient:
|
|
|
435
618
|
params["seed"] = seed
|
|
436
619
|
params.update(kwargs)
|
|
437
620
|
|
|
438
|
-
return self.create_dataset(
|
|
621
|
+
return self.create_dataset(GENERATOR_SPIRAL, params)
|
|
439
622
|
|
|
440
623
|
# ─── Batch Operations ────────────────────────────────────────────────
|
|
441
624
|
|
|
@@ -448,8 +631,8 @@ class JuniperDataClient:
|
|
|
448
631
|
Returns:
|
|
449
632
|
Dictionary with deleted, not_found, and total_deleted.
|
|
450
633
|
"""
|
|
451
|
-
response = self._request("POST",
|
|
452
|
-
return
|
|
634
|
+
response = self._request("POST", ENDPOINT_BATCH_DELETE, json={"dataset_ids": dataset_ids})
|
|
635
|
+
return self._parse_json(response)
|
|
453
636
|
|
|
454
637
|
def batch_create(self, datasets: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
455
638
|
"""Create multiple datasets in a single request.
|
|
@@ -463,8 +646,8 @@ class JuniperDataClient:
|
|
|
463
646
|
Returns:
|
|
464
647
|
Dictionary with results, total_created, and total_failed.
|
|
465
648
|
"""
|
|
466
|
-
response = self._request("POST",
|
|
467
|
-
return
|
|
649
|
+
response = self._request("POST", ENDPOINT_BATCH_CREATE, json={"datasets": datasets})
|
|
650
|
+
return self._parse_json(response)
|
|
468
651
|
|
|
469
652
|
def batch_update_tags(
|
|
470
653
|
self,
|
|
@@ -487,8 +670,8 @@ class JuniperDataClient:
|
|
|
487
670
|
payload["add_tags"] = add_tags
|
|
488
671
|
if remove_tags:
|
|
489
672
|
payload["remove_tags"] = remove_tags
|
|
490
|
-
response = self._request("PATCH",
|
|
491
|
-
return
|
|
673
|
+
response = self._request("PATCH", ENDPOINT_BATCH_TAGS, json=payload)
|
|
674
|
+
return self._parse_json(response)
|
|
492
675
|
|
|
493
676
|
def batch_export(self, dataset_ids: List[str]) -> bytes:
|
|
494
677
|
"""Export multiple datasets as a ZIP archive of NPZ files.
|
|
@@ -502,7 +685,7 @@ class JuniperDataClient:
|
|
|
502
685
|
Raises:
|
|
503
686
|
JuniperDataNotFoundError: If none of the datasets exist.
|
|
504
687
|
"""
|
|
505
|
-
response = self._request("POST",
|
|
688
|
+
response = self._request("POST", ENDPOINT_BATCH_EXPORT, json={"dataset_ids": dataset_ids})
|
|
506
689
|
return response.content
|
|
507
690
|
|
|
508
691
|
def close(self) -> None:
|