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 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
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the package version (read by ``pyproject.toml``)."""
2
+
3
+ __version__ = "0.1.0"
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