rtls-sdk 0.2.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.
Files changed (78) hide show
  1. rtls_sdk/__init__.py +123 -0
  2. rtls_sdk/_auth.py +266 -0
  3. rtls_sdk/_client.py +419 -0
  4. rtls_sdk/_envelope.py +74 -0
  5. rtls_sdk/_http.py +145 -0
  6. rtls_sdk/_logging.py +143 -0
  7. rtls_sdk/_pagination.py +235 -0
  8. rtls_sdk/_query.py +114 -0
  9. rtls_sdk/_time.py +84 -0
  10. rtls_sdk/compounds/__init__.py +19 -0
  11. rtls_sdk/compounds/auth.py +100 -0
  12. rtls_sdk/compounds/context.py +159 -0
  13. rtls_sdk/compounds/groups.py +126 -0
  14. rtls_sdk/compounds/nodes.py +176 -0
  15. rtls_sdk/compounds/reports.py +339 -0
  16. rtls_sdk/compounds/system.py +43 -0
  17. rtls_sdk/compounds/tags.py +404 -0
  18. rtls_sdk/compounds/users.py +143 -0
  19. rtls_sdk/compounds/zones.py +203 -0
  20. rtls_sdk/errors.py +238 -0
  21. rtls_sdk/models/__init__.py +73 -0
  22. rtls_sdk/models/_base.py +46 -0
  23. rtls_sdk/models/alarm.py +23 -0
  24. rtls_sdk/models/anchor.py +25 -0
  25. rtls_sdk/models/area.py +28 -0
  26. rtls_sdk/models/association.py +48 -0
  27. rtls_sdk/models/bulk.py +80 -0
  28. rtls_sdk/models/company.py +32 -0
  29. rtls_sdk/models/csv_blob.py +40 -0
  30. rtls_sdk/models/floorplan.py +42 -0
  31. rtls_sdk/models/group.py +20 -0
  32. rtls_sdk/models/heatmap.py +37 -0
  33. rtls_sdk/models/import_result.py +42 -0
  34. rtls_sdk/models/node.py +28 -0
  35. rtls_sdk/models/notification.py +38 -0
  36. rtls_sdk/models/position.py +66 -0
  37. rtls_sdk/models/project.py +26 -0
  38. rtls_sdk/models/pws.py +38 -0
  39. rtls_sdk/models/report.py +34 -0
  40. rtls_sdk/models/session_context.py +81 -0
  41. rtls_sdk/models/site.py +31 -0
  42. rtls_sdk/models/subscriber.py +68 -0
  43. rtls_sdk/models/system.py +97 -0
  44. rtls_sdk/models/system_health.py +36 -0
  45. rtls_sdk/models/tag.py +48 -0
  46. rtls_sdk/models/tag_template.py +24 -0
  47. rtls_sdk/models/user.py +121 -0
  48. rtls_sdk/models/zone.py +27 -0
  49. rtls_sdk/models/zone_event.py +21 -0
  50. rtls_sdk/py.typed +0 -0
  51. rtls_sdk/resources/__init__.py +49 -0
  52. rtls_sdk/resources/_base.py +63 -0
  53. rtls_sdk/resources/alarms.py +108 -0
  54. rtls_sdk/resources/anchors.py +147 -0
  55. rtls_sdk/resources/areas.py +78 -0
  56. rtls_sdk/resources/associations.py +157 -0
  57. rtls_sdk/resources/auth.py +63 -0
  58. rtls_sdk/resources/companies.py +79 -0
  59. rtls_sdk/resources/context.py +50 -0
  60. rtls_sdk/resources/events.py +149 -0
  61. rtls_sdk/resources/floorplans.py +283 -0
  62. rtls_sdk/resources/groups.py +99 -0
  63. rtls_sdk/resources/logger.py +40 -0
  64. rtls_sdk/resources/messaging.py +51 -0
  65. rtls_sdk/resources/nodes.py +157 -0
  66. rtls_sdk/resources/notifications.py +55 -0
  67. rtls_sdk/resources/projects.py +67 -0
  68. rtls_sdk/resources/reports.py +180 -0
  69. rtls_sdk/resources/sites.py +115 -0
  70. rtls_sdk/resources/subscribers.py +110 -0
  71. rtls_sdk/resources/system.py +125 -0
  72. rtls_sdk/resources/tags.py +370 -0
  73. rtls_sdk/resources/users.py +275 -0
  74. rtls_sdk/resources/zones.py +199 -0
  75. rtls_sdk-0.2.0.dist-info/METADATA +141 -0
  76. rtls_sdk-0.2.0.dist-info/RECORD +78 -0
  77. rtls_sdk-0.2.0.dist-info/WHEEL +4 -0
  78. rtls_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
