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 +59 -0
- sequenx/_endpoints.py +44 -0
- sequenx/_http.py +170 -0
- sequenx/_version.py +1 -0
- sequenx/client.py +226 -0
- sequenx/errors.py +84 -0
- sequenx/models.py +25 -0
- sequenx/py.typed +0 -0
- sequenx/types.py +214 -0
- sequenx_ai-0.1.0.dist-info/METADATA +106 -0
- sequenx_ai-0.1.0.dist-info/RECORD +13 -0
- sequenx_ai-0.1.0.dist-info/WHEEL +5 -0
- sequenx_ai-0.1.0.dist-info/top_level.txt +1 -0
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 @@
|
|
|
1
|
+
sequenx
|