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/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
+ )