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.
376 lines
16 KiB
Python
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)
|