pushmetrics-cli 0.1.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 (86) hide show
  1. pm/__init__.py +1 -0
  2. pm/__main__.py +4 -0
  3. pm/_alias.py +164 -0
  4. pm/auth/__init__.py +0 -0
  5. pm/auth/device_flow.py +187 -0
  6. pm/auth/exchange.py +79 -0
  7. pm/auth/jwt_cache.py +163 -0
  8. pm/auth/secret_files.py +58 -0
  9. pm/auth/session.py +440 -0
  10. pm/auth/store.py +160 -0
  11. pm/auth/token.py +289 -0
  12. pm/cli.py +282 -0
  13. pm/client/__init__.py +68 -0
  14. pm/client/access_tokens.py +54 -0
  15. pm/client/base.py +229 -0
  16. pm/client/blocks.py +461 -0
  17. pm/client/databases.py +147 -0
  18. pm/client/favorites.py +14 -0
  19. pm/client/folders.py +10 -0
  20. pm/client/integrations.py +14 -0
  21. pm/client/organizations.py +20 -0
  22. pm/client/recipients.py +92 -0
  23. pm/client/reports.py +148 -0
  24. pm/client/roles.py +100 -0
  25. pm/client/runs.py +134 -0
  26. pm/client/saved_blocks.py +13 -0
  27. pm/client/tableau.py +108 -0
  28. pm/client/users.py +71 -0
  29. pm/client/webhooks.py +43 -0
  30. pm/client/workspaces.py +34 -0
  31. pm/commands/__init__.py +0 -0
  32. pm/commands/_block_factory.py +762 -0
  33. pm/commands/_helpers.py +178 -0
  34. pm/commands/auth.py +222 -0
  35. pm/commands/block.py +26 -0
  36. pm/commands/chart.py +20 -0
  37. pm/commands/db.py +291 -0
  38. pm/commands/email.py +13 -0
  39. pm/commands/explore.py +20 -0
  40. pm/commands/favorites.py +77 -0
  41. pm/commands/folders.py +113 -0
  42. pm/commands/help.py +643 -0
  43. pm/commands/integrations.py +95 -0
  44. pm/commands/metrics_view.py +195 -0
  45. pm/commands/organizations.py +56 -0
  46. pm/commands/recipient.py +226 -0
  47. pm/commands/reports.py +364 -0
  48. pm/commands/roles.py +102 -0
  49. pm/commands/runs.py +214 -0
  50. pm/commands/saved_blocks.py +97 -0
  51. pm/commands/sftp.py +19 -0
  52. pm/commands/slack.py +13 -0
  53. pm/commands/slides.py +216 -0
  54. pm/commands/sql.py +17 -0
  55. pm/commands/tableau.py +148 -0
  56. pm/commands/token.py +107 -0
  57. pm/commands/users.py +262 -0
  58. pm/commands/webhooks.py +132 -0
  59. pm/commands/workspace.py +156 -0
  60. pm/config.py +192 -0
  61. pm/errors.py +228 -0
  62. pm/log.py +63 -0
  63. pm/models/__init__.py +31 -0
  64. pm/models/access_tokens.py +25 -0
  65. pm/models/blocks.py +21 -0
  66. pm/models/databases.py +15 -0
  67. pm/models/favorites.py +14 -0
  68. pm/models/folders.py +14 -0
  69. pm/models/integrations.py +20 -0
  70. pm/models/organizations.py +13 -0
  71. pm/models/recipients.py +41 -0
  72. pm/models/reports.py +16 -0
  73. pm/models/roles.py +22 -0
  74. pm/models/runs.py +56 -0
  75. pm/models/saved_blocks.py +15 -0
  76. pm/models/users.py +36 -0
  77. pm/models/webhooks.py +23 -0
  78. pm/models/workspaces.py +16 -0
  79. pm/output.py +92 -0
  80. pm/types.py +130 -0
  81. pm/validators.py +499 -0
  82. pushmetrics_cli-0.1.0.dist-info/METADATA +221 -0
  83. pushmetrics_cli-0.1.0.dist-info/RECORD +86 -0
  84. pushmetrics_cli-0.1.0.dist-info/WHEEL +5 -0
  85. pushmetrics_cli-0.1.0.dist-info/entry_points.txt +2 -0
  86. pushmetrics_cli-0.1.0.dist-info/top_level.txt +1 -0
pm/client/databases.py ADDED
@@ -0,0 +1,147 @@
1
+ """Database endpoints."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict, List
5
+
6
+ from pm.client.base import CrudClient, PMApiError, _unwrap_result, raise_for_status
7
+ from pm.models.databases import Database
8
+
9
+
10
+ class DatabasesClient(CrudClient[Database]):
11
+ base_path = "/api/v1/database"
12
+ model = Database
13
+
14
+ def resolve_uid(self, ref: str) -> str:
15
+ """Resolve a database UID or name (case-insensitive) to its UID.
16
+
17
+ The metadata endpoints below (``/schemas/``, ``/schema/<schema>/``,
18
+ ``/table/<table>/<schema>/``, ``/select_star_sql/...``,
19
+ ``/table_exists/...``, ``/search_tables/``, ``/function_names/``)
20
+ accept either a UID hashid or a numeric id in the ``<pk>`` slot,
21
+ but UIDs are the public identifier — we always go through UIDs to
22
+ stay consistent with the rest of the CLI surface.
23
+
24
+ If the caller already passed an exact UID, this still validates it
25
+ against the workspace's database list (cheap — single round-trip).
26
+ """
27
+ items = self.list()
28
+ for db in items:
29
+ if db.uid and db.uid == ref:
30
+ return db.uid
31
+ for db in items:
32
+ if (
33
+ db.database_name
34
+ and db.database_name.lower() == ref.lower()
35
+ and db.uid
36
+ ):
37
+ return db.uid
38
+ raise PMApiError(
39
+ 404,
40
+ f"Database not found by uid/name: {ref!r}",
41
+ method="GET",
42
+ path=f"{self.base_path}/",
43
+ )
44
+
45
+ def schemas(self, ref: str, *, force: bool = False) -> List[str]:
46
+ """List schemas in a database. Pass ``force=True`` to bypass cache."""
47
+ uid = self.resolve_uid(ref)
48
+ params: Dict[str, Any] = {}
49
+ if force:
50
+ # Rison-encoded query the schemas endpoint expects.
51
+ params["q"] = "(force:!t)"
52
+ resp = self.session.get(f"{self.base_path}/{uid}/schemas/", params=params)
53
+ raise_for_status(resp)
54
+ return _unwrap_result(resp.json(), default=[]) or []
55
+
56
+ def tables(self, ref: str, schema: str) -> List[Dict[str, Any]]:
57
+ """List tables and views in a schema.
58
+
59
+ Uses ``?action=tree`` so the backend returns structured dicts
60
+ (``{name, type, databaseId, schemaName, ...}``) rather than the
61
+ default ``DatasourceName`` tuples (which serialize as 2-element
62
+ lists). Easier to render and filter on the client.
63
+ """
64
+ uid = self.resolve_uid(ref)
65
+ resp = self.session.get(
66
+ f"{self.base_path}/{uid}/schema/{schema}/",
67
+ params={"action": "tree"},
68
+ )
69
+ raise_for_status(resp)
70
+ return _unwrap_result(resp.json(), default=[]) or []
71
+
72
+ def table_metadata(self, ref: str, table: str, schema: str) -> Dict[str, Any]:
73
+ """Describe a table: columns, primary key, foreign keys, indexes, comment.
74
+
75
+ Returns the full payload from ``/database/<uid>/table/<table>/<schema>/``
76
+ — top-level keys include ``name``, ``columns``, ``primaryKey``,
77
+ ``foreignKeys``, ``indexes``, ``comment``, ``selectStar``.
78
+ """
79
+ uid = self.resolve_uid(ref)
80
+ resp = self.session.get(
81
+ f"{self.base_path}/{uid}/table/{table}/{schema}/",
82
+ )
83
+ raise_for_status(resp)
84
+ body = resp.json()
85
+ return body if isinstance(body, dict) else {"result": body}
86
+
87
+ def select_star_sql(self, ref: str, table: str, schema: str, *, limit: int = 100) -> str:
88
+ """Return the dialect-correct ``SELECT * FROM <schema>.<table> LIMIT N``.
89
+
90
+ Does NOT execute the query — the canonical ``select_star`` endpoint
91
+ does that. Use this when you want the SQL as a template.
92
+ """
93
+ uid = self.resolve_uid(ref)
94
+ resp = self.session.get(
95
+ f"{self.base_path}/{uid}/select_star_sql/{table}/{schema}/",
96
+ params={"limit": limit},
97
+ )
98
+ raise_for_status(resp)
99
+ return _unwrap_result(resp.json(), default="") or ""
100
+
101
+ def table_exists(self, ref: str, table: str, schema: str) -> Dict[str, Any]:
102
+ """Return ``{exists, type, resolved_name, table, schema}``.
103
+
104
+ ``resolved_name`` echoes the matched identifier — different from the
105
+ input on case-folding dialects (Snowflake/Oracle).
106
+ """
107
+ uid = self.resolve_uid(ref)
108
+ resp = self.session.get(
109
+ f"{self.base_path}/{uid}/table_exists/{table}/{schema}/",
110
+ )
111
+ raise_for_status(resp)
112
+ return _unwrap_result(resp.json(), default={}) or {}
113
+
114
+ def search_tables(self, ref: str, pattern: str, *, schema: str = "") -> Dict[str, Any]:
115
+ """Search tables and views by substring across all non-system schemas
116
+ (or a single ``schema`` when provided).
117
+
118
+ Returns ``{pattern, count, results: [{schema, name, type}, ...]}``.
119
+ """
120
+ uid = self.resolve_uid(ref)
121
+ params: Dict[str, Any] = {"pattern": pattern}
122
+ if schema:
123
+ params["schema"] = schema
124
+ resp = self.session.get(
125
+ f"{self.base_path}/{uid}/search_tables/",
126
+ params=params,
127
+ )
128
+ raise_for_status(resp)
129
+ return _unwrap_result(resp.json(), default={}) or {}
130
+
131
+ def function_names(
132
+ self, ref: str, *, filter: str = "", limit: int = 100
133
+ ) -> Dict[str, Any]:
134
+ """List SQL function names supported by the database engine.
135
+
136
+ Returns ``{functions, count, total, backend, [filter], [note]}``.
137
+ """
138
+ uid = self.resolve_uid(ref)
139
+ params: Dict[str, Any] = {"limit": limit}
140
+ if filter:
141
+ params["filter"] = filter
142
+ resp = self.session.get(
143
+ f"{self.base_path}/{uid}/function_names/",
144
+ params=params,
145
+ )
146
+ raise_for_status(resp)
147
+ return _unwrap_result(resp.json(), default={}) or {}
pm/client/favorites.py ADDED
@@ -0,0 +1,14 @@
1
+ """Favorite endpoints.
2
+
3
+ Backend supports full CRUD but `edit_columns` is empty (no meaningful PUT).
4
+ The CLI only exposes ls/create/delete at the commands layer to match the FE.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from pm.client.base import CrudClient
9
+ from pm.models.favorites import Favorite
10
+
11
+
12
+ class FavoritesClient(CrudClient[Favorite]):
13
+ base_path = "/api/v1/favorite"
14
+ model = Favorite
pm/client/folders.py ADDED
@@ -0,0 +1,10 @@
1
+ """Folder endpoints (full CRUD)."""
2
+ from __future__ import annotations
3
+
4
+ from pm.client.base import CrudClient
5
+ from pm.models.folders import Folder
6
+
7
+
8
+ class FoldersClient(CrudClient[Folder]):
9
+ base_path = "/api/v1/folder"
10
+ model = Folder
@@ -0,0 +1,14 @@
1
+ """Integration endpoints — list / get only.
2
+
3
+ Connect/disconnect/edit go through the FE wizard because their payloads
4
+ are AES-encrypted (OAuth tokens for Slack / Tableau / GDrive etc.).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from pm.client.base import CrudClient
9
+ from pm.models.integrations import Integration
10
+
11
+
12
+ class IntegrationsClient(CrudClient[Integration]):
13
+ base_path = "/api/v1/integration"
14
+ model = Integration
@@ -0,0 +1,20 @@
1
+ """Organization endpoints.
2
+
3
+ Backend supports GET / POST / PUT / `current`. **No GET_LIST** — the FE lists
4
+ via `/es/organization/` (Elasticsearch). The CLI omits `list()` for now;
5
+ use `pm orgs current` and `pm orgs get <uid>`.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from pm.client.base import CrudClient, raise_for_status
10
+ from pm.models.organizations import Organization
11
+
12
+
13
+ class OrganizationsClient(CrudClient[Organization]):
14
+ base_path = "/api/v1/organization"
15
+ model = Organization
16
+
17
+ def current(self) -> Organization:
18
+ resp = self.session.get(f"{self.base_path}/current")
19
+ raise_for_status(resp)
20
+ return Organization.model_validate(resp.json())
@@ -0,0 +1,92 @@
1
+ """Recipients API client — wraps ``/api/v1/recipient/``.
2
+
3
+ Standard FAB CRUD (get / create / update / delete) inherited from
4
+ :class:`CrudClient`. ``list`` is overridden because
5
+ ``RecipientRestApi._get_list_headless`` returns
6
+ ``{results: [...], count: ...}`` (note ``results`` plural — the
7
+ endpoint pre-dates FAB's standard ``{result: [...]}`` shape) rather
8
+ than the standard envelope. Without the override, ``_unwrap_result``
9
+ returns the whole response dict and the model validator wraps it as
10
+ a single mostly-empty Recipient (codex review P2: real-world bug
11
+ that mock-based tests never caught).
12
+
13
+ The ``details`` route is also added on top — it returns the
14
+ recipient plus its destinations, connected reports, and recent
15
+ executions, which is the agent's primary read-path (one roundtrip
16
+ instead of three).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ from pm.client.base import CrudClient, PMApiError, _unwrap_result, raise_for_status
23
+ from pm.models.recipients import Recipient
24
+
25
+
26
+ class RecipientsClient(CrudClient[Recipient]):
27
+ base_path = "/api/v1/recipient"
28
+ model = Recipient
29
+
30
+ def list(
31
+ self, params: Optional[Dict[str, Any]] = None,
32
+ ) -> List[Recipient]:
33
+ """Recipient list — backend returns ``{results, count, ids}``
34
+ instead of FAB's standard ``{result: [...]}``. Override
35
+ unwrapping to read ``results``."""
36
+ resp = self.session.get(f"{self.base_path}/", params=params)
37
+ raise_for_status(resp)
38
+ body = resp.json() if resp.content else {}
39
+ if isinstance(body, dict) and "results" in body:
40
+ raw = body["results"]
41
+ else:
42
+ # Defensive: tolerate the standard shape too.
43
+ raw = _unwrap_result(body, default=[])
44
+ if not isinstance(raw, list):
45
+ raw = [raw] if raw else []
46
+ return [self.model.model_validate(item) for item in raw]
47
+
48
+ def create(self, data: Dict[str, Any]) -> Recipient:
49
+ """Create a recipient, returning a fully-hydrated model.
50
+
51
+ FAB's POST response returns ``destinations`` as a list of bare
52
+ FK ids (e.g. ``[743]``), which doesn't satisfy the ``Recipient``
53
+ model's nested ``RecipientDestination`` shape and makes the
54
+ generic ``CrudClient.create`` raise a ``ValidationError``. Re-fetch
55
+ the created recipient by its uid so the caller gets destinations as
56
+ dicts - mirrors how the web app reloads after a write.
57
+ """
58
+ resp = self.session.post(f"{self.base_path}/", json=data)
59
+ raise_for_status(resp)
60
+ result = _unwrap_result(resp.json(), default={})
61
+ uid = result.get("uid") if isinstance(result, dict) else None
62
+ if not uid:
63
+ # Without a uid we can't re-fetch, and the POST body's
64
+ # ``destinations`` are bare ids that won't validate - surface a
65
+ # clear error rather than returning a half-built / uid-less model.
66
+ raise PMApiError(
67
+ resp.status_code,
68
+ "recipient create response missing 'uid'; cannot re-fetch",
69
+ method="POST",
70
+ path=f"{self.base_path}/",
71
+ )
72
+ return self.get(uid)
73
+
74
+ def update(self, uid: str, data: Dict[str, Any]) -> Recipient:
75
+ """Update a recipient, returning a fully-hydrated model.
76
+
77
+ Same FAB id-list ``destinations`` shape as ``create`` (and FAB PUT
78
+ often echoes only ``edit_columns``), so re-fetch by uid rather than
79
+ trusting the PUT response body.
80
+ """
81
+ resp = self.session.put(f"{self.base_path}/{uid}", json=data)
82
+ raise_for_status(resp)
83
+ return self.get(uid)
84
+
85
+ def details(self, uid: str) -> Dict[str, Any]:
86
+ """Fetch a recipient with destinations + connected_reports +
87
+ recent_executions in one call. Returns the raw response dict —
88
+ the connected_reports / recent_executions shapes are open and
89
+ evolve faster than a fixed Pydantic model would tolerate."""
90
+ resp = self.session.get(f"{self.base_path}/{uid}/details")
91
+ raise_for_status(resp)
92
+ return _unwrap_result(resp.json(), default={}) or {}
pm/client/reports.py ADDED
@@ -0,0 +1,148 @@
1
+ """Report endpoints (CRUD + custom run/runs)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from pm.client.base import CrudClient, _unwrap_result, raise_for_status
7
+ from pm.models.reports import Report
8
+ from pm.models.runs import Run
9
+
10
+
11
+ class ReportsClient(CrudClient[Report]):
12
+ base_path = "/api/v1/report"
13
+ model = Report
14
+
15
+ def run(
16
+ self,
17
+ uid: str,
18
+ *,
19
+ send_to_me: bool = False,
20
+ email: Optional[str] = None,
21
+ ) -> Dict[str, Any]:
22
+ """Trigger a noteflow run.
23
+
24
+ Hits ``POST /api/v1/report/<uid>/run/noteflow``. Returns the raw
25
+ backend response — typically ``{result: {noteflow_run_uid: ...}}``.
26
+
27
+ ``send_to_me=True`` causes the backend's
28
+ ``execute_options_to_block_properties`` to compute per-block
29
+ property overrides. **Without an ``email`` value, the backend
30
+ treats it as ``False`` and SUPPRESSES email blocks** rather than
31
+ redirecting them. For a true "send to me" preview that actually
32
+ delivers an email, pass the caller's verified email address here.
33
+ Other addresses must already be saved email recipients in the report
34
+ workspace. The backend rejects unknown or foreign-workspace addresses.
35
+
36
+ Non-email destination blocks (slack, sftp, webhook, cloud storage)
37
+ are always suppressed under ``send_to_me=True`` because there's
38
+ no equivalent "redirect to me" semantics — no per-user slack
39
+ channel or per-user S3 bucket.
40
+ """
41
+ if email and not send_to_me:
42
+ raise ValueError("email requires send_to_me=True")
43
+ body: Dict[str, Any] = {}
44
+ if send_to_me:
45
+ options: Dict[str, Any] = {"send_to_me": True}
46
+ if email:
47
+ # Backend reads ``options.email`` and uses it as the
48
+ # ``to_`` address on every email block; falsy / missing
49
+ # means "suppress email blocks entirely".
50
+ options["email"] = email
51
+ body["options"] = options
52
+ resp = self.session.post(
53
+ f"{self.base_path}/{uid}/run/noteflow",
54
+ json=body if body else None,
55
+ )
56
+ raise_for_status(resp)
57
+ return resp.json()
58
+
59
+ def yaml(self, uid: str) -> str:
60
+ """Export a report as a raw YAML string.
61
+
62
+ Hits ``GET /api/v1/report/<uid>/config`` which returns
63
+ ``text/plain; charset=utf-8`` — not JSON. Returns the raw text.
64
+ """
65
+ resp = self.session.get(f"{self.base_path}/{uid}/config")
66
+ raise_for_status(resp)
67
+ return resp.text
68
+
69
+ def apply_yaml(
70
+ self,
71
+ yaml_str: str,
72
+ *,
73
+ allow_create_folder: bool = False,
74
+ ) -> Dict[str, Any]:
75
+ """Create a new report from a raw YAML string.
76
+
77
+ Hits ``POST /api/v1/report/config`` with the YAML as the request
78
+ body (``text/plain`` — not JSON-wrapped). Returns
79
+ ``{uid, name, type, [warning_duplicate_uids]}``. The backend
80
+ also auto-fills top-level ``destinations:`` from block types and
81
+ attaches the new report's permission row before commit.
82
+
83
+ ``allow_create_folder=True`` passes ``?allow_create_folder=true``
84
+ so a YAML ``folder:`` path that doesn't fully exist gets created
85
+ silently. Default ``False`` rejects unknown paths — useful when
86
+ the YAML came from a fallible source (agent, hand-edited file)
87
+ and you'd rather catch typos than spawn ghost folders.
88
+ """
89
+ params: Dict[str, str] = {}
90
+ if allow_create_folder:
91
+ params["allow_create_folder"] = "true"
92
+ resp = self.session.post(
93
+ f"{self.base_path}/config",
94
+ data=yaml_str.encode("utf-8"),
95
+ headers={"Content-Type": "text/plain; charset=utf-8"},
96
+ params=params or None,
97
+ )
98
+ raise_for_status(resp)
99
+ return _unwrap_result(resp.json(), default={}) or {}
100
+
101
+ def update_yaml(
102
+ self,
103
+ uid: str,
104
+ yaml_str: str,
105
+ *,
106
+ prune: bool = True,
107
+ dry_run: bool = False,
108
+ allow_create_folder: bool = False,
109
+ ) -> Dict[str, Any]:
110
+ """Update an existing report from a raw YAML string.
111
+
112
+ Hits ``PUT /api/v1/report/<uid>/config`` with the YAML as the
113
+ request body.
114
+
115
+ - ``prune`` (default ``True``): blocks absent from the YAML are
116
+ removed from the report (YAML is source of truth). Set ``False``
117
+ for upsert-no-delete semantics (kept blocks stay).
118
+ - ``dry_run`` (default ``False``): server applies in a savepoint,
119
+ captures before/after, ROLLS BACK. Returns the diff payload
120
+ ``{would_change, summary, yaml_diff}`` instead of ``{uid, name}``.
121
+ - ``allow_create_folder`` (default ``False``): allow YAML
122
+ ``folder:`` path that doesn't exist to be created on the fly.
123
+ """
124
+ params: Dict[str, str] = {}
125
+ if not prune:
126
+ params["prune"] = "false"
127
+ if dry_run:
128
+ params["dry_run"] = "true"
129
+ if allow_create_folder:
130
+ params["allow_create_folder"] = "true"
131
+ resp = self.session.put(
132
+ f"{self.base_path}/{uid}/config",
133
+ data=yaml_str.encode("utf-8"),
134
+ headers={"Content-Type": "text/plain; charset=utf-8"},
135
+ params=params or None,
136
+ )
137
+ raise_for_status(resp)
138
+ return _unwrap_result(resp.json(), default={}) or {}
139
+
140
+ def runs(self, uid: str) -> List[Run]:
141
+ """List runs of a specific report (hits /noteflow_run/, not /report/)."""
142
+ resp = self.session.get("/api/v1/noteflow_run/", params={"report_uid": uid})
143
+ raise_for_status(resp)
144
+ body = resp.json()
145
+ raw = body.get("result", []) if isinstance(body, dict) else body
146
+ items = [Run.model_validate(item) for item in raw]
147
+ filtered = [r for r in items if getattr(r, "report_uid", None) == uid]
148
+ return filtered or items
pm/client/roles.py ADDED
@@ -0,0 +1,100 @@
1
+ """Role / group endpoints.
2
+
3
+ Three main roles (Admin / User / Guest) live alongside any number of
4
+ "groups" (other roles in ``ab_role`` minus a few hidden ones like
5
+ ``Everyone`` and per-user roles — see ``RoleRestApi.base_filters``).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Dict, List
10
+
11
+ from pm.client.base import CrudClient, PMApiError, raise_for_status
12
+ from pm.models.roles import Role
13
+
14
+
15
+ # The three main-role names recognized by ``security/models.py:MainRoles``.
16
+ # A user has exactly one of these in their ``roles`` list at any time;
17
+ # everything else is a group.
18
+ MAIN_ROLE_NAMES = ("Admin", "User", "Guest")
19
+
20
+
21
+ class RolesClient(CrudClient[Role]):
22
+ base_path = "/api/v1/role"
23
+ model = Role
24
+
25
+ def resolve_uid(self, ref: str) -> str:
26
+ """Accept a role UID or name (case-insensitive), return the UID.
27
+
28
+ Costs one ``/role/`` list call. Cheap — workspaces typically
29
+ have <50 roles.
30
+ """
31
+ items = self.list()
32
+ for r in items:
33
+ if r.uid and r.uid == ref:
34
+ return r.uid
35
+ ref_lc = ref.lower()
36
+ for r in items:
37
+ if r.name and r.name.lower() == ref_lc:
38
+ if r.uid:
39
+ return r.uid
40
+ raise PMApiError(
41
+ 404,
42
+ f"Role not found by uid/name: {ref!r}",
43
+ method="GET",
44
+ path=f"{self.base_path}/",
45
+ )
46
+
47
+ def assign_users(self, role_ref: str, user_uids: List[str]) -> Dict[str, Any]:
48
+ """Add users to a role / group.
49
+
50
+ Backend endpoint accepts UIDs (not integer IDs) in the body. New
51
+ users that are already in the role are silently skipped — the
52
+ server-side loop checks ``existing_users`` before inserting.
53
+ """
54
+ uid = self.resolve_uid(role_ref)
55
+ resp = self.session.post(
56
+ f"{self.base_path}/{uid}/assign_users",
57
+ json={"users_uid": list(user_uids)},
58
+ )
59
+ raise_for_status(resp)
60
+ body = resp.json()
61
+ return body.get("result", body) if isinstance(body, dict) else body
62
+
63
+ def revoke_users(self, role_ref: str, user_uids: List[str]) -> Dict[str, Any]:
64
+ """Remove users from a role / group (inverse of ``assign_users``)."""
65
+ uid = self.resolve_uid(role_ref)
66
+ resp = self.session.post(
67
+ f"{self.base_path}/{uid}/revoke_users",
68
+ json={"users_uid": list(user_uids)},
69
+ )
70
+ raise_for_status(resp)
71
+ body = resp.json()
72
+ return body.get("result", body) if isinstance(body, dict) else body
73
+
74
+ def find_main_role_id(self, name: str) -> int:
75
+ """Resolve ``Admin`` / ``User`` / ``Guest`` (case-insensitive)
76
+ to the integer ``role.id`` in the current workspace.
77
+
78
+ Integer ID is what the user PUT body needs — ``pre_update`` does
79
+ ``filter(lambda r: r.id == new_main_role, main_roles)``.
80
+ """
81
+ wanted_lc = name.lower()
82
+ matches = [n for n in MAIN_ROLE_NAMES if n.lower() == wanted_lc]
83
+ if not matches:
84
+ raise PMApiError(
85
+ 400,
86
+ f"{name!r} is not a main role. Use one of: {', '.join(MAIN_ROLE_NAMES)}.",
87
+ method="GET",
88
+ path=f"{self.base_path}/",
89
+ )
90
+ target_name = matches[0]
91
+ for r in self.list():
92
+ if r.name == target_name and r.id is not None:
93
+ return r.id
94
+ raise PMApiError(
95
+ 404,
96
+ f"Main role {target_name!r} not found in current workspace. "
97
+ "Has this workspace been initialised correctly?",
98
+ method="GET",
99
+ path=f"{self.base_path}/",
100
+ )