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/__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"]