cnovatech-sdk-core 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.
- cnovatech/sdk_core/__init__.py +24 -0
- cnovatech/sdk_core/client.py +146 -0
- cnovatech/sdk_core/config.py +257 -0
- cnovatech/sdk_core/dataframe.py +159 -0
- cnovatech/sdk_core/errors.py +450 -0
- cnovatech/sdk_core/metadata.py +23 -0
- cnovatech/sdk_core/models.py +95 -0
- cnovatech/sdk_core/operation.py +96 -0
- cnovatech/sdk_core/pagination.py +211 -0
- cnovatech/sdk_core/pricing.py +21 -0
- cnovatech/sdk_core/py.typed +0 -0
- cnovatech/sdk_core/resource.py +71 -0
- cnovatech/sdk_core/retry.py +119 -0
- cnovatech/sdk_core/transport.py +530 -0
- cnovatech/sdk_core/usage.py +110 -0
- cnovatech/sdk_core/validation.py +92 -0
- cnovatech_sdk_core-0.1.0.dist-info/METADATA +53 -0
- cnovatech_sdk_core-0.1.0.dist-info/RECORD +20 -0
- cnovatech_sdk_core-0.1.0.dist-info/WHEEL +4 -0
- cnovatech_sdk_core-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Shared foundation of the Cnovatech Python SDKs.
|
|
2
|
+
|
|
3
|
+
This package is not used directly by application code. The product SDKs build
|
|
4
|
+
on its modules (``config``, ``transport``, ``client``, ``resource``, ``models``,
|
|
5
|
+
``pagination``, ``errors``, ...) and re-export what their users need. It knows
|
|
6
|
+
nothing about any product: no endpoint, field name or domain concept lives
|
|
7
|
+
here, and CI enforces that boundary.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
from .config import CORE_DISTRIBUTION_NAME
|
|
15
|
+
from .metadata import read_distribution_version
|
|
16
|
+
|
|
17
|
+
__version__ = read_distribution_version(CORE_DISTRIBUTION_NAME)
|
|
18
|
+
|
|
19
|
+
# Libraries log but never configure logging. The null handler keeps Python from
|
|
20
|
+
# printing "No handlers could be found" when the application has not set up
|
|
21
|
+
# logging; the application routes the `cnovatech` logger wherever it wants.
|
|
22
|
+
logging.getLogger("cnovatech").addHandler(logging.NullHandler())
|
|
23
|
+
|
|
24
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""What every product client shares: configuration, transport, accounting and lifecycle.
|
|
2
|
+
|
|
3
|
+
A product SDK subclasses :class:`BaseSyncClient` and :class:`BaseAsyncClient`,
|
|
4
|
+
binds its :class:`ProductSettings` and attaches its resources. Everything
|
|
5
|
+
else, from reading the environment to closing the connection pool, is here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from .config import DEFAULT_MAX_RETRIES, DEFAULT_READ_TIMEOUT_SECONDS, build_client_config
|
|
13
|
+
from .transport import AsyncTransport, SyncTransport
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from types import TracebackType
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from typing_extensions import Self
|
|
20
|
+
|
|
21
|
+
from .config import ProductSettings
|
|
22
|
+
from .usage import RateLimitState, UsageTracker
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BaseSyncClient:
|
|
26
|
+
"""A client using blocking I/O. See the product client for the constructor arguments."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
product: ProductSettings,
|
|
31
|
+
api_key: str | None = None,
|
|
32
|
+
*,
|
|
33
|
+
base_url: str | None = None,
|
|
34
|
+
timeout: float | httpx.Timeout = DEFAULT_READ_TIMEOUT_SECONDS,
|
|
35
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
36
|
+
max_units_per_request: int | None = None,
|
|
37
|
+
http_client: httpx.Client | None = None,
|
|
38
|
+
transport: httpx.BaseTransport | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._config = build_client_config(
|
|
41
|
+
product,
|
|
42
|
+
api_key=api_key,
|
|
43
|
+
base_url=base_url,
|
|
44
|
+
timeout=timeout,
|
|
45
|
+
max_retries=max_retries,
|
|
46
|
+
max_units_per_request=max_units_per_request,
|
|
47
|
+
)
|
|
48
|
+
self._transport = SyncTransport(self._config, http_client=http_client, transport=transport)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def base_url(self) -> str:
|
|
52
|
+
"""Origin the client talks to."""
|
|
53
|
+
return self._config.base_url
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def usage(self) -> UsageTracker:
|
|
57
|
+
"""Quota spent through this client: ``spent``, ``calls`` and the ``last`` usage block."""
|
|
58
|
+
return self._transport.usage
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def rate_limit(self) -> RateLimitState:
|
|
62
|
+
"""The per-minute rate limit as last reported by the API."""
|
|
63
|
+
return self._transport.rate_limit
|
|
64
|
+
|
|
65
|
+
def close(self) -> None:
|
|
66
|
+
"""Release the connection pool. Clients supplied through ``http_client`` are left open."""
|
|
67
|
+
self._transport.close()
|
|
68
|
+
|
|
69
|
+
def __enter__(self) -> Self:
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def __exit__(
|
|
73
|
+
self,
|
|
74
|
+
exc_type: type[BaseException] | None,
|
|
75
|
+
exc: BaseException | None,
|
|
76
|
+
traceback: TracebackType | None,
|
|
77
|
+
) -> None:
|
|
78
|
+
self.close()
|
|
79
|
+
|
|
80
|
+
def __repr__(self) -> str:
|
|
81
|
+
return (
|
|
82
|
+
f"{type(self).__name__}(base_url={self._config.base_url!r}, "
|
|
83
|
+
f"api_key={self._config.masked_api_key!r})"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class BaseAsyncClient:
|
|
88
|
+
"""A client using asyncio. See the product client for the constructor arguments."""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
product: ProductSettings,
|
|
93
|
+
api_key: str | None = None,
|
|
94
|
+
*,
|
|
95
|
+
base_url: str | None = None,
|
|
96
|
+
timeout: float | httpx.Timeout = DEFAULT_READ_TIMEOUT_SECONDS,
|
|
97
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
98
|
+
max_units_per_request: int | None = None,
|
|
99
|
+
http_client: httpx.AsyncClient | None = None,
|
|
100
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
self._config = build_client_config(
|
|
103
|
+
product,
|
|
104
|
+
api_key=api_key,
|
|
105
|
+
base_url=base_url,
|
|
106
|
+
timeout=timeout,
|
|
107
|
+
max_retries=max_retries,
|
|
108
|
+
max_units_per_request=max_units_per_request,
|
|
109
|
+
)
|
|
110
|
+
self._transport = AsyncTransport(self._config, http_client=http_client, transport=transport)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def base_url(self) -> str:
|
|
114
|
+
"""Origin the client talks to."""
|
|
115
|
+
return self._config.base_url
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def usage(self) -> UsageTracker:
|
|
119
|
+
"""Quota spent through this client: ``spent``, ``calls`` and the ``last`` usage block."""
|
|
120
|
+
return self._transport.usage
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def rate_limit(self) -> RateLimitState:
|
|
124
|
+
"""The per-minute rate limit as last reported by the API."""
|
|
125
|
+
return self._transport.rate_limit
|
|
126
|
+
|
|
127
|
+
async def aclose(self) -> None:
|
|
128
|
+
"""Release the connection pool. Clients supplied through ``http_client`` are left open."""
|
|
129
|
+
await self._transport.aclose()
|
|
130
|
+
|
|
131
|
+
async def __aenter__(self) -> Self:
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
async def __aexit__(
|
|
135
|
+
self,
|
|
136
|
+
exc_type: type[BaseException] | None,
|
|
137
|
+
exc: BaseException | None,
|
|
138
|
+
traceback: TracebackType | None,
|
|
139
|
+
) -> None:
|
|
140
|
+
await self.aclose()
|
|
141
|
+
|
|
142
|
+
def __repr__(self) -> str:
|
|
143
|
+
return (
|
|
144
|
+
f"{type(self).__name__}(base_url={self._config.base_url!r}, "
|
|
145
|
+
f"api_key={self._config.masked_api_key!r})"
|
|
146
|
+
)
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Client configuration shared by every Cnovatech SDK.
|
|
2
|
+
|
|
3
|
+
A product SDK describes itself once through :class:`ProductSettings`. The core
|
|
4
|
+
combines that with the constructor arguments and the environment into an
|
|
5
|
+
immutable :class:`ClientConfig`, validating everything before any request is
|
|
6
|
+
sent.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from pydantic import SecretStr
|
|
20
|
+
|
|
21
|
+
from .errors import APIStatusError, ConfigurationError
|
|
22
|
+
from .metadata import read_distribution_version
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Mapping
|
|
26
|
+
|
|
27
|
+
CORE_DISTRIBUTION_NAME = "cnovatech-sdk-core"
|
|
28
|
+
|
|
29
|
+
DEFAULT_READ_TIMEOUT_SECONDS = 150.0
|
|
30
|
+
DEFAULT_CONNECT_TIMEOUT_SECONDS = 10.0
|
|
31
|
+
DEFAULT_WRITE_TIMEOUT_SECONDS = 10.0
|
|
32
|
+
DEFAULT_POOL_TIMEOUT_SECONDS = 10.0
|
|
33
|
+
# The server closes any request that runs longer than this.
|
|
34
|
+
MAX_READ_TIMEOUT_SECONDS = 300.0
|
|
35
|
+
DEFAULT_MAX_RETRIES = 2
|
|
36
|
+
|
|
37
|
+
API_KEY_ENV_SUFFIX = "API_KEY"
|
|
38
|
+
BASE_URL_ENV_SUFFIX = "BASE_URL"
|
|
39
|
+
|
|
40
|
+
_LOOPBACK_HOSTNAMES = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
41
|
+
_MASK_PREFIX_MAX_LENGTH = 4
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class ProductSettings:
|
|
46
|
+
"""What a product SDK tells the core about itself.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
distribution_name: Name on PyPI. Appears in the ``User-Agent`` header.
|
|
50
|
+
version: Installed version of the product SDK.
|
|
51
|
+
env_prefix: Prefix of the environment variables the client reads,
|
|
52
|
+
for example ``"DATAMARKET_"``.
|
|
53
|
+
default_base_url: Production origin of the API, without a trailing slash.
|
|
54
|
+
api_prefix: Path prefix shared by the authenticated endpoints.
|
|
55
|
+
api_key_header: Header that carries the API key.
|
|
56
|
+
error_overrides: Exception classes to raise for specific error codes,
|
|
57
|
+
in place of the class chosen by HTTP status.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
distribution_name: str
|
|
61
|
+
version: str
|
|
62
|
+
env_prefix: str
|
|
63
|
+
default_base_url: str
|
|
64
|
+
api_prefix: str = "/api/v1"
|
|
65
|
+
api_key_header: str = "X-API-Key"
|
|
66
|
+
error_overrides: Mapping[str, type[APIStatusError]] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class ClientConfig:
|
|
71
|
+
"""Validated settings of one client instance. Build it with :func:`build_client_config`."""
|
|
72
|
+
|
|
73
|
+
product: ProductSettings
|
|
74
|
+
api_key: SecretStr | None
|
|
75
|
+
base_url: str
|
|
76
|
+
timeout: httpx.Timeout
|
|
77
|
+
max_retries: int
|
|
78
|
+
max_units_per_request: int | None
|
|
79
|
+
user_agent: str
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def hostname(self) -> str:
|
|
83
|
+
"""Host part of ``base_url``, safe to show in messages."""
|
|
84
|
+
return urlsplit(self.base_url).hostname or self.base_url
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def read_timeout_seconds(self) -> float:
|
|
88
|
+
"""Read timeout in seconds. :func:`build_timeout` guarantees it is set."""
|
|
89
|
+
read_timeout = self.timeout.read
|
|
90
|
+
if read_timeout is None:
|
|
91
|
+
raise ConfigurationError("The read timeout must be set.")
|
|
92
|
+
return float(read_timeout)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def masked_api_key(self) -> str:
|
|
96
|
+
"""The API key as it may appear in ``repr`` and logs."""
|
|
97
|
+
return mask_api_key(self.api_key)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def mask_api_key(api_key: SecretStr | None) -> str:
|
|
101
|
+
"""Redact a key, keeping only a short public prefix such as ``dm_****``."""
|
|
102
|
+
if api_key is None:
|
|
103
|
+
return "<not set>"
|
|
104
|
+
prefix, separator, _ = api_key.get_secret_value().partition("_")
|
|
105
|
+
if separator and 0 < len(prefix) <= _MASK_PREFIX_MAX_LENGTH:
|
|
106
|
+
return f"{prefix}_****"
|
|
107
|
+
return "****"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_user_agent(product: ProductSettings) -> str:
|
|
111
|
+
"""Identify the product SDK, the core, Python and httpx, in that order."""
|
|
112
|
+
python_version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
113
|
+
core_version = read_distribution_version(CORE_DISTRIBUTION_NAME)
|
|
114
|
+
return (
|
|
115
|
+
f"{product.distribution_name}/{product.version} "
|
|
116
|
+
f"{CORE_DISTRIBUTION_NAME}/{core_version} "
|
|
117
|
+
f"python/{python_version} httpx/{httpx.__version__}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def build_client_config(
|
|
122
|
+
product: ProductSettings,
|
|
123
|
+
*,
|
|
124
|
+
api_key: str | None = None,
|
|
125
|
+
base_url: str | None = None,
|
|
126
|
+
timeout: float | httpx.Timeout = DEFAULT_READ_TIMEOUT_SECONDS,
|
|
127
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
128
|
+
max_units_per_request: int | None = None,
|
|
129
|
+
environ: Mapping[str, str] | None = None,
|
|
130
|
+
) -> ClientConfig:
|
|
131
|
+
"""Resolve and validate the settings of a client.
|
|
132
|
+
|
|
133
|
+
Explicit arguments win over environment variables, which win over the
|
|
134
|
+
product defaults. A blank string counts as not given, at every level.
|
|
135
|
+
``environ`` replaces ``os.environ`` in tests.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
ConfigurationError: A value is invalid. Nothing has been sent.
|
|
139
|
+
"""
|
|
140
|
+
env = os.environ if environ is None else environ
|
|
141
|
+
api_key_value = _first_present(api_key, env.get(product.env_prefix + API_KEY_ENV_SUFFIX))
|
|
142
|
+
base_url_value = (
|
|
143
|
+
_first_present(base_url, env.get(product.env_prefix + BASE_URL_ENV_SUFFIX))
|
|
144
|
+
or product.default_base_url
|
|
145
|
+
)
|
|
146
|
+
return ClientConfig(
|
|
147
|
+
product=product,
|
|
148
|
+
api_key=_validate_api_key(api_key_value) if api_key_value is not None else None,
|
|
149
|
+
base_url=validate_base_url(base_url_value),
|
|
150
|
+
timeout=build_timeout(timeout),
|
|
151
|
+
max_retries=_validate_max_retries(max_retries),
|
|
152
|
+
max_units_per_request=_validate_max_units_per_request(max_units_per_request),
|
|
153
|
+
user_agent=build_user_agent(product),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def validate_base_url(base_url: str) -> str:
|
|
158
|
+
"""Normalise a base URL and reject anything the client must not talk to.
|
|
159
|
+
|
|
160
|
+
The URL must be absolute, name a host, and use HTTPS. Plain HTTP is accepted
|
|
161
|
+
only for loopback addresses, so a local API can be used in development
|
|
162
|
+
without ever sending a key in clear text over a network.
|
|
163
|
+
"""
|
|
164
|
+
candidate = base_url.strip().rstrip("/")
|
|
165
|
+
parts = urlsplit(candidate)
|
|
166
|
+
if parts.scheme not in {"http", "https"} or not parts.hostname:
|
|
167
|
+
raise ConfigurationError(
|
|
168
|
+
f"base_url must be an absolute http(s) URL with a host, got {base_url!r}."
|
|
169
|
+
)
|
|
170
|
+
if parts.query or parts.fragment:
|
|
171
|
+
raise ConfigurationError("base_url must not carry a query string or fragment.")
|
|
172
|
+
if parts.scheme == "http" and parts.hostname not in _LOOPBACK_HOSTNAMES:
|
|
173
|
+
raise ConfigurationError(
|
|
174
|
+
f"base_url must use https; plain http is only accepted for loopback, got {base_url!r}."
|
|
175
|
+
)
|
|
176
|
+
return candidate
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def build_timeout(timeout: float | httpx.Timeout) -> httpx.Timeout:
|
|
180
|
+
"""Turn the constructor's ``timeout`` into a complete httpx timeout.
|
|
181
|
+
|
|
182
|
+
A number sets the read timeout only; connect, write and pool keep their
|
|
183
|
+
defaults. An ``httpx.Timeout`` is used as given. The read timeout must be
|
|
184
|
+
a finite number greater than zero and at most :data:`MAX_READ_TIMEOUT_SECONDS`.
|
|
185
|
+
"""
|
|
186
|
+
if isinstance(timeout, httpx.Timeout):
|
|
187
|
+
resolved = timeout
|
|
188
|
+
else:
|
|
189
|
+
if not _is_number(timeout):
|
|
190
|
+
raise ConfigurationError(
|
|
191
|
+
f"timeout must be a number of seconds or an httpx.Timeout, got {timeout!r}."
|
|
192
|
+
)
|
|
193
|
+
resolved = httpx.Timeout(
|
|
194
|
+
connect=DEFAULT_CONNECT_TIMEOUT_SECONDS,
|
|
195
|
+
read=float(timeout),
|
|
196
|
+
write=DEFAULT_WRITE_TIMEOUT_SECONDS,
|
|
197
|
+
pool=DEFAULT_POOL_TIMEOUT_SECONDS,
|
|
198
|
+
)
|
|
199
|
+
read_timeout = resolved.read
|
|
200
|
+
if (
|
|
201
|
+
read_timeout is None
|
|
202
|
+
or not math.isfinite(read_timeout)
|
|
203
|
+
or read_timeout <= 0
|
|
204
|
+
or read_timeout > MAX_READ_TIMEOUT_SECONDS
|
|
205
|
+
):
|
|
206
|
+
raise ConfigurationError(
|
|
207
|
+
"The read timeout must be greater than 0 and at most "
|
|
208
|
+
f"{MAX_READ_TIMEOUT_SECONDS:g} seconds, the server's own limit; got {read_timeout!r}."
|
|
209
|
+
)
|
|
210
|
+
return resolved
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _first_present(*candidates: str | None) -> str | None:
|
|
214
|
+
"""The first candidate that is not ``None`` or blank, stripped."""
|
|
215
|
+
for candidate in candidates:
|
|
216
|
+
if candidate is not None and candidate.strip():
|
|
217
|
+
return candidate.strip()
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _validate_api_key(api_key: str) -> SecretStr:
|
|
222
|
+
"""Refuse a key that cannot travel in an HTTP header. The key itself is never in the message."""
|
|
223
|
+
if not (api_key.isascii() and api_key.isprintable()) or " " in api_key:
|
|
224
|
+
raise ConfigurationError(
|
|
225
|
+
"The API key contains characters that cannot be sent in an HTTP header: only "
|
|
226
|
+
"printable ASCII without spaces is accepted. Check for invisible characters or "
|
|
227
|
+
"line breaks copied along with the key."
|
|
228
|
+
)
|
|
229
|
+
return SecretStr(api_key)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _validate_max_retries(max_retries: int) -> int:
|
|
233
|
+
if not _is_integer(max_retries) or max_retries < 0:
|
|
234
|
+
raise ConfigurationError(
|
|
235
|
+
f"max_retries must be an integer of at least 0, got {max_retries!r}."
|
|
236
|
+
)
|
|
237
|
+
return max_retries
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _validate_max_units_per_request(max_units: int | None) -> int | None:
|
|
241
|
+
if max_units is None:
|
|
242
|
+
return None
|
|
243
|
+
if not _is_integer(max_units) or max_units < 1:
|
|
244
|
+
raise ConfigurationError(
|
|
245
|
+
f"max_units_per_request must be an integer of at least 1 or None, got {max_units!r}."
|
|
246
|
+
)
|
|
247
|
+
return max_units
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _is_integer(value: object) -> bool:
|
|
251
|
+
"""An ``int`` that is not a ``bool``; ``True`` would otherwise pass as 1."""
|
|
252
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _is_number(value: object) -> bool:
|
|
256
|
+
"""An ``int`` or ``float`` that is not a ``bool``; numeric strings do not count."""
|
|
257
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Conversion of list responses to pandas DataFrames. pandas is imported only when used."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING, Any, ClassVar, cast
|
|
7
|
+
|
|
8
|
+
from .models import APIResponse
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Mapping, Sequence
|
|
12
|
+
|
|
13
|
+
import pandas
|
|
14
|
+
|
|
15
|
+
ENVELOPE_FIELDS_SKIPPED = frozenset({"meta"})
|
|
16
|
+
NESTED_ROWS_SEPARATOR = "."
|
|
17
|
+
_CAMEL_CASE_BOUNDARY = re.compile(r"(?<!^)(?=[A-Z])")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TabularResponse(APIResponse):
|
|
21
|
+
"""A response whose main content is a list of rows.
|
|
22
|
+
|
|
23
|
+
Subclasses name the list in ``rows_field`` when it is not ``data``. A
|
|
24
|
+
dotted name such as ``"groups.members"`` points at a list nested in each
|
|
25
|
+
item of another list; the parent's own fields are repeated on each row.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
rows_field: ClassVar[str] = "data"
|
|
29
|
+
|
|
30
|
+
def to_dataframe(self, *, include_envelope: bool = True) -> pandas.DataFrame:
|
|
31
|
+
"""One row per item of the list, as a pandas DataFrame.
|
|
32
|
+
|
|
33
|
+
Nested objects are flattened with ``_``: ``stats.median_price`` becomes
|
|
34
|
+
``stats_median_price``. With ``include_envelope``, the scalar fields
|
|
35
|
+
around the list, such as the queried entity, the summary and the
|
|
36
|
+
``usage`` block, are repeated on every row, prefixed by their name. An
|
|
37
|
+
envelope field that shares its name with a row field is prefixed with
|
|
38
|
+
the response's name instead, as in ``product_image_url`` next to the
|
|
39
|
+
offer's ``image_url``, so no value is lost. Lists inside items are kept
|
|
40
|
+
as Python lists.
|
|
41
|
+
|
|
42
|
+
Requires pandas, available through the SDK's ``pandas`` extra.
|
|
43
|
+
"""
|
|
44
|
+
return build_dataframe(self, rows_field=self.rows_field, include_envelope=include_envelope)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_dataframe(
|
|
48
|
+
response: APIResponse, *, rows_field: str, include_envelope: bool
|
|
49
|
+
) -> pandas.DataFrame:
|
|
50
|
+
"""Build the DataFrame of ``response`` from the list in ``rows_field``.
|
|
51
|
+
|
|
52
|
+
Only the named list is serialised; sibling lists are left out of the dump
|
|
53
|
+
since the envelope never includes them.
|
|
54
|
+
"""
|
|
55
|
+
parent_field, _, child_field = rows_field.partition(NESTED_ROWS_SEPARATOR)
|
|
56
|
+
payload = response.model_dump(mode="python", exclude=_other_lists(response, parent_field))
|
|
57
|
+
parents = [row for row in payload.pop(parent_field, None) or [] if isinstance(row, dict)]
|
|
58
|
+
if child_field:
|
|
59
|
+
rows = [
|
|
60
|
+
{**flatten(_without(parent, child_field)), **flatten(child)}
|
|
61
|
+
for parent in parents
|
|
62
|
+
for child in parent.get(child_field) or []
|
|
63
|
+
if isinstance(child, dict)
|
|
64
|
+
]
|
|
65
|
+
else:
|
|
66
|
+
rows = [flatten(row) for row in parents]
|
|
67
|
+
envelope: dict[str, Any] = {}
|
|
68
|
+
if include_envelope:
|
|
69
|
+
envelope = disambiguate_envelope(
|
|
70
|
+
flatten_envelope(payload), rows, prefix=response_prefix(response)
|
|
71
|
+
)
|
|
72
|
+
records = [{**envelope, **row} for row in rows]
|
|
73
|
+
return records_to_dataframe(records, columns=column_union(records) or list(envelope))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def disambiguate_envelope(
|
|
77
|
+
envelope: Mapping[str, Any], rows: Sequence[Mapping[str, Any]], *, prefix: str
|
|
78
|
+
) -> dict[str, Any]:
|
|
79
|
+
"""Prefix the envelope columns that a row also uses, so both values get a column."""
|
|
80
|
+
row_keys = {key for row in rows for key in row}
|
|
81
|
+
return {
|
|
82
|
+
(f"{prefix}_{key}" if key in row_keys else key): value for key, value in envelope.items()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def response_prefix(response: APIResponse) -> str:
|
|
87
|
+
"""The response's class name in snake case: ``PriceHistory`` gives ``price_history``."""
|
|
88
|
+
return _CAMEL_CASE_BOUNDARY.sub("_", type(response).__name__).lower()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def column_union(records: Sequence[Mapping[str, Any]]) -> list[str]:
|
|
92
|
+
"""Every key seen across the records, in order of first appearance.
|
|
93
|
+
|
|
94
|
+
Rows need not share the same keys: free-form objects and fields the API
|
|
95
|
+
adds to some rows only still get a column.
|
|
96
|
+
"""
|
|
97
|
+
seen: dict[str, None] = {}
|
|
98
|
+
for record in records:
|
|
99
|
+
for key in record:
|
|
100
|
+
seen.setdefault(key, None)
|
|
101
|
+
return list(seen)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def records_to_dataframe(
|
|
105
|
+
records: Sequence[Mapping[str, Any]], *, columns: Sequence[str]
|
|
106
|
+
) -> pandas.DataFrame:
|
|
107
|
+
"""Build a DataFrame from flat records, importing pandas on first use."""
|
|
108
|
+
try:
|
|
109
|
+
import pandas as pandas_module
|
|
110
|
+
except ImportError as exc:
|
|
111
|
+
raise ImportError(
|
|
112
|
+
"pandas is not installed. Install it, or install the SDK with its 'pandas' extra."
|
|
113
|
+
) from exc
|
|
114
|
+
# pandas ships partial inline types, so the constructor resolves to Any.
|
|
115
|
+
return cast("pandas.DataFrame", pandas_module.DataFrame(list(records), columns=list(columns)))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def flatten(mapping: Mapping[str, Any], prefix: str = "") -> dict[str, Any]:
|
|
119
|
+
"""Flatten nested mappings into one level, joining keys with ``_``."""
|
|
120
|
+
flat: dict[str, Any] = {}
|
|
121
|
+
for key, value in mapping.items():
|
|
122
|
+
name = f"{prefix}{key}"
|
|
123
|
+
if isinstance(value, dict):
|
|
124
|
+
flat.update(flatten(value, f"{name}_"))
|
|
125
|
+
else:
|
|
126
|
+
flat[name] = value
|
|
127
|
+
return flat
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def flatten_envelope(payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
131
|
+
"""Flatten what surrounds the rows: scalars as they are, objects prefixed by their name.
|
|
132
|
+
|
|
133
|
+
A field of an object that already starts with the object's name keeps it:
|
|
134
|
+
``store.store_id`` becomes ``store_id``, ``store.city`` becomes
|
|
135
|
+
``store_city``. Lists and pagination metadata are left out.
|
|
136
|
+
"""
|
|
137
|
+
flat: dict[str, Any] = {}
|
|
138
|
+
for key, value in payload.items():
|
|
139
|
+
if key in ENVELOPE_FIELDS_SKIPPED or isinstance(value, list):
|
|
140
|
+
continue
|
|
141
|
+
if isinstance(value, dict):
|
|
142
|
+
for inner_key, inner_value in flatten(value).items():
|
|
143
|
+
name = inner_key if inner_key.startswith(f"{key}_") else f"{key}_{inner_key}"
|
|
144
|
+
flat[name] = inner_value
|
|
145
|
+
else:
|
|
146
|
+
flat[key] = value
|
|
147
|
+
return flat
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _other_lists(response: APIResponse, keep: str) -> set[str]:
|
|
151
|
+
return {
|
|
152
|
+
name
|
|
153
|
+
for name in type(response).model_fields
|
|
154
|
+
if name != keep and isinstance(getattr(response, name, None), list)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _without(mapping: Mapping[str, Any], key: str) -> dict[str, Any]:
|
|
159
|
+
return {name: value for name, value in mapping.items() if name != key}
|