openepcis-client 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 benelog GmbH & Co. KG
3
+ """OpenEPCIS platform client. See README.md for the module layout."""
4
+
5
+ __version__ = "0.1.0.dev0"
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 benelog GmbH & Co. KG
3
+ """Identifiers, Digital Link URIs, transport, auth strategies, errors."""
4
+
5
+ from .errors import OpenEpcisError
6
+
7
+ __all__ = ["OpenEpcisError"]
@@ -0,0 +1,335 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 benelog GmbH & Co. KG
3
+ """Authentication strategies and the discovery that feeds them.
4
+
5
+ The platform authenticates connectors with an OIDC offline token: a refresh
6
+ token issued with the ``offline_access`` scope, exchanged for short-lived
7
+ access tokens as needed. Issuing the offline token is a human act in a
8
+ browser; this library only consumes one.
9
+
10
+ Discovery removes the last hand-typed URL besides the resolver's own. The
11
+ resolver publishes OAuth 2.0 Protected Resource Metadata (RFC 9728) naming its
12
+ authorization server; the authorization server's OIDC discovery document names
13
+ the token endpoint. One configured URL, everything else is found.
14
+
15
+ The seam for a second mode (a customer-supplied GS1 Germany token, or anything
16
+ after that) is :class:`AuthStrategy`: one method that yields a bearer, one
17
+ that forgets it after a 401.
18
+ """
19
+
20
+ import base64
21
+ import binascii
22
+ import json
23
+ import time
24
+ from typing import Any, Protocol
25
+
26
+ import requests
27
+
28
+ from .config import ClientConfig
29
+ from .errors import OpenEpcisError
30
+
31
+ #: Mint a new access token this many seconds before the current one lapses, so
32
+ #: a request never starts with a token that expires mid-flight.
33
+ EXPIRY_MARGIN = 30
34
+
35
+ #: Authorization servers discovered from a resolver's protected-resource
36
+ #: metadata, keyed by resolver base URL. Class-wide and unbounded on purpose: a
37
+ #: process talks to a handful of resolvers, and the answer changes only when a
38
+ #: deployment is reconfigured. A restart is an acceptable cache invalidation.
39
+ _PROTECTED_RESOURCE: dict[str, str] = {}
40
+
41
+ #: OIDC discovery documents, keyed by issuer. Same lifetime argument.
42
+ _OIDC_CONFIG: dict[str, dict[str, Any]] = {}
43
+
44
+
45
+ class TokenStore(Protocol):
46
+ """Where the host keeps the offline token and what is known about it.
47
+
48
+ The host framework owns persistence: an ERP stores the token on a company
49
+ record, a script keeps it in memory or a secret store. Two of the three
50
+ methods exist because of Keycloak behaviour the library must honour:
51
+ rotation ("Revoke Refresh Token") replaces the offline token on every
52
+ exchange, and losing the replacement locks the connector out with a
53
+ credential that still looks correct on screen.
54
+ """
55
+
56
+ def get_offline_token(self) -> str:
57
+ """The current offline token. Read on every mint, so rotation holds."""
58
+ ...
59
+
60
+ def save_offline_token(self, token: str) -> None:
61
+ """Persist a rotated offline token the moment it arrives."""
62
+ ...
63
+
64
+ def save_subject(self, username: str) -> None:
65
+ """Record whom the token belongs to, for display next to the setting."""
66
+ ...
67
+
68
+
69
+ class AuthStrategy(Protocol):
70
+ """A source of bearer tokens for :class:`~openepcis_client.core.client.Client`."""
71
+
72
+ def bearer(self) -> str:
73
+ """A currently valid access token."""
74
+ ...
75
+
76
+ def invalidate(self) -> None:
77
+ """Forget any cached token; the next :meth:`bearer` mints afresh."""
78
+ ...
79
+
80
+
81
+ class InMemoryTokenStore:
82
+ """A token store for scripts and tests. Nothing outlives the process."""
83
+
84
+ def __init__(self, offline_token: str) -> None:
85
+ self.offline_token = offline_token
86
+ self.subject = ""
87
+
88
+ def get_offline_token(self) -> str:
89
+ return self.offline_token
90
+
91
+ def save_offline_token(self, token: str) -> None:
92
+ self.offline_token = token
93
+
94
+ def save_subject(self, username: str) -> None:
95
+ self.subject = username
96
+
97
+
98
+ def token_claims(token: str) -> dict[str, Any]:
99
+ """The claims of a JWT, read without verifying it.
100
+
101
+ Only ever used to describe a token back to whoever deposited it. Nothing is
102
+ authorised on this basis; the platform and Keycloak do the verifying.
103
+ Answers an empty dict for anything opaque.
104
+ """
105
+ try:
106
+ payload = token.split(".")[1]
107
+ payload += "=" * (-len(payload) % 4)
108
+ claims = json.loads(base64.urlsafe_b64decode(payload))
109
+ except (IndexError, ValueError, binascii.Error, UnicodeDecodeError):
110
+ return {}
111
+ return claims if isinstance(claims, dict) else {}
112
+
113
+
114
+ def token_type(token: str) -> str:
115
+ """The ``typ`` claim: ``Offline`` for the token this library expects."""
116
+ return str(token_claims(token).get("typ") or "")
117
+
118
+
119
+ def token_subject(token: str) -> str:
120
+ """Whom a token belongs to, as well as it can be told."""
121
+ claims = token_claims(token)
122
+ return str(claims.get("preferred_username") or claims.get("sub") or "")
123
+
124
+
125
+ class OfflineTokenAuth:
126
+ """Mode one: OpenEPCIS credentials via an OIDC offline token.
127
+
128
+ :param config: where the resolver is and how patient to be.
129
+ :param token_store: the host's persistence for the offline token.
130
+ :param client_id: the OIDC client this connector authenticates as.
131
+ :param client_secret: only for a confidential client.
132
+ :param issuer_override: skip RFC 9728 discovery and use this realm URL.
133
+ For a resolver that publishes no protected-resource metadata.
134
+ :param session: a ``requests``-compatible session, injectable for tests.
135
+ """
136
+
137
+ def __init__(
138
+ self,
139
+ config: ClientConfig,
140
+ token_store: TokenStore,
141
+ client_id: str,
142
+ client_secret: str = "",
143
+ issuer_override: str = "",
144
+ session: requests.Session | None = None,
145
+ ) -> None:
146
+ self._config = config
147
+ self._store = token_store
148
+ self._client_id = client_id
149
+ self._client_secret = client_secret
150
+ self._issuer_override = issuer_override.rstrip("/")
151
+ self._session = session or requests.Session()
152
+ self._access_token = ""
153
+ self._expires_at = 0.0
154
+
155
+ # -- AuthStrategy ------------------------------------------------------
156
+
157
+ def bearer(self) -> str:
158
+ if self._access_token and self._expires_at > time.time():
159
+ return self._access_token
160
+ token, lifetime = self._mint()
161
+ self._access_token = token
162
+ self._expires_at = time.time() + max(lifetime - EXPIRY_MARGIN, 5)
163
+ return token
164
+
165
+ def invalidate(self) -> None:
166
+ self._access_token = ""
167
+ self._expires_at = 0.0
168
+
169
+ # -- Discovery ---------------------------------------------------------
170
+
171
+ def issuer(self) -> str:
172
+ """The authorization server, configured or discovered (RFC 9728)."""
173
+ if self._issuer_override:
174
+ return self._issuer_override
175
+ base_url = self._config.base_url
176
+ if base_url in _PROTECTED_RESOURCE:
177
+ return _PROTECTED_RESOURCE[base_url]
178
+
179
+ url = f"{base_url}/.well-known/oauth-protected-resource"
180
+ try:
181
+ response = self._session.get(url, timeout=self._config.token_timeout)
182
+ except requests.exceptions.RequestException as exc:
183
+ raise OpenEpcisError(f"Could not reach the resolver at {url}: {exc}") from exc
184
+ if response.status_code >= 300:
185
+ raise OpenEpcisError(
186
+ f"The resolver at {base_url} does not publish OAuth metadata "
187
+ f"({url} answered {response.status_code}). Configure the realm "
188
+ "URL explicitly.",
189
+ status=response.status_code,
190
+ )
191
+ try:
192
+ servers = (response.json() or {}).get("authorization_servers") or []
193
+ except ValueError as exc:
194
+ raise OpenEpcisError(
195
+ f"The resolver at {url} returned no OAuth metadata document."
196
+ ) from exc
197
+ if not servers:
198
+ raise OpenEpcisError(
199
+ f"The resolver at {url} names no authorization server in its OAuth metadata."
200
+ )
201
+
202
+ issuer = str(servers[0]).rstrip("/")
203
+ _PROTECTED_RESOURCE[base_url] = issuer
204
+ return issuer
205
+
206
+ def _oidc_config(self) -> dict[str, Any]:
207
+ """The realm's discovery document, so no endpoint is hardcoded."""
208
+ issuer = self.issuer()
209
+ if issuer in _OIDC_CONFIG:
210
+ return _OIDC_CONFIG[issuer]
211
+
212
+ url = f"{issuer}/.well-known/openid-configuration"
213
+ try:
214
+ response = self._session.get(url, timeout=self._config.token_timeout)
215
+ except requests.exceptions.RequestException as exc:
216
+ raise OpenEpcisError(f"Could not reach the realm at {url}: {exc}") from exc
217
+ if response.status_code >= 300:
218
+ raise OpenEpcisError(
219
+ f"The realm URL does not look like an OIDC realm: {url} answered "
220
+ f"{response.status_code}. It should end in /realms/<name>.",
221
+ status=response.status_code,
222
+ )
223
+ try:
224
+ document = response.json()
225
+ except ValueError as exc:
226
+ raise OpenEpcisError(
227
+ f"The realm at {url} did not return a discovery document."
228
+ ) from exc
229
+ if not isinstance(document, dict):
230
+ raise OpenEpcisError(f"The realm at {url} did not return a discovery document.")
231
+
232
+ _OIDC_CONFIG[issuer] = document
233
+ return document
234
+
235
+ # -- Token exchange ------------------------------------------------------
236
+
237
+ def _mint(self) -> tuple[str, int]:
238
+ """Exchange the offline token for an access token.
239
+
240
+ Honours rotation: when the realm has "Revoke Refresh Token" switched
241
+ on, Keycloak replaces the offline token on every exchange, and the
242
+ replacement goes straight to the :class:`TokenStore`. Failing to store
243
+ it would lock the connector out at the next refresh with a credential
244
+ that still looks unchanged where it was pasted.
245
+ """
246
+ endpoint = self._oidc_config().get("token_endpoint")
247
+ if not endpoint:
248
+ raise OpenEpcisError("The realm advertises no token endpoint.")
249
+
250
+ offline_token = self._store.get_offline_token()
251
+ payload = {
252
+ "grant_type": "refresh_token",
253
+ "refresh_token": offline_token,
254
+ "client_id": self._client_id,
255
+ }
256
+ if self._client_secret:
257
+ payload["client_secret"] = self._client_secret
258
+
259
+ try:
260
+ response = self._session.post(
261
+ str(endpoint), data=payload, timeout=self._config.token_timeout
262
+ )
263
+ except requests.exceptions.RequestException as exc:
264
+ raise OpenEpcisError(f"Could not reach the token endpoint: {exc}") from exc
265
+
266
+ if response.status_code >= 300:
267
+ raise self._token_error(response)
268
+
269
+ body = response.json()
270
+ access_token = body.get("access_token")
271
+ if not access_token:
272
+ raise OpenEpcisError("The token endpoint returned no access token.")
273
+
274
+ rotated = body.get("refresh_token")
275
+ if rotated and rotated != offline_token:
276
+ self._store.save_offline_token(str(rotated))
277
+
278
+ # An offline refresh token carries no preferred_username, so a token
279
+ # deposited by hand can only be labelled with its subject UUID. The
280
+ # access token does carry it; correct the label the first time one is
281
+ # minted, which is when the information becomes available.
282
+ username = token_claims(str(access_token)).get("preferred_username")
283
+ if username:
284
+ self._store.save_subject(str(username))
285
+
286
+ return str(access_token), int(body.get("expires_in") or 60)
287
+
288
+ @staticmethod
289
+ def _token_error(response: requests.Response) -> OpenEpcisError:
290
+ """Phrase a token failure as something an operator can act on."""
291
+ detail: dict[str, Any] = {}
292
+ try:
293
+ body = response.json()
294
+ if isinstance(body, dict):
295
+ detail = body
296
+ except ValueError:
297
+ pass
298
+ code = str(detail.get("error") or "")
299
+ description = str(detail.get("error_description") or "")
300
+
301
+ if code == "invalid_grant":
302
+ # A token is bound to the issuer URL it was minted under. Where two
303
+ # hostnames serve the same realm, a token issued via one and
304
+ # refreshed via the other fails here; calling that "revoked" sends
305
+ # the reader to the wrong fix. Keycloak names the expected issuer,
306
+ # so pass it through.
307
+ if "issuer" in description.lower():
308
+ return OpenEpcisError(
309
+ "The offline token was issued by a different URL than the "
310
+ f"one configured here: {description}. A token is bound to "
311
+ "the issuer it was minted under; an alias hostname for the "
312
+ "same realm counts as different.",
313
+ status=response.status_code,
314
+ problem=detail,
315
+ )
316
+ return OpenEpcisError(
317
+ f"The offline token is no longer accepted ({description or code}). "
318
+ "It has been revoked, or the realm's offline session has been "
319
+ "removed. Deposit a fresh one.",
320
+ status=response.status_code,
321
+ problem=detail,
322
+ )
323
+ if code == "unauthorized_client":
324
+ return OpenEpcisError(
325
+ "The identity provider refused the client. Check the client ID, "
326
+ "and the secret if the client is confidential.",
327
+ status=response.status_code,
328
+ problem=detail,
329
+ )
330
+ return OpenEpcisError(
331
+ "The identity provider refused to issue a token: "
332
+ f"{description or code or response.text[:200]}",
333
+ status=response.status_code,
334
+ problem=detail,
335
+ )
@@ -0,0 +1,255 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 benelog GmbH & Co. KG
3
+ """The HTTP client every module calls through.
4
+
5
+ Three properties are worth defending, because getting any of them wrong is
6
+ either a lock-out or a lie:
7
+
8
+ - a 401 is re-authorised exactly once, and that does not count as repeating
9
+ the operation. Otherwise a POST that was refused never gets its second
10
+ chance, or worse, one that succeeded gets sent twice;
11
+ - only idempotent verbs retry. A retried POST could publish twice or burn a
12
+ GS1 key;
13
+ - errors carry the server's own RFC 7807 ``detail`` where there is one, with
14
+ the structured facts alongside, and never invent a sentence the server did
15
+ not say.
16
+ """
17
+
18
+ import json
19
+ import logging
20
+ import time
21
+ from typing import Any
22
+
23
+ import requests
24
+
25
+ from .auth import AuthStrategy
26
+ from .config import ClientConfig
27
+ from .errors import OpenEpcisError
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ #: Verbs that are safe to repeat. A retried POST is not: it could create a
32
+ #: second record or, worse, burn a GS1 key.
33
+ IDEMPOTENT_METHODS = frozenset({"GET", "PUT", "DELETE", "HEAD"})
34
+
35
+ #: What this client will accept back, and why it is not just ``application/json``.
36
+ #:
37
+ #: The EPCIS capture endpoint produces ``application/ld+json`` and
38
+ #: ``application/problem+json`` and nothing else. Asking for plain JSON — the
39
+ #: obvious thing, and what this sent — is answered with **406 Not Acceptable**
40
+ #: before the document is even looked at, so no event from this client would
41
+ #: ever have been captured against a real repository. It went unnoticed because
42
+ #: every test here stubs the transport at the seam, and the connector that runs
43
+ #: in production is the Java one, which sends its own headers.
44
+ #:
45
+ #: Listing both keeps the catalog and registry endpoints, which answer plain
46
+ #: JSON, working as before: the server picks what it can produce.
47
+ ACCEPT = "application/json, application/ld+json"
48
+
49
+ MAX_ATTEMPTS = 3
50
+ BACKOFF_SECONDS: tuple[float, ...] = (0.5, 1.5)
51
+
52
+
53
+ class Client:
54
+ """Authenticated JSON calls against the platform.
55
+
56
+ :param config: where the platform is.
57
+ :param auth: the bearer source; see
58
+ :class:`~openepcis_client.core.auth.OfflineTokenAuth`.
59
+ :param session: a ``requests``-compatible session, injectable for tests.
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ config: ClientConfig,
65
+ auth: AuthStrategy,
66
+ session: requests.Session | None = None,
67
+ ) -> None:
68
+ self._config = config
69
+ self._auth = auth
70
+ self._session = session or requests.Session()
71
+
72
+ # -- Verbs ---------------------------------------------------------------
73
+
74
+ def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
75
+ return self.request("GET", path, params=params)
76
+
77
+ def put(self, path: str, payload: Any) -> Any:
78
+ return self.request("PUT", path, payload=payload)
79
+
80
+ def post(self, path: str, payload: Any = None) -> Any:
81
+ return self.request("POST", path, payload=payload)
82
+
83
+ def patch(self, path: str, payload: Any) -> Any:
84
+ return self.request("PATCH", path, payload=payload)
85
+
86
+ def delete(self, path: str) -> Any:
87
+ return self.request("DELETE", path)
88
+
89
+ def request(
90
+ self,
91
+ method: str,
92
+ path: str,
93
+ payload: Any = None,
94
+ params: dict[str, Any] | None = None,
95
+ timeout: tuple[float, float] | None = None,
96
+ raw: bool = False,
97
+ ) -> Any:
98
+ """Call the platform and return the decoded body.
99
+
100
+ :param raw: return the ``requests`` response instead of the parsed
101
+ body. For the handful of endpoints whose answer is in the headers —
102
+ EPCIS capture replies ``202`` with an empty body and a ``Location``
103
+ naming the job — where decoding the body would discard the only
104
+ thing the call returned.
105
+ :returns: the parsed JSON, or ``None`` for an empty body (``204``).
106
+ :raises OpenEpcisError: for every non-2xx answer and every transport
107
+ failure. Callers decide whether that aborts them or gets recorded.
108
+ """
109
+ url = self._url(path)
110
+ method = method.upper()
111
+
112
+ tries = MAX_ATTEMPTS if method in IDEMPOTENT_METHODS else 1
113
+ reauth_allowed = True
114
+ used = 0
115
+ backoff: float = 0
116
+
117
+ while True:
118
+ if backoff:
119
+ time.sleep(backoff)
120
+ backoff = 0
121
+
122
+ headers = {
123
+ "Accept": ACCEPT,
124
+ "Authorization": f"Bearer {self._auth.bearer()}",
125
+ }
126
+ if payload is not None:
127
+ headers["Content-Type"] = "application/json"
128
+
129
+ try:
130
+ response = self._session.request(
131
+ method,
132
+ url,
133
+ headers=headers,
134
+ params=params or None,
135
+ data=json.dumps(payload) if payload is not None else None,
136
+ timeout=timeout or self._config.request_timeout,
137
+ )
138
+ except requests.exceptions.RequestException as exc:
139
+ # No status: the request may or may not have been applied. Only
140
+ # idempotent methods get another go, so repeating is safe.
141
+ last_error = OpenEpcisError(
142
+ f"Could not reach the platform at {url}: {exc}", path=path
143
+ )
144
+ logger.warning("OpenEPCIS %s %s failed: %s", method, path, exc)
145
+ used += 1
146
+ if used >= tries:
147
+ raise last_error from exc
148
+ backoff = BACKOFF_SECONDS[min(used - 1, len(BACKOFF_SECONDS) - 1)]
149
+ continue
150
+
151
+ # A 401 means the access token lapsed early or was revoked in
152
+ # flight. Minting a new one and going again is not a retry of the
153
+ # operation: the call was refused, so nothing was applied. It
154
+ # therefore costs no attempt and needs no backoff, which is what
155
+ # makes it safe even for POST.
156
+ if response.status_code == 401 and reauth_allowed:
157
+ reauth_allowed = False
158
+ self._auth.invalidate()
159
+ continue
160
+
161
+ if response.status_code < 300:
162
+ return response if raw else self._decode(response)
163
+
164
+ error = self._error_from(response, path)
165
+ used += 1
166
+ if error.is_retryable and used < tries:
167
+ logger.info(
168
+ "OpenEPCIS %s %s answered %s, retrying", method, path, response.status_code
169
+ )
170
+ backoff = BACKOFF_SECONDS[min(used - 1, len(BACKOFF_SECONDS) - 1)]
171
+ continue
172
+ raise error
173
+
174
+ def post_file(
175
+ self,
176
+ path: str,
177
+ filename: str,
178
+ content: bytes,
179
+ form: dict[str, str] | None = None,
180
+ timeout: tuple[float, float] | None = None,
181
+ ) -> Any:
182
+ """Upload a file as multipart form data.
183
+
184
+ Separate from :meth:`request` because the body is not JSON and must not
185
+ carry a ``Content-Type`` of its own; ``requests`` sets the multipart
186
+ boundary, and overriding it produces an unparseable request.
187
+ """
188
+ url = self._url(path)
189
+ headers = {
190
+ "Accept": ACCEPT,
191
+ "Authorization": f"Bearer {self._auth.bearer()}",
192
+ }
193
+ try:
194
+ response = self._session.post(
195
+ url,
196
+ headers=headers,
197
+ files={"file": (filename, content, "application/octet-stream")},
198
+ data=form or {},
199
+ timeout=timeout or (5, 300),
200
+ )
201
+ except requests.exceptions.RequestException as exc:
202
+ raise OpenEpcisError(
203
+ f"Could not reach the platform at {url}: {exc}", path=path
204
+ ) from exc
205
+ if response.status_code >= 300:
206
+ raise self._error_from(response, path)
207
+ return self._decode(response)
208
+
209
+ # -- Answers ---------------------------------------------------------------
210
+
211
+ def _url(self, path: str) -> str:
212
+ return self._config.base_url + (path if path.startswith("/") else "/" + path)
213
+
214
+ @staticmethod
215
+ def _decode(response: requests.Response) -> Any:
216
+ if response.status_code == 204 or not response.content:
217
+ return None
218
+ try:
219
+ return response.json()
220
+ except ValueError:
221
+ # A login page instead of JSON is the classic symptom of an OIDC
222
+ # bounce in front of the API; say so instead of "invalid JSON".
223
+ if "html" in (response.headers.get("Content-Type") or ""):
224
+ raise OpenEpcisError(
225
+ "The platform answered with a web page instead of data. The "
226
+ "URL probably points at something other than the API.",
227
+ status=response.status_code,
228
+ ) from None
229
+ raise OpenEpcisError(
230
+ "The platform sent a body that is not JSON.", status=response.status_code
231
+ ) from None
232
+
233
+ @staticmethod
234
+ def _error_from(response: requests.Response, path: str) -> OpenEpcisError:
235
+ """Turn a failed answer into a :class:`OpenEpcisError` a human can read.
236
+
237
+ The platform reports errors as RFC 7807 problem documents, so
238
+ ``detail`` is already a sentence written for the caller. Prefer it over
239
+ anything this side could invent.
240
+ """
241
+ problem: dict[str, Any] = {}
242
+ try:
243
+ body = response.json()
244
+ if isinstance(body, dict):
245
+ problem = body
246
+ except ValueError:
247
+ pass
248
+
249
+ message = str(problem.get("detail") or problem.get("title") or problem.get("message") or "")
250
+ if not message:
251
+ message = (response.text or "").strip()[:300]
252
+ if not message:
253
+ message = "The platform refused the call without saying why."
254
+
255
+ return OpenEpcisError(message, status=response.status_code, problem=problem, path=path)
@@ -0,0 +1,30 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 benelog GmbH & Co. KG
3
+ """Connection settings, held apart from credentials.
4
+
5
+ The config names *where* the platform is; the credentials live in an
6
+ :class:`~openepcis_client.core.auth.AuthStrategy` and its
7
+ :class:`~openepcis_client.core.auth.TokenStore`, because a host framework owns
8
+ their persistence and this library must not.
9
+ """
10
+
11
+ from dataclasses import dataclass
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ClientConfig:
16
+ """Where the platform is and how patient to be with it.
17
+
18
+ :param base_url: origin of the GS1 Digital Link resolver, e.g.
19
+ ``https://id.epcis.cloud``. Also the prefix of every Digital Link.
20
+ :param request_timeout: ``(connect, read)`` seconds for API calls.
21
+ :param token_timeout: ``(connect, read)`` seconds for the identity
22
+ provider, which answers fast or not at all.
23
+ """
24
+
25
+ base_url: str
26
+ request_timeout: tuple[float, float] = (5, 30)
27
+ token_timeout: tuple[float, float] = (5, 15)
28
+
29
+ def __post_init__(self) -> None:
30
+ object.__setattr__(self, "base_url", self.base_url.rstrip("/"))