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,147 @@
1
+ """WIN anchors resource sub-client.
2
+
3
+ A WIN anchor is a **placed beacon**, identified by its server-issued
4
+ ``uid`` — *not* by a MAC address. The MAC address belongs to a
5
+ :class:`Node` (the hardware), and the binding between a node and an
6
+ anchor is an :class:`Association` (``anchor_assoc``). The desktop
7
+ site-plan client aggregates the three for display, but the server
8
+ keeps them separate.
9
+
10
+ The server's ``create_win_anchor`` schema requires:
11
+
12
+ - ``name`` (string)
13
+ - ``type`` (``ANCHOR`` or ``BRIDGE``; defaults to ``ANCHOR``)
14
+ - ``position`` (object): ``{x, y, z, sublocation_uid}`` (or ``sl_uid``)
15
+
16
+ Optional: ``locked``, ``acc_config``, ``bt_config``, ``state``,
17
+ ``device_config``.
18
+
19
+ If your workflow is "I have a MAC and want a placed anchor bound to
20
+ it," that's three calls — anchor create + node create + association
21
+ create. A compound that wraps the saga is planned for a future
22
+ milestone; for now, drive the three primitives in your application.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Any
28
+
29
+ from .._envelope import unwrap_resource, unwrap_resource_single
30
+ from ..models import Anchor
31
+ from ._base import ResourceAPI
32
+
33
+ _VALID_ANCHOR_TYPES: frozenset[str] = frozenset({"ANCHOR", "BRIDGE"})
34
+
35
+
36
+ class AnchorsAPI(ResourceAPI):
37
+ """List, create, update, and delete WIN anchors in the current scope.
38
+
39
+ Anchors are present only when the WIN module is enabled on the
40
+ server. Some deployments return 404 on the list endpoint — the SDK
41
+ raises :class:`NotFound`.
42
+
43
+ Server-side anchor deletion closes open anchor associations
44
+ automatically; the SDK does not pre-emptively call
45
+ :meth:`AssociationsAPI.close`.
46
+
47
+ ``list`` is defined last (class-scope shadow workaround).
48
+ """
49
+
50
+ def create(
51
+ self,
52
+ *,
53
+ name: str,
54
+ position: dict[str, Any],
55
+ type: str = "ANCHOR",
56
+ **fields: Any,
57
+ ) -> Anchor:
58
+ """Create a placed anchor (WIN beacon).
59
+
60
+ Parameters
61
+ ----------
62
+ name
63
+ Display name. Required.
64
+ position
65
+ Placement object. Must include ``x``, ``y``, ``z`` numbers
66
+ and either ``sublocation_uid`` or ``sl_uid``. The server's
67
+ Joi schema accepts both alias names.
68
+ type
69
+ ``"ANCHOR"`` (default) or ``"BRIDGE"``. Other values are
70
+ rejected with ``ValueError`` before any HTTP call.
71
+ **fields
72
+ Optional pass-through fields the server schema permits:
73
+ ``locked`` (bool), ``acc_config`` / ``bt_config`` /
74
+ ``state`` (dicts), ``device_config`` (dict with
75
+ ``battery``, ``model``, ``pwr_profile``, plus arbitrary
76
+ keys up to 64 bytes).
77
+
78
+ Examples
79
+ --------
80
+ >>> anchor = client.anchors.create(
81
+ ... name="Anchor 7",
82
+ ... position={"x": 1.0, "y": 2.0, "z": 0.0, "sublocation_uid": area.uid},
83
+ ... )
84
+ >>> anchor.uid
85
+ 'NELkj…'
86
+
87
+ Notes
88
+ -----
89
+ ``win_anchor`` does NOT carry a ``mac_address`` field. If you
90
+ want to bind a piece of hardware (with a MAC) to this anchor,
91
+ also call :meth:`NodesAPI.create` and
92
+ :meth:`AssociationsAPI.create` (or use the planned
93
+ ``anchors.create_with_node`` compound when it lands).
94
+ """
95
+ if type not in _VALID_ANCHOR_TYPES:
96
+ raise ValueError(
97
+ f"anchor type must be one of {sorted(_VALID_ANCHOR_TYPES)}; got {type!r}"
98
+ )
99
+ body: dict[str, Any] = {
100
+ "name": name,
101
+ "type": type,
102
+ "position": position,
103
+ **fields,
104
+ }
105
+ response = self._post("/api/v2/win_anchors/", json=body)
106
+ payload = response.json()
107
+ try:
108
+ item = unwrap_resource_single(payload, "win_anchor")
109
+ except ValueError:
110
+ item = payload if isinstance(payload, dict) else {}
111
+ return Anchor.from_wire(item)
112
+
113
+ def update(self, uid: str, **fields: Any) -> Anchor:
114
+ """Update an anchor's fields.
115
+
116
+ Free-form ``**fields`` to match the server's permissive
117
+ ``update_win_anchor`` schema. Validates ``type`` when present
118
+ (must be ``ANCHOR`` or ``BRIDGE``). Does NOT validate
119
+ ``position`` structure — caller is responsible for shape.
120
+ """
121
+ if "type" in fields and fields["type"] not in _VALID_ANCHOR_TYPES:
122
+ raise ValueError(
123
+ f"anchor type must be one of {sorted(_VALID_ANCHOR_TYPES)}; got {fields['type']!r}"
124
+ )
125
+ response = self._put(f"/api/v2/win_anchors/{uid}", json=fields)
126
+ payload = response.json()
127
+ try:
128
+ item = unwrap_resource_single(payload, "win_anchor")
129
+ except ValueError:
130
+ item = payload if isinstance(payload, dict) else {}
131
+ return Anchor.from_wire(item)
132
+
133
+ def delete(self, uid: str) -> None:
134
+ """Delete an anchor.
135
+
136
+ Server-side closes any open anchor associations referencing it;
137
+ the SDK does NOT pre-emptively call ``associations.close``.
138
+ """
139
+ self._delete(f"/api/v2/win_anchors/{uid}")
140
+
141
+ def list(self) -> list[Anchor]:
142
+ response = self._get("/api/v2/win_anchors/")
143
+ items = unwrap_resource(response.json(), "win_anchors")
144
+ return [Anchor.from_wire(item) for item in items]
145
+
146
+
147
+ __all__ = ["AnchorsAPI"]
@@ -0,0 +1,78 @@
1
+ """Areas (sublocations) 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 ..models import Area
9
+ from ._base import ResourceAPI
10
+
11
+
12
+ class AreasAPI(ResourceAPI):
13
+ """List, create, update, and delete areas (sublocations) in the current scope.
14
+
15
+ The wire-format name is ``sublocations``; SDK exposes them as
16
+ ``areas`` to match operator terminology.
17
+
18
+ Cascade contract: ``areas.delete(uid)`` removes the area and every
19
+ floorplan, anchor, and zone bound to it (server-side cascade). The
20
+ SDK does not pre-emptively delete children.
21
+
22
+ ``list`` is defined last (class-scope shadow workaround).
23
+ """
24
+
25
+ def create(self, name: str, *, location_uid: str, **fields: Any) -> Area:
26
+ """Create an area inside ``location_uid``.
27
+
28
+ Parameters
29
+ ----------
30
+ name
31
+ Display name.
32
+ location_uid
33
+ Parent site uid. Required by the server.
34
+ **fields
35
+ Additional optional placement metadata: ``description``,
36
+ ``position_x``, ``position_y``, ``width``, ``height``,
37
+ ``rotation``.
38
+
39
+ Examples
40
+ --------
41
+ >>> area = client.areas.create(name="Bay 3", location_uid=site.uid)
42
+ """
43
+ body: dict[str, Any] = {"name": name, "location_uid": location_uid, **fields}
44
+ response = self._post("/api/v2/sublocations/", json=body)
45
+ payload = response.json()
46
+ try:
47
+ item = unwrap_resource_single(payload, "sublocation")
48
+ except ValueError:
49
+ item = payload if isinstance(payload, dict) else {}
50
+ return Area.from_wire(item)
51
+
52
+ def update(self, uid: str, **fields: Any) -> Area:
53
+ """Update an area's fields.
54
+
55
+ The body is the kwargs verbatim — the SDK does not enforce an
56
+ allowlist on area updates, mirroring the desktop site-plan
57
+ client's ``updateItem`` flow.
58
+ """
59
+ response = self._put(f"/api/v2/sublocations/{uid}", json=fields)
60
+ payload = response.json()
61
+ try:
62
+ item = unwrap_resource_single(payload, "sublocation")
63
+ except ValueError:
64
+ item = payload if isinstance(payload, dict) else {}
65
+ return Area.from_wire(item)
66
+
67
+ def delete(self, uid: str) -> None:
68
+ """Delete an area (cascades to its floorplans / anchors / zones server-side)."""
69
+ self._delete(f"/api/v2/sublocations/{uid}")
70
+
71
+ def list(self) -> list[Area]:
72
+ """Return all areas visible to the current scope."""
73
+ response = self._get("/api/v2/sublocations/")
74
+ items = unwrap_resource(response.json(), "sublocations")
75
+ return [Area.from_wire(item) for item in items]
76
+
77
+
78
+ __all__ = ["AreasAPI"]
@@ -0,0 +1,157 @@
1
+ """Trackable-association resource sub-client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from .._envelope import unwrap_resource, unwrap_resource_single
8
+ from .._query import normalize_mac
9
+ from .._time import to_epoch_seconds
10
+ from ..errors import RtlsError
11
+ from ..models import Association, BulkFailure, BulkResult
12
+ from ._base import ResourceAPI
13
+
14
+
15
+ class AssociationsAPI(ResourceAPI):
16
+ """Read trackable ↔ MAC associations.
17
+
18
+ Two endpoints surface here:
19
+
20
+ - :meth:`list` reads currently-open associations.
21
+ - :meth:`list_active_in_period` reads associations active in a window;
22
+ uses a distinct endpoint path (``/track_assoc``) and — uniquely
23
+ among RTLS endpoints — accepts ``start`` / ``end`` as **epoch
24
+ seconds**, not milliseconds (RESEARCH §4).
25
+
26
+ ``list`` is defined last (class-scope shadow workaround).
27
+ """
28
+
29
+ def list_active_in_period(self, start: datetime, end: datetime) -> list[Association]:
30
+ """Return associations active during ``[start, end]``.
31
+
32
+ The SDK converts ``datetime`` to epoch seconds for this endpoint.
33
+ Pass timezone-aware datetimes; naive values raise ``ValueError``.
34
+ """
35
+ params = {
36
+ "start": to_epoch_seconds(start, arg_name="start"),
37
+ "end": to_epoch_seconds(end, arg_name="end"),
38
+ }
39
+ response = self._get("/api/v2/track_assoc", params=params)
40
+ items = unwrap_resource(response.json(), "trackable_objects_associations")
41
+ return [Association.from_wire(item) for item in items]
42
+
43
+ def create(self, mac_address: str, trackable_uid: str) -> Association:
44
+ """Create a trackable-association (bind a MAC to a tag).
45
+
46
+ The MAC is normalized to bare 12-hex lowercase before sending
47
+ (server validates ``/^[a-fA-F0-9]{12}$/``).
48
+
49
+ Note: this binds a node (by MAC) to a TAG (`obj_uid`). To bind a
50
+ node to a placed ANCHOR (`win_uid`), the wire endpoint is
51
+ ``/win_anchor_associations`` — that resource is not yet exposed
52
+ on the SDK (planned for a future milestone alongside
53
+ ``anchors.create_with_node``).
54
+ """
55
+ body = {"mac_address": normalize_mac(mac_address), "obj_uid": trackable_uid}
56
+ response = self._post("/api/v2/trackable_objects_associations/", json=body)
57
+ payload = response.json()
58
+ # Create returns the singular envelope {association: ...}; the SDK
59
+ # tolerates both shapes.
60
+ try:
61
+ item = unwrap_resource_single(payload, "association")
62
+ except ValueError:
63
+ item = payload if isinstance(payload, dict) else {}
64
+ return Association.from_wire(item)
65
+
66
+ def close(
67
+ self,
68
+ association: Association | str | None = None,
69
+ *,
70
+ mac_address: str | None = None,
71
+ trackable_uid: str | None = None,
72
+ ) -> Association:
73
+ """Close an open association.
74
+
75
+ The endpoint is ``PUT /trackable_objects_associations/close`` and
76
+ the server identifies the association by ``mac_address`` OR
77
+ ``obj_uid`` (the trackable's uid) in the body — **not** by the
78
+ association's own uid (the server's close-handler reads only
79
+ ``req.body.mac_address`` and ``req.body.obj_uid``). M14 regression
80
+ — earlier ``close(uid)`` sent ``{"uid": "..."}`` and the server
81
+ replied ``"No mac or trackable object uid specified."``.
82
+
83
+ Three accepted call shapes:
84
+
85
+ - ``close(association)`` — pass the :class:`Association` object
86
+ returned by :meth:`create` / :meth:`list`; the SDK picks
87
+ ``mac_address`` or ``obj_uid`` from it.
88
+ - ``close(association_uid)`` — pass the association's own uid;
89
+ the SDK does one extra ``list()`` to resolve the identifying
90
+ fields.
91
+ - ``close(mac_address=..., trackable_uid=...)`` — pass either
92
+ identifier explicitly.
93
+ """
94
+ if isinstance(association, Association):
95
+ mac_address = mac_address or association.mac_address
96
+ trackable_uid = trackable_uid or association.obj_uid
97
+ elif isinstance(association, str):
98
+ # Look up by association uid (extra round-trip — callers can
99
+ # avoid this by passing the Association object directly).
100
+ for existing in self.list():
101
+ if existing.uid == association:
102
+ mac_address = mac_address or existing.mac_address
103
+ trackable_uid = trackable_uid or existing.obj_uid
104
+ break
105
+
106
+ body: dict[str, str] = {}
107
+ if mac_address:
108
+ body["mac_address"] = mac_address
109
+ if trackable_uid:
110
+ body["obj_uid"] = trackable_uid
111
+ if not body:
112
+ raise ValueError(
113
+ "associations.close: provide an Association, an association uid, "
114
+ "or mac_address / trackable_uid kwargs"
115
+ )
116
+
117
+ response = self._put("/api/v2/trackable_objects_associations/close", json=body)
118
+ payload = response.json()
119
+ try:
120
+ item = unwrap_resource_single(payload, "association")
121
+ except ValueError:
122
+ item = payload if isinstance(payload, dict) else {}
123
+ return Association.from_wire(item)
124
+
125
+ def bulk_create(self, pairs: list[tuple[str, str]]) -> BulkResult[Association]:
126
+ """Create many associations, one at a time, continue-on-error.
127
+
128
+ Parameters
129
+ ----------
130
+ pairs
131
+ List of ``(mac_address, trackable_uid)`` tuples.
132
+
133
+ Returns
134
+ -------
135
+ BulkResult[Association]
136
+ ``succeeded`` lists the created associations; ``failures``
137
+ carries per-row errors.
138
+ """
139
+ result: BulkResult[Association] = BulkResult()
140
+ for mac, tag_uid in pairs:
141
+ try:
142
+ result.succeeded.append(self.create(mac, tag_uid))
143
+ except RtlsError as exc:
144
+ result.failures.append(
145
+ BulkFailure(error=exc, input={"mac_address": mac, "obj_uid": tag_uid})
146
+ )
147
+ # ValueError from MAC normalization (malformed input) is a
148
+ # programmer error — propagates out and aborts the bulk.
149
+ return result
150
+
151
+ def list(self) -> list[Association]:
152
+ response = self._get("/api/v2/trackable_objects_associations/")
153
+ items = unwrap_resource(response.json(), "trackable_objects_associations")
154
+ return [Association.from_wire(item) for item in items]
155
+
156
+
157
+ __all__ = ["AssociationsAPI"]
@@ -0,0 +1,63 @@
1
+ """Auth / capabilities resource sub-client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..compounds import auth as _compound
6
+ from ..errors import RtlsError
7
+ from ..models import Capabilities, User
8
+ from ._base import ResourceAPI
9
+
10
+
11
+ class AuthAPI(ResourceAPI):
12
+ """User-facing auth helpers — capabilities probe and ``whoami``.
13
+
14
+ Note that the active SDK token is *not* exposed here. Mutating
15
+ operations (login, logout, change-password) land in M5.
16
+ """
17
+
18
+ def whoami(self) -> User:
19
+ """Return the currently-authenticated user.
20
+
21
+ Delegates to :meth:`UsersAPI.me` — composed via a search of
22
+ ``GET /users?withProjects=true`` for the row matching the SDK's
23
+ stored auth email. See ``UsersAPI.me`` for details.
24
+ """
25
+ return self._client.users.me()
26
+
27
+ def capabilities(self) -> Capabilities:
28
+ """Probe whether SSO is configured + infer the API version.
29
+
30
+ Calls ``GET /auth/sso/log_in``. A successful response means SSO is
31
+ configured (``sso=True``, ``api_version="2.1"``); any
32
+ :class:`RtlsError` from the call means SSO is unavailable
33
+ (``sso=False``, ``api_version="2.0"``). This mirrors the JS
34
+ reference client's "rejection-is-the-signal" heuristic
35
+ (RESEARCH §4 "Probe SSO support").
36
+ """
37
+ try:
38
+ self._get("/api/v2/auth/sso/log_in")
39
+ except RtlsError:
40
+ return Capabilities(sso=False, api_version="2.0")
41
+ return Capabilities(sso=True, api_version="2.1")
42
+
43
+ def change_password(self, old_password: str, new_password: str) -> None:
44
+ """Change the current user's password and re-authenticate the SDK.
45
+
46
+ Four steps: validate the old password, validate the new
47
+ password, ``PUT /users/changePassword``, then clear the cached
48
+ token and force a fresh login with the new credentials. After a
49
+ successful run, the SDK's stored password is the new one.
50
+
51
+ Step names: ``validate_old``, ``validate_new``,
52
+ ``change_password``, ``reauthenticate``.
53
+
54
+ If the re-authentication step fails (server accepted the change
55
+ but the new login can't proceed), the SDK raises
56
+ :class:`AuthenticationError` with a message explaining that the
57
+ in-memory state is now stale. The caller should reconstruct the
58
+ client with the new password.
59
+ """
60
+ _compound.change_password(self._client, old_password, new_password)
61
+
62
+
63
+ __all__ = ["AuthAPI"]
@@ -0,0 +1,79 @@
1
+ """Companies 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 ..models import Company
9
+ from ._base import ResourceAPI
10
+
11
+
12
+ class CompaniesAPI(ResourceAPI):
13
+ """List, create, update, and delete companies.
14
+
15
+ ``list`` is defined last (class-scope shadow workaround).
16
+ """
17
+
18
+ def create(
19
+ self,
20
+ name: str,
21
+ *,
22
+ admin_email: str,
23
+ admin_password: str,
24
+ admin_role: str = "company_admin",
25
+ **company_fields: Any,
26
+ ) -> Company:
27
+ """Create a new company and seed it with an admin user.
28
+
29
+ Per RESEARCH §1, ``POST /companies/`` accepts a body like
30
+ ``{company: {...}, user: {email, password, role}}`` — the SDK
31
+ normalises that envelope shape so callers don't have to.
32
+
33
+ The seeded admin's ``role`` is server-required (Joi
34
+ ``companyCreate.user.role``). It defaults to ``"company_admin"``,
35
+ the only role that makes sense for a freshly-seeded tenant.
36
+ """
37
+ body = {
38
+ "company": {"name": name, **company_fields},
39
+ "user": {
40
+ "email": admin_email,
41
+ "password": admin_password,
42
+ "role": admin_role,
43
+ },
44
+ }
45
+ response = self._post("/api/v2/companies/", json=body)
46
+ payload = response.json()
47
+ try:
48
+ item = unwrap_resource_single(payload, "company")
49
+ except ValueError:
50
+ item = payload if isinstance(payload, dict) else {}
51
+ return Company.from_wire(item)
52
+
53
+ def update(self, uid: str, **fields: Any) -> Company:
54
+ """Update a company's fields."""
55
+ body = {"company": fields}
56
+ response = self._put(f"/api/v2/companies/{uid}", json=body)
57
+ payload = response.json()
58
+ try:
59
+ item = unwrap_resource_single(payload, "company")
60
+ except ValueError:
61
+ item = payload if isinstance(payload, dict) else {}
62
+ return Company.from_wire(item)
63
+
64
+ def delete(self, uid: str) -> None:
65
+ """Delete a company."""
66
+ self._delete(f"/api/v2/companies/{uid}")
67
+
68
+ def list(self) -> list[Company]:
69
+ """Return companies visible to the current user.
70
+
71
+ On most deployments this returns exactly one company; super-admin
72
+ users may see all companies.
73
+ """
74
+ response = self._get("/api/v2/companies/")
75
+ items = unwrap_resource(response.json(), "companies")
76
+ return [Company.from_wire(item) for item in items]
77
+
78
+
79
+ __all__ = ["CompaniesAPI"]
@@ -0,0 +1,50 @@
1
+ """Session-bootstrap sub-client (M6).
2
+
3
+ Surfaces :meth:`load` — the mega-method that hydrates the full
4
+ deployment view under the active project scope. Delegates to
5
+ :mod:`rtls_sdk.compounds.context`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ..compounds import context as _compound
11
+ from ..models import SessionContext
12
+ from ._base import ResourceAPI
13
+
14
+
15
+ class ContextAPI(ResourceAPI):
16
+ """Aggregated session bootstrap.
17
+
18
+ The single :meth:`load` method replaces the JS reference client's
19
+ 18-call ``loadEntities`` saga (RESEARCH §4) — DESIGN's highest-value
20
+ compound collapse.
21
+ """
22
+
23
+ def load(self, *, scope: str | None = None) -> SessionContext:
24
+ """Hydrate the deployment view under the current scope.
25
+
26
+ Sub-resources are fetched sequentially in a stable order. Each
27
+ sub-call's failure goes into :attr:`SessionContext.errors`
28
+ keyed by a stable name; the rest of the snapshot still
29
+ populates. :attr:`SessionContext.partial` is ``True`` when any
30
+ sub-call failed.
31
+
32
+ Parameters
33
+ ----------
34
+ scope
35
+ ``None`` or ``"project"`` (the default and only supported
36
+ value in v1). ``"company"`` and ``"system"`` raise
37
+ :class:`NotImplementedError`.
38
+
39
+ Examples
40
+ --------
41
+ >>> ctx = client.context.load()
42
+ >>> ctx.tags, ctx.zones, ctx.sites
43
+ >>> if ctx.partial:
44
+ ... for name, exc in ctx.errors.items():
45
+ ... print(f"{name}: {exc}")
46
+ """
47
+ return _compound.collect_session_context(self._client, scope=scope)
48
+
49
+
50
+ __all__ = ["ContextAPI"]