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,149 @@
1
+ """Events resource sub-client.
2
+
3
+ M2 ships a single-page reader for zone events; M7 adds the lazy
4
+ iterator surface (``iter_pws`` / ``pages``) for PWS / external_events
5
+ via the body-URL paginator.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterator
11
+ from datetime import datetime
12
+ from typing import Any
13
+
14
+ from .._envelope import unwrap_resource
15
+ from .._pagination import BodyUrlPaginator, Page
16
+ from .._query import pack_array_param
17
+ from .._time import to_epoch_ms
18
+ from ..models import PwsEvent, ZoneEvent
19
+ from ._base import ResourceAPI
20
+
21
+
22
+ class EventsAPI(ResourceAPI):
23
+ """Read zone events and external (PWS) events.
24
+
25
+ ``iter_pws`` and ``pages`` are lazy: HTTP requests fire only when
26
+ the iterator advances past the current page. This keeps large
27
+ windows out of memory and lets callers ``break`` early.
28
+ """
29
+
30
+ def list_zone_events(
31
+ self,
32
+ start: datetime,
33
+ end: datetime,
34
+ *,
35
+ area_uids: list[str],
36
+ type: str = "information",
37
+ ) -> list[ZoneEvent]:
38
+ """Return zone events for ``area_uids`` in ``[start, end]``.
39
+
40
+ The server returns a paginated response; this method reads only
41
+ the first page. Callers needing the full set should use the
42
+ iterator surface.
43
+ """
44
+ params: list[tuple[str, str]] = [
45
+ ("from", str(to_epoch_ms(start, arg_name="start"))),
46
+ ("to", str(to_epoch_ms(end, arg_name="end"))),
47
+ ("type", type),
48
+ ("format", "json"),
49
+ ]
50
+ params.extend(pack_array_param("area_uid", area_uids))
51
+ response = self._get("/api/v2/data/zone_events", params=params)
52
+ payload = response.json()
53
+ if isinstance(payload, dict):
54
+ for key in ("zone_events", "events", "data"):
55
+ if key in payload and isinstance(payload[key], list):
56
+ return [ZoneEvent.from_wire(it) for it in payload[key]]
57
+ items = unwrap_resource(payload, "zone_events")
58
+ return [ZoneEvent.from_wire(item) for item in items]
59
+
60
+ def iter_pws(
61
+ self,
62
+ *,
63
+ trackable_uid: str | list[str],
64
+ start: datetime,
65
+ end: datetime,
66
+ page_size: int = 1000,
67
+ ) -> Iterator[PwsEvent]:
68
+ """Lazy iterator over PWS events for one or more trackables.
69
+
70
+ Yields one :class:`PwsEvent` per server row. HTTP requests fire
71
+ only when the iterator advances past the current page — calling
72
+ ``list(client.events.iter_pws(...))`` materialises everything,
73
+ while ``next(iter)`` fetches the minimum needed.
74
+
75
+ Parameters
76
+ ----------
77
+ trackable_uid
78
+ One uid string or a list of uids (Rails-style
79
+ ``track_uid[]=`` query params).
80
+ start, end
81
+ Tz-aware datetimes. Naive datetimes raise ``ValueError``.
82
+ page_size
83
+ Server-side limit hint. The server may return fewer rows.
84
+
85
+ Examples
86
+ --------
87
+ >>> for event in client.events.iter_pws(
88
+ ... trackable_uid="t-1", start=start, end=end
89
+ ... ):
90
+ ... print(event.ts, event.zone_uid)
91
+ """
92
+ return iter(self._build_pws_paginator(trackable_uid, start, end, page_size))
93
+
94
+ def pages(
95
+ self,
96
+ *,
97
+ trackable_uid: str | list[str],
98
+ start: datetime,
99
+ end: datetime,
100
+ page_size: int = 1000,
101
+ ) -> Iterator[Page[PwsEvent]]:
102
+ """Iterate one :class:`Page` at a time — for progress reporting.
103
+
104
+ Same conventions as :meth:`iter_pws`, but yields whole pages
105
+ carrying ``page_number`` / ``total_count`` / ``has_next`` so
106
+ callers can print progress without counting items themselves.
107
+ """
108
+ return self._build_pws_paginator(trackable_uid, start, end, page_size).pages()
109
+
110
+ def _build_pws_paginator(
111
+ self,
112
+ trackable_uid: str | list[str],
113
+ start: datetime,
114
+ end: datetime,
115
+ page_size: int,
116
+ ) -> BodyUrlPaginator[PwsEvent]:
117
+ uids = [trackable_uid] if isinstance(trackable_uid, str) else list(trackable_uid)
118
+ params: list[tuple[str, str]] = [
119
+ ("event_type[]", "pws"),
120
+ ("from", str(to_epoch_ms(start, arg_name="start"))),
121
+ ("to", str(to_epoch_ms(end, arg_name="end"))),
122
+ ("limit", str(page_size)),
123
+ ]
124
+ params.extend(pack_array_param("track_uid", uids))
125
+
126
+ def parse(raw: Any) -> list[PwsEvent]:
127
+ if not isinstance(raw, list):
128
+ return []
129
+ out: list[PwsEvent] = []
130
+ for item in raw:
131
+ if isinstance(item, dict):
132
+ out.append(PwsEvent.from_wire(item))
133
+ return out
134
+
135
+ # Bind the client's request method as the fetcher. Wraps in
136
+ # check_response via self._client._request semantics.
137
+ def fetch(path: str, *, params: Any = None) -> Any:
138
+ return self._client._request("GET", path, params=params)
139
+
140
+ return BodyUrlPaginator[PwsEvent](
141
+ fetch=fetch,
142
+ path="/api/v2/external_events",
143
+ params=params,
144
+ parse_items=parse,
145
+ list_key="external_events",
146
+ )
147
+
148
+
149
+ __all__ = ["EventsAPI"]
@@ -0,0 +1,283 @@
1
+ """Floorplans resource sub-client.
2
+
3
+ Full CRUD plus image upload (base64-in-JSON on the same POST body) and
4
+ image download. Live-server schema (probed M10) requires:
5
+
6
+ - ``POST /api/v2/floorplans/`` — ``sublocation_uid`` and ``image:
7
+ {original_filename, file}`` are required; ``display_name``,
8
+ ``image_scale``, ``image_rotation``, ``image_offset_x``,
9
+ ``image_offset_y`` are optional. **There is no `name` field, and
10
+ imageless floorplans are NOT permitted.**
11
+ - ``PUT /api/v2/floorplans/{uid}`` — accepts any subset of the above
12
+ fields. Image replacement is optional.
13
+ - ``GET /api/v2/floorplans/{uid}`` — returns ``{floorplans: [<item>]}``
14
+ (single-item list envelope).
15
+ - The created floorplan's ``url`` is a relative path of the form
16
+ ``/uploads/floorplan/image/<uid>/<original_filename>`` and is
17
+ fetched via a separate authenticated GET with a 180-second read
18
+ timeout (production floorplans can be tens of MB of PDF).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from pathlib import Path
24
+ from typing import IO, Any
25
+ from urllib.parse import urljoin
26
+
27
+ import httpx
28
+
29
+ from .._envelope import unwrap_resource, unwrap_resource_single
30
+ from .._query import encode_image_payload
31
+ from ..models import Floorplan
32
+ from ._base import ResourceAPI
33
+
34
+ # Matches the desktop site-plan client's ``Network::Timeout::Resource``
35
+ # of 180 s. Used only for image GETs — chunked PDFs can be tens of MB.
36
+ _IMAGE_DOWNLOAD_TIMEOUT_SEC: float = 180.0
37
+
38
+
39
+ class FloorplansAPI(ResourceAPI):
40
+ """List, create, update, delete floorplans; upload + download images.
41
+
42
+ ``list`` is defined last (class-scope shadow workaround).
43
+ """
44
+
45
+ def get(self, uid: str) -> Floorplan:
46
+ """Fetch one floorplan by uid.
47
+
48
+ Wire format: server returns ``{floorplans: [<item>]}`` even for
49
+ a single-uid GET. The SDK unwraps the list.
50
+ """
51
+ response = self._get(f"/api/v2/floorplans/{uid}")
52
+ payload = response.json()
53
+ if isinstance(payload, dict) and "floorplans" in payload:
54
+ items = payload["floorplans"]
55
+ if isinstance(items, list) and items:
56
+ return Floorplan.from_wire(items[0])
57
+ if isinstance(payload, dict):
58
+ try:
59
+ return Floorplan.from_wire(unwrap_resource_single(payload, "floorplan"))
60
+ except ValueError:
61
+ return Floorplan.from_wire(payload)
62
+ raise ValueError(f"unexpected floorplan payload shape: {type(payload).__name__}")
63
+
64
+ def create(
65
+ self,
66
+ *,
67
+ sublocation_uid: str,
68
+ image_bytes: bytes | None = None,
69
+ original_filename: str | None = None,
70
+ image_path: str | Path | None = None,
71
+ display_name: str | None = None,
72
+ image_scale: float = 1.0,
73
+ image_rotation: float = 0.0,
74
+ image_offset_x: float = 0.0,
75
+ image_offset_y: float = 0.0,
76
+ **fields: Any,
77
+ ) -> Floorplan:
78
+ """Create a floorplan attached to ``sublocation_uid``.
79
+
80
+ Image input is required and either-or:
81
+
82
+ - Pass ``image_bytes`` (``bytes``) + ``original_filename`` (``str``), OR
83
+ - Pass ``image_path`` (``str | Path``) — the SDK reads the file
84
+ and derives ``original_filename`` from ``Path(image_path).name``.
85
+
86
+ Passing both raises ``ValueError``. Passing neither raises
87
+ ``ValueError`` — the server schema requires an image.
88
+
89
+ Parameters
90
+ ----------
91
+ sublocation_uid
92
+ Parent area uid. Required.
93
+ image_bytes
94
+ Raw image bytes (PNG / SVG / PDF). Must be valid for its
95
+ format — the server runs ``image-size`` on the bytes and
96
+ 500s on a malformed file.
97
+ original_filename
98
+ File name as the server should store it. Required when
99
+ ``image_bytes`` is passed.
100
+ image_path
101
+ Convenience: a path on disk. Mutually exclusive with
102
+ ``image_bytes`` / ``original_filename``.
103
+ display_name
104
+ Optional user-facing label (the server's ``display_name``
105
+ field — note ``display_name``, not ``name``).
106
+ image_scale, image_rotation, image_offset_x, image_offset_y
107
+ Placement transform. Identity defaults.
108
+ **fields
109
+ Other server-accepted fields (deployment-specific).
110
+
111
+ Examples
112
+ --------
113
+ From in-memory bytes:
114
+
115
+ >>> with open("warehouse.png", "rb") as fh:
116
+ ... plan = client.floorplans.create(
117
+ ... sublocation_uid=area.uid,
118
+ ... image_bytes=fh.read(),
119
+ ... original_filename="warehouse.png",
120
+ ... )
121
+
122
+ From a path:
123
+
124
+ >>> plan = client.floorplans.create(
125
+ ... sublocation_uid=area.uid,
126
+ ... image_path="warehouse.png",
127
+ ... display_name="Warehouse 12 — Bay 3",
128
+ ... )
129
+ """
130
+ image = self._resolve_image_payload(
131
+ image_bytes=image_bytes,
132
+ original_filename=original_filename,
133
+ image_path=image_path,
134
+ required=True,
135
+ )
136
+ body: dict[str, Any] = {
137
+ "sublocation_uid": sublocation_uid,
138
+ "image": image,
139
+ "image_scale": image_scale,
140
+ "image_rotation": image_rotation,
141
+ "image_offset_x": image_offset_x,
142
+ "image_offset_y": image_offset_y,
143
+ **fields,
144
+ }
145
+ if display_name is not None:
146
+ body["display_name"] = display_name
147
+ response = self._post("/api/v2/floorplans/", json=body)
148
+ return _parse_floorplan(response.json())
149
+
150
+ def update(
151
+ self,
152
+ uid: str,
153
+ *,
154
+ image_bytes: bytes | None = None,
155
+ original_filename: str | None = None,
156
+ image_path: str | Path | None = None,
157
+ **fields: Any,
158
+ ) -> Floorplan:
159
+ """Update a floorplan's fields. Image replacement is optional.
160
+
161
+ Same either-or rule for image inputs as :meth:`create`. Pass
162
+ neither to keep the stored image untouched; pass one to replace
163
+ it.
164
+ """
165
+ if image_bytes is not None or image_path is not None:
166
+ fields = {
167
+ **fields,
168
+ "image": self._resolve_image_payload(
169
+ image_bytes=image_bytes,
170
+ original_filename=original_filename,
171
+ image_path=image_path,
172
+ required=False,
173
+ ),
174
+ }
175
+ response = self._put(f"/api/v2/floorplans/{uid}", json=fields)
176
+ return _parse_floorplan(response.json())
177
+
178
+ def delete(self, uid: str) -> None:
179
+ """Delete a floorplan (server removes the row AND the stored image)."""
180
+ self._delete(f"/api/v2/floorplans/{uid}")
181
+
182
+ def download_image(
183
+ self,
184
+ floorplan_or_uid: Floorplan | str,
185
+ *,
186
+ stream_to: IO[bytes] | None = None,
187
+ ) -> bytes | None:
188
+ """Download a floorplan's image bytes.
189
+
190
+ Accepts a :class:`Floorplan` (uses its ``.url``) or a uid (the
191
+ SDK first calls :meth:`get` to resolve). The request issues a
192
+ single GET against the server-returned relative URL with a
193
+ 180-second read timeout — production floorplans can be tens of
194
+ MB of PDF, well beyond the default 30-second read budget.
195
+
196
+ With ``stream_to=None`` (default), accumulates the full body
197
+ and returns ``bytes``.
198
+
199
+ With ``stream_to=<writable bytes file-like>``, writes chunks
200
+ as they arrive and returns ``None``. Use this for very large
201
+ downloads to keep memory bounded.
202
+
203
+ Examples
204
+ --------
205
+ >>> data = client.floorplans.download_image(plan)
206
+ >>> with open("warehouse.png", "wb") as fh:
207
+ ... client.floorplans.download_image(plan, stream_to=fh)
208
+ """
209
+ if isinstance(floorplan_or_uid, Floorplan):
210
+ plan_url = floorplan_or_uid.url
211
+ else:
212
+ plan_url = self.get(floorplan_or_uid).url
213
+ if not plan_url:
214
+ raise ValueError("floorplan has no image URL (was an image actually uploaded?)")
215
+
216
+ absolute = urljoin(str(self._client._http.base_url), plan_url)
217
+ timeout = httpx.Timeout(
218
+ connect=10.0,
219
+ read=_IMAGE_DOWNLOAD_TIMEOUT_SEC,
220
+ write=10.0,
221
+ pool=5.0,
222
+ )
223
+
224
+ if stream_to is None:
225
+ response = self._client._http.get(absolute, timeout=timeout)
226
+ response.raise_for_status()
227
+ return response.content
228
+
229
+ with self._client._http.stream("GET", absolute, timeout=timeout) as response:
230
+ response.raise_for_status()
231
+ for chunk in response.iter_bytes(chunk_size=64 * 1024):
232
+ stream_to.write(chunk)
233
+ return None
234
+
235
+ # -- helpers --------------------------------------------------------
236
+
237
+ def _resolve_image_payload(
238
+ self,
239
+ *,
240
+ image_bytes: bytes | None,
241
+ original_filename: str | None,
242
+ image_path: str | Path | None,
243
+ required: bool,
244
+ ) -> dict[str, Any]:
245
+ """Resolve the either-or image input into a server-shaped sub-object."""
246
+ if image_bytes is not None and image_path is not None:
247
+ raise ValueError("pass either image_bytes / image_path, not both")
248
+ if image_path is not None:
249
+ path = Path(image_path)
250
+ return encode_image_payload(path.read_bytes(), original_filename or path.name)
251
+ if image_bytes is not None:
252
+ if not original_filename:
253
+ raise ValueError(
254
+ "original_filename is required when image_bytes is passed "
255
+ "(the server uses it to compose the storage path)"
256
+ )
257
+ return encode_image_payload(image_bytes, original_filename)
258
+ if required:
259
+ raise ValueError(
260
+ "image is required on floorplans.create: "
261
+ "pass image_bytes + original_filename, or image_path"
262
+ )
263
+ # Unreachable in practice: callers only enter this method when at
264
+ # least one of the image inputs is set, OR with required=True.
265
+ raise ValueError("no image input supplied")
266
+
267
+ def list(self) -> list[Floorplan]:
268
+ response = self._get("/api/v2/floorplans/")
269
+ items = unwrap_resource(response.json(), "floorplans")
270
+ return [Floorplan.from_wire(item) for item in items]
271
+
272
+
273
+ def _parse_floorplan(payload: Any) -> Floorplan:
274
+ """Server returns bare object on create/update; some deployments envelope."""
275
+ if isinstance(payload, dict):
276
+ try:
277
+ return Floorplan.from_wire(unwrap_resource_single(payload, "floorplan"))
278
+ except ValueError:
279
+ return Floorplan.from_wire(payload)
280
+ raise ValueError(f"unexpected floorplan payload shape: {type(payload).__name__}")
281
+
282
+
283
+ __all__ = ["FloorplansAPI"]
@@ -0,0 +1,99 @@
1
+ """Groups resource sub-client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .._envelope import unwrap_resource, unwrap_resource_single
8
+ from ..compounds import groups as _compound
9
+ from ..models import BulkResult, Group
10
+ from ._base import ResourceAPI
11
+
12
+
13
+ class GroupsAPI(ResourceAPI):
14
+ """List, create, update, and delete trackable groups.
15
+
16
+ ``list`` is defined last (class-scope shadow workaround for mypy).
17
+ M4 adds compound ``delete`` (cascade-detach) and ``add_tags`` /
18
+ ``remove_tags`` helpers.
19
+ """
20
+
21
+ def create(self, name: str, **fields: Any) -> Group:
22
+ """Create a new group (single REST call)."""
23
+ body: dict[str, Any] = {"name": name, **fields}
24
+ response = self._post("/api/v2/groups", json=body)
25
+ payload = response.json()
26
+ try:
27
+ item = unwrap_resource_single(payload, "group")
28
+ except ValueError:
29
+ item = payload if isinstance(payload, dict) else {}
30
+ return Group.from_wire(item)
31
+
32
+ def update(self, uid: str, **fields: Any) -> Group:
33
+ """Update a group's metadata (single REST call)."""
34
+ response = self._put(f"/api/v2/groups/{uid}", json=fields)
35
+ payload = response.json()
36
+ try:
37
+ item = unwrap_resource_single(payload, "group")
38
+ except ValueError:
39
+ item = payload if isinstance(payload, dict) else {}
40
+ return Group.from_wire(item)
41
+
42
+ def delete_raw(self, uid: str) -> None:
43
+ """Delete a group (single REST call, no trackable-detach).
44
+
45
+ **Advanced.** :meth:`delete` (added in M4) first removes the
46
+ group from every trackable that referenced it. Calling
47
+ ``delete_raw`` on a group with members leaves dangling references
48
+ — only use it if you've already detached or know the server
49
+ cascades server-side.
50
+ """
51
+ self._delete(f"/api/v2/groups/{uid}")
52
+
53
+ def delete(self, uid: str) -> None:
54
+ """Delete a group, first detaching every trackable that referenced it.
55
+
56
+ The server has no "tags in group X" endpoint per the JS reference
57
+ client (RESEARCH §4 "Delete group"), so the SDK lists trackables
58
+ and filters client-side, then calls ``replace_groups`` on each
59
+ member before the final ``DELETE /groups/{uid}``.
60
+
61
+ Step names: ``load_member_tags``, ``detach_from_tag``,
62
+ ``delete_group``.
63
+
64
+ Detach failures do not abort the cascade — the group is still
65
+ deleted afterward. If any tags couldn't be detached, the SDK
66
+ raises :class:`PartialFailureError` AFTER the group delete
67
+ succeeded, with the dangling tag uids in ``partial_result``.
68
+ """
69
+ _compound.delete_group(self._client, uid)
70
+
71
+ def add_tags(self, group_uid: str, tag_uids: list[str]) -> BulkResult[None]:
72
+ """Add a group to each tag's membership, continue-on-error.
73
+
74
+ For each tag the SDK loads its current group list, appends
75
+ ``group_uid`` (no-op if already present), and writes the new
76
+ list. Failures land in ``BulkResult.failures``.
77
+ """
78
+ return _compound.add_tags(self._client, group_uid, tag_uids)
79
+
80
+ def remove_tags(self, group_uid: str, tag_uids: list[str]) -> BulkResult[None]:
81
+ """Remove a group from each tag's membership, continue-on-error."""
82
+ return _compound.remove_tags(self._client, group_uid, tag_uids)
83
+
84
+ def list(self) -> list[Group]:
85
+ """Return all groups for the current project.
86
+
87
+ Examples
88
+ --------
89
+ >>> groups = client.groups.list()
90
+ >>> {g.name: g.uid for g in groups}
91
+ {'Fleet': 'g-fleet', 'Loading bay': 'g-loading'}
92
+ """
93
+ response = self._get("/api/v2/groups/")
94
+ payload = response.json()
95
+ items = unwrap_resource(payload, "groups")
96
+ return [Group.from_wire(item) for item in items]
97
+
98
+
99
+ __all__ = ["GroupsAPI"]
@@ -0,0 +1,40 @@
1
+ """Logger resource sub-client (admin).
2
+
3
+ Admin-only operations for tuning the server's logging filter at runtime.
4
+ Most SDK consumers will never need this — included for parity with the
5
+ JS reference client.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from ._base import ResourceAPI
13
+
14
+
15
+ class LoggerAPI(ResourceAPI):
16
+ """Get / set / clear the server's runtime logger filter (admin).
17
+
18
+ The JS reference client treats this endpoint as no-auth-required;
19
+ the SDK relies on the configured client identity. Filters are
20
+ arbitrary key/value pairs whose semantics live server-side.
21
+ """
22
+
23
+ def get_filter(self) -> dict[str, Any]:
24
+ """Read the current server logger filter."""
25
+ response = self._get("/api/v2/logger/")
26
+ payload = response.json() if response.content else None
27
+ return payload if isinstance(payload, dict) else {"data": payload}
28
+
29
+ def set_filter(self, filters: dict[str, Any]) -> dict[str, Any]:
30
+ """Apply / merge a new server logger filter."""
31
+ response = self._post("/api/v2/logger/", json=filters)
32
+ payload = response.json() if response.content else None
33
+ return payload if isinstance(payload, dict) else {"data": payload}
34
+
35
+ def clear_filter(self) -> None:
36
+ """Clear the current server logger filter."""
37
+ self._delete("/api/v2/logger/")
38
+
39
+
40
+ __all__ = ["LoggerAPI"]
@@ -0,0 +1,51 @@
1
+ """Messaging resource sub-client.
2
+
3
+ The wire endpoint is ``POST /api/v2/messaging`` (RESEARCH Discrepancy
4
+ #15) — distinct from the deprecated ``/api/v2/win_message_que/create.json``
5
+ the JS client still defines but doesn't call. The SDK exposes only the
6
+ live endpoint.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from ._base import ResourceAPI
14
+
15
+
16
+ class MessagingAPI(ResourceAPI):
17
+ """Send messages to trackable badges / users."""
18
+
19
+ def send(self, *, from_uid: str, to_uids: list[str], body: str) -> dict[str, Any]:
20
+ """Send a message addressed to one or more recipients.
21
+
22
+ Parameters
23
+ ----------
24
+ from_uid
25
+ The sender's uid (typically the current user).
26
+ to_uids
27
+ Recipient uids — trackables, users, or device uids depending
28
+ on what your deployment routes via messaging.
29
+ body
30
+ The message text.
31
+
32
+ Returns
33
+ -------
34
+ dict
35
+ The server response. M3 surfaces it as a raw dict because
36
+ the wire shape isn't documented stably (RESEARCH §1 only
37
+ says "messages: []"); a typed model can be added later
38
+ without breaking callers that already treat the return as
39
+ opaque.
40
+ """
41
+ payload = {
42
+ "from": {"uid": from_uid},
43
+ "to": {"uids": to_uids},
44
+ "body": body,
45
+ }
46
+ response = self._post("/api/v2/messaging", json=payload)
47
+ parsed = response.json() if response.content else None
48
+ return parsed if isinstance(parsed, dict) else {"data": parsed}
49
+
50
+
51
+ __all__ = ["MessagingAPI"]