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/runs.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Noteflow run endpoints — fetching run metadata and per-task results."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
from pm.client.base import CrudClient, _unwrap_result, raise_for_status
|
|
7
|
+
from pm.models.runs import Run, RunLog, RunTaskResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _rison_value(s: str) -> str:
|
|
11
|
+
"""Encode a string for inclusion as a Rison value.
|
|
12
|
+
|
|
13
|
+
Bare identifiers pass through unquoted; anything else gets
|
|
14
|
+
single-quote-wrapped with ``!`` / ``'`` escaped per the Rison spec.
|
|
15
|
+
Mirrors the helper in ``commands/integrations.py`` (the client layer must
|
|
16
|
+
not import from the commands layer).
|
|
17
|
+
|
|
18
|
+
A bare Rison id must NOT start with a digit (or ``-``/``.``): the
|
|
19
|
+
parser then commits to a number and fails on the first non-numeric
|
|
20
|
+
char. Uids/UUIDs routinely start with a digit — ``pm run logs
|
|
21
|
+
12490fe3-…`` was rejected with "Not a valid rison/json argument"
|
|
22
|
+
(E2E verification 2026-07-16), and any ``pm run list
|
|
23
|
+
--report=<uid>`` with a digit-leading uid hit the same. Quote
|
|
24
|
+
unless the first char is a letter or underscore.
|
|
25
|
+
"""
|
|
26
|
+
if (
|
|
27
|
+
s
|
|
28
|
+
and (s[0].isalpha() or s[0] == "_")
|
|
29
|
+
and all(c.isalnum() or c in "_-." for c in s)
|
|
30
|
+
):
|
|
31
|
+
return s
|
|
32
|
+
return "'" + s.replace("!", "!!").replace("'", "!'") + "'"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RunsClient(CrudClient[Run]):
|
|
36
|
+
base_path = "/api/v1/noteflow_run"
|
|
37
|
+
log_path = "/api/v1/noteflow_run_log"
|
|
38
|
+
model = Run
|
|
39
|
+
|
|
40
|
+
def list(
|
|
41
|
+
self,
|
|
42
|
+
limit: Optional[int] = None,
|
|
43
|
+
*,
|
|
44
|
+
report_uid: Optional[str] = None,
|
|
45
|
+
last_days: Optional[int] = None,
|
|
46
|
+
status: Optional[str] = None,
|
|
47
|
+
) -> List[Run]:
|
|
48
|
+
"""List recent noteflow runs in the current workspace.
|
|
49
|
+
|
|
50
|
+
Backend's `base_order` is `(id, desc)` so the list is already sorted
|
|
51
|
+
most-recent-first. Pass `limit` to override FAB's default page size
|
|
52
|
+
(~25) up to `max_page_size` of 5000.
|
|
53
|
+
|
|
54
|
+
Optional server-side filters (mirror the backend ``search_filters``):
|
|
55
|
+
- ``report_uid`` — only runs of one report (``FilterReport``).
|
|
56
|
+
- ``last_days`` — only runs created within the last N days
|
|
57
|
+
(``FilterLessThanDaysAgo``; N=1 ≈ since yesterday midnight).
|
|
58
|
+
- ``status`` — exact status match (e.g. ``failed`` / ``success``).
|
|
59
|
+
"""
|
|
60
|
+
filters = []
|
|
61
|
+
if report_uid is not None:
|
|
62
|
+
filters.append(f"(col:report,opr:rel_r_nf,value:{_rison_value(report_uid)})")
|
|
63
|
+
if last_days is not None:
|
|
64
|
+
filters.append(f"(col:created_on,opr:ltd,value:{int(last_days)})")
|
|
65
|
+
if status is not None:
|
|
66
|
+
filters.append(f"(col:status,opr:eq,value:{_rison_value(status)})")
|
|
67
|
+
|
|
68
|
+
bits = []
|
|
69
|
+
if filters:
|
|
70
|
+
bits.append(f"filters:!({','.join(filters)})")
|
|
71
|
+
if limit is not None:
|
|
72
|
+
bits.append("page:0")
|
|
73
|
+
bits.append(f"page_size:{int(limit)}")
|
|
74
|
+
params = {"q": f"({','.join(bits)})"} if bits else None
|
|
75
|
+
return super().list(params=params)
|
|
76
|
+
|
|
77
|
+
def results(self, run_uid: str) -> List[RunTaskResult]:
|
|
78
|
+
"""Fetch per-task (per-block) results for a run."""
|
|
79
|
+
resp = self.session.get(f"{self.base_path}/{run_uid}/get_task_results")
|
|
80
|
+
raise_for_status(resp)
|
|
81
|
+
body = resp.json()
|
|
82
|
+
if isinstance(body, dict):
|
|
83
|
+
raw = body.get("result", []) if "result" in body else [body]
|
|
84
|
+
else:
|
|
85
|
+
raw = body
|
|
86
|
+
return [RunTaskResult.model_validate(item) for item in raw]
|
|
87
|
+
|
|
88
|
+
def logs(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
flow_run_id: Optional[str] = None,
|
|
92
|
+
noteflow_run_id: Optional[int] = None,
|
|
93
|
+
limit: Optional[int] = None,
|
|
94
|
+
) -> List[RunLog]:
|
|
95
|
+
"""Fetch execution log lines for a run.
|
|
96
|
+
|
|
97
|
+
Filter by ``noteflow_run_id`` or ``flow_run_id``:
|
|
98
|
+
- ``noteflow_run_id`` is the stable per-run key — it does not
|
|
99
|
+
change across retries, so it returns ALL attempts' logs. Prefer
|
|
100
|
+
it for "every log line for this run".
|
|
101
|
+
- ``flow_run_id`` is the per-attempt key — a run's flow_run_id can
|
|
102
|
+
change between retry attempts, so filtering on it returns only
|
|
103
|
+
the matching attempt's logs.
|
|
104
|
+
|
|
105
|
+
Logs come back oldest-first (backend ``base_order`` is ``(id, asc)``).
|
|
106
|
+
At least one filter is required.
|
|
107
|
+
"""
|
|
108
|
+
if flow_run_id is None and noteflow_run_id is None:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
"RunsClient.logs requires flow_run_id or noteflow_run_id"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
filters = []
|
|
114
|
+
if flow_run_id is not None:
|
|
115
|
+
filters.append(
|
|
116
|
+
f"(col:flow_run_id,opr:frid_eq,value:{_rison_value(flow_run_id)})"
|
|
117
|
+
)
|
|
118
|
+
if noteflow_run_id is not None:
|
|
119
|
+
filters.append(
|
|
120
|
+
f"(col:noteflow_run_id,opr:frid_eq,value:{int(noteflow_run_id)})"
|
|
121
|
+
)
|
|
122
|
+
bits = []
|
|
123
|
+
if filters:
|
|
124
|
+
bits.append(f"filters:!({','.join(filters)})")
|
|
125
|
+
if limit is not None:
|
|
126
|
+
bits.append(f"page_size:{int(limit)}")
|
|
127
|
+
params = {"q": f"({','.join(bits)})"} if bits else None
|
|
128
|
+
|
|
129
|
+
resp = self.session.get(f"{self.log_path}/", params=params)
|
|
130
|
+
raise_for_status(resp)
|
|
131
|
+
raw = _unwrap_result(resp.json(), default=[])
|
|
132
|
+
if not isinstance(raw, list):
|
|
133
|
+
raw = [raw]
|
|
134
|
+
return [RunLog.model_validate(item) for item in raw]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Saved block (template) endpoints.
|
|
2
|
+
|
|
3
|
+
Backend supports full CRUD but only `parameters_schema` is editable.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pm.client.base import CrudClient
|
|
8
|
+
from pm.models.saved_blocks import SavedBlock
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SavedBlocksClient(CrudClient[SavedBlock]):
|
|
12
|
+
base_path = "/api/v1/saved_block"
|
|
13
|
+
model = SavedBlock
|
pm/client/tableau.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Tableau integration endpoints — view/workbook discovery + view details.
|
|
2
|
+
|
|
3
|
+
Scoped to a Tableau integration in the current workspace (the
|
|
4
|
+
``integration_uid`` identifies which Tableau instance to talk to). The
|
|
5
|
+
backend mounts these under ``/api/v1/integration/tableau/<integration_uid>/...``
|
|
6
|
+
— a separate resource from the generic ``/api/v1/integration`` CRUD, with
|
|
7
|
+
its own auth-token-refresh and Tableau-specific concerns.
|
|
8
|
+
|
|
9
|
+
Used by ``pm tableau search`` (list views/workbooks) and ``pm tableau view``
|
|
10
|
+
(get one view's details). These are direct-API ops — they don't create
|
|
11
|
+
blocks. The block-lifecycle path is ``pm tableau create`` + ``pm tableau
|
|
12
|
+
run`` on a block whose content_json points at a specific view_id.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from pm.client.base import _unwrap_result, raise_for_status
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TableauClient:
|
|
22
|
+
"""HTTP wrapper for the workspace's Tableau integration API.
|
|
23
|
+
|
|
24
|
+
Every method takes ``integration_uid`` — the Tableau integration to
|
|
25
|
+
talk to. Workspaces with multiple Tableau integrations need an explicit
|
|
26
|
+
pick (no implicit-default behaviour today; we can layer that on later
|
|
27
|
+
if it proves common).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
base_path = "/api/v1/integration/tableau"
|
|
31
|
+
|
|
32
|
+
def __init__(self, session):
|
|
33
|
+
self.session = session
|
|
34
|
+
|
|
35
|
+
def list_views(
|
|
36
|
+
self,
|
|
37
|
+
integration_uid: str,
|
|
38
|
+
*,
|
|
39
|
+
keyword: Optional[str] = None,
|
|
40
|
+
workbook_id: Optional[str] = None,
|
|
41
|
+
) -> List[Dict[str, Any]]:
|
|
42
|
+
"""List Tableau views available through this integration.
|
|
43
|
+
|
|
44
|
+
``keyword`` is a server-side substring filter on view name (the
|
|
45
|
+
backend passes it to the Tableau REST API). ``workbook_id``
|
|
46
|
+
narrows the result set to views inside a single workbook. Returns
|
|
47
|
+
a list of dicts — exact shape mirrors what the Tableau REST API
|
|
48
|
+
returns through the backend's ``get_views`` helper.
|
|
49
|
+
"""
|
|
50
|
+
params: Dict[str, str] = {}
|
|
51
|
+
if keyword:
|
|
52
|
+
params["keyword"] = keyword
|
|
53
|
+
if workbook_id:
|
|
54
|
+
params["workbook_id"] = workbook_id
|
|
55
|
+
resp = self.session.get(
|
|
56
|
+
f"{self.base_path}/{integration_uid}/view",
|
|
57
|
+
params=params or None,
|
|
58
|
+
)
|
|
59
|
+
raise_for_status(resp)
|
|
60
|
+
body = resp.json() if resp.text else {}
|
|
61
|
+
results = body.get("results") if isinstance(body, dict) else None
|
|
62
|
+
if results is None:
|
|
63
|
+
results = _unwrap_result(body, default=[]) or []
|
|
64
|
+
return results if isinstance(results, list) else [results]
|
|
65
|
+
|
|
66
|
+
def list_workbooks(
|
|
67
|
+
self,
|
|
68
|
+
integration_uid: str,
|
|
69
|
+
*,
|
|
70
|
+
keyword: Optional[str] = None,
|
|
71
|
+
) -> List[Dict[str, Any]]:
|
|
72
|
+
"""List Tableau workbooks available through this integration.
|
|
73
|
+
|
|
74
|
+
Same shape as ``list_views`` minus the workbook_id filter — a
|
|
75
|
+
workbook is the parent of views, so it doesn't have one of its own.
|
|
76
|
+
"""
|
|
77
|
+
params: Dict[str, str] = {}
|
|
78
|
+
if keyword:
|
|
79
|
+
params["keyword"] = keyword
|
|
80
|
+
resp = self.session.get(
|
|
81
|
+
f"{self.base_path}/{integration_uid}/workbook",
|
|
82
|
+
params=params or None,
|
|
83
|
+
)
|
|
84
|
+
raise_for_status(resp)
|
|
85
|
+
body = resp.json() if resp.text else {}
|
|
86
|
+
results = body.get("results") if isinstance(body, dict) else None
|
|
87
|
+
if results is None:
|
|
88
|
+
results = _unwrap_result(body, default=[]) or []
|
|
89
|
+
return results if isinstance(results, list) else [results]
|
|
90
|
+
|
|
91
|
+
def get_view(
|
|
92
|
+
self,
|
|
93
|
+
integration_uid: str,
|
|
94
|
+
view_id: str,
|
|
95
|
+
) -> Dict[str, Any]:
|
|
96
|
+
"""Fetch one Tableau view's metadata.
|
|
97
|
+
|
|
98
|
+
Returns whatever the backend's ``get_view(integration, view_id)``
|
|
99
|
+
produces — typically a dict with id, name, workbook_id, content_url,
|
|
100
|
+
etc. Useful for drilling into a search result before creating a
|
|
101
|
+
Tableau block that points at it.
|
|
102
|
+
"""
|
|
103
|
+
resp = self.session.get(
|
|
104
|
+
f"{self.base_path}/{integration_uid}/view/{view_id}",
|
|
105
|
+
)
|
|
106
|
+
raise_for_status(resp)
|
|
107
|
+
body = resp.json() if resp.text else {}
|
|
108
|
+
return _unwrap_result(body, default={}) or {}
|
pm/client/users.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""User endpoints."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from pm.client.base import CrudClient, _unwrap_result, raise_for_status
|
|
7
|
+
from pm.models.users import User
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UsersClient(CrudClient[User]):
|
|
11
|
+
base_path = "/api/v1/user"
|
|
12
|
+
model = User
|
|
13
|
+
|
|
14
|
+
def current(self) -> User:
|
|
15
|
+
"""The authenticated user (custom endpoint, not part of CRUD)."""
|
|
16
|
+
resp = self.session.get("/api/v1/user/current")
|
|
17
|
+
raise_for_status(resp)
|
|
18
|
+
return User.model_validate(resp.json())
|
|
19
|
+
|
|
20
|
+
def create(
|
|
21
|
+
self,
|
|
22
|
+
data: Dict[str, Any],
|
|
23
|
+
*,
|
|
24
|
+
skip_welcome_email: bool = False,
|
|
25
|
+
) -> tuple:
|
|
26
|
+
"""Create (invite) a user, or attach an existing one to the workspace.
|
|
27
|
+
|
|
28
|
+
``skip_welcome_email=True`` adds ``?skip_welcome_email=true`` so
|
|
29
|
+
the backend's ``post_add`` hook does not call
|
|
30
|
+
``send_welcome_to_workspace_email``. Use for smoke tests and
|
|
31
|
+
scripted onboarding to avoid sending mail to test addresses.
|
|
32
|
+
|
|
33
|
+
Returns ``(user, action)`` where ``action`` is one of:
|
|
34
|
+
|
|
35
|
+
- ``"created"`` — brand-new user record was inserted
|
|
36
|
+
- ``"added_to_workspace"`` — existing user attached to current workspace
|
|
37
|
+
- ``"no_op"`` — user already in this workspace, no state change
|
|
38
|
+
"""
|
|
39
|
+
params: Optional[Dict[str, str]] = None
|
|
40
|
+
if skip_welcome_email:
|
|
41
|
+
params = {"skip_welcome_email": "true"}
|
|
42
|
+
resp = self.session.post(f"{self.base_path}/", json=data, params=params)
|
|
43
|
+
raise_for_status(resp)
|
|
44
|
+
body = resp.json() if resp.text else {}
|
|
45
|
+
action = body.get("action") if isinstance(body, dict) else None
|
|
46
|
+
user = self.model.model_validate(_unwrap_result(body))
|
|
47
|
+
return user, action
|
|
48
|
+
|
|
49
|
+
def remove_from_workspace(
|
|
50
|
+
self,
|
|
51
|
+
user_uid: str,
|
|
52
|
+
workspace_uid: Optional[str] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Remove a user from a workspace.
|
|
55
|
+
|
|
56
|
+
Hits ``DELETE /api/v1/user/<user_uid>/remove_from_workspace/`` —
|
|
57
|
+
or ``/remove_from_workspace/<workspace_uid>`` when a specific
|
|
58
|
+
target is given. Without ``workspace_uid``, the backend removes
|
|
59
|
+
from the active session's workspace.
|
|
60
|
+
|
|
61
|
+
Side effects (mirroring the backend ``remove_from_workspace``
|
|
62
|
+
handler): the user's workspace roles for the target workspace
|
|
63
|
+
are stripped. If this was their last workspace they are also
|
|
64
|
+
deactivated, removed from the organization, anonymised, and
|
|
65
|
+
deleted from Auth0.
|
|
66
|
+
"""
|
|
67
|
+
path = f"{self.base_path}/{user_uid}/remove_from_workspace/"
|
|
68
|
+
if workspace_uid:
|
|
69
|
+
path = f"{path}{workspace_uid}"
|
|
70
|
+
resp = self.session.delete(path)
|
|
71
|
+
raise_for_status(resp)
|
pm/client/webhooks.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Webhook endpoints (full CRUD).
|
|
2
|
+
|
|
3
|
+
Note: backend `add_columns` / `edit_columns` JSON-stringify `recipients`/
|
|
4
|
+
`details`/`slack_recipients` on input. The CLI accepts dict/list and
|
|
5
|
+
serializes to JSON before sending.
|
|
6
|
+
|
|
7
|
+
**Backend gap (filed as a follow-up):** `WebhookRestApi` (`superset/
|
|
8
|
+
pushmetrics/views/webhook/api.py`) does not define `list_columns`, so
|
|
9
|
+
FAB's `/webhook/` list endpoint returns only the model's primary key (`id`)
|
|
10
|
+
per row. All other fields come back null in the response. There's no
|
|
11
|
+
client-side workaround — FAB filters any requested `columns:!(...)` against
|
|
12
|
+
the server-declared `list_columns`. To fix: add `list_columns = show_columns`
|
|
13
|
+
to `WebhookRestApi`. Until then, use `pm webhooks get <uid>` (the single-item
|
|
14
|
+
endpoint uses `show_columns` and returns the full record).
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from typing import Any, Dict
|
|
20
|
+
|
|
21
|
+
from pm.client.base import CrudClient
|
|
22
|
+
from pm.models.webhooks import Webhook
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _serialize_payload(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
26
|
+
"""JSON-stringify the dict/list fields the backend expects as strings."""
|
|
27
|
+
out = dict(data)
|
|
28
|
+
for key in ("details", "recipients", "slack_recipients"):
|
|
29
|
+
value = out.get(key)
|
|
30
|
+
if value is not None and not isinstance(value, str):
|
|
31
|
+
out[key] = json.dumps(value)
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class WebhooksClient(CrudClient[Webhook]):
|
|
36
|
+
base_path = "/api/v1/webhook"
|
|
37
|
+
model = Webhook
|
|
38
|
+
|
|
39
|
+
def create(self, data: Dict[str, Any]) -> Webhook:
|
|
40
|
+
return super().create(_serialize_payload(data))
|
|
41
|
+
|
|
42
|
+
def update(self, uid: str, data: Dict[str, Any]) -> Webhook:
|
|
43
|
+
return super().update(uid, _serialize_payload(data))
|
pm/client/workspaces.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Workspace endpoints.
|
|
2
|
+
|
|
3
|
+
Workspace selection is **server-side state** in the Flask session. Switching
|
|
4
|
+
issues GET /api/v1/workspace/<pk>/switch which mutates the session, so all
|
|
5
|
+
subsequent calls run against the new workspace (RLS enforced).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pm.client.base import CrudClient, PMApiError, raise_for_status
|
|
10
|
+
from pm.models.workspaces import Workspace
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WorkspacesClient(CrudClient[Workspace]):
|
|
14
|
+
base_path = "/api/v1/workspace"
|
|
15
|
+
model = Workspace
|
|
16
|
+
|
|
17
|
+
def current(self) -> Workspace:
|
|
18
|
+
resp = self.session.get("/api/v1/workspace/current")
|
|
19
|
+
raise_for_status(resp)
|
|
20
|
+
return Workspace.model_validate(resp.json())
|
|
21
|
+
|
|
22
|
+
def switch(self, uid: str) -> Workspace:
|
|
23
|
+
"""Switch the active workspace by UID. Resolves UID -> integer PK via list."""
|
|
24
|
+
for ws in self.list():
|
|
25
|
+
if ws.uid == uid:
|
|
26
|
+
pk = ws.id
|
|
27
|
+
if pk is None:
|
|
28
|
+
raise PMApiError(500, f"Workspace {uid} has no id field")
|
|
29
|
+
resp = self.session.get(f"/api/v1/workspace/{pk}/switch")
|
|
30
|
+
raise_for_status(resp)
|
|
31
|
+
self.session.creds.last_workspace_uid = uid
|
|
32
|
+
self.session.persist()
|
|
33
|
+
return ws
|
|
34
|
+
raise PMApiError(404, f"Workspace with uid {uid!r} not found in your accessible list.")
|
pm/commands/__init__.py
ADDED
|
File without changes
|