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,159 @@
1
+ """Session-bootstrap compound — DESIGN §4.2.
2
+
3
+ ``collect_session_context(client, scope=...)`` fans out the ~20 sub-calls
4
+ that hydrate the deployment view used by every operator UI. Sequential
5
+ in v1 (sync SDK); a v2 async surface would parallelize.
6
+
7
+ Continue-on-error: per-sub-call ``RtlsError`` lands on
8
+ ``SessionContext.errors`` keyed by stable sub-resource name. Transport
9
+ failures (``ConnectionError`` and other non-``RtlsError`` exceptions)
10
+ propagate — there's no point assembling a partial snapshot if the
11
+ network is down.
12
+
13
+ Conditional sub-resources (handled specially):
14
+
15
+ - **anchors**: module may not be installed. ``NotFound`` /
16
+ ``PermissionDenied`` on that endpoint is treated as "feature absent",
17
+ NOT as an error — anchors stays empty and the error is not recorded.
18
+ - **nodes**: must be fetched at company-scope (RESEARCH §4 "Load all
19
+ entities for current scope"). The SDK uses ``_call_with_scope(
20
+ company_uid=..., clear_project=True)`` to strip ``X-User-Project``
21
+ and add ``X-User-Subcontractor`` for that one call only.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from collections.abc import Callable
28
+ from datetime import datetime, timezone
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ from ..errors import (
32
+ ConnectionError as RtlsConnectionError,
33
+ )
34
+ from ..errors import (
35
+ NotFound,
36
+ PermissionDenied,
37
+ RtlsError,
38
+ )
39
+ from ..models import SessionContext
40
+
41
+ if TYPE_CHECKING:
42
+ from .._client import RtlsClient
43
+
44
+
45
+ logger = logging.getLogger("rtls_sdk.compounds.context")
46
+
47
+
48
+ # Sub-resources whose 404 / 403 is treated as "module absent", not as
49
+ # a hard error. Currently just anchors; documented stably so callers
50
+ # can rely on the absence-of-an-errors-entry signal.
51
+ _SOFT_OPTIONAL: frozenset[str] = frozenset({"anchors"})
52
+
53
+
54
+ def collect_session_context(client: RtlsClient, *, scope: str | None = None) -> SessionContext:
55
+ """Hydrate a :class:`SessionContext` under the active project scope.
56
+
57
+ Only ``scope="project"`` (or ``None``, equivalent) is implemented in
58
+ v1. ``"company"`` / ``"system"`` variants raise
59
+ :class:`NotImplementedError`.
60
+ """
61
+ if scope not in (None, "project"):
62
+ raise NotImplementedError(
63
+ f"context.load(scope={scope!r}) is not implemented in v1. "
64
+ "Only the default project scope is supported; company / "
65
+ "system bundles are a v2 candidate (DESIGN §9 R4)."
66
+ )
67
+
68
+ result = SessionContext()
69
+
70
+ # Each sub-resource: (name, fetcher, setter).
71
+ # The setter writes onto ``result``; we keep it explicit so mypy
72
+ # doesn't need to verify a getattr-based generic assignment.
73
+ sub_resources: list[tuple[str, Callable[[], Any], Callable[[SessionContext, Any], None]]] = [
74
+ ("sites", client.sites.list, _setter("sites")),
75
+ ("areas", client.areas.list, _setter("areas")),
76
+ ("floorplans", client.floorplans.list, _setter("floorplans")),
77
+ ("tags", client.tags.list, _setter("tags")),
78
+ ("associations", client.associations.list, _setter("associations")),
79
+ ("zones", client.zones.list, _setter("zones")),
80
+ (
81
+ "zone_prototypes",
82
+ client.zones.list_custom_prototypes,
83
+ _setter("zone_prototypes"),
84
+ ),
85
+ ("groups", client.groups.list, _setter("groups")),
86
+ ("alarms", client.alarms.list_active, _setter("alarms")),
87
+ ("notifications", client.notifications.list, _setter("notifications")),
88
+ (
89
+ "notification_types",
90
+ client.notifications.list_types,
91
+ _setter("notification_types"),
92
+ ),
93
+ ("subscribers", client.subscribers.list, _setter("subscribers")),
94
+ (
95
+ "system_subscribers",
96
+ client.subscribers.list_system_notification_subscribers,
97
+ _setter("system_subscribers"),
98
+ ),
99
+ ("users", client.users.list, _setter("users")),
100
+ ("user_roles", client.users.list_roles, _setter("user_roles")),
101
+ ("tag_templates", client.tags.list_templates, _setter("tag_templates")),
102
+ ("helper_link", client.system.helper_link, _setter("helper_link")),
103
+ ("monitor_link", client.system.monitor_link, _setter("monitor_link")),
104
+ ]
105
+
106
+ for name, fetcher, setter in sub_resources:
107
+ try:
108
+ value = fetcher()
109
+ except RtlsConnectionError:
110
+ # Transport-level failure (DNS, refused, timeout) — propagate.
111
+ # There's no point assembling a partial snapshot when the
112
+ # network is down (DESIGN §4.2).
113
+ raise
114
+ except RtlsError as exc:
115
+ logger.warning("context.load: %s failed: %s", name, exc)
116
+ result.errors[name] = exc
117
+ continue
118
+ setter(result, value)
119
+
120
+ # -- anchors (optional module) -----------------------------------
121
+ try:
122
+ result.anchors = client.anchors.list()
123
+ except (NotFound, PermissionDenied):
124
+ # Module not installed on this deployment — silent skip.
125
+ # Crucially we do NOT add this to result.errors; callers use
126
+ # the absence of an "anchors" entry as the "module not present"
127
+ # signal (DESIGN §4.2 / RESEARCH §4 "Load all entities").
128
+ logger.debug("context.load: anchors module not present, skipped")
129
+ except RtlsConnectionError:
130
+ raise
131
+ except RtlsError as exc:
132
+ logger.warning("context.load: anchors failed: %s", exc)
133
+ result.errors["anchors"] = exc
134
+
135
+ # -- nodes (cross-scope: company, not project) -------------------
136
+ company_uid = client._auth_state.company_uid
137
+ try:
138
+ with client._call_with_scope(company_uid=company_uid or "", clear_project=True):
139
+ result.nodes = client.nodes.list()
140
+ except RtlsConnectionError:
141
+ raise
142
+ except RtlsError as exc:
143
+ logger.warning("context.load: nodes failed: %s", exc)
144
+ result.errors["nodes"] = exc
145
+
146
+ # Stamp completion time and the partial flag.
147
+ result.loaded_at = datetime.now(timezone.utc)
148
+ result.partial = bool(result.errors)
149
+ return result
150
+
151
+
152
+ def _setter(field_name: str) -> Callable[[SessionContext, Any], None]:
153
+ def assign(ctx: SessionContext, value: Any) -> None:
154
+ setattr(ctx, field_name, value)
155
+
156
+ return assign
157
+
158
+
159
+ __all__ = ["collect_session_context"]
@@ -0,0 +1,126 @@
1
+ """Compound group workflows.
2
+
3
+ Step-name conventions:
4
+
5
+ - ``delete``: ``load_member_tags``, ``detach_from_tag``, ``delete_group``.
6
+ - ``add_tags`` / ``remove_tags``: per-tag continue-on-error; no shared
7
+ step-names — the BulkResult carries per-row errors.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from typing import TYPE_CHECKING
14
+
15
+ from ..errors import PartialFailureError, RtlsError
16
+ from ..models import BulkFailure, BulkResult
17
+
18
+ if TYPE_CHECKING:
19
+ from .._client import RtlsClient
20
+
21
+
22
+ logger = logging.getLogger("rtls_sdk.compounds.groups")
23
+
24
+
25
+ def delete_group(client: RtlsClient, uid: str) -> None:
26
+ """Delete a group, detaching its trackable memberships first.
27
+
28
+ The server has no dedicated "tags in group X" endpoint per the JS
29
+ reference client (RESEARCH §4 "Delete group") — the SDK lists all
30
+ tags and filters client-side. For each member, ``replace_groups`` is
31
+ called with the group removed.
32
+
33
+ Detach failures are aggregated but do NOT abort the cascade — the
34
+ group is still deleted afterwards. The exception's ``partial_result``
35
+ names every tag that still carries a stale reference, so the caller
36
+ can clean up.
37
+ """
38
+ completed: list[str] = []
39
+
40
+ # Step 1: find member trackables.
41
+ try:
42
+ all_tags = client.tags.list()
43
+ except RtlsError as exc:
44
+ raise PartialFailureError(
45
+ f"groups.delete: load_member_tags failed: {exc}",
46
+ completed_steps=completed,
47
+ failed_step="load_member_tags",
48
+ cause=exc,
49
+ ) from exc
50
+ completed.append("load_member_tags")
51
+ members = [tag for tag in all_tags if uid in tag.groups]
52
+
53
+ # Step 2: detach each member from this group.
54
+ stale: list[str] = []
55
+ for tag in members:
56
+ new_groups = [g for g in tag.groups if g != uid]
57
+ try:
58
+ client.tags.replace_groups(tag.uid, new_groups, no_notify=True)
59
+ except RtlsError as exc:
60
+ logger.warning("groups.delete: detach from tag %s failed: %s", tag.uid, exc)
61
+ stale.append(tag.uid)
62
+
63
+ # Step 3: delete the group itself.
64
+ try:
65
+ client.groups.delete_raw(uid)
66
+ except RtlsError as exc:
67
+ raise PartialFailureError(
68
+ f"groups.delete: delete_group failed: {exc}",
69
+ completed_steps=completed,
70
+ failed_step="delete_group",
71
+ cause=exc,
72
+ partial_result={"group_uid": uid, "stale_members": stale},
73
+ ) from exc
74
+
75
+ if stale:
76
+ # We succeeded in deleting the group, but some members couldn't
77
+ # be detached cleanly — surface that as a soft failure so the
78
+ # caller can reconcile.
79
+ raise PartialFailureError(
80
+ f"groups.delete: deleted group {uid!r} but {len(stale)} tag(s) "
81
+ f"still carry a stale group reference",
82
+ completed_steps=[*completed, "delete_group"],
83
+ failed_step="detach_from_tag",
84
+ cause=RtlsError(f"stale references on tags: {stale}"),
85
+ partial_result={"group_uid": uid, "stale_members": stale},
86
+ )
87
+
88
+
89
+ def add_tags(client: RtlsClient, group_uid: str, tag_uids: list[str]) -> BulkResult[None]:
90
+ """Add a group to each tag's membership list, continue-on-error.
91
+
92
+ Per-tag: load → append → ``replace_groups``. Already-member tags are
93
+ no-ops (the result counts them as succeeded).
94
+ """
95
+ result: BulkResult[None] = BulkResult()
96
+ for tag_uid in tag_uids:
97
+ try:
98
+ tag = client.tags.get(tag_uid)
99
+ if group_uid in tag.groups:
100
+ result.succeeded.append(None)
101
+ continue
102
+ client.tags.replace_groups(tag_uid, [*tag.groups, group_uid], no_notify=True)
103
+ result.succeeded.append(None)
104
+ except RtlsError as exc:
105
+ result.failures.append(BulkFailure(error=exc, input=tag_uid, uid=tag_uid))
106
+ return result
107
+
108
+
109
+ def remove_tags(client: RtlsClient, group_uid: str, tag_uids: list[str]) -> BulkResult[None]:
110
+ """Remove a group from each tag's membership list, continue-on-error."""
111
+ result: BulkResult[None] = BulkResult()
112
+ for tag_uid in tag_uids:
113
+ try:
114
+ tag = client.tags.get(tag_uid)
115
+ if group_uid not in tag.groups:
116
+ result.succeeded.append(None)
117
+ continue
118
+ new_groups = [g for g in tag.groups if g != group_uid]
119
+ client.tags.replace_groups(tag_uid, new_groups, no_notify=True)
120
+ result.succeeded.append(None)
121
+ except RtlsError as exc:
122
+ result.failures.append(BulkFailure(error=exc, input=tag_uid, uid=tag_uid))
123
+ return result
124
+
125
+
126
+ __all__ = ["add_tags", "delete_group", "remove_tags"]
@@ -0,0 +1,176 @@
1
+ """Compound node workflows.
2
+
3
+ Step-name conventions (stable, part of the API contract):
4
+
5
+ - ``release``: ``close_association``, ``release_node``.
6
+ - ``import_``: per-row failures land in ``ImportResult.failures``; no
7
+ shared step names.
8
+ - ``delete``: per-row failures land in ``BulkResult.failures``.
9
+
10
+ The headline behaviour win for M5: ``release`` uses per-call scope
11
+ override (``client._call_with_scope``) so it can target a project
12
+ different from the client's default WITHOUT mutating shared state. The
13
+ JS reference client juggles global axios defaults here and leaks the
14
+ change on error (RESEARCH §4 "Release node and close its association",
15
+ DESIGN §1 snippet 1.11).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from collections.abc import Callable
22
+ from typing import TYPE_CHECKING
23
+
24
+ from ..errors import PartialFailureError, RtlsError
25
+ from ..models import BulkFailure, BulkResult, ImportResult
26
+
27
+ if TYPE_CHECKING:
28
+ from .._client import RtlsClient
29
+
30
+
31
+ logger = logging.getLogger("rtls_sdk.compounds.nodes")
32
+
33
+
34
+ def release_node(
35
+ client: RtlsClient,
36
+ mac_address: str,
37
+ *,
38
+ project_uid: str | None = None,
39
+ ) -> None:
40
+ """Close any open association for ``mac_address``, then release.
41
+
42
+ All sub-calls run under the supplied ``project_uid`` (or the
43
+ client's default project if ``None`` is passed). The scope override
44
+ is per-thread and reverts on exit — concurrent calls on the same
45
+ client don't trample each other (the bug the JS reference client
46
+ has).
47
+
48
+ Step names:
49
+ - ``close_association`` (only when an open association exists)
50
+ - ``release_node``
51
+
52
+ Partial-failure rule: if ``close_association`` fails, raise without
53
+ attempting release. If ``release_node`` fails after a successful
54
+ close, raise :class:`PartialFailureError` with
55
+ ``completed_steps=["close_association"]`` — the association is now
56
+ closed but the node is still assigned; caller must reconcile.
57
+ """
58
+ target_project = project_uid or client._auth_state.project_uid
59
+
60
+ with client._call_with_scope(project_uid=target_project):
61
+ # Step 1: find an open association for this MAC.
62
+ open_assoc = None
63
+ try:
64
+ for assoc in client.associations.list():
65
+ if assoc.mac_address == mac_address and assoc.closed_at is None and assoc.uid:
66
+ open_assoc = assoc
67
+ break
68
+ except RtlsError:
69
+ # If we can't list associations, fall through to release —
70
+ # the server will surface any blocking constraint.
71
+ open_assoc = None
72
+
73
+ completed: list[str] = []
74
+
75
+ # Step 2: close the association if one was open.
76
+ if open_assoc is not None:
77
+ try:
78
+ # Pass the Association directly so close() doesn't have
79
+ # to re-list. The server identifies by mac_address /
80
+ # obj_uid in the body, not by the association uid.
81
+ client.associations.close(open_assoc)
82
+ except RtlsError as exc:
83
+ # Re-raise without attempting release — the association
84
+ # didn't close, releasing the node would orphan it.
85
+ raise PartialFailureError(
86
+ f"nodes.release: close_association failed: {exc}",
87
+ completed_steps=completed,
88
+ failed_step="close_association",
89
+ cause=exc,
90
+ ) from exc
91
+ completed.append("close_association")
92
+
93
+ # Step 3: release the node.
94
+ try:
95
+ client.nodes.release_raw(mac_address, project_uid=target_project or "")
96
+ except RtlsError as exc:
97
+ raise PartialFailureError(
98
+ f"nodes.release: release_node failed: {exc}",
99
+ completed_steps=completed,
100
+ failed_step="release_node",
101
+ cause=exc,
102
+ ) from exc
103
+
104
+
105
+ def import_nodes(
106
+ client: RtlsClient,
107
+ mac_addresses: list[str],
108
+ *,
109
+ on_progress: Callable[[int, int], None] | None = None,
110
+ ) -> ImportResult:
111
+ """Validate MACs server-side, then create each valid row.
112
+
113
+ Short-circuits if any MAC is invalid or duplicate — DESIGN §4.9: the
114
+ server's validation step is treated as a hard gate, and the SDK
115
+ refuses to partially import a batch with known-bad rows.
116
+
117
+ ``on_progress(done, total)`` (optional) is invoked after each create
118
+ attempt, succeeded or not.
119
+ """
120
+ validation = client.nodes.validate(mac_addresses)
121
+ valid_raw = validation.get("valid")
122
+ invalid_raw = validation.get("invalid")
123
+ duplicate_raw = validation.get("duplicate")
124
+
125
+ valid = list(valid_raw) if isinstance(valid_raw, list) else []
126
+ invalid = list(invalid_raw) if isinstance(invalid_raw, list) else []
127
+ duplicate = list(duplicate_raw) if isinstance(duplicate_raw, list) else []
128
+
129
+ # Short-circuit: any invalid or duplicate row means we don't create.
130
+ if invalid or duplicate:
131
+ return ImportResult(
132
+ valid=valid,
133
+ invalid=invalid,
134
+ duplicate=duplicate,
135
+ imported_count=0,
136
+ failures=[],
137
+ )
138
+
139
+ failures: list[BulkFailure] = []
140
+ total = len(valid)
141
+ for i, mac in enumerate(valid, start=1):
142
+ try:
143
+ client.nodes.create(mac)
144
+ except RtlsError as exc:
145
+ failures.append(BulkFailure(error=exc, input=mac, uid=mac))
146
+ if on_progress is not None:
147
+ on_progress(i, total)
148
+
149
+ return ImportResult(
150
+ valid=valid,
151
+ invalid=invalid,
152
+ duplicate=duplicate,
153
+ imported_count=len(valid) - len(failures),
154
+ failures=failures,
155
+ )
156
+
157
+
158
+ def delete_nodes(client: RtlsClient, uid_or_uids: str | list[str]) -> BulkResult[None]:
159
+ """Delete one or many nodes, continue-on-error.
160
+
161
+ Diverges from M3's :meth:`NodesAPI.delete_raw` which is fail-fast.
162
+ The compound :meth:`NodesAPI.delete` is the safer default for bulk
163
+ deletes; the raw variant remains for callers that want a hard fail.
164
+ """
165
+ uids = [uid_or_uids] if isinstance(uid_or_uids, str) else uid_or_uids
166
+ result: BulkResult[None] = BulkResult()
167
+ for uid in uids:
168
+ try:
169
+ client.nodes.delete_raw(uid)
170
+ result.succeeded.append(None)
171
+ except RtlsError as exc:
172
+ result.failures.append(BulkFailure(error=exc, input=uid, uid=uid))
173
+ return result
174
+
175
+
176
+ __all__ = ["delete_nodes", "import_nodes", "release_node"]