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/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))
@@ -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.")
File without changes