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 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
+ )