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