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
|
@@ -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).
|