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.
@@ -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"""
@@ -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"
@@ -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()
@@ -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
+ ]