weclapp-client 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.
- weclapp_client/__init__.py +49 -0
- weclapp_client/_converters.py +70 -0
- weclapp_client/_http.py +258 -0
- weclapp_client/_version.py +10 -0
- weclapp_client/client.py +52 -0
- weclapp_client/config.py +97 -0
- weclapp_client/exceptions.py +111 -0
- weclapp_client/models/__init__.py +26 -0
- weclapp_client/models/base.py +61 -0
- weclapp_client/models/custom_attribute.py +55 -0
- weclapp_client/models/employee.py +44 -0
- weclapp_client/models/user.py +64 -0
- weclapp_client/py.typed +2 -0
- weclapp_client/query.py +140 -0
- weclapp_client/resources/__init__.py +3 -0
- weclapp_client/resources/base.py +202 -0
- weclapp_client/resources/custom_attribute_definitions.py +98 -0
- weclapp_client/resources/employees.py +73 -0
- weclapp_client/resources/users.py +56 -0
- weclapp_client/testing.py +692 -0
- weclapp_client-0.1.0a1.dist-info/METADATA +217 -0
- weclapp_client-0.1.0a1.dist-info/RECORD +23 -0
- weclapp_client-0.1.0a1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Typed Python client for the weclapp REST API v2, focused on the endpoints needed to synchronise employee master data.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from weclapp_client._version import __version__
|
|
6
|
+
from weclapp_client.client import WeclappClient
|
|
7
|
+
from weclapp_client.config import WeclappConfig
|
|
8
|
+
from weclapp_client.exceptions import (
|
|
9
|
+
AmbiguousResultError,
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
ConflictError,
|
|
12
|
+
CustomAttributeDefinitionError,
|
|
13
|
+
InvalidQueryError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
OptimisticLockError,
|
|
16
|
+
PermissionDeniedError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
ServerError,
|
|
19
|
+
ValidationIssue,
|
|
20
|
+
WeclappApiError,
|
|
21
|
+
WeclappConfigurationError,
|
|
22
|
+
WeclappConnectionError,
|
|
23
|
+
WeclappError,
|
|
24
|
+
WeclappValidationError,
|
|
25
|
+
)
|
|
26
|
+
from weclapp_client.query import Filter
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AmbiguousResultError",
|
|
30
|
+
"AuthenticationError",
|
|
31
|
+
"ConflictError",
|
|
32
|
+
"CustomAttributeDefinitionError",
|
|
33
|
+
"Filter",
|
|
34
|
+
"InvalidQueryError",
|
|
35
|
+
"NotFoundError",
|
|
36
|
+
"OptimisticLockError",
|
|
37
|
+
"PermissionDeniedError",
|
|
38
|
+
"RateLimitError",
|
|
39
|
+
"ServerError",
|
|
40
|
+
"ValidationIssue",
|
|
41
|
+
"WeclappApiError",
|
|
42
|
+
"WeclappClient",
|
|
43
|
+
"WeclappConfig",
|
|
44
|
+
"WeclappConfigurationError",
|
|
45
|
+
"WeclappConnectionError",
|
|
46
|
+
"WeclappError",
|
|
47
|
+
"WeclappValidationError",
|
|
48
|
+
"__version__",
|
|
49
|
+
]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Conversion between the weclapp representation of dates (milliseconds since the Unix epoch) and Python objects.
|
|
3
|
+
|
|
4
|
+
The arithmetic uses ``timedelta`` instead of ``datetime.fromtimestamp``: birth dates before 1970 have negative
|
|
5
|
+
timestamps, which ``fromtimestamp`` cannot handle on Windows.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import UTC, date, datetime, timedelta
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
from pydantic import BeforeValidator, PlainSerializer
|
|
12
|
+
|
|
13
|
+
_EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
|
|
14
|
+
_ONE_MILLISECOND = timedelta(milliseconds=1)
|
|
15
|
+
_HALF_DAY = timedelta(hours=12)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def ms_to_datetime(milliseconds: int) -> datetime:
|
|
19
|
+
"""Converts a weclapp timestamp to a timezone-aware ``datetime`` in UTC."""
|
|
20
|
+
return _EPOCH + timedelta(milliseconds=milliseconds)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def datetime_to_ms(value: datetime) -> int:
|
|
24
|
+
"""Converts a timezone-aware ``datetime`` to a weclapp timestamp."""
|
|
25
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
26
|
+
raise ValueError("naive datetimes are ambiguous, please pass a timezone-aware datetime")
|
|
27
|
+
return (value - _EPOCH) // _ONE_MILLISECOND
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def ms_to_date(milliseconds: int) -> date:
|
|
31
|
+
"""
|
|
32
|
+
Converts the timestamp of a date-only field (e.g. ``birthDate``) to a ``date``.
|
|
33
|
+
|
|
34
|
+
The timestamp is rounded to the nearest midnight (UTC). This yields the intended date no matter whether weclapp
|
|
35
|
+
stored midnight UTC or midnight in a local timezone such as Europe/Berlin (i.e. 22:00 or 23:00 UTC of the day
|
|
36
|
+
before).
|
|
37
|
+
"""
|
|
38
|
+
return (ms_to_datetime(milliseconds) + _HALF_DAY).date()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def date_to_ms(value: date) -> int:
|
|
42
|
+
"""Converts a ``date`` to the timestamp of midnight UTC of that day."""
|
|
43
|
+
return datetime_to_ms(datetime(value.year, value.month, value.day, tzinfo=UTC))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse_date(value: object) -> object:
|
|
47
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
48
|
+
return ms_to_date(value)
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_datetime(value: object) -> object:
|
|
53
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
54
|
+
return ms_to_datetime(value)
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
WeclappDate = Annotated[
|
|
59
|
+
date,
|
|
60
|
+
BeforeValidator(_parse_date),
|
|
61
|
+
PlainSerializer(date_to_ms, return_type=int, when_used="json"),
|
|
62
|
+
]
|
|
63
|
+
"""a date-only field; weclapp sends and expects milliseconds since the epoch"""
|
|
64
|
+
|
|
65
|
+
WeclappDateTime = Annotated[
|
|
66
|
+
datetime,
|
|
67
|
+
BeforeValidator(_parse_datetime),
|
|
68
|
+
PlainSerializer(datetime_to_ms, return_type=int, when_used="json"),
|
|
69
|
+
]
|
|
70
|
+
"""a timestamp field; weclapp sends and expects milliseconds since the epoch"""
|
weclapp_client/_http.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP transport: authentication, headers, timeouts, retries and the mapping of error responses to exceptions.
|
|
3
|
+
|
|
4
|
+
Retry rules (with exponential backoff and jitter):
|
|
5
|
+
|
|
6
|
+
- ``429 Too Many Requests`` and failed connection attempts are retried for every method, because weclapp did not
|
|
7
|
+
process these requests.
|
|
8
|
+
- ``502``/``503``/``504`` and other network errors (e.g. read timeouts) are only retried for idempotent methods
|
|
9
|
+
(``GET``, ``PUT``, ``DELETE``). A ``POST`` might already have created the entity, so it is not repeated; callers
|
|
10
|
+
should look up an entity before creating it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import random
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
17
|
+
from typing import Any
|
|
18
|
+
from urllib.parse import quote
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from weclapp_client._version import __version__
|
|
23
|
+
from weclapp_client.config import WeclappConfig
|
|
24
|
+
from weclapp_client.exceptions import (
|
|
25
|
+
AuthenticationError,
|
|
26
|
+
ConflictError,
|
|
27
|
+
NotFoundError,
|
|
28
|
+
OptimisticLockError,
|
|
29
|
+
PermissionDeniedError,
|
|
30
|
+
RateLimitError,
|
|
31
|
+
ServerError,
|
|
32
|
+
ValidationIssue,
|
|
33
|
+
WeclappApiError,
|
|
34
|
+
WeclappConnectionError,
|
|
35
|
+
WeclappError,
|
|
36
|
+
WeclappValidationError,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_logger = logging.getLogger("weclapp_client")
|
|
40
|
+
|
|
41
|
+
QueryParams = Sequence[tuple[str, str]]
|
|
42
|
+
|
|
43
|
+
_IDEMPOTENT_METHODS = frozenset({"GET", "PUT", "DELETE"})
|
|
44
|
+
_RETRY_STATUS_ALWAYS = frozenset({429})
|
|
45
|
+
_RETRY_STATUS_IDEMPOTENT = frozenset({429, 502, 503, 504})
|
|
46
|
+
_NOT_SENT_ERRORS = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)
|
|
47
|
+
"""transport errors that occur before the request reached weclapp"""
|
|
48
|
+
_IDEMPOTENT_RETRY_ERRORS = (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError)
|
|
49
|
+
_WAIT_WARNING_THRESHOLD_MS = 5000
|
|
50
|
+
_MAX_MESSAGE_LENGTH = 500
|
|
51
|
+
|
|
52
|
+
_ERROR_CLASSES: Mapping[int, type[WeclappApiError]] = {
|
|
53
|
+
400: WeclappValidationError,
|
|
54
|
+
401: AuthenticationError,
|
|
55
|
+
403: PermissionDeniedError,
|
|
56
|
+
404: NotFoundError,
|
|
57
|
+
409: ConflictError,
|
|
58
|
+
429: RateLimitError,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def entity_path(resource_path: str, entity_id: str) -> str:
|
|
63
|
+
"""Returns the path of a single entity, e.g. ``user/id/4711``; the id is URL-encoded."""
|
|
64
|
+
return f"{resource_path}/id/{quote(entity_id, safe='')}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _last_path_segment(value: object) -> str | None:
|
|
68
|
+
if not isinstance(value, str) or not value:
|
|
69
|
+
return None
|
|
70
|
+
return value.rsplit("/", 1)[-1]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _optional_str(value: object) -> str | None:
|
|
74
|
+
return value if isinstance(value, str) else None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _parse_validation_issue(raw: object) -> ValidationIssue | None:
|
|
78
|
+
if not isinstance(raw, dict):
|
|
79
|
+
return None
|
|
80
|
+
allowed = raw.get("allowed")
|
|
81
|
+
return ValidationIssue(
|
|
82
|
+
type=_last_path_segment(raw.get("type")),
|
|
83
|
+
title=_optional_str(raw.get("title")),
|
|
84
|
+
detail=_optional_str(raw.get("detail")),
|
|
85
|
+
error_code=_optional_str(raw.get("errorCode")),
|
|
86
|
+
instance=_optional_str(raw.get("instance")),
|
|
87
|
+
location=_optional_str(raw.get("location")),
|
|
88
|
+
allowed=tuple(str(item) for item in allowed) if isinstance(allowed, list) else (),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def error_from_response(response: httpx.Response) -> WeclappApiError:
|
|
93
|
+
"""Maps an error response of the weclapp API to the matching :class:`WeclappApiError` subclass."""
|
|
94
|
+
status = response.status_code
|
|
95
|
+
body: object = None
|
|
96
|
+
try:
|
|
97
|
+
body = response.json()
|
|
98
|
+
except ValueError:
|
|
99
|
+
body = None
|
|
100
|
+
problem: Mapping[str, Any] = body if isinstance(body, dict) else {}
|
|
101
|
+
|
|
102
|
+
problem_type = _last_path_segment(problem.get("type"))
|
|
103
|
+
title = _optional_str(problem.get("title"))
|
|
104
|
+
detail = _optional_str(problem.get("detail"))
|
|
105
|
+
raw_issues = problem.get("validationErrors")
|
|
106
|
+
issues = tuple(
|
|
107
|
+
issue for issue in map(_parse_validation_issue, raw_issues if isinstance(raw_issues, list) else []) if issue
|
|
108
|
+
)
|
|
109
|
+
text = response.text.strip() if not problem else ""
|
|
110
|
+
message = detail or title or _optional_str(problem.get("error")) or text or response.reason_phrase or "error"
|
|
111
|
+
if len(message) > _MAX_MESSAGE_LENGTH:
|
|
112
|
+
message = message[:_MAX_MESSAGE_LENGTH] + "..."
|
|
113
|
+
|
|
114
|
+
if status == 409 and problem_type == "optimistic_lock":
|
|
115
|
+
error_class: type[WeclappApiError] = OptimisticLockError
|
|
116
|
+
elif status >= 500:
|
|
117
|
+
error_class = ServerError
|
|
118
|
+
else:
|
|
119
|
+
error_class = _ERROR_CLASSES.get(status, WeclappApiError)
|
|
120
|
+
return error_class(
|
|
121
|
+
message,
|
|
122
|
+
status_code=status,
|
|
123
|
+
problem_type=problem_type,
|
|
124
|
+
title=title,
|
|
125
|
+
detail=detail,
|
|
126
|
+
instance=_optional_str(problem.get("instance")),
|
|
127
|
+
validation_errors=issues,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class HttpTransport:
|
|
132
|
+
"""
|
|
133
|
+
Sends requests to the weclapp API. Internal; use :class:`weclapp_client.WeclappClient` instead.
|
|
134
|
+
|
|
135
|
+
:param http_client: an existing client (e.g. with a mock transport for tests); it is not closed by :meth:`close`
|
|
136
|
+
:param sleep: used to wait between retries (injectable for tests)
|
|
137
|
+
:param jitter: returns a random number in ``[0, 1)`` that randomises the backoff (injectable for tests)
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
config: WeclappConfig,
|
|
143
|
+
http_client: httpx.Client | None = None,
|
|
144
|
+
*,
|
|
145
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
146
|
+
jitter: Callable[[], float] = random.random,
|
|
147
|
+
) -> None:
|
|
148
|
+
self._config = config
|
|
149
|
+
self._timeout = httpx.Timeout(config.timeout_seconds, connect=config.connect_timeout_seconds)
|
|
150
|
+
self._owns_client = http_client is None
|
|
151
|
+
self._client = http_client if http_client is not None else httpx.Client(timeout=self._timeout)
|
|
152
|
+
self._sleep = sleep
|
|
153
|
+
self._jitter = jitter
|
|
154
|
+
self._headers = {
|
|
155
|
+
"AuthenticationToken": config.api_token.get_secret_value(),
|
|
156
|
+
"Accept": "application/json",
|
|
157
|
+
"User-Agent": config.user_agent or f"weclapp-client-py/{__version__}",
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
def close(self) -> None:
|
|
161
|
+
"""Closes the underlying HTTP client if this transport created it."""
|
|
162
|
+
if self._owns_client:
|
|
163
|
+
self._client.close()
|
|
164
|
+
|
|
165
|
+
def request_json(
|
|
166
|
+
self,
|
|
167
|
+
method: str,
|
|
168
|
+
path: str,
|
|
169
|
+
*,
|
|
170
|
+
params: QueryParams = (),
|
|
171
|
+
json_body: Mapping[str, Any] | None = None,
|
|
172
|
+
) -> Any:
|
|
173
|
+
"""Sends a request and returns the decoded JSON body of the successful response."""
|
|
174
|
+
response = self.request(method, path, params=params, json_body=json_body)
|
|
175
|
+
try:
|
|
176
|
+
return response.json()
|
|
177
|
+
except ValueError as error:
|
|
178
|
+
raise WeclappError(f"weclapp returned an invalid JSON body for {method} {path}") from error
|
|
179
|
+
|
|
180
|
+
def request(
|
|
181
|
+
self,
|
|
182
|
+
method: str,
|
|
183
|
+
path: str,
|
|
184
|
+
*,
|
|
185
|
+
params: QueryParams = (),
|
|
186
|
+
json_body: Mapping[str, Any] | None = None,
|
|
187
|
+
) -> httpx.Response:
|
|
188
|
+
"""
|
|
189
|
+
Sends a request, retries it where that is safe and returns the successful response.
|
|
190
|
+
|
|
191
|
+
:param path: path relative to the base URL, e.g. ``user/id/4711``
|
|
192
|
+
:raises WeclappApiError: (or a subclass) if weclapp answers with an error status
|
|
193
|
+
:raises WeclappConnectionError: if weclapp cannot be reached
|
|
194
|
+
"""
|
|
195
|
+
method = method.upper()
|
|
196
|
+
idempotent = method in _IDEMPOTENT_METHODS
|
|
197
|
+
retry_status = _RETRY_STATUS_IDEMPOTENT if idempotent else _RETRY_STATUS_ALWAYS
|
|
198
|
+
url = self._config.base_url + path.lstrip("/")
|
|
199
|
+
attempt = 0
|
|
200
|
+
while True:
|
|
201
|
+
try:
|
|
202
|
+
response = self._client.request(
|
|
203
|
+
method,
|
|
204
|
+
url,
|
|
205
|
+
params=list(params),
|
|
206
|
+
json=json_body,
|
|
207
|
+
headers=self._headers,
|
|
208
|
+
timeout=self._timeout,
|
|
209
|
+
)
|
|
210
|
+
except httpx.TransportError as error:
|
|
211
|
+
retryable = isinstance(error, _NOT_SENT_ERRORS) or (
|
|
212
|
+
idempotent and isinstance(error, _IDEMPOTENT_RETRY_ERRORS)
|
|
213
|
+
)
|
|
214
|
+
if retryable and attempt < self._config.max_retries:
|
|
215
|
+
self._wait_before_retry(method, path, attempt, type(error).__name__)
|
|
216
|
+
attempt += 1
|
|
217
|
+
continue
|
|
218
|
+
raise WeclappConnectionError(f"{method} {path} failed: {type(error).__name__}") from error
|
|
219
|
+
|
|
220
|
+
self._log_response(method, path, response)
|
|
221
|
+
if response.status_code in retry_status and attempt < self._config.max_retries:
|
|
222
|
+
self._wait_before_retry(method, path, attempt, f"HTTP {response.status_code}")
|
|
223
|
+
attempt += 1
|
|
224
|
+
continue
|
|
225
|
+
if response.is_error:
|
|
226
|
+
raise error_from_response(response)
|
|
227
|
+
return response
|
|
228
|
+
|
|
229
|
+
def _wait_before_retry(self, method: str, path: str, attempt: int, reason: str) -> None:
|
|
230
|
+
delay = min(self._config.backoff_max_seconds, self._config.backoff_initial_seconds * 2**attempt)
|
|
231
|
+
delay *= 0.5 + 0.5 * self._jitter()
|
|
232
|
+
_logger.warning(
|
|
233
|
+
"Retrying %s %s after %s in %.1f s (retry %d of %d)",
|
|
234
|
+
method,
|
|
235
|
+
path,
|
|
236
|
+
reason,
|
|
237
|
+
delay,
|
|
238
|
+
attempt + 1,
|
|
239
|
+
self._config.max_retries,
|
|
240
|
+
)
|
|
241
|
+
self._sleep(delay)
|
|
242
|
+
|
|
243
|
+
@staticmethod
|
|
244
|
+
def _log_response(method: str, path: str, response: httpx.Response) -> None:
|
|
245
|
+
# only method, path and status are logged: query parameters and bodies may contain personal data
|
|
246
|
+
_logger.debug("%s %s -> %d", method, path, response.status_code)
|
|
247
|
+
wait_ms = response.headers.get("X-Weclapp-Wait-Ms")
|
|
248
|
+
if wait_ms is None or not wait_ms.isdigit():
|
|
249
|
+
return
|
|
250
|
+
level = logging.WARNING if int(wait_ms) >= _WAIT_WARNING_THRESHOLD_MS else logging.DEBUG
|
|
251
|
+
_logger.log(
|
|
252
|
+
level,
|
|
253
|
+
"weclapp delayed %s %s by %s ms (reason: %s)",
|
|
254
|
+
method,
|
|
255
|
+
path,
|
|
256
|
+
wait_ms,
|
|
257
|
+
response.headers.get("X-Weclapp-Wait-Reason", "unknown"),
|
|
258
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Version of the installed distribution (derived from git tags by hatch-vcs at build time).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("weclapp-client")
|
|
9
|
+
except PackageNotFoundError: # pragma: no cover - only happens when the package is used without being installed
|
|
10
|
+
__version__ = "0.0.0"
|
weclapp_client/client.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The entry point of the package: :class:`WeclappClient`.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from weclapp_client._http import HttpTransport
|
|
11
|
+
from weclapp_client.config import WeclappConfig
|
|
12
|
+
from weclapp_client.resources.custom_attribute_definitions import CustomAttributeDefinitionsResource
|
|
13
|
+
from weclapp_client.resources.employees import EmployeesResource
|
|
14
|
+
from weclapp_client.resources.users import UsersResource
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WeclappClient:
|
|
18
|
+
"""
|
|
19
|
+
Client for the weclapp REST API v2.
|
|
20
|
+
|
|
21
|
+
Use it as a context manager, so the HTTP connections are closed afterwards::
|
|
22
|
+
|
|
23
|
+
with WeclappClient(WeclappConfig.from_env()) as client:
|
|
24
|
+
user = client.users.current()
|
|
25
|
+
|
|
26
|
+
:param http_client: an existing ``httpx.Client``, e.g. with a mock transport for tests; it is not closed by the
|
|
27
|
+
weclapp client
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, config: WeclappConfig, *, http_client: httpx.Client | None = None) -> None:
|
|
31
|
+
self._transport = HttpTransport(config, http_client)
|
|
32
|
+
self.users = UsersResource(self._transport)
|
|
33
|
+
"""``/user``: the accounts that hold name and e-mail address of employees"""
|
|
34
|
+
self.employees = EmployeesResource(self._transport)
|
|
35
|
+
"""``/employee``: the HR records, each linked to a user"""
|
|
36
|
+
self.custom_attribute_definitions = CustomAttributeDefinitionsResource(self._transport)
|
|
37
|
+
"""``/customAttributeDefinition``: the definitions of custom attributes"""
|
|
38
|
+
|
|
39
|
+
def close(self) -> None:
|
|
40
|
+
"""Closes the HTTP connections (unless the ``httpx.Client`` was passed in)."""
|
|
41
|
+
self._transport.close()
|
|
42
|
+
|
|
43
|
+
def __enter__(self) -> Self:
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def __exit__(
|
|
47
|
+
self,
|
|
48
|
+
exc_type: type[BaseException] | None,
|
|
49
|
+
exc_value: BaseException | None,
|
|
50
|
+
traceback: TracebackType | None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.close()
|
weclapp_client/config.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration of the weclapp client.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any, Self
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
|
|
11
|
+
|
|
12
|
+
from weclapp_client.exceptions import WeclappConfigurationError
|
|
13
|
+
|
|
14
|
+
_TENANT_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
|
15
|
+
|
|
16
|
+
ENV_BASE_URL = "WECLAPP_BASE_URL"
|
|
17
|
+
ENV_TENANT = "WECLAPP_TENANT"
|
|
18
|
+
ENV_API_TOKEN = "WECLAPP_API_TOKEN"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def tenant_base_url(tenant: str) -> str:
|
|
22
|
+
"""
|
|
23
|
+
Returns the base URL of the REST API v2 of the given weclapp tenant, e.g. ``"acme"`` becomes
|
|
24
|
+
``"https://acme.weclapp.com/webapp/api/v2/"``.
|
|
25
|
+
"""
|
|
26
|
+
normalized = tenant.strip().lower()
|
|
27
|
+
if not _TENANT_PATTERN.fullmatch(normalized):
|
|
28
|
+
raise WeclappConfigurationError(f"Invalid weclapp tenant name: {tenant!r}")
|
|
29
|
+
return f"https://{normalized}.weclapp.com/webapp/api/v2/"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class WeclappConfig(BaseModel):
|
|
33
|
+
"""
|
|
34
|
+
Connection settings for the weclapp REST API v2.
|
|
35
|
+
|
|
36
|
+
The API token is stored as :class:`~pydantic.SecretStr`, so it does not show up in ``repr``, logs or exceptions.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
40
|
+
|
|
41
|
+
base_url: str
|
|
42
|
+
"""base URL of the REST API v2, e.g. ``https://<tenant>.weclapp.com/webapp/api/v2/``"""
|
|
43
|
+
api_token: SecretStr
|
|
44
|
+
"""API token of the (technical) weclapp user, sent in the ``AuthenticationToken`` header"""
|
|
45
|
+
timeout_seconds: float = Field(default=60.0, gt=0)
|
|
46
|
+
"""timeout for a single request; weclapp recommends at least one minute"""
|
|
47
|
+
connect_timeout_seconds: float = Field(default=10.0, gt=0)
|
|
48
|
+
"""timeout for establishing the connection"""
|
|
49
|
+
max_retries: int = Field(default=5, ge=0)
|
|
50
|
+
"""how often a request is retried if that is safe (see the README for the retry rules)"""
|
|
51
|
+
backoff_initial_seconds: float = Field(default=1.0, ge=0)
|
|
52
|
+
"""delay before the first retry; it doubles with every further retry"""
|
|
53
|
+
backoff_max_seconds: float = Field(default=30.0, ge=0)
|
|
54
|
+
"""upper limit for the delay between two retries"""
|
|
55
|
+
user_agent: str | None = None
|
|
56
|
+
"""value of the ``User-Agent`` header; defaults to ``weclapp-client-py/<version>``"""
|
|
57
|
+
|
|
58
|
+
@field_validator("base_url")
|
|
59
|
+
@classmethod
|
|
60
|
+
def _normalize_base_url(cls, value: str) -> str:
|
|
61
|
+
normalized = value.strip()
|
|
62
|
+
if not normalized.startswith(("https://", "http://")):
|
|
63
|
+
raise ValueError("base_url must start with https:// (or http:// for local test servers)")
|
|
64
|
+
return normalized.rstrip("/") + "/"
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def for_tenant(cls, tenant: str, api_token: str | SecretStr, **settings: Any) -> Self:
|
|
68
|
+
"""
|
|
69
|
+
Creates the configuration for the weclapp tenant ``https://<tenant>.weclapp.com``.
|
|
70
|
+
|
|
71
|
+
:param settings: further fields of this class, e.g. ``timeout_seconds``
|
|
72
|
+
"""
|
|
73
|
+
token = api_token if isinstance(api_token, SecretStr) else SecretStr(api_token)
|
|
74
|
+
return cls(base_url=tenant_base_url(tenant), api_token=token, **settings)
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_env(cls, environ: Mapping[str, str] | None = None, **settings: Any) -> Self:
|
|
78
|
+
"""
|
|
79
|
+
Creates the configuration from environment variables:
|
|
80
|
+
|
|
81
|
+
- ``WECLAPP_API_TOKEN`` (required)
|
|
82
|
+
- ``WECLAPP_BASE_URL`` or, alternatively, ``WECLAPP_TENANT``
|
|
83
|
+
|
|
84
|
+
:param environ: the variables to read from; defaults to :data:`os.environ`
|
|
85
|
+
:param settings: further fields of this class, e.g. ``timeout_seconds``
|
|
86
|
+
"""
|
|
87
|
+
variables = os.environ if environ is None else environ
|
|
88
|
+
api_token = variables.get(ENV_API_TOKEN, "").strip()
|
|
89
|
+
if not api_token:
|
|
90
|
+
raise WeclappConfigurationError(f"Environment variable {ENV_API_TOKEN} is not set")
|
|
91
|
+
base_url = variables.get(ENV_BASE_URL, "").strip()
|
|
92
|
+
if base_url:
|
|
93
|
+
return cls(base_url=base_url, api_token=SecretStr(api_token), **settings)
|
|
94
|
+
tenant = variables.get(ENV_TENANT, "").strip()
|
|
95
|
+
if tenant:
|
|
96
|
+
return cls.for_tenant(tenant, api_token, **settings)
|
|
97
|
+
raise WeclappConfigurationError(f"Neither {ENV_BASE_URL} nor {ENV_TENANT} is set")
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exceptions raised by the weclapp client.
|
|
3
|
+
|
|
4
|
+
All exceptions derive from :class:`WeclappError`. Errors returned by the weclapp API derive from
|
|
5
|
+
:class:`WeclappApiError` and carry the parsed problem details (weclapp uses a structure based on RFC 7807).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class WeclappError(Exception):
|
|
12
|
+
"""Base class of all errors raised by this package."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WeclappConfigurationError(WeclappError):
|
|
16
|
+
"""The client configuration is incomplete or invalid (e.g. a missing environment variable)."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WeclappConnectionError(WeclappError):
|
|
20
|
+
"""The weclapp API could not be reached (network error or timeout), even after all retries."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InvalidQueryError(WeclappError, ValueError):
|
|
24
|
+
"""
|
|
25
|
+
A query uses a property that is unknown or not filterable/sortable for the resource.
|
|
26
|
+
|
|
27
|
+
weclapp silently ignores unknown filter parameters, so such a query is rejected before it is sent.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AmbiguousResultError(WeclappError):
|
|
32
|
+
"""A lookup that expects at most one result matched several entities."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CustomAttributeDefinitionError(WeclappError):
|
|
36
|
+
"""A custom attribute definition does not exist or does not have the expected type."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, kw_only=True)
|
|
40
|
+
class ValidationIssue:
|
|
41
|
+
"""One entry of the ``validationErrors`` list of a weclapp error response."""
|
|
42
|
+
|
|
43
|
+
type: str | None = None
|
|
44
|
+
"""the problem type, i.e. the part of the weclapp ``type`` URI after the last ``/`` (e.g. ``"reference"``)"""
|
|
45
|
+
title: str | None = None
|
|
46
|
+
detail: str | None = None
|
|
47
|
+
error_code: str | None = None
|
|
48
|
+
instance: str | None = None
|
|
49
|
+
location: str | None = None
|
|
50
|
+
"""JSON path of the affected property"""
|
|
51
|
+
allowed: tuple[str, ...] = ()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class WeclappApiError(WeclappError):
|
|
55
|
+
"""The weclapp API answered with an HTTP error status."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
message: str,
|
|
60
|
+
*,
|
|
61
|
+
status_code: int,
|
|
62
|
+
problem_type: str | None = None,
|
|
63
|
+
title: str | None = None,
|
|
64
|
+
detail: str | None = None,
|
|
65
|
+
instance: str | None = None,
|
|
66
|
+
validation_errors: tuple[ValidationIssue, ...] = (),
|
|
67
|
+
) -> None:
|
|
68
|
+
super().__init__(message)
|
|
69
|
+
self.message = message
|
|
70
|
+
self.status_code = status_code
|
|
71
|
+
self.problem_type = problem_type
|
|
72
|
+
"""the part of the weclapp ``type`` URI after the last ``/`` (e.g. ``"optimistic_lock"``)"""
|
|
73
|
+
self.title = title
|
|
74
|
+
self.detail = detail
|
|
75
|
+
self.instance = instance
|
|
76
|
+
self.validation_errors = validation_errors
|
|
77
|
+
|
|
78
|
+
def __str__(self) -> str:
|
|
79
|
+
return f"{self.message} (HTTP {self.status_code})"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AuthenticationError(WeclappApiError):
|
|
83
|
+
"""HTTP 401: the API token is missing or invalid."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class PermissionDeniedError(WeclappApiError):
|
|
87
|
+
"""HTTP 403: the API user lacks the permission for the requested operation or property."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class NotFoundError(WeclappApiError):
|
|
91
|
+
"""HTTP 404: the requested entity or resource does not exist."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class WeclappValidationError(WeclappApiError):
|
|
95
|
+
"""HTTP 400: the request was rejected, see :attr:`validation_errors` for the affected properties."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ConflictError(WeclappApiError):
|
|
99
|
+
"""HTTP 409: the operation is not possible in the current state of the entity."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class OptimisticLockError(ConflictError):
|
|
103
|
+
"""HTTP 409 ``optimistic_lock``: the entity was changed in the meantime, re-read it and try again."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class RateLimitError(WeclappApiError):
|
|
107
|
+
"""HTTP 429: weclapp rejected the request due to load, even after all retries."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ServerError(WeclappApiError):
|
|
111
|
+
"""HTTP 5xx: weclapp failed to process the request."""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Models of the weclapp entities: read models (as returned by weclapp) and write models (for creating and updating).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from weclapp_client.models.base import WeclappCreateModel, WeclappEntity, WeclappModel, WeclappUpdateModel
|
|
6
|
+
from weclapp_client.models.custom_attribute import CustomAttribute, CustomAttributeDefinition, CustomAttributeType
|
|
7
|
+
from weclapp_client.models.employee import Employee, EmployeeCreate, EmployeeUpdate, EmploymentStatus
|
|
8
|
+
from weclapp_client.models.user import User, UserCreate, UserStatus, UserUpdate
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"CustomAttribute",
|
|
12
|
+
"CustomAttributeDefinition",
|
|
13
|
+
"CustomAttributeType",
|
|
14
|
+
"Employee",
|
|
15
|
+
"EmployeeCreate",
|
|
16
|
+
"EmployeeUpdate",
|
|
17
|
+
"EmploymentStatus",
|
|
18
|
+
"User",
|
|
19
|
+
"UserCreate",
|
|
20
|
+
"UserStatus",
|
|
21
|
+
"UserUpdate",
|
|
22
|
+
"WeclappCreateModel",
|
|
23
|
+
"WeclappEntity",
|
|
24
|
+
"WeclappModel",
|
|
25
|
+
"WeclappUpdateModel",
|
|
26
|
+
]
|