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
rtls_sdk/_logging.py ADDED
@@ -0,0 +1,143 @@
1
+ """Named logger and secret redaction for SDK HTTP traffic.
2
+
3
+ Redaction is mandatory and has no opt-out flag. If the user wants to log
4
+ raw payloads they can install their own ``httpx`` event hooks on a separate
5
+ client; the SDK's logger never emits credentials at any level.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ logger = logging.getLogger("rtls_sdk")
16
+
17
+ _REDACTED = "***"
18
+
19
+ _REDACT_HEADER_NAMES: frozenset[str] = frozenset(
20
+ {
21
+ "x-user-token",
22
+ "x-user-email",
23
+ "x-user-refresh-token",
24
+ "authorization",
25
+ "cookie",
26
+ "set-cookie",
27
+ }
28
+ )
29
+
30
+ _REDACT_BODY_KEYS: frozenset[str] = frozenset(
31
+ {
32
+ "password",
33
+ "token",
34
+ "refresh_token",
35
+ "access_token",
36
+ "client_secret",
37
+ "api_key",
38
+ }
39
+ )
40
+
41
+ # Strings longer than this in a logged body are replaced with a
42
+ # ``<truncated N bytes>`` placeholder. Set so a base64-encoded image
43
+ # payload (typically tens of KB to MB) doesn't pollute DEBUG logs and
44
+ # obscure the structurally interesting fields. 1024 chars covers any
45
+ # legitimate user-facing string (names, descriptions, URLs) with room
46
+ # to spare.
47
+ _MAX_LOGGED_STRING_LEN: int = 1024
48
+
49
+
50
+ def redact_headers(headers: httpx.Headers | dict[str, str]) -> dict[str, str]:
51
+ """Return a copy of ``headers`` with sensitive values masked."""
52
+ out: dict[str, str] = {}
53
+ for key, value in headers.items():
54
+ if key.lower() in _REDACT_HEADER_NAMES:
55
+ out[key] = _REDACTED
56
+ else:
57
+ out[key] = value
58
+ return out
59
+
60
+
61
+ def redact_body(body: Any) -> Any:
62
+ """Recursively mask sensitive values in a parsed JSON body.
63
+
64
+ Three transformations applied to every node:
65
+
66
+ 1. Keys in :data:`_REDACT_BODY_KEYS` (case-insensitive) have their
67
+ values replaced with ``"***"``.
68
+ 2. String values longer than :data:`_MAX_LOGGED_STRING_LEN` are
69
+ replaced with ``"<truncated N bytes>"``. This keeps base64
70
+ image payloads and other large blobs out of DEBUG logs.
71
+ 3. Containers are recursed into; lists / dicts return a new
72
+ structure (the input is never mutated).
73
+ """
74
+ if isinstance(body, dict):
75
+ return {
76
+ key: (_REDACTED if key.lower() in _REDACT_BODY_KEYS else redact_body(value))
77
+ for key, value in body.items()
78
+ }
79
+ if isinstance(body, list):
80
+ return [redact_body(item) for item in body]
81
+ if isinstance(body, str) and len(body) > _MAX_LOGGED_STRING_LEN:
82
+ return f"<truncated {len(body)} bytes>"
83
+ return body
84
+
85
+
86
+ def log_request(request: httpx.Request) -> None:
87
+ """httpx request event hook — log method + URL + redacted headers + body at DEBUG.
88
+
89
+ The request body is parsed as JSON when ``content-type`` is
90
+ ``application/json`` and run through :func:`redact_body`, which both
91
+ masks secret keys (passwords, tokens) and truncates strings longer
92
+ than 1024 chars (image payloads, etc.). Non-JSON bodies log as
93
+ ``<binary N bytes>`` placeholders.
94
+ """
95
+ if not logger.isEnabledFor(logging.DEBUG):
96
+ return
97
+ body: Any = None
98
+ content_type = request.headers.get("content-type", "")
99
+ if content_type.startswith("application/json") and request.content:
100
+ try:
101
+ import json
102
+
103
+ body = redact_body(json.loads(request.content))
104
+ except (ValueError, UnicodeDecodeError):
105
+ body = f"<unparseable {len(request.content)} bytes>"
106
+ elif request.content:
107
+ body = f"<binary {len(request.content)} bytes>"
108
+ logger.debug(
109
+ "request %s %s headers=%s body=%s",
110
+ request.method,
111
+ request.url,
112
+ redact_headers(request.headers),
113
+ body,
114
+ )
115
+
116
+
117
+ def log_response(response: httpx.Response) -> None:
118
+ """httpx response event hook — log status at DEBUG; body if available."""
119
+ if not logger.isEnabledFor(logging.DEBUG):
120
+ return
121
+ body: Any = None
122
+ if response.headers.get("content-type", "").startswith("application/json"):
123
+ try:
124
+ response.read()
125
+ body = response.json()
126
+ except Exception:
127
+ body = None
128
+ logger.debug(
129
+ "response %s %s status=%s body=%s",
130
+ response.request.method,
131
+ response.request.url,
132
+ response.status_code,
133
+ redact_body(body),
134
+ )
135
+
136
+
137
+ __all__ = [
138
+ "log_request",
139
+ "log_response",
140
+ "logger",
141
+ "redact_body",
142
+ "redact_headers",
143
+ ]
@@ -0,0 +1,235 @@
1
+ """Pagination paginators — three server styles, one SDK shape.
2
+
3
+ The RTLS server uses three pagination conventions across endpoints
4
+ (RESEARCH §2):
5
+
6
+ - **Style 1 — header cursor**: response header
7
+ ``content-next-positions-page`` carries the next ``page`` value.
8
+ Used by ``positions_history``.
9
+ - **Style 2 — body-included nextPage URL**: response body has
10
+ ``nextPage`` (full URL) and ``totalCount``. Used by
11
+ ``external_events`` / PWS events.
12
+ - **Style 3 — bare page+limit**: caller supplies ``page`` /
13
+ ``limit``; no metadata in the response. Continue until an empty page.
14
+
15
+ Each style has its own ``Paginator`` subclass; resource methods
16
+ construct the right one and expose a single iterator surface to
17
+ callers. The :class:`Page` value type is identical across styles.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Callable, Iterator
23
+ from dataclasses import dataclass, field
24
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
25
+
26
+ if TYPE_CHECKING:
27
+ import httpx
28
+
29
+
30
+ T = TypeVar("T")
31
+
32
+
33
+ @dataclass
34
+ class Page(Generic[T]):
35
+ """One page of a paginated result.
36
+
37
+ Attributes
38
+ ----------
39
+ items
40
+ Parsed items on this page.
41
+ page_number
42
+ 1-indexed.
43
+ total_count
44
+ Total across all pages, if the server reported it (Style 2 only).
45
+ has_next
46
+ ``True`` if at least one more page is expected.
47
+ """
48
+
49
+ items: list[T] = field(default_factory=list)
50
+ page_number: int = 1
51
+ total_count: int | None = None
52
+ has_next: bool = False
53
+
54
+
55
+ # A fetcher takes (url, params) and returns the raw httpx.Response.
56
+ # Resource methods supply ``client._http.get``-shaped callable.
57
+ Fetcher = Callable[..., "httpx.Response"]
58
+ # Parse a server payload into typed items.
59
+ ItemParser = Callable[[Any], list[T]]
60
+
61
+
62
+ class _Paginator(Generic[T]):
63
+ """Common surface: ``pages()`` and ``__iter__()``."""
64
+
65
+ def pages(self) -> Iterator[Page[T]]:
66
+ raise NotImplementedError
67
+
68
+ def __iter__(self) -> Iterator[T]:
69
+ for page in self.pages():
70
+ yield from page.items
71
+
72
+
73
+ class HeaderCursorPaginator(_Paginator[T]):
74
+ """Style 1: server returns next-page cursor in a response header.
75
+
76
+ The cursor value is whatever the server emitted (often a sequence
77
+ number); we feed it back as ``params["page"]`` on the next call.
78
+ A cursor that's literally absent (header missing) or the string
79
+ ``"null"`` ends pagination.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ *,
85
+ fetch: Fetcher,
86
+ path: str,
87
+ params: dict[str, Any] | list[tuple[str, str]],
88
+ parse_items: ItemParser[T],
89
+ header_name: str = "content-next-positions-page",
90
+ ) -> None:
91
+ self._fetch = fetch
92
+ self._path = path
93
+ # Coerce to a mutable dict so we can update ``page`` between calls.
94
+ if isinstance(params, dict):
95
+ self._params: dict[str, Any] = dict(params)
96
+ else:
97
+ self._params = dict(params)
98
+ self._parse_items = parse_items
99
+ self._header_name = header_name
100
+
101
+ def pages(self) -> Iterator[Page[T]]:
102
+ page_number = 0
103
+ while True:
104
+ page_number += 1
105
+ response = self._fetch(self._path, params=self._params)
106
+ items = self._parse_items(response.json())
107
+ cursor = response.headers.get(self._header_name)
108
+ has_next = bool(cursor) and cursor != "null"
109
+ yield Page(
110
+ items=items,
111
+ page_number=page_number,
112
+ total_count=None,
113
+ has_next=has_next,
114
+ )
115
+ if not has_next:
116
+ return
117
+ self._params["page"] = cursor
118
+
119
+
120
+ class BodyUrlPaginator(_Paginator[T]):
121
+ """Style 2: server returns ``nextPage`` URL in the response body.
122
+
123
+ The URL is fed verbatim to the fetcher; the SDK does not append
124
+ query params to it (the server's URL already carries the right
125
+ query). Termination is ``nextPage`` being ``null`` / absent.
126
+ """
127
+
128
+ def __init__(
129
+ self,
130
+ *,
131
+ fetch: Fetcher,
132
+ path: str,
133
+ params: dict[str, Any] | list[tuple[str, str]],
134
+ parse_items: ItemParser[T],
135
+ list_key: str,
136
+ next_page_key: str = "nextPage",
137
+ total_key: str = "totalCount",
138
+ ) -> None:
139
+ self._fetch = fetch
140
+ self._initial_path = path
141
+ self._initial_params = params
142
+ self._parse_items = parse_items
143
+ self._list_key = list_key
144
+ self._next_page_key = next_page_key
145
+ self._total_key = total_key
146
+
147
+ def pages(self) -> Iterator[Page[T]]:
148
+ page_number = 0
149
+ path: str | None = self._initial_path
150
+ params: Any = self._initial_params
151
+
152
+ while path is not None:
153
+ page_number += 1
154
+ response = self._fetch(path, params=params)
155
+ payload = response.json()
156
+ list_value = payload.get(self._list_key, []) if isinstance(payload, dict) else []
157
+ items = self._parse_items(list_value)
158
+ total = payload.get(self._total_key) if isinstance(payload, dict) else None
159
+ next_page = payload.get(self._next_page_key) if isinstance(payload, dict) else None
160
+ has_next = bool(next_page)
161
+ yield Page(
162
+ items=items,
163
+ page_number=page_number,
164
+ total_count=total if isinstance(total, int) else None,
165
+ has_next=has_next,
166
+ )
167
+ if has_next and isinstance(next_page, str):
168
+ path = next_page
169
+ # The next-page URL carries its own query string.
170
+ params = None
171
+ else:
172
+ path = None
173
+
174
+
175
+ class PageLimitPaginator(_Paginator[T]):
176
+ """Style 3: caller drives via ``page`` + ``limit`` query params.
177
+
178
+ No metadata in the response — the SDK heuristically continues
179
+ until a page comes back with fewer items than ``page_size``, OR
180
+ until the server explicitly stops (an empty page).
181
+ """
182
+
183
+ def __init__(
184
+ self,
185
+ *,
186
+ fetch: Fetcher,
187
+ path: str,
188
+ params: dict[str, Any] | list[tuple[str, str]],
189
+ parse_items: ItemParser[T],
190
+ page_size: int = 1000,
191
+ ) -> None:
192
+ self._fetch = fetch
193
+ self._path = path
194
+ # Coerce params to a list so we can append page/limit as tuples
195
+ # without overwriting existing ones (the alarms-style endpoints
196
+ # use Rails array params that need list-of-tuples).
197
+ if isinstance(params, dict):
198
+ self._params: list[tuple[str, str]] = [(k, str(v)) for k, v in params.items()]
199
+ else:
200
+ self._params = [(k, str(v)) for k, v in params]
201
+ self._parse_items = parse_items
202
+ self._page_size = page_size
203
+
204
+ def pages(self) -> Iterator[Page[T]]:
205
+ page_number = 0
206
+ while True:
207
+ page_number += 1
208
+ paged_params = [
209
+ *self._params,
210
+ ("page", str(page_number)),
211
+ ("limit", str(self._page_size)),
212
+ ]
213
+ response = self._fetch(self._path, params=paged_params)
214
+ items = self._parse_items(response.json())
215
+ # Stop when the server returns fewer than the requested
216
+ # page size (or zero rows).
217
+ has_next = len(items) >= self._page_size
218
+ yield Page(
219
+ items=items,
220
+ page_number=page_number,
221
+ total_count=None,
222
+ has_next=has_next,
223
+ )
224
+ if not has_next:
225
+ return
226
+
227
+
228
+ __all__ = [
229
+ "BodyUrlPaginator",
230
+ "Fetcher",
231
+ "HeaderCursorPaginator",
232
+ "ItemParser",
233
+ "Page",
234
+ "PageLimitPaginator",
235
+ ]
rtls_sdk/_query.py ADDED
@@ -0,0 +1,114 @@
1
+ """Query-string, identifier, and payload helpers.
2
+
3
+ The RTLS server expects Rails-style array parameters with a literal
4
+ ``[]`` suffix on the parameter name — e.g. ``alarm_type_name[]=a&alarm_type_name[]=b``.
5
+ ``httpx``'s default ``params=`` serializer does not add the suffix, so we
6
+ build the parameter pairs explicitly as a list of tuples.
7
+
8
+ This module also hosts:
9
+
10
+ - :func:`normalize_mac` — used by node / association write paths.
11
+ - :func:`encode_image_payload` — used by floorplan upload (M10) to
12
+ build the ``image: {original_filename, file: <base64>}`` sub-object
13
+ the server's Joi schema requires.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import re
20
+ from typing import Any
21
+
22
+ # A loose pre-filter: anything that contains exactly 12 hex digits, with
23
+ # optional separators between byte pairs. We strip separators and revalidate
24
+ # strictly before emitting the canonical form.
25
+ _MAC_SEPARATOR_RE = re.compile(r"[:\-]")
26
+ _HEX_12_RE = re.compile(r"^[0-9a-fA-F]{12}$")
27
+
28
+
29
+ def normalize_mac(mac: str) -> str:
30
+ """Return ``mac`` in canonical bare 12-hex **lowercase** form.
31
+
32
+ The RTLS server's validation regex is ``/^[a-fA-F0-9]{12}$/`` — no
33
+ separators allowed; either case accepted on input. The server stores
34
+ and returns MACs in lowercase (observed live), so the SDK
35
+ normalizes to lowercase to keep ``sent_mac == returned_mac``
36
+ round-trips clean.
37
+
38
+ Six convenient input shapes accepted; raises :class:`ValueError`
39
+ on malformed input. The error message includes the offending value
40
+ so a caller's failed HTTP-shaped batch shows which input was wrong.
41
+
42
+ Accepted inputs:
43
+
44
+ - ``aa:bb:cc:dd:ee:ff``
45
+ - ``AA:BB:CC:DD:EE:FF``
46
+ - ``aa-bb-cc-dd-ee-ff``
47
+ - ``AA-BB-CC-DD-EE-FF``
48
+ - ``aabbccddeeff``
49
+ - ``AABBCCDDEEFF``
50
+
51
+ Examples
52
+ --------
53
+ >>> normalize_mac("AA-BB-CC-DD-EE-FF")
54
+ 'aabbccddeeff'
55
+ >>> normalize_mac("aabbccddeeff")
56
+ 'aabbccddeeff'
57
+ """
58
+ if not isinstance(mac, str) or not mac:
59
+ raise ValueError(f"mac_address must be a non-empty string; got {mac!r}")
60
+ stripped = _MAC_SEPARATOR_RE.sub("", mac)
61
+ if not _HEX_12_RE.match(stripped):
62
+ raise ValueError(
63
+ f"mac_address must be 12 hex digits (with optional ':' or '-' separators); got {mac!r}"
64
+ )
65
+ return stripped.lower()
66
+
67
+
68
+ def encode_image_payload(image_bytes: bytes, original_filename: str) -> dict[str, Any]:
69
+ """Build the ``image: {original_filename, file: <base64>}`` sub-object.
70
+
71
+ The RTLS server's ``createFloorplan`` Joi schema requires both keys
72
+ inside the ``image`` object — see M10's live probe. The ``file``
73
+ value is a standard RFC 4648 §4 base64 string (NOT URL-safe).
74
+
75
+ Examples
76
+ --------
77
+ >>> encode_image_payload(b"hello", "x.png")
78
+ {'original_filename': 'x.png', 'file': 'aGVsbG8='}
79
+ """
80
+ if not isinstance(image_bytes, bytes):
81
+ raise TypeError(f"image_bytes must be bytes, got {type(image_bytes).__name__}")
82
+ if not isinstance(original_filename, str) or not original_filename:
83
+ raise ValueError("original_filename must be a non-empty string")
84
+ return {
85
+ "original_filename": original_filename,
86
+ "file": base64.b64encode(image_bytes).decode("ascii"),
87
+ }
88
+
89
+
90
+ def pack_array_param(name: str, values: list[str]) -> list[tuple[str, str]]:
91
+ """Build a list of ``(key, value)`` tuples for a Rails-style array param.
92
+
93
+ Parameters
94
+ ----------
95
+ name
96
+ The parameter name *without* trailing ``[]``. The function adds it.
97
+ values
98
+ One value per emitted query parameter.
99
+
100
+ Returns
101
+ -------
102
+ list[tuple[str, str]]
103
+ Empty if ``values`` is empty.
104
+
105
+ Examples
106
+ --------
107
+ >>> pack_array_param("alarm_type_name", ["zone_violation", "buffer_violation"])
108
+ [('alarm_type_name[]', 'zone_violation'), ('alarm_type_name[]', 'buffer_violation')]
109
+ """
110
+ key = f"{name}[]"
111
+ return [(key, value) for value in values]
112
+
113
+
114
+ __all__ = ["encode_image_payload", "normalize_mac", "pack_array_param"]
rtls_sdk/_time.py ADDED
@@ -0,0 +1,84 @@
1
+ """Timestamp conversion helpers.
2
+
3
+ The RTLS API mixes ISO-8601 strings (reports), epoch milliseconds
4
+ (alarms, zone events, position history), and — for one alarm endpoint —
5
+ ``Date.getTime()`` integers (RESEARCH §2). The SDK normalizes by accepting
6
+ ``datetime`` everywhere and converting to whatever the target endpoint
7
+ expects.
8
+
9
+ All inbound ``datetime`` values must be **timezone-aware**. Naive
10
+ datetimes raise ``ValueError`` with a clear message; the SDK refuses to
11
+ guess UTC vs. local time.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from datetime import datetime, timezone
17
+
18
+
19
+ def _ensure_aware(dt: datetime, *, arg_name: str = "dt") -> datetime:
20
+ """Reject naive datetimes early.
21
+
22
+ ``dt.tzinfo`` is ``None`` for naive datetimes. We reject rather than
23
+ assume UTC because silent assumption is the kind of bug that causes
24
+ off-by-hours data joins.
25
+ """
26
+ if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
27
+ raise ValueError(
28
+ f"{arg_name} must be a timezone-aware datetime — "
29
+ f"the SDK does not assume UTC. Use datetime.now(timezone.utc) "
30
+ f"or attach a tzinfo before passing it in."
31
+ )
32
+ return dt
33
+
34
+
35
+ def to_epoch_ms(dt: datetime, *, arg_name: str = "dt") -> int:
36
+ """Convert a tz-aware datetime to integer epoch milliseconds.
37
+
38
+ Used for alarms, zone events, position history, and other endpoints
39
+ that accept ``from=`` / ``to=`` as millisecond integers.
40
+
41
+ Raises
42
+ ------
43
+ ValueError
44
+ If ``dt`` is naive (no tzinfo).
45
+ """
46
+ dt = _ensure_aware(dt, arg_name=arg_name)
47
+ return int(dt.timestamp() * 1000)
48
+
49
+
50
+ def to_epoch_seconds(dt: datetime, *, arg_name: str = "dt") -> int:
51
+ """Convert a tz-aware datetime to integer epoch seconds.
52
+
53
+ The ``/track_assoc?start=&end=`` endpoint uses seconds, not
54
+ milliseconds (RESEARCH §4 "Load trackables with active associations").
55
+ """
56
+ dt = _ensure_aware(dt, arg_name=arg_name)
57
+ return int(dt.timestamp())
58
+
59
+
60
+ def to_iso8601(dt: datetime, *, arg_name: str = "dt") -> str:
61
+ """Convert a tz-aware datetime to an ISO-8601 string with offset.
62
+
63
+ Used for the reports and external-events endpoints.
64
+ """
65
+ dt = _ensure_aware(dt, arg_name=arg_name)
66
+ return dt.isoformat()
67
+
68
+
69
+ def from_epoch_ms(value: int | float) -> datetime:
70
+ """Build a tz-aware UTC datetime from epoch milliseconds.
71
+
72
+ Used by models that surface server-side timestamps. The SDK chooses
73
+ UTC over local time so consumers can convert downstream without
74
+ ambiguity.
75
+ """
76
+ return datetime.fromtimestamp(value / 1000.0, tz=timezone.utc)
77
+
78
+
79
+ __all__ = [
80
+ "from_epoch_ms",
81
+ "to_epoch_ms",
82
+ "to_epoch_seconds",
83
+ "to_iso8601",
84
+ ]
@@ -0,0 +1,19 @@
1
+ """Compound multi-call workflows.
2
+
3
+ Each module in this package implements the multi-call sequences that
4
+ DESIGN §4 promised the SDK would collapse into single methods. Resource
5
+ sub-clients in ``rtls_sdk.resources`` delegate here.
6
+
7
+ Step-name conventions for :class:`PartialFailureError`
8
+ ------------------------------------------------------
9
+
10
+ When a compound fails mid-sequence the exception carries a
11
+ ``failed_step`` string. These strings are part of the SDK's public
12
+ contract — callers may key recovery logic off them. They never change
13
+ once shipped.
14
+
15
+ The strings used by each compound are documented in the docstring of
16
+ its public entry point on the resource sub-client.
17
+ """
18
+
19
+ from __future__ import annotations
@@ -0,0 +1,100 @@
1
+ """Compound auth workflows.
2
+
3
+ Currently ships just ``change_password``. Future compounds (SSO login,
4
+ session-bootstrap variants) may land here.
5
+
6
+ Step names for ``change_password``: ``validate_old``, ``validate_new``,
7
+ ``change_password``, ``reauthenticate``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from typing import TYPE_CHECKING
14
+
15
+ from ..errors import AuthenticationError, PartialFailureError, RtlsError
16
+
17
+ if TYPE_CHECKING:
18
+ from .._client import RtlsClient
19
+
20
+
21
+ logger = logging.getLogger("rtls_sdk.compounds.auth")
22
+
23
+
24
+ def change_password(client: RtlsClient, old_password: str, new_password: str) -> None:
25
+ """Change the current user's password and re-authenticate.
26
+
27
+ Sequence:
28
+
29
+ 1. ``validate_old``: confirm the supplied old password matches.
30
+ 2. ``validate_new``: confirm the new password passes server policy.
31
+ 3. ``change_password``: ``PUT /users/changePassword``.
32
+ 4. ``reauthenticate``: clear the current token, swap to the new
33
+ password, force a fresh login.
34
+
35
+ If step 4 fails (server accepted the change but the new login
36
+ fails for some reason — race, network blip), the SDK raises
37
+ :class:`AuthenticationError` with explicit guidance: the password
38
+ is changed server-side but the in-memory state can't continue;
39
+ reconstruct the client with the new password.
40
+ """
41
+ self_user = client.users.me()
42
+ self_uid = self_user.uid
43
+ if not self_uid:
44
+ raise RtlsError("change_password: current user has no uid — cannot proceed")
45
+
46
+ completed: list[str] = []
47
+
48
+ # Step 1: validate old password.
49
+ try:
50
+ client.users.validate(password=old_password, uid=self_uid, isOld=True)
51
+ except RtlsError as exc:
52
+ raise PartialFailureError(
53
+ f"auth.change_password: validate_old failed: {exc}",
54
+ completed_steps=completed,
55
+ failed_step="validate_old",
56
+ cause=exc,
57
+ ) from exc
58
+ completed.append("validate_old")
59
+
60
+ # Step 2: validate new password.
61
+ try:
62
+ client.users.validate(password=new_password, uid=self_uid, isOld=False)
63
+ except RtlsError as exc:
64
+ raise PartialFailureError(
65
+ f"auth.change_password: validate_new failed: {exc}",
66
+ completed_steps=completed,
67
+ failed_step="validate_new",
68
+ cause=exc,
69
+ ) from exc
70
+ completed.append("validate_new")
71
+
72
+ # Step 3: change password.
73
+ try:
74
+ client.users.change_password_raw(
75
+ self_uid, old_password=old_password, new_password=new_password
76
+ )
77
+ except RtlsError as exc:
78
+ raise PartialFailureError(
79
+ f"auth.change_password: change_password failed: {exc}",
80
+ completed_steps=completed,
81
+ failed_step="change_password",
82
+ cause=exc,
83
+ ) from exc
84
+ completed.append("change_password")
85
+
86
+ # Step 4: re-authenticate with the new password.
87
+ try:
88
+ client._auth_state.set_password(new_password)
89
+ client._auth_state.login_if_needed()
90
+ except (RtlsError, RuntimeError) as exc:
91
+ raise AuthenticationError(
92
+ "auth.change_password: the server accepted the password change "
93
+ "but re-authentication failed. The in-memory client state is "
94
+ "now stale — reconstruct the client with the new password and "
95
+ "your existing email.",
96
+ status_code=getattr(exc, "status_code", None),
97
+ ) from exc
98
+
99
+
100
+ __all__ = ["change_password"]