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/validators.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""Per-block-type content_json schema validation (pm-tool plan §2.7).
|
|
2
|
+
|
|
3
|
+
Lightweight first-pass validation at the CLI layer — catches obvious
|
|
4
|
+
mistakes (missing required field, wrong type) with a clear error +
|
|
5
|
+
example body so the agent can recover in one round trip. The backend
|
|
6
|
+
still does authoritative validation; this is the friendly front door.
|
|
7
|
+
|
|
8
|
+
**Critical contract** (codex review 2026-05-22): the schemas below MUST
|
|
9
|
+
match the backend's ``content_json_attr`` lists at
|
|
10
|
+
``prefect/queryme/models/block/parameters/*.py``. If they drift, the CLI
|
|
11
|
+
silently accepts bodies the backend can't execute (or rejects valid
|
|
12
|
+
ones). ``cli/tests/test_validators.py`` has a cross-reference check that
|
|
13
|
+
parses those parameters files and fails if a schema key isn't in the
|
|
14
|
+
corresponding ``content_json_attr``.
|
|
15
|
+
|
|
16
|
+
**Scope**:
|
|
17
|
+
|
|
18
|
+
- Only validates the *shape* of a body (required fields present, types
|
|
19
|
+
plausible). Semantic checks (does database_id exist? is the integration
|
|
20
|
+
authorized?) happen server-side.
|
|
21
|
+
- Required fields must be present AND match the expected type. Optional
|
|
22
|
+
fields, if present, must match. Extra fields are allowed (the backend
|
|
23
|
+
may use or ignore them).
|
|
24
|
+
- Missing/unknown ``block_type`` → no-op (the type canonicalizer in
|
|
25
|
+
``cli/pm/types.py`` is the gatekeeper for unknown types; this module
|
|
26
|
+
trusts that and lets unrecognized types through to the backend).
|
|
27
|
+
|
|
28
|
+
**Modes** (pm-tool only ships ``create``):
|
|
29
|
+
|
|
30
|
+
- ``mode='create'`` — all required fields must be present.
|
|
31
|
+
- ``mode='update'`` — fields present must match types; missing fields OK.
|
|
32
|
+
Not used in A.3b; documented for the factory to wire later.
|
|
33
|
+
|
|
34
|
+
Schemas are intentionally conservative — when in doubt, accept and let
|
|
35
|
+
the backend error. The point of this module is the *friendly* path, not
|
|
36
|
+
exhaustive coverage.
|
|
37
|
+
"""
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
from pm.errors import PMError
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Per-block-type schemas. ``required`` and ``optional`` map field name →
|
|
46
|
+
# accepted Python type (or tuple of types for unions). Example body is
|
|
47
|
+
# returned verbatim in the structured error envelope so the agent can
|
|
48
|
+
# copy it as a starting point.
|
|
49
|
+
#
|
|
50
|
+
# EVERY field name below MUST appear in the corresponding
|
|
51
|
+
# ``content_json_attr`` list in
|
|
52
|
+
# ``prefect/queryme/models/block/parameters/<type>.py``. The cross-
|
|
53
|
+
# reference test in cli/tests/test_validators.py enforces this.
|
|
54
|
+
BLOCK_SCHEMAS: dict[str, dict[str, Any]] = {
|
|
55
|
+
"sql": {
|
|
56
|
+
"required": {
|
|
57
|
+
"sql": str,
|
|
58
|
+
"database_id": str,
|
|
59
|
+
},
|
|
60
|
+
"optional": {
|
|
61
|
+
"limit": int,
|
|
62
|
+
"schema": str,
|
|
63
|
+
"export_options": dict,
|
|
64
|
+
},
|
|
65
|
+
"example": {
|
|
66
|
+
"sql": "SELECT 1",
|
|
67
|
+
"database_id": "<db_uid>",
|
|
68
|
+
"limit": 1000,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
"email": {
|
|
72
|
+
# Matches EmailParameters.content_json_attr in
|
|
73
|
+
# prefect/queryme/models/block/parameters/email.py:
|
|
74
|
+
# body, subject, to_, attachments, cc, bcc, merge_pdf_config,
|
|
75
|
+
# merge_csv_config, smtp_from, smtp_from_fullname, integration_id.
|
|
76
|
+
#
|
|
77
|
+
# NOTE: `to_` (trailing underscore) — Python-style mangling that
|
|
78
|
+
# exists in the backend model because `to` is reserved-ish in
|
|
79
|
+
# several ORMs. Don't drop it. The agent's existing send_email
|
|
80
|
+
# tool already uses this exact key (block_tools.py:967).
|
|
81
|
+
"required": {
|
|
82
|
+
"subject": str,
|
|
83
|
+
"body": str,
|
|
84
|
+
"to_": (str, list),
|
|
85
|
+
"integration_id": str,
|
|
86
|
+
},
|
|
87
|
+
"optional": {
|
|
88
|
+
"cc": (str, list),
|
|
89
|
+
"bcc": (str, list),
|
|
90
|
+
"chart_block_uids": (str, list),
|
|
91
|
+
"attachments": (str, list),
|
|
92
|
+
"merge_pdf_config": dict,
|
|
93
|
+
"merge_csv_config": dict,
|
|
94
|
+
"smtp_from": str,
|
|
95
|
+
"smtp_from_fullname": str,
|
|
96
|
+
},
|
|
97
|
+
"example": {
|
|
98
|
+
"subject": "Daily orders",
|
|
99
|
+
"body": "<p>See attached.</p>",
|
|
100
|
+
"integration_id": "<email_integration_uid>",
|
|
101
|
+
"to_": "finance-team@example.com",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
"slack": {
|
|
105
|
+
# Matches SlackParameters.content_json_attr:
|
|
106
|
+
# integration_id, channels, attachments, thread_ts,
|
|
107
|
+
# attachments_to_thread, merge_pdf_config, merge_csv_config,
|
|
108
|
+
# include_reload_button.
|
|
109
|
+
#
|
|
110
|
+
# NOTE: `channels` is a LIST of channel ids/names — see
|
|
111
|
+
# template_block/slack.py:471 which reads it as iterable. The
|
|
112
|
+
# agent's existing send_slack tool already uses this shape
|
|
113
|
+
# (block_tools.py:1041).
|
|
114
|
+
#
|
|
115
|
+
# `message` is the body content. NOT in content_json_attr (the
|
|
116
|
+
# parameters layer reads body via the `value` property), but the
|
|
117
|
+
# agent's send_slack tool writes it as `message` in the override
|
|
118
|
+
# content_json (block_tools.py:1040) — that's the path agents
|
|
119
|
+
# actually use, so we accept it as the user-facing key.
|
|
120
|
+
"required": {
|
|
121
|
+
"integration_id": str,
|
|
122
|
+
"channels": (str, list),
|
|
123
|
+
"message": str,
|
|
124
|
+
},
|
|
125
|
+
"optional": {
|
|
126
|
+
"attachments": (str, list),
|
|
127
|
+
"thread_ts": str,
|
|
128
|
+
"attachments_to_thread": bool,
|
|
129
|
+
"merge_pdf_config": dict,
|
|
130
|
+
"merge_csv_config": dict,
|
|
131
|
+
"include_reload_button": bool,
|
|
132
|
+
},
|
|
133
|
+
"example": {
|
|
134
|
+
"integration_id": "<slack_integration_uid>",
|
|
135
|
+
"channels": ["#data-alerts"],
|
|
136
|
+
"message": "Daily report is ready.",
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
"sftp": {
|
|
140
|
+
# Matches SftpParameters.content_json_attr:
|
|
141
|
+
# path, integration_id, attachments, merge_pdf_config,
|
|
142
|
+
# merge_csv_config.
|
|
143
|
+
#
|
|
144
|
+
# `path` is the REMOTE destination on the SFTP server. The data
|
|
145
|
+
# to upload comes from `attachments` (URLs / refs to upstream
|
|
146
|
+
# block outputs). There is NO inline file_contents or
|
|
147
|
+
# source_block_uid in the backend schema — those were earlier
|
|
148
|
+
# assumptions on the CLI side that didn't match reality.
|
|
149
|
+
"required": {
|
|
150
|
+
"integration_id": str,
|
|
151
|
+
"path": str,
|
|
152
|
+
},
|
|
153
|
+
"optional": {
|
|
154
|
+
"attachments": (str, list),
|
|
155
|
+
"merge_pdf_config": dict,
|
|
156
|
+
"merge_csv_config": dict,
|
|
157
|
+
},
|
|
158
|
+
"example": {
|
|
159
|
+
"integration_id": "<sftp_integration_uid>",
|
|
160
|
+
"path": "/uploads/daily/{date}.csv",
|
|
161
|
+
"attachments": ["<csv_url_from_run_sql>"],
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
"plotly": {
|
|
165
|
+
# Matches ChartParameters.content_json_attr: just `properties`.
|
|
166
|
+
# `properties` holds the full Plotly spec ({data, layout}). The
|
|
167
|
+
# agent's generate_chart tool writes it as
|
|
168
|
+
# {"properties": spec, "image_filename": <name>} —
|
|
169
|
+
# block_tools.py:1193.
|
|
170
|
+
"required": {
|
|
171
|
+
"properties": dict,
|
|
172
|
+
},
|
|
173
|
+
"optional": {
|
|
174
|
+
"image_filename": str,
|
|
175
|
+
},
|
|
176
|
+
"example": {
|
|
177
|
+
"properties": {
|
|
178
|
+
"data": [{"x": [1, 2, 3], "y": [4, 5, 6], "type": "bar"}],
|
|
179
|
+
"layout": {"title": "Example"},
|
|
180
|
+
},
|
|
181
|
+
"image_filename": "chart.png",
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
"metrics_view": {
|
|
185
|
+
# Metrics views are content blocks (not runnable). content_json
|
|
186
|
+
# holds the semantic layer definition. Fields below are what
|
|
187
|
+
# the semantic layer reads — keep this list in sync with
|
|
188
|
+
# ``superset/pushmetrics/semantic``.
|
|
189
|
+
# Codex pm-consolidation pass 2 (2026-05-22) flagged this drift:
|
|
190
|
+
# an earlier draft advertised ``database_id`` + ``source_sql``
|
|
191
|
+
# but the engine reads ``database`` + ``model`` — agents
|
|
192
|
+
# following the validator built content_json that the engine
|
|
193
|
+
# then rejected with "no database configured". 2026-06-05 fix.
|
|
194
|
+
"required": {
|
|
195
|
+
"name": str,
|
|
196
|
+
"measures": list,
|
|
197
|
+
"database": str, # database UID hashid (not the human name —
|
|
198
|
+
# the FE YAML export uses the human name for
|
|
199
|
+
# portability and resolves on import, but
|
|
200
|
+
# ``pm metrics-view create`` writes directly
|
|
201
|
+
# to content_json so it must be the UID).
|
|
202
|
+
"model": str, # SQL string. The engine wraps it as
|
|
203
|
+
# ``FROM ({model}) AS model`` (see
|
|
204
|
+
# ``semantic/sql_builder.py``) — a bare
|
|
205
|
+
# table reference like ``"orders"`` fails.
|
|
206
|
+
# FE "table mode" generates
|
|
207
|
+
# ``SELECT * FROM <schema>.<table>``
|
|
208
|
+
# before persisting; do the same.
|
|
209
|
+
},
|
|
210
|
+
"optional": {
|
|
211
|
+
"description": str,
|
|
212
|
+
"dimensions": list,
|
|
213
|
+
"timeseries": str, # ts column name
|
|
214
|
+
"smallest_time_grain": str, # day | week | month | etc.
|
|
215
|
+
# ``parameters`` is a LIST of parameter defs
|
|
216
|
+
# ``[{key, type, default_value, label?}]`` per the FE
|
|
217
|
+
# ``MetricsParameter`` interface and the SQL builder. Codex
|
|
218
|
+
# round-1 P1 (2026-06-05): an earlier draft typed this as
|
|
219
|
+
# ``dict`` — FE-saved metrics views would have failed
|
|
220
|
+
# validator round-trip.
|
|
221
|
+
"parameters": list,
|
|
222
|
+
"filters": list,
|
|
223
|
+
"joins": list,
|
|
224
|
+
"sources": list,
|
|
225
|
+
},
|
|
226
|
+
"example": {
|
|
227
|
+
"name": "Daily Orders",
|
|
228
|
+
"database": "<database_uid>",
|
|
229
|
+
"model": "SELECT * FROM orders",
|
|
230
|
+
# Measures use ``expression`` (any SQL agg) and an optional
|
|
231
|
+
# ``additive`` flag. Today ``additive`` is FE metadata only
|
|
232
|
+
# (the Explore UI uses it to choose chart aggregation hints);
|
|
233
|
+
# the semantic engine's symmetric aggregation is keyed on
|
|
234
|
+
# source_table + join relationships at
|
|
235
|
+
# ``semantic/sql_builder.py``, not on this flag. Set it
|
|
236
|
+
# truthfully (false for COUNT DISTINCT / SUM-of-distinct
|
|
237
|
+
# things) so downstream consumers can rely on it later.
|
|
238
|
+
"measures": [
|
|
239
|
+
{
|
|
240
|
+
"name": "order_count",
|
|
241
|
+
"expression": "COUNT(*)",
|
|
242
|
+
"display_name": "Orders",
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
"name": "unique_customers",
|
|
246
|
+
"expression": "COUNT(DISTINCT customer_id)",
|
|
247
|
+
"additive": False,
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
# Dimensions use ``column`` (the SQL column name in the
|
|
251
|
+
# model) — NOT ``expression``. The SQL builder at
|
|
252
|
+
# ``superset/pushmetrics/semantic/sql_builder.py`` reads it
|
|
253
|
+
# via ``dim_def.get("column", dim_name)`` (e.g. lines 292/346),
|
|
254
|
+
# falling back to ``name`` when ``column`` is absent.
|
|
255
|
+
"dimensions": [
|
|
256
|
+
{"name": "order_date", "column": "created_at", "type": "temporal"},
|
|
257
|
+
{"name": "region", "column": "region_code"},
|
|
258
|
+
],
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
"tableau": {
|
|
262
|
+
# Matches TableauParameters.content_json_attr:
|
|
263
|
+
# integration_id, workbook_id, workbook_name, view_id, view_name,
|
|
264
|
+
# custom_view_id, action, filters, resolution, orientation,
|
|
265
|
+
# page_type, viz_height, viz_width, export_options.
|
|
266
|
+
"required": {
|
|
267
|
+
"integration_id": str,
|
|
268
|
+
"action": str,
|
|
269
|
+
},
|
|
270
|
+
"optional": {
|
|
271
|
+
# ``view_id`` is required for view_* actions (view_image,
|
|
272
|
+
# view_csv, view_pdf, ...); ``workbook_id`` is required
|
|
273
|
+
# instead for workbook_* actions (workbook_pdf,
|
|
274
|
+
# workbook_powerpoint). Codex round-1 P2 (2026-06-05):
|
|
275
|
+
# earlier validator forced view_id for ALL actions,
|
|
276
|
+
# blocking workbook exports the worker accepts. We mark
|
|
277
|
+
# both optional + rely on the worker to reject the
|
|
278
|
+
# missing-id case with the action-specific error.
|
|
279
|
+
"view_id": str,
|
|
280
|
+
"workbook_id": str,
|
|
281
|
+
"workbook_name": str,
|
|
282
|
+
"view_name": str,
|
|
283
|
+
"custom_view_id": str,
|
|
284
|
+
# Tableau filter string. Comma-separates multiple values
|
|
285
|
+
# (``value1,value2``); embedded commas escape as ``%5C%2C``
|
|
286
|
+
# per Salesforce docs. The worker calls ``.strip()`` and
|
|
287
|
+
# ``re.sub()`` on this directly — a dict would raise
|
|
288
|
+
# ``AttributeError`` (no ``.strip()``) on the first call.
|
|
289
|
+
# Codex pm-consolidation pass 2 (2026-05-22) flagged this;
|
|
290
|
+
# 2026-06-05 fix restricts to ``str`` only.
|
|
291
|
+
"filters": str,
|
|
292
|
+
"resolution": str,
|
|
293
|
+
"orientation": str,
|
|
294
|
+
"page_type": str,
|
|
295
|
+
"viz_height": (int, str),
|
|
296
|
+
"viz_width": (int, str),
|
|
297
|
+
"export_options": dict,
|
|
298
|
+
},
|
|
299
|
+
"example": {
|
|
300
|
+
"integration_id": "<tableau_integration_uid>",
|
|
301
|
+
"view_id": "<tableau_view_id>",
|
|
302
|
+
"action": "view_image",
|
|
303
|
+
# Comma-separated; escape embedded commas as ``%5C%2C``.
|
|
304
|
+
"filters": "Region=EMEA",
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
"google_slides": {
|
|
308
|
+
# Matches GoogleSlidesParameters.content_json_attr:
|
|
309
|
+
# integration_id, action, source_file_id, presentation_id, title,
|
|
310
|
+
# folder_id, replacements, share_with, requests, page_id,
|
|
311
|
+
# page_object_id, image_url, chart_block_uid, workspace_id,
|
|
312
|
+
# position_x, position_y, width, height, composition_json,
|
|
313
|
+
# template_definition, export_options.
|
|
314
|
+
#
|
|
315
|
+
# Different `action` values require different additional fields
|
|
316
|
+
# (compose_from_template needs source_file_id + composition_json;
|
|
317
|
+
# export_pdf needs presentation_id; batch_update needs requests +
|
|
318
|
+
# presentation_id). The validator only requires `integration_id`
|
|
319
|
+
# + `action` — the runner produces clear errors for missing
|
|
320
|
+
# action-specific fields.
|
|
321
|
+
"required": {
|
|
322
|
+
"integration_id": str,
|
|
323
|
+
"action": str,
|
|
324
|
+
},
|
|
325
|
+
"optional": {
|
|
326
|
+
"source_file_id": str,
|
|
327
|
+
"presentation_id": str,
|
|
328
|
+
"title": str,
|
|
329
|
+
"folder_id": str,
|
|
330
|
+
"replacements": dict,
|
|
331
|
+
"share_with": (str, list),
|
|
332
|
+
"requests": list,
|
|
333
|
+
"page_id": str,
|
|
334
|
+
"page_object_id": str,
|
|
335
|
+
"image_url": str,
|
|
336
|
+
"chart_block_uid": str,
|
|
337
|
+
"position_x": (int, float),
|
|
338
|
+
"position_y": (int, float),
|
|
339
|
+
"width": (int, float),
|
|
340
|
+
"height": (int, float),
|
|
341
|
+
"composition_json": dict,
|
|
342
|
+
"template_definition": dict,
|
|
343
|
+
"export_options": dict,
|
|
344
|
+
},
|
|
345
|
+
"example": {
|
|
346
|
+
"integration_id": "<google_integration_uid>",
|
|
347
|
+
"action": "compose_from_template",
|
|
348
|
+
"source_file_id": "<slides_template_id>",
|
|
349
|
+
"composition_json": {"slides": []},
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
# agent_skill / agent_memory / explore — minimal schemas. The bodies are
|
|
353
|
+
# mostly free-form (markdown for skills/memory; explore is a saved view).
|
|
354
|
+
# Leave them out of BLOCK_SCHEMAS — validate_content_json no-ops for
|
|
355
|
+
# unrecognized types, which is what we want here.
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def validate_content_json(
|
|
360
|
+
block_type: str,
|
|
361
|
+
body: dict[str, Any],
|
|
362
|
+
*,
|
|
363
|
+
mode: str = "create",
|
|
364
|
+
) -> None:
|
|
365
|
+
"""Raise :class:`pm.errors.PMError` if ``body`` doesn't match the schema
|
|
366
|
+
for ``block_type``. No-op for types not in ``BLOCK_SCHEMAS`` (lets them
|
|
367
|
+
through to the backend, which is authoritative).
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
block_type: canonical block type (already canonicalized via
|
|
371
|
+
``cli/pm/types.py``).
|
|
372
|
+
body: parsed JSON dict.
|
|
373
|
+
mode: ``'create'`` (default — required fields must be present) or
|
|
374
|
+
``'update'`` (only check types of fields that are present).
|
|
375
|
+
"""
|
|
376
|
+
if not isinstance(body, dict):
|
|
377
|
+
raise PMError(
|
|
378
|
+
error_code="block_schema_validation_failed",
|
|
379
|
+
message=f"Body must be a JSON object; got {type(body).__name__}",
|
|
380
|
+
received=type(body).__name__,
|
|
381
|
+
help=f"pm help {block_type}",
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
schema = BLOCK_SCHEMAS.get(block_type)
|
|
385
|
+
if schema is None:
|
|
386
|
+
# No schema for this type — trust the backend to validate.
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
required = schema.get("required", {})
|
|
390
|
+
optional = schema.get("optional", {})
|
|
391
|
+
example = schema.get("example")
|
|
392
|
+
|
|
393
|
+
# Pass 1 (create mode only): required fields present?
|
|
394
|
+
if mode == "create":
|
|
395
|
+
missing = [name for name in required if name not in body]
|
|
396
|
+
if missing:
|
|
397
|
+
raise PMError(
|
|
398
|
+
error_code="block_schema_validation_failed",
|
|
399
|
+
message=(
|
|
400
|
+
f"Missing required field(s) for type={block_type!r}: "
|
|
401
|
+
f"{', '.join(missing)}"
|
|
402
|
+
),
|
|
403
|
+
received=", ".join(sorted(body.keys())) if body else "(empty)",
|
|
404
|
+
allowed=sorted(set(required) | set(optional)),
|
|
405
|
+
example=example,
|
|
406
|
+
help=f"pm help {block_type}",
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
# Pass 2: type-check every field present in body against required+optional
|
|
410
|
+
type_map = {**required, **optional}
|
|
411
|
+
for field_name, value in body.items():
|
|
412
|
+
expected = type_map.get(field_name)
|
|
413
|
+
if expected is None:
|
|
414
|
+
# Unknown field — allow; backend may use or ignore. Conservative
|
|
415
|
+
# choice to keep the CLI welcoming.
|
|
416
|
+
continue
|
|
417
|
+
if not isinstance(value, expected):
|
|
418
|
+
raise PMError(
|
|
419
|
+
error_code="block_schema_validation_failed",
|
|
420
|
+
message=(
|
|
421
|
+
f"Field {field_name!r} for type={block_type!r}: expected "
|
|
422
|
+
f"{_type_name(expected)}, got {type(value).__name__}"
|
|
423
|
+
),
|
|
424
|
+
received=f"{field_name}={type(value).__name__}",
|
|
425
|
+
allowed=[_type_name(expected)],
|
|
426
|
+
example=example,
|
|
427
|
+
help=f"pm help {block_type}",
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _type_name(t: Any) -> str:
|
|
432
|
+
"""Human-friendly name for a type or tuple of types."""
|
|
433
|
+
if isinstance(t, tuple):
|
|
434
|
+
return " | ".join(_type_name(x) for x in t)
|
|
435
|
+
return t.__name__
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# ── User-facing → backend-key translation ─────────────────────────────
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def normalize_content_json(block_type: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
442
|
+
"""Translate CLI user-facing keys to the backend's expected content_json
|
|
443
|
+
shape. Run after ``validate_content_json`` and before sending to the
|
|
444
|
+
BlocksClient.
|
|
445
|
+
|
|
446
|
+
The CLI persists ``block.content_json`` directly (no override
|
|
447
|
+
middleware between persist and run), so the persisted shape MUST
|
|
448
|
+
match what the noteflow worker reads. The agent's existing
|
|
449
|
+
``send_email`` / ``send_slack`` tools take user-friendly keys
|
|
450
|
+
(``body``, ``message``) and translate at the override layer in
|
|
451
|
+
``prefect/queryme/agent/block_runner.py``; the CLI bypasses that
|
|
452
|
+
path, so the translation has to happen here instead.
|
|
453
|
+
|
|
454
|
+
Codex review pass 2 (2026-05-22) caught the cases handled below.
|
|
455
|
+
|
|
456
|
+
The function never raises — input bodies validated under
|
|
457
|
+
``validate_content_json`` are well-shaped enough that translation
|
|
458
|
+
is mechanical. Unknown block types pass through unchanged.
|
|
459
|
+
|
|
460
|
+
Translations applied:
|
|
461
|
+
|
|
462
|
+
- **email**: ``body`` → ``value`` (renamed key — the runner at
|
|
463
|
+
``template_block/email.py:94`` reads ``content.get("value")``).
|
|
464
|
+
|
|
465
|
+
- **slack**:
|
|
466
|
+
|
|
467
|
+
- ``message`` → ``value_markdown`` (renamed key — the
|
|
468
|
+
``SlackParameters.value_wysiwyg`` property reads
|
|
469
|
+
``content_json.get("value_markdown")``). Also sets
|
|
470
|
+
``editor_mode = "wysiwyg_mode"`` so the parameters layer
|
|
471
|
+
picks the markdown branch.
|
|
472
|
+
- ``channels`` as ``str`` → ``[str]`` (list). The runner iterates
|
|
473
|
+
``channels`` at ``template_block/slack.py:471``; a string would
|
|
474
|
+
be split character-by-character into single-char channel names.
|
|
475
|
+
|
|
476
|
+
Returns a new dict (does not mutate the caller's body).
|
|
477
|
+
"""
|
|
478
|
+
if not isinstance(body, dict):
|
|
479
|
+
return body
|
|
480
|
+
out = dict(body)
|
|
481
|
+
|
|
482
|
+
if block_type == "email":
|
|
483
|
+
if "body" in out:
|
|
484
|
+
# Pop + reassign so a body=... payload becomes value=...
|
|
485
|
+
out["value"] = out.pop("body")
|
|
486
|
+
|
|
487
|
+
elif block_type == "slack":
|
|
488
|
+
if "message" in out:
|
|
489
|
+
out["value_markdown"] = out.pop("message")
|
|
490
|
+
# The Slack parameters layer picks between value_json and
|
|
491
|
+
# value_markdown based on editor_mode. Default to the
|
|
492
|
+
# markdown branch (the common authoring path); callers who
|
|
493
|
+
# need value_json can set editor_mode explicitly.
|
|
494
|
+
out.setdefault("editor_mode", "wysiwyg_mode")
|
|
495
|
+
ch = out.get("channels")
|
|
496
|
+
if isinstance(ch, str):
|
|
497
|
+
out["channels"] = [ch]
|
|
498
|
+
|
|
499
|
+
return out
|