Files
DumiFlex-ComfyUI-Wildcard-P…/engine/modules/fixed_values_handler.py
T
DumiFlex 0a8f376f4f fix(api,engine): qa hardening sweep — validators, concurrency, orphans
Lands the 15-bug catalogue from the 2026-05-19 creative-QA pass.

API layer (wp_api/):
- New _validators.py centralises meta + identifier + body-size checks
  so modules and bundles share the same shape rules
- SPA catch-all now returns JSON 404 for unknown /wp/api/* paths
  instead of the SPA shell (debugging hostile previously)
- create/update/import_from_workflow validate meta (name/desc/tags/
  category_id) before hitting the repo; tag list now rejects non-str
  entries (was silently stored, breaking the SPA type contract)
- IntegrityError on dangling category_id surfaces as 400, not 500
- Optional If-Match header on PUT enables opt-in optimistic
  concurrency — stale version → 409 instead of silent overwrite
- Bundle name collisions auto-suffixed with (copy)/(copy N) mirroring
  the modules-side forkModule rule
- Bundle children deduped by id at create + update time
- Deleting a module flags meta.orphaned=true on every bundle child
  whose snapshot referenced it so the SPA can render a stale badge

Engine layer (engine/modules/*_handler.py):
- wildcard: reject empty + __dunder var_binding, cap at 64 chars,
  reject duplicate option ids
- combine: reject empty template, cap template at 8000 chars, cap
  output_var at 64, reject __dunder output_var
- fixed_values: cap value name length, reject __dunder names, reject
  duplicate names across rows (was silent last-write-wins)
- derivation: reject duplicate rule ids (was nondeterministic for
  disabled_rule_ids / rule_order_override lookups)

Tests:
- New test_qa_hardening.py exercises every endpoint-level fix
- Extended test_payload_validation.py parametrised rejection matrix
- New test_spa_fallback.py case for /wp/api/* JSON 404

655 pytest pass, 1665 vitest pass, ruff clean.
2026-05-19 20:12:41 +03:00

192 lines
8.1 KiB
Python

"""Fixed-values module resolver.
Two-tier model mirroring wildcard:
- `payload.values` = library-truth (immutable while picked).
- `instance.values_overrides` = optional full-replacement list of
`{id, name, value}` written by the modal when the user edits a
library-tracked fixed_values.
Per-value syntax resolution (Phase: combine v2 + syntax parity cycle):
each emitted value passes through resolve_text with surface="fixed_values"
so {a|b|c} alternations + {N$$sep$$...} repeats + escapes resolve.
$var reads + @{uuid} refs are gated off — the surface is a binding
PRODUCER, not a consumer. Lenient mode renders unsupported tokens as
literal text + emits warnings (see engine/syntax/resolve.py).
Seed lock: instance.locked_seed pins {a|b|c} resolution per instance
via the shared engine.modules._seed.derive_module_rng helper, keyed by
the module id when present (ctx["__wp_module_id__"]).
"""
from __future__ import annotations
import re
from typing import Any
from engine.modules import build_resolve_ctx
from engine.modules._seed import derive_module_rng
from engine.modules.dispatcher import ModuleHandler
from engine.syntax import resolve_text
_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
_MAX_IDENT_LEN = 64
class FixedValuesHandler(ModuleHandler):
type_id = "fixed_values"
@classmethod
def validate_payload(cls, payload: dict[str, Any]) -> None:
if not isinstance(payload, dict):
raise ValueError("fixed_values payload must be an object")
values = payload.get("values")
if not isinstance(values, list):
raise ValueError("fixed_values payload.values must be a list")
seen_names: set[str] = set()
for i, v in enumerate(values):
if not isinstance(v, dict):
raise ValueError(
f"fixed_values payload.values[{i}] must be an object"
)
name = v.get("name")
if name is not None and not isinstance(name, str):
raise ValueError(
f"fixed_values payload.values[{i}].name must be a string"
)
# Empty name is allowed (resolver skips those entries) but a
# non-empty name must be a valid identifier — it gets emitted
# as a $var into the runtime ctx.
if isinstance(name, str) and name:
if len(name) > _MAX_IDENT_LEN:
raise ValueError(
f"fixed_values payload.values[{i}].name must be at most "
f"{_MAX_IDENT_LEN} chars (got {len(name)})"
)
if name.startswith("__"):
raise ValueError(
f"fixed_values payload.values[{i}].name must not start "
f"with '__' (reserved for engine-internal keys)"
)
if not _IDENT_RE.match(name):
raise ValueError(
f"fixed_values payload.values[{i}].name {name!r} is not a valid identifier"
)
# Names must be unique across rows — two rows with the
# same name silently last-write-wins at runtime, hiding
# the earlier row's value.
if name in seen_names:
raise ValueError(
f"fixed_values payload.values[{i}].name {name!r} "
f"duplicates an earlier row"
)
seen_names.add(name)
value = v.get("value", "")
if not isinstance(value, str):
raise ValueError(
f"fixed_values payload.values[{i}].value must be a string"
)
@classmethod
def resolve(
cls,
payload: dict[str, Any],
instance: dict[str, Any],
ctx: Any,
) -> dict[str, str]:
# Values source-of-truth: instance.values_overrides wins when
# set + non-empty, else payload.values. Existing two-tier model
# preserved.
overrides = instance.get("values_overrides")
if isinstance(overrides, list) and overrides:
values: list[dict[str, Any]] = [v for v in overrides if isinstance(v, dict)]
else:
# Non-None but non-list (string/dict/number) is malformed
# state — almost certainly a workflow saved by an old SPA
# or hand-edited JSON. Surface a warning so the user sees
# their edits were dropped, instead of silently reverting
# to library values without explanation.
if overrides is not None and not isinstance(overrides, list):
warnings = None
if ctx is not None:
try:
warnings = ctx.get("__wp_warnings__") if hasattr(ctx, "get") else None
except Exception:
warnings = None
if isinstance(warnings, list):
warnings.append({
"type": "fixed_values_overrides_malformed",
"severity": "warn",
"module_id": "",
"source_field": "instance.values_overrides",
"position": 0,
"token_index": None,
"detail": {
"got_type": type(overrides).__name__,
"fell_back_to": "payload.values",
},
"message": (
"fixed_values.instance.values_overrides was "
f"{type(overrides).__name__}, expected list — "
"fell back to library payload.values"
),
})
values = list(payload.get("values", []))
enabled = instance.get("enabled_options")
if enabled is not None:
allowed = set(enabled)
values = [v for v in values if v.get("id") in allowed]
# Syntax-aware emission path. When ctx is missing required keys
# for build_resolve_ctx (legacy callers passing None / minimal
# dicts), fall back to raw emission so existing tests + workflows
# keep working.
if not _ctx_supports_resolve(ctx):
out: dict[str, str] = {}
for v in values:
name = (v.get("name") or "").strip()
if not name:
continue
out[name] = str(v.get("value", ""))
return out
# Effective seed selection (mirrors WildcardHandler + CombineHandler):
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
locked_seed = instance.get("locked_seed")
if isinstance(locked_seed, (int, float)):
effective_seed = int(locked_seed)
else:
effective_seed = chain_seed
# Use module id as the per-module RNG key when ctx carries one;
# fall back to "fixed_values" literal so two co-resident
# fixed_values modules in the same chain still derive distinct
# RNG streams from the same seed (independence property).
module_key = str(ctx.get("__wp_module_id__") or "fixed_values")
rng = derive_module_rng(effective_seed, module_key)
ctx_local = {**ctx, "__wp_rng__": rng}
resolve_ctx = build_resolve_ctx(ctx_local, surface="fixed_values")
out_resolved: dict[str, str] = {}
for v in values:
name = (v.get("name") or "").strip()
if not name:
continue
raw = str(v.get("value", ""))
out_resolved[name] = resolve_text(raw, resolve_ctx)
return out_resolved
def _ctx_supports_resolve(ctx: Any) -> bool:
"""build_resolve_ctx requires __wp_rng__ + __wp_warnings__ in ctx.
Legacy test/usage passes None or minimal dicts — detect those and
skip the syntax-resolution path so behavior matches pre-rewrite.
"""
if ctx is None:
return False
if not hasattr(ctx, "__getitem__"):
return False
try:
return "__wp_rng__" in ctx and "__wp_warnings__" in ctx
except (TypeError, KeyError):
return False