sequenx-ai 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.
sequenx/__init__.py ADDED
@@ -0,0 +1,59 @@
1
+ """Python client for the SequenxAI platform API."""
2
+
3
+ from ._version import __version__
4
+ from .client import SequenxClient
5
+ from .errors import (
6
+ AuthenticationError,
7
+ ConflictError,
8
+ NotFoundError,
9
+ RateLimitedError,
10
+ SequenxError,
11
+ ServerError,
12
+ TransportError,
13
+ ValidationError,
14
+ )
15
+ from .types import (
16
+ BatchJobDetail,
17
+ BatchJobProgress,
18
+ BatchJobSummary,
19
+ CleanResponse,
20
+ DataQualityReport,
21
+ ForecastPoint,
22
+ ForecastResponse,
23
+ ForecastWarning,
24
+ ModelCapabilities,
25
+ ModelInfo,
26
+ ModelLimits,
27
+ ModelList,
28
+ RollingForecastPoint,
29
+ RollingForecastResponse,
30
+ UsageMetrics,
31
+ )
32
+
33
+ __all__ = [
34
+ "AuthenticationError",
35
+ "BatchJobDetail",
36
+ "BatchJobProgress",
37
+ "BatchJobSummary",
38
+ "CleanResponse",
39
+ "DataQualityReport",
40
+ "ConflictError",
41
+ "ForecastPoint",
42
+ "ForecastResponse",
43
+ "ForecastWarning",
44
+ "ModelCapabilities",
45
+ "ModelInfo",
46
+ "ModelLimits",
47
+ "ModelList",
48
+ "NotFoundError",
49
+ "RateLimitedError",
50
+ "RollingForecastPoint",
51
+ "RollingForecastResponse",
52
+ "SequenxClient",
53
+ "SequenxError",
54
+ "ServerError",
55
+ "TransportError",
56
+ "UsageMetrics",
57
+ "ValidationError",
58
+ "__version__",
59
+ ]
sequenx/_endpoints.py ADDED
@@ -0,0 +1,44 @@
1
+ """The v1 endpoint surface: one row per client method.
2
+
3
+ Single source of truth shared by the client (method + path + policy) and the
4
+ contract drift test, which asserts that every method/path row below exists in
5
+ packages/contracts/openapi.yaml. Path templates use ``{param}`` placeholders
6
+ filled in by the calling client method.
7
+
8
+ Idempotency: the control plane claims an ``Idempotency-Key`` only on routes that
9
+ run through ``_idempotent -> _admit -> _claim_idempotency``
10
+ (services/api/src/sequenx_api/modules/public_api/router.py), i.e. ``POST
11
+ /forecast``, ``POST /forecast/rolling`` and ``POST /forecast/batch``; the claim is
12
+ settled with the response, and replaying an identical retry returns the stored
13
+ response instead of charging again
14
+ (services/api/tests/test_public_idempotency.py). That replay is what makes a
15
+ retry safe, so only those rows are auto-retried; every other route would simply
16
+ run again.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Endpoint:
26
+ """One HTTP endpoint: wire shape plus its idempotency/retry policy."""
27
+
28
+ method: str
29
+ path: str
30
+ claims_idempotency_key: bool
31
+ auto_retry: bool
32
+
33
+
34
+ ENDPOINTS: dict[str, Endpoint] = {
35
+ # name: method, path, claims_key, auto_retry
36
+ "models": Endpoint("GET", "/models", False, True),
37
+ "forecast": Endpoint("POST", "/forecast", True, True),
38
+ "rolling": Endpoint("POST", "/forecast/rolling", True, True),
39
+ "batch": Endpoint("POST", "/forecast/batch", True, True),
40
+ "batch_job": Endpoint("GET", "/forecast/batch/{job_id}", False, True),
41
+ "cancel_batch_job": Endpoint("POST", "/forecast/batch/{job_id}/cancel", False, False),
42
+ "delete_batch_job": Endpoint("DELETE", "/forecast/batch/{job_id}", False, False),
43
+ "clean": Endpoint("POST", "/clean", False, False),
44
+ }
sequenx/_http.py ADDED
@@ -0,0 +1,170 @@
1
+ """HTTP layer: auth header, retry policy and typed error mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from collections.abc import Mapping
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from ._endpoints import ENDPOINTS
13
+ from ._version import __version__
14
+ from .errors import (
15
+ AuthenticationError,
16
+ ConflictError,
17
+ NotFoundError,
18
+ RateLimitedError,
19
+ SequenxError,
20
+ ServerError,
21
+ TransportError,
22
+ ValidationError,
23
+ )
24
+
25
+ _RETRYABLE_STATUS = {429, 500, 502, 503, 504}
26
+ # 409 is the control plane's IDEMPOTENCY_IN_PROGRESS: the request that claimed
27
+ # the key is still running. Retrying it with the same key returns the stored
28
+ # response once it settles, so it is safe on the routes that carry a key.
29
+ _CONFLICT_STATUS = 409
30
+ _BACKOFF_SECONDS = 0.5
31
+
32
+
33
+ def _error_from_response(
34
+ resp: httpx.Response, idempotency_key: str | None = None
35
+ ) -> SequenxError:
36
+ try:
37
+ body: Any = resp.json()
38
+ except ValueError:
39
+ body = {}
40
+ err: dict[str, Any] = {}
41
+ if isinstance(body, dict):
42
+ inner = body.get("error")
43
+ err = inner if isinstance(inner, dict) else {}
44
+ code = err.get("code")
45
+ message = err.get("message") or f"HTTP {resp.status_code}"
46
+ request_id = err.get("request_id") or resp.headers.get("x-request-id")
47
+ kw: dict[str, Any] = {
48
+ "code": code,
49
+ "request_id": request_id,
50
+ "status": resp.status_code,
51
+ "category": err.get("category"),
52
+ "retryable": err.get("retryable"),
53
+ "details": err.get("details"),
54
+ "idempotency_key": idempotency_key,
55
+ }
56
+ status = resp.status_code
57
+ if status in (401, 403):
58
+ return AuthenticationError(message, **kw)
59
+ if status == 404:
60
+ return NotFoundError(message, **kw)
61
+ if status == 409:
62
+ return ConflictError(message, **kw)
63
+ if status == 422:
64
+ return ValidationError(message, **kw)
65
+ if status == 429:
66
+ retry_after = resp.headers.get("retry-after")
67
+ limit_type = resp.headers.get("x-ratelimit-limit-type")
68
+ return RateLimitedError(
69
+ message,
70
+ retry_after=float(retry_after) if retry_after else None,
71
+ limit_type=limit_type,
72
+ **kw,
73
+ )
74
+ if status >= 500:
75
+ return ServerError(message, **kw)
76
+ return SequenxError(message, **kw)
77
+
78
+
79
+ class _Http:
80
+ """Bearer-auth HTTP calls with per-endpoint retry policy and typed errors."""
81
+
82
+ def __init__(
83
+ self,
84
+ api_key: str,
85
+ base_url: str,
86
+ *,
87
+ timeout: float = 60.0,
88
+ max_retries: int = 2,
89
+ transport: httpx.BaseTransport | None = None,
90
+ sleep: Any = time.sleep,
91
+ ) -> None:
92
+ self._client = httpx.Client(
93
+ base_url=base_url,
94
+ timeout=timeout,
95
+ transport=transport,
96
+ headers={
97
+ "Authorization": f"Bearer {api_key}",
98
+ "User-Agent": f"sequenx-python/{__version__}",
99
+ },
100
+ )
101
+ self._max_retries = max(0, max_retries)
102
+ self._sleep = sleep
103
+
104
+ def close(self) -> None:
105
+ self._client.close()
106
+
107
+ def request(
108
+ self,
109
+ endpoint: str,
110
+ *,
111
+ path_args: Mapping[str, Any] | None = None,
112
+ json_body: Any = None,
113
+ params: dict[str, Any] | None = None,
114
+ idempotency_key: str | None = None,
115
+ ) -> Any:
116
+ """Call one endpoint of the contract by name.
117
+
118
+ The endpoint name selects the method, path and the idempotency/retry
119
+ policy from ``_endpoints.ENDPOINTS``; the key is generated once per call
120
+ and reused by every retry of that call.
121
+ """
122
+ spec = ENDPOINTS[endpoint]
123
+ if idempotency_key is not None and not spec.claims_idempotency_key:
124
+ raise ValueError(
125
+ f"{endpoint}: the control plane ignores Idempotency-Key on "
126
+ f"{spec.method} {spec.path}; it is not claimed for idempotency there"
127
+ )
128
+ headers: dict[str, str] = {}
129
+ key: str | None = None
130
+ if spec.claims_idempotency_key:
131
+ key = idempotency_key or uuid.uuid4().hex
132
+ headers["Idempotency-Key"] = key
133
+ path = spec.path.format(**(path_args or {}))
134
+ attempt = 0
135
+ while True:
136
+ try:
137
+ resp = self._client.request(
138
+ spec.method, path, json=json_body, params=params, headers=headers or None
139
+ )
140
+ except httpx.TransportError as exc:
141
+ if spec.auto_retry and attempt < self._max_retries:
142
+ attempt += 1
143
+ self._sleep(_BACKOFF_SECONDS * attempt)
144
+ continue
145
+ raise TransportError(
146
+ f"{spec.method} {path} failed: {exc}", idempotency_key=key
147
+ ) from exc
148
+ retryable = (
149
+ spec.auto_retry
150
+ and attempt < self._max_retries
151
+ and (
152
+ resp.status_code in _RETRYABLE_STATUS
153
+ or (resp.status_code == _CONFLICT_STATUS and spec.claims_idempotency_key)
154
+ )
155
+ )
156
+ if retryable:
157
+ retry_after = resp.headers.get("retry-after")
158
+ self._sleep(float(retry_after) if retry_after else _BACKOFF_SECONDS * (attempt + 1))
159
+ attempt += 1
160
+ continue
161
+ if resp.is_success:
162
+ if resp.status_code == 204 or not resp.content:
163
+ return None
164
+ try:
165
+ return resp.json()
166
+ except ValueError as exc:
167
+ raise ValidationError(
168
+ f"the API returned a non-JSON body for {spec.method} {path}"
169
+ ) from exc
170
+ raise _error_from_response(resp, key)
sequenx/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
sequenx/client.py ADDED
@@ -0,0 +1,226 @@
1
+ """Synchronous client for the SequenxAI platform API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import time
7
+ from collections.abc import Mapping
8
+ from typing import Any, cast
9
+
10
+ from ._http import _Http
11
+ from .errors import ValidationError
12
+ from .models import to_rows
13
+ from .types import (
14
+ BatchJobDetail,
15
+ BatchJobSummary,
16
+ CleanResponse,
17
+ ForecastResponse,
18
+ ModelList,
19
+ RollingForecastResponse,
20
+ )
21
+
22
+ DEFAULT_BASE_URL = "https://api.sequenxai.com/v1"
23
+
24
+ # Batch job ids are opaque ids minted by the control plane; they are put into a
25
+ # URL path, so anything that could change the path is refused up front.
26
+ _JOB_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
27
+
28
+ _FORECAST_FIELDS = (
29
+ "model",
30
+ "model_options",
31
+ "joint_groups",
32
+ "future_covariates",
33
+ "freq",
34
+ "level",
35
+ "quantile_policy",
36
+ "point_quantile",
37
+ "clean_series",
38
+ "clean_exog",
39
+ "options",
40
+ )
41
+
42
+
43
+ def _forecast_body(series: Any, h: int, kwargs: dict[str, Any]) -> dict[str, Any]:
44
+ body: dict[str, Any] = {"series": to_rows(series), "h": h}
45
+ for field in _FORECAST_FIELDS:
46
+ value = kwargs.pop(field, None)
47
+ if value is not None:
48
+ body[field] = value
49
+ leftover = [k for k in kwargs if kwargs[k] is not None]
50
+ if leftover:
51
+ raise TypeError(f"unknown forecast arguments: {', '.join(sorted(leftover))}")
52
+ return body
53
+
54
+
55
+ def _payload(value: Any, endpoint: str) -> dict[str, Any]:
56
+ """The response body as a dict.
57
+
58
+ An empty body is a broken answer, not an empty one: the SDK raises a typed
59
+ error instead of handing back None (or a TypeError at the caller's side).
60
+ """
61
+ if not isinstance(value, dict):
62
+ raise ValidationError(f"the API returned no JSON body for {endpoint}")
63
+ return value
64
+
65
+
66
+ def _job_id(job_id: str) -> str:
67
+ if not _JOB_ID.match(job_id):
68
+ raise ValueError(f"invalid job id: {job_id!r}")
69
+ return job_id
70
+
71
+
72
+ class SequenxClient:
73
+ """Synchronous client for the SequenxAI public API (contract: openapi.yaml)."""
74
+
75
+ DEFAULT_BASE_URL = DEFAULT_BASE_URL
76
+
77
+ def __init__(
78
+ self,
79
+ api_key: str,
80
+ *,
81
+ base_url: str | None = None,
82
+ timeout: float = 60.0,
83
+ max_retries: int = 2,
84
+ transport: Any = None,
85
+ sleep: Any = time.sleep,
86
+ ) -> None:
87
+ if not api_key:
88
+ raise ValueError("api_key is required")
89
+ self._http = _Http(
90
+ api_key,
91
+ (base_url or DEFAULT_BASE_URL).rstrip("/"),
92
+ timeout=timeout,
93
+ max_retries=max_retries,
94
+ transport=transport,
95
+ sleep=sleep,
96
+ )
97
+
98
+ def close(self) -> None:
99
+ """Release the underlying HTTP connection pool."""
100
+ self._http.close()
101
+
102
+ # -- models ------------------------------------------------------------
103
+ def models(self) -> ModelList:
104
+ """GET /models: the public model catalog for this key."""
105
+ return cast(ModelList, _payload(self._http.request("models"), "GET /models"))
106
+
107
+ # -- forecast ------------------------------------------------------------
108
+ def forecast(
109
+ self, series: Any, *, h: int, idempotency_key: str | None = None, **kw: Any
110
+ ) -> ForecastResponse:
111
+ """POST /forecast: point + intervals for every series."""
112
+ return cast(
113
+ ForecastResponse,
114
+ _payload(
115
+ self._http.request(
116
+ "forecast",
117
+ json_body=_forecast_body(series, h, kw),
118
+ idempotency_key=idempotency_key,
119
+ ),
120
+ "POST /forecast",
121
+ ),
122
+ )
123
+
124
+ def forecast_rolling(
125
+ self,
126
+ series: Any,
127
+ *,
128
+ rolling: Mapping[str, Any],
129
+ h: int,
130
+ idempotency_key: str | None = None,
131
+ **kw: Any,
132
+ ) -> RollingForecastResponse:
133
+ """POST /forecast/rolling: multiple backtest origins inside one series.
134
+
135
+ ``h`` and ``rolling`` are required by the contract
136
+ (``RollingForecastRequest.required``).
137
+ """
138
+ body = _forecast_body(series, h, kw)
139
+ body["rolling"] = dict(rolling)
140
+ return cast(
141
+ RollingForecastResponse,
142
+ _payload(
143
+ self._http.request(
144
+ "rolling", json_body=body, idempotency_key=idempotency_key
145
+ ),
146
+ "POST /forecast/rolling",
147
+ ),
148
+ )
149
+
150
+ def forecast_batch(
151
+ self, series: Any, *, h: int, idempotency_key: str | None = None, **kw: Any
152
+ ) -> BatchJobSummary:
153
+ """POST /forecast/batch: submit an asynchronous batch job (poll with batch_job)."""
154
+ return cast(
155
+ BatchJobSummary,
156
+ _payload(
157
+ self._http.request(
158
+ "batch",
159
+ json_body=_forecast_body(series, h, kw),
160
+ idempotency_key=idempotency_key,
161
+ ),
162
+ "POST /forecast/batch",
163
+ ),
164
+ )
165
+
166
+ def batch_job(self, job_id: str) -> BatchJobDetail:
167
+ """GET /forecast/batch/{job_id}: poll a batch job."""
168
+ return cast(
169
+ BatchJobDetail,
170
+ _payload(
171
+ self._http.request("batch_job", path_args={"job_id": _job_id(job_id)}),
172
+ "GET /forecast/batch/{job_id}",
173
+ ),
174
+ )
175
+
176
+ def cancel_batch_job(self, job_id: str) -> BatchJobDetail:
177
+ """POST /forecast/batch/{job_id}/cancel: cancel a queued or running job.
178
+
179
+ The control plane does not claim an idempotency key on this route, so the
180
+ call is not retried automatically: a repeat would run the cancellation
181
+ again. Call it again yourself if the outcome matters.
182
+ """
183
+ return cast(
184
+ BatchJobDetail,
185
+ _payload(
186
+ self._http.request("cancel_batch_job", path_args={"job_id": _job_id(job_id)}),
187
+ "POST /forecast/batch/{job_id}/cancel",
188
+ ),
189
+ )
190
+
191
+ def delete_batch_job(self, job_id: str) -> None:
192
+ """DELETE /forecast/batch/{job_id}: delete a finished job and its result file.
193
+
194
+ Not retried automatically: after a lost response the job may already be
195
+ gone, and the retry would answer 404 although the delete succeeded. Read
196
+ the job first when the outcome matters.
197
+ """
198
+ self._http.request("delete_batch_job", path_args={"job_id": _job_id(job_id)})
199
+
200
+ # -- clean ------------------------------------------------------------
201
+ def clean(
202
+ self,
203
+ series: Any,
204
+ *,
205
+ options: dict[str, Any] | None = None,
206
+ freq: str | None = None,
207
+ model: str | None = None,
208
+ clean_series: bool | None = None,
209
+ ) -> CleanResponse:
210
+ """POST /clean: free cleaning dry-run (report + decision, no forecast).
211
+
212
+ The optional arguments mirror the contract's CleanRequest: ``freq``,
213
+ ``model`` and ``clean_series`` next to ``options``.
214
+ """
215
+ body: dict[str, Any] = {"series": to_rows(series)}
216
+ if options is not None:
217
+ body["options"] = options
218
+ if freq is not None:
219
+ body["freq"] = freq
220
+ if model is not None:
221
+ body["model"] = model
222
+ if clean_series is not None:
223
+ body["clean_series"] = clean_series
224
+ return cast(
225
+ CleanResponse, _payload(self._http.request("clean", json_body=body), "POST /clean")
226
+ )
sequenx/errors.py ADDED
@@ -0,0 +1,84 @@
1
+ """Typed errors mapped from the contract's ErrorResponse (code/message/request_id)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class SequenxError(Exception):
9
+ """Base error: every API-level and transport failure the SDK raises.
10
+
11
+ ``idempotency_key`` is the key the call carried (None when the route does
12
+ not use one). It is the key to replay the request with, so the control
13
+ plane replays the stored response instead of charging again.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ message: str,
19
+ *,
20
+ code: str | None = None,
21
+ request_id: str | None = None,
22
+ status: int | None = None,
23
+ category: str | None = None,
24
+ retryable: bool | None = None,
25
+ details: Any = None,
26
+ idempotency_key: str | None = None,
27
+ ) -> None:
28
+ super().__init__(message)
29
+ self.code = code
30
+ self.request_id = request_id
31
+ self.status = status
32
+ self.category = category
33
+ self.retryable = retryable
34
+ self.details = details
35
+ self.idempotency_key = idempotency_key
36
+
37
+
38
+ class AuthenticationError(SequenxError):
39
+ """401/403: missing, invalid or unauthorized API key."""
40
+
41
+
42
+ class NotFoundError(SequenxError):
43
+ """404: unknown path or (batch) job id."""
44
+
45
+
46
+ class ValidationError(SequenxError):
47
+ """422: request parsed but its content cannot be used as sent."""
48
+
49
+
50
+ class RateLimitedError(SequenxError):
51
+ """429: RPM or daily-points quota exhausted.
52
+
53
+ ``retry_after`` is the ``Retry-After`` header in seconds and ``limit_type``
54
+ the ``X-RateLimit-Limit-Type`` header (``rpm`` / ``daily``): a daily quota
55
+ only frees up at the next reset, a minute quota in a minute.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ message: str,
61
+ *,
62
+ retry_after: float | None = None,
63
+ limit_type: str | None = None,
64
+ **kw: Any,
65
+ ) -> None:
66
+ super().__init__(message, **kw)
67
+ self.retry_after = retry_after
68
+ self.limit_type = limit_type
69
+
70
+
71
+ class ConflictError(SequenxError):
72
+ """409: a duplicate arrived while the first request with that key is still running.
73
+
74
+ A key reused with a different body is a 422 (ValidationError, code
75
+ ``IDEMPOTENCY_KEY_REUSED``), not a 409.
76
+ """
77
+
78
+
79
+ class ServerError(SequenxError):
80
+ """5xx: control-plane or gateway failure."""
81
+
82
+
83
+ class TransportError(SequenxError):
84
+ """Network-level failure (connection refused, timeout, DNS)."""
sequenx/models.py ADDED
@@ -0,0 +1,25 @@
1
+ """Lightweight helpers for request/response payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+
9
+ def to_rows(data: Any) -> list[dict[str, Any]]:
10
+ """Normalize series input to a list of row mappings.
11
+
12
+ Accepts a list of mappings (unique_id/ds/y) or a pandas/polars DataFrame
13
+ (the extra is optional; it is only used when installed and passed in).
14
+ """
15
+ if isinstance(data, Mapping):
16
+ raise TypeError("series must be a list of rows or a DataFrame, not a single mapping")
17
+ if isinstance(data, list):
18
+ return list(data)
19
+ to_records = getattr(data, "to_dict", None)
20
+ if callable(to_records): # pandas
21
+ return [dict(r) for r in data.to_dict("records")]
22
+ rows = getattr(data, "rows", None)
23
+ if callable(rows): # polars
24
+ return [dict(r) for r in data.rows(named=True)]
25
+ raise TypeError("series must be a list of mappings or a pandas/polars DataFrame")
sequenx/py.typed ADDED
File without changes
sequenx/types.py ADDED
@@ -0,0 +1,214 @@
1
+ """Typed response shapes, mirroring the contract's schemas.
2
+
3
+ Every class below mirrors one schema of packages/contracts/openapi.yaml; the
4
+ drift test asserts that the required keys are exactly the contract's required
5
+ keys and that the optional keys are exactly the contract's remaining
6
+ properties, in both directions. Nested objects the contract
7
+ does not model in the SDK (intervals, cleaning report, effective policy,
8
+ quantile report, rolling windows, and the rolling response's ``data_quality`` /
9
+ ``usage``) stay ``dict[str, Any]``: the wire format is the contract's, not the
10
+ SDK's.
11
+
12
+ Python 3.10 has no ``typing.NotRequired``, so the optional keys live in a
13
+ ``total=False`` base class.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, TypedDict
19
+
20
+
21
+ class _UsageMetricsOptional(TypedDict, total=False):
22
+ forecast_data_points: int
23
+ billable_points: int
24
+
25
+
26
+ class UsageMetrics(_UsageMetricsOptional):
27
+ """Contract: UsageMetrics."""
28
+
29
+ series_count: int
30
+ input_data_points: int
31
+ latency_ms: int
32
+
33
+
34
+ class _DataQualityReportOptional(TypedDict, total=False):
35
+ inferred_freq: str | None
36
+ imputed_points: int
37
+ outlier_points_suppressed: int
38
+ timezone_normalized: bool
39
+ notes: list[str]
40
+
41
+
42
+ class DataQualityReport(_DataQualityReportOptional):
43
+ """Contract: DataQualityReport."""
44
+
45
+ status: str
46
+ missing_ratio: float
47
+
48
+
49
+ class _ForecastPointOptional(TypedDict, total=False):
50
+ intervals: dict[str, Any]
51
+
52
+
53
+ class ForecastPoint(_ForecastPointOptional):
54
+ """Contract: ForecastResultPoint."""
55
+
56
+ unique_id: str
57
+ ds: str
58
+ y_hat: float
59
+
60
+
61
+ class _ForecastWarningOptional(TypedDict, total=False):
62
+ unique_id: str
63
+ count: int
64
+ used: int
65
+ dropped: int
66
+ tail_handling: str
67
+ impact: str
68
+ confirmed: bool
69
+ version: str
70
+ columns: list[Any]
71
+ native_horizon: int
72
+
73
+
74
+ class ForecastWarning(_ForecastWarningOptional):
75
+ """Contract: ForecastWarning."""
76
+
77
+ code: str
78
+ message: str
79
+
80
+
81
+ class _ModelLimitsOptional(TypedDict, total=False):
82
+ freq: str
83
+
84
+
85
+ class _RollingForecastPointOptional(TypedDict, total=False):
86
+ intervals: dict[str, Any]
87
+
88
+
89
+ class RollingForecastPoint(_RollingForecastPointOptional):
90
+ """Contract: RollingForecastPoint (a row of the rolling response)."""
91
+
92
+ unique_id: str
93
+ cutoff: str
94
+ step: int
95
+ ds: str
96
+ y_hat: float
97
+
98
+
99
+ class ModelLimits(_ModelLimitsOptional):
100
+ """Contract: ModelLimits."""
101
+
102
+ min_context: int | None
103
+ max_context: int | None
104
+ max_horizon: int | None
105
+ context_horizon_budget: int | None
106
+
107
+
108
+ class ModelCapabilities(TypedDict):
109
+ """Contract: ModelCapabilities."""
110
+
111
+ covariates: str
112
+ joint: str
113
+
114
+
115
+ class ModelInfo(TypedDict):
116
+ """Contract: ModelInfo."""
117
+
118
+ id: str
119
+ aliases: list[str]
120
+ limits: ModelLimits
121
+ quantiles: list[float]
122
+ capabilities: ModelCapabilities
123
+
124
+
125
+ class ModelList(TypedDict):
126
+ """Contract: ModelList."""
127
+
128
+ default: str | None
129
+ models: list[ModelInfo]
130
+ request_id: str
131
+
132
+
133
+ class _ForecastResponseOptional(TypedDict, total=False):
134
+ freq: str
135
+ point_quantile: float
136
+ cleaning_report: dict[str, Any]
137
+ effective_policy: dict[str, Any]
138
+ model_input: list[dict[str, Any]]
139
+ quantile_report: dict[str, Any]
140
+ warnings: list[ForecastWarning]
141
+
142
+
143
+ class ForecastResponse(_ForecastResponseOptional):
144
+ """Contract: ForecastResponse."""
145
+
146
+ request_id: str
147
+ model_used: str
148
+ forecast: list[ForecastPoint]
149
+ data_quality: DataQualityReport
150
+ usage: UsageMetrics
151
+
152
+
153
+ class _RollingForecastResponseOptional(TypedDict, total=False):
154
+ point_quantile: float
155
+ effective_policy: dict[str, Any]
156
+ quantile_report: dict[str, Any]
157
+ warnings: list[ForecastWarning]
158
+
159
+
160
+ class RollingForecastResponse(_RollingForecastResponseOptional):
161
+ """Contract: RollingForecastResponse."""
162
+
163
+ request_id: str
164
+ model_used: str
165
+ freq: str
166
+ rolling: dict[str, Any]
167
+ forecast: list[RollingForecastPoint]
168
+ windows: list[dict[str, Any]]
169
+ data_quality: dict[str, Any]
170
+ usage: dict[str, Any]
171
+
172
+
173
+ class BatchJobProgress(TypedDict):
174
+ """Contract: BatchJobProgress."""
175
+
176
+ done_series: int
177
+ total_series: int
178
+
179
+
180
+ class BatchJobSummary(TypedDict):
181
+ """Contract: BatchJobSummary."""
182
+
183
+ job_id: str
184
+ status: str
185
+ progress: BatchJobProgress
186
+ created_at: str
187
+
188
+
189
+ class BatchJobDetail(TypedDict):
190
+ """Contract: BatchJobDetail."""
191
+
192
+ job_id: str
193
+ status: str
194
+ model: str
195
+ h: int
196
+ progress: BatchJobProgress
197
+ created_at: str
198
+ started_at: str | None
199
+ finished_at: str | None
200
+ result: dict[str, Any] | None
201
+ error: dict[str, Any] | None
202
+
203
+
204
+ class _CleanResponseOptional(TypedDict, total=False):
205
+ warnings: list[ForecastWarning]
206
+
207
+
208
+ class CleanResponse(_CleanResponseOptional):
209
+ """Contract: CleanResponse."""
210
+
211
+ request_id: str
212
+ freq: str
213
+ cleaning_report: dict[str, Any]
214
+ effective_policy: dict[str, Any]
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: sequenx-ai
3
+ Version: 0.1.0
4
+ Summary: Python client for the SequenxAI time-series platform API
5
+ Author-email: SequenxAI <lcw_1497007939@163.com>
6
+ License-Expression: LicenseRef-Proprietary
7
+ Keywords: time-series,forecasting,sequenxai
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: httpx<1.0,>=0.27
17
+ Provides-Extra: pandas
18
+ Requires-Dist: pandas>=2.0; extra == "pandas"
19
+ Provides-Extra: polars
20
+ Requires-Dist: polars>=1.0; extra == "polars"
21
+
22
+ # sequenx (SequenxAI Python client)
23
+
24
+ Python client for the SequenxAI platform API (`packages/contracts/openapi.yaml` is the single
25
+ source of truth for the wire format). Install locally:
26
+
27
+ ```bash
28
+ pip install -e packages/sequenx-py
29
+ ```
30
+
31
+ ```python
32
+ from sequenx import SequenxClient
33
+
34
+ client = SequenxClient("sqx_live_...")
35
+ result = client.forecast(series, h=96) # series: list[dict] with unique_id/ds/y
36
+ print(result["model_used"], result["usage"]["billable_points"])
37
+ client.close()
38
+ ```
39
+
40
+ Covered endpoints (v1): `POST /forecast`, `POST /forecast/rolling`, `POST /forecast/batch`
41
+ (+ get / cancel / delete job), `POST /clean` (dry-run), `GET /models`. `POST /anomaly-detection`
42
+ is intentionally not covered (endpoint not open). PyPI publishing is a separate decision.
43
+
44
+ ## Idempotency and retries
45
+
46
+ The control plane claims an `Idempotency-Key` on `POST /forecast`, `POST /forecast/rolling` and
47
+ `POST /forecast/batch` only: a repeated identical request replays the stored response instead of
48
+ charging again. The SDK sends the key on exactly those three routes and reuses it across retries,
49
+ so a transport error or a 429/5xx is retried safely (`max_retries=0` disables retries).
50
+
51
+ A retry that races the original request can answer 409 (`IDEMPOTENCY_IN_PROGRESS`): the request
52
+ that claimed the key is still running. The SDK retries it with the same key (the control plane
53
+ replays the stored response once it settles). If it still cannot finish, the raised
54
+ `ConflictError` carries the `idempotency_key` it used, so the call can be replayed safely:
55
+
56
+ ```python
57
+ from sequenx import ConflictError
58
+
59
+ try:
60
+ out = client.forecast(series, h=96)
61
+ except ConflictError as exc:
62
+ out = client.forecast(series, h=96, idempotency_key=exc.idempotency_key)
63
+ ```
64
+
65
+ The SDK generates one key per call; pass your own `idempotency_key=` to `forecast`,
66
+ `forecast_rolling` or `forecast_batch` when you want to hold on to it yourself.
67
+
68
+ Every other route is re-run by the control plane, so the SDK neither sends a key nor retries it
69
+ automatically: `POST /clean` (a free dry-run), `POST /forecast/batch/{job_id}/cancel` and
70
+ `DELETE /forecast/batch/{job_id}` (a repeated delete would answer 404 although the first delete
71
+ succeeded — read the job with `batch_job()` when the outcome matters). `GET` requests are retried.
72
+
73
+ ## Batch jobs
74
+
75
+ `forecast_batch()` submits a job; `batch_job()` polls it, `cancel_batch_job()` cancels it and
76
+ `delete_batch_job()` removes a finished job and its result file. Polling is the caller's loop.
77
+
78
+ ## Errors
79
+
80
+ Errors are typed (`AuthenticationError`, `ValidationError`, `RateLimitedError`, `ServerError`,
81
+ `TransportError`, ...) and carry the contract's `code` / `request_id` / `status`.
82
+
83
+ ## Response types
84
+
85
+ Responses are typed too (`ForecastResponse`, `RollingForecastResponse`, `ModelList`,
86
+ `BatchJobSummary`, `BatchJobDetail`, `CleanResponse`, ...); the shapes mirror the contract
87
+ schemas and the package ships `py.typed`. Nested objects the SDK does not model (intervals,
88
+ cleaning report, effective policy) stay plain dictionaries.
89
+
90
+ ## DataFrames
91
+
92
+ `pandas` and `polars` frames are accepted as input (`pip install -e "packages/sequenx-py[pandas]"`
93
+ or `[polars]`); neither is required.
94
+
95
+ ## Development
96
+
97
+ ```bash
98
+ pip install -e packages/sequenx-py -r packages/sequenx-py/requirements-dev.txt
99
+ python -m pytest -q # unit + contract drift tests
100
+ python -m ruff check src tests
101
+ SEQUENX_SDK_LIVE_SMOKE=1 SEQUENX_API_KEY=sqx_live_... python scripts/live_smoke.py
102
+ ```
103
+
104
+ The live smoke is opt-in and never passes silently: without the flag and key it exits 2 with
105
+ instructions. The contract drift test reads `packages/contracts/openapi.yaml` and fails when the
106
+ SDK and the contract disagree; Gitee Go runs it via `scripts/ci/gitee-verify.sh sdk`.
@@ -0,0 +1,13 @@
1
+ sequenx/__init__.py,sha256=cvviZRp1e4gIB-wJZZs6wgoHtFlTivN6mENZlYC76xA,1211
2
+ sequenx/_endpoints.py,sha256=SHpF2cBfnwbLWQvMQBHpeipJfMb57FArCPq8_SnuxHs,1822
3
+ sequenx/_http.py,sha256=Hw2-UTlBo-5-VbBF6X0DQsjBc9dYaKktDcRQxmcXUSM,5786
4
+ sequenx/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
5
+ sequenx/client.py,sha256=cN33eMU7PgyNDQPJZt45eSRlgfp_HAXSFIjJAQEnzBI,7385
6
+ sequenx/errors.py,sha256=MJKFOxQd3Dar2b7qxo5FVUvHSNfjkZq14watc3elbzs,2443
7
+ sequenx/models.py,sha256=jP4nhfp5fZl4-MQoT0-xRw5m6PKRDD8lcOlWIOHzLMA,965
8
+ sequenx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ sequenx/types.py,sha256=DTlmQ1t_56L0WuriIsOU6M_ArTedb09hQ7ay9SlTFAQ,4851
10
+ sequenx_ai-0.1.0.dist-info/METADATA,sha256=QmUm6CEY7RLrFdzS2QTVwNov7RJgQD8DTO4syDGLVNM,4550
11
+ sequenx_ai-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
12
+ sequenx_ai-0.1.0.dist-info/top_level.txt,sha256=2p30V9_RZheJb1EdRGxBitsBbeArMYUU4uwdIUb1eQ0,8
13
+ sequenx_ai-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ sequenx