rtls_sdk/_client.py ADDED
@@ -0,0 +1,419 @@
1
+ """The main :class:`RtlsClient` class — the one thing users construct.
2
+
3
+ The constructor is pure: no network I/O happens here. The first call on
4
+ any sub-client triggers a single ``POST /auth/log_in.json`` to acquire the
5
+ token (lazy login).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from collections.abc import Iterator
12
+ from contextlib import contextmanager
13
+ from types import TracebackType
14
+ from typing import Any, overload
15
+
16
+ import httpx
17
+
18
+ from ._auth import _AuthState
19
+ from ._http import _RtlsAuth, check_response, wrap_transport_error
20
+ from ._logging import log_request, log_response
21
+ from .resources import (
22
+ AlarmsAPI,
23
+ AnchorsAPI,
24
+ AreasAPI,
25
+ AssociationsAPI,
26
+ AuthAPI,
27
+ CompaniesAPI,
28
+ ContextAPI,
29
+ EventsAPI,
30
+ FloorplansAPI,
31
+ GroupsAPI,
32
+ LoggerAPI,
33
+ MessagingAPI,
34
+ NodesAPI,
35
+ NotificationsAPI,
36
+ ProjectsAPI,
37
+ ReportsAPI,
38
+ SitesAPI,
39
+ SubscribersAPI,
40
+ SystemAPI,
41
+ TagsAPI,
42
+ UsersAPI,
43
+ ZonesAPI,
44
+ )
45
+
46
+
47
+ class RtlsClient:
48
+ """Entry point to the RTLS SDK.
49
+
50
+ Construct one client per identity. Switch project scope cheaply via
51
+ :meth:`with_scope` (added in M8) or by passing ``project_uid=`` to the
52
+ constructor.
53
+
54
+ Three construction modes are supported:
55
+
56
+ 1. **From environment** (recommended for production): call
57
+ :meth:`from_env`. Reads ``RTLS_USERNAME`` / ``RTLS_PASSWORD`` /
58
+ ``RTLS_BASE_URL`` (and optional scope vars) and constructs a client.
59
+ 2. **Explicit credentials** (for testing): pass ``username=``,
60
+ ``password=`` and ``base_url=``.
61
+ 3. **Bring-your-own token** (advanced): pass ``token=`` and
62
+ ``base_url=``. The SDK cannot re-authenticate on 401 in this mode —
63
+ it raises :class:`AuthenticationError` instead.
64
+
65
+ Examples
66
+ --------
67
+ >>> client = RtlsClient.from_env()
68
+ >>> tags = client.tags.list() # lazy login fires here
69
+ >>> client.close() # or use as context mgr
70
+ """
71
+
72
+ @overload
73
+ def __init__(
74
+ self,
75
+ *,
76
+ username: str,
77
+ password: str,
78
+ base_url: str,
79
+ project_uid: str | None = None,
80
+ company_uid: str | None = None,
81
+ timeout: float = 30.0,
82
+ http_client: httpx.Client | None = None,
83
+ ) -> None: ...
84
+
85
+ @overload
86
+ def __init__(
87
+ self,
88
+ *,
89
+ token: str,
90
+ base_url: str,
91
+ project_uid: str | None = None,
92
+ company_uid: str | None = None,
93
+ timeout: float = 30.0,
94
+ http_client: httpx.Client | None = None,
95
+ ) -> None: ...
96
+
97
+ def __init__(
98
+ self,
99
+ *,
100
+ username: str | None = None,
101
+ password: str | None = None,
102
+ token: str | None = None,
103
+ base_url: str,
104
+ project_uid: str | None = None,
105
+ company_uid: str | None = None,
106
+ timeout: float = 30.0,
107
+ http_client: httpx.Client | None = None,
108
+ ) -> None:
109
+ self._auth_state = _AuthState(
110
+ username=username,
111
+ password=password,
112
+ token=token,
113
+ project_uid=project_uid,
114
+ company_uid=company_uid,
115
+ )
116
+
117
+ if http_client is not None:
118
+ # Caller-supplied client. We rewire its auth to our _RtlsAuth so
119
+ # 401-replay and lazy-login behave as documented, and add our
120
+ # request/response hooks for redacted logging. The caller is
121
+ # responsible for the connection pool and TLS/proxy/timeout
122
+ # configuration; we do not change ``base_url`` or transport.
123
+ http_client.auth = _RtlsAuth(self._auth_state)
124
+ existing_hooks = dict(http_client.event_hooks)
125
+ existing_hooks.setdefault("request", []).append(log_request)
126
+ existing_hooks.setdefault("response", []).append(log_response)
127
+ http_client.event_hooks = existing_hooks
128
+ self._http = http_client
129
+ self._owns_http = False
130
+ else:
131
+ self._http = httpx.Client(
132
+ base_url=base_url,
133
+ auth=_RtlsAuth(self._auth_state),
134
+ timeout=httpx.Timeout(connect=10.0, read=timeout, write=10.0, pool=5.0),
135
+ transport=httpx.HTTPTransport(retries=3),
136
+ limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
137
+ headers={"User-Agent": f"rtls-sdk/{_version()}"},
138
+ event_hooks={
139
+ "request": [log_request],
140
+ "response": [log_response],
141
+ },
142
+ )
143
+ self._owns_http = True
144
+ self._auth_state.bind_http(self._http)
145
+
146
+ # Scope view: when set, every request applies these scope values as
147
+ # a per-thread override (None means "no header"), so the copy made
148
+ # by ``with_scope`` talks to a different project/company without
149
+ # mutating the original client's state. ``None`` here means "no
150
+ # view; use the auth state's default scope."
151
+ self._scope_view: tuple[str | None, str | None] | None = None
152
+
153
+ # Sub-clients. Instantiated once per RtlsClient.
154
+ self._init_resources()
155
+
156
+ def _init_resources(self) -> None:
157
+ """Wire the resource sub-clients to this RtlsClient instance.
158
+
159
+ Extracted so :meth:`with_scope` can re-bind them on the copy.
160
+ """
161
+ self.tags = TagsAPI(self)
162
+ self.sites = SitesAPI(self)
163
+ self.areas = AreasAPI(self)
164
+ self.floorplans = FloorplansAPI(self)
165
+ self.zones = ZonesAPI(self)
166
+ self.groups = GroupsAPI(self)
167
+ self.users = UsersAPI(self)
168
+ self.nodes = NodesAPI(self)
169
+ self.anchors = AnchorsAPI(self)
170
+ self.associations = AssociationsAPI(self)
171
+ self.alarms = AlarmsAPI(self)
172
+ self.events = EventsAPI(self)
173
+ self.reports = ReportsAPI(self)
174
+ self.notifications = NotificationsAPI(self)
175
+ self.subscribers = SubscribersAPI(self)
176
+ self.projects = ProjectsAPI(self)
177
+ self.companies = CompaniesAPI(self)
178
+ self.system = SystemAPI(self)
179
+ self.auth = AuthAPI(self)
180
+ self.messaging = MessagingAPI(self)
181
+ self.logger = LoggerAPI(self)
182
+ self.context = ContextAPI(self)
183
+
184
+ # -- public scope helpers --------------------------------------------
185
+
186
+ def with_scope(
187
+ self,
188
+ *,
189
+ project_uid: str | None = None,
190
+ company_uid: str | None = None,
191
+ ) -> RtlsClient:
192
+ """Return a shallow copy of this client bound to a different scope.
193
+
194
+ The copy shares the same authenticated session — ``_auth_state``,
195
+ token store, connection pool — so switching scope does **not**
196
+ trigger a re-login. The copy resolves its scope snapshot at copy
197
+ time: arguments left as ``None`` inherit the current effective
198
+ scope and freeze it on the copy. Subsequent ``use_project`` /
199
+ ``use_company`` calls on the original client do not affect the
200
+ copy's scope.
201
+
202
+ Use this for parallel / concurrent work or any case where you
203
+ need both scopes alive at once. For a long-lived in-place
204
+ rebind, use :meth:`use_project` / :meth:`use_company` instead.
205
+
206
+ Examples
207
+ --------
208
+ >>> default_client = RtlsClient.from_env()
209
+ >>> a = default_client.with_scope(project_uid="proj-a")
210
+ >>> b = default_client.with_scope(project_uid="proj-b")
211
+ >>> a.tags.list() # uses proj-a
212
+ >>> b.tags.list() # uses proj-b
213
+ >>> default_client.tags.list() # uses the env default — unchanged
214
+ """
215
+ current_project, current_company = self._effective_scope()
216
+ new_project = project_uid if project_uid is not None else current_project
217
+ new_company = company_uid if company_uid is not None else current_company
218
+
219
+ copy = RtlsClient.__new__(RtlsClient)
220
+ copy._auth_state = self._auth_state
221
+ copy._http = self._http
222
+ copy._owns_http = False
223
+ copy._scope_view = (new_project, new_company)
224
+ copy._init_resources()
225
+ return copy
226
+
227
+ def use_project(self, project_uid: str | None) -> None:
228
+ """Rebind this client's default project scope in-place.
229
+
230
+ Mutates the client. Subsequent requests use ``project_uid`` as
231
+ the ``X-User-Project`` header (or omit the header when ``None``).
232
+ Unlike :meth:`with_scope`, this does NOT create a copy — every
233
+ sub-client and every future call sees the new scope.
234
+
235
+ Not thread-safe: concurrent calls on the same client while one
236
+ thread is mutating scope can interleave headers. For concurrent
237
+ / parallel work use :meth:`with_scope` instead.
238
+ """
239
+ if self._scope_view is not None:
240
+ self._scope_view = (project_uid, self._scope_view[1])
241
+ else:
242
+ self._auth_state.project_uid = project_uid
243
+
244
+ def use_company(self, company_uid: str | None) -> None:
245
+ """Rebind this client's default company scope in-place.
246
+
247
+ Same semantics as :meth:`use_project` — mutates in place; not
248
+ thread-safe; for concurrent work use :meth:`with_scope`.
249
+ """
250
+ if self._scope_view is not None:
251
+ self._scope_view = (self._scope_view[0], company_uid)
252
+ else:
253
+ self._auth_state.company_uid = company_uid
254
+
255
+ def _effective_scope(self) -> tuple[str | None, str | None]:
256
+ """The (project_uid, company_uid) this client currently sends."""
257
+ if self._scope_view is not None:
258
+ return self._scope_view
259
+ return (self._auth_state.project_uid, self._auth_state.company_uid)
260
+
261
+ # -- factories --------------------------------------------------------
262
+
263
+ @classmethod
264
+ def from_env(cls, *, prefix: str = "RTLS_") -> RtlsClient:
265
+ """Construct a client from environment variables.
266
+
267
+ Reads ``{prefix}USERNAME``, ``{prefix}PASSWORD``, ``{prefix}BASE_URL``
268
+ (required) plus ``{prefix}PROJECT_UID`` and ``{prefix}COMPANY_UID``
269
+ (optional). All values are looked up at call time — no caching.
270
+
271
+ Parameters
272
+ ----------
273
+ prefix
274
+ Environment-variable prefix. Defaults to ``"RTLS_"``.
275
+
276
+ Raises
277
+ ------
278
+ KeyError
279
+ If a required variable is missing.
280
+ """
281
+ return cls(
282
+ username=os.environ[f"{prefix}USERNAME"],
283
+ password=os.environ[f"{prefix}PASSWORD"],
284
+ base_url=os.environ[f"{prefix}BASE_URL"],
285
+ project_uid=os.environ.get(f"{prefix}PROJECT_UID"),
286
+ company_uid=os.environ.get(f"{prefix}COMPANY_UID"),
287
+ )
288
+
289
+ # -- context manager -------------------------------------------------
290
+
291
+ def __enter__(self) -> RtlsClient:
292
+ return self
293
+
294
+ def __exit__(
295
+ self,
296
+ exc_type: type[BaseException] | None,
297
+ exc: BaseException | None,
298
+ tb: TracebackType | None,
299
+ ) -> None:
300
+ self.close()
301
+
302
+ def close(self) -> None:
303
+ """Close the underlying HTTP connection pool and clear the token.
304
+
305
+ For clients returned by :meth:`with_scope`, ``close()`` is a
306
+ no-op — the copy doesn't own the pool. Close the original
307
+ client to free shared resources.
308
+ """
309
+ if self._owns_http:
310
+ self._http.close()
311
+ self._auth_state.clear()
312
+
313
+ # -- per-call scope override ----------------------------------------
314
+
315
+ @contextmanager
316
+ def _call_with_scope(
317
+ self,
318
+ *,
319
+ project_uid: str | None = None,
320
+ company_uid: str | None = None,
321
+ clear_project: bool = False,
322
+ clear_company: bool = False,
323
+ ) -> Iterator[RtlsClient]:
324
+ """Temporarily override scope headers for calls inside the block.
325
+
326
+ Internal — the public surface is :meth:`with_scope`. Used by
327
+ compounds (``nodes.release(project_uid=...)``, ``context.load()``)
328
+ and by the per-call wrap that :meth:`with_scope` copies use.
329
+
330
+ ``clear_project=True`` / ``clear_company=True`` force the
331
+ corresponding header OUT for calls inside the block, taking
332
+ precedence over the matching ``*_uid`` argument and the auth
333
+ state's default scope.
334
+
335
+ The override is per-thread (``threading.local`` on the auth
336
+ state), so concurrent callers don't trample each other.
337
+ Restored in a ``finally`` block — exceptions inside the block
338
+ do not leak the override.
339
+ """
340
+ override = self._auth_state._scope_override
341
+ old_project = getattr(override, "project_uid", None)
342
+ old_company = getattr(override, "company_uid", None)
343
+ old_clear_project = getattr(override, "clear_project", False)
344
+ old_clear_company = getattr(override, "clear_company", False)
345
+ try:
346
+ if clear_project:
347
+ override.clear_project = True
348
+ elif project_uid is not None:
349
+ override.project_uid = project_uid
350
+ override.clear_project = False
351
+ if clear_company:
352
+ override.clear_company = True
353
+ elif company_uid is not None:
354
+ override.company_uid = company_uid
355
+ override.clear_company = False
356
+ yield self
357
+ finally:
358
+ override.project_uid = old_project
359
+ override.company_uid = old_company
360
+ override.clear_project = old_clear_project
361
+ override.clear_company = old_clear_company
362
+
363
+ # -- request execution ----------------------------------------------
364
+
365
+ def _request(
366
+ self,
367
+ method: str,
368
+ path: str,
369
+ *,
370
+ params: Any = None,
371
+ json: Any = None,
372
+ headers: dict[str, str] | None = None,
373
+ ) -> httpx.Response:
374
+ """Send a request and raise typed exceptions on non-2xx.
375
+
376
+ When this client has a :attr:`_scope_view` (i.e. it was returned
377
+ by :meth:`with_scope`), the call is wrapped in a per-thread scope
378
+ override so the snapshot scope takes precedence over the auth
379
+ state's default. The override is restored after every call.
380
+ """
381
+ if self._scope_view is not None:
382
+ proj, comp = self._scope_view
383
+ with self._call_with_scope(
384
+ project_uid=proj,
385
+ company_uid=comp,
386
+ clear_project=proj is None,
387
+ clear_company=comp is None,
388
+ ):
389
+ return self._send(method, path, params=params, json=json, headers=headers)
390
+ return self._send(method, path, params=params, json=json, headers=headers)
391
+
392
+ def _send(
393
+ self,
394
+ method: str,
395
+ path: str,
396
+ *,
397
+ params: Any = None,
398
+ json: Any = None,
399
+ headers: dict[str, str] | None = None,
400
+ ) -> httpx.Response:
401
+ try:
402
+ response = self._http.request(method, path, params=params, json=json, headers=headers)
403
+ except httpx.HTTPError as exc:
404
+ raise wrap_transport_error(exc, url=path, method=method) from exc
405
+ check_response(response)
406
+ return response
407
+
408
+
409
+ def _version() -> str:
410
+ """Return the installed package version, falling back to a dev string."""
411
+ try:
412
+ from importlib.metadata import version
413
+
414
+ return version("rtls-sdk")
415
+ except Exception:
416
+ return "0.0.0+dev"
417
+
418
+
419
+ __all__ = ["RtlsClient"]
rtls_sdk/_envelope.py ADDED
@@ -0,0 +1,74 @@
1
+ """Helpers for unwrapping the server's response envelopes.
2
+
3
+ The RTLS API uses two envelope shapes (RESEARCH §2):
4
+
5
+ - Auth endpoints: ``{"success": true, "data": {...}}``
6
+ - Resource endpoints: ``{"<plural_noun>": [...]}``
7
+
8
+ These helpers parse strictly. We do not replicate the JS reference client's
9
+ ``data.x || data`` fallback — when the shape is wrong, fail loudly rather
10
+ than silently return half the payload.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+
18
+ def unwrap_data(payload: Any) -> dict[str, Any]:
19
+ """Unwrap an auth-style envelope: ``{"success": true, "data": {...}}``.
20
+
21
+ Raises ``ValueError`` if the envelope shape is unexpected.
22
+ """
23
+ if not isinstance(payload, dict):
24
+ raise ValueError(f"expected dict envelope, got {type(payload).__name__}")
25
+ if "data" not in payload:
26
+ raise ValueError(f"missing 'data' key in envelope: {list(payload)}")
27
+ data = payload["data"]
28
+ if not isinstance(data, dict):
29
+ raise ValueError(f"'data' is not a dict: {type(data).__name__}")
30
+ return data
31
+
32
+
33
+ def unwrap_resource(payload: Any, key: str) -> list[dict[str, Any]]:
34
+ """Unwrap a resource list envelope: ``{"<key>": [...]}``.
35
+
36
+ Tolerates both the wrapped form and a bare list (some endpoints — see
37
+ RESEARCH §5 — return one or the other depending on server version).
38
+ Raises ``ValueError`` if neither shape matches.
39
+ """
40
+ if isinstance(payload, list):
41
+ return payload
42
+ if isinstance(payload, dict):
43
+ items = payload.get(key)
44
+ if isinstance(items, list):
45
+ return items
46
+ raise ValueError(f"expected {{'{key}': [...]}} envelope, got keys {list(payload)}")
47
+ raise ValueError(f"expected list or dict envelope, got {type(payload).__name__}")
48
+
49
+
50
+ def unwrap_resource_single(payload: Any, key: str) -> dict[str, Any]:
51
+ """Unwrap a single-resource envelope.
52
+
53
+ The server returns ``{"<key>": [t]}`` even for single-resource GETs
54
+ (RESEARCH §3 Trackable / Discrepancy #10). Some endpoints return the
55
+ object bare. Handle both, raise ``ValueError`` on neither.
56
+ """
57
+ if isinstance(payload, dict):
58
+ if key in payload:
59
+ inner = payload[key]
60
+ if isinstance(inner, list):
61
+ if not inner:
62
+ raise ValueError(f"empty list under '{key}'")
63
+ first = inner[0]
64
+ if not isinstance(first, dict):
65
+ raise ValueError(f"first item under '{key}' is not a dict")
66
+ return first
67
+ if isinstance(inner, dict):
68
+ return inner
69
+ raise ValueError(f"value under '{key}' is neither dict nor list")
70
+ return payload
71
+ raise ValueError(f"expected dict envelope, got {type(payload).__name__}")
72
+
73
+
74
+ __all__ = ["unwrap_data", "unwrap_resource", "unwrap_resource_single"]
rtls_sdk/_http.py ADDED
@@ -0,0 +1,145 @@
1
+ """httpx.Auth subclass and the response-to-exception mapping.
2
+
3
+ The auth subclass implements lazy login + 401-once-replay; it does not
4
+ raise typed exceptions. Error mapping is a separate helper called by the
5
+ resource sub-clients after every request, so the auth flow can intercept
6
+ 401 before the error-raising path runs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Generator
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ import httpx
15
+
16
+ from ._logging import redact_body
17
+ from .errors import (
18
+ ConnectionError as RtlsConnectionError,
19
+ )
20
+ from .errors import (
21
+ RateLimited,
22
+ RtlsError,
23
+ ValidationError,
24
+ exception_for_status,
25
+ parse_error_envelope,
26
+ )
27
+
28
+ if TYPE_CHECKING:
29
+ from ._auth import _AuthState
30
+
31
+
32
+ # Endpoints whose 401s must not trigger re-authentication (otherwise we
33
+ # recurse). Match by substring against the URL path.
34
+ _NO_REAUTH_PATHS: tuple[str, ...] = (
35
+ "/auth/log_in",
36
+ "/auth/refresh_token",
37
+ )
38
+
39
+
40
+ class _RtlsAuth(httpx.Auth):
41
+ """Attach SDK auth headers; on 401, re-authenticate once and replay.
42
+
43
+ This class is intentionally limited to auth concerns. It does not
44
+ raise typed exceptions — that's the response-checking helper's job,
45
+ called by the resource layer after each request.
46
+ """
47
+
48
+ requires_response_body = False
49
+
50
+ def __init__(self, state: _AuthState) -> None:
51
+ self._state = state
52
+
53
+ def sync_auth_flow(
54
+ self, request: httpx.Request
55
+ ) -> Generator[httpx.Request, httpx.Response, None]:
56
+ # Lazy login — runs at most once per client lifetime, serialized
57
+ # by the state's lock.
58
+ self._state.login_if_needed()
59
+ self._apply_headers(request)
60
+ # Remember which token we sent so a concurrent thread doing the
61
+ # refresh doesn't cause us to do a second redundant refresh.
62
+ token_used = self._state.token
63
+
64
+ response = yield request
65
+
66
+ if response.status_code == 401 and self._should_retry(request):
67
+ self._state.reauthenticate(expected_token=token_used)
68
+ self._apply_headers(request)
69
+ yield request
70
+ # The second response is returned to the caller; if it's also
71
+ # 401, check_response below will raise AuthenticationError.
72
+
73
+ def _apply_headers(self, request: httpx.Request) -> None:
74
+ for key, value in self._state.headers().items():
75
+ request.headers[key] = value
76
+
77
+ def _should_retry(self, request: httpx.Request) -> bool:
78
+ if not self._state.can_reauthenticate():
79
+ return False
80
+ path = request.url.path
81
+ return not any(skip in path for skip in _NO_REAUTH_PATHS)
82
+
83
+
84
+ def check_response(response: httpx.Response) -> None:
85
+ """Raise the appropriate typed exception if ``response`` is non-2xx.
86
+
87
+ Called by the resource layer after every request. Reads and parses
88
+ the response body, applies secret redaction before stashing on the
89
+ raised exception, and maps the status code to a class from
90
+ ``rtls_sdk.errors``.
91
+ """
92
+ if 200 <= response.status_code < 300:
93
+ return
94
+
95
+ body: Any = None
96
+ if response.headers.get("content-type", "").startswith("application/json"):
97
+ try:
98
+ body = response.json()
99
+ except ValueError:
100
+ body = None
101
+ body = redact_body(body)
102
+
103
+ envelope = parse_error_envelope(body)
104
+ exc_cls = exception_for_status(response.status_code)
105
+ message = envelope or f"HTTP {response.status_code}"
106
+
107
+ common: dict[str, Any] = {
108
+ "status_code": response.status_code,
109
+ "request_url": str(response.request.url),
110
+ "request_method": response.request.method,
111
+ "response_body": body,
112
+ "error_envelope": envelope,
113
+ }
114
+
115
+ if exc_cls is ValidationError:
116
+ field_errors: dict[str, Any] | None = None
117
+ if isinstance(body, dict):
118
+ raw_errors = body.get("errors") or body.get("field_errors")
119
+ if isinstance(raw_errors, dict):
120
+ field_errors = raw_errors
121
+ raise ValidationError(message, field_errors=field_errors, **common)
122
+
123
+ if exc_cls is RateLimited:
124
+ retry_after = response.headers.get("Retry-After")
125
+ retry_after_seconds: float | None = None
126
+ if retry_after is not None:
127
+ try:
128
+ retry_after_seconds = float(retry_after)
129
+ except ValueError:
130
+ retry_after_seconds = None
131
+ raise RateLimited(message, retry_after_seconds=retry_after_seconds, **common)
132
+
133
+ raise exc_cls(message, **common)
134
+
135
+
136
+ def wrap_transport_error(exc: httpx.HTTPError, *, url: str, method: str) -> RtlsError:
137
+ """Convert an httpx transport-level exception to our ConnectionError."""
138
+ return RtlsConnectionError(
139
+ f"transport error: {exc.__class__.__name__}: {exc}",
140
+ request_url=url,
141
+ request_method=method,
142
+ )
143
+
144
+
145
+ __all__ = ["_RtlsAuth", "check_response", "wrap_transport_error"]