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.
- xoople_sdk-0.1.0/LICENSE +23 -0
- xoople_sdk-0.1.0/PKG-INFO +53 -0
- xoople_sdk-0.1.0/README.md +37 -0
- xoople_sdk-0.1.0/pyproject.toml +39 -0
- xoople_sdk-0.1.0/pyproject.toml.orig +47 -0
- xoople_sdk-0.1.0/src/xoople/sdk/__init__.py +21 -0
- xoople_sdk-0.1.0/src/xoople/sdk/_model_base.py +12 -0
- xoople_sdk-0.1.0/src/xoople/sdk/_transport.py +149 -0
- xoople_sdk-0.1.0/src/xoople/sdk/_version.py +17 -0
- xoople_sdk-0.1.0/src/xoople/sdk/auth.py +76 -0
- xoople_sdk-0.1.0/src/xoople/sdk/client.py +439 -0
- xoople_sdk-0.1.0/src/xoople/sdk/config.py +28 -0
- xoople_sdk-0.1.0/src/xoople/sdk/errors.py +119 -0
- xoople_sdk-0.1.0/src/xoople/sdk/models.py +2320 -0
- xoople_sdk-0.1.0/src/xoople/sdk/py.typed +0 -0
xoople_sdk-0.1.0/LICENSE
ADDED
|
@@ -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
|