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,125 @@
1
+ """System / host resource sub-client.
2
+
3
+ These endpoints expose server-health diagnostics. They share an
4
+ ``RtlsClient`` with the rest of the SDK but use simple object responses
5
+ (no envelope wrapping).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from ..compounds import system as _compound
13
+ from ..models import Connection, Host, SystemHealth, Uptime, Version, WsHost
14
+ from ._base import ResourceAPI
15
+
16
+
17
+ class SystemAPI(ResourceAPI):
18
+ """Host info, uptime, connections, and server version metadata.
19
+
20
+ The aggregated :meth:`health` view (fan-out across host + uptime +
21
+ connections) is implemented in M5; M2 only exposes the individual
22
+ accessors.
23
+ """
24
+
25
+ def host(self) -> Host:
26
+ """Return the server's hostname + status snapshot."""
27
+ response = self._get("/api/v2/host")
28
+ return Host.from_wire(_as_dict(response.json()))
29
+
30
+ def uptime(self) -> list[Uptime]:
31
+ """Return per-service uptime rows."""
32
+ response = self._get("/api/v2/host/uptime")
33
+ return [Uptime.from_wire(_as_dict(item)) for item in _as_list(response.json())]
34
+
35
+ def connections(self) -> list[Connection]:
36
+ """Return active server connections."""
37
+ response = self._get("/api/v2/host/connections")
38
+ return [Connection.from_wire(_as_dict(item)) for item in _as_list(response.json())]
39
+
40
+ def version(self) -> Version:
41
+ """Return server version info from ``GET /version``.
42
+
43
+ Note: ``/version`` is one of the few endpoints not under the
44
+ ``/api/v2/`` prefix.
45
+ """
46
+ response = self._get("/version")
47
+ return Version.from_wire(_as_dict(response.json()))
48
+
49
+ def beta_version(self) -> Version:
50
+ """Return the beta-channel version info."""
51
+ response = self._get("/api/v2/beta/version")
52
+ return Version.from_wire(_as_dict(response.json()))
53
+
54
+ def health(self) -> SystemHealth:
55
+ """Aggregate host + uptime + connections in one snapshot.
56
+
57
+ Each sub-call is independent: a failure on one sets that field
58
+ to ``None`` and records the exception in
59
+ ``SystemHealth.errors``, leaving the rest of the snapshot
60
+ intact. ``partial=True`` when any sub-call failed.
61
+
62
+ Sequential in v1 (sync SDK); a v2 async surface would fan these
63
+ out in parallel.
64
+ """
65
+ return _compound.collect_system_health(self._client)
66
+
67
+ def ws_host(self) -> WsHost:
68
+ """Return the WebSocket hostname the server expects clients to dial.
69
+
70
+ This is the single REST hop needed before opening a WS connection
71
+ (deferred to v2).
72
+ """
73
+ response = self._get("/api/v2/wshost/")
74
+ return WsHost.from_wire(_as_dict(response.json()))
75
+
76
+ def helper_link(self) -> str | None:
77
+ """URL of the help portal (deployment-specific).
78
+
79
+ Returned by ``GET /info/helper``. Body shape is loose across
80
+ deployments; this method surfaces whichever URL-shaped string
81
+ the server returned, or ``None`` if absent. Used by
82
+ :meth:`ContextAPI.load` (M6).
83
+ """
84
+ response = self._get("/api/v2/info/helper")
85
+ return _extract_url(response.json())
86
+
87
+ def monitor_link(self) -> str | None:
88
+ """URL of the site-monitor portal (deployment-specific).
89
+
90
+ Returned by ``GET /info/site_monitor``. Same loose-shape
91
+ conventions as :meth:`helper_link`.
92
+ """
93
+ response = self._get("/api/v2/info/site_monitor")
94
+ return _extract_url(response.json())
95
+
96
+
97
+ def _extract_url(payload: Any) -> str | None:
98
+ """Pick a URL-shaped string out of a loose info-endpoint payload."""
99
+ if isinstance(payload, str):
100
+ return payload
101
+ if isinstance(payload, dict):
102
+ for key in ("url", "link", "href", "value", "data"):
103
+ value = payload.get(key)
104
+ if isinstance(value, str) and value:
105
+ return value
106
+ return None
107
+
108
+
109
+ def _as_dict(payload: Any) -> dict[str, Any]:
110
+ if isinstance(payload, dict):
111
+ return payload
112
+ return {"data": payload}
113
+
114
+
115
+ def _as_list(payload: Any) -> list[Any]:
116
+ if isinstance(payload, list):
117
+ return list(payload)
118
+ if isinstance(payload, dict):
119
+ for key in ("uptime", "connections", "data"):
120
+ if key in payload and isinstance(payload[key], list):
121
+ return list(payload[key])
122
+ return []
123
+
124
+
125
+ __all__ = ["SystemAPI"]
@@ -0,0 +1,370 @@
1
+ """Trackable / tag 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 tags as _compound
11
+ from ..compounds.tags import UNSET, _Unset
12
+ from ..errors import NotFound
13
+ from ..models import BulkResult, Position, Tag, TagTemplate
14
+ from ._base import ResourceAPI
15
+
16
+
17
+ class TagsAPI(ResourceAPI):
18
+ """List, get, and (later) mutate trackables.
19
+
20
+ The wire name for trackables is ``trackable_objects`` — exposed as
21
+ ``tags`` here because "tag" is what operators actually call them.
22
+
23
+ Note on method order: ``list`` is defined last in this class so that
24
+ mypy resolves ``list[Tag]`` annotations in other methods to the
25
+ builtin ``list`` and not to ``TagsAPI.list``. (Python class-scope
26
+ shadowing limitation, even with PEP-563 deferred annotations.)
27
+ """
28
+
29
+ def get(self, uid: str) -> Tag:
30
+ """Fetch a single trackable by uid.
31
+
32
+ The server returns ``{trackableObjects: [t]}`` even for single
33
+ gets — a one-element list wrapper. The SDK unwraps it. An empty
34
+ wrapper (meaning the uid isn't valid) raises :class:`NotFound`.
35
+ """
36
+ response = self._get(f"/api/v2/trackable_objects/{uid}")
37
+ payload = response.json()
38
+ try:
39
+ item = unwrap_resource_single(payload, "trackableObjects")
40
+ except ValueError as exc:
41
+ raise NotFound(
42
+ f"trackable {uid!r} not found",
43
+ status_code=response.status_code,
44
+ request_url=str(response.request.url),
45
+ request_method=response.request.method,
46
+ ) from exc
47
+ return Tag.from_wire(item)
48
+
49
+ def list_active_for_period(
50
+ self,
51
+ start: datetime,
52
+ end: datetime,
53
+ *,
54
+ sublocation_uid: str,
55
+ ) -> list[Tag]:
56
+ """Trackables active in ``[start, end]`` inside one sublocation.
57
+
58
+ Timestamps go on the wire as epoch milliseconds.
59
+ """
60
+ params = {
61
+ "sublocation_uid": sublocation_uid,
62
+ "from": str(to_epoch_ms(start, arg_name="start")),
63
+ "to": str(to_epoch_ms(end, arg_name="end")),
64
+ }
65
+ response = self._get("/api/v2/trackable_objects/active_for_period", params=params)
66
+ items = unwrap_resource(response.json(), "trackableObjects")
67
+ return [Tag.from_wire(item) for item in items]
68
+
69
+ def list_existed_for_period(self, start: datetime, end: datetime) -> list[Tag]:
70
+ """Trackables that existed at any point in ``[start, end]``."""
71
+ params = {
72
+ "from": str(to_epoch_ms(start, arg_name="start")),
73
+ "to": str(to_epoch_ms(end, arg_name="end")),
74
+ }
75
+ response = self._get("/api/v2/trackable_objects/existed_for_period", params=params)
76
+ items = unwrap_resource(response.json(), "trackableObjects")
77
+ return [Tag.from_wire(item) for item in items]
78
+
79
+ def list_templates(self) -> list[TagTemplate]:
80
+ """Return all tag-creation templates available on the server.
81
+
82
+ Server-side templates are pre-baked configurations operators reuse
83
+ when creating new trackables.
84
+ """
85
+ response = self._get("/api/v2/trackable_objects/templates")
86
+ payload = response.json()
87
+ # The wire envelope key is ``trackable_templates`` (snake_case)
88
+ # rather than the camelCase used by ``trackableObjects``.
89
+ items = unwrap_resource(payload, "trackable_templates")
90
+ return [TagTemplate.from_wire(item) for item in items]
91
+
92
+ def last_position(self, uid: str) -> Position | None:
93
+ """Return the last cached position for a trackable, if known.
94
+
95
+ The response envelope is ``{trackablePositionFromState}`` — a
96
+ single position object, not a list (RESEARCH §3). Returns
97
+ ``None`` if the server has no cached position.
98
+ """
99
+ response = self._get(f"/api/v2/trackable_objects/position_from_state/{uid}")
100
+ payload = response.json()
101
+ if not isinstance(payload, dict):
102
+ return None
103
+ for key in ("trackablePositionFromState", "trackable_position_from_state"):
104
+ if key in payload and isinstance(payload[key], dict):
105
+ return Position.from_wire(payload[key])
106
+ # Some deployments return the position object directly.
107
+ if "x" in payload or "ts" in payload:
108
+ return Position.from_wire(payload)
109
+ return None
110
+
111
+ def create_raw(self, name: str, **fields: Any) -> Tag:
112
+ """Create a tag (single REST call, no zone / group plumbing).
113
+
114
+ **Advanced.** Most users want :meth:`create` (added in M4) which
115
+ also handles attached-zone and group binding automatically.
116
+
117
+ Parameters
118
+ ----------
119
+ name
120
+ Required. The tag's name.
121
+ **fields
122
+ Any other field the server's ``createTrackObject`` schema
123
+ accepts: ``display_name``, ``description``, ``type``,
124
+ ``category``, ``position_update_interval``, ``self_zone_uid``,
125
+ ``buffer``, ``tag_config``, ``acc_config``, ``bt_config``,
126
+ ``sensor_config``, ``user_params``, ``custom_info``,
127
+ ``t2t_no_alarm_uid``. Unknown fields are stripped silently
128
+ by the server's Joi default.
129
+
130
+ Notes
131
+ -----
132
+ **Tags do NOT carry a `mac_address`.** The MAC belongs to a
133
+ :class:`Node` (the hardware), bound to a tag via an
134
+ :class:`Association` (`trackable_objects_associations`). Passing
135
+ ``mac_address=`` here is a silent no-op — the server drops it.
136
+ If your workflow is "I have a MAC and want a tag bound to it,"
137
+ call :meth:`NodesAPI.create` + :meth:`AssociationsAPI.create`
138
+ in sequence. A compound ``tags.create_with_node`` that wraps
139
+ the saga is planned for a future milestone.
140
+
141
+ Returns
142
+ -------
143
+ Tag
144
+ The created tag as returned by the server.
145
+ """
146
+ body: dict[str, Any] = {"name": name, **fields}
147
+ response = self._post("/api/v2/trackable_objects", json=body)
148
+ payload = response.json()
149
+ try:
150
+ item = unwrap_resource_single(payload, "trackableObjects")
151
+ except ValueError:
152
+ item = payload if isinstance(payload, dict) else {}
153
+ return Tag.from_wire(item)
154
+
155
+ def update_raw(self, uid: str, **fields: Any) -> Tag:
156
+ """Update a trackable (single REST call).
157
+
158
+ **Advanced.** Most users want :meth:`update` (added in M4) which
159
+ diff-applies zone/group changes alongside the field updates.
160
+ """
161
+ response = self._put(f"/api/v2/trackable_objects/{uid}", json=fields)
162
+ payload = response.json()
163
+ try:
164
+ item = unwrap_resource_single(payload, "trackableObjects")
165
+ except ValueError:
166
+ item = payload if isinstance(payload, dict) else {}
167
+ return Tag.from_wire(item)
168
+
169
+ def delete_raw(self, uid: str) -> None:
170
+ """Delete a trackable (single REST call, no zone-cascade).
171
+
172
+ **Advanced.** Most users want :meth:`delete` (added in M4) which
173
+ also deletes the trackable's attached zone if any.
174
+ """
175
+ self._delete(f"/api/v2/trackable_objects/{uid}")
176
+
177
+ def replace_groups(self, uid: str, group_uids: list[str], *, no_notify: bool = True) -> None:
178
+ """Replace the set of groups a trackable belongs to.
179
+
180
+ Two server paths exist:
181
+
182
+ - ``PUT /trackable_objects/{uid}/groups`` with a bare-array body
183
+ (per RESEARCH §5 quirk #15 and the JS reference client).
184
+ - ``PUT /trackable_objects/{uid}`` with ``{"groups": [...]}`` in
185
+ the body (the standard tag-update endpoint).
186
+
187
+ The live test server treats the dedicated ``/groups`` endpoint
188
+ as a silent no-op (returns 204, doesn't persist) — only the
189
+ tag-update path actually writes membership. The SDK uses the
190
+ tag-update path because it's the one that works.
191
+
192
+ ``no_notify`` is accepted for API stability but ignored on the
193
+ tag-update path; the server doesn't broadcast a group-change
194
+ event from there.
195
+ """
196
+ del no_notify # kept for signature stability; no analog on the working path
197
+ self._put(f"/api/v2/trackable_objects/{uid}", json={"groups": group_uids})
198
+
199
+ def create(
200
+ self,
201
+ name: str,
202
+ *,
203
+ attached_zone: dict[str, Any] | None = None,
204
+ groups: list[str] | None = None,
205
+ **fields: Any,
206
+ ) -> Tag:
207
+ """Create a tag, optionally attaching a zone and group memberships.
208
+
209
+ One call collapses up to four REST round-trips (create tag,
210
+ create bound zone, replace groups, update tag with
211
+ ``self_zone_uid``). On any sub-step failure the SDK attempts to
212
+ roll back the created tag and zone; if a rollback fails, the
213
+ raised :class:`PartialFailureError` carries the orphaned uid in
214
+ its ``partial_result`` so the caller can clean up.
215
+
216
+ Step names (for ``PartialFailureError.failed_step``):
217
+ ``create_tag``, ``create_zone``, ``replace_groups``,
218
+ ``update_tag_with_zone_uid``.
219
+
220
+ Parameters
221
+ ----------
222
+ name
223
+ Required tag name.
224
+ attached_zone
225
+ Optional zone spec dict — ``shape``, geometry, etc. The SDK
226
+ stamps ``trackable_object_uid`` and ``dynamic=True`` onto
227
+ it automatically.
228
+ groups
229
+ Optional list of group uids the tag should belong to.
230
+ **fields
231
+ Other tag fields the ``createTrackObject`` schema accepts
232
+ (``display_name``, ``type``, ``category``, etc.). **Not
233
+ `mac_address`** — see Notes below.
234
+
235
+ Notes
236
+ -----
237
+ Tags do NOT carry a ``mac_address``. The MAC belongs to a
238
+ :class:`Node`, bound via an :class:`Association`. See the
239
+ Notes on :meth:`create_raw` for how to bind hardware to a tag.
240
+
241
+ Examples
242
+ --------
243
+ >>> tag = client.tags.create(
244
+ ... name="Forklift 7",
245
+ ... attached_zone={"shape": "circle", "geometry": {"radius": 3000}},
246
+ ... groups=["g-fleet"],
247
+ ... )
248
+ """
249
+ return _compound.create_tag(
250
+ self._client,
251
+ name,
252
+ attached_zone=attached_zone,
253
+ groups=groups,
254
+ **fields,
255
+ )
256
+
257
+ def update(
258
+ self,
259
+ uid: str,
260
+ *,
261
+ attached_zone: dict[str, Any] | None | _Unset = UNSET,
262
+ groups: list[str] | None | _Unset = UNSET,
263
+ **fields: Any,
264
+ ) -> Tag:
265
+ """Diff-driven update of a tag's fields, attached zone, and groups.
266
+
267
+ ``attached_zone`` / ``groups`` accept three forms:
268
+
269
+ - default (``...`` — sentinel): leave unchanged.
270
+ - ``None``: remove (delete bound zone / clear groups).
271
+ - dict / list: set or replace.
272
+
273
+ Step names: ``load_current_tag``, ``delete_attached_zone``,
274
+ ``create_attached_zone``, ``update_attached_zone``,
275
+ ``replace_groups``, ``update_tag``.
276
+
277
+ Unlike :meth:`create`, partial updates cannot be unwound
278
+ cleanly. On failure the SDK raises :class:`PartialFailureError`
279
+ listing the ``completed_steps`` so the caller can reconcile.
280
+ """
281
+ return _compound.update_tag(
282
+ self._client,
283
+ uid,
284
+ attached_zone=attached_zone,
285
+ groups=groups,
286
+ **fields,
287
+ )
288
+
289
+ def delete(self, target: Tag | str | list[Tag | str]) -> BulkResult[None] | None:
290
+ """Delete one or many tags, cascading to attached zones.
291
+
292
+ - Single uid (``str``) or :class:`Tag`: returns ``None``, raises
293
+ :class:`PartialFailureError` on failure.
294
+ - List: returns :class:`BulkResult` with per-item errors; an
295
+ error on one row does NOT abort the others.
296
+
297
+ Passing :class:`Tag` instances skips the per-tag GET that would
298
+ otherwise be needed to discover ``self_zone_uid``.
299
+
300
+ Step names (single-target variant): ``load_tag``,
301
+ ``delete_attached_zone``, ``delete_tag``.
302
+ """
303
+ if isinstance(target, list):
304
+ return _compound.bulk_delete_tags(self._client, target)
305
+ _compound.delete_tag(self._client, target)
306
+ return None
307
+
308
+ def bulk_update(self, tag_uids: list[str], changes: dict[str, Any]) -> BulkResult[Tag]:
309
+ """Apply the same updates to many tags, continue-on-error.
310
+
311
+ Each tag is updated via :meth:`update` (so ``attached_zone`` and
312
+ ``groups`` inside ``changes`` get the same diff semantics as the
313
+ single-tag form). Failures land in ``BulkResult.failures``.
314
+ """
315
+ return _compound.bulk_update_tags(self._client, tag_uids, dict(changes))
316
+
317
+ def list_active_in_period(
318
+ self,
319
+ sublocation_uid: str,
320
+ start: datetime,
321
+ end: datetime,
322
+ ) -> list[Tag]:
323
+ """Tags active in ``[start, end]`` inside one sublocation.
324
+
325
+ Inner-joins ``trackable_objects/active_for_period`` with the
326
+ active-association list — the union the JS reference client
327
+ manually composes (RESEARCH §4 "Load trackables with active
328
+ associations for a period").
329
+ """
330
+ return _compound.list_active_in_period(self._client, sublocation_uid, start, end)
331
+
332
+ def list(self, *, include_removed: bool = False, company_scope: bool = False) -> list[Tag]:
333
+ """Return active trackables in the current project scope.
334
+
335
+ Parameters
336
+ ----------
337
+ include_removed
338
+ Include soft-deleted trackables. Defaults to ``False``.
339
+ company_scope
340
+ If ``True``, list across the whole company instead of the
341
+ currently-scoped project.
342
+
343
+ Returns
344
+ -------
345
+ list[Tag]
346
+ Possibly empty.
347
+
348
+ Examples
349
+ --------
350
+ >>> tags = client.tags.list()
351
+ >>> [t.name for t in tags]
352
+ ['Forklift 7', 'Pallet jack 12']
353
+ """
354
+ if company_scope:
355
+ path = "/api/v2/trackable_objects/all"
356
+ params = {"include_removed": "true"} if include_removed else None
357
+ elif include_removed:
358
+ path = "/api/v2/trackable_objects/all"
359
+ params = {"include_removed": "true"}
360
+ else:
361
+ path = "/api/v2/trackable_objects"
362
+ params = None
363
+
364
+ response = self._get(path, params=params)
365
+ payload = response.json()
366
+ items = unwrap_resource(payload, "trackableObjects")
367
+ return [Tag.from_wire(item) for item in items]
368
+
369
+
370
+ __all__ = ["TagsAPI"]