|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +from collections.abc import Iterator |
| 4 | + |
| 5 | +from dynamicprompts.commands import ( |
| 6 | + Command, |
| 7 | + SequenceCommand, |
| 8 | + VariantCommand, |
| 9 | + WildcardCommand, |
| 10 | + WrapCommand, |
| 11 | +) |
| 12 | +from dynamicprompts.parser.parse import parse |
| 13 | +from dynamicprompts.wildcards import WildcardManager |
| 14 | +from pyparsing import ParseException |
| 15 | + |
| 16 | + |
| 17 | +def _iter_wildcard_names(command: Command, in_variant: bool = False) -> Iterator[str]: |
| 18 | + """Recursively yield the statically-known wildcard names that appear as (part of) a variant value. |
| 19 | +
|
| 20 | + Only wildcards reachable from a `VariantCommand` value are yielded: those are the references that |
| 21 | + hang the combinatorial generator (see `find_missing_wildcards`). The same wildcard used as plain |
| 22 | + literal text (e.g. `a __nope__ b`) generates fine, so it is intentionally not reported. |
| 23 | + """ |
| 24 | + if isinstance(command, WildcardCommand): |
| 25 | + # The wildcard name may itself be a dynamic Command (e.g. `__${var}__`). Only plain string |
| 26 | + # names can be validated ahead of time, so the dynamic case is intentionally skipped. |
| 27 | + if in_variant and isinstance(command.wildcard, str): |
| 28 | + yield command.wildcard |
| 29 | + elif isinstance(command, SequenceCommand): |
| 30 | + for token in command.tokens: |
| 31 | + yield from _iter_wildcard_names(token, in_variant) |
| 32 | + elif isinstance(command, VariantCommand): |
| 33 | + # Everything below a variant value is a variant-nested reference, even across sequences. |
| 34 | + for value in command.values: |
| 35 | + yield from _iter_wildcard_names(value, in_variant=True) |
| 36 | + elif isinstance(command, WrapCommand): |
| 37 | + yield from _iter_wildcard_names(command.wrapper, in_variant) |
| 38 | + yield from _iter_wildcard_names(command.inner, in_variant) |
| 39 | + # LiteralCommand and variable commands reference no wildcards we can resolve statically. |
| 40 | + |
| 41 | + |
| 42 | +def find_missing_wildcards(prompt: str, wildcard_manager: WildcardManager | None = None) -> list[str]: |
| 43 | + """Return the unique unknown wildcard names in `prompt` that hang the combinatorial generator. |
| 44 | +
|
| 45 | + Referencing an unknown wildcard *as a variant value* (e.g. `{__nope__|x}`) makes dynamicprompts' |
| 46 | + combinatorial generator loop forever: its not-found fallback (`get_wildcard_not_found_fallback`) |
| 47 | + yields the wrapped wildcard infinitely, and the combinatorial variant logic dedupes those |
| 48 | + duplicates away without ever advancing. Detecting these names up front lets callers report a clear |
| 49 | + error instead of hanging. Only the combinatorial generator is affected, and only for wildcards |
| 50 | + nested in a variant — a bare `a __nope__ b` generates fine and is not reported. |
| 51 | +
|
| 52 | + Without a configured `wildcard_manager`, an empty one is used so that every referenced wildcard is |
| 53 | + treated as missing (wildcards are not resolved against any files here). |
| 54 | + """ |
| 55 | + if wildcard_manager is None: |
| 56 | + wildcard_manager = WildcardManager() |
| 57 | + |
| 58 | + try: |
| 59 | + tree = parse(prompt) |
| 60 | + except ParseException: |
| 61 | + # Malformed prompts are surfaced separately by the generators; nothing to validate here. |
| 62 | + return [] |
| 63 | + |
| 64 | + missing: list[str] = [] |
| 65 | + for name in _iter_wildcard_names(tree): |
| 66 | + if name not in missing and not wildcard_manager.get_values(name): |
| 67 | + missing.append(name) |
| 68 | + return missing |
0 commit comments