workwae 0.1.0a1__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.
- workwae/__init__.py +52 -0
- workwae/_client.py +154 -0
- workwae/_core.py +263 -0
- workwae/_errors.py +72 -0
- workwae/_models.py +17 -0
- workwae/_resources.py +8656 -0
- workwae/_version.py +1 -0
- workwae/pagination.py +159 -0
- workwae/py.typed +0 -0
- workwae/types.py +3846 -0
- workwae/webhooks.py +204 -0
- workwae-0.1.0a1.dist-info/METADATA +249 -0
- workwae-0.1.0a1.dist-info/RECORD +14 -0
- workwae-0.1.0a1.dist-info/WHEEL +4 -0
workwae/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""workwae: a typed client for the Workwae /v1 API, plus cursor, error and webhook helpers.
|
|
2
|
+
|
|
3
|
+
Every API model is in `workwae.types`, under the name the OpenAPI document gives it."""
|
|
4
|
+
|
|
5
|
+
from . import types
|
|
6
|
+
from ._client import AsyncWorkwae, Workwae
|
|
7
|
+
from ._core import NOT_GIVEN, BinaryResponse, NotGiven, RequestOptions
|
|
8
|
+
from ._errors import APIError, WorkwaeError
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
from .pagination import AsyncChangeFeed, ChangeFeed, changes, pages, paginate
|
|
11
|
+
from .types import EVENT_TYPES, ErrorReason, Event
|
|
12
|
+
from .webhooks import (
|
|
13
|
+
MAX_SIGNATURE_CANDIDATES,
|
|
14
|
+
WEBHOOK_TOLERANCE_SECONDS,
|
|
15
|
+
UnknownEvent,
|
|
16
|
+
WebhookEvent,
|
|
17
|
+
WebhookVerificationError,
|
|
18
|
+
WebhookVerificationFailure,
|
|
19
|
+
check_webhook_signature,
|
|
20
|
+
parse_webhook,
|
|
21
|
+
verify_webhook,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"EVENT_TYPES",
|
|
26
|
+
"MAX_SIGNATURE_CANDIDATES",
|
|
27
|
+
"NOT_GIVEN",
|
|
28
|
+
"WEBHOOK_TOLERANCE_SECONDS",
|
|
29
|
+
"APIError",
|
|
30
|
+
"AsyncChangeFeed",
|
|
31
|
+
"AsyncWorkwae",
|
|
32
|
+
"BinaryResponse",
|
|
33
|
+
"ChangeFeed",
|
|
34
|
+
"ErrorReason",
|
|
35
|
+
"Event",
|
|
36
|
+
"NotGiven",
|
|
37
|
+
"RequestOptions",
|
|
38
|
+
"UnknownEvent",
|
|
39
|
+
"WebhookEvent",
|
|
40
|
+
"WebhookVerificationError",
|
|
41
|
+
"WebhookVerificationFailure",
|
|
42
|
+
"Workwae",
|
|
43
|
+
"WorkwaeError",
|
|
44
|
+
"__version__",
|
|
45
|
+
"changes",
|
|
46
|
+
"check_webhook_signature",
|
|
47
|
+
"pages",
|
|
48
|
+
"paginate",
|
|
49
|
+
"parse_webhook",
|
|
50
|
+
"types",
|
|
51
|
+
"verify_webhook",
|
|
52
|
+
]
|
workwae/_client.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ._core import (
|
|
14
|
+
DEFAULT_MAX_RETRIES,
|
|
15
|
+
DEFAULT_MAX_RETRY_DELAY,
|
|
16
|
+
DEFAULT_TIMEOUT,
|
|
17
|
+
ApiKey,
|
|
18
|
+
AsyncApiKey,
|
|
19
|
+
BaseClient,
|
|
20
|
+
Operation,
|
|
21
|
+
RequestOptions,
|
|
22
|
+
)
|
|
23
|
+
from ._resources import AsyncResources, Resources
|
|
24
|
+
|
|
25
|
+
# Module attributes rather than direct calls, so tests can stand in for the clock.
|
|
26
|
+
_sleep = time.sleep
|
|
27
|
+
_async_sleep = asyncio.sleep
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Workwae(BaseClient, Resources):
|
|
31
|
+
"""A client for the Workwae /v1 API: `Workwae(api_key=...).accounts.get(account_id=...)`.
|
|
32
|
+
|
|
33
|
+
`api_key` is an API key (`wk_...`) or an account token (`wat_...`), or a function returning
|
|
34
|
+
one per request, for tokens that are refreshed; it defaults to the WORKWAE_API_KEY environment
|
|
35
|
+
variable. `base_url` defaults to the hosted API; set it only to go through a proxy or gateway
|
|
36
|
+
of your own. `http_client` replaces the httpx.Client the client makes (proxies, transports,
|
|
37
|
+
tests); it is not closed by `close()`.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
api_key: ApiKey | None = None,
|
|
44
|
+
base_url: str | None = None,
|
|
45
|
+
headers: Mapping[str, str] | None = None,
|
|
46
|
+
timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
|
|
47
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
48
|
+
max_retry_delay: float = DEFAULT_MAX_RETRY_DELAY,
|
|
49
|
+
http_client: httpx.Client | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
BaseClient.__init__(
|
|
52
|
+
self,
|
|
53
|
+
base_url=base_url,
|
|
54
|
+
headers=headers,
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
max_retries=max_retries,
|
|
57
|
+
max_retry_delay=max_retry_delay,
|
|
58
|
+
)
|
|
59
|
+
self._api_key = api_key if api_key is not None else os.environ.get("WORKWAE_API_KEY")
|
|
60
|
+
self._owns_http = http_client is None
|
|
61
|
+
self._http = http_client if http_client is not None else httpx.Client()
|
|
62
|
+
Resources.__init__(self)
|
|
63
|
+
|
|
64
|
+
def _call(
|
|
65
|
+
self, op: Operation, params: Mapping[str, Any], options: RequestOptions | None
|
|
66
|
+
) -> Any:
|
|
67
|
+
key = self._api_key() if callable(self._api_key) else self._api_key
|
|
68
|
+
request = self._build(op, params, options, key)
|
|
69
|
+
attempt = 0
|
|
70
|
+
while True:
|
|
71
|
+
response = self._http.send(request)
|
|
72
|
+
delay = self._retry_delay(response, attempt)
|
|
73
|
+
if delay is None:
|
|
74
|
+
return self._decode(op, response)
|
|
75
|
+
response.close()
|
|
76
|
+
_sleep(delay)
|
|
77
|
+
attempt += 1
|
|
78
|
+
|
|
79
|
+
def close(self) -> None:
|
|
80
|
+
if self._owns_http:
|
|
81
|
+
self._http.close()
|
|
82
|
+
|
|
83
|
+
def __enter__(self) -> Workwae:
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def __exit__(
|
|
87
|
+
self,
|
|
88
|
+
exc_type: type[BaseException] | None,
|
|
89
|
+
exc: BaseException | None,
|
|
90
|
+
tb: TracebackType | None,
|
|
91
|
+
) -> None:
|
|
92
|
+
self.close()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class AsyncWorkwae(BaseClient, AsyncResources):
|
|
96
|
+
"""The asyncio client: `await AsyncWorkwae(api_key=...).accounts.get(account_id=...)`. Takes
|
|
97
|
+
the same options as Workwae; `api_key` may also be an async function, and `http_client` an
|
|
98
|
+
httpx.AsyncClient."""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
*,
|
|
103
|
+
api_key: AsyncApiKey | None = None,
|
|
104
|
+
base_url: str | None = None,
|
|
105
|
+
headers: Mapping[str, str] | None = None,
|
|
106
|
+
timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
|
|
107
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
108
|
+
max_retry_delay: float = DEFAULT_MAX_RETRY_DELAY,
|
|
109
|
+
http_client: httpx.AsyncClient | None = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
BaseClient.__init__(
|
|
112
|
+
self,
|
|
113
|
+
base_url=base_url,
|
|
114
|
+
headers=headers,
|
|
115
|
+
timeout=timeout,
|
|
116
|
+
max_retries=max_retries,
|
|
117
|
+
max_retry_delay=max_retry_delay,
|
|
118
|
+
)
|
|
119
|
+
self._api_key = api_key if api_key is not None else os.environ.get("WORKWAE_API_KEY")
|
|
120
|
+
self._owns_http = http_client is None
|
|
121
|
+
self._http = http_client if http_client is not None else httpx.AsyncClient()
|
|
122
|
+
AsyncResources.__init__(self)
|
|
123
|
+
|
|
124
|
+
async def _call(
|
|
125
|
+
self, op: Operation, params: Mapping[str, Any], options: RequestOptions | None
|
|
126
|
+
) -> Any:
|
|
127
|
+
key = self._api_key() if callable(self._api_key) else self._api_key
|
|
128
|
+
if inspect.isawaitable(key):
|
|
129
|
+
key = await key
|
|
130
|
+
request = self._build(op, params, options, key)
|
|
131
|
+
attempt = 0
|
|
132
|
+
while True:
|
|
133
|
+
response = await self._http.send(request)
|
|
134
|
+
delay = self._retry_delay(response, attempt)
|
|
135
|
+
if delay is None:
|
|
136
|
+
return self._decode(op, response)
|
|
137
|
+
await response.aclose()
|
|
138
|
+
await _async_sleep(delay)
|
|
139
|
+
attempt += 1
|
|
140
|
+
|
|
141
|
+
async def close(self) -> None:
|
|
142
|
+
if self._owns_http:
|
|
143
|
+
await self._http.aclose()
|
|
144
|
+
|
|
145
|
+
async def __aenter__(self) -> AsyncWorkwae:
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
async def __aexit__(
|
|
149
|
+
self,
|
|
150
|
+
exc_type: type[BaseException] | None,
|
|
151
|
+
exc: BaseException | None,
|
|
152
|
+
tb: TracebackType | None,
|
|
153
|
+
) -> None:
|
|
154
|
+
await self.close()
|
workwae/_core.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# The transport both clients share: how an operation's keyword arguments become a request, how
|
|
2
|
+
# 429 and 503 answers are retried, and how an answer becomes a model or an APIError. The
|
|
3
|
+
# generated resources (_resources.py) only describe each operation as an Operation.
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import email.utils
|
|
7
|
+
import functools
|
|
8
|
+
import json
|
|
9
|
+
import random
|
|
10
|
+
import re
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import date, datetime, timezone
|
|
15
|
+
from typing import Any, Protocol, TypedDict
|
|
16
|
+
from urllib.parse import quote, unquote
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from pydantic import TypeAdapter
|
|
20
|
+
|
|
21
|
+
from ._errors import APIError
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.workwae.com"
|
|
25
|
+
DEFAULT_TIMEOUT = 60.0
|
|
26
|
+
DEFAULT_MAX_RETRIES = 2
|
|
27
|
+
DEFAULT_MAX_RETRY_DELAY = 60.0
|
|
28
|
+
_BACKOFF_START = 0.5
|
|
29
|
+
_RETRIED_STATUSES = (429, 503)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NotGiven:
|
|
33
|
+
"""The default of every optional argument: the field is left out of the request, where None
|
|
34
|
+
sends null (which clears a nullable field on PATCH)."""
|
|
35
|
+
|
|
36
|
+
_instance: NotGiven | None = None
|
|
37
|
+
|
|
38
|
+
def __new__(cls) -> NotGiven:
|
|
39
|
+
if cls._instance is None:
|
|
40
|
+
cls._instance = super().__new__(cls)
|
|
41
|
+
return cls._instance
|
|
42
|
+
|
|
43
|
+
def __bool__(self) -> bool:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
def __repr__(self) -> str:
|
|
47
|
+
return "NOT_GIVEN"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
NOT_GIVEN = NotGiven()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RequestOptions(TypedDict, total=False):
|
|
54
|
+
"""Per-call options, passed as `request_options=`."""
|
|
55
|
+
|
|
56
|
+
idempotency_key: str
|
|
57
|
+
"""The POST's Idempotency-Key. Without one, each POST call sends a key of its own, the same on
|
|
58
|
+
every automatic retry. Pass one to make retries of your own (a later process, a timeout)
|
|
59
|
+
answer what the first attempt did."""
|
|
60
|
+
timeout: float | httpx.Timeout
|
|
61
|
+
headers: Mapping[str, str]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class BinaryResponse:
|
|
66
|
+
"""An answer that is not JSON: a raw message, an attachment, a contact photo, an export."""
|
|
67
|
+
|
|
68
|
+
content: bytes
|
|
69
|
+
content_type: str | None
|
|
70
|
+
filename: str | None
|
|
71
|
+
headers: httpx.Headers = field(repr=False)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Operation:
|
|
76
|
+
"""One /v1 operation. Keyword arguments named in `path` and `query` go there, under their
|
|
77
|
+
wire names; every other one is a body field, validated through `body`."""
|
|
78
|
+
|
|
79
|
+
method: str
|
|
80
|
+
path: str
|
|
81
|
+
path_params: Mapping[str, str] = field(default_factory=dict[str, str])
|
|
82
|
+
query_params: Mapping[str, str] = field(default_factory=dict[str, str])
|
|
83
|
+
body: Any = None
|
|
84
|
+
response: Any = None
|
|
85
|
+
binary: bool = False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@functools.cache
|
|
89
|
+
def adapter(tp: Any) -> TypeAdapter[Any]:
|
|
90
|
+
# Built on first use, so importing the package does not compile every model's schema.
|
|
91
|
+
return TypeAdapter(tp)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _query_value(value: Any) -> str:
|
|
95
|
+
if isinstance(value, bool):
|
|
96
|
+
return "true" if value else "false"
|
|
97
|
+
if isinstance(value, datetime):
|
|
98
|
+
return value.isoformat()
|
|
99
|
+
if isinstance(value, date):
|
|
100
|
+
return value.isoformat()
|
|
101
|
+
return str(value)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _query(
|
|
105
|
+
op: Operation, params: Mapping[str, Any]
|
|
106
|
+
) -> list[tuple[str, str | int | float | bool | None]]:
|
|
107
|
+
query: list[tuple[str, str | int | float | bool | None]] = []
|
|
108
|
+
for name, wire in op.query_params.items():
|
|
109
|
+
value = params.get(name, NOT_GIVEN)
|
|
110
|
+
if value is NOT_GIVEN or value is None:
|
|
111
|
+
continue
|
|
112
|
+
if isinstance(value, (list, tuple)):
|
|
113
|
+
# Bracket notation (`types[0]=...`), which the API reads arrays from.
|
|
114
|
+
items: list[Any] | tuple[Any, ...] = value # pyright: ignore[reportUnknownVariableType]
|
|
115
|
+
query.extend((f"{wire}[{i}]", _query_value(item)) for i, item in enumerate(items))
|
|
116
|
+
else:
|
|
117
|
+
query.append((wire, _query_value(value)))
|
|
118
|
+
return query
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _body(op: Operation, params: Mapping[str, Any]) -> bytes | None:
|
|
122
|
+
if op.body is None:
|
|
123
|
+
return None
|
|
124
|
+
reserved = op.path_params.keys() | op.query_params.keys()
|
|
125
|
+
given = {
|
|
126
|
+
name: value
|
|
127
|
+
for name, value in params.items()
|
|
128
|
+
if name not in reserved and value is not NOT_GIVEN
|
|
129
|
+
}
|
|
130
|
+
body_adapter = adapter(op.body)
|
|
131
|
+
model = body_adapter.validate_python(given)
|
|
132
|
+
data = body_adapter.dump_python(model, mode="json", by_alias=True, exclude_unset=True)
|
|
133
|
+
return json.dumps(data, separators=(",", ":")).encode()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_retry_after(value: str | None) -> float | None:
|
|
137
|
+
"""Retry-After in seconds: delta seconds or an HTTP date; None when absent or unreadable."""
|
|
138
|
+
if value is None:
|
|
139
|
+
return None
|
|
140
|
+
value = value.strip()
|
|
141
|
+
if re.fullmatch(r"\d+(\.\d+)?", value):
|
|
142
|
+
return float(value)
|
|
143
|
+
try:
|
|
144
|
+
at = email.utils.parsedate_to_datetime(value)
|
|
145
|
+
except (TypeError, ValueError):
|
|
146
|
+
return None
|
|
147
|
+
if at.tzinfo is None:
|
|
148
|
+
at = at.replace(tzinfo=timezone.utc)
|
|
149
|
+
return max(0.0, (at - datetime.now(timezone.utc)).total_seconds())
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _filename(disposition: str | None) -> str | None:
|
|
153
|
+
if not disposition:
|
|
154
|
+
return None
|
|
155
|
+
match = re.search(
|
|
156
|
+
r"filename\*=UTF-8''([^;]+)|filename=\"([^\"]*)\"|filename=([^;]+)", disposition
|
|
157
|
+
)
|
|
158
|
+
if not match:
|
|
159
|
+
return None
|
|
160
|
+
encoded, quoted, bare = match.groups()
|
|
161
|
+
if encoded:
|
|
162
|
+
return unquote(encoded)
|
|
163
|
+
return quoted if quoted is not None else bare.strip()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class BaseClient:
|
|
167
|
+
"""What the sync and async clients share: options, request building and decoding."""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
*,
|
|
172
|
+
base_url: str | None,
|
|
173
|
+
headers: Mapping[str, str] | None,
|
|
174
|
+
timeout: float | httpx.Timeout,
|
|
175
|
+
max_retries: int,
|
|
176
|
+
max_retry_delay: float,
|
|
177
|
+
) -> None:
|
|
178
|
+
# The /v1 paths are appended to it, so a path prefix is kept.
|
|
179
|
+
self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
180
|
+
self.headers = dict(headers or {})
|
|
181
|
+
self.timeout = timeout
|
|
182
|
+
self.max_retries = max_retries
|
|
183
|
+
self.max_retry_delay = max_retry_delay
|
|
184
|
+
|
|
185
|
+
def _build(
|
|
186
|
+
self,
|
|
187
|
+
op: Operation,
|
|
188
|
+
params: Mapping[str, Any],
|
|
189
|
+
options: RequestOptions | None,
|
|
190
|
+
api_key: str | None,
|
|
191
|
+
) -> httpx.Request:
|
|
192
|
+
options = options or {}
|
|
193
|
+
path = op.path
|
|
194
|
+
for name, wire in op.path_params.items():
|
|
195
|
+
path = path.replace("{" + wire + "}", quote(str(params[name]), safe=""))
|
|
196
|
+
headers = {
|
|
197
|
+
"accept": "application/json",
|
|
198
|
+
"user-agent": f"workwae-python/{__version__}",
|
|
199
|
+
**self.headers,
|
|
200
|
+
**options.get("headers", {}),
|
|
201
|
+
}
|
|
202
|
+
if api_key:
|
|
203
|
+
headers["authorization"] = f"Bearer {api_key}"
|
|
204
|
+
content = _body(op, params)
|
|
205
|
+
if content is not None:
|
|
206
|
+
headers["content-type"] = "application/json"
|
|
207
|
+
# One key for every attempt, so an attempt the API did act on is never acted on again.
|
|
208
|
+
if op.method == "POST":
|
|
209
|
+
key = options.get("idempotency_key") or headers.get("idempotency-key")
|
|
210
|
+
headers["idempotency-key"] = key or str(uuid.uuid4())
|
|
211
|
+
timeout = options.get("timeout", self.timeout)
|
|
212
|
+
return httpx.Request(
|
|
213
|
+
op.method,
|
|
214
|
+
self.base_url + path,
|
|
215
|
+
params=_query(op, params),
|
|
216
|
+
headers=headers,
|
|
217
|
+
content=content,
|
|
218
|
+
extensions={"timeout": httpx.Timeout(timeout).as_dict()},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def _retry_delay(self, response: httpx.Response, attempt: int) -> float | None:
|
|
222
|
+
"""How long to wait before sending again, or None to return this answer. The API answers
|
|
223
|
+
429 and 503 before acting on the request, so sending it again cannot apply it twice. The
|
|
224
|
+
wait is Retry-After when the answer names one, else an exponential backoff with full
|
|
225
|
+
jitter on its upper half, which keeps clients that failed together from retrying
|
|
226
|
+
together."""
|
|
227
|
+
if attempt >= self.max_retries or response.status_code not in _RETRIED_STATUSES:
|
|
228
|
+
return None
|
|
229
|
+
retry_after = parse_retry_after(response.headers.get("retry-after"))
|
|
230
|
+
if retry_after is not None:
|
|
231
|
+
return None if retry_after > self.max_retry_delay else retry_after
|
|
232
|
+
backoff = _BACKOFF_START * 2**attempt * (0.5 + random.random() / 2)
|
|
233
|
+
return min(backoff, self.max_retry_delay)
|
|
234
|
+
|
|
235
|
+
def _decode(self, op: Operation, response: httpx.Response) -> Any:
|
|
236
|
+
if response.status_code >= 400:
|
|
237
|
+
raise APIError.from_response(response)
|
|
238
|
+
if op.binary:
|
|
239
|
+
return BinaryResponse(
|
|
240
|
+
content=response.content,
|
|
241
|
+
content_type=response.headers.get("content-type"),
|
|
242
|
+
filename=_filename(response.headers.get("content-disposition")),
|
|
243
|
+
headers=response.headers,
|
|
244
|
+
)
|
|
245
|
+
if op.response is None:
|
|
246
|
+
return None
|
|
247
|
+
return adapter(op.response).validate_json(response.content)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
ApiKey = str | Callable[[], str]
|
|
251
|
+
AsyncApiKey = str | Callable[[], str | Awaitable[str]]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class SyncCaller(Protocol):
|
|
255
|
+
def _call(
|
|
256
|
+
self, op: Operation, params: Mapping[str, Any], options: RequestOptions | None
|
|
257
|
+
) -> Any: ...
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class AsyncCaller(Protocol):
|
|
261
|
+
def _call(
|
|
262
|
+
self, op: Operation, params: Mapping[str, Any], options: RequestOptions | None
|
|
263
|
+
) -> Awaitable[Any]: ...
|
workwae/_errors.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WorkwaeError(Exception):
|
|
10
|
+
"""Base of every error this package raises itself."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class APIError(WorkwaeError):
|
|
14
|
+
"""A refusal the API answered with. `code` is the error's stable reason (`account_not_found`,
|
|
15
|
+
`invalid_input`, ...; see `workwae.types.ErrorReason`), `status` its HTTP status, `data` the
|
|
16
|
+
reason's details when it has any, and `request_id` the answer's X-Request-Id (quote it to
|
|
17
|
+
support). `defined` is true for a reason the operation declares.
|
|
18
|
+
|
|
19
|
+
An answer whose body is not an API error (a proxy's page, say) has `code` None."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
message: str,
|
|
24
|
+
*,
|
|
25
|
+
status: int,
|
|
26
|
+
code: str | None,
|
|
27
|
+
data: Any = None,
|
|
28
|
+
defined: bool = False,
|
|
29
|
+
request_id: str | None = None,
|
|
30
|
+
headers: httpx.Headers | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.message = message
|
|
34
|
+
self.status = status
|
|
35
|
+
self.code = code
|
|
36
|
+
self.data = data
|
|
37
|
+
self.defined = defined
|
|
38
|
+
self.request_id = request_id
|
|
39
|
+
self.headers = headers if headers is not None else httpx.Headers()
|
|
40
|
+
|
|
41
|
+
def __repr__(self) -> str:
|
|
42
|
+
return (
|
|
43
|
+
f"APIError(status={self.status}, code={self.code!r}, message={self.message!r}, "
|
|
44
|
+
f"request_id={self.request_id!r})"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_response(cls, response: httpx.Response) -> APIError:
|
|
49
|
+
request_id = response.headers.get("x-request-id")
|
|
50
|
+
try:
|
|
51
|
+
body: Any = json.loads(response.content)
|
|
52
|
+
except ValueError:
|
|
53
|
+
body = None
|
|
54
|
+
fields = cast(dict[str, Any], body) if isinstance(body, dict) else {}
|
|
55
|
+
if isinstance(fields.get("code"), str):
|
|
56
|
+
message = fields.get("message")
|
|
57
|
+
return cls(
|
|
58
|
+
message if isinstance(message, str) else fields["code"],
|
|
59
|
+
status=response.status_code,
|
|
60
|
+
code=fields["code"],
|
|
61
|
+
data=fields.get("data"),
|
|
62
|
+
defined=fields.get("defined") is True,
|
|
63
|
+
request_id=request_id,
|
|
64
|
+
headers=response.headers,
|
|
65
|
+
)
|
|
66
|
+
return cls(
|
|
67
|
+
f"HTTP {response.status_code} {response.reason_phrase}".strip(),
|
|
68
|
+
status=response.status_code,
|
|
69
|
+
code=None,
|
|
70
|
+
request_id=request_id,
|
|
71
|
+
headers=response.headers,
|
|
72
|
+
)
|
workwae/_models.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pydantic
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Model(pydantic.BaseModel):
|
|
7
|
+
"""Base of every API model. Fields are snake_case and read their camelCase wire names; either
|
|
8
|
+
is accepted when building one. Fields the API adds after this SDK version are kept."""
|
|
9
|
+
|
|
10
|
+
model_config = pydantic.ConfigDict(
|
|
11
|
+
extra="allow",
|
|
12
|
+
validate_by_name=True,
|
|
13
|
+
validate_by_alias=True,
|
|
14
|
+
serialize_by_alias=True,
|
|
15
|
+
# Built on first use: importing the package does not compile every model's schema.
|
|
16
|
+
defer_build=True,
|
|
17
|
+
)
|