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.
Files changed (30) hide show
  1. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/PKG-INFO +4 -1
  2. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client/__init__.py +5 -2
  3. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client/client.py +269 -86
  4. juniper_data_client-0.4.1/juniper_data_client/constants.py +217 -0
  5. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client/testing/fake_client.py +175 -68
  6. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client/testing/generators.py +54 -26
  7. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client.egg-info/PKG-INFO +4 -1
  8. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client.egg-info/SOURCES.txt +9 -1
  9. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client.egg-info/requires.txt +4 -0
  10. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/pyproject.toml +14 -1
  11. juniper_data_client-0.4.1/tests/test_create_dataset_tags_ttl.py +140 -0
  12. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/tests/test_fake_client.py +16 -7
  13. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/tests/test_fake_client_batch.py +3 -3
  14. juniper_data_client-0.4.1/tests/test_generator_parity.py +135 -0
  15. juniper_data_client-0.4.1/tests/test_malformed_json_response.py +72 -0
  16. juniper_data_client-0.4.1/tests/test_npzfile_resource_lifecycle.py +138 -0
  17. juniper_data_client-0.4.1/tests/test_on_request_hook.py +210 -0
  18. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/tests/test_performance.py +7 -7
  19. juniper_data_client-0.4.1/tests/test_retry_policy.py +54 -0
  20. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/tests/test_versioning.py +2 -2
  21. juniper_data_client-0.4.1/tests/test_x_request_id_propagation.py +119 -0
  22. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/LICENSE +0 -0
  23. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/README.md +0 -0
  24. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client/exceptions.py +0 -0
  25. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client/py.typed +0 -0
  26. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client/testing/__init__.py +0 -0
  27. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client.egg-info/dependency_links.txt +0 -0
  28. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/juniper_data_client.egg-info/top_level.txt +0 -0
  29. {juniper_data_client-0.4.0 → juniper_data_client-0.4.1}/setup.cfg +0 -0
  30. {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.0
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.0"
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 = "http://localhost:8100",
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("JUNIPER_DATA_API_KEY")
154
+ resolved_api_key = api_key or os.environ.get(API_KEY_ENV_VAR)
63
155
  if resolved_api_key:
64
- self.session.headers["X-API-Key"] = resolved_api_key
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(("http://", "https://")):
78
- url = f"http://{url}"
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("/v1"):
85
- normalized = normalized[:-3]
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=[429, 500, 502, 503, 504],
101
- allowed_methods=["HEAD", "GET", "POST", "PATCH", "DELETE"],
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=10,
107
- pool_maxsize=10,
198
+ pool_connections=HTTP_POOL_CONNECTIONS,
199
+ pool_maxsize=HTTP_POOL_MAXSIZE,
108
200
  )
109
201
 
110
- session.mount("http://", adapter)
111
- session.mount("https://", adapter)
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
- response = self.session.request(method, url, **kwargs)
138
- except requests.exceptions.ConnectionError as e:
139
- raise JuniperDataConnectionError(f"Failed to connect to JuniperData at {self.base_url}: {e}") from e
140
- except requests.exceptions.Timeout as e:
141
- raise JuniperDataTimeoutError(f"Request to {url} timed out after {self.timeout}s: {e}") from e
142
- except requests.exceptions.RequestException as e:
143
- raise JuniperDataClientError(f"Request failed: {e}") from e
144
-
145
- if response.ok:
146
- return response
147
-
148
- error_detail = response.text
149
- try:
150
- error_json = response.json()
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
- if response.status_code == 404:
159
- raise JuniperDataNotFoundError(f"Resource not found: {error_detail}")
160
- elif response.status_code in (400, 422):
161
- raise JuniperDataValidationError(f"Validation error: {error_detail}")
162
- else:
163
- raise JuniperDataClientError(f"Request failed ({response.status_code}): {error_detail}")
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", "/v1/health")
175
- return response.json()
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", "/v1/health/ready")
185
- return response.json().get("status") == "ready"
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 = 30.0, poll_interval: float = 0.5) -> bool:
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", "/v1/generators")
213
- return response.json()
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", f"/v1/generators/{name}/schema")
228
- return response.json()
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", "/v1/datasets", json=payload)
277
- return response.json()
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", "/v1/datasets/versions", params={"name": name})
289
- return response.json()
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", "/v1/datasets/latest", params={"name": name})
304
- return response.json()
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 = 100, offset: int = 0) -> List[str]:
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", "/v1/datasets", params={"limit": limit, "offset": offset})
317
- return response.json()
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", f"/v1/datasets/{dataset_id}")
332
- return response.json()
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", f"/v1/datasets/{dataset_id}/artifact")
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
- npz_file = np.load(io.BytesIO(content))
370
- return {key: npz_file[key] for key in npz_file.files}
371
-
372
- def get_preview(self, dataset_id: str, n: int = 100) -> Dict[str, Any]:
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", f"/v1/datasets/{dataset_id}/preview", params={"n": n})
386
- return response.json()
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", f"/v1/datasets/{dataset_id}")
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 = 2,
406
- n_points_per_spiral: int = 100,
407
- noise: float = 0.1,
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 = "modern",
410
- train_ratio: float = 0.8,
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("spiral", params)
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", "/v1/datasets/batch-delete", json={"dataset_ids": dataset_ids})
452
- return response.json()
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", "/v1/datasets/batch-create", json={"datasets": datasets})
467
- return response.json()
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", "/v1/datasets/batch-tags", json=payload)
491
- return response.json()
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", "/v1/datasets/batch-export", json={"dataset_ids": dataset_ids})
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: