Convert .txt, .yaml/.yml and .json wildcard files (or a .zip of them)
into one wildcard module per list, named by path, with references turned
into @{} links, glob and parent-key references into group wildcards,
PPP labels, weights and else choices into sub-categories, weights and the
fallback. Optionally files them into one bundle per top folder plus a
pack bundle. A report lists everything that converts differently.
Also fixes import commits that add a category alongside its modules
(categories now go in first, and members follow a name merge), and lets
the Import / Export view load more than 1000 library rows.
170 lines
7.2 KiB
Python
170 lines
7.2 KiB
Python
"""Shared API-level validators for meta + identifier shape.
|
||
|
||
Centralises the "between-the-handler-and-the-engine" guards so both
|
||
modules and bundles enforce the same name / description / tag rules,
|
||
and so the engine-level ``validate_payload`` calls aren't responsible
|
||
for the API-layer concerns (length caps, tag-element types, etc).
|
||
|
||
Caps are deliberately generous — the goal is "block obvious abuse"
|
||
(megabyte names, non-string tag entries, runaway identifiers) without
|
||
constraining real authoring. Bump these if a real workflow needs more.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Any
|
||
|
||
from engine.syntax.subcat_filter import validate_subcat_name
|
||
|
||
# ─── Length caps ─────────────────────────────────────────────────────
|
||
MAX_NAME_LEN = 200
|
||
MAX_DESC_LEN = 4000
|
||
MAX_TAG_LEN = 64
|
||
MAX_TAGS = 50
|
||
MAX_IDENT_LEN = 64
|
||
MAX_TEMPLATE_LEN = 8000
|
||
# Hard cap on the raw POST/PUT body size. Real bundles serialise to a
|
||
# few hundred KB at most; the largest realistic payload is a wildcard
|
||
# with a couple hundred options. 5 MB is roughly 100× the realistic
|
||
# upper bound — enough headroom for unusual library entries without
|
||
# letting a 50k-option DoS slip through.
|
||
MAX_BODY_BYTES = 5 * 1024 * 1024
|
||
# Import commits carry a whole library at once; a converted wildcard pack
|
||
# (the Dynamic Prompts collections are ~7 MB) is far past the 5 MB cap.
|
||
MAX_IMPORT_BODY_BYTES = 64 * 1024 * 1024
|
||
|
||
# Identifier rule: Python-style ident, no leading underscore-underscore
|
||
# (those collide with the engine's `__wp_*__` internal-key convention —
|
||
# see CLAUDE.md and engine/modules/types.py:strip_internals).
|
||
_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
||
|
||
# Characters reserved by the nested-ref grammar `@{uuid[#name][:expr][!null]}`.
|
||
# A wildcard `name` becomes the `#name` segment, so it must not contain
|
||
# the segment delimiters `# : } @ { !` (comma kept forbidden for
|
||
# continuity). Sub-category NAMES follow stricter §3.6 rules (the boolean
|
||
# grammar) and are validated via the shared `validate_subcat_name`.
|
||
REF_GRAMMAR_FORBIDDEN_CHARS = frozenset("{}:#@,!")
|
||
|
||
|
||
def validate_wildcard_name(name: str) -> str | None:
|
||
"""Wildcard-only name guard. Returns an error string when the name
|
||
contains characters reserved by the nested-ref grammar, ``None``
|
||
otherwise. Generic shape checks live in :func:`validate_meta`."""
|
||
bad = sorted(set(name) & REF_GRAMMAR_FORBIDDEN_CHARS)
|
||
if bad:
|
||
return (
|
||
f"wildcard name must not contain {bad!r} — these characters "
|
||
f"are reserved by the @{{uuid#name:subcat}} ref grammar"
|
||
)
|
||
return None
|
||
|
||
|
||
def validate_wildcard_subcats(payload: Any) -> str | None:
|
||
"""Wildcard payload `sub_categories[]` guard — mirrors the engine
|
||
handler's per-entry check ``engine/modules/wildcard_handler.py`` so
|
||
the import boundary rejects forbidden chars BEFORE the engine
|
||
importer commits the row. Lets the import-commit endpoint enforce
|
||
the same grammar contract the live editor enforces."""
|
||
if not isinstance(payload, dict):
|
||
return None
|
||
subs = payload.get("sub_categories")
|
||
if not isinstance(subs, list):
|
||
return None
|
||
for i, sc in enumerate(subs):
|
||
if not isinstance(sc, str):
|
||
return f"sub_categories[{i}] must be a string"
|
||
# Shared §3.6 name rules (single token; no whitespace /
|
||
# boolean-grammar / ref chars; not a reserved word incl. `null`).
|
||
err = validate_subcat_name(sc)
|
||
if err:
|
||
return f"sub_categories[{i}]: {err}"
|
||
return None
|
||
|
||
|
||
def validate_identifier(value: Any, where: str) -> None:
|
||
"""Raise ``ValueError`` when ``value`` is not a usable engine identifier.
|
||
|
||
Stricter than the engine handler's own regex check: also rejects
|
||
empty strings (the handler check guards with ``if value and …`` so
|
||
empty falls through), rejects ``__dunder`` prefixes (would collide
|
||
with engine-internal key stripping), and caps length.
|
||
"""
|
||
if not isinstance(value, str):
|
||
raise ValueError(f"{where} must be a string")
|
||
if not value:
|
||
raise ValueError(f"{where} must not be empty")
|
||
if len(value) > MAX_IDENT_LEN:
|
||
raise ValueError(
|
||
f"{where} must be at most {MAX_IDENT_LEN} chars (got {len(value)})"
|
||
)
|
||
if value.startswith("__"):
|
||
raise ValueError(
|
||
f"{where} must not start with '__' (reserved for engine-internal keys)"
|
||
)
|
||
if not _IDENT_RE.match(value):
|
||
raise ValueError(f"{where} {value!r} is not a valid identifier")
|
||
|
||
|
||
def validate_meta(body: dict[str, Any]) -> str | None:
|
||
"""Validate the meta fields shared between modules and bundles.
|
||
|
||
Returns an error string when something's off, ``None`` when every
|
||
present field passes. Missing fields are skipped — this validates
|
||
shape/length, not required-field presence (POST handlers separately
|
||
enforce ``required = {"type", "name", "payload"}`` etc).
|
||
|
||
Tags receive the strictest treatment: the SPA's TypeScript declares
|
||
``tags: string[]`` and the manager UI renders chips with naive
|
||
coercion (``null`` collapses to an empty label, objects stringify
|
||
to ``[object Object]``). Catching non-string entries here preserves
|
||
the type contract end-to-end.
|
||
"""
|
||
if "name" in body:
|
||
name = body["name"]
|
||
if not isinstance(name, str):
|
||
return "name must be a string"
|
||
if not name.strip():
|
||
return "name must not be empty"
|
||
if len(name) > MAX_NAME_LEN:
|
||
return f"name must be at most {MAX_NAME_LEN} chars (got {len(name)})"
|
||
if "description" in body:
|
||
desc = body["description"]
|
||
if desc is not None and not isinstance(desc, str):
|
||
return "description must be a string"
|
||
if isinstance(desc, str) and len(desc) > MAX_DESC_LEN:
|
||
return f"description must be at most {MAX_DESC_LEN} chars (got {len(desc)})"
|
||
if "tags" in body:
|
||
tags = body["tags"]
|
||
if not isinstance(tags, list):
|
||
return "tags must be a list"
|
||
if len(tags) > MAX_TAGS:
|
||
return f"tags must contain at most {MAX_TAGS} entries (got {len(tags)})"
|
||
for i, t in enumerate(tags):
|
||
if not isinstance(t, str):
|
||
return f"tags[{i}] must be a string"
|
||
if len(t) > MAX_TAG_LEN:
|
||
return f"tags[{i}] must be at most {MAX_TAG_LEN} chars (got {len(t)})"
|
||
if "category_id" in body:
|
||
cat = body["category_id"]
|
||
if cat is not None and not isinstance(cat, str):
|
||
return "category_id must be a string or null"
|
||
return None
|
||
|
||
|
||
def validate_body_size(
|
||
content_length: int | None, limit: int = MAX_BODY_BYTES,
|
||
) -> str | None:
|
||
"""Reject requests whose ``Content-Length`` exceeds the API cap.
|
||
|
||
Pre-flight guard; the aiohttp ``client_max_size`` setting backs
|
||
this up at the transport layer, but we want a clean ``400`` JSON
|
||
error instead of aiohttp's stock 413 HTML. ``limit`` lets the bulk
|
||
import routes accept a whole wildcard pack.
|
||
"""
|
||
if content_length is not None and content_length > limit:
|
||
return (
|
||
f"request body too large ({content_length} bytes); "
|
||
f"limit is {limit} bytes"
|
||
)
|
||
return None
|