juniper-data-client 0.4.1__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. juniper_data_client-0.5.0/PKG-INFO +119 -0
  2. juniper_data_client-0.5.0/README.md +74 -0
  3. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/juniper_data_client/__init__.py +9 -2
  4. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/juniper_data_client/client.py +136 -9
  5. juniper_data_client-0.5.0/juniper_data_client/constants.py +465 -0
  6. juniper_data_client-0.5.0/juniper_data_client/contract.py +135 -0
  7. juniper_data_client-0.5.0/juniper_data_client/exceptions.py +101 -0
  8. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/juniper_data_client/testing/__init__.py +1 -1
  9. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/juniper_data_client/testing/fake_client.py +54 -20
  10. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/juniper_data_client/testing/generators.py +28 -17
  11. juniper_data_client-0.5.0/juniper_data_client.egg-info/PKG-INFO +119 -0
  12. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/juniper_data_client.egg-info/SOURCES.txt +9 -0
  13. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/pyproject.toml +2 -2
  14. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/tests/test_client.py +403 -1
  15. juniper_data_client-0.5.0/tests/test_client_coverage_gaps.py +218 -0
  16. juniper_data_client-0.5.0/tests/test_constants_all_drift.py +59 -0
  17. juniper_data_client-0.5.0/tests/test_contract.py +279 -0
  18. juniper_data_client-0.5.0/tests/test_create_dataset_signature.py +55 -0
  19. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/tests/test_fake_client.py +63 -39
  20. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/tests/test_fake_client_batch.py +3 -2
  21. juniper_data_client-0.5.0/tests/test_file_header_versions.py +57 -0
  22. juniper_data_client-0.5.0/tests/test_generator_parity.py +285 -0
  23. juniper_data_client-0.5.0/tests/test_memory_budget_check.py +462 -0
  24. juniper_data_client-0.5.0/tests/test_npz_val_partition.py +208 -0
  25. juniper_data_client-0.5.0/tests/test_retry_policy.py +110 -0
  26. juniper_data_client-0.5.0/tests/test_version_lockstep.py +108 -0
  27. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/tests/test_versioning.py +1 -1
  28. juniper_data_client-0.4.1/PKG-INFO +0 -271
  29. juniper_data_client-0.4.1/README.md +0 -226
  30. juniper_data_client-0.4.1/juniper_data_client/constants.py +0 -217
  31. juniper_data_client-0.4.1/juniper_data_client/exceptions.py +0 -37
  32. juniper_data_client-0.4.1/juniper_data_client.egg-info/PKG-INFO +0 -271
  33. juniper_data_client-0.4.1/tests/test_generator_parity.py +0 -135
  34. juniper_data_client-0.4.1/tests/test_retry_policy.py +0 -54
  35. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/LICENSE +0 -0
  36. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/juniper_data_client/py.typed +0 -0
  37. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/juniper_data_client.egg-info/dependency_links.txt +0 -0
  38. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/juniper_data_client.egg-info/requires.txt +0 -0
  39. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/juniper_data_client.egg-info/top_level.txt +0 -0
  40. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/setup.cfg +0 -0
  41. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/tests/test_create_dataset_tags_ttl.py +0 -0
  42. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/tests/test_malformed_json_response.py +0 -0
  43. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/tests/test_npzfile_resource_lifecycle.py +0 -0
  44. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/tests/test_on_request_hook.py +0 -0
  45. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/tests/test_performance.py +0 -0
  46. {juniper_data_client-0.4.1 → juniper_data_client-0.5.0}/tests/test_x_request_id_propagation.py +0 -0
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: juniper-data-client
3
+ Version: 0.5.0
4
+ Summary: HTTP client for the JuniperData dataset generation service
5
+ Author: Paul Calnon
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/pcalnon/juniper-data-client
8
+ Project-URL: Repository, https://github.com/pcalnon/juniper-data-client
9
+ Project-URL: Documentation, https://github.com/pcalnon/juniper-data-client#readme
10
+ Project-URL: Issues, https://github.com/pcalnon/juniper-data-client/issues
11
+ Keywords: juniper,juniper-ml,juniper-ai,artificial-intelligence,dataset,machine-learning,api-client
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: numpy>=1.24.0
27
+ Requires-Dist: requests>=2.28.0
28
+ Requires-Dist: urllib3>=2.0.0
29
+ Provides-Extra: test
30
+ Requires-Dist: pytest>=7.0.0; extra == "test"
31
+ Requires-Dist: pytest-cov>=4.0.0; extra == "test"
32
+ Requires-Dist: pytest-timeout>=2.2.0; extra == "test"
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"
37
+ Provides-Extra: dev
38
+ Requires-Dist: juniper-data-client[test]; extra == "dev"
39
+ Requires-Dist: black>=23.0.0; extra == "dev"
40
+ Requires-Dist: isort>=5.12.0; extra == "dev"
41
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
42
+ Requires-Dist: flake8>=7.0.0; extra == "dev"
43
+ Requires-Dist: types-requests>=2.28.0; extra == "dev"
44
+ Dynamic: license-file
45
+
46
+ # juniper-data-client
47
+
48
+ [![PyPI](https://img.shields.io/pypi/v/juniper-data-client)](https://pypi.org/project/juniper-data-client/)
49
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/)
50
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
51
+
52
+ **Python HTTP client for the juniper-data dataset-generation service.**
53
+
54
+ `juniper-data-client` is a thin, synchronous HTTP client for fetching datasets from a `juniper-data`
55
+ instance. It exposes a single `JuniperDataClient` class whose methods correspond to the REST surface:
56
+ generator schemas, dataset creation, tag-based filtering, batch operations, the named-version
57
+ registry, and download of the resulting NPZ artifacts as ready-to-train `numpy` arrays. It is the
58
+ canonical dataset-fetch interface used by `juniper-cascor` (training) and `juniper-canopy`
59
+ (visualisation), and it reads the platform's shared `X_train` / `y_train` / `X_val` / `y_val` /
60
+ `X_test` / `y_test` NPZ schema (all `float32`). `val` is
61
+ presence-conditional: `validate_npz_contract` skips a split the artifact does not
62
+ carry. Fake producers emit it (#187); live artifacts may omit it.
63
+
64
+ > **Part of the Juniper platform.** juniper-data-client is the Python HTTP client for the juniper-data
65
+ > dataset service in [Juniper](https://github.com/pcalnon/juniper-ml) — a multi-package ML research
66
+ > platform built around constructive (Cascade-Correlation) and recurrent neural networks. Point it at
67
+ > any running juniper-data instance.
68
+
69
+ ## Install
70
+
71
+ ```bash
72
+ pip install juniper-data-client
73
+ ```
74
+
75
+ Optional extras: `[observability]` adds X-Request-ID propagation via `juniper-observability`; `[test]`
76
+ and `[dev]` install the test and lint/type-check toolchains.
77
+
78
+ ## Quick start
79
+
80
+ ```python
81
+ from juniper_data_client import JuniperDataClient
82
+
83
+ with JuniperDataClient("http://localhost:8100") as client:
84
+ print(client.health_check()["status"])
85
+
86
+ result = client.create_spiral_dataset(n_spirals=2, n_points_per_spiral=100, noise=0.1, seed=42)
87
+ arrays = client.download_artifact_npz(result["dataset_id"])
88
+
89
+ print("train:", len(arrays["X_train"]), "test:", len(arrays["X_test"]))
90
+ ```
91
+
92
+ The artifacts come back as a dict of `float32` `numpy` arrays, ready for `torch.from_numpy(...)`.
93
+
94
+ ## API
95
+
96
+ | Method | Purpose |
97
+ |--------|---------|
98
+ | `health_check()` / `is_ready()` / `wait_for_ready(...)` | Liveness / readiness probes |
99
+ | `list_generators()` / `get_generator_schema(name)` | Discover available generators + their schemas |
100
+ | `create_dataset(generator, params, ...)` | Create a dataset from any generator |
101
+ | `create_spiral_dataset(...)` | Convenience wrapper for the spiral generator |
102
+ | `download_artifact_npz(dataset_id)` / `download_artifact_bytes(dataset_id)` | Download as numpy arrays / raw bytes |
103
+ | `get_dataset_metadata(id)` / `get_preview(id, n)` / `delete_dataset(id)` | Inspect / preview / delete |
104
+ | `list_datasets(limit, offset)` | Paginated dataset listing |
105
+ | `list_versions(name)` / `get_latest(name)` | Named-version registry |
106
+ | `batch_create(...)` / `batch_delete(...)` / `batch_update_tags(...)` / `batch_export(...)` | Batch operations |
107
+
108
+ ## Status
109
+
110
+ **Beta** on PyPI. The current version is shown by the badge above; see [`CHANGELOG.md`](./CHANGELOG.md).
111
+
112
+ ## Documentation
113
+
114
+ - [`docs/QUICK_START.md`](./docs/QUICK_START.md) — installation and verification guide
115
+ - [`docs/REFERENCE.md`](./docs/REFERENCE.md) — full API reference, configuration, and error model
116
+
117
+ ## License
118
+
119
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,74 @@
1
+ # juniper-data-client
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/juniper-data-client)](https://pypi.org/project/juniper-data-client/)
4
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
6
+
7
+ **Python HTTP client for the juniper-data dataset-generation service.**
8
+
9
+ `juniper-data-client` is a thin, synchronous HTTP client for fetching datasets from a `juniper-data`
10
+ instance. It exposes a single `JuniperDataClient` class whose methods correspond to the REST surface:
11
+ generator schemas, dataset creation, tag-based filtering, batch operations, the named-version
12
+ registry, and download of the resulting NPZ artifacts as ready-to-train `numpy` arrays. It is the
13
+ canonical dataset-fetch interface used by `juniper-cascor` (training) and `juniper-canopy`
14
+ (visualisation), and it reads the platform's shared `X_train` / `y_train` / `X_val` / `y_val` /
15
+ `X_test` / `y_test` NPZ schema (all `float32`). `val` is
16
+ presence-conditional: `validate_npz_contract` skips a split the artifact does not
17
+ carry. Fake producers emit it (#187); live artifacts may omit it.
18
+
19
+ > **Part of the Juniper platform.** juniper-data-client is the Python HTTP client for the juniper-data
20
+ > dataset service in [Juniper](https://github.com/pcalnon/juniper-ml) — a multi-package ML research
21
+ > platform built around constructive (Cascade-Correlation) and recurrent neural networks. Point it at
22
+ > any running juniper-data instance.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pip install juniper-data-client
28
+ ```
29
+
30
+ Optional extras: `[observability]` adds X-Request-ID propagation via `juniper-observability`; `[test]`
31
+ and `[dev]` install the test and lint/type-check toolchains.
32
+
33
+ ## Quick start
34
+
35
+ ```python
36
+ from juniper_data_client import JuniperDataClient
37
+
38
+ with JuniperDataClient("http://localhost:8100") as client:
39
+ print(client.health_check()["status"])
40
+
41
+ result = client.create_spiral_dataset(n_spirals=2, n_points_per_spiral=100, noise=0.1, seed=42)
42
+ arrays = client.download_artifact_npz(result["dataset_id"])
43
+
44
+ print("train:", len(arrays["X_train"]), "test:", len(arrays["X_test"]))
45
+ ```
46
+
47
+ The artifacts come back as a dict of `float32` `numpy` arrays, ready for `torch.from_numpy(...)`.
48
+
49
+ ## API
50
+
51
+ | Method | Purpose |
52
+ |--------|---------|
53
+ | `health_check()` / `is_ready()` / `wait_for_ready(...)` | Liveness / readiness probes |
54
+ | `list_generators()` / `get_generator_schema(name)` | Discover available generators + their schemas |
55
+ | `create_dataset(generator, params, ...)` | Create a dataset from any generator |
56
+ | `create_spiral_dataset(...)` | Convenience wrapper for the spiral generator |
57
+ | `download_artifact_npz(dataset_id)` / `download_artifact_bytes(dataset_id)` | Download as numpy arrays / raw bytes |
58
+ | `get_dataset_metadata(id)` / `get_preview(id, n)` / `delete_dataset(id)` | Inspect / preview / delete |
59
+ | `list_datasets(limit, offset)` | Paginated dataset listing |
60
+ | `list_versions(name)` / `get_latest(name)` | Named-version registry |
61
+ | `batch_create(...)` / `batch_delete(...)` / `batch_update_tags(...)` / `batch_export(...)` | Batch operations |
62
+
63
+ ## Status
64
+
65
+ **Beta** on PyPI. The current version is shown by the badge above; see [`CHANGELOG.md`](./CHANGELOG.md).
66
+
67
+ ## Documentation
68
+
69
+ - [`docs/QUICK_START.md`](./docs/QUICK_START.md) — installation and verification guide
70
+ - [`docs/REFERENCE.md`](./docs/REFERENCE.md) — full API reference, configuration, and error model
71
+
72
+ ## License
73
+
74
+ MIT — see [LICENSE](./LICENSE).
@@ -5,20 +5,27 @@ dataset generation service, used by both JuniperCascor and juniper-canopy.
5
5
  """
6
6
 
7
7
  from juniper_data_client.client import JuniperDataClient, RequestHook
8
- from juniper_data_client.exceptions import JuniperDataClientError, JuniperDataConfigurationError, JuniperDataConnectionError, JuniperDataNotFoundError, JuniperDataTimeoutError, JuniperDataValidationError
8
+ from juniper_data_client.constants import ContractKind
9
+ from juniper_data_client.contract import validate_npz_contract
10
+ from juniper_data_client.exceptions import JuniperDataClientError, JuniperDataConfigurationError, JuniperDataConnectionError, JuniperDataContractError, JuniperDataNotFoundError, JuniperDataTimeoutError, JuniperDataValidationError
9
11
 
10
- __version__ = "0.4.1"
12
+ __version__ = "0.5.0"
11
13
 
12
14
  __all__ = [
13
15
  "JuniperDataClient",
16
+ "validate_npz_contract",
14
17
  "JuniperDataClientError",
15
18
  "JuniperDataConfigurationError",
16
19
  "JuniperDataConnectionError",
20
+ "JuniperDataContractError",
17
21
  "JuniperDataNotFoundError",
18
22
  "JuniperDataTimeoutError",
19
23
  "JuniperDataValidationError",
20
24
  # METRICS-MON R4.3 / seed-13: instrumentation hook type alias
21
25
  # exported so consumers can type their hook closures.
22
26
  "RequestHook",
27
+ # APD-DCLIENT-006: the validate_npz_contract return Literal, exported so
28
+ # consumers can annotate and exhaustively match on the result.
29
+ "ContractKind",
23
30
  "__version__",
24
31
  ]
@@ -8,6 +8,7 @@ import io
8
8
  import logging
9
9
  import os
10
10
  import time
11
+ from pathlib import Path
11
12
  from typing import Any, Callable, Dict, List, Optional
12
13
  from urllib.parse import urlparse
13
14
 
@@ -18,9 +19,11 @@ from urllib3.util.retry import Retry
18
19
 
19
20
  from juniper_data_client.constants import (
20
21
  API_KEY_ENV_VAR,
22
+ API_KEY_FILE_ENV_VAR,
21
23
  API_KEY_HEADER_NAME,
22
24
  API_VERSION_PATH_SUFFIX,
23
25
  DEFAULT_BACKOFF_FACTOR,
26
+ DEFAULT_BACKOFF_JITTER,
24
27
  DEFAULT_BASE_URL,
25
28
  DEFAULT_LIST_LIMIT,
26
29
  DEFAULT_LIST_OFFSET,
@@ -60,11 +63,31 @@ from juniper_data_client.constants import (
60
63
  SPIRAL_TRAIN_RATIO_DEFAULT,
61
64
  URL_SCHEME_PREFIXES,
62
65
  )
63
- from juniper_data_client.exceptions import JuniperDataClientError, JuniperDataConnectionError, JuniperDataNotFoundError, JuniperDataTimeoutError, JuniperDataValidationError
66
+ from juniper_data_client.exceptions import JuniperDataClientError, JuniperDataConfigurationError, JuniperDataConnectionError, JuniperDataNotFoundError, JuniperDataTimeoutError, JuniperDataValidationError
64
67
 
65
68
  logger = logging.getLogger("juniper_data_client.client")
66
69
 
67
70
 
71
+ def _resolve_api_key_from_env() -> Optional[str]:
72
+ """Resolve the juniper-data API key from the environment.
73
+
74
+ Honors the Docker-secret ``JUNIPER_DATA_API_KEY_FILE`` indirection (a file whose
75
+ stripped contents are the key, e.g. ``/run/secrets/juniper_data_api_keys``) before the
76
+ plain ``JUNIPER_DATA_API_KEY`` env var, so a consumer that mounts the key as a file and
77
+ leaves ``api_key`` unset still authenticates. Mirrors how the Juniper services resolve
78
+ their own secrets (e.g. cascor ``api.secrets.get_secret``).
79
+ """
80
+ file_path = os.environ.get(API_KEY_FILE_ENV_VAR)
81
+ if file_path:
82
+ try:
83
+ content = Path(file_path).read_text(encoding="utf-8").strip()
84
+ except OSError:
85
+ content = ""
86
+ if content:
87
+ return content
88
+ return os.environ.get(API_KEY_ENV_VAR)
89
+
90
+
68
91
  # METRICS-MON R4.3 / seed-13: type alias for the optional instrumentation
69
92
  # hook. Consumers (canopy, cascor) pass a callable matching this shape;
70
93
  # default in :class:`JuniperDataClient` is no-op so standalone users
@@ -100,6 +123,42 @@ def _noop_request_hook(
100
123
  """
101
124
 
102
125
 
126
+ def _render_error_detail(detail: Any) -> str:
127
+ """Render a server ``detail`` payload as readable text for an error message.
128
+
129
+ FastAPI answers a validation failure with a *list* of error objects, e.g.::
130
+
131
+ [{"type": "missing", "loc": ["body", "seed"], "msg": "Field required"}]
132
+
133
+ Interpolating that list into an f-string produced a Python repr -- valid
134
+ text, but nothing a caller could parse and nothing a human could read at a
135
+ glance (defect-register ``APD-DCLIENT-003``). This renders the same content
136
+ as ``body.seed: Field required``, while the untouched structure stays
137
+ available on ``exc.detail``.
138
+
139
+ Anything that is not a list of mappings is returned via ``str`` unchanged,
140
+ so plain-string details and unexpected shapes both survive intact.
141
+ """
142
+ if not isinstance(detail, list):
143
+ return str(detail)
144
+
145
+ rendered: list[str] = []
146
+ for item in detail:
147
+ if not isinstance(item, dict):
148
+ rendered.append(str(item))
149
+ continue
150
+ msg = item.get("msg", "")
151
+ loc = item.get("loc")
152
+ if isinstance(loc, (list, tuple)) and loc:
153
+ location = ".".join(str(part) for part in loc)
154
+ rendered.append(f"{location}: {msg}" if msg else location)
155
+ else:
156
+ rendered.append(str(msg) if msg else str(item))
157
+ # An empty list is a degenerate but legal payload; ``str`` keeps it visible
158
+ # rather than collapsing it to an empty message.
159
+ return "; ".join(rendered) if rendered else str(detail)
160
+
161
+
103
162
  class JuniperDataClient:
104
163
  """Client for interacting with the JuniperData REST API.
105
164
 
@@ -151,7 +210,7 @@ class JuniperDataClient:
151
210
  # guards — the no-op call is a single attribute load + return).
152
211
  self._on_request: RequestHook = on_request or _noop_request_hook
153
212
 
154
- resolved_api_key = api_key or os.environ.get(API_KEY_ENV_VAR)
213
+ resolved_api_key = api_key or _resolve_api_key_from_env()
155
214
  if resolved_api_key:
156
215
  self.session.headers[API_KEY_HEADER_NAME] = resolved_api_key
157
216
 
@@ -163,13 +222,28 @@ class JuniperDataClient:
163
222
 
164
223
  Returns:
165
224
  Normalized URL with scheme, no trailing slash, no /v1 suffix
225
+
226
+ Raises:
227
+ JuniperDataConfigurationError: when the URL carries no host (an
228
+ empty string, a bare scheme, a path-only value, or a
229
+ userinfo-only authority like ``http://user:secret@``) — a
230
+ misconfiguration that would otherwise normalize to a broken,
231
+ hostless URL and fail opaquely on the first request
232
+ (``APD-DCLIENT-004``; mirrors juniper-recurrence-client).
166
233
  """
167
234
  url = url.strip()
168
235
 
169
- if not url.startswith(URL_SCHEME_PREFIXES):
236
+ # Scheme matching is case-insensitive (RFC 3986 §3.1): a case-sensitive
237
+ # startswith would re-prefix "HTTPS://host" into "http://HTTPS://host",
238
+ # silently downgrading TLS and sending the API key to hostname "https".
239
+ if not url.lower().startswith(URL_SCHEME_PREFIXES):
170
240
  url = f"{DEFAULT_URL_SCHEME_PREFIX}{url}"
171
241
 
172
242
  parsed = urlparse(url)
243
+ # hostname, not netloc: netloc accepts a userinfo-only authority
244
+ # ("user:secret@") as truthy while hostname is None for it.
245
+ if not parsed.hostname:
246
+ raise JuniperDataConfigurationError(f"base_url must include a host; got {url!r}")
173
247
  normalized = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
174
248
  normalized = normalized.rstrip("/")
175
249
 
@@ -189,6 +263,8 @@ class JuniperDataClient:
189
263
  retry_strategy = Retry(
190
264
  total=self.retries,
191
265
  backoff_factor=self.backoff_factor,
266
+ # APD-ECO-002: decorrelate retry schedules across client instances.
267
+ backoff_jitter=DEFAULT_BACKOFF_JITTER,
192
268
  status_forcelist=RETRYABLE_STATUS_CODES,
193
269
  allowed_methods=RETRY_ALLOWED_METHODS,
194
270
  )
@@ -287,14 +363,46 @@ class JuniperDataClient:
287
363
  # fall back to using the raw response text as the error detail.
288
364
  error_detail = response.text
289
365
 
366
+ # APD-DCLIENT-003: ``error_detail`` is whatever the server sent --
367
+ # a str for most handlers, but a list[dict] for FastAPI's 422. It is
368
+ # attached to the exception UNMODIFIED, and rendered separately for
369
+ # the human-readable message. Interpolating the list directly
370
+ # produced a Python repr no caller could parse, and which lost
371
+ # nothing only because there was nowhere else for the structure to
372
+ # go. Now there is: ``exc.detail``.
373
+ rendered_detail = _render_error_detail(error_detail)
374
+
375
+ # The context is passed explicitly at each site rather than unpacked
376
+ # from a shared ``**kwargs`` dict: mypy --strict infers such a dict
377
+ # as ``dict[str, object]`` and cannot match it against the typed
378
+ # keyword-only parameters, so the dict form silently gives up the
379
+ # type checking on exactly the arguments being added here.
290
380
  if response.status_code == HTTP_404_NOT_FOUND:
291
- outgoing_error = JuniperDataNotFoundError(f"Resource not found: {error_detail}")
381
+ outgoing_error = JuniperDataNotFoundError(
382
+ f"Resource not found: {rendered_detail}",
383
+ status_code=response.status_code,
384
+ detail=error_detail,
385
+ response=response,
386
+ )
292
387
  raise outgoing_error
293
388
  elif response.status_code in (HTTP_400_BAD_REQUEST, HTTP_422_UNPROCESSABLE_ENTITY):
294
- outgoing_error = JuniperDataValidationError(f"Validation error: {error_detail}")
389
+ # APD-DCLIENT-001: the status code is what separates these two.
390
+ # It rides on the exception now, so a caller no longer has to
391
+ # substring-match the message to tell them apart.
392
+ outgoing_error = JuniperDataValidationError(
393
+ f"Validation error ({response.status_code}): {rendered_detail}",
394
+ status_code=response.status_code,
395
+ detail=error_detail,
396
+ response=response,
397
+ )
295
398
  raise outgoing_error
296
399
  else:
297
- outgoing_error = JuniperDataClientError(f"Request failed ({response.status_code}): {error_detail}")
400
+ outgoing_error = JuniperDataClientError(
401
+ f"Request failed ({response.status_code}): {rendered_detail}",
402
+ status_code=response.status_code,
403
+ detail=error_detail,
404
+ response=response,
405
+ )
298
406
  raise outgoing_error
299
407
  finally:
300
408
  duration_ms = (time.monotonic() - start) * 1000.0
@@ -391,6 +499,7 @@ class JuniperDataClient:
391
499
  self,
392
500
  generator: str,
393
501
  params: Dict[str, Any],
502
+ *,
394
503
  persist: bool = True,
395
504
  name: Optional[str] = None,
396
505
  description: Optional[str] = None,
@@ -401,6 +510,12 @@ class JuniperDataClient:
401
510
  ) -> Dict[str, Any]:
402
511
  """Create a new dataset via the JuniperData API.
403
512
 
513
+ ``persist`` and everything after it are keyword-only (defect-register
514
+ ``APD-DCLIENT-008``): with nine positional-or-keyword parameters the
515
+ call ``create_dataset("spiral", p, False)`` was legal and unreadable,
516
+ and any signature reordering would silently rebind arguments. Only
517
+ ``generator`` and ``params`` — the universal pair — stay positional.
518
+
404
519
  If a dataset with the same parameters already exists, the existing
405
520
  dataset is returned (caching behavior).
406
521
 
@@ -525,12 +640,24 @@ class JuniperDataClient:
525
640
  def download_artifact_npz(self, dataset_id: str) -> Dict[str, np.ndarray]:
526
641
  """Download and load an NPZ artifact for a dataset.
527
642
 
528
- The returned dictionary contains numpy arrays with the standard keys:
643
+ The returned dictionary contains every array in the NPZ. Classification
644
+ artifacts carry the standard keys:
529
645
  - X_train, y_train: Training features and one-hot labels
530
646
  - X_test, y_test: Test features and one-hot labels
531
- - X_full, y_full: Full dataset features and one-hot labels
532
647
 
533
- All arrays are float32 dtype.
648
+ Additional keys may be present depending on the generator (WS-1 /
649
+ juniper-data#168):
650
+ - y_reg_{split}: regression target(s) (e.g. equities next-day close).
651
+ - 3-D sequence artifacts (X.ndim == 3, shape (W, L, F)) add a per-step
652
+ dt_{split} (or absolute t_{split}) elapsed-time channel plus
653
+ target_dt_{split} and optional observed_mask_{split} /
654
+ padding_mask_{split}. Pass the result to
655
+ :func:`juniper_data_client.validate_npz_contract` to classify
656
+ ("tabular" / "sequence") and validate it. The matching ``task_type``
657
+ ("classification" / "regression") and ``time_unit`` live in the
658
+ dataset metadata (see :meth:`get_dataset_metadata`).
659
+
660
+ Feature and label arrays are float32.
534
661
 
535
662
  Args:
536
663
  dataset_id: ID of the dataset whose artifact to download