"""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, negative?}` written by the modal when the user edits a library-tracked fixed_values. A row's `negative` key replaces the library row's negative ("" clears it); without it the library's stays. 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 instance (`instance_key`), and each value rolls on its own stream. """ from __future__ import annotations import re from typing import Any, cast from engine import negatives from engine.modules import build_resolve_ctx from engine.modules._detail import module_detail from engine.modules._seed import derive_module_rng, instance_key, stream_key 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" ) neg = v.get("negative") if neg is not None and not isinstance(neg, str): raise ValueError( f"fixed_values payload.values[{i}].negative 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") detail = module_detail(ctx) from_override = isinstance(overrides, list) and bool(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") toggled_off: list[str] = [] if enabled is not None: allowed = set(enabled) keys = _row_keys(values) toggled_off = [ (v.get("name") or "").strip() for v, k in zip(values, keys, strict=True) if k not in allowed ] values = [v for v, k in zip(values, keys, strict=True) if k in allowed] if detail is not None: # WP Debug: where the rows came from and which the node turned off. detail["source"] = "override" if from_override else "library" if toggled_off: detail["toggled_off"] = [n for n in toggled_off if n] if instance.get("locked_seed") is not None: detail["locked_seed"] = True # 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): if instance.get("seed_scope") == "hold": # Held: reuse each name's frame-0 resolved value verbatim (from the # pipeline's base pass) so a held fixed-values alternation stays # frozen across the whole run. _hb = ctx.get("__wp_hold_base_ctx__") if isinstance(_hb, dict): _names = [n for n in ((v.get("name") or "").strip() for v in values) if n] if _names and all(n in _hb for n in _names): if detail is not None: detail["held"] = True negatives.copy_from(ctx, _hb, _names) return {n: _hb[n] for n in _names} chain_seed = int( ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0 ) else: 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 # Keyed by instance so two fixed-values modules (or two instances # of one) roll independently; each value then rolls on its own # stream, keyed by its name, so adding or editing one value never # moves another. module_key = instance_key(ctx, "fixed_values") rng = derive_module_rng(effective_seed, module_key) ctx_local = {**cast("dict[str, Any]", ctx), "__wp_rng__": rng} resolve_ctx = build_resolve_ctx(ctx_local, surface="fixed_values") # Negatives follow the value: an override row with its own # ``negative`` key replaces the library row's ("" clears it); one # without keeps the library row's (by id, then by name). lib_rows = [v for v in payload.get("values", []) if isinstance(v, dict)] lib_by_id = {v.get("id"): v for v in lib_rows if v.get("id")} lib_by_name = {(v.get("name") or "").strip(): v for v in lib_rows} n_rng = negatives.neg_rng(effective_seed, module_key) out_resolved: dict[str, str] = {} rows_detail: list[dict[str, Any]] = [] 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, build_resolve_ctx( ctx_local, surface="fixed_values", stream=stream_key(effective_seed, f"{module_key}/{name}"), )) if detail is not None: # The text as written, when resolving changed it ($vars, {a|b}). row: dict[str, Any] = {"name": name} if raw != out_resolved[name]: row["raw"] = raw[:240] rows_detail.append(row) if "negative" in v: own = negatives.clean_negative(v.get("negative")) else: lib = lib_by_id.get(v.get("id")) or lib_by_name.get(name) or {} own = negatives.clean_negative(lib.get("negative")) negatives.set_entries(ctx, name, negatives.own_entries( [own] if own else [], resolve_ctx, n_rng, pick=None, source=name, )) if detail is not None: detail["rows"] = rows_detail return out_resolved def _row_keys(values: list[Any]) -> list[str]: """The key each row is toggled by: its ``id``, or ``#`` when the id is missing, blank or repeats an earlier row's. Imported packs can carry rows without ids. Mirrors ``withRowKeys`` in src/components/context/editors/fixed-values/defaults.ts.""" seen: set[str] = set() keys: list[str] = [] for i, v in enumerate(values): rid = v.get("id") if isinstance(v, dict) else None key = rid if isinstance(rid, str) and rid.strip() and rid not in seen else f"#{i}" seen.add(key) keys.append(key) return keys 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