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
@@ -0,0 +1,275 @@
1
+ """Users resource sub-client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .._envelope import unwrap_resource
8
+ from ..compounds import users as _compound
9
+ from ..errors import RtlsError
10
+ from ..models import Role, User
11
+ from ._base import ResourceAPI
12
+
13
+
14
+ class UsersAPI(ResourceAPI):
15
+ """User-related calls.
16
+
17
+ M2 ships ``list``, ``me``, and ``get_access_control``. Mutating
18
+ operations (create / update / delete / change_password) land in M3
19
+ and M5. ``list`` is defined last to avoid mypy's class-scope shadow
20
+ of the builtin ``list``.
21
+ """
22
+
23
+ def me(self) -> User:
24
+ """Return the currently-authenticated user as a :class:`User`.
25
+
26
+ There is no server-side ``/users/me`` endpoint — this method is
27
+ composed: it calls ``GET /users/?withProjects=true``, finds the
28
+ record whose ``email`` matches the SDK's stored auth email, and
29
+ returns it.
30
+
31
+ Raises
32
+ ------
33
+ RtlsError
34
+ If the current email is not present in the response (the
35
+ server's user list excludes self for some reason — unusual
36
+ but possible with scoped tokens).
37
+
38
+ Examples
39
+ --------
40
+ >>> me = client.auth.whoami()
41
+ >>> me.email, me.role
42
+ ('qa@example.com', Role.SYSTEM_ADMIN)
43
+ """
44
+ users = self.list(with_projects=True)
45
+ email = self._client._auth_state.email
46
+ if email is None:
47
+ raise RtlsError(
48
+ "users.me() called before authentication completed — this is an SDK bug",
49
+ )
50
+
51
+ target = email.lower()
52
+ for user in users:
53
+ if user.email.lower() == target:
54
+ # Cache uid on the auth state for self-edit detection in
55
+ # users.update() and auth.change_password().
56
+ if user.uid:
57
+ self._client._auth_state._user_uid = user.uid
58
+ return user
59
+
60
+ raise RtlsError(
61
+ f"authenticated user {email!r} not found in /users response",
62
+ )
63
+
64
+ def get_access_control(self) -> list[dict[str, Any]]:
65
+ """Return the current user's access-control entries.
66
+
67
+ Returns a list of per-company role assignments. M2 surfaces them
68
+ as raw dicts; a dedicated ``AccessControlEntry`` model lands
69
+ with the write surface in M3.
70
+ """
71
+ response = self._get("/api/v2/users/access_control/")
72
+ payload = response.json()
73
+ if isinstance(payload, dict):
74
+ entries = payload.get("access_control")
75
+ if isinstance(entries, list):
76
+ return entries
77
+ if isinstance(payload, list):
78
+ return payload
79
+ return []
80
+
81
+ def create_raw(self, email: str, password: str, **fields: Any) -> User:
82
+ """Create a user (single REST call, no project-access binding).
83
+
84
+ **Advanced.** :meth:`create` (added in M5) also binds the new
85
+ user to a project role in one compound call. Use ``create_raw``
86
+ only if you need to skip the role-binding step.
87
+ """
88
+ body: dict[str, Any] = {"email": email, "password": password, **fields}
89
+ response = self._post("/api/v2/users", json=body)
90
+ payload = response.json()
91
+ if isinstance(payload, dict):
92
+ item = payload.get("user", payload)
93
+ else:
94
+ item = {}
95
+ return User.from_wire(item if isinstance(item, dict) else {})
96
+
97
+ def update_raw(self, uid: str, **fields: Any) -> User:
98
+ """Update a user (single REST call, no project-access binding).
99
+
100
+ **Advanced.** :meth:`update` (added in M5) also re-binds the
101
+ user's project role if you pass ``project_role=``.
102
+ """
103
+ response = self._put(f"/api/v2/users/{uid}/", json=fields)
104
+ payload = response.json()
105
+ if isinstance(payload, dict):
106
+ item = payload.get("user", payload)
107
+ else:
108
+ item = {}
109
+ return User.from_wire(item if isinstance(item, dict) else {})
110
+
111
+ def delete(self, uid: str) -> None:
112
+ """Delete a user by uid (single REST call)."""
113
+ self._delete(f"/api/v2/users/{uid}/")
114
+
115
+ def update_access(self, project_uid: str, *, uid: str, role: str) -> dict[str, Any]:
116
+ """Assign or change a user's role on a project.
117
+
118
+ Wraps ``POST /projects/access/``. Used internally by M5's
119
+ :meth:`create` / :meth:`update` compounds to bind project roles
120
+ atomically; exposed here for callers that need it standalone.
121
+ """
122
+ body = {
123
+ "projectUsersAccess": [{"project_uid": project_uid, "user_uid": uid, "user_role": role}]
124
+ }
125
+ response = self._post("/api/v2/projects/access/", json=body)
126
+ payload = response.json()
127
+ return payload if isinstance(payload, dict) else {"data": payload}
128
+
129
+ def restore_password(self, email: str) -> dict[str, Any]:
130
+ """Trigger a password-restore email for the given address.
131
+
132
+ Unauthenticated — the server only needs the email. Returns the
133
+ server's response payload.
134
+ """
135
+ response = self._put("/api/v2/users/restorePassword/", json={"email": email})
136
+ payload = response.json()
137
+ return payload if isinstance(payload, dict) else {"data": payload}
138
+
139
+ def validate(self, **fields: Any) -> dict[str, Any]:
140
+ """Server-side validation for user-form fields.
141
+
142
+ The wire shape varies by field (``{"email": "..."}``,
143
+ ``{"password": "...", "uid": "...", "isOld": true}``, etc.); the
144
+ SDK passes through whatever you give it. Returns the parsed
145
+ server response. M5's :meth:`change_password` compound calls
146
+ this internally.
147
+ """
148
+ response = self._post("/api/v2/users/validation", json=fields)
149
+ payload = response.json()
150
+ return payload if isinstance(payload, dict) else {"data": payload}
151
+
152
+ def change_password_raw(
153
+ self, uid: str, *, old_password: str, new_password: str
154
+ ) -> dict[str, Any]:
155
+ """Change a user's password (single REST call, no re-login).
156
+
157
+ **Advanced.** M5's :meth:`change_password` wraps this to also
158
+ re-authenticate the SDK afterwards. Calling ``change_password_raw``
159
+ directly leaves the SDK holding a stale token — your next call
160
+ will trigger a 401 + re-login dance.
161
+ """
162
+ body = {"uid": uid, "oldPassword": old_password, "newPassword": new_password}
163
+ response = self._put("/api/v2/users/changePassword", json=body)
164
+ payload = response.json() if response.content else None
165
+ return payload if isinstance(payload, dict) else {}
166
+
167
+ def create(
168
+ self,
169
+ email: str,
170
+ password: str,
171
+ *,
172
+ project_uid: str | None = None,
173
+ project_role: str | Role | None = None,
174
+ **fields: Any,
175
+ ) -> User:
176
+ """Create a user and optionally bind them to a project role.
177
+
178
+ One call collapses two REST round-trips: ``POST /users`` plus
179
+ ``POST /projects/access/``. If the role-binding step fails, the
180
+ SDK best-effort rolls back the user create — see
181
+ :class:`PartialFailureError` for the orphan uid if rollback
182
+ also fails.
183
+
184
+ ``project_role`` is skipped (no second call) when the created
185
+ user comes back as ``COMPANY_ADMIN``: company admins have
186
+ implicit project access at the org level.
187
+
188
+ Step names: ``create_user``, ``bind_project_role``,
189
+ ``delete_user_rollback``.
190
+ """
191
+ return _compound.create_user(
192
+ self._client,
193
+ email,
194
+ password,
195
+ project_uid=project_uid,
196
+ project_role=project_role,
197
+ **fields,
198
+ )
199
+
200
+ def update(
201
+ self,
202
+ uid: str,
203
+ *,
204
+ project_uid: str | None = None,
205
+ project_role: str | Role | None = None,
206
+ **fields: Any,
207
+ ) -> User:
208
+ """Update a user, optionally re-binding their project role.
209
+
210
+ Self-edit detection: if ``uid`` matches the SDK's current user
211
+ AND the update changes ``email``, the SDK refreshes the
212
+ in-memory auth state so subsequent ``X-User-Email`` headers
213
+ stay valid.
214
+
215
+ Step names: ``update_user``, ``bind_project_role``.
216
+ """
217
+ return _compound.update_user(
218
+ self._client,
219
+ uid,
220
+ project_uid=project_uid,
221
+ project_role=project_role,
222
+ **fields,
223
+ )
224
+
225
+ def list_roles(self) -> list[Role]:
226
+ """Return all valid roles the server accepts on user create / update.
227
+
228
+ Endpoint shape varies by deployment; the SDK best-effort parses
229
+ the response into :class:`Role` members. Used by
230
+ :meth:`ContextAPI.load` (M6). Returns an empty list if the
231
+ endpoint isn't available or the response shape isn't recognized.
232
+ """
233
+ response = self._get("/api/v2/user_roles")
234
+ payload = response.json()
235
+ roles: list[Role] = []
236
+ # Tolerate {user_roles: [...]}, bare list, or {roles: [...]}.
237
+ candidates: list[Any] = []
238
+ if isinstance(payload, dict):
239
+ for key in ("user_roles", "roles", "data"):
240
+ value = payload.get(key)
241
+ if isinstance(value, list):
242
+ candidates = value
243
+ break
244
+ elif isinstance(payload, list):
245
+ candidates = payload
246
+ for item in candidates:
247
+ role_str: str | None = None
248
+ if isinstance(item, str):
249
+ role_str = item
250
+ elif isinstance(item, dict):
251
+ raw = item.get("name") or item.get("role") or item.get("uid")
252
+ if isinstance(raw, str):
253
+ role_str = raw
254
+ if role_str is None:
255
+ continue
256
+ try:
257
+ roles.append(Role(role_str.lower()))
258
+ except ValueError:
259
+ # Unknown role string — skip rather than fail.
260
+ continue
261
+ return roles
262
+
263
+ def list(self, *, with_projects: bool = False) -> list[User]:
264
+ """Return all users visible to the current scope.
265
+
266
+ ``with_projects=True`` asks the server to include per-user
267
+ project assignments inside each user record.
268
+ """
269
+ params = {"withProjects": "true"} if with_projects else None
270
+ response = self._get("/api/v2/users/", params=params)
271
+ items = unwrap_resource(response.json(), "users")
272
+ return [User.from_wire(item) for item in items]
273
+
274
+
275
+ __all__ = ["UsersAPI"]
@@ -0,0 +1,199 @@
1
+ """Zones resource sub-client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ from .._envelope import unwrap_resource, unwrap_resource_single
9
+ from .._time import to_epoch_ms
10
+ from ..compounds import zones as _compound
11
+ from ..compounds.tags import UNSET, _Unset
12
+ from ..errors import NotFound
13
+ from ..models import Zone
14
+ from ._base import ResourceAPI
15
+
16
+
17
+ class ZonesAPI(ResourceAPI):
18
+ """List, get, and (later) mutate zones (static + buffers).
19
+
20
+ ``list`` is defined last so mypy resolves ``list[Zone]`` annotations
21
+ in other methods to the builtin (class-scope shadow workaround).
22
+ """
23
+
24
+ def get(self, uid: str) -> Zone:
25
+ """Fetch a single zone by uid.
26
+
27
+ The server has no dedicated ``GET /zones/{uid}`` per the JS
28
+ client; the SDK composes via ``list()`` and filters. This is a
29
+ documented stop-gap — if the server later exposes a single-zone
30
+ endpoint, the implementation switches without changing the
31
+ surface.
32
+ """
33
+ zones = self.list()
34
+ for zone in zones:
35
+ if zone.uid == uid:
36
+ return zone
37
+ raise NotFound(f"zone {uid!r} not found")
38
+
39
+ def history(self, start: datetime, end: datetime) -> list[Zone]:
40
+ """Return zones (including soft-deleted) active during ``[start, end]``.
41
+
42
+ Used for audit / replay workflows. Timestamps go on the wire as
43
+ epoch milliseconds.
44
+ """
45
+ params = {
46
+ "include_removed": "true",
47
+ "from": str(to_epoch_ms(start, arg_name="start")),
48
+ "to": str(to_epoch_ms(end, arg_name="end")),
49
+ }
50
+ response = self._get("/api/v2/zones/", params=params)
51
+ items = unwrap_resource(response.json(), "zones")
52
+ return [Zone.from_wire(item) for item in items]
53
+
54
+ def list_custom_prototypes(self) -> list[dict[str, Any]]:
55
+ """List custom-zone prototypes — server-side reusable zone shapes.
56
+
57
+ These don't yet have a dedicated model; we surface them as raw
58
+ dicts. M2's pydantic shapes will replace this when usage stabilizes.
59
+ """
60
+ response = self._get("/api/v2/custom_zones_prototypes/")
61
+ items = unwrap_resource(response.json(), "custom_zones_prototypes")
62
+ return list(items)
63
+
64
+ def create_raw(self, name: str, **fields: Any) -> Zone:
65
+ """Create a zone or buffer (single REST call).
66
+
67
+ **Advanced.** :meth:`create` (added in M4) wraps this and also
68
+ handles ``dynamic`` zone-to-trackable binding. Use ``create_raw``
69
+ only when you need exact control over the call sequence.
70
+
71
+ Parameters
72
+ ----------
73
+ name
74
+ Required. Zone name.
75
+ **fields
76
+ Pass ``shape``, ``points`` / ``radius_m`` / center, ``dynamic``,
77
+ ``trackable_object_uid``, ``sublocation_uid``, etc. — whatever
78
+ the server accepts.
79
+ """
80
+ body: dict[str, Any] = {"name": name, **fields}
81
+ response = self._post("/api/v2/zones", json=body)
82
+ payload = response.json()
83
+ try:
84
+ item = unwrap_resource_single(payload, "zone")
85
+ except ValueError:
86
+ item = payload if isinstance(payload, dict) else {}
87
+ return Zone.from_wire(item)
88
+
89
+ def update_raw(self, uid: str, **fields: Any) -> Zone:
90
+ """Update a zone (single REST call, no trackable re-binding).
91
+
92
+ **Advanced.** :meth:`update` (added in M4) handles dynamic-zone
93
+ re-binding automatically.
94
+ """
95
+ response = self._put(f"/api/v2/zones/{uid}", json=fields)
96
+ payload = response.json()
97
+ try:
98
+ item = unwrap_resource_single(payload, "zone")
99
+ except ValueError:
100
+ item = payload if isinstance(payload, dict) else {}
101
+ return Zone.from_wire(item)
102
+
103
+ def delete_raw(self, uid: str) -> None:
104
+ """Delete a zone (single REST call, no trackable-detach).
105
+
106
+ **Advanced.** :meth:`delete` (added in M4) clears any dynamic
107
+ binding before deletion.
108
+ """
109
+ self._delete(f"/api/v2/zones/{uid}")
110
+
111
+ def create(
112
+ self,
113
+ name: str,
114
+ *,
115
+ dynamic: bool = False,
116
+ trackable_uid: str | None = None,
117
+ **fields: Any,
118
+ ) -> Zone:
119
+ """Create a zone or buffer, binding it to a trackable if requested.
120
+
121
+ For static zones (``dynamic=False``) this is a single REST call.
122
+ For dynamic zones bound to a trackable, the SDK also writes
123
+ ``self_zone_uid`` onto the trackable so the binding is consistent
124
+ from both sides. The trackable update is rolled back (best
125
+ effort) if the zone create succeeds but the binding fails.
126
+
127
+ Step names: ``create_zone``, ``set_trackable_binding``.
128
+
129
+ Examples
130
+ --------
131
+ >>> static = client.zones.create(
132
+ ... "Loading Bay", shape="polygon",
133
+ ... points=[(0, 0), (10, 0), (10, 5), (0, 5)],
134
+ ... sublocation_uid="sl-east",
135
+ ... )
136
+ >>> buffer_zone = client.zones.create(
137
+ ... "FL7-buf", shape="circle", radius_m=3.0,
138
+ ... dynamic=True, trackable_uid="t-7",
139
+ ... )
140
+ """
141
+ return _compound.create_zone(
142
+ self._client,
143
+ name,
144
+ dynamic=dynamic,
145
+ trackable_uid=trackable_uid,
146
+ **fields,
147
+ )
148
+
149
+ def update(
150
+ self,
151
+ uid: str,
152
+ *,
153
+ trackable_uid: str | None | _Unset = UNSET,
154
+ **fields: Any,
155
+ ) -> Zone:
156
+ """Update a zone, re-binding the trackable if requested.
157
+
158
+ Pass ``trackable_uid=...`` (default) to leave the current
159
+ binding alone. Pass ``trackable_uid=None`` to clear it;
160
+ ``trackable_uid="t-x"`` to rebind to a different trackable.
161
+
162
+ Step names: ``load_zone``, ``clear_old_binding``,
163
+ ``set_new_binding``, ``update_zone``.
164
+
165
+ If ``set_new_binding`` fails after ``clear_old_binding``
166
+ succeeded, the SDK best-effort restores the old binding before
167
+ raising. If that restore also fails, the exception's
168
+ ``partial_result`` names the dangling trackable.
169
+ """
170
+ return _compound.update_zone(self._client, uid, trackable_uid=trackable_uid, **fields)
171
+
172
+ def delete(self, uid: str) -> None:
173
+ """Delete a zone, clearing any dynamic-trackable binding first.
174
+
175
+ Step names: ``load_zone``, ``clear_binding``, ``delete_zone``.
176
+
177
+ Clearing a stale binding is best-effort — failure there is
178
+ logged and the delete proceeds anyway.
179
+ """
180
+ _compound.delete_zone(self._client, uid)
181
+
182
+ def list(self) -> list[Zone]:
183
+ """Return zones and buffers for the current project.
184
+
185
+ The server returns both static zones (``dynamic=False``) and
186
+ buffers (``dynamic=True``) in the same response. The SDK does
187
+ not filter client-side (the JS reference client does — RESEARCH §5
188
+ quirk #11 — and the SDK explicitly chooses not to).
189
+
190
+ Examples
191
+ --------
192
+ >>> static_zones = [z for z in client.zones.list() if not z.dynamic]
193
+ """
194
+ response = self._get("/api/v2/zones/")
195
+ items = unwrap_resource(response.json(), "zones")
196
+ return [Zone.from_wire(item) for item in items]
197
+
198
+
199
+ __all__ = ["ZonesAPI"]
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: rtls-sdk
3
+ Version: 0.2.0
4
+ Summary: Python SDK for the RTLS REST API
5
+ Project-URL: Homepage, https://github.com/rpplabs/rtls-sdk
6
+ Project-URL: Documentation, https://github.com/rpplabs/rtls-sdk/tree/master/docs
7
+ Project-URL: Issues, https://github.com/rpplabs/rtls-sdk/issues
8
+ Project-URL: Changelog, https://github.com/rpplabs/rtls-sdk/blob/master/CHANGELOG.md
9
+ Author: RTLS SDK Maintainers
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: iot,rtls,sdk,tracking
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx>=0.27
23
+ Requires-Dist: pydantic>=2.5
24
+ Provides-Extra: dev
25
+ Requires-Dist: mypy>=1.8; extra == 'dev'
26
+ Requires-Dist: pytest-cov>=4.1; extra == 'dev'
27
+ Requires-Dist: pytest-httpserver>=1.0; extra == 'dev'
28
+ Requires-Dist: pytest>=7.4; extra == 'dev'
29
+ Requires-Dist: python-dotenv>=1.0; extra == 'dev'
30
+ Requires-Dist: respx>=0.20; extra == 'dev'
31
+ Requires-Dist: ruff>=0.4; extra == 'dev'
32
+ Provides-Extra: docs
33
+ Requires-Dist: mkdocs-material>=9; extra == 'docs'
34
+ Requires-Dist: mkdocstrings[python]>=0.24; extra == 'docs'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # RTLS SDK
38
+
39
+ A Python SDK for the RTLS REST API. Three-step usage:
40
+
41
+ ```python
42
+ from rtls_sdk import RtlsClient
43
+
44
+ client = RtlsClient.from_env()
45
+ tags = client.tags.list()
46
+ ```
47
+
48
+ That's it — token, refresh, scoping headers, 401 re-login are all
49
+ internal. The first method call performs a lazy login; subsequent
50
+ calls reuse the session.
51
+
52
+ ## What you get
53
+
54
+ - **Compound workflows.** One Python call replaces the multi-call
55
+ REST sagas the JS reference client makes you script — e.g.
56
+ `tags.create(name="X", attached_zone="Z", groups=[...])` runs the
57
+ three-step create-and-attach with rollback on partial failure.
58
+ - **One exception hierarchy.** Everything under `RtlsError`:
59
+ `NotFound`, `ValidationError`, `RateLimited`, `ServerError`,
60
+ `PartialFailureError`, `ReportFailed` / `ReportTimeout`, …
61
+ - **Typed models.** Pydantic v2 under the hood; `mypy --strict` clean
62
+ out of the box; ships `py.typed`.
63
+ - **Mandatory redaction.** No password, token, refresh-token, cookie,
64
+ or `Authorization` header ever reaches a log handler. There is no
65
+ opt-out flag.
66
+ - **Sync-only for v1.** Async surface and WebSocket support are on the
67
+ v2 roadmap.
68
+
69
+ ## Install
70
+
71
+ ```bash
72
+ pip install rtls-sdk
73
+ ```
74
+
75
+ Python 3.10+ supported. See [`docs/install.md`](docs/install.md) for
76
+ dev install and verification.
77
+
78
+ ## Quickstart
79
+
80
+ ```bash
81
+ export RTLS_USERNAME="qa@example.com"
82
+ export RTLS_PASSWORD="hunter2"
83
+ export RTLS_BASE_URL="https://rtls.example.com"
84
+ ```
85
+
86
+ ```python
87
+ from rtls_sdk import RtlsClient
88
+
89
+ with RtlsClient.from_env() as client:
90
+ for tag in client.tags.list():
91
+ print(tag.uid, tag.name)
92
+ ```
93
+
94
+ For the full 5-minute walkthrough see
95
+ [`docs/quickstart.md`](docs/quickstart.md).
96
+
97
+ ## Documentation
98
+
99
+ The full docs site (`mkdocs build`):
100
+
101
+ **Start here**
102
+
103
+ - **[Entity catalog](docs/entities/index.md)** — one page per entity
104
+ (tags, sites, areas, floorplans, anchors, zones, nodes, users,
105
+ reports, …) with methods, examples, and model fields. The fastest
106
+ path to "what can I do with X?".
107
+ - [Install](docs/install.md) · [Quickstart](docs/quickstart.md)
108
+
109
+ **Cross-cutting guides**
110
+
111
+ - [Compound workflows](docs/guides/compound-workflows.md) · [Reports](docs/guides/reports.md) · [Floorplans](docs/guides/floorplans.md)
112
+ - [Error handling](docs/guides/error-handling.md) · [Scope switching](docs/guides/scope-switching.md) · [Pagination](docs/guides/pagination.md)
113
+ - [Rate limiting](docs/guides/rate-limiting.md) · [Timestamps](docs/guides/timestamps.md) · [Credential rotation](docs/guides/credential-rotation.md)
114
+
115
+ **Advanced**
116
+
117
+ - [BYO token](docs/advanced/byo-token.md) · [Raw REST methods](docs/advanced/raw-rest-methods.md) · [Logging](docs/advanced/logging.md) · [HTTP client override](docs/advanced/http-client-override.md) · [Security](docs/advanced/security.md)
118
+
119
+ **Reference**
120
+
121
+ - [Migrating from the JS client](docs/migration/from-js-client.md)
122
+ - API reference — auto-generated from docstrings via mkdocstrings.
123
+
124
+ Build locally:
125
+
126
+ ```bash
127
+ pip install -e ".[docs]"
128
+ mkdocs serve
129
+ ```
130
+
131
+ ## Status
132
+
133
+ v0.2.0 — adds M9 places CRUD (sites / areas / anchors), M10 floorplans
134
+ + image lifecycle, M11 entity-oriented documentation reorganisation,
135
+ and M12 schema-audit fixes (canonical bare-12-hex MACs;
136
+ `tags.create` no longer accepts the silently-stripped `mac_address`
137
+ argument). See [`CHANGELOG.md`](CHANGELOG.md).
138
+
139
+ ## License
140
+
141
+ MIT — see [`LICENSE`](LICENSE).