sequenx-ai 0.1.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.
@@ -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,85 @@
1
+ # sequenx (SequenxAI Python client)
2
+
3
+ Python client for the SequenxAI platform API (`packages/contracts/openapi.yaml` is the single
4
+ source of truth for the wire format). Install locally:
5
+
6
+ ```bash
7
+ pip install -e packages/sequenx-py
8
+ ```
9
+
10
+ ```python
11
+ from sequenx import SequenxClient
12
+
13
+ client = SequenxClient("sqx_live_...")
14
+ result = client.forecast(series, h=96) # series: list[dict] with unique_id/ds/y
15
+ print(result["model_used"], result["usage"]["billable_points"])
16
+ client.close()
17
+ ```
18
+
19
+ Covered endpoints (v1): `POST /forecast`, `POST /forecast/rolling`, `POST /forecast/batch`
20
+ (+ get / cancel / delete job), `POST /clean` (dry-run), `GET /models`. `POST /anomaly-detection`
21
+ is intentionally not covered (endpoint not open). PyPI publishing is a separate decision.
22
+
23
+ ## Idempotency and retries
24
+
25
+ The control plane claims an `Idempotency-Key` on `POST /forecast`, `POST /forecast/rolling` and
26
+ `POST /forecast/batch` only: a repeated identical request replays the stored response instead of
27
+ charging again. The SDK sends the key on exactly those three routes and reuses it across retries,
28
+ so a transport error or a 429/5xx is retried safely (`max_retries=0` disables retries).
29
+
30
+ A retry that races the original request can answer 409 (`IDEMPOTENCY_IN_PROGRESS`): the request
31
+ that claimed the key is still running. The SDK retries it with the same key (the control plane
32
+ replays the stored response once it settles). If it still cannot finish, the raised
33
+ `ConflictError` carries the `idempotency_key` it used, so the call can be replayed safely:
34
+
35
+ ```python
36
+ from sequenx import ConflictError
37
+
38
+ try:
39
+ out = client.forecast(series, h=96)
40
+ except ConflictError as exc:
41
+ out = client.forecast(series, h=96, idempotency_key=exc.idempotency_key)
42
+ ```
43
+
44
+ The SDK generates one key per call; pass your own `idempotency_key=` to `forecast`,
45
+ `forecast_rolling` or `forecast_batch` when you want to hold on to it yourself.
46
+
47
+ Every other route is re-run by the control plane, so the SDK neither sends a key nor retries it
48
+ automatically: `POST /clean` (a free dry-run), `POST /forecast/batch/{job_id}/cancel` and
49
+ `DELETE /forecast/batch/{job_id}` (a repeated delete would answer 404 although the first delete
50
+ succeeded — read the job with `batch_job()` when the outcome matters). `GET` requests are retried.
51
+
52
+ ## Batch jobs
53
+
54
+ `forecast_batch()` submits a job; `batch_job()` polls it, `cancel_batch_job()` cancels it and
55
+ `delete_batch_job()` removes a finished job and its result file. Polling is the caller's loop.
56
+
57
+ ## Errors
58
+
59
+ Errors are typed (`AuthenticationError`, `ValidationError`, `RateLimitedError`, `ServerError`,
60
+ `TransportError`, ...) and carry the contract's `code` / `request_id` / `status`.
61
+
62
+ ## Response types
63
+
64
+ Responses are typed too (`ForecastResponse`, `RollingForecastResponse`, `ModelList`,
65
+ `BatchJobSummary`, `BatchJobDetail`, `CleanResponse`, ...); the shapes mirror the contract
66
+ schemas and the package ships `py.typed`. Nested objects the SDK does not model (intervals,
67
+ cleaning report, effective policy) stay plain dictionaries.
68
+
69
+ ## DataFrames
70
+
71
+ `pandas` and `polars` frames are accepted as input (`pip install -e "packages/sequenx-py[pandas]"`
72
+ or `[polars]`); neither is required.
73
+
74
+ ## Development
75
+
76
+ ```bash
77
+ pip install -e packages/sequenx-py -r packages/sequenx-py/requirements-dev.txt
78
+ python -m pytest -q # unit + contract drift tests
79
+ python -m ruff check src tests
80
+ SEQUENX_SDK_LIVE_SMOKE=1 SEQUENX_API_KEY=sqx_live_... python scripts/live_smoke.py
81
+ ```
82
+
83
+ The live smoke is opt-in and never passes silently: without the flag and key it exits 2 with
84
+ instructions. The contract drift test reads `packages/contracts/openapi.yaml` and fails when the
85
+ SDK and the contract disagree; Gitee Go runs it via `scripts/ci/gitee-verify.sh sdk`.
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sequenx-ai"
7
+ dynamic = ["version"]
8
+ description = "Python client for the SequenxAI time-series platform API"
9
+ authors = [{ name = "SequenxAI", email = "lcw_1497007939@163.com" }]
10
+ keywords = ["time-series", "forecasting", "sequenxai"]
11
+ classifiers = [
12
+ "Intended Audience :: Developers",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
18
+ ]
19
+ readme = "README.md"
20
+ requires-python = ">=3.10"
21
+ license = "LicenseRef-Proprietary"
22
+ dependencies = [
23
+ "httpx>=0.27,<1.0",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ pandas = ["pandas>=2.0"]
28
+ polars = ["polars>=1.0"]
29
+
30
+ [tool.setuptools.dynamic]
31
+ version = { attr = "sequenx._version.__version__" }
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
35
+
36
+ [tool.setuptools.package-data]
37
+ sequenx = ["py.typed"]
38
+
39
+ [tool.ruff]
40
+ line-length = 100
41
+ target-version = "py310"
42
+ src = ["src", "tests"]
43
+
44
+ [tool.ruff.lint]
45
+ # Same rule set as the other Python packages in the monorepo.
46
+ select = ["E", "F", "I", "B", "W", "UP"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]
@@ -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
+ }
@@ -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)
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"