Files
DumiFlex e891e4f6af feat: export library items as plain wildcard files (#310)
Import / Export gets a Wildcard files (.zip) button: picks become Dynamic Prompts / Impact Pack .txt files (engine/wildcard_export.py, POST /wp/api/export/plain), with a README listing what has no plain form. The community site vendors the exporter.
2026-11-09 14:36:15 +01:00

376 lines
16 KiB
Python

"""/wp/api/import/* + /wp/api/export/* — library bundle commit pipeline."""
from __future__ import annotations
import base64
from typing import Any
from aiohttp import web
from engine.exporter import build_export_payload
from engine.importer import commit_import, get_undo_entry, undo_import
from engine.modules import get_handler
from engine.wildcard_export import export_envelope, slug
from wp_api._helpers import db_session, json_error, json_ok
from wp_api._validators import (
MAX_IMPORT_BODY_BYTES,
validate_body_size,
validate_wildcard_name,
validate_wildcard_subcats,
)
from wp_api.modules import _hydrate_constraint_exceptions
# 8-bucket export request keys (Task 10). Each key, when present, must be
# a list of UUIDs; missing keys default to []. Mis-typed UUIDs (e.g. a
# wildcard id under combine_uuids) are silently dropped by the exporter.
_EXPORT_BUILD_KEYS = (
"bundle_uuids",
"wildcard_uuids",
"fixed_values_uuids",
"combine_uuids",
"derivation_uuids",
"constraint_uuids",
"category_uuids",
"template_uuids",
)
async def _read_export_request(request: web.Request) -> dict[str, Any] | web.Response:
"""The export body as a dict, or the error response to send."""
err = validate_body_size(request.content_length)
if err is not None:
return json_error(err, status=400)
try:
body = await request.json()
except Exception:
return json_error("invalid JSON body", status=400)
if not isinstance(body, dict):
return json_error("body must be a JSON object", status=400)
for key in _EXPORT_BUILD_KEYS:
if key in body and not isinstance(body[key], list):
return json_error("uuid lists must be arrays", status=400)
return body
def _build(request: web.Request, body: dict[str, Any]) -> dict[str, Any]:
with db_session(request) as conn:
return build_export_payload(
conn,
bundle_uuids=body.get("bundle_uuids", []),
wildcard_uuids=body.get("wildcard_uuids", []),
fixed_values_uuids=body.get("fixed_values_uuids", []),
combine_uuids=body.get("combine_uuids", []),
derivation_uuids=body.get("derivation_uuids", []),
constraint_uuids=body.get("constraint_uuids", []),
category_uuids=body.get("category_uuids", []),
template_uuids=body.get("template_uuids", []),
)
async def export_build(request: web.Request) -> web.Response:
"""POST /wp/api/export/build — assemble an 8-bucket export payload.
Body shape:
{
"bundle_uuids": [...],
"wildcard_uuids": [...],
"fixed_values_uuids":[...],
"combine_uuids": [...],
"derivation_uuids": [...],
"constraint_uuids": [...],
"category_uuids": [...],
"template_uuids": [...],
}
Every key is optional and defaults to ``[]``. UUID format is NOT
validated here: the exporter silently drops missing or cross-bucket
UUIDs, which is the intended behavior for the picker UI's loose
type tracking. Response is the exporter payload returned directly
(no ``{"ok": true, ...}`` wrapper).
"""
body = await _read_export_request(request)
if isinstance(body, web.Response):
return body
return json_ok(_build(request, body))
async def export_plain(request: web.Request) -> web.Response:
"""POST /wp/api/export/plain — the same picks as ``export/build``, as
plain wildcard files (``engine/wildcard_export.py``) for Dynamic
Prompts, Impact Pack and the like. Optional ``name`` names the pack
folder. Returns ``{filename, data (base64 zip), files, skipped}``.
"""
body = await _read_export_request(request)
if isinstance(body, web.Response):
return body
raw_name = body.get("name")
name = raw_name.strip()[:80] if isinstance(raw_name, str) and raw_name.strip() else "wildcards"
plain = export_envelope(_build(request, body), name)
if not plain.files:
return json_error("nothing in the selection can be written as wildcard files", status=400)
data = plain.to_zip(title=name)
return json_ok({
"filename": f"{slug(name, 'wildcards')}-wildcards.zip",
"data": base64.b64encode(data).decode("ascii"),
"files": len(plain.files),
"skipped": plain.skipped,
})
def _validate_wildcard_grammar(body: dict) -> str | None:
"""Walk the 3 commit buckets and reject any wildcard whose name or
sub_categories[] would break the `@{uuid#name:subcat}` ref grammar.
Bucket entity keys differ — `adds` uses ``entity``, `replaces` uses
``new_content``, `renames` uses ``content``. Each is treated the
same for our purposes (we read ``name`` and ``payload.sub_categories``).
Returns an error string on first violation, ``None`` on clean pass.
"""
bucket_entity_key = {"adds": "entity", "replaces": "new_content", "renames": "content"}
for bucket, key in bucket_entity_key.items():
items = body.get(bucket) or []
if not isinstance(items, list):
continue
for i, item in enumerate(items):
if not isinstance(item, dict):
continue
if item.get("kind") != "wildcard":
continue
entity = item.get(key)
if not isinstance(entity, dict):
continue
name = entity.get("name")
if isinstance(name, str):
err = validate_wildcard_name(name)
if err is not None:
return f"{bucket}[{i}]: {err}"
payload = entity.get("payload")
err = validate_wildcard_subcats(payload)
if err is not None:
return f"{bucket}[{i}].payload: {err}"
return None
_MODULE_KINDS = ("wildcard", "fixed_values", "combine", "derivation", "constraint")
_BUCKET_ENTITY_KEY = {"adds": "entity", "replaces": "new_content", "renames": "content"}
def _commit_entities(body: dict):
"""Yield ``(label, kind, entity, id)`` for every entity in the commit."""
for bucket, key in _BUCKET_ENTITY_KEY.items():
for i, item in enumerate(body.get(bucket) or []):
if not isinstance(item, dict):
continue
entity = item.get(key)
if not isinstance(entity, dict):
continue
eid = item.get("new_id") if bucket == "renames" else (
item.get("id") if bucket == "replaces" else entity.get("id")
)
yield f"{bucket}[{i}]", item.get("kind"), entity, eid
def _hydrate_from_pack(payload: dict, pack_wildcards: dict[str, dict]) -> None:
"""Fill a constraint exception's missing option text from a wildcard in
the same commit (the library lookup in ``_hydrate_constraint_exceptions``
can't see wildcards that are only now being imported)."""
excs = payload.get("exceptions")
if not isinstance(excs, list):
return
for side in ("source", "target"):
opts = (pack_wildcards.get(str(payload.get(f"{side}_wildcard_id"))) or {}).get("options")
if not isinstance(opts, list):
continue
by_id = {o.get("id"): o.get("value") for o in opts if isinstance(o, dict)}
for exc in excs:
if not isinstance(exc, dict):
continue
if isinstance(exc.get(side), str) or isinstance(exc.get(f"{side}_value"), str):
continue
v = by_id.get(exc.get(f"{side}_id"))
if isinstance(v, str):
exc[f"{side}_value"] = v
def _check_payload(
label: str, kind: Any, payload: Any, conn, pack_wildcards: dict[str, dict],
) -> str | None:
if not isinstance(payload, dict):
return f"{label}.payload must be an object"
handler = get_handler(kind)
if handler is None:
return None
if kind == "constraint":
_hydrate_from_pack(payload, pack_wildcards)
_hydrate_constraint_exceptions(conn, payload)
try:
# The runtime check only (what every graph run re-applies), not the
# authoring rules: an older export that runs today must still import.
handler.validate_payload(payload)
except ValueError as exc:
return f"{label}.payload: {exc}"
return None
def _validate_payload_shapes(body: dict, conn) -> str | None:
"""Reject a commit whose module (or bundle child) payload the engine
would refuse at run time. The importer writes whatever it's handed, so
without this a hand-edited or buggy pack landed in the library and only
failed later, in a graph run or an editor. Returns the first error."""
entities = list(_commit_entities(body))
pack_wildcards = {
eid: e["payload"] for _, kind, e, eid in entities
if kind == "wildcard" and isinstance(eid, str) and isinstance(e.get("payload"), dict)
}
for label, kind, entity, _ in entities:
if kind in _MODULE_KINDS:
if entity.get("payload") is None:
missing = [f for f in ("name", "payload") if entity.get(f) is None]
return f"{label}: {kind} is missing " + ", ".join(repr(f) for f in missing)
err = _check_payload(label, kind, entity.get("payload"), conn, pack_wildcards)
if err is not None:
return err
elif kind == "bundle":
children = entity.get("children")
if children is None:
continue
if not isinstance(children, list):
return f"{label}.children must be a list"
for j, child in enumerate(children):
if not isinstance(child, dict):
return f"{label}.children[{j}] must be an object"
if child.get("payload") is None:
# Inline entries may omit the payload (same rule as the
# bundle editor's own save).
continue
err = _check_payload(
f"{label}.children[{j}]", child.get("type"), child.get("payload"),
conn, pack_wildcards,
)
if err is not None:
return err
return None
async def import_commit(request: web.Request) -> web.Response:
"""POST /wp/api/import/commit — apply a classified commit payload.
Body shape (produced by the SPA's client-side classifier, Task 15)::
{
"adds": [{"kind": str, "entity": {...}}, ...],
"replaces": [{"kind": str, "id": str, "new_content": {...}}, ...],
"renames": [{"kind": str, "old_id": str, "new_id": str,
"content": {...}}, ...]
}
Every bucket is optional and defaults to ``[]``. ``kind`` must be one
of the 8 buckets (``wildcard``, ``fixed_values``, ``combine``,
``derivation``, ``constraint``, ``bundle``, ``category``,
``template``) — the engine rejects anything else as a contract
violation.
Response on success: ``{"ok": True, "undo_entry_id": str, "summary":
{...}}`` (200). Contract violations and DB-layer errors return
``{"error": str}`` with status 400; the engine already wraps raw
sqlite errors as ``"database integrity violation"`` so no internals
leak. The body cap is 64 MB (MAX_IMPORT_BODY_BYTES) so a converted
wildcard pack fits in one commit.
"""
err = validate_body_size(request.content_length, MAX_IMPORT_BODY_BYTES)
if err is not None:
return json_error(err, status=400)
try:
body = await request.json()
except Exception:
return json_error("invalid JSON body", status=400)
if not isinstance(body, dict):
return json_error("body must be a JSON object", status=400)
# Each bucket must be a list when present. Mirrors the export_build
# check at line 209 — the engine would crash with TypeError otherwise.
for key in ("adds", "replaces", "renames"):
if key in body and not isinstance(body[key], list):
return json_error(f"{key} must be a list", status=400)
# Wildcard name + sub_categories grammar guard. The engine importer
# commits whatever it's handed, so the ref-grammar invariant is
# enforced at the HTTP boundary here. Older exports (authored
# before the `@{uuid#name:subcat}` form locked the forbidden char
# set) could otherwise sneak in names with `{}:#@,` and break ref
# parsing on the next save. Walks all 3 buckets — adds carry
# `entity`, replaces carry `new_content`, renames carry `content`.
err = _validate_wildcard_grammar(body)
if err is not None:
return json_error(err, status=400)
with db_session(request) as conn:
err = _validate_payload_shapes(body, conn)
if err is not None:
return json_error(err, status=400)
result = commit_import(conn, body)
if not result.get("ok"):
# commit_import returns {"ok": False, "error": str} for both
# contract violations and wrapped DB errors. 400 covers both.
return json_error(result.get("error", "import commit failed"), status=400)
# Engine returns ``undo_id``; the locked HTTP contract uses
# ``undo_entry_id``. Rename at the boundary so the engine field name
# stays decoupled from the client-facing API.
return json_ok({
"ok": True,
"undo_entry_id": result["undo_id"],
"summary": result.get("summary", {}),
})
async def import_undo(request: web.Request) -> web.Response:
"""POST /wp/api/import/undo — reverse a previous import/commit call.
Body shape::
{"undo_entry_id": str}
Response on success: ``{"ok": True}`` (200). Missing or non-string
``undo_entry_id`` → 400. Unknown ``undo_entry_id`` → **404** (clean
miss; the engine returns ``"undo entry ... not found"``). Other
failures (corrupt undo blob, DB error) → 400.
"""
err = validate_body_size(request.content_length)
if err is not None:
return json_error(err, status=400)
try:
body = await request.json()
except Exception:
return json_error("invalid JSON body", status=400)
if not isinstance(body, dict):
return json_error("body must be a JSON object", status=400)
if "undo_entry_id" not in body:
return json_error("undo_entry_id required", status=400)
undo_entry_id = body["undo_entry_id"]
if not isinstance(undo_entry_id, str):
return json_error("undo_entry_id must be a string", status=400)
with db_session(request) as conn:
# Structural 404: existence check before attempting the undo.
# Mirrors the typed-exception pattern used by ModuleNotFound /
# BundleNotFound / CategoryNotFound elsewhere in wp_api — the
# HTTP status is decided by row presence, not by parsing engine
# error wording. Same connection drives both reads so we keep a
# single transaction context.
entry = get_undo_entry(conn, undo_entry_id)
if entry is None:
return json_error(
f"undo entry {undo_entry_id!r} not found", status=404,
)
result = undo_import(conn, undo_entry_id)
if not result.get("ok"):
# Remaining failures (corrupt undo blob, IntegrityError during
# restore) are all proper 400s — the 404 path is handled above.
return json_error(result.get("error", "undo failed"), status=400)
return json_ok({"ok": True})
def register(router) -> None:
router.add_post("/wp/api/export/build", export_build)
router.add_post("/wp/api/export/plain", export_plain)
router.add_post("/wp/api/import/commit", import_commit)
router.add_post("/wp/api/import/undo", import_undo)