xoople-sdk 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,23 @@
1
+ Xoople SDK License
2
+
3
+ Copyright (c) 2026 Xoople S.L. All rights reserved.
4
+
5
+ This software is the confidential and proprietary property of Xoople S.L.
6
+
7
+ Permission is granted, free of charge, to any person or organisation holding a
8
+ valid subscription to the Xoople Products API to use, copy, and distribute this
9
+ software, and to modify it for their own internal use, solely for the purpose of
10
+ accessing that API.
11
+
12
+ No other rights are granted. In particular, no right is granted to sell,
13
+ sublicense, or otherwise distribute this software or any derivative work as a
14
+ standalone product, or to use it to develop, operate, or market a service that
15
+ competes with the Xoople Products API. The Xoople name and marks are not
16
+ licensed hereunder.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
20
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL XOOPLE S.L. BE
21
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
22
+ CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
23
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: xoople-sdk
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Xoople Products API.
5
+ License-Expression: LicenseRef-Xoople-Proprietary
6
+ License-File: LICENSE
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Programming Language :: Python :: 3.12
9
+ Requires-Dist: requests>=2.28.1,<3
10
+ Requires-Dist: pydantic>=2.8.2
11
+ Requires-Dist: azure-identity>=1.16 ; extra == 'entra'
12
+ Requires-Python: >=3.12
13
+ Project-URL: Homepage, https://xoople.com
14
+ Provides-Extra: entra
15
+ Description-Content-Type: text/markdown
16
+
17
+ # xoople-sdk
18
+
19
+ Official Python SDK for the Xoople Products API.
20
+
21
+ ```python
22
+ from xoople.sdk import Config, XoopleClient, StaticToken, models
23
+
24
+ client = XoopleClient.from_credentials(
25
+ "https://api.xoople.com", StaticToken("...")
26
+ )
27
+ # Or pass the complete transport configuration directly:
28
+ # client = XoopleClient(Config.from_env())
29
+
30
+ estimate = client.analyses.estimate(request)
31
+ analysis = client.analyses.create(request)
32
+
33
+ # `list` returns a paginator: iterate it for items across every page.
34
+ for run in client.analyses.runs.list(analysis.uid):
35
+ print(run.state)
36
+
37
+ # Sub-resources take the parent analysis uid:
38
+ output = client.analyses.outputs.get(analysis.uid, output_uid)
39
+ access = client.analyses.outputs.generate_access_url(analysis.uid, output_uid, ttl_seconds=900)
40
+
41
+ # Custom verbs are plain methods:
42
+ client.analyses.cancel(analysis.uid)
43
+ client.analyses.schedules.pause(analysis.uid, schedule_uid)
44
+
45
+ # Cross-analysis reads and catalogs are top-level:
46
+ client.schedules.list(state=models.ScheduleStatus.ACTIVE)
47
+ client.usage.limits()
48
+ client.catalog.measures(product="tabular_time_series")
49
+ ```
50
+
51
+ `Config.from_env()` reads `XOOPLE_API_URL` and `XOOPLE_TOKEN`.
52
+
53
+ Every failure raises a subclass of `xoople.sdk.errors.XoopleError`.
@@ -0,0 +1,37 @@
1
+ # xoople-sdk
2
+
3
+ Official Python SDK for the Xoople Products API.
4
+
5
+ ```python
6
+ from xoople.sdk import Config, XoopleClient, StaticToken, models
7
+
8
+ client = XoopleClient.from_credentials(
9
+ "https://api.xoople.com", StaticToken("...")
10
+ )
11
+ # Or pass the complete transport configuration directly:
12
+ # client = XoopleClient(Config.from_env())
13
+
14
+ estimate = client.analyses.estimate(request)
15
+ analysis = client.analyses.create(request)
16
+
17
+ # `list` returns a paginator: iterate it for items across every page.
18
+ for run in client.analyses.runs.list(analysis.uid):
19
+ print(run.state)
20
+
21
+ # Sub-resources take the parent analysis uid:
22
+ output = client.analyses.outputs.get(analysis.uid, output_uid)
23
+ access = client.analyses.outputs.generate_access_url(analysis.uid, output_uid, ttl_seconds=900)
24
+
25
+ # Custom verbs are plain methods:
26
+ client.analyses.cancel(analysis.uid)
27
+ client.analyses.schedules.pause(analysis.uid, schedule_uid)
28
+
29
+ # Cross-analysis reads and catalogs are top-level:
30
+ client.schedules.list(state=models.ScheduleStatus.ACTIVE)
31
+ client.usage.limits()
32
+ client.catalog.measures(product="tabular_time_series")
33
+ ```
34
+
35
+ `Config.from_env()` reads `XOOPLE_API_URL` and `XOOPLE_TOKEN`.
36
+
37
+ Every failure raises a subclass of `xoople.sdk.errors.XoopleError`.
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "xoople-sdk"
3
+ version = "0.1.0"
4
+ description = "Official Python SDK for the Xoople Products API."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "LicenseRef-Xoople-Proprietary"
8
+ license-files = ["LICENSE"]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Programming Language :: Python :: 3.12",
12
+ ]
13
+ dependencies = [
14
+ "requests<3,>=2.28.1",
15
+ "pydantic>=2.8.2",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ entra = ["azure-identity>=1.16"]
20
+
21
+ [project.urls]
22
+ Homepage = "https://xoople.com"
23
+
24
+ [build-system]
25
+ requires = ["uv_build>=0.11.6,<0.12"]
26
+ build-backend = "uv_build"
27
+
28
+ [tool.uv.build-backend]
29
+ module-name = "xoople.sdk"
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "ruff>=0.15.7",
34
+ "pytest>=8.0",
35
+ "pytest-cov>=6.0",
36
+ "types-requests>=2.31",
37
+ "datamodel-code-generator>=0.28",
38
+ "azure-identity>=1.16",
39
+ ]
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "xoople-sdk"
3
+ version = "0.1.0"
4
+ description = "Official Python SDK for the Xoople Products API."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ # Proprietary: the SDK is licensed for use against the Xoople Products API, not
8
+ # redistribution. `license-files` ships the text inside the wheel.
9
+ license = "LicenseRef-Xoople-Proprietary"
10
+ license-files = ["LICENSE"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Programming Language :: Python :: 3.12",
14
+ ]
15
+ dependencies = [
16
+ "requests<3,>=2.28.1",
17
+ "pydantic>=2.8.2",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ # Internal-testing auth: EntraCredential wraps azure-identity. Not needed for
22
+ # the customer API-key / OAuth paths, so kept out of the default install.
23
+ entra = [
24
+ "azure-identity>=1.16",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://xoople.com"
29
+
30
+ [build-system]
31
+ requires = ["uv_build>=0.11.6,<0.12"]
32
+ build-backend = "uv_build"
33
+
34
+ [tool.uv.build-backend]
35
+ module-name = "xoople.sdk"
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "ruff>=0.15.7",
40
+ "pytest>=8.0",
41
+ "pytest-cov>=6.0",
42
+ "types-requests>=2.31",
43
+ "datamodel-code-generator>=0.28",
44
+ # Present in dev so `ty` can resolve the optional azure.identity import;
45
+ # shipped to users only via the `entra` extra above.
46
+ "azure-identity>=1.16",
47
+ ]
@@ -0,0 +1,21 @@
1
+ from xoople.sdk import errors, models
2
+ from xoople.sdk._version import version
3
+ from xoople.sdk.auth import EntraCredential, StaticToken
4
+ from xoople.sdk.client import Estimate, Output, Paginator, XoopleClient
5
+ from xoople.sdk.config import Config, CredentialProvider
6
+
7
+ __version__ = version()
8
+
9
+ __all__ = [
10
+ "Config",
11
+ "CredentialProvider",
12
+ "EntraCredential",
13
+ "Estimate",
14
+ "Output",
15
+ "Paginator",
16
+ "StaticToken",
17
+ "XoopleClient",
18
+ "__version__",
19
+ "errors",
20
+ "models",
21
+ ]
@@ -0,0 +1,12 @@
1
+ from pydantic import BaseModel, ConfigDict
2
+
3
+
4
+ class XoopleModel(BaseModel):
5
+ """Base for generated SDK models. Ignores unknown fields for forward-compat.
6
+
7
+ Codegen overrides ``extra`` to ``"forbid"`` on request-body schemas, where a
8
+ dropped field would silently send the wrong request — see
9
+ ``scripts/postprocess_models.py``.
10
+ """
11
+
12
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
@@ -0,0 +1,149 @@
1
+ import json
2
+ import random
3
+ import time
4
+ from collections.abc import Callable
5
+ from datetime import date
6
+ from typing import TypeVar
7
+
8
+ import requests
9
+ from pydantic import BaseModel
10
+ from xoople.sdk._version import version
11
+ from xoople.sdk.config import Config
12
+ from xoople.sdk.errors import XoopleConnectionError, XoopleResponseError, error_from_response
13
+
14
+ M = TypeVar("M", bound=BaseModel)
15
+
16
+ _RETRY_STATUS = frozenset({429, 500, 502, 503, 504})
17
+ _BACKOFF_CAP = 5.0
18
+
19
+
20
+ def _json_or_text(raw: bytes) -> object:
21
+ try:
22
+ return json.loads(raw)
23
+ except ValueError:
24
+ return raw.decode(errors="replace")
25
+
26
+
27
+ class _Transport:
28
+ def __init__(
29
+ self,
30
+ config: Config,
31
+ session: requests.Session | None = None,
32
+ sleep: Callable[[float], None] = time.sleep,
33
+ rand: Callable[[], float] = random.random,
34
+ ) -> None:
35
+ self._config = config
36
+ self._owns_session = session is None
37
+ self._session = session if session is not None else requests.Session()
38
+ self._sleep = sleep
39
+ self._rand = rand
40
+
41
+ def request(
42
+ self,
43
+ method: str,
44
+ path: str,
45
+ *,
46
+ model: type[M],
47
+ params: dict[str, str | int | date | list[str] | None] | None = None,
48
+ json_body: object | None = None,
49
+ headers: dict[str, str] | None = None,
50
+ replay_safe: bool = False,
51
+ ) -> M:
52
+ url = f"{self._config.base_url}{path}"
53
+ merged: dict[str, str] = {
54
+ "User-Agent": f"xoople-sdk/{version()}",
55
+ "Accept": "application/json",
56
+ **self._config.credentials.auth_header(),
57
+ }
58
+ if headers:
59
+ merged.update(headers)
60
+ payload = (
61
+ json_body.model_dump(mode="json", by_alias=True, exclude_none=True)
62
+ if isinstance(json_body, BaseModel)
63
+ else json_body
64
+ )
65
+ sanitized: dict[str, str | int | list[str]] = {
66
+ k: v.isoformat() if isinstance(v, date) else v
67
+ for k, v in (params or {}).items()
68
+ if v is not None
69
+ }
70
+
71
+ # A write is only safe to replay when the server can dedupe it or the
72
+ # operation has no side effect, so other non-GET requests never retry.
73
+ replayable = method.upper() == "GET" or "Idempotency-Key" in merged or replay_safe
74
+ retries = self._config.max_retries if replayable else 0
75
+
76
+ attempt = 0
77
+ while True:
78
+ try:
79
+ resp = self._session.request(
80
+ method,
81
+ url,
82
+ params=sanitized or None,
83
+ json=payload,
84
+ headers=merged,
85
+ timeout=self._config.timeout,
86
+ )
87
+ except (requests.Timeout, requests.ConnectionError) as exc:
88
+ if attempt < retries:
89
+ self._sleep(self._backoff(attempt))
90
+ attempt += 1
91
+ continue
92
+ raise XoopleConnectionError(f"request to {url} failed: {exc}") from exc
93
+ except requests.RequestException as exc:
94
+ # ChunkedEncodingError, TooManyRedirects and friends: not
95
+ # retryable, but callers still only ever catch XoopleError.
96
+ raise XoopleConnectionError(f"request to {url} failed: {exc}") from exc
97
+
98
+ status = resp.status_code
99
+ if status in _RETRY_STATUS and attempt < retries:
100
+ self._sleep(self._retry_delay(resp, attempt))
101
+ attempt += 1
102
+ continue
103
+
104
+ # The body can still fail mid-stream — a truncated chunked response
105
+ # raises here, not from the call above.
106
+ try:
107
+ raw = resp.content
108
+ except requests.RequestException as exc:
109
+ raise XoopleConnectionError(
110
+ f"reading the response from {url} failed: {exc}"
111
+ ) from exc
112
+
113
+ if status >= 400:
114
+ raise error_from_response(
115
+ status, _json_or_text(raw), trace_id=resp.headers.get("X-Trace-ID")
116
+ )
117
+ try:
118
+ return model.model_validate_json(raw)
119
+ except ValueError as exc:
120
+ raise XoopleResponseError(status, raw.decode(errors="replace")) from exc
121
+
122
+ def _retry_delay(self, resp: requests.Response, attempt: int) -> float:
123
+ # The server's own backpressure hint wins wherever it sends one — the
124
+ # external spec attaches Retry-After to 429 and 503 alike.
125
+ retry_after = self._parse_retry_after(resp)
126
+ if retry_after is not None:
127
+ return min(max(retry_after, 0.0), _BACKOFF_CAP)
128
+ return self._backoff(attempt)
129
+
130
+ def _backoff(self, attempt: int) -> float:
131
+ # Jittered so concurrent clients recovering from one outage do not
132
+ # retry in lockstep.
133
+ return min(2**attempt * 0.1, _BACKOFF_CAP) * (0.5 + 0.5 * self._rand())
134
+
135
+ @staticmethod
136
+ def _parse_retry_after(resp: requests.Response) -> float | None:
137
+ # Only the delta-seconds form is honored; the HTTP-date form falls back
138
+ # to exponential backoff.
139
+ value = resp.headers.get("Retry-After")
140
+ if value is None:
141
+ return None
142
+ try:
143
+ return float(value)
144
+ except ValueError:
145
+ return None
146
+
147
+ def close(self) -> None:
148
+ if self._owns_session:
149
+ self._session.close()
@@ -0,0 +1,17 @@
1
+ from functools import cache
2
+ from importlib.metadata import PackageNotFoundError
3
+ from importlib.metadata import version as _distribution_version
4
+
5
+
6
+ @cache
7
+ def version() -> str:
8
+ """The installed distribution version, reported in the ``User-Agent``.
9
+
10
+ Read from installed metadata so ``pyproject.toml`` stays the only place the
11
+ version is written. Falls back to ``0.0.0`` when the package is imported
12
+ from a source tree that was never installed.
13
+ """
14
+ try:
15
+ return _distribution_version("xoople-sdk")
16
+ except PackageNotFoundError:
17
+ return "0.0.0"
@@ -0,0 +1,76 @@
1
+ import time
2
+ from collections.abc import Callable
3
+ from dataclasses import dataclass
4
+ from typing import Protocol
5
+
6
+ from xoople.sdk.errors import XoopleError
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class StaticToken:
11
+ token: str
12
+
13
+ def auth_header(self) -> dict[str, str]:
14
+ return {"Authorization": f"Bearer {self.token}"}
15
+
16
+
17
+ class _AccessToken(Protocol):
18
+ @property
19
+ def token(self) -> str: ...
20
+
21
+ @property
22
+ def expires_on(self) -> int: ...
23
+
24
+
25
+ class _TokenCredential(Protocol):
26
+ def get_token(self, *scopes: str) -> _AccessToken: ...
27
+
28
+
29
+ def _default_azure_credential() -> _TokenCredential:
30
+ try:
31
+ from azure.identity import DefaultAzureCredential # noqa: PLC0415 — optional extra
32
+ except ImportError as exc:
33
+ raise XoopleError(
34
+ "Entra auth requires the optional dependency. "
35
+ "Install it with: pip install 'xoople-sdk[entra]'"
36
+ ) from exc
37
+ return DefaultAzureCredential()
38
+
39
+
40
+ class EntraCredential:
41
+ """Entra ID (Azure AD) bearer auth — for internal testing against the API.
42
+
43
+ Acquires a token from ``azure-identity`` (``DefaultAzureCredential`` by
44
+ default: `az login`, env service-principal creds, or managed identity) for
45
+ ``scope`` and caches it until shortly before expiry. ``scope`` is the API's
46
+ accepted audience as a token scope, e.g. ``api://<app-id>/.default``.
47
+
48
+ This is not the customer auth path (that is OAuth against the auth service,
49
+ still pending); it exists so internal callers can exercise the live API.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ scope: str,
55
+ credential: _TokenCredential | None = None,
56
+ *,
57
+ expiry_skew_seconds: int = 300,
58
+ now: Callable[[], float] = time.time,
59
+ ) -> None:
60
+ self._scope = scope
61
+ self._credential = credential
62
+ self._skew = expiry_skew_seconds
63
+ self._now = now
64
+ self._cached: tuple[str, int] | None = None
65
+
66
+ def auth_header(self) -> dict[str, str]:
67
+ return {"Authorization": f"Bearer {self._token()}"}
68
+
69
+ def _token(self) -> str:
70
+ if self._cached is not None and self._cached[1] - self._skew > self._now():
71
+ return self._cached[0]
72
+ if self._credential is None:
73
+ self._credential = _default_azure_credential()
74
+ access = self._credential.get_token(self._scope)
75
+ self._cached = (access.token, access.expires_on)
76
+ return access.token