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.
- rtls_sdk/__init__.py +123 -0
- rtls_sdk/_auth.py +266 -0
- rtls_sdk/_client.py +419 -0
- rtls_sdk/_envelope.py +74 -0
- rtls_sdk/_http.py +145 -0
- rtls_sdk/_logging.py +143 -0
- rtls_sdk/_pagination.py +235 -0
- rtls_sdk/_query.py +114 -0
- rtls_sdk/_time.py +84 -0
- rtls_sdk/compounds/__init__.py +19 -0
- rtls_sdk/compounds/auth.py +100 -0
- rtls_sdk/compounds/context.py +159 -0
- rtls_sdk/compounds/groups.py +126 -0
- rtls_sdk/compounds/nodes.py +176 -0
- rtls_sdk/compounds/reports.py +339 -0
- rtls_sdk/compounds/system.py +43 -0
- rtls_sdk/compounds/tags.py +404 -0
- rtls_sdk/compounds/users.py +143 -0
- rtls_sdk/compounds/zones.py +203 -0
- rtls_sdk/errors.py +238 -0
- rtls_sdk/models/__init__.py +73 -0
- rtls_sdk/models/_base.py +46 -0
- rtls_sdk/models/alarm.py +23 -0
- rtls_sdk/models/anchor.py +25 -0
- rtls_sdk/models/area.py +28 -0
- rtls_sdk/models/association.py +48 -0
- rtls_sdk/models/bulk.py +80 -0
- rtls_sdk/models/company.py +32 -0
- rtls_sdk/models/csv_blob.py +40 -0
- rtls_sdk/models/floorplan.py +42 -0
- rtls_sdk/models/group.py +20 -0
- rtls_sdk/models/heatmap.py +37 -0
- rtls_sdk/models/import_result.py +42 -0
- rtls_sdk/models/node.py +28 -0
- rtls_sdk/models/notification.py +38 -0
- rtls_sdk/models/position.py +66 -0
- rtls_sdk/models/project.py +26 -0
- rtls_sdk/models/pws.py +38 -0
- rtls_sdk/models/report.py +34 -0
- rtls_sdk/models/session_context.py +81 -0
- rtls_sdk/models/site.py +31 -0
- rtls_sdk/models/subscriber.py +68 -0
- rtls_sdk/models/system.py +97 -0
- rtls_sdk/models/system_health.py +36 -0
- rtls_sdk/models/tag.py +48 -0
- rtls_sdk/models/tag_template.py +24 -0
- rtls_sdk/models/user.py +121 -0
- rtls_sdk/models/zone.py +27 -0
- rtls_sdk/models/zone_event.py +21 -0
- rtls_sdk/py.typed +0 -0
- rtls_sdk/resources/__init__.py +49 -0
- rtls_sdk/resources/_base.py +63 -0
- rtls_sdk/resources/alarms.py +108 -0
- rtls_sdk/resources/anchors.py +147 -0
- rtls_sdk/resources/areas.py +78 -0
- rtls_sdk/resources/associations.py +157 -0
- rtls_sdk/resources/auth.py +63 -0
- rtls_sdk/resources/companies.py +79 -0
- rtls_sdk/resources/context.py +50 -0
- rtls_sdk/resources/events.py +149 -0
- rtls_sdk/resources/floorplans.py +283 -0
- rtls_sdk/resources/groups.py +99 -0
- rtls_sdk/resources/logger.py +40 -0
- rtls_sdk/resources/messaging.py +51 -0
- rtls_sdk/resources/nodes.py +157 -0
- rtls_sdk/resources/notifications.py +55 -0
- rtls_sdk/resources/projects.py +67 -0
- rtls_sdk/resources/reports.py +180 -0
- rtls_sdk/resources/sites.py +115 -0
- rtls_sdk/resources/subscribers.py +110 -0
- rtls_sdk/resources/system.py +125 -0
- rtls_sdk/resources/tags.py +370 -0
- rtls_sdk/resources/users.py +275 -0
- rtls_sdk/resources/zones.py +199 -0
- rtls_sdk-0.2.0.dist-info/METADATA +141 -0
- rtls_sdk-0.2.0.dist-info/RECORD +78 -0
- rtls_sdk-0.2.0.dist-info/WHEEL +4 -0
- 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"]
|