juniper-recurrence-client 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ """juniper-recurrence-client — HTTP client for the juniper-recurrence service.
2
+
3
+ A lean ``requests``-based client wrapping the juniper-recurrence FastAPI app's REST surface
4
+ (train / predict / cross-validate / inspect / health). Mirrors juniper-data-client and
5
+ juniper-cascor-client so consumers (notably juniper-canopy's recurrence backend adapter) drive
6
+ every Juniper backend the same way.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from juniper_recurrence_client._version import __version__
12
+ from juniper_recurrence_client.client import JuniperRecurrenceClient, RequestHook
13
+ from juniper_recurrence_client.exceptions import (
14
+ JuniperRecurrenceClientError,
15
+ JuniperRecurrenceConfigurationError,
16
+ JuniperRecurrenceConflictError,
17
+ JuniperRecurrenceConnectionError,
18
+ JuniperRecurrenceNotFoundError,
19
+ JuniperRecurrenceTimeoutError,
20
+ JuniperRecurrenceValidationError,
21
+ )
22
+
23
+ __all__ = [
24
+ "__version__",
25
+ "JuniperRecurrenceClient",
26
+ "RequestHook",
27
+ "JuniperRecurrenceClientError",
28
+ "JuniperRecurrenceConnectionError",
29
+ "JuniperRecurrenceTimeoutError",
30
+ "JuniperRecurrenceNotFoundError",
31
+ "JuniperRecurrenceConflictError",
32
+ "JuniperRecurrenceValidationError",
33
+ "JuniperRecurrenceConfigurationError",
34
+ ]
@@ -0,0 +1,7 @@
1
+ """Single source of truth for the juniper-recurrence-client version.
2
+
3
+ Kept import-free so setuptools can parse ``__version__`` statically at build time
4
+ (``[tool.setuptools.dynamic]`` in pyproject.toml) without importing requests.
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,433 @@
1
+ """REST API client for the juniper-recurrence service.
2
+
3
+ A lean ``requests``-based client wrapping the juniper-recurrence FastAPI app's REST surface
4
+ (train / predict / cross-validate / inspect / health), for consumers such as juniper-canopy's
5
+ recurrence backend adapter. Mirrors juniper-data-client's transport machinery: an idempotent-only
6
+ retry policy, ``X-API-Key`` auth with ``_FILE`` Docker-secret indirection, typed exceptions, the
7
+ optional ``on_request`` instrumentation hook, and best-effort ``X-Request-ID`` propagation.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any, Callable, Optional
17
+ from urllib.parse import urlparse
18
+
19
+ import requests
20
+ from requests.adapters import HTTPAdapter
21
+ from urllib3.util.retry import Retry
22
+
23
+ from juniper_recurrence_client.constants import (
24
+ API_KEY_ENV_VAR,
25
+ API_KEY_FILE_ENV_VAR,
26
+ API_KEY_HEADER_NAME,
27
+ API_VERSION_PATH_SUFFIX,
28
+ DEFAULT_BACKOFF_FACTOR,
29
+ DEFAULT_BASE_URL,
30
+ DEFAULT_READY_POLL_INTERVAL,
31
+ DEFAULT_READY_TIMEOUT,
32
+ DEFAULT_RETRIES,
33
+ DEFAULT_TIMEOUT,
34
+ DEFAULT_URL_SCHEME_PREFIX,
35
+ ENDPOINT_CROSSVAL,
36
+ ENDPOINT_CROSSVAL_STATUS,
37
+ ENDPOINT_DATASET,
38
+ ENDPOINT_HEALTH,
39
+ ENDPOINT_HEALTH_READY,
40
+ ENDPOINT_MODEL,
41
+ ENDPOINT_PREDICT,
42
+ ENDPOINT_TRAIN,
43
+ ENDPOINT_TRAINING_STATUS,
44
+ HEALTH_READY_STATUS,
45
+ HTTP_400_BAD_REQUEST,
46
+ HTTP_404_NOT_FOUND,
47
+ HTTP_409_CONFLICT,
48
+ HTTP_422_UNPROCESSABLE_ENTITY,
49
+ HTTP_POOL_CONNECTIONS,
50
+ HTTP_POOL_MAXSIZE,
51
+ RETRY_ALLOWED_METHODS,
52
+ RETRYABLE_STATUS_CODES,
53
+ URL_SCHEME_PREFIXES,
54
+ )
55
+ from juniper_recurrence_client.exceptions import (
56
+ JuniperRecurrenceClientError,
57
+ JuniperRecurrenceConflictError,
58
+ JuniperRecurrenceConnectionError,
59
+ JuniperRecurrenceNotFoundError,
60
+ JuniperRecurrenceTimeoutError,
61
+ JuniperRecurrenceValidationError,
62
+ )
63
+
64
+ logger = logging.getLogger("juniper_recurrence_client.client")
65
+
66
+
67
+ def _resolve_api_key_from_env() -> Optional[str]:
68
+ """Resolve the juniper-recurrence API key from the environment.
69
+
70
+ Honors the Docker-secret ``JUNIPER_RECURRENCE_API_KEY_FILE`` indirection (a file whose
71
+ stripped contents are the key) before the plain ``JUNIPER_RECURRENCE_API_KEY`` env var, so a
72
+ consumer that mounts the key as a file and leaves ``api_key`` unset still authenticates.
73
+ """
74
+ file_path = os.environ.get(API_KEY_FILE_ENV_VAR)
75
+ if file_path:
76
+ try:
77
+ content = Path(file_path).read_text(encoding="utf-8").strip()
78
+ except OSError:
79
+ content = ""
80
+ if content:
81
+ return content
82
+ return os.environ.get(API_KEY_ENV_VAR)
83
+
84
+
85
+ #: Optional instrumentation hook, invoked once per HTTP call with
86
+ #: ``(method, url, status, duration_ms, error)``. ``error is None`` is the canonical success
87
+ #: signal (``status`` may be set even on the typed-error paths). Mirrors juniper-data-client so
88
+ #: canopy/cascor can pass the same Prometheus/structured-log closure they already use.
89
+ RequestHook = Callable[[str, str, Optional[int], float, Optional[BaseException]], None]
90
+
91
+
92
+ def _noop_request_hook(
93
+ method: str,
94
+ url: str,
95
+ status: Optional[int],
96
+ duration_ms: float,
97
+ error: Optional[BaseException],
98
+ ) -> None:
99
+ """Default :data:`RequestHook` — does nothing (named so the default is a real callable)."""
100
+
101
+
102
+ def _dataset_ref(
103
+ *,
104
+ dataset_id: Optional[str],
105
+ name: Optional[str],
106
+ generator: Optional[str],
107
+ params: Optional[dict[str, Any]],
108
+ split: str,
109
+ ) -> dict[str, Any]:
110
+ """Build the app's ``DatasetRef`` body from selection kwargs.
111
+
112
+ Exactly one of ``dataset_id`` / ``name`` / ``generator`` is expected; the server validates
113
+ that invariant and returns 422 otherwise.
114
+ """
115
+ ref: dict[str, Any] = {"split": split}
116
+ if dataset_id is not None:
117
+ ref["dataset_id"] = dataset_id
118
+ if name is not None:
119
+ ref["name"] = name
120
+ if generator is not None:
121
+ ref["generator"] = generator
122
+ if params is not None:
123
+ ref["params"] = params
124
+ return ref
125
+
126
+
127
+ class JuniperRecurrenceClient:
128
+ """Client for the juniper-recurrence REST API (train / predict / cross-validate / inspect).
129
+
130
+ Automatic retry (idempotent methods only), connection pooling, and ``X-API-Key`` auth.
131
+
132
+ Example:
133
+ >>> client = JuniperRecurrenceClient("http://localhost:8211")
134
+ >>> client.train(name="equities", d=16)
135
+ >>> preds = client.predict(dataset_id="ds-1")
136
+ """
137
+
138
+ def __init__(
139
+ self,
140
+ base_url: str = DEFAULT_BASE_URL,
141
+ timeout: int = DEFAULT_TIMEOUT,
142
+ retries: int = DEFAULT_RETRIES,
143
+ backoff_factor: float = DEFAULT_BACKOFF_FACTOR,
144
+ api_key: Optional[str] = None,
145
+ on_request: Optional[RequestHook] = None,
146
+ ) -> None:
147
+ """Initialize the client.
148
+
149
+ Args:
150
+ base_url: Base URL of the juniper-recurrence app (default ``http://localhost:8211``).
151
+ timeout: Per-request timeout in seconds.
152
+ retries: Retry attempts for transient failures on idempotent methods.
153
+ backoff_factor: Exponential backoff factor for retries.
154
+ api_key: API key for ``X-API-Key`` auth. If unset, resolved from
155
+ ``JUNIPER_RECURRENCE_API_KEY`` (and its ``_FILE`` form).
156
+ on_request: Optional instrumentation hook (see :data:`RequestHook`); defaults to a
157
+ no-op. Hook exceptions are caught and logged so instrumentation never crashes a
158
+ request path.
159
+ """
160
+ self.base_url = self._normalize_url(base_url)
161
+ self.timeout = timeout
162
+ self.retries = retries
163
+ self.backoff_factor = backoff_factor
164
+ self.session = self._create_session()
165
+ self._on_request: RequestHook = on_request or _noop_request_hook
166
+
167
+ resolved_api_key = api_key or _resolve_api_key_from_env()
168
+ if resolved_api_key:
169
+ self.session.headers[API_KEY_HEADER_NAME] = resolved_api_key
170
+
171
+ def _normalize_url(self, url: str) -> str:
172
+ """Normalize the base URL: ensure a scheme, drop a trailing slash and any ``/v1`` suffix."""
173
+ url = url.strip()
174
+ if not url.startswith(URL_SCHEME_PREFIXES):
175
+ url = f"{DEFAULT_URL_SCHEME_PREFIX}{url}"
176
+ parsed = urlparse(url)
177
+ normalized = f"{parsed.scheme}://{parsed.netloc}{parsed.path}".rstrip("/")
178
+ if normalized.endswith(API_VERSION_PATH_SUFFIX):
179
+ normalized = normalized[: -len(API_VERSION_PATH_SUFFIX)]
180
+ return normalized
181
+
182
+ def _create_session(self) -> requests.Session:
183
+ """Create a ``requests.Session`` with the idempotent-only retry policy + pooling."""
184
+ session = requests.Session()
185
+ retry_strategy = Retry(
186
+ total=self.retries,
187
+ backoff_factor=self.backoff_factor,
188
+ status_forcelist=RETRYABLE_STATUS_CODES,
189
+ allowed_methods=RETRY_ALLOWED_METHODS,
190
+ )
191
+ adapter = HTTPAdapter(
192
+ max_retries=retry_strategy,
193
+ pool_connections=HTTP_POOL_CONNECTIONS,
194
+ pool_maxsize=HTTP_POOL_MAXSIZE,
195
+ )
196
+ for scheme in URL_SCHEME_PREFIXES:
197
+ session.mount(scheme, adapter)
198
+ return session
199
+
200
+ def _request(self, method: str, endpoint: str, **kwargs: Any) -> requests.Response: # noqa: C901
201
+ """Make an HTTP request, mapping transport/HTTP errors to typed exceptions.
202
+
203
+ Raises:
204
+ JuniperRecurrenceConnectionError / JuniperRecurrenceTimeoutError: transport failures.
205
+ JuniperRecurrenceNotFoundError (404), JuniperRecurrenceConflictError (409),
206
+ JuniperRecurrenceValidationError (400/422), or JuniperRecurrenceClientError (other).
207
+ """
208
+ url = f"{self.base_url}{endpoint}"
209
+ kwargs.setdefault("timeout", self.timeout)
210
+
211
+ # Best-effort X-Request-ID propagation via juniper-observability (no-op if absent or
212
+ # unset); a caller-supplied X-Request-ID always wins.
213
+ headers = dict(kwargs.get("headers") or {})
214
+ if "X-Request-ID" not in headers:
215
+ try:
216
+ from juniper_observability import request_id_var # noqa: PLC0415
217
+
218
+ rid = request_id_var.get()
219
+ if rid:
220
+ headers["X-Request-ID"] = rid
221
+ kwargs["headers"] = headers
222
+ except (ImportError, LookupError):
223
+ pass
224
+
225
+ start = time.monotonic()
226
+ response: Optional[requests.Response] = None
227
+ outgoing_error: Optional[BaseException] = None
228
+ try:
229
+ try:
230
+ response = self.session.request(method, url, **kwargs)
231
+ except requests.exceptions.ConnectionError as e:
232
+ outgoing_error = JuniperRecurrenceConnectionError(f"Failed to connect to juniper-recurrence at {self.base_url}: {e}")
233
+ raise outgoing_error from e
234
+ except requests.exceptions.Timeout as e:
235
+ outgoing_error = JuniperRecurrenceTimeoutError(f"Request to {url} timed out after {self.timeout}s: {e}")
236
+ raise outgoing_error from e
237
+ except requests.exceptions.RequestException as e:
238
+ outgoing_error = JuniperRecurrenceClientError(f"Request failed: {e}")
239
+ raise outgoing_error from e
240
+
241
+ if response.ok:
242
+ return response
243
+
244
+ error_detail = response.text
245
+ try:
246
+ error_json = response.json()
247
+ if "detail" in error_json:
248
+ error_detail = error_json["detail"]
249
+ except (ValueError, KeyError):
250
+ error_detail = response.text
251
+
252
+ if response.status_code == HTTP_404_NOT_FOUND:
253
+ outgoing_error = JuniperRecurrenceNotFoundError(f"Resource not found: {error_detail}")
254
+ raise outgoing_error
255
+ elif response.status_code == HTTP_409_CONFLICT:
256
+ outgoing_error = JuniperRecurrenceConflictError(f"Conflict: {error_detail}")
257
+ raise outgoing_error
258
+ elif response.status_code in (HTTP_400_BAD_REQUEST, HTTP_422_UNPROCESSABLE_ENTITY):
259
+ outgoing_error = JuniperRecurrenceValidationError(f"Validation error: {error_detail}")
260
+ raise outgoing_error
261
+ else:
262
+ outgoing_error = JuniperRecurrenceClientError(f"Request failed ({response.status_code}): {error_detail}")
263
+ raise outgoing_error
264
+ finally:
265
+ duration_ms = (time.monotonic() - start) * 1000.0
266
+ status = response.status_code if response is not None else None
267
+ try:
268
+ self._on_request(method, url, status, duration_ms, outgoing_error)
269
+ except Exception: # noqa: BLE001 — instrumentation must not crash production paths
270
+ logger.warning("on_request hook raised; suppressed to keep request path resilient", exc_info=True)
271
+
272
+ @staticmethod
273
+ def _parse_json(response: requests.Response) -> Any:
274
+ """Parse a response body as JSON, surfacing a typed error on a malformed body."""
275
+ try:
276
+ return response.json()
277
+ except ValueError as e:
278
+ preview = (response.text or "")[:200]
279
+ raise JuniperRecurrenceClientError(f"Malformed JSON response from {response.url}: {e}: {preview!r}") from e
280
+
281
+ # ─── Training ─────────────────────────────────────────────────────────────
282
+
283
+ def train(
284
+ self,
285
+ *,
286
+ dataset_id: Optional[str] = None,
287
+ name: Optional[str] = None,
288
+ generator: Optional[str] = None,
289
+ params: Optional[dict[str, Any]] = None,
290
+ split: str = "train",
291
+ d: Optional[int] = None,
292
+ theta: Optional[float] = None,
293
+ ridge: Optional[float] = None,
294
+ ) -> dict[str, Any]:
295
+ """``POST /v1/train`` — synchronously fit the LMU regressor on a dataset split.
296
+
297
+ Supply exactly one of ``dataset_id`` / ``name`` / ``generator``. Returns the
298
+ ``TrainResponse`` (``final_metrics``, ``n_epochs``, ``stopped_reason``, ``dataset``).
299
+ Raises :class:`JuniperRecurrenceConflictError` (409) if a run is already in progress.
300
+ """
301
+ body: dict[str, Any] = {"dataset": _dataset_ref(dataset_id=dataset_id, name=name, generator=generator, params=params, split=split)}
302
+ if d is not None:
303
+ body["d"] = d
304
+ if theta is not None:
305
+ body["theta"] = theta
306
+ if ridge is not None:
307
+ body["ridge"] = ridge
308
+ return self._parse_json(self._request("POST", ENDPOINT_TRAIN, json=body))
309
+
310
+ def training_status(self) -> dict[str, Any]:
311
+ """``GET /v1/training/status`` — current training state, metrics, and emitted events."""
312
+ return self._parse_json(self._request("GET", ENDPOINT_TRAINING_STATUS))
313
+
314
+ # ─── Prediction ───────────────────────────────────────────────────────────
315
+
316
+ def predict(
317
+ self,
318
+ *,
319
+ X: Optional[Any] = None,
320
+ dt: Optional[Any] = None,
321
+ target_dt: Optional[Any] = None,
322
+ seq_lengths: Optional[Any] = None,
323
+ dataset_id: Optional[str] = None,
324
+ name: Optional[str] = None,
325
+ generator: Optional[str] = None,
326
+ params: Optional[dict[str, Any]] = None,
327
+ split: str = "train",
328
+ ) -> dict[str, Any]:
329
+ """``POST /v1/predict`` — predictions from the trained model.
330
+
331
+ Supply exactly one of inline ``X`` (optionally with ``dt`` / ``target_dt`` /
332
+ ``seq_lengths``) or a dataset reference. Returns ``{"predictions": ..., "shape": ...}``.
333
+ """
334
+ body: dict[str, Any] = {}
335
+ if X is not None:
336
+ body["X"] = X
337
+ if dt is not None:
338
+ body["dt"] = dt
339
+ if target_dt is not None:
340
+ body["target_dt"] = target_dt
341
+ if seq_lengths is not None:
342
+ body["seq_lengths"] = seq_lengths
343
+ if dataset_id is not None or name is not None or generator is not None:
344
+ body["dataset"] = _dataset_ref(dataset_id=dataset_id, name=name, generator=generator, params=params, split=split)
345
+ return self._parse_json(self._request("POST", ENDPOINT_PREDICT, json=body))
346
+
347
+ # ─── Cross-validation ──────────────────────────────────────────────────────
348
+
349
+ def crossval(
350
+ self,
351
+ *,
352
+ n_folds: int,
353
+ dataset_id: Optional[str] = None,
354
+ name: Optional[str] = None,
355
+ generator: Optional[str] = None,
356
+ params: Optional[dict[str, Any]] = None,
357
+ split: str = "train",
358
+ scheme: str = "expanding",
359
+ embargo: int = 0,
360
+ min_train: Optional[int] = None,
361
+ d: Optional[int] = None,
362
+ theta: Optional[float] = None,
363
+ ridge: Optional[float] = None,
364
+ ) -> dict[str, Any]:
365
+ """``POST /v1/crossval`` — synchronous walk-forward cross-validation over the ``_full`` split.
366
+
367
+ Returns the ``CrossValResponse`` (per-fold ``folds`` + ``eval_aggregate`` / ``eval_std``).
368
+ ``scheme`` is ``"expanding"`` or ``"rolling"``. Raises 409 if a CV run is already running.
369
+ """
370
+ body: dict[str, Any] = {
371
+ "dataset": _dataset_ref(dataset_id=dataset_id, name=name, generator=generator, params=params, split=split),
372
+ "n_folds": n_folds,
373
+ "scheme": scheme,
374
+ "embargo": embargo,
375
+ }
376
+ if min_train is not None:
377
+ body["min_train"] = min_train
378
+ if d is not None:
379
+ body["d"] = d
380
+ if theta is not None:
381
+ body["theta"] = theta
382
+ if ridge is not None:
383
+ body["ridge"] = ridge
384
+ return self._parse_json(self._request("POST", ENDPOINT_CROSSVAL, json=body))
385
+
386
+ def crossval_status(self) -> dict[str, Any]:
387
+ """``GET /v1/crossval/status`` — the most recent cross-validation result, if any."""
388
+ return self._parse_json(self._request("GET", ENDPOINT_CROSSVAL_STATUS))
389
+
390
+ # ─── Inspection ────────────────────────────────────────────────────────────
391
+
392
+ def get_model(self) -> dict[str, Any]:
393
+ """``GET /v1/model`` — the trained model's topology + metrics (409 if none trained)."""
394
+ return self._parse_json(self._request("GET", ENDPOINT_MODEL))
395
+
396
+ def get_dataset(self) -> dict[str, Any]:
397
+ """``GET /v1/dataset`` — descriptor of the split the model was trained on (409 if none)."""
398
+ return self._parse_json(self._request("GET", ENDPOINT_DATASET))
399
+
400
+ # ─── Health / Readiness ────────────────────────────────────────────────────
401
+
402
+ def health_check(self) -> dict[str, Any]:
403
+ """``GET /v1/health`` — liveness."""
404
+ return self._parse_json(self._request("GET", ENDPOINT_HEALTH))
405
+
406
+ def is_ready(self) -> bool:
407
+ """``GET /v1/health/ready`` — ``True`` iff the service reports ready."""
408
+ try:
409
+ payload = self._parse_json(self._request("GET", ENDPOINT_HEALTH_READY))
410
+ except JuniperRecurrenceClientError:
411
+ return False
412
+ return payload.get("status") == HEALTH_READY_STATUS
413
+
414
+ def wait_for_ready(self, timeout: float = DEFAULT_READY_TIMEOUT, poll_interval: float = DEFAULT_READY_POLL_INTERVAL) -> bool:
415
+ """Poll ``/v1/health/ready`` until ready or ``timeout`` seconds elapse."""
416
+ deadline = time.monotonic() + timeout
417
+ while time.monotonic() < deadline:
418
+ if self.is_ready():
419
+ return True
420
+ time.sleep(poll_interval)
421
+ return self.is_ready()
422
+
423
+ # ─── Lifecycle ─────────────────────────────────────────────────────────────
424
+
425
+ def close(self) -> None:
426
+ """Close the underlying HTTP session."""
427
+ self.session.close()
428
+
429
+ def __enter__(self) -> "JuniperRecurrenceClient":
430
+ return self
431
+
432
+ def __exit__(self, *exc: object) -> None:
433
+ self.close()
@@ -0,0 +1,97 @@
1
+ """Protocol-level constants for the juniper-recurrence REST client.
2
+
3
+ Centralizes the literals used by ``client.py`` — base URL, endpoint paths, header names,
4
+ HTTP/retry configuration — mirroring juniper-data-client's constants module so the wire
5
+ contract is discoverable in one place.
6
+ """
7
+
8
+ from typing import List, Tuple
9
+
10
+ __all__ = [
11
+ "DEFAULT_BASE_URL",
12
+ "DEFAULT_TIMEOUT",
13
+ "DEFAULT_RETRIES",
14
+ "DEFAULT_BACKOFF_FACTOR",
15
+ "RETRYABLE_STATUS_CODES",
16
+ "RETRY_ALLOWED_METHODS",
17
+ "HTTP_POOL_CONNECTIONS",
18
+ "HTTP_POOL_MAXSIZE",
19
+ "URL_SCHEME_PREFIXES",
20
+ "DEFAULT_URL_SCHEME_PREFIX",
21
+ "API_VERSION_PATH_SUFFIX",
22
+ "DEFAULT_READY_TIMEOUT",
23
+ "DEFAULT_READY_POLL_INTERVAL",
24
+ "HEALTH_READY_STATUS",
25
+ "API_KEY_HEADER_NAME",
26
+ "API_KEY_ENV_VAR",
27
+ "API_KEY_FILE_ENV_VAR",
28
+ "ENDPOINT_HEALTH",
29
+ "ENDPOINT_HEALTH_READY",
30
+ "ENDPOINT_TRAIN",
31
+ "ENDPOINT_TRAINING_STATUS",
32
+ "ENDPOINT_PREDICT",
33
+ "ENDPOINT_MODEL",
34
+ "ENDPOINT_DATASET",
35
+ "ENDPOINT_CROSSVAL",
36
+ "ENDPOINT_CROSSVAL_STATUS",
37
+ ]
38
+
39
+ # ─── Service Configuration ───────────────────────────────────────────────────
40
+
41
+ # The juniper-recurrence app binds container port 8210; juniper-deploy maps host 8211 -> 8210,
42
+ # so the default host-facing base URL is 8211 (mirrors the deploy port map).
43
+ DEFAULT_BASE_URL: str = "http://localhost:8211"
44
+
45
+ # ─── HTTP Configuration ──────────────────────────────────────────────────────
46
+
47
+ DEFAULT_TIMEOUT: int = 30
48
+ DEFAULT_RETRIES: int = 3
49
+ DEFAULT_BACKOFF_FACTOR: float = 0.5
50
+ RETRYABLE_STATUS_CODES: List[int] = [429, 500, 502, 503, 504]
51
+ # Auto-retry is restricted to idempotent methods (RFC 9110 §9.2.2). The recurrence POSTs
52
+ # (train / predict / crossval) carry server-side state — train and crossval are lock-guarded —
53
+ # so a transient-5xx retry must not silently re-issue them. Only GET/HEAD auto-retry.
54
+ RETRY_ALLOWED_METHODS: List[str] = ["HEAD", "GET"]
55
+ HTTP_POOL_CONNECTIONS: int = 10
56
+ HTTP_POOL_MAXSIZE: int = 10
57
+
58
+ # ─── URL Normalization ───────────────────────────────────────────────────────
59
+
60
+ URL_SCHEME_PREFIXES: Tuple[str, ...] = ("http://", "https://")
61
+ DEFAULT_URL_SCHEME_PREFIX: str = "http://"
62
+ API_VERSION_PATH_SUFFIX: str = "/v1"
63
+
64
+ # ─── Readiness Polling ───────────────────────────────────────────────────────
65
+
66
+ DEFAULT_READY_TIMEOUT: float = 30.0
67
+ DEFAULT_READY_POLL_INTERVAL: float = 0.5
68
+ HEALTH_READY_STATUS: str = "ready"
69
+
70
+ # ─── Authentication ──────────────────────────────────────────────────────────
71
+
72
+ # The recurrence app enforces the X-API-Key header, reading its accepted keys from
73
+ # JUNIPER_RECURRENCE_API_KEYS (plural; CSV or JSON array, with _FILE indirection). The client
74
+ # sends a single key, resolved from the singular JUNIPER_RECURRENCE_API_KEY (and its _FILE
75
+ # Docker-secret form) — document the singular/plural asymmetry in AGENTS.md.
76
+ API_KEY_HEADER_NAME: str = "X-API-Key"
77
+ API_KEY_ENV_VAR: str = "JUNIPER_RECURRENCE_API_KEY"
78
+ API_KEY_FILE_ENV_VAR: str = f"{API_KEY_ENV_VAR}_FILE"
79
+
80
+ # ─── REST Endpoints (the juniper-recurrence app surface) ─────────────────────
81
+
82
+ ENDPOINT_HEALTH: str = "/v1/health"
83
+ ENDPOINT_HEALTH_READY: str = "/v1/health/ready"
84
+ ENDPOINT_TRAIN: str = "/v1/train"
85
+ ENDPOINT_TRAINING_STATUS: str = "/v1/training/status"
86
+ ENDPOINT_PREDICT: str = "/v1/predict"
87
+ ENDPOINT_MODEL: str = "/v1/model"
88
+ ENDPOINT_DATASET: str = "/v1/dataset"
89
+ ENDPOINT_CROSSVAL: str = "/v1/crossval"
90
+ ENDPOINT_CROSSVAL_STATUS: str = "/v1/crossval/status"
91
+
92
+ # ─── HTTP Status Codes ───────────────────────────────────────────────────────
93
+
94
+ HTTP_400_BAD_REQUEST: int = 400
95
+ HTTP_404_NOT_FOUND: int = 404
96
+ HTTP_409_CONFLICT: int = 409
97
+ HTTP_422_UNPROCESSABLE_ENTITY: int = 422
@@ -0,0 +1,36 @@
1
+ """Custom exceptions for the juniper-recurrence client library.
2
+
3
+ Mirrors juniper-data-client's flat hierarchy (one base + typed leaves), adding a
4
+ ``JuniperRecurrenceConflictError`` for the recurrence app's ``409`` responses (a training /
5
+ cross-validation run already in progress, or an operation that needs a trained model that does
6
+ not yet exist) — a status the data-client surface never returns.
7
+ """
8
+
9
+
10
+ class JuniperRecurrenceClientError(Exception):
11
+ """Base exception for all juniper-recurrence client errors."""
12
+
13
+
14
+ class JuniperRecurrenceConnectionError(JuniperRecurrenceClientError):
15
+ """Raised when the connection to the juniper-recurrence service fails."""
16
+
17
+
18
+ class JuniperRecurrenceTimeoutError(JuniperRecurrenceClientError):
19
+ """Raised when a request to the juniper-recurrence service times out."""
20
+
21
+
22
+ class JuniperRecurrenceNotFoundError(JuniperRecurrenceClientError):
23
+ """Raised when a requested resource is not found (404)."""
24
+
25
+
26
+ class JuniperRecurrenceValidationError(JuniperRecurrenceClientError):
27
+ """Raised when request parameters fail validation (400 / 422)."""
28
+
29
+
30
+ class JuniperRecurrenceConflictError(JuniperRecurrenceClientError):
31
+ """Raised on a 409 Conflict — a training/cross-validation run is already in progress, or
32
+ the operation requires a trained model/dataset that does not yet exist."""
33
+
34
+
35
+ class JuniperRecurrenceConfigurationError(JuniperRecurrenceClientError):
36
+ """Raised when juniper-recurrence client configuration is missing or invalid."""
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: juniper-recurrence-client
3
+ Version: 0.1.0
4
+ Summary: HTTP client for the juniper-recurrence service (the Δt-native LMU recurrence model + cross-validation API)
5
+ Author: Paul Calnon
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/pcalnon/juniper-recurrence
8
+ Project-URL: Repository, https://github.com/pcalnon/juniper-recurrence
9
+ Project-URL: Issues, https://github.com/pcalnon/juniper-recurrence/issues
10
+ Keywords: juniper,recurrence,lmu,http-client,rest,time-series,cross-validation
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
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
+ Requires-Python: >=3.12
23
+ Description-Content-Type: text/markdown
24
+ Requires-Dist: requests>=2.28.0
25
+ Requires-Dist: urllib3>=2.0.0
26
+ Provides-Extra: test
27
+ Requires-Dist: pytest>=8.0; extra == "test"
28
+ Requires-Dist: pytest-cov>=5.0; extra == "test"
29
+ Requires-Dist: responses>=0.23; extra == "test"
30
+ Provides-Extra: observability
31
+ Requires-Dist: juniper-observability>=0.3.1; extra == "observability"
32
+
33
+ # juniper-recurrence-client
34
+
35
+ HTTP client for the **juniper-recurrence** service — the FastAPI app wrapping the Δt-native LMU
36
+ recurrence model and its cross-validation API. A lean `requests`-based client mirroring
37
+ [`juniper-data-client`](https://github.com/pcalnon/juniper-data-client) and
38
+ [`juniper-cascor-client`](https://github.com/pcalnon/juniper-cascor-client), so consumers
39
+ (notably **juniper-canopy**'s recurrence backend adapter) drive every Juniper backend the same way.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install juniper-recurrence-client # once published
45
+ pip install -e ".[test]" # local development
46
+ ```
47
+
48
+ `requests`-only at the core; `pip install juniper-recurrence-client[observability]` adds the
49
+ optional `juniper-observability` integration (X-Request-ID propagation + the `on_request` hook).
50
+
51
+ ## Quick start
52
+
53
+ ```python
54
+ from juniper_recurrence_client import JuniperRecurrenceClient
55
+
56
+ client = JuniperRecurrenceClient("http://localhost:8211", api_key="…")
57
+
58
+ # Train the LMU regressor on a dataset (by id / name / generator)
59
+ client.train(name="equities", d=16)
60
+
61
+ # Predict — inline X with Δt, or a dataset reference
62
+ client.predict(dataset_id="ds-1")
63
+
64
+ # Walk-forward cross-validation over the dataset's _full split
65
+ result = client.crossval(name="equities", n_folds=4, scheme="expanding", embargo=2)
66
+ print(result["eval_aggregate"])
67
+
68
+ # Inspect
69
+ client.get_model() # topology + metrics
70
+ client.training_status() # state + events
71
+ client.is_ready() # readiness probe
72
+ ```
73
+
74
+ ## API surface
75
+
76
+ | Method | Endpoint |
77
+ |--------|----------|
78
+ | `train(*, dataset_id / name / generator, params, split, d, theta, ridge)` | `POST /v1/train` |
79
+ | `training_status()` | `GET /v1/training/status` |
80
+ | `predict(*, X / dt / target_dt / seq_lengths, or a dataset ref)` | `POST /v1/predict` |
81
+ | `crossval(*, n_folds, scheme, embargo, min_train, dataset ref, d, theta, ridge)` | `POST /v1/crossval` |
82
+ | `crossval_status()` | `GET /v1/crossval/status` |
83
+ | `get_model()` | `GET /v1/model` |
84
+ | `get_dataset()` | `GET /v1/dataset` |
85
+ | `health_check()` / `is_ready()` / `wait_for_ready()` | `GET /v1/health[/ready]` |
86
+
87
+ ## Authentication
88
+
89
+ Pass `api_key=…`, or set `JUNIPER_RECURRENCE_API_KEY` (or the Docker-secret
90
+ `JUNIPER_RECURRENCE_API_KEY_FILE`, a path whose stripped contents are the key). The key is sent
91
+ as the `X-API-Key` header. Note the asymmetry: the **server** reads the *plural*
92
+ `JUNIPER_RECURRENCE_API_KEYS` (its accepted set); the **client** sends one key under the
93
+ *singular* env var.
94
+
95
+ ## Errors
96
+
97
+ All errors derive from `JuniperRecurrenceClientError`: `JuniperRecurrenceConnectionError`,
98
+ `JuniperRecurrenceTimeoutError`, `JuniperRecurrenceNotFoundError` (404),
99
+ `JuniperRecurrenceConflictError` (409 — a run already in progress, or no trained model yet),
100
+ `JuniperRecurrenceValidationError` (400/422), `JuniperRecurrenceConfigurationError`.
101
+
102
+ ## License
103
+
104
+ MIT — see [LICENSE](https://github.com/pcalnon/juniper-recurrence/blob/main/LICENSE).
@@ -0,0 +1,9 @@
1
+ juniper_recurrence_client/__init__.py,sha256=9syq9UcmxcaAUFKFdDFi0L0OfowLfLhH6Re_6M40184,1241
2
+ juniper_recurrence_client/_version.py,sha256=G5N0gMJr16EzZkJxmZ_-331Ik5n1_GiAiwcdfH5yVBY,257
3
+ juniper_recurrence_client/client.py,sha256=ThQ-lIxYiRj77q1L5kjoi0D6ddrN-LkWVi87cc9cTJw,18484
4
+ juniper_recurrence_client/constants.py,sha256=dAjhsmj2FYoMoMZ9Bzgm_M0Kh--dhLgJ7HqLs_CpAIY,4370
5
+ juniper_recurrence_client/exceptions.py,sha256=s35j9ptFLreRiKwJaTcxx5PqcMalqG9XWQwUDQ5wExY,1495
6
+ juniper_recurrence_client-0.1.0.dist-info/METADATA,sha256=lGn4AThn9DMxFK9AyCRlvEENUtkuX72etiE_ld6S310,4457
7
+ juniper_recurrence_client-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ juniper_recurrence_client-0.1.0.dist-info/top_level.txt,sha256=9vPOg7TzwR0LzObh6LovcuQClP5qPmMabSqEqjnoawo,26
9
+ juniper_recurrence_client-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ juniper_recurrence_client