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/base.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Common API client primitives.
|
|
2
|
+
|
|
3
|
+
`CrudClient[T]` is the generic base for resources that follow FAB's standard
|
|
4
|
+
shape: list at `/<resource>/`, single at `/<resource>/<uid>`, with `POST`
|
|
5
|
+
create, `PUT` update, `DELETE` remove. Subclasses set `base_path` + `model`
|
|
6
|
+
and inherit list/get/create/update/delete. Resources with custom ops add
|
|
7
|
+
methods on top; resources without all 5 verbs simply don't expose the
|
|
8
|
+
unsupported ones at the commands layer (the methods still exist on the client
|
|
9
|
+
but will get a 405 from the backend if called).
|
|
10
|
+
|
|
11
|
+
`SharingMixin` provides the cross-resource `share_with_user/group/workspace`
|
|
12
|
+
+ `sharing_settings` pattern. Documented and stubbed here; not applied to any
|
|
13
|
+
client until Phase 6.3.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any, ClassVar, Dict, Generic, List, Optional, Type, TypeVar
|
|
18
|
+
|
|
19
|
+
import requests
|
|
20
|
+
from pydantic import BaseModel
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T", bound=BaseModel)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def api_error_prefix(method: str, path: str) -> str:
|
|
26
|
+
"""``API <METHOD> <url> failed`` for an HTTP response; ``Request failed``
|
|
27
|
+
for an error the client raises itself (no request to name). Both keep
|
|
28
|
+
the ``failed: <status>`` text the agent harness scrapes."""
|
|
29
|
+
return f"API {method} {path} failed" if (method or path) else "Request failed"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PMApiError(RuntimeError):
|
|
33
|
+
"""Raised on non-2xx API responses (other than 401, which raises NotAuthenticatedError)."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, status: int, body: str, *, method: str = "", path: str = ""):
|
|
36
|
+
self.status = status
|
|
37
|
+
self.body = body
|
|
38
|
+
self.method = method
|
|
39
|
+
self.path = path
|
|
40
|
+
super().__init__(f"{api_error_prefix(method, path)}: {status} {body}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def raise_for_status(resp: requests.Response) -> None:
|
|
44
|
+
if resp.status_code >= 400:
|
|
45
|
+
# Redact PAT/Bearer-shaped strings from the error body before it is
|
|
46
|
+
# embedded in the exception: PMApiError messages propagate into CLI
|
|
47
|
+
# stderr and from there into agent transcripts (via the prefect
|
|
48
|
+
# cli_adapter), and a proxy/debug error page can echo the request's
|
|
49
|
+
# Authorization header.
|
|
50
|
+
from pm.auth.token import redact_secrets
|
|
51
|
+
|
|
52
|
+
raise PMApiError(
|
|
53
|
+
resp.status_code,
|
|
54
|
+
redact_secrets(resp.text or ""),
|
|
55
|
+
method=(resp.request.method or "") if resp.request else "",
|
|
56
|
+
path=resp.url,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _unwrap_result(body: Any, default: Any = None) -> Any:
|
|
61
|
+
"""FAB endpoints typically return `{"result": ...}`; tolerate bare shapes too."""
|
|
62
|
+
if isinstance(body, dict) and "result" in body:
|
|
63
|
+
return body["result"]
|
|
64
|
+
return body if body is not None else default
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CrudClient(Generic[T]):
|
|
68
|
+
"""Generic FAB-CRUD client.
|
|
69
|
+
|
|
70
|
+
Subclasses must set `base_path` (no trailing slash; e.g. `/api/v1/folder`)
|
|
71
|
+
and `model` (a Pydantic model). They then inherit:
|
|
72
|
+
|
|
73
|
+
- `list(params=None) -> List[T]`
|
|
74
|
+
- `get(uid) -> T`
|
|
75
|
+
- `create(data) -> T`
|
|
76
|
+
- `update(uid, data) -> T`
|
|
77
|
+
- `delete(uid) -> None`
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
base_path: ClassVar[str] = ""
|
|
81
|
+
model: ClassVar[Type[BaseModel]]
|
|
82
|
+
|
|
83
|
+
def __init__(self, session):
|
|
84
|
+
self.session = session
|
|
85
|
+
|
|
86
|
+
def list(self, params: Optional[Dict[str, Any]] = None) -> List[T]:
|
|
87
|
+
resp = self.session.get(f"{self.base_path}/", params=params)
|
|
88
|
+
raise_for_status(resp)
|
|
89
|
+
raw = _unwrap_result(resp.json(), default=[])
|
|
90
|
+
if not isinstance(raw, list):
|
|
91
|
+
raw = [raw]
|
|
92
|
+
return [self.model.model_validate(item) for item in raw]
|
|
93
|
+
|
|
94
|
+
def list_all(self, *, page_size: int = 100, limit: Optional[int] = None) -> List[T]:
|
|
95
|
+
"""Fetch EVERY row by walking FAB's pages until exhausted.
|
|
96
|
+
|
|
97
|
+
A plain ``list()`` returns only the first page — FAB caps
|
|
98
|
+
``page_size`` at ``FAB_API_MAX_PAGE_SIZE`` (default 100) and
|
|
99
|
+
silently clamps the ``-1`` "uncap" unless the endpoint also sets
|
|
100
|
+
``max_page_size = -1``. So a caller that wants the complete set
|
|
101
|
+
(and, critically, the true TOTAL) can't rely on one request. This
|
|
102
|
+
pages by index, using the response's top-level ``count`` to know
|
|
103
|
+
when to stop, and returns every row.
|
|
104
|
+
|
|
105
|
+
``count`` is the PRIMARY stop condition: keep paging until we've
|
|
106
|
+
collected ``count`` rows. The short-page heuristic is only a
|
|
107
|
+
fallback for when ``count`` is absent — it must NOT stop the walk
|
|
108
|
+
while ``count`` says more rows exist, because FAB clamps an
|
|
109
|
+
over-large ``page_size`` to ``max_page_size`` and returns a page
|
|
110
|
+
SHORTER than requested on the very first request (codex/Copilot
|
|
111
|
+
review PR #5791). The page STRIDE is likewise taken from the
|
|
112
|
+
first response's actual length, not the requested ``page_size``,
|
|
113
|
+
so page indices stay aligned to what the server honored.
|
|
114
|
+
|
|
115
|
+
Used by ``pm report list`` so the agent doesn't present a single
|
|
116
|
+
capped page as "you have N reports in your workspace" (support
|
|
117
|
+
session GYmXdRekqy, 2026-07-19: 22 shown, workspace had more).
|
|
118
|
+
|
|
119
|
+
``limit`` stops the walk once that many rows are collected, so
|
|
120
|
+
``pm report list --limit 5`` makes one request, not one per page.
|
|
121
|
+
Without it every page is fetched, as above.
|
|
122
|
+
"""
|
|
123
|
+
if limit is not None:
|
|
124
|
+
page_size = min(page_size, limit)
|
|
125
|
+
out: List[T] = []
|
|
126
|
+
page = 0
|
|
127
|
+
stride: Optional[int] = None # server-honored page length
|
|
128
|
+
while True:
|
|
129
|
+
ps = stride or page_size
|
|
130
|
+
q = f"(page:{page},page_size:{ps})"
|
|
131
|
+
resp = self.session.get(f"{self.base_path}/", params={"q": q})
|
|
132
|
+
raise_for_status(resp)
|
|
133
|
+
body = resp.json()
|
|
134
|
+
raw = _unwrap_result(body, default=[])
|
|
135
|
+
if not isinstance(raw, list):
|
|
136
|
+
raw = [raw] if raw else []
|
|
137
|
+
out.extend(self.model.model_validate(item) for item in raw)
|
|
138
|
+
if limit is not None and len(out) >= limit:
|
|
139
|
+
return out[:limit]
|
|
140
|
+
count = body.get("count") if isinstance(body, dict) else None
|
|
141
|
+
# Empty page always terminates (nothing more to fetch).
|
|
142
|
+
if not raw:
|
|
143
|
+
break
|
|
144
|
+
# Primary: ``count`` is authoritative. Keep paging until we
|
|
145
|
+
# have every row, regardless of how short FAB made the page
|
|
146
|
+
# (an over-large page_size gets clamped, so page 0 can be
|
|
147
|
+
# shorter than requested while more rows still exist).
|
|
148
|
+
if isinstance(count, int):
|
|
149
|
+
if len(out) >= count:
|
|
150
|
+
break
|
|
151
|
+
# Lock the stride to the server-honored first-page length
|
|
152
|
+
# so page indices align with what FAB returned.
|
|
153
|
+
if stride is None:
|
|
154
|
+
stride = len(raw)
|
|
155
|
+
page += 1
|
|
156
|
+
continue
|
|
157
|
+
# Fallback (no ``count`` field): treat a page shorter than the
|
|
158
|
+
# stride as the end. On the FIRST page the stride is the
|
|
159
|
+
# requested ``page_size`` — a short first page then means "no
|
|
160
|
+
# more rows". On later pages it's the honored length.
|
|
161
|
+
prev_stride = stride if stride is not None else page_size
|
|
162
|
+
if len(raw) < prev_stride:
|
|
163
|
+
break
|
|
164
|
+
if stride is None:
|
|
165
|
+
stride = len(raw)
|
|
166
|
+
page += 1
|
|
167
|
+
return out
|
|
168
|
+
|
|
169
|
+
def get(self, uid: str) -> T:
|
|
170
|
+
resp = self.session.get(f"{self.base_path}/{uid}")
|
|
171
|
+
raise_for_status(resp)
|
|
172
|
+
return self.model.model_validate(_unwrap_result(resp.json()))
|
|
173
|
+
|
|
174
|
+
def create(self, data: Dict[str, Any]) -> T:
|
|
175
|
+
resp = self.session.post(f"{self.base_path}/", json=data)
|
|
176
|
+
raise_for_status(resp)
|
|
177
|
+
return self.model.model_validate(_unwrap_result(resp.json()))
|
|
178
|
+
|
|
179
|
+
def update(self, uid: str, data: Dict[str, Any]) -> T:
|
|
180
|
+
resp = self.session.put(f"{self.base_path}/{uid}", json=data)
|
|
181
|
+
raise_for_status(resp)
|
|
182
|
+
return self.model.model_validate(_unwrap_result(resp.json()))
|
|
183
|
+
|
|
184
|
+
def delete(self, uid: str) -> None:
|
|
185
|
+
resp = self.session.delete(f"{self.base_path}/{uid}")
|
|
186
|
+
raise_for_status(resp)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class SharingMixin:
|
|
190
|
+
"""Cross-resource sharing pattern (Phase 6.3 — not yet applied).
|
|
191
|
+
|
|
192
|
+
Reports, databases, agents, and integrations all expose:
|
|
193
|
+
|
|
194
|
+
- `GET /<base>/<uid>/sharing_settings`
|
|
195
|
+
- `POST /<base>/<uid>/share_with_user` { user_uid, permission }
|
|
196
|
+
- `POST /<base>/<uid>/share_with_group` { group_uid, permission }
|
|
197
|
+
- `POST /<base>/<uid>/share_with_workspace` { permission }
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
base_path: ClassVar[str]
|
|
201
|
+
|
|
202
|
+
def sharing_settings(self, uid: str) -> Dict[str, Any]:
|
|
203
|
+
resp = self.session.get(f"{self.base_path}/{uid}/sharing_settings")
|
|
204
|
+
raise_for_status(resp)
|
|
205
|
+
return resp.json()
|
|
206
|
+
|
|
207
|
+
def share_with_user(self, uid: str, user_uid: str, permission: str) -> Dict[str, Any]:
|
|
208
|
+
resp = self.session.post(
|
|
209
|
+
f"{self.base_path}/{uid}/share_with_user",
|
|
210
|
+
json={"user_uid": user_uid, "permission": permission},
|
|
211
|
+
)
|
|
212
|
+
raise_for_status(resp)
|
|
213
|
+
return resp.json()
|
|
214
|
+
|
|
215
|
+
def share_with_group(self, uid: str, group_uid: str, permission: str) -> Dict[str, Any]:
|
|
216
|
+
resp = self.session.post(
|
|
217
|
+
f"{self.base_path}/{uid}/share_with_group",
|
|
218
|
+
json={"group_uid": group_uid, "permission": permission},
|
|
219
|
+
)
|
|
220
|
+
raise_for_status(resp)
|
|
221
|
+
return resp.json()
|
|
222
|
+
|
|
223
|
+
def share_with_workspace(self, uid: str, permission: str) -> Dict[str, Any]:
|
|
224
|
+
resp = self.session.post(
|
|
225
|
+
f"{self.base_path}/{uid}/share_with_workspace",
|
|
226
|
+
json={"permission": permission},
|
|
227
|
+
)
|
|
228
|
+
raise_for_status(resp)
|
|
229
|
+
return resp.json()
|
pm/client/blocks.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""Block endpoints — CRUD + execute.
|
|
2
|
+
|
|
3
|
+
Used by both the generic `pm block` plumbing group and every domain group's
|
|
4
|
+
factory-generated commands (`pm sql create`, `pm email send`, etc.). The
|
|
5
|
+
domain groups bind `block_type` ahead of time; `pm block` accepts it per call.
|
|
6
|
+
|
|
7
|
+
`execute_existing` and `execute_against_scratch` are deliberately split
|
|
8
|
+
so the call site is unambiguous — no single `run()` method that switches on
|
|
9
|
+
the presence of a uid argument. The scratch-reuse path mirrors the legacy
|
|
10
|
+
``run_sql`` / ``send_email`` agent tools (``prefect/queryme/agent/tools/
|
|
11
|
+
block_tools.py``) — no new block is persisted; an assigned scratch block
|
|
12
|
+
of the requested type is reused with a transient ``content_json`` override.
|
|
13
|
+
|
|
14
|
+
HTTP-only — no Superset app-context imports (codex F2 rule). This module
|
|
15
|
+
runs inside the CLI process and must stay importable without the backend
|
|
16
|
+
Flask app being on the path.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from typing import Any, Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
from pm.client.base import (
|
|
24
|
+
CrudClient,
|
|
25
|
+
_unwrap_result,
|
|
26
|
+
raise_for_status,
|
|
27
|
+
)
|
|
28
|
+
from pm.models.blocks import Block
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _ensure_content_json_string(content_json: Any) -> str:
|
|
32
|
+
"""Backend stores `content_json` as a string column. Accept dict-or-string
|
|
33
|
+
on the way in so callers don't have to remember to dump."""
|
|
34
|
+
if content_json is None:
|
|
35
|
+
return ""
|
|
36
|
+
if isinstance(content_json, str):
|
|
37
|
+
return content_json
|
|
38
|
+
return json.dumps(content_json)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _rison_type_filter(block_type: str) -> str:
|
|
42
|
+
"""Build the Rison `q=` filter that FAB list endpoints expect for an
|
|
43
|
+
equality filter on `type`. Mirrors the runs client's q-encoding."""
|
|
44
|
+
return f"(filters:!((col:type,opr:eq,value:{block_type})))"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Per-type content_json field that selects WHICH assigned scratch block
|
|
48
|
+
# to reuse for `pm <type> run/send --stdin`. Mirrors the legacy
|
|
49
|
+
# `_make_db_lookup` (sql) / `_make_integration_lookup` (email/slack/...)
|
|
50
|
+
# split at ``prefect/queryme/agent/tools/block_tools.py``. Types absent
|
|
51
|
+
# from the map have no routing discriminator — the first assigned scratch
|
|
52
|
+
# block of the type wins.
|
|
53
|
+
SCRATCH_ROUTING_FIELD: Dict[str, str] = {
|
|
54
|
+
"sql": "database_id",
|
|
55
|
+
"email": "integration_id",
|
|
56
|
+
"slack": "integration_id",
|
|
57
|
+
"sftp": "integration_id",
|
|
58
|
+
"tableau": "integration_id",
|
|
59
|
+
"webhook": "integration_id",
|
|
60
|
+
"google_slides": "integration_id",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_block_content_json(value: Any) -> Dict[str, Any]:
|
|
65
|
+
"""Return a parsed dict from a block's `content_json` field.
|
|
66
|
+
|
|
67
|
+
Backend stores it as a string; `Block.content_json` may be either
|
|
68
|
+
str (fresh from the API) or dict (already parsed by a caller). Empty
|
|
69
|
+
or malformed values return ``{}`` so callers never see exceptions
|
|
70
|
+
while building a lookup.
|
|
71
|
+
"""
|
|
72
|
+
if value is None:
|
|
73
|
+
return {}
|
|
74
|
+
if isinstance(value, dict):
|
|
75
|
+
return value
|
|
76
|
+
if not isinstance(value, str):
|
|
77
|
+
return {}
|
|
78
|
+
try:
|
|
79
|
+
parsed = json.loads(value)
|
|
80
|
+
except (json.JSONDecodeError, ValueError):
|
|
81
|
+
return {}
|
|
82
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class BlocksClient(CrudClient[Block]):
|
|
86
|
+
"""One client for all block CRUD + execute ops.
|
|
87
|
+
|
|
88
|
+
The execute verbs (`run`, `send`) hit the same `POST /api/v1/block/<uid>/run`
|
|
89
|
+
endpoint — the `verb` argument is preserved so callers (factory-generated
|
|
90
|
+
Click commands) can attach the correct canonical_action to traces. The
|
|
91
|
+
backend doesn't differentiate; the agent telemetry does.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
base_path = "/api/v1/block"
|
|
95
|
+
model = Block
|
|
96
|
+
|
|
97
|
+
# ── CRUD ───────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def list(
|
|
100
|
+
self,
|
|
101
|
+
*,
|
|
102
|
+
type: Optional[str] = None,
|
|
103
|
+
params: Optional[Dict[str, Any]] = None,
|
|
104
|
+
) -> List[Block]:
|
|
105
|
+
"""List blocks, optionally filtered by type.
|
|
106
|
+
|
|
107
|
+
`type` uses FAB's Rison `q` filter so it matches the backend's
|
|
108
|
+
`search_filters = {"type": [FilterEqual], ...}` registration. Pass
|
|
109
|
+
raw `params` for ad-hoc cases (e.g. pagination); when both are
|
|
110
|
+
given, `params` wins and `type` is ignored — callers wanting both
|
|
111
|
+
should build the full Rison `q` themselves.
|
|
112
|
+
"""
|
|
113
|
+
if params is None and type is not None:
|
|
114
|
+
params = {"q": _rison_type_filter(type)}
|
|
115
|
+
return super().list(params=params)
|
|
116
|
+
|
|
117
|
+
def create(
|
|
118
|
+
self,
|
|
119
|
+
*,
|
|
120
|
+
type: str,
|
|
121
|
+
name: str,
|
|
122
|
+
content_json: Any,
|
|
123
|
+
verbose_name: Optional[str] = None,
|
|
124
|
+
) -> Block:
|
|
125
|
+
"""Create a new block. Returns the persisted Block (incl. uid).
|
|
126
|
+
|
|
127
|
+
Does NOT execute. The chart (`plotly`) carve-out documented in
|
|
128
|
+
plan §3.0 — auto-render PNG on creation — lives in the executor,
|
|
129
|
+
not here; from this client's perspective every type is "create
|
|
130
|
+
only" at this verb.
|
|
131
|
+
"""
|
|
132
|
+
body: Dict[str, Any] = {
|
|
133
|
+
"type": type,
|
|
134
|
+
"name": name,
|
|
135
|
+
"content_json": _ensure_content_json_string(content_json),
|
|
136
|
+
}
|
|
137
|
+
if verbose_name is not None:
|
|
138
|
+
body["verbose_name"] = verbose_name
|
|
139
|
+
return super().create(body)
|
|
140
|
+
|
|
141
|
+
def update(
|
|
142
|
+
self,
|
|
143
|
+
uid: str,
|
|
144
|
+
content_json: Any = None,
|
|
145
|
+
*,
|
|
146
|
+
name: Optional[str] = None,
|
|
147
|
+
verbose_name: Optional[str] = None,
|
|
148
|
+
) -> Block:
|
|
149
|
+
"""Update editable fields on an existing block. Only sends the
|
|
150
|
+
fields that were provided — FAB's edit_columns lets us PATCH-style
|
|
151
|
+
update without resending the rest."""
|
|
152
|
+
body: Dict[str, Any] = {}
|
|
153
|
+
if content_json is not None:
|
|
154
|
+
body["content_json"] = _ensure_content_json_string(content_json)
|
|
155
|
+
if name is not None:
|
|
156
|
+
body["name"] = name
|
|
157
|
+
if verbose_name is not None:
|
|
158
|
+
body["verbose_name"] = verbose_name
|
|
159
|
+
return super().update(uid, body)
|
|
160
|
+
|
|
161
|
+
# ── Execute (split — codex R4 #5) ──────────────────────────────────
|
|
162
|
+
|
|
163
|
+
def execute_existing(
|
|
164
|
+
self,
|
|
165
|
+
uid: str,
|
|
166
|
+
*,
|
|
167
|
+
verb: str,
|
|
168
|
+
block_properties: Optional[Dict[str, Any]] = None,
|
|
169
|
+
) -> Dict[str, Any]:
|
|
170
|
+
"""Run an existing block by uid.
|
|
171
|
+
|
|
172
|
+
`verb` is `"run"` or `"send"` — preserved by the caller for
|
|
173
|
+
canonical_action telemetry but not sent to the backend (the
|
|
174
|
+
existing endpoint doesn't differentiate). `block_properties` is
|
|
175
|
+
merged into the block in-memory before execution (matches today's
|
|
176
|
+
manual-run UI behaviour). Returns `{"run_id": int, "block_uid": str,
|
|
177
|
+
"verb": str}` so trace + replay-guard keying works at the call site.
|
|
178
|
+
"""
|
|
179
|
+
if verb not in ("run", "send"):
|
|
180
|
+
raise ValueError(f"execute_existing: verb must be 'run' or 'send', got {verb!r}")
|
|
181
|
+
resp = self.session.post(
|
|
182
|
+
f"{self.base_path}/{uid}/run",
|
|
183
|
+
json=block_properties or {},
|
|
184
|
+
)
|
|
185
|
+
raise_for_status(resp)
|
|
186
|
+
body = _unwrap_result(resp.json(), default={}) or {}
|
|
187
|
+
# The backend `/run` handler returns {"run_id": int}; layer the
|
|
188
|
+
# caller's context onto it so command-level telemetry doesn't
|
|
189
|
+
# have to thread these separately.
|
|
190
|
+
return {
|
|
191
|
+
"run_id": body.get("run_id"),
|
|
192
|
+
"block_uid": uid,
|
|
193
|
+
"verb": verb,
|
|
194
|
+
**{k: v for k, v in body.items() if k != "run_id"},
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
# ── Slides ────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
def parse_slides_template(self, uid: str) -> Dict[str, Any]:
|
|
200
|
+
"""Parse a Google Slides master deck into template partials.
|
|
201
|
+
|
|
202
|
+
Hits ``POST /api/v1/block/<uid>/parse_slides_template``. The backend
|
|
203
|
+
pulls the source_file_id + integration_id off the block's
|
|
204
|
+
content_json, calls Google's Slides API, and persists the parsed
|
|
205
|
+
``template_definition`` + a sample ``composition_json`` back onto
|
|
206
|
+
the block — then returns them in the response.
|
|
207
|
+
|
|
208
|
+
Use as the first step after creating a slides block from a master
|
|
209
|
+
deck: parse-template populates the block with the available
|
|
210
|
+
slide partials + variables; subsequent runs use the discovered
|
|
211
|
+
``composition_json`` to render presentations.
|
|
212
|
+
"""
|
|
213
|
+
resp = self.session.post(f"{self.base_path}/{uid}/parse_slides_template")
|
|
214
|
+
raise_for_status(resp)
|
|
215
|
+
body = resp.json() if resp.text else {}
|
|
216
|
+
return body if isinstance(body, dict) else {"result": body}
|
|
217
|
+
|
|
218
|
+
# ── Metrics view ──────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
def metrics_query(
|
|
221
|
+
self,
|
|
222
|
+
metrics_view_uid: str,
|
|
223
|
+
*,
|
|
224
|
+
measures: List[str],
|
|
225
|
+
dimensions: Optional[List[str]] = None,
|
|
226
|
+
dimension_grains: Optional[Dict[str, Any]] = None,
|
|
227
|
+
filters: Optional[List[Dict[str, Any]]] = None,
|
|
228
|
+
time_range: Optional[str] = None,
|
|
229
|
+
time_grain: Optional[str] = None,
|
|
230
|
+
order_by: Optional[List[Dict[str, Any]]] = None,
|
|
231
|
+
limit: Optional[int] = None,
|
|
232
|
+
offset: Optional[int] = None,
|
|
233
|
+
no_cache: bool = False,
|
|
234
|
+
parameters: Optional[Dict[str, Any]] = None,
|
|
235
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
236
|
+
) -> Dict[str, Any]:
|
|
237
|
+
"""Execute a semantic-layer query against a metrics view.
|
|
238
|
+
|
|
239
|
+
Hits ``POST /api/v1/block/metrics_query`` (NOTE: no uid in the URL
|
|
240
|
+
path — the metrics_view_uid lives in the body). The backend
|
|
241
|
+
compiles dialect-specific SQL via MetricsQueryEngine and runs it
|
|
242
|
+
against the underlying database; results are cached for ~5 min.
|
|
243
|
+
|
|
244
|
+
Returns the raw backend payload — ``{result?, sql, columns, data,
|
|
245
|
+
...}``. Compilation errors come back as ``PMApiError(400)``;
|
|
246
|
+
execution errors as ``PMApiError(422)`` with the failing SQL
|
|
247
|
+
attached (the backend redacts DB URLs from the message).
|
|
248
|
+
|
|
249
|
+
``extra`` is an escape hatch — any keys passed there are merged
|
|
250
|
+
into the request body verbatim. Used by ``pm metrics-view query
|
|
251
|
+
--stdin`` to forward fields not surfaced as kwargs (e.g. future
|
|
252
|
+
backend additions) without a client-side rev.
|
|
253
|
+
"""
|
|
254
|
+
body: Dict[str, Any] = {
|
|
255
|
+
"metrics_view_uid": metrics_view_uid,
|
|
256
|
+
"measures": list(measures),
|
|
257
|
+
}
|
|
258
|
+
if dimensions:
|
|
259
|
+
body["dimensions"] = list(dimensions)
|
|
260
|
+
if dimension_grains:
|
|
261
|
+
body["dimension_grains"] = dimension_grains
|
|
262
|
+
if filters:
|
|
263
|
+
body["filters"] = list(filters)
|
|
264
|
+
if time_range:
|
|
265
|
+
body["time_range"] = time_range
|
|
266
|
+
if time_grain:
|
|
267
|
+
body["time_grain"] = time_grain
|
|
268
|
+
if order_by:
|
|
269
|
+
body["order_by"] = list(order_by)
|
|
270
|
+
if limit is not None:
|
|
271
|
+
body["limit"] = int(limit)
|
|
272
|
+
if offset is not None:
|
|
273
|
+
body["offset"] = int(offset)
|
|
274
|
+
if no_cache:
|
|
275
|
+
body["no_cache"] = True
|
|
276
|
+
if parameters:
|
|
277
|
+
body["parameters"] = parameters
|
|
278
|
+
if extra:
|
|
279
|
+
# extra wins — caller is explicitly overriding via --stdin
|
|
280
|
+
body.update(extra)
|
|
281
|
+
|
|
282
|
+
resp = self.session.post(f"{self.base_path}/metrics_query", json=body)
|
|
283
|
+
raise_for_status(resp)
|
|
284
|
+
payload = resp.json()
|
|
285
|
+
return payload if isinstance(payload, dict) else {"result": payload}
|
|
286
|
+
|
|
287
|
+
def metrics_schema(self, uid: str) -> Dict[str, Any]:
|
|
288
|
+
"""Return the measures/dimensions/parameters available on a
|
|
289
|
+
metrics view. Hits ``GET /api/v1/block/<uid>/metrics_schema``.
|
|
290
|
+
|
|
291
|
+
Used as the "what can I query?" companion to ``metrics_query`` —
|
|
292
|
+
agents typically fetch the schema before constructing a query so
|
|
293
|
+
they know which measure/dimension names are valid.
|
|
294
|
+
"""
|
|
295
|
+
resp = self.session.get(f"{self.base_path}/{uid}/metrics_schema")
|
|
296
|
+
raise_for_status(resp)
|
|
297
|
+
body = resp.json()
|
|
298
|
+
return _unwrap_result(body, default={}) or {}
|
|
299
|
+
|
|
300
|
+
# ── Results ────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
def results(
|
|
303
|
+
self,
|
|
304
|
+
uid: str,
|
|
305
|
+
*,
|
|
306
|
+
result_id: Optional[str] = None,
|
|
307
|
+
) -> Dict[str, Any]:
|
|
308
|
+
"""Fetch past execution result(s) for a block.
|
|
309
|
+
|
|
310
|
+
- ``result_id=None`` → ``GET /api/v1/block/<uid>/results/`` returns
|
|
311
|
+
a list of recent results (backend caps at ~10 by default).
|
|
312
|
+
- ``result_id="last_result"`` → backend special-cases this: returns
|
|
313
|
+
``{"result": <latest>, "last_results": [<rest>]}``.
|
|
314
|
+
- ``result_id=<specific id>`` → ``{"result": <that one>}``.
|
|
315
|
+
|
|
316
|
+
The shape varies between modes — the caller is expected to know
|
|
317
|
+
which key it asked for. Returns the raw dict from the backend.
|
|
318
|
+
"""
|
|
319
|
+
path = f"{self.base_path}/{uid}/results/"
|
|
320
|
+
if result_id:
|
|
321
|
+
path = f"{self.base_path}/{uid}/results/{result_id}"
|
|
322
|
+
resp = self.session.get(path)
|
|
323
|
+
raise_for_status(resp)
|
|
324
|
+
body = resp.json()
|
|
325
|
+
# Backend's `response(200, **sanitized_results)` flattens the dict
|
|
326
|
+
# at the top level. Hand the whole thing back.
|
|
327
|
+
return body if isinstance(body, dict) else {"result": body}
|
|
328
|
+
|
|
329
|
+
def execute_against_scratch(
|
|
330
|
+
self,
|
|
331
|
+
*,
|
|
332
|
+
block_type: str,
|
|
333
|
+
content_json: Dict[str, Any],
|
|
334
|
+
verb: str,
|
|
335
|
+
) -> Dict[str, Any]:
|
|
336
|
+
"""Run `content_json` as a transient override against an existing
|
|
337
|
+
assigned scratch block of `block_type`. Mirrors the legacy
|
|
338
|
+
``run_sql`` / ``send_email`` agent tools — no new block is
|
|
339
|
+
persisted; the scratch's stored content is merged with the body,
|
|
340
|
+
the run is executed with the merged content, and the scratch's
|
|
341
|
+
DB row is restored on the in-memory object by the backend's
|
|
342
|
+
``/<uid>/run`` handler (see ``superset/pushmetrics/views/block/
|
|
343
|
+
api.py:1010-1027``).
|
|
344
|
+
|
|
345
|
+
Resolution:
|
|
346
|
+
- List the agent's assigned blocks of `block_type` (FAB applies
|
|
347
|
+
``FilterAgentBlockScope`` so the list is already restricted to
|
|
348
|
+
blocks the agent owns).
|
|
349
|
+
- When the type has a routing field (``database_id`` for sql,
|
|
350
|
+
``integration_id`` for email/slack/tableau/...) and the body
|
|
351
|
+
supplies a value for it, pick the scratch whose stored
|
|
352
|
+
content has the matching value; reject unknown values rather
|
|
353
|
+
than silently falling back (mirrors legacy ``_resolve_block``
|
|
354
|
+
in ``block_tools.py``).
|
|
355
|
+
- With no routing field or no value, use the first assigned
|
|
356
|
+
scratch block.
|
|
357
|
+
|
|
358
|
+
Safety: the scratch's routing field WINS in the merge. The body
|
|
359
|
+
can supply ``sql``/``body``/``message`` etc., but cannot escape
|
|
360
|
+
the agent's assigned database/integration by overriding
|
|
361
|
+
``database_id`` / ``integration_id``. Same boundary the legacy
|
|
362
|
+
tool enforced.
|
|
363
|
+
|
|
364
|
+
Raises ``PMApiError`` when no assigned scratch matches.
|
|
365
|
+
"""
|
|
366
|
+
# Local import — ``PMApiError`` lives in client.base and importing
|
|
367
|
+
# at module scope makes the cyclic shape harder to reason about
|
|
368
|
+
# for testing. Cheap to import per call.
|
|
369
|
+
from pm.client.base import PMApiError
|
|
370
|
+
|
|
371
|
+
body = content_json or {}
|
|
372
|
+
routing_field = SCRATCH_ROUTING_FIELD.get(block_type)
|
|
373
|
+
target_value = body.get(routing_field) if routing_field else None
|
|
374
|
+
|
|
375
|
+
assigned = self.list(type=block_type)
|
|
376
|
+
if not assigned:
|
|
377
|
+
raise PMApiError(
|
|
378
|
+
400,
|
|
379
|
+
f"No {block_type!r} blocks are assigned to this agent. "
|
|
380
|
+
f"Ask an admin to provision one to enable "
|
|
381
|
+
f"`pm {block_type} {verb} --stdin`.",
|
|
382
|
+
method="POST",
|
|
383
|
+
path=f"{self.base_path}/<scratch>/run",
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# Parse every assigned block's content_json once up-front; reused
|
|
387
|
+
# for matching, error-message construction, and the final merge.
|
|
388
|
+
# Mirrors legacy ``_make_db_lookup`` / ``_make_integration_lookup``
|
|
389
|
+
# which built the lookup map in one pass.
|
|
390
|
+
parsed_assigned = [
|
|
391
|
+
(b, _parse_block_content_json(b.content_json)) for b in assigned
|
|
392
|
+
]
|
|
393
|
+
|
|
394
|
+
scratch = None
|
|
395
|
+
scratch_stored: Dict[str, Any] = {}
|
|
396
|
+
# ``is not None`` (NOT a truthy check) so explicitly-supplied
|
|
397
|
+
# falsy routing values (``database_id: 0`` / ``""``) still trigger
|
|
398
|
+
# the routing branch — otherwise they'd silently fall back to
|
|
399
|
+
# the first scratch and the user gets the wrong database.
|
|
400
|
+
# Mirrors the comment on the available-values list below.
|
|
401
|
+
if target_value is not None:
|
|
402
|
+
for b, stored in parsed_assigned:
|
|
403
|
+
# ``stored.get(...)`` directly (no ``or ""`` shim) so
|
|
404
|
+
# stored value ``0`` can match request value ``0``.
|
|
405
|
+
stored_value = stored.get(routing_field)
|
|
406
|
+
if stored_value is not None and str(stored_value) == str(target_value):
|
|
407
|
+
scratch = b
|
|
408
|
+
scratch_stored = stored
|
|
409
|
+
break
|
|
410
|
+
if scratch is None:
|
|
411
|
+
# Include falsy routing values (empty string, 0) in the
|
|
412
|
+
# available list — operators debugging a mismatch need to
|
|
413
|
+
# see what's actually configured, even if it's empty.
|
|
414
|
+
available = sorted({
|
|
415
|
+
str(stored.get(routing_field))
|
|
416
|
+
for _, stored in parsed_assigned
|
|
417
|
+
if routing_field in stored
|
|
418
|
+
})
|
|
419
|
+
raise PMApiError(
|
|
420
|
+
400,
|
|
421
|
+
f"{routing_field}={target_value!r} is not in this "
|
|
422
|
+
f"agent's allowed {block_type} scratch blocks. "
|
|
423
|
+
f"Available {routing_field}: "
|
|
424
|
+
f"{available or '(none with that field)'}.",
|
|
425
|
+
method="POST",
|
|
426
|
+
path=f"{self.base_path}/<scratch>/run",
|
|
427
|
+
)
|
|
428
|
+
else:
|
|
429
|
+
scratch, scratch_stored = parsed_assigned[0]
|
|
430
|
+
|
|
431
|
+
# Defensive — list() blocks always carry a uid in practice (FAB's
|
|
432
|
+
# schema makes it non-null), but ``Block.uid`` is typed
|
|
433
|
+
# ``Optional[str]`` and the contract isn't enforced client-side.
|
|
434
|
+
# Fail loudly rather than POST to /api/v1/block/None/run.
|
|
435
|
+
if not scratch.uid:
|
|
436
|
+
raise PMApiError(
|
|
437
|
+
500,
|
|
438
|
+
f"Assigned {block_type} scratch block returned without a "
|
|
439
|
+
f"uid; cannot execute.",
|
|
440
|
+
method="POST",
|
|
441
|
+
path=f"{self.base_path}/<scratch>/run",
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# Build the override: scratch's stored content as the base, body
|
|
445
|
+
# overlays on top. Then pin the routing field UNCONDITIONALLY when
|
|
446
|
+
# the type has one — the scratch's value wins even if its stored
|
|
447
|
+
# content is missing the field (in which case the field is dropped
|
|
448
|
+
# from the merge so the agent cannot supply an arbitrary value).
|
|
449
|
+
# This is the boundary that prevents scope escape via the body.
|
|
450
|
+
merged: Dict[str, Any] = {**scratch_stored, **body}
|
|
451
|
+
if routing_field:
|
|
452
|
+
if routing_field in scratch_stored:
|
|
453
|
+
merged[routing_field] = scratch_stored[routing_field]
|
|
454
|
+
else:
|
|
455
|
+
merged.pop(routing_field, None)
|
|
456
|
+
|
|
457
|
+
return self.execute_existing(
|
|
458
|
+
scratch.uid,
|
|
459
|
+
verb=verb,
|
|
460
|
+
block_properties={"content_json": _ensure_content_json_string(merged)},
|
|
461
|
+
)
|