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,404 @@
1
+ """Compound tag workflows.
2
+
3
+ Each function here is the multi-call sequence behind one ``TagsAPI``
4
+ compound method. Rollback discipline (DESIGN §4.3 / §4.4 / §4.5):
5
+
6
+ - ``create`` rolls back tag creation if a subsequent step fails.
7
+ - ``update`` cannot truly roll back a partial update; it raises a
8
+ ``PartialFailureError`` with stable step names listing what completed.
9
+ - ``delete`` best-effort deletes the attached zone first; failure there
10
+ is logged and the tag delete proceeds.
11
+
12
+ The list of stable step-name strings is in each function's docstring.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from datetime import datetime
19
+ from typing import TYPE_CHECKING, Any, Final
20
+
21
+ from ..errors import PartialFailureError, RtlsError
22
+ from ..models import BulkFailure, BulkResult, Tag
23
+
24
+ if TYPE_CHECKING:
25
+ from .._client import RtlsClient
26
+
27
+
28
+ logger = logging.getLogger("rtls_sdk.compounds.tags")
29
+
30
+
31
+ # ZoneSpec — the dict shape callers pass to attach a zone in one go.
32
+ # Not a typed model in v1: the server accepts many fields and we want
33
+ # to pass through whatever the caller provides verbatim.
34
+ ZoneSpec = dict[str, Any]
35
+
36
+
37
+ class _Unset:
38
+ """Sentinel singleton for "argument not supplied" semantics."""
39
+
40
+ _instance: _Unset | None = None
41
+
42
+ def __new__(cls) -> _Unset:
43
+ if cls._instance is None:
44
+ cls._instance = super().__new__(cls)
45
+ return cls._instance
46
+
47
+ def __repr__(self) -> str:
48
+ return "UNSET"
49
+
50
+ def __bool__(self) -> bool:
51
+ return False
52
+
53
+
54
+ UNSET: Final[_Unset] = _Unset()
55
+
56
+
57
+ def create_tag(
58
+ client: RtlsClient,
59
+ name: str,
60
+ *,
61
+ attached_zone: ZoneSpec | None = None,
62
+ groups: list[str] | None = None,
63
+ **fields: Any,
64
+ ) -> Tag:
65
+ """Create a tag, optionally attaching a zone and group memberships.
66
+
67
+ Step names (stable, part of the API contract):
68
+ ``create_tag``, ``create_zone``, ``replace_groups``,
69
+ ``update_tag_with_zone_uid``.
70
+
71
+ Rollback: if any step after ``create_tag`` fails, the SDK tries to
72
+ ``delete_raw`` the freshly-created tag. If that rollback also fails,
73
+ :class:`PartialFailureError` carries both errors and a
74
+ ``partial_result`` with the orphaned uid for manual cleanup.
75
+ """
76
+ completed: list[str] = []
77
+ partial: dict[str, Any] = {"tag_uid": None, "zone_uid": None}
78
+
79
+ # Step 1: create the tag.
80
+ try:
81
+ tag = client.tags.create_raw(name=name, **fields)
82
+ except RtlsError:
83
+ raise
84
+ completed.append("create_tag")
85
+ partial["tag_uid"] = tag.uid
86
+
87
+ # Step 2: optionally attach a zone bound to this tag.
88
+ zone_uid: str | None = None
89
+ if attached_zone is not None:
90
+ try:
91
+ zone_payload = {
92
+ **attached_zone,
93
+ "trackable_object_uid": tag.uid,
94
+ "dynamic": True,
95
+ }
96
+ zone = client.zones.create_raw(name=zone_payload.pop("name"), **zone_payload)
97
+ zone_uid = zone.uid
98
+ except RtlsError as exc:
99
+ _rollback_tag(client, tag.uid, partial=partial)
100
+ raise PartialFailureError(
101
+ f"tags.create: create_zone failed: {exc}",
102
+ completed_steps=completed,
103
+ failed_step="create_zone",
104
+ cause=exc,
105
+ partial_result=partial,
106
+ ) from exc
107
+ completed.append("create_zone")
108
+ partial["zone_uid"] = zone_uid
109
+
110
+ # Step 3: optionally set group memberships.
111
+ if groups is not None:
112
+ try:
113
+ client.tags.replace_groups(tag.uid, groups, no_notify=True)
114
+ except RtlsError as exc:
115
+ _rollback_zone(client, zone_uid)
116
+ _rollback_tag(client, tag.uid, partial=partial)
117
+ raise PartialFailureError(
118
+ f"tags.create: replace_groups failed: {exc}",
119
+ completed_steps=completed,
120
+ failed_step="replace_groups",
121
+ cause=exc,
122
+ partial_result=partial,
123
+ ) from exc
124
+ completed.append("replace_groups")
125
+
126
+ # Step 4: if we created a bound zone, write self_zone_uid back onto
127
+ # the tag so its server-side state matches the binding.
128
+ final_tag: Tag = tag
129
+ if zone_uid is not None:
130
+ try:
131
+ final_tag = client.tags.update_raw(tag.uid, self_zone_uid=zone_uid)
132
+ except RtlsError as exc:
133
+ # Don't roll back here — the tag exists, the zone exists,
134
+ # they're bound on the zone side. The user can recover by
135
+ # calling tags.update(uid, attached_zone=...) themselves.
136
+ raise PartialFailureError(
137
+ f"tags.create: update_tag_with_zone_uid failed: {exc}",
138
+ completed_steps=completed,
139
+ failed_step="update_tag_with_zone_uid",
140
+ cause=exc,
141
+ partial_result=partial,
142
+ ) from exc
143
+ completed.append("update_tag_with_zone_uid")
144
+
145
+ return final_tag
146
+
147
+
148
+ def _rollback_tag(client: RtlsClient, uid: str, *, partial: dict[str, Any]) -> None:
149
+ """Best-effort delete of a tag created by a now-failing compound."""
150
+ try:
151
+ client.tags.delete_raw(uid)
152
+ partial["tag_uid"] = None
153
+ except RtlsError as exc:
154
+ logger.warning("rollback of created tag %s failed: %s", uid, exc)
155
+
156
+
157
+ def _rollback_zone(client: RtlsClient, uid: str | None) -> None:
158
+ if uid is None:
159
+ return
160
+ try:
161
+ client.zones.delete_raw(uid)
162
+ except RtlsError as exc:
163
+ logger.warning("rollback of created zone %s failed: %s", uid, exc)
164
+
165
+
166
+ def update_tag(
167
+ client: RtlsClient,
168
+ uid: str,
169
+ *,
170
+ attached_zone: ZoneSpec | None | _Unset = UNSET,
171
+ groups: list[str] | None | _Unset = UNSET,
172
+ **fields: Any,
173
+ ) -> Tag:
174
+ """Diff-driven update of a tag's fields, attached zone, and groups.
175
+
176
+ Sentinel ``...`` marks "leave unchanged". Pass ``attached_zone=None``
177
+ to explicitly *remove* the bound zone; pass a dict to set/replace it.
178
+ Same for ``groups``.
179
+
180
+ Step names (stable):
181
+ ``load_current_tag``, ``delete_attached_zone``,
182
+ ``create_attached_zone``, ``update_attached_zone``,
183
+ ``replace_groups``, ``update_tag``.
184
+
185
+ No rollback path — partial updates cannot be unwound cleanly. On
186
+ failure the SDK raises :class:`PartialFailureError` listing which
187
+ steps completed; the user reconciles state.
188
+ """
189
+ completed: list[str] = []
190
+
191
+ # Step 1: load current state so we can compute the zone diff.
192
+ try:
193
+ current = client.tags.get(uid)
194
+ except RtlsError as exc:
195
+ raise PartialFailureError(
196
+ f"tags.update: load_current_tag failed: {exc}",
197
+ completed_steps=completed,
198
+ failed_step="load_current_tag",
199
+ cause=exc,
200
+ ) from exc
201
+ completed.append("load_current_tag")
202
+
203
+ # Step 2: zone diff. UNSET means "leave unchanged".
204
+ if not isinstance(attached_zone, _Unset):
205
+ existing_zone_uid = current.self_zone_uid
206
+ if attached_zone is None and existing_zone_uid is not None:
207
+ # Removing.
208
+ step = "delete_attached_zone"
209
+ try:
210
+ client.zones.delete_raw(existing_zone_uid)
211
+ except RtlsError as exc:
212
+ raise PartialFailureError(
213
+ f"tags.update: {step} failed: {exc}",
214
+ completed_steps=completed,
215
+ failed_step=step,
216
+ cause=exc,
217
+ ) from exc
218
+ completed.append(step)
219
+ fields["self_zone_uid"] = None
220
+ elif attached_zone is not None and existing_zone_uid is None:
221
+ # Creating new bound zone.
222
+ step = "create_attached_zone"
223
+ try:
224
+ payload = {
225
+ **attached_zone,
226
+ "trackable_object_uid": uid,
227
+ "dynamic": True,
228
+ }
229
+ new_zone = client.zones.create_raw(name=payload.pop("name"), **payload)
230
+ except RtlsError as exc:
231
+ raise PartialFailureError(
232
+ f"tags.update: {step} failed: {exc}",
233
+ completed_steps=completed,
234
+ failed_step=step,
235
+ cause=exc,
236
+ ) from exc
237
+ completed.append(step)
238
+ fields["self_zone_uid"] = new_zone.uid
239
+ elif attached_zone is not None and existing_zone_uid is not None:
240
+ # Updating existing bound zone.
241
+ step = "update_attached_zone"
242
+ try:
243
+ client.zones.update_raw(existing_zone_uid, **attached_zone)
244
+ except RtlsError as exc:
245
+ raise PartialFailureError(
246
+ f"tags.update: {step} failed: {exc}",
247
+ completed_steps=completed,
248
+ failed_step=step,
249
+ cause=exc,
250
+ ) from exc
251
+ completed.append(step)
252
+
253
+ # Step 3: groups diff.
254
+ if not isinstance(groups, _Unset):
255
+ step = "replace_groups"
256
+ try:
257
+ client.tags.replace_groups(uid, groups or [], no_notify=True)
258
+ except RtlsError as exc:
259
+ raise PartialFailureError(
260
+ f"tags.update: {step} failed: {exc}",
261
+ completed_steps=completed,
262
+ failed_step=step,
263
+ cause=exc,
264
+ ) from exc
265
+ completed.append(step)
266
+
267
+ # Step 4: write remaining field updates onto the tag.
268
+ if fields:
269
+ step = "update_tag"
270
+ try:
271
+ tag = client.tags.update_raw(uid, **fields)
272
+ except RtlsError as exc:
273
+ raise PartialFailureError(
274
+ f"tags.update: {step} failed: {exc}",
275
+ completed_steps=completed,
276
+ failed_step=step,
277
+ cause=exc,
278
+ ) from exc
279
+ completed.append(step)
280
+ return tag
281
+
282
+ # No-op update — re-fetch to give the caller a fresh view.
283
+ return client.tags.get(uid)
284
+
285
+
286
+ def delete_tag(client: RtlsClient, target: Tag | str, *, skip_load: bool = False) -> None:
287
+ """Delete a tag, cascading to its attached zone if any.
288
+
289
+ Step names: ``load_tag``, ``delete_attached_zone``, ``delete_tag``.
290
+
291
+ ``delete_attached_zone`` failure is logged at WARNING and the
292
+ cascade continues — leaving an orphan zone behind is preferable to
293
+ leaving a half-deleted tag.
294
+
295
+ Pass a :class:`Tag` instance to skip the load step.
296
+ """
297
+ completed: list[str] = []
298
+ if isinstance(target, Tag):
299
+ tag: Tag = target
300
+ elif skip_load:
301
+ tag = Tag.from_wire({"uid": target, "name": "", "self_zone_uid": None})
302
+ else:
303
+ try:
304
+ tag = client.tags.get(target)
305
+ except RtlsError as exc:
306
+ raise PartialFailureError(
307
+ f"tags.delete: load_tag failed: {exc}",
308
+ completed_steps=completed,
309
+ failed_step="load_tag",
310
+ cause=exc,
311
+ ) from exc
312
+ completed.append("load_tag")
313
+
314
+ if tag.self_zone_uid:
315
+ try:
316
+ client.zones.delete_raw(tag.self_zone_uid)
317
+ completed.append("delete_attached_zone")
318
+ except RtlsError as exc:
319
+ logger.warning(
320
+ "tags.delete: orphan attached zone %s on tag %s (%s)",
321
+ tag.self_zone_uid,
322
+ tag.uid,
323
+ exc,
324
+ )
325
+
326
+ try:
327
+ client.tags.delete_raw(tag.uid)
328
+ except RtlsError as exc:
329
+ raise PartialFailureError(
330
+ f"tags.delete: delete_tag failed: {exc}",
331
+ completed_steps=completed,
332
+ failed_step="delete_tag",
333
+ cause=exc,
334
+ partial_result={"tag_uid": tag.uid},
335
+ ) from exc
336
+
337
+
338
+ def bulk_delete_tags(client: RtlsClient, targets: list[Tag | str]) -> BulkResult[None]:
339
+ """Loop :func:`delete_tag` over a list with continue-on-error semantics."""
340
+ result: BulkResult[None] = BulkResult()
341
+ for target in targets:
342
+ try:
343
+ delete_tag(client, target)
344
+ result.succeeded.append(None)
345
+ except RtlsError as exc:
346
+ uid = target.uid if isinstance(target, Tag) else target
347
+ result.failures.append(BulkFailure(error=exc, input=target, uid=uid))
348
+ return result
349
+
350
+
351
+ def bulk_update_tags(
352
+ client: RtlsClient, uids: list[str], changes: dict[str, Any]
353
+ ) -> BulkResult[Tag]:
354
+ """Loop :func:`update_tag` over a list with continue-on-error.
355
+
356
+ ``changes`` is applied identically to each uid. ``attached_zone`` and
357
+ ``groups`` are treated as ordinary fields here — they're delegated
358
+ to :func:`update_tag` which interprets them.
359
+ """
360
+ attached_zone = changes.pop("attached_zone", UNSET)
361
+ groups = changes.pop("groups", UNSET)
362
+ result: BulkResult[Tag] = BulkResult()
363
+ for uid in uids:
364
+ try:
365
+ tag = update_tag(
366
+ client,
367
+ uid,
368
+ attached_zone=attached_zone,
369
+ groups=groups,
370
+ **changes,
371
+ )
372
+ result.succeeded.append(tag)
373
+ except RtlsError as exc:
374
+ result.failures.append(BulkFailure(error=exc, input=uid, uid=uid))
375
+ return result
376
+
377
+
378
+ def list_active_in_period(
379
+ client: RtlsClient,
380
+ sublocation_uid: str,
381
+ start: datetime,
382
+ end: datetime,
383
+ ) -> list[Tag]:
384
+ """Inner-join trackables and active associations over a window.
385
+
386
+ Two underlying calls — both are required; if either fails, raise
387
+ (rather than return a half-joined result that pretends to be
388
+ complete).
389
+ """
390
+ tags = client.tags.list_active_for_period(start, end, sublocation_uid=sublocation_uid)
391
+ associations = client.associations.list_active_in_period(start, end)
392
+ bound_uids = {assoc.obj_uid for assoc in associations if assoc.obj_uid}
393
+ return [tag for tag in tags if tag.uid in bound_uids]
394
+
395
+
396
+ __all__ = [
397
+ "ZoneSpec",
398
+ "bulk_delete_tags",
399
+ "bulk_update_tags",
400
+ "create_tag",
401
+ "delete_tag",
402
+ "list_active_in_period",
403
+ "update_tag",
404
+ ]
@@ -0,0 +1,143 @@
1
+ """Compound user workflows.
2
+
3
+ Step-name conventions:
4
+
5
+ - ``create``: ``create_user``, ``bind_project_role``, ``delete_user_rollback``.
6
+ - ``update``: ``update_user``, ``bind_project_role``.
7
+
8
+ A successful ``create`` rolls back the freshly-created user if the
9
+ project-role binding step fails. ``update`` does not have a clean
10
+ rollback path — partial updates land in ``PartialFailureError`` with
11
+ ``completed_steps`` for the caller to reconcile.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from ..errors import PartialFailureError, RtlsError
20
+ from ..models import Role, User
21
+
22
+ if TYPE_CHECKING:
23
+ from .._client import RtlsClient
24
+
25
+
26
+ logger = logging.getLogger("rtls_sdk.compounds.users")
27
+
28
+
29
+ def create_user(
30
+ client: RtlsClient,
31
+ email: str,
32
+ password: str,
33
+ *,
34
+ project_uid: str | None = None,
35
+ project_role: str | Role | None = None,
36
+ **fields: Any,
37
+ ) -> User:
38
+ """Create a user and optionally bind them to a project role.
39
+
40
+ Step names: ``create_user``, ``bind_project_role``,
41
+ ``delete_user_rollback``.
42
+
43
+ If the user comes back from the server as ``COMPANY_ADMIN``, the
44
+ role binding is skipped — they already have implicit access at the
45
+ company level (matches the JS reference client's check).
46
+ """
47
+ user = client.users.create_raw(email, password, **fields)
48
+ completed = ["create_user"]
49
+
50
+ if project_role is not None and project_uid is not None:
51
+ if user.role == Role.COMPANY_ADMIN:
52
+ # Company admins don't need per-project role assignment.
53
+ return user
54
+
55
+ role_str = project_role.value if isinstance(project_role, Role) else project_role
56
+ try:
57
+ client.users.update_access(project_uid, uid=user.uid or "", role=role_str)
58
+ except RtlsError as exc:
59
+ # Roll back the user create.
60
+ rollback_uid = user.uid
61
+ try:
62
+ if rollback_uid:
63
+ client.users.delete(rollback_uid)
64
+ completed.append("delete_user_rollback")
65
+ partial: dict[str, Any] = {"user_uid": None}
66
+ except RtlsError as rollback_exc:
67
+ logger.warning(
68
+ "rollback of created user %s failed: %s",
69
+ rollback_uid,
70
+ rollback_exc,
71
+ )
72
+ partial = {"user_uid": rollback_uid}
73
+ raise PartialFailureError(
74
+ f"users.create: bind_project_role failed: {exc}",
75
+ completed_steps=completed,
76
+ failed_step="bind_project_role",
77
+ cause=exc,
78
+ partial_result=partial,
79
+ ) from exc
80
+ return user
81
+
82
+
83
+ def update_user(
84
+ client: RtlsClient,
85
+ uid: str,
86
+ *,
87
+ project_uid: str | None = None,
88
+ project_role: str | Role | None = None,
89
+ **fields: Any,
90
+ ) -> User:
91
+ """Update a user's profile + (optionally) their project role.
92
+
93
+ Step names: ``update_user``, ``bind_project_role``.
94
+
95
+ No rollback — server-side updates can't be cleanly reversed. On
96
+ partial failure the SDK raises :class:`PartialFailureError` with
97
+ ``completed_steps=["update_user"]`` so the caller knows the user's
98
+ fields were updated but their role binding wasn't.
99
+
100
+ Self-edit handling: if the user being updated is the SDK's current
101
+ user, and the update changes the email, the SDK refreshes the
102
+ stored email so subsequent ``X-User-Email`` headers stay valid.
103
+ """
104
+ # Identify self-edit BEFORE the update so we know what to refresh
105
+ # after. The lazy lookup of user_uid via me() is gated on email
106
+ # being part of the update — no extra GET if we're not changing
107
+ # email or scope.
108
+ self_uid = client._auth_state.user_uid
109
+ if self_uid is None and "email" in fields:
110
+ try:
111
+ self_uid = client.users.me().uid
112
+ except RtlsError:
113
+ self_uid = None
114
+
115
+ user = client.users.update_raw(uid, **fields)
116
+ completed = ["update_user"]
117
+
118
+ # Self-edit detection: refresh the auth state's cached email.
119
+ if self_uid and uid == self_uid:
120
+ new_email = fields.get("email")
121
+ if isinstance(new_email, str):
122
+ client._auth_state._email = new_email
123
+
124
+ if project_role is not None and project_uid is not None:
125
+ if user.role == Role.COMPANY_ADMIN:
126
+ return user
127
+
128
+ role_str = project_role.value if isinstance(project_role, Role) else project_role
129
+ try:
130
+ client.users.update_access(project_uid, uid=uid, role=role_str)
131
+ except RtlsError as exc:
132
+ raise PartialFailureError(
133
+ f"users.update: bind_project_role failed: {exc}",
134
+ completed_steps=completed,
135
+ failed_step="bind_project_role",
136
+ cause=exc,
137
+ ) from exc
138
+ completed.append("bind_project_role")
139
+
140
+ return user
141
+
142
+
143
+ __all__ = ["create_user", "update_user"]