updo-sdk 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.
- updo/__init__.py +92 -0
- updo/_transport.py +196 -0
- updo/_version.py +3 -0
- updo/aio.py +235 -0
- updo/cli.py +681 -0
- updo/client.py +241 -0
- updo/codegen.py +465 -0
- updo/config.py +140 -0
- updo/errors.py +305 -0
- updo/models.py +678 -0
- updo/py.typed +0 -0
- updo/query.py +201 -0
- updo/resources/__init__.py +14 -0
- updo/resources/_params.py +261 -0
- updo/resources/base.py +75 -0
- updo/resources/entities.py +88 -0
- updo/resources/records.py +521 -0
- updo/resources/webhooks.py +275 -0
- updo/webhooks.py +148 -0
- updo_sdk-0.1.0.dist-info/METADATA +618 -0
- updo_sdk-0.1.0.dist-info/RECORD +25 -0
- updo_sdk-0.1.0.dist-info/WHEEL +5 -0
- updo_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- updo_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- updo_sdk-0.1.0.dist-info/top_level.txt +1 -0
updo/__init__.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Python SDK for the Updo360 (Qlaris) public ERP API.
|
|
2
|
+
|
|
3
|
+
from updo import UpdoClient
|
|
4
|
+
|
|
5
|
+
with UpdoClient(token="sk_live_...") as client:
|
|
6
|
+
print(client.me().tenant_slug)
|
|
7
|
+
for product in client.entity("product").iterate(where={"status": "active"}):
|
|
8
|
+
print(product["sku"], product["sale_price"])
|
|
9
|
+
|
|
10
|
+
The public surface is PAT-only and lives under ``/api/public/v1/``. A token is
|
|
11
|
+
bound to exactly one workspace at creation, so there is no tenant to select and
|
|
12
|
+
no session to maintain.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ._version import __version__
|
|
16
|
+
from .aio import AsyncUpdoClient
|
|
17
|
+
from .client import UpdoClient
|
|
18
|
+
from .config import API_PREFIX, DEFAULT_BASE_URL, ClientConfig
|
|
19
|
+
from .errors import (
|
|
20
|
+
ApprovalRequired,
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
ConflictError,
|
|
23
|
+
NotFoundError,
|
|
24
|
+
PermissionDenied,
|
|
25
|
+
PlanLimitExceeded,
|
|
26
|
+
RateLimitError,
|
|
27
|
+
ServerError,
|
|
28
|
+
UpdoAPIError,
|
|
29
|
+
UpdoConfigError,
|
|
30
|
+
UpdoConnectionError,
|
|
31
|
+
UpdoError,
|
|
32
|
+
UpdoTimeoutError,
|
|
33
|
+
UpdoTransportError,
|
|
34
|
+
ValidationError,
|
|
35
|
+
)
|
|
36
|
+
from .models import (
|
|
37
|
+
AggregateResult,
|
|
38
|
+
ApprovalPending,
|
|
39
|
+
EntitySchema,
|
|
40
|
+
EntitySummary,
|
|
41
|
+
FieldSpec,
|
|
42
|
+
ODataPage,
|
|
43
|
+
Page,
|
|
44
|
+
PivotResult,
|
|
45
|
+
Record,
|
|
46
|
+
TokenIdentity,
|
|
47
|
+
Webhook,
|
|
48
|
+
WebhookDelivery,
|
|
49
|
+
WebhookEvent,
|
|
50
|
+
)
|
|
51
|
+
from .query import F
|
|
52
|
+
from .webhooks import parse_event, verify_signature
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"API_PREFIX",
|
|
56
|
+
"DEFAULT_BASE_URL",
|
|
57
|
+
"AggregateResult",
|
|
58
|
+
"ApprovalPending",
|
|
59
|
+
"ApprovalRequired",
|
|
60
|
+
"AsyncUpdoClient",
|
|
61
|
+
"AuthenticationError",
|
|
62
|
+
"ClientConfig",
|
|
63
|
+
"ConflictError",
|
|
64
|
+
"EntitySchema",
|
|
65
|
+
"EntitySummary",
|
|
66
|
+
"F",
|
|
67
|
+
"FieldSpec",
|
|
68
|
+
"NotFoundError",
|
|
69
|
+
"ODataPage",
|
|
70
|
+
"Page",
|
|
71
|
+
"PermissionDenied",
|
|
72
|
+
"PivotResult",
|
|
73
|
+
"PlanLimitExceeded",
|
|
74
|
+
"RateLimitError",
|
|
75
|
+
"Record",
|
|
76
|
+
"ServerError",
|
|
77
|
+
"TokenIdentity",
|
|
78
|
+
"UpdoAPIError",
|
|
79
|
+
"UpdoClient",
|
|
80
|
+
"UpdoConfigError",
|
|
81
|
+
"UpdoConnectionError",
|
|
82
|
+
"UpdoError",
|
|
83
|
+
"UpdoTimeoutError",
|
|
84
|
+
"UpdoTransportError",
|
|
85
|
+
"ValidationError",
|
|
86
|
+
"Webhook",
|
|
87
|
+
"WebhookDelivery",
|
|
88
|
+
"WebhookEvent",
|
|
89
|
+
"__version__",
|
|
90
|
+
"parse_event",
|
|
91
|
+
"verify_signature",
|
|
92
|
+
]
|
updo/_transport.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Transport plumbing shared by the sync and async clients.
|
|
2
|
+
|
|
3
|
+
Everything here is pure: it builds requests and decides what to do with
|
|
4
|
+
responses, but performs no I/O. The two clients differ only by ``await``, so
|
|
5
|
+
keeping the decisions in one place is what stops them from drifting apart.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import random
|
|
11
|
+
from collections.abc import Iterable, Mapping
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from .config import ClientConfig
|
|
17
|
+
from .errors import (
|
|
18
|
+
ApprovalRequired,
|
|
19
|
+
UpdoConnectionError,
|
|
20
|
+
UpdoTimeoutError,
|
|
21
|
+
UpdoTransportError,
|
|
22
|
+
error_from_response,
|
|
23
|
+
parse_retry_after,
|
|
24
|
+
)
|
|
25
|
+
from .models import ApprovalPending
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"APPROVAL_CODE",
|
|
29
|
+
"IDEMPOTENT_METHODS",
|
|
30
|
+
"RETRY_STATUSES",
|
|
31
|
+
"build_headers",
|
|
32
|
+
"join_path",
|
|
33
|
+
"prepare_params",
|
|
34
|
+
"process_json",
|
|
35
|
+
"retry_delay",
|
|
36
|
+
"should_retry_exception",
|
|
37
|
+
"should_retry_status",
|
|
38
|
+
"wrap_transport_error",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
#: Methods safe to replay after a partial failure. POST and PATCH are absent on
|
|
42
|
+
#: purpose: a 500 after a POST may mean the write LANDED and the response was
|
|
43
|
+
#: lost, so retrying would duplicate the record.
|
|
44
|
+
IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
|
|
45
|
+
|
|
46
|
+
#: Statuses worth a second attempt.
|
|
47
|
+
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
48
|
+
|
|
49
|
+
#: Platform code carried by the 202 approval envelope.
|
|
50
|
+
APPROVAL_CODE = "APPROVAL_REQUIRED"
|
|
51
|
+
|
|
52
|
+
_BACKOFF_BASE = 0.5
|
|
53
|
+
_BACKOFF_CAP = 30.0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_headers(config: ClientConfig, extra: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
57
|
+
"""Standard headers for every call.
|
|
58
|
+
|
|
59
|
+
Notably absent: ``X-Tenant-ID``. A personal access token is bound to exactly
|
|
60
|
+
one workspace at creation time, and sending a tenant header that disagrees
|
|
61
|
+
is a 401 -- so the SDK never sends one at all.
|
|
62
|
+
"""
|
|
63
|
+
headers = {
|
|
64
|
+
"Authorization": f"Bearer {config.token}",
|
|
65
|
+
"Accept": "application/json",
|
|
66
|
+
"User-Agent": config.user_agent,
|
|
67
|
+
}
|
|
68
|
+
if extra:
|
|
69
|
+
headers.update({key: value for key, value in extra.items() if value is not None})
|
|
70
|
+
return headers
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def join_path(base_url: str, path: str) -> str:
|
|
74
|
+
"""Join base and path, forcing the trailing slash Django requires.
|
|
75
|
+
|
|
76
|
+
Without it Django answers a 301 to the slashed URL, and httpx drops the
|
|
77
|
+
``Authorization`` header across some redirects -- surfacing as a baffling
|
|
78
|
+
401 on a perfectly valid token.
|
|
79
|
+
"""
|
|
80
|
+
cleaned = path.strip()
|
|
81
|
+
if cleaned.startswith(("http://", "https://")):
|
|
82
|
+
return cleaned
|
|
83
|
+
cleaned = cleaned.lstrip("/")
|
|
84
|
+
if cleaned and not cleaned.endswith("/"):
|
|
85
|
+
cleaned += "/"
|
|
86
|
+
return f"{base_url.rstrip('/')}/{cleaned}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def prepare_params(*sources: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
90
|
+
"""Merge parameter mappings, dropping ``None`` values (unset, not empty)."""
|
|
91
|
+
merged: dict[str, Any] = {}
|
|
92
|
+
for source in sources:
|
|
93
|
+
if not source:
|
|
94
|
+
continue
|
|
95
|
+
for key, value in source.items():
|
|
96
|
+
if value is None:
|
|
97
|
+
continue
|
|
98
|
+
merged[key] = value
|
|
99
|
+
return merged
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def should_retry_status(method: str, status: int, attempt: int, max_retries: int) -> bool:
|
|
103
|
+
"""Whether to replay after an HTTP error response."""
|
|
104
|
+
if attempt >= max_retries:
|
|
105
|
+
return False
|
|
106
|
+
if status not in RETRY_STATUSES:
|
|
107
|
+
return False
|
|
108
|
+
if status == 429:
|
|
109
|
+
# The request was rejected by the throttle BEFORE any side effect, so
|
|
110
|
+
# replaying is safe even for a POST.
|
|
111
|
+
return True
|
|
112
|
+
return method.upper() in IDEMPOTENT_METHODS
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def should_retry_exception(method: str, exc: Exception, attempt: int, max_retries: int) -> bool:
|
|
116
|
+
"""Whether to replay after a transport failure."""
|
|
117
|
+
if attempt >= max_retries:
|
|
118
|
+
return False
|
|
119
|
+
if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)):
|
|
120
|
+
# The connection was never established: nothing can have been applied.
|
|
121
|
+
return True
|
|
122
|
+
if isinstance(exc, (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError)):
|
|
123
|
+
return method.upper() in IDEMPOTENT_METHODS
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def retry_delay(attempt: int, response: httpx.Response | None = None) -> float:
|
|
128
|
+
"""Seconds to wait before the next attempt (``Retry-After`` wins if sent)."""
|
|
129
|
+
if response is not None:
|
|
130
|
+
explicit = parse_retry_after(response.headers.get("Retry-After"))
|
|
131
|
+
if explicit is not None:
|
|
132
|
+
return min(explicit, _BACKOFF_CAP)
|
|
133
|
+
window = min(_BACKOFF_CAP, _BACKOFF_BASE * (2**attempt))
|
|
134
|
+
# Full jitter: identical clients retrying in lockstep would re-empty the
|
|
135
|
+
# bucket the instant it refills.
|
|
136
|
+
return random.uniform(0.0, window) # noqa: S311 - jitter, not cryptography
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def wrap_transport_error(exc: Exception) -> UpdoTransportError:
|
|
140
|
+
"""Translate an httpx transport failure into this SDK's vocabulary."""
|
|
141
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
142
|
+
return UpdoTimeoutError(f"Request timed out: {exc}")
|
|
143
|
+
if isinstance(exc, httpx.ConnectError):
|
|
144
|
+
return UpdoConnectionError(f"Could not connect: {exc}")
|
|
145
|
+
return UpdoTransportError(f"Transport failure: {exc}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _json_or_none(response: httpx.Response) -> Any:
|
|
149
|
+
try:
|
|
150
|
+
return response.json()
|
|
151
|
+
except Exception: # noqa: BLE001 - a non-JSON success body is still a success
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def process_json(response: httpx.Response, *, on_approval: str) -> Any:
|
|
156
|
+
"""Turn a response into a payload, raising the right exception otherwise.
|
|
157
|
+
|
|
158
|
+
Three outcomes the caller must not have to spell out each time:
|
|
159
|
+
|
|
160
|
+
* **204** (and an empty body) -> ``None``
|
|
161
|
+
* **202 APPROVAL_REQUIRED** -> the write is queued, not applied. Raising by
|
|
162
|
+
default is deliberate: returning a record-shaped object with no ``id``
|
|
163
|
+
would let the caller carry on as if the write had landed.
|
|
164
|
+
* **>= 400** -> the normalised exception from :mod:`updo.errors`
|
|
165
|
+
"""
|
|
166
|
+
status = response.status_code
|
|
167
|
+
|
|
168
|
+
if status >= 400:
|
|
169
|
+
raise error_from_response(response)
|
|
170
|
+
|
|
171
|
+
if status == 204 or not response.content:
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
payload = _json_or_none(response)
|
|
175
|
+
|
|
176
|
+
if status == 202 and isinstance(payload, Mapping) and payload.get("code") == APPROVAL_CODE:
|
|
177
|
+
pending = ApprovalPending.from_dict(payload)
|
|
178
|
+
if on_approval == "return":
|
|
179
|
+
return pending
|
|
180
|
+
raise ApprovalRequired(
|
|
181
|
+
pending.detail or "This write requires an approval before it is applied.",
|
|
182
|
+
approval_request_id=pending.approval_request_id,
|
|
183
|
+
status=pending.status,
|
|
184
|
+
body=dict(payload),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return payload
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def stream_to(target: Any, chunks: Iterable[bytes]) -> int:
|
|
191
|
+
"""Write chunks to a binary file object, returning the byte count."""
|
|
192
|
+
total = 0
|
|
193
|
+
for chunk in chunks:
|
|
194
|
+
target.write(chunk)
|
|
195
|
+
total += len(chunk)
|
|
196
|
+
return total
|
updo/_version.py
ADDED
updo/aio.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""The asynchronous client -- a mirror of :mod:`updo.client`.
|
|
2
|
+
|
|
3
|
+
Every decision (retry policy, error mapping, parameter building) lives in the
|
|
4
|
+
shared modules, so this file is the sync client with ``await`` in front of the
|
|
5
|
+
I/O and nothing else different.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from ._transport import (
|
|
19
|
+
build_headers,
|
|
20
|
+
join_path,
|
|
21
|
+
prepare_params,
|
|
22
|
+
process_json,
|
|
23
|
+
retry_delay,
|
|
24
|
+
should_retry_exception,
|
|
25
|
+
should_retry_status,
|
|
26
|
+
wrap_transport_error,
|
|
27
|
+
)
|
|
28
|
+
from .config import ClientConfig, OnApproval, mask_token
|
|
29
|
+
from .errors import error_from_response
|
|
30
|
+
from .models import EntitySchema, TokenIdentity
|
|
31
|
+
from .resources.entities import AsyncEntitiesResource
|
|
32
|
+
from .resources.records import AsyncRecordsResource
|
|
33
|
+
from .resources.webhooks import AsyncWebhooksResource
|
|
34
|
+
|
|
35
|
+
__all__ = ["AsyncUpdoClient"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AsyncUpdoClient:
|
|
39
|
+
"""Asynchronous client for the Updo public API.
|
|
40
|
+
|
|
41
|
+
import asyncio
|
|
42
|
+
from updo import AsyncUpdoClient
|
|
43
|
+
|
|
44
|
+
async def main():
|
|
45
|
+
async with AsyncUpdoClient(token="sk_live_...") as client:
|
|
46
|
+
print(await client.me())
|
|
47
|
+
|
|
48
|
+
asyncio.run(main())
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
token: str | None = None,
|
|
54
|
+
base_url: str | None = None,
|
|
55
|
+
*,
|
|
56
|
+
timeout: Any = 30.0,
|
|
57
|
+
max_retries: int = 3,
|
|
58
|
+
on_approval: OnApproval = "raise",
|
|
59
|
+
user_agent: str | None = None,
|
|
60
|
+
http_client: httpx.AsyncClient | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self.config = ClientConfig.build(
|
|
63
|
+
token=token,
|
|
64
|
+
base_url=base_url,
|
|
65
|
+
timeout=timeout,
|
|
66
|
+
max_retries=max_retries,
|
|
67
|
+
on_approval=on_approval,
|
|
68
|
+
user_agent=user_agent,
|
|
69
|
+
)
|
|
70
|
+
self._owns_http = http_client is None
|
|
71
|
+
self._http = http_client or httpx.AsyncClient(timeout=self.config.timeout)
|
|
72
|
+
self._schemas: dict[str, EntitySchema] = {}
|
|
73
|
+
|
|
74
|
+
self.entities = AsyncEntitiesResource(self)
|
|
75
|
+
self.webhooks = AsyncWebhooksResource(self)
|
|
76
|
+
|
|
77
|
+
# -- lifecycle ----------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
async def close(self) -> None:
|
|
80
|
+
if self._owns_http:
|
|
81
|
+
await self._http.aclose()
|
|
82
|
+
|
|
83
|
+
async def __aenter__(self) -> AsyncUpdoClient:
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
async def __aexit__(
|
|
87
|
+
self,
|
|
88
|
+
exc_type: type[BaseException] | None,
|
|
89
|
+
exc: BaseException | None,
|
|
90
|
+
tb: TracebackType | None,
|
|
91
|
+
) -> None:
|
|
92
|
+
await self.close()
|
|
93
|
+
|
|
94
|
+
def __repr__(self) -> str:
|
|
95
|
+
return (
|
|
96
|
+
f"AsyncUpdoClient(base_url={self.config.base_url!r}, "
|
|
97
|
+
f"token={mask_token(self.config.token)!r})"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# -- schema cache -------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def cached_schema(self, slug: str) -> EntitySchema | None:
|
|
103
|
+
return self._schemas.get(slug)
|
|
104
|
+
|
|
105
|
+
def store_schema(self, schema: EntitySchema) -> None:
|
|
106
|
+
self._schemas[schema.slug] = schema
|
|
107
|
+
|
|
108
|
+
def clear_schema_cache(self) -> None:
|
|
109
|
+
self._schemas.clear()
|
|
110
|
+
|
|
111
|
+
# -- HTTP ---------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
async def request(
|
|
114
|
+
self,
|
|
115
|
+
method: str,
|
|
116
|
+
path: str,
|
|
117
|
+
*,
|
|
118
|
+
params: Mapping[str, Any] | None = None,
|
|
119
|
+
json: Any = None,
|
|
120
|
+
headers: Mapping[str, str] | None = None,
|
|
121
|
+
) -> Any:
|
|
122
|
+
response = await self._send(method, path, params=params, json=json, headers=headers)
|
|
123
|
+
return process_json(response, on_approval=self.config.on_approval)
|
|
124
|
+
|
|
125
|
+
async def _send(
|
|
126
|
+
self,
|
|
127
|
+
method: str,
|
|
128
|
+
path: str,
|
|
129
|
+
*,
|
|
130
|
+
params: Mapping[str, Any] | None = None,
|
|
131
|
+
json: Any = None,
|
|
132
|
+
headers: Mapping[str, str] | None = None,
|
|
133
|
+
) -> httpx.Response:
|
|
134
|
+
url = join_path(self.config.base_url, path)
|
|
135
|
+
query = prepare_params(params)
|
|
136
|
+
request_headers = build_headers(self.config, headers)
|
|
137
|
+
|
|
138
|
+
attempt = 0
|
|
139
|
+
while True:
|
|
140
|
+
try:
|
|
141
|
+
response = await self._http.request(
|
|
142
|
+
method.upper(),
|
|
143
|
+
url,
|
|
144
|
+
params=query or None,
|
|
145
|
+
json=json,
|
|
146
|
+
headers=request_headers,
|
|
147
|
+
)
|
|
148
|
+
except Exception as exc: # noqa: BLE001 - re-raised as our own type below
|
|
149
|
+
if should_retry_exception(method, exc, attempt, self.config.max_retries):
|
|
150
|
+
await asyncio.sleep(retry_delay(attempt))
|
|
151
|
+
attempt += 1
|
|
152
|
+
continue
|
|
153
|
+
raise wrap_transport_error(exc) from exc
|
|
154
|
+
|
|
155
|
+
if should_retry_status(method, response.status_code, attempt, self.config.max_retries):
|
|
156
|
+
delay = retry_delay(attempt, response)
|
|
157
|
+
await response.aclose()
|
|
158
|
+
await asyncio.sleep(delay)
|
|
159
|
+
attempt += 1
|
|
160
|
+
continue
|
|
161
|
+
return response
|
|
162
|
+
|
|
163
|
+
async def download(
|
|
164
|
+
self,
|
|
165
|
+
path: str,
|
|
166
|
+
*,
|
|
167
|
+
params: Mapping[str, Any] | None = None,
|
|
168
|
+
dest: Any = None,
|
|
169
|
+
) -> Any:
|
|
170
|
+
url = join_path(self.config.base_url, path)
|
|
171
|
+
query = prepare_params(params)
|
|
172
|
+
headers = build_headers(self.config, {"Accept": "*/*"})
|
|
173
|
+
|
|
174
|
+
attempt = 0
|
|
175
|
+
while True:
|
|
176
|
+
try:
|
|
177
|
+
async with self._http.stream(
|
|
178
|
+
"GET", url, params=query or None, headers=headers
|
|
179
|
+
) as response:
|
|
180
|
+
if should_retry_status(
|
|
181
|
+
"GET", response.status_code, attempt, self.config.max_retries
|
|
182
|
+
):
|
|
183
|
+
delay = retry_delay(attempt, response)
|
|
184
|
+
await asyncio.sleep(delay)
|
|
185
|
+
attempt += 1
|
|
186
|
+
continue
|
|
187
|
+
if response.status_code >= 400:
|
|
188
|
+
await response.aread()
|
|
189
|
+
raise error_from_response(response)
|
|
190
|
+
return await _consume(response.aiter_bytes(), dest)
|
|
191
|
+
except httpx.HTTPError as exc:
|
|
192
|
+
if should_retry_exception("GET", exc, attempt, self.config.max_retries):
|
|
193
|
+
await asyncio.sleep(retry_delay(attempt))
|
|
194
|
+
attempt += 1
|
|
195
|
+
continue
|
|
196
|
+
raise wrap_transport_error(exc) from exc
|
|
197
|
+
|
|
198
|
+
# -- endpoints ----------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
async def me(self) -> TokenIdentity:
|
|
201
|
+
return TokenIdentity.from_dict(await self.request("GET", "me") or {})
|
|
202
|
+
|
|
203
|
+
async def entity(self, slug: str, *, coerce: bool = False) -> AsyncRecordsResource:
|
|
204
|
+
"""Access one entity's records.
|
|
205
|
+
|
|
206
|
+
Unlike the sync counterpart this is a coroutine, because ``coerce=True``
|
|
207
|
+
may need to fetch the schema first.
|
|
208
|
+
"""
|
|
209
|
+
schema = await self.entities.schema(slug) if coerce else None
|
|
210
|
+
return AsyncRecordsResource(self, slug, schema=schema)
|
|
211
|
+
|
|
212
|
+
async def openapi(self) -> Any:
|
|
213
|
+
return await self.request("GET", "schema", params={"format": "json"})
|
|
214
|
+
|
|
215
|
+
async def ping(self) -> bool:
|
|
216
|
+
await self.me()
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def _consume(chunks: Any, dest: Any) -> Any:
|
|
221
|
+
if dest is None:
|
|
222
|
+
buffer = bytearray()
|
|
223
|
+
async for chunk in chunks:
|
|
224
|
+
buffer.extend(chunk)
|
|
225
|
+
return bytes(buffer)
|
|
226
|
+
if hasattr(dest, "write"):
|
|
227
|
+
async for chunk in chunks:
|
|
228
|
+
dest.write(chunk)
|
|
229
|
+
return dest
|
|
230
|
+
target = Path(dest)
|
|
231
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
with target.open("wb") as handle:
|
|
233
|
+
async for chunk in chunks:
|
|
234
|
+
handle.write(chunk)
|
|
235
|
+
return target
|