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/__init__.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Python SDK for the RTLS REST API.
|
|
2
|
+
|
|
3
|
+
Three-step usage:
|
|
4
|
+
|
|
5
|
+
1. Put credentials in env vars (``RTLS_USERNAME``, ``RTLS_PASSWORD``,
|
|
6
|
+
``RTLS_BASE_URL``).
|
|
7
|
+
2. Construct the client: ``client = RtlsClient.from_env()``.
|
|
8
|
+
3. Call a resource method: ``tags = client.tags.list()``.
|
|
9
|
+
|
|
10
|
+
Tokens, refresh, scoping headers, 401 re-login — all internal. See the
|
|
11
|
+
README for the quickstart and ``docs/advanced.md`` for advanced paths.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from ._client import RtlsClient
|
|
17
|
+
from .errors import (
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
Conflict,
|
|
20
|
+
ConnectionError,
|
|
21
|
+
NotFound,
|
|
22
|
+
PartialFailureError,
|
|
23
|
+
PermissionDenied,
|
|
24
|
+
RateLimited,
|
|
25
|
+
ReportError,
|
|
26
|
+
ReportFailed,
|
|
27
|
+
ReportTimeout,
|
|
28
|
+
RtlsError,
|
|
29
|
+
ServerError,
|
|
30
|
+
ValidationError,
|
|
31
|
+
)
|
|
32
|
+
from .models import (
|
|
33
|
+
Alarm,
|
|
34
|
+
Anchor,
|
|
35
|
+
Area,
|
|
36
|
+
Association,
|
|
37
|
+
BulkFailure,
|
|
38
|
+
BulkResult,
|
|
39
|
+
Capabilities,
|
|
40
|
+
Company,
|
|
41
|
+
Connection,
|
|
42
|
+
Floorplan,
|
|
43
|
+
Group,
|
|
44
|
+
Host,
|
|
45
|
+
ImportResult,
|
|
46
|
+
Node,
|
|
47
|
+
NotificationProfile,
|
|
48
|
+
NotificationType,
|
|
49
|
+
Position,
|
|
50
|
+
Project,
|
|
51
|
+
Report,
|
|
52
|
+
ReportType,
|
|
53
|
+
Role,
|
|
54
|
+
Site,
|
|
55
|
+
Subscriber,
|
|
56
|
+
SubscriberAddresses,
|
|
57
|
+
SystemHealth,
|
|
58
|
+
SystemSubscriber,
|
|
59
|
+
Tag,
|
|
60
|
+
TagTemplate,
|
|
61
|
+
Uptime,
|
|
62
|
+
User,
|
|
63
|
+
UserFlags,
|
|
64
|
+
Version,
|
|
65
|
+
WsHost,
|
|
66
|
+
Zone,
|
|
67
|
+
ZoneEvent,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
__all__ = [ # noqa: RUF022 — grouped logically, not alphabetically
|
|
71
|
+
# Client
|
|
72
|
+
"RtlsClient",
|
|
73
|
+
# Errors
|
|
74
|
+
"AuthenticationError",
|
|
75
|
+
"Conflict",
|
|
76
|
+
"ConnectionError",
|
|
77
|
+
"NotFound",
|
|
78
|
+
"PartialFailureError",
|
|
79
|
+
"PermissionDenied",
|
|
80
|
+
"RateLimited",
|
|
81
|
+
"ReportError",
|
|
82
|
+
"ReportFailed",
|
|
83
|
+
"ReportTimeout",
|
|
84
|
+
"RtlsError",
|
|
85
|
+
"ServerError",
|
|
86
|
+
"ValidationError",
|
|
87
|
+
# Models
|
|
88
|
+
"Alarm",
|
|
89
|
+
"Anchor",
|
|
90
|
+
"Area",
|
|
91
|
+
"Association",
|
|
92
|
+
"BulkFailure",
|
|
93
|
+
"BulkResult",
|
|
94
|
+
"Capabilities",
|
|
95
|
+
"Company",
|
|
96
|
+
"Connection",
|
|
97
|
+
"Floorplan",
|
|
98
|
+
"Group",
|
|
99
|
+
"Host",
|
|
100
|
+
"ImportResult",
|
|
101
|
+
"Node",
|
|
102
|
+
"NotificationProfile",
|
|
103
|
+
"NotificationType",
|
|
104
|
+
"Position",
|
|
105
|
+
"Project",
|
|
106
|
+
"Report",
|
|
107
|
+
"ReportType",
|
|
108
|
+
"Role",
|
|
109
|
+
"Site",
|
|
110
|
+
"Subscriber",
|
|
111
|
+
"SubscriberAddresses",
|
|
112
|
+
"SystemHealth",
|
|
113
|
+
"SystemSubscriber",
|
|
114
|
+
"Tag",
|
|
115
|
+
"TagTemplate",
|
|
116
|
+
"Uptime",
|
|
117
|
+
"User",
|
|
118
|
+
"UserFlags",
|
|
119
|
+
"Version",
|
|
120
|
+
"WsHost",
|
|
121
|
+
"Zone",
|
|
122
|
+
"ZoneEvent",
|
|
123
|
+
]
|
rtls_sdk/_auth.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Authentication state for an ``RtlsClient`` instance.
|
|
2
|
+
|
|
3
|
+
Holds credentials, current token, scoping, and the lock + event used to
|
|
4
|
+
serialize concurrent re-authentications.
|
|
5
|
+
|
|
6
|
+
A single ``_AuthState`` instance is shared across all sub-clients of one
|
|
7
|
+
``RtlsClient``. Tokens never leave this object via any public method.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from ._envelope import unwrap_data
|
|
16
|
+
from .errors import AuthenticationError
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_LOGIN_PATH = "/api/v2/auth/log_in.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _AuthState:
|
|
26
|
+
"""In-memory store for the active session.
|
|
27
|
+
|
|
28
|
+
One of (``username`` + ``password``) or ``token`` must be present. With
|
|
29
|
+
only ``token``, the SDK cannot re-authenticate on expiry — it raises
|
|
30
|
+
``AuthenticationError`` immediately on 401.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
username: str | None,
|
|
37
|
+
password: str | None,
|
|
38
|
+
token: str | None,
|
|
39
|
+
project_uid: str | None,
|
|
40
|
+
company_uid: str | None,
|
|
41
|
+
) -> None:
|
|
42
|
+
if (username is None) != (password is None):
|
|
43
|
+
raise TypeError("username and password must be passed together")
|
|
44
|
+
if token is None and username is None:
|
|
45
|
+
raise TypeError("provide either token= or username= + password=")
|
|
46
|
+
if token is not None and username is not None:
|
|
47
|
+
raise TypeError("pass either token= or username= + password=, not both")
|
|
48
|
+
|
|
49
|
+
self._username = username
|
|
50
|
+
self._password = password
|
|
51
|
+
self._token: str | None = token
|
|
52
|
+
self._email: str | None = username # provisional; updated by login
|
|
53
|
+
self.project_uid = project_uid
|
|
54
|
+
self.company_uid = company_uid
|
|
55
|
+
|
|
56
|
+
# Cached uid of the currently-authenticated user. Populated when
|
|
57
|
+
# ``users.me()`` resolves the email against /users for the first
|
|
58
|
+
# time. Used by ``users.update()`` for self-edit detection.
|
|
59
|
+
self._user_uid: str | None = None
|
|
60
|
+
|
|
61
|
+
# Refresh serialization. Threads coalesce on this lock; whichever
|
|
62
|
+
# acquires it first does the actual login, the rest discover the
|
|
63
|
+
# token has changed under them and skip a duplicate refresh.
|
|
64
|
+
self._refresh_lock = threading.Lock()
|
|
65
|
+
|
|
66
|
+
# Per-thread scope override for per-call ``X-User-Project`` /
|
|
67
|
+
# ``X-User-Subcontractor`` swaps without mutating shared state.
|
|
68
|
+
# Used by ``nodes.release(project_uid=...)`` and (in M8) by
|
|
69
|
+
# ``with_scope``. Each thread sees its own override; a missing
|
|
70
|
+
# attribute means "no override" and the default scope applies.
|
|
71
|
+
self._scope_override: threading.local = threading.local()
|
|
72
|
+
|
|
73
|
+
# Bound by RtlsClient after the httpx.Client is constructed.
|
|
74
|
+
self._http: httpx.Client | None = None
|
|
75
|
+
|
|
76
|
+
# -- binding ----------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def bind_http(self, http: httpx.Client) -> None:
|
|
79
|
+
"""Wire the httpx.Client used for login calls.
|
|
80
|
+
|
|
81
|
+
Login requests bypass our own auth wrapper via ``auth=None`` on the
|
|
82
|
+
call site.
|
|
83
|
+
"""
|
|
84
|
+
self._http = http
|
|
85
|
+
|
|
86
|
+
# -- credential introspection ----------------------------------------
|
|
87
|
+
|
|
88
|
+
def can_reauthenticate(self) -> bool:
|
|
89
|
+
"""True when stored credentials let the SDK re-login on 401."""
|
|
90
|
+
return self._password is not None and self._username is not None
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def token(self) -> str | None:
|
|
94
|
+
return self._token
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def email(self) -> str | None:
|
|
98
|
+
return self._email
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def user_uid(self) -> str | None:
|
|
102
|
+
"""Cached uid of the authenticated user.
|
|
103
|
+
|
|
104
|
+
``None`` until something calls ``users.me()`` / ``auth.whoami()``,
|
|
105
|
+
which resolves the email against the user list and caches the uid.
|
|
106
|
+
"""
|
|
107
|
+
return self._user_uid
|
|
108
|
+
|
|
109
|
+
# -- headers ----------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def headers(self) -> dict[str, str]:
|
|
112
|
+
"""Headers to attach to every authenticated request.
|
|
113
|
+
|
|
114
|
+
``X-User-Subcontractor`` is omitted when no company scope is set —
|
|
115
|
+
the JS client sends the literal string ``"null"`` in that case,
|
|
116
|
+
which we treat as a bug rather than a contract (RESEARCH §5
|
|
117
|
+
quirk #4, DESIGN §9 open #3). Backend confirmation pending; if the
|
|
118
|
+
server later proves to require the header, this is the single spot
|
|
119
|
+
to change.
|
|
120
|
+
|
|
121
|
+
Scoping headers respect any per-call override set by
|
|
122
|
+
:meth:`RtlsClient._call_with_scope` (per-thread).
|
|
123
|
+
"""
|
|
124
|
+
out: dict[str, str] = {}
|
|
125
|
+
if self._token is not None:
|
|
126
|
+
out["X-User-Token"] = self._token
|
|
127
|
+
if self._email is not None:
|
|
128
|
+
out["X-User-Email"] = self._email
|
|
129
|
+
|
|
130
|
+
# Per-thread override takes precedence over the default scope.
|
|
131
|
+
# ``clear_project=True`` (set by context-load's cross-scope nodes
|
|
132
|
+
# fetch) forces ``X-User-Project`` OUT regardless of any default
|
|
133
|
+
# project on the auth state.
|
|
134
|
+
override_clear_project = getattr(self._scope_override, "clear_project", False)
|
|
135
|
+
override_clear_company = getattr(self._scope_override, "clear_company", False)
|
|
136
|
+
override_project = getattr(self._scope_override, "project_uid", None)
|
|
137
|
+
override_company = getattr(self._scope_override, "company_uid", None)
|
|
138
|
+
|
|
139
|
+
if override_clear_project:
|
|
140
|
+
effective_project = None
|
|
141
|
+
elif override_project is not None:
|
|
142
|
+
effective_project = override_project
|
|
143
|
+
else:
|
|
144
|
+
effective_project = self.project_uid
|
|
145
|
+
|
|
146
|
+
if override_clear_company:
|
|
147
|
+
effective_company = None
|
|
148
|
+
elif override_company is not None:
|
|
149
|
+
effective_company = override_company
|
|
150
|
+
else:
|
|
151
|
+
effective_company = self.company_uid
|
|
152
|
+
|
|
153
|
+
if effective_project is not None:
|
|
154
|
+
out["X-User-Project"] = effective_project
|
|
155
|
+
if effective_company is not None:
|
|
156
|
+
out["X-User-Subcontractor"] = effective_company
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
# -- login ------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def login_if_needed(self) -> None:
|
|
162
|
+
"""Acquire an initial token if we don't have one (lazy login)."""
|
|
163
|
+
if self._token is not None:
|
|
164
|
+
return
|
|
165
|
+
with self._refresh_lock:
|
|
166
|
+
if self._token is not None:
|
|
167
|
+
return # another thread won the race
|
|
168
|
+
self._do_login()
|
|
169
|
+
|
|
170
|
+
def reauthenticate(self, expected_token: str | None) -> None:
|
|
171
|
+
"""Re-acquire a token after a 401 — but only if it hasn't been
|
|
172
|
+
refreshed already since the caller's failing request.
|
|
173
|
+
|
|
174
|
+
``expected_token`` is the token value the caller used when its
|
|
175
|
+
request returned 401. We acquire the lock, then check whether the
|
|
176
|
+
token has already changed (meaning another concurrent thread
|
|
177
|
+
already did the refresh). If so, return without doing another
|
|
178
|
+
login — the caller just needs to retry with the new token.
|
|
179
|
+
"""
|
|
180
|
+
if not self.can_reauthenticate():
|
|
181
|
+
return
|
|
182
|
+
with self._refresh_lock:
|
|
183
|
+
if self._token != expected_token:
|
|
184
|
+
# Already refreshed by a concurrent thread.
|
|
185
|
+
return
|
|
186
|
+
self._token = None
|
|
187
|
+
self._do_login()
|
|
188
|
+
|
|
189
|
+
def _do_login(self) -> None:
|
|
190
|
+
"""Perform the actual login HTTP call, populating token + email."""
|
|
191
|
+
if self._http is None:
|
|
192
|
+
raise RuntimeError("_AuthState._http is not bound; this is an SDK bug")
|
|
193
|
+
if self._username is None or self._password is None:
|
|
194
|
+
raise AuthenticationError(
|
|
195
|
+
"cannot login: no username/password stored",
|
|
196
|
+
status_code=None,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# auth=None bypasses our own _RtlsAuth wrapper. httpx accepts this
|
|
200
|
+
# at runtime; the stub doesn't model it, hence the ignore.
|
|
201
|
+
response = self._http.post(
|
|
202
|
+
_LOGIN_PATH,
|
|
203
|
+
json={"email": self._username, "password": self._password},
|
|
204
|
+
auth=None, # type: ignore[arg-type]
|
|
205
|
+
)
|
|
206
|
+
if response.status_code != 200:
|
|
207
|
+
# Don't surface server's response body verbatim — could contain
|
|
208
|
+
# diagnostic data that names credentials. The redaction filter
|
|
209
|
+
# on response hooks already neutralized any logged version, but
|
|
210
|
+
# error messages compose differently — keep it terse here.
|
|
211
|
+
raise AuthenticationError(
|
|
212
|
+
f"login failed: HTTP {response.status_code}",
|
|
213
|
+
status_code=response.status_code,
|
|
214
|
+
request_url=str(response.request.url),
|
|
215
|
+
request_method=response.request.method,
|
|
216
|
+
)
|
|
217
|
+
try:
|
|
218
|
+
payload = response.json()
|
|
219
|
+
except ValueError as exc:
|
|
220
|
+
raise AuthenticationError(
|
|
221
|
+
"login response was not valid JSON",
|
|
222
|
+
status_code=response.status_code,
|
|
223
|
+
request_url=str(response.request.url),
|
|
224
|
+
request_method=response.request.method,
|
|
225
|
+
) from exc
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
data = unwrap_data(payload)
|
|
229
|
+
token = data["token"]
|
|
230
|
+
email = data["email"]
|
|
231
|
+
except (KeyError, ValueError) as exc:
|
|
232
|
+
raise AuthenticationError(
|
|
233
|
+
f"login response had unexpected shape: {exc}",
|
|
234
|
+
status_code=response.status_code,
|
|
235
|
+
request_url=str(response.request.url),
|
|
236
|
+
request_method=response.request.method,
|
|
237
|
+
) from exc
|
|
238
|
+
|
|
239
|
+
if not isinstance(token, str) or not isinstance(email, str):
|
|
240
|
+
raise AuthenticationError(
|
|
241
|
+
"login response had non-string token or email",
|
|
242
|
+
status_code=response.status_code,
|
|
243
|
+
)
|
|
244
|
+
self._token = token
|
|
245
|
+
self._email = email
|
|
246
|
+
|
|
247
|
+
def clear(self) -> None:
|
|
248
|
+
"""Forget the in-memory token. Called by ``RtlsClient.close``."""
|
|
249
|
+
self._token = None
|
|
250
|
+
|
|
251
|
+
def set_password(self, new_password: str) -> None:
|
|
252
|
+
"""Replace the stored password and invalidate the current token.
|
|
253
|
+
|
|
254
|
+
Called by ``auth.change_password`` after the server accepts a
|
|
255
|
+
password change. Subsequent calls trigger a fresh lazy login
|
|
256
|
+
with the new credentials.
|
|
257
|
+
"""
|
|
258
|
+
if self._username is None:
|
|
259
|
+
raise RuntimeError(
|
|
260
|
+
"set_password called on a BYO-token client; the SDK has no username to log in with"
|
|
261
|
+
)
|
|
262
|
+
self._password = new_password
|
|
263
|
+
self._token = None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
__all__ = ["_AuthState"]
|