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.
- pm/__init__.py +1 -0
- pm/__main__.py +4 -0
- pm/_alias.py +164 -0
- pm/auth/__init__.py +0 -0
- pm/auth/device_flow.py +187 -0
- pm/auth/exchange.py +79 -0
- pm/auth/jwt_cache.py +163 -0
- pm/auth/secret_files.py +58 -0
- pm/auth/session.py +440 -0
- pm/auth/store.py +160 -0
- pm/auth/token.py +289 -0
- pm/cli.py +282 -0
- pm/client/__init__.py +68 -0
- pm/client/access_tokens.py +54 -0
- pm/client/base.py +229 -0
- pm/client/blocks.py +461 -0
- pm/client/databases.py +147 -0
- pm/client/favorites.py +14 -0
- pm/client/folders.py +10 -0
- pm/client/integrations.py +14 -0
- pm/client/organizations.py +20 -0
- pm/client/recipients.py +92 -0
- pm/client/reports.py +148 -0
- pm/client/roles.py +100 -0
- pm/client/runs.py +134 -0
- pm/client/saved_blocks.py +13 -0
- pm/client/tableau.py +108 -0
- pm/client/users.py +71 -0
- pm/client/webhooks.py +43 -0
- pm/client/workspaces.py +34 -0
- pm/commands/__init__.py +0 -0
- pm/commands/_block_factory.py +762 -0
- pm/commands/_helpers.py +178 -0
- pm/commands/auth.py +222 -0
- pm/commands/block.py +26 -0
- pm/commands/chart.py +20 -0
- pm/commands/db.py +291 -0
- pm/commands/email.py +13 -0
- pm/commands/explore.py +20 -0
- pm/commands/favorites.py +77 -0
- pm/commands/folders.py +113 -0
- pm/commands/help.py +643 -0
- pm/commands/integrations.py +95 -0
- pm/commands/metrics_view.py +195 -0
- pm/commands/organizations.py +56 -0
- pm/commands/recipient.py +226 -0
- pm/commands/reports.py +364 -0
- pm/commands/roles.py +102 -0
- pm/commands/runs.py +214 -0
- pm/commands/saved_blocks.py +97 -0
- pm/commands/sftp.py +19 -0
- pm/commands/slack.py +13 -0
- pm/commands/slides.py +216 -0
- pm/commands/sql.py +17 -0
- pm/commands/tableau.py +148 -0
- pm/commands/token.py +107 -0
- pm/commands/users.py +262 -0
- pm/commands/webhooks.py +132 -0
- pm/commands/workspace.py +156 -0
- pm/config.py +192 -0
- pm/errors.py +228 -0
- pm/log.py +63 -0
- pm/models/__init__.py +31 -0
- pm/models/access_tokens.py +25 -0
- pm/models/blocks.py +21 -0
- pm/models/databases.py +15 -0
- pm/models/favorites.py +14 -0
- pm/models/folders.py +14 -0
- pm/models/integrations.py +20 -0
- pm/models/organizations.py +13 -0
- pm/models/recipients.py +41 -0
- pm/models/reports.py +16 -0
- pm/models/roles.py +22 -0
- pm/models/runs.py +56 -0
- pm/models/saved_blocks.py +15 -0
- pm/models/users.py +36 -0
- pm/models/webhooks.py +23 -0
- pm/models/workspaces.py +16 -0
- pm/output.py +92 -0
- pm/types.py +130 -0
- pm/validators.py +499 -0
- pushmetrics_cli-0.1.0.dist-info/METADATA +221 -0
- pushmetrics_cli-0.1.0.dist-info/RECORD +86 -0
- pushmetrics_cli-0.1.0.dist-info/WHEEL +5 -0
- pushmetrics_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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,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())
|
pm/client/recipients.py
ADDED
|
@@ -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
|
+
)
|