* feat(parse): port the browser generation-parameter parser to Python Adds utils/generation_params.py, a line-for-line port of extractGenerationParams in web/js/comfy-metadata.js, the parser behind the gallery, admin and metadata pages. The scan and live monitor can use it so every surface reads prompts the same way. The JavaScript parser is unchanged. The port reproduces the JavaScript value semantics the result depends on (property order, truthiness, String(), trim(), strict equality, Map keys, JSON.parse number rounding, and the inputs on which it throws), and runs its recursive tracing on an explicit stack so crafted graphs cannot exhaust the Python stack. Tests: - behaviour tests for each tracing rule, with JavaScript results as expected values, including the visit-limit boundary; - golden vectors of the JavaScript parser's results for every named case and every PNG fixture, so the comparison runs without Node; - a live comparison against the JavaScript parser when Node is present; - a randomized differential test over 9,000 generated graphs and workflows, coherent and corrupted, including inputs where the JavaScript throws. Five deliberate porting mistakes (trim, truthiness, prepend handling, visit limit, pair slot) each fail the tests. On a 38,602-file output folder, all 18,491 files with a workflow give identical results. * fix(scan): link the traced prompt when the pair resolver cannot decide The output scan skipped every file the pair resolver called ambiguous or unresolved, so workflows where several samplers share one prompt (a base pass, an upscale pass and a detailer) never reached the Library from a scan. It now links the positive prompt the shared parser traces for those files, the prompt the inspector and the legacy pages show. Files the resolver decides are stored as before, and graphs without a recognized sampler keep the legacy extractor (#252). Read-only scans of a 38,602-file output folder into temporary databases: - links: 1,500 before, 7,954 after; none of the 1,500 dropped or changed; - of the added links that differ from the pre-redesign scan, 1,275 replace a number, a LoRA tag, a fragment or a truncated prompt that scan stored. * refactor(parse): return a uniform pair from the SameValueZero key
156 lines
5.4 KiB
Python
156 lines
5.4 KiB
Python
"""The Python generation-parameter parser equals the browser parser.
|
|
|
|
Golden vectors (tests/fixtures/generation_params/vectors.json) hold the browser
|
|
parser's results for every named case and every PNG fixture, so this runs on
|
|
machines without Node. With Node, the live browser parser is compared too and
|
|
the vectors are checked for staleness. Regenerate them with:
|
|
|
|
python tests/test_generation_params_js_parity.py --write-vectors
|
|
"""
|
|
|
|
import json
|
|
import math
|
|
import shutil
|
|
import subprocess # nosec B404 - runs the repository's own Node oracle
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from utils.generation_params import ( # noqa: E402
|
|
GenerationParamsError,
|
|
extract_from_records,
|
|
)
|
|
|
|
try:
|
|
from tests.generation_params_cases import CASES # noqa: E402
|
|
except ImportError: # discovered with tests/ as the top-level directory
|
|
from generation_params_cases import CASES # noqa: E402
|
|
|
|
VECTORS = ROOT / "tests" / "fixtures" / "generation_params" / "vectors.json"
|
|
ORACLE = ROOT / "tests" / "js" / "support" / "generation-params-oracle.mjs"
|
|
FIXTURE_DIRS = [ROOT / "tests" / "fixtures"]
|
|
NODE = shutil.which("node")
|
|
|
|
|
|
def fixture_cases():
|
|
"""Text records of every PNG fixture, keyed by its repository path."""
|
|
from PIL import Image
|
|
|
|
cases = {}
|
|
for directory in FIXTURE_DIRS:
|
|
for path in sorted(directory.rglob("*.png")):
|
|
with Image.open(path) as image:
|
|
info = {k: v for k, v in image.info.items() if isinstance(v, str)}
|
|
if "prompt" in info or "workflow" in info:
|
|
name = path.relative_to(ROOT).as_posix()
|
|
cases[name] = {
|
|
"prompt": info.get("prompt"),
|
|
"workflow": info.get("workflow"),
|
|
}
|
|
return cases
|
|
|
|
|
|
def all_cases():
|
|
return {**{f"case:{k}": v for k, v in CASES.items()}, **fixture_cases()}
|
|
|
|
|
|
def run_oracle(cases):
|
|
"""Browser parser results for case records, in order."""
|
|
lines = "".join(json.dumps(case) + "\n" for case in cases)
|
|
completed = subprocess.run( # nosec B603 - fixed argv, no shell
|
|
[NODE, str(ORACLE)],
|
|
input=lines,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
check=True,
|
|
timeout=600,
|
|
)
|
|
return [json.loads(line) for line in completed.stdout.splitlines()]
|
|
|
|
|
|
def run_python(case):
|
|
try:
|
|
return {
|
|
"ok": True,
|
|
"result": extract_from_records(case["prompt"], case["workflow"]),
|
|
}
|
|
except GenerationParamsError:
|
|
return {"ok": False}
|
|
|
|
|
|
def js_equal(left, right):
|
|
"""Equality as JavaScript values: 1 == 1.0, but true is not 1."""
|
|
if isinstance(left, bool) or isinstance(right, bool):
|
|
return isinstance(left, bool) and isinstance(right, bool) and left == right
|
|
if isinstance(left, (int, float)) and isinstance(right, (int, float)):
|
|
# Every JavaScript number is a double; compare as doubles.
|
|
left, right = float(left), float(right)
|
|
return left == right or (math.isnan(left) and math.isnan(right))
|
|
if isinstance(left, list) and isinstance(right, list):
|
|
return len(left) == len(right) and all(
|
|
js_equal(a, b) for a, b in zip(left, right)
|
|
)
|
|
if isinstance(left, dict) and isinstance(right, dict):
|
|
return left.keys() == right.keys() and all(
|
|
js_equal(left[k], right[k]) for k in left
|
|
)
|
|
return type(left) is type(right) and left == right
|
|
|
|
|
|
def write_vectors():
|
|
cases = all_cases()
|
|
results = run_oracle(cases.values())
|
|
vectors = [
|
|
{"name": name, **case, "expected": result}
|
|
for (name, case), result in zip(cases.items(), results)
|
|
]
|
|
VECTORS.parent.mkdir(parents=True, exist_ok=True)
|
|
VECTORS.write_text(json.dumps(vectors, indent=1, sort_keys=True) + "\n", "utf-8")
|
|
print(f"wrote {len(vectors)} vectors to {VECTORS.relative_to(ROOT)}")
|
|
|
|
|
|
class GoldenVectors(unittest.TestCase):
|
|
def test_every_vector_matches(self):
|
|
vectors = json.loads(VECTORS.read_text("utf-8"))
|
|
self.assertGreater(len(vectors), len(CASES))
|
|
for vector in vectors:
|
|
with self.subTest(vector=vector["name"]):
|
|
actual = run_python(vector)
|
|
self.assertTrue(
|
|
js_equal(actual, vector["expected"]), (actual, vector["expected"])
|
|
)
|
|
|
|
def test_vectors_cover_every_case_and_fixture(self):
|
|
names = {vector["name"] for vector in json.loads(VECTORS.read_text("utf-8"))}
|
|
self.assertEqual(names, set(all_cases()))
|
|
|
|
|
|
@unittest.skipUnless(NODE, "Node is required to run the browser parser")
|
|
class LiveBrowserParser(unittest.TestCase):
|
|
def test_live_results_match_python_and_the_vectors(self):
|
|
cases = all_cases()
|
|
stored = {
|
|
vector["name"]: vector["expected"]
|
|
for vector in json.loads(VECTORS.read_text("utf-8"))
|
|
}
|
|
for (name, case), expected in zip(cases.items(), run_oracle(cases.values())):
|
|
with self.subTest(case=name):
|
|
self.assertTrue(
|
|
js_equal(run_python(case), expected), (run_python(case), expected)
|
|
)
|
|
self.assertTrue(
|
|
js_equal(stored[name], expected),
|
|
"vectors are stale: regenerate them",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if "--write-vectors" in sys.argv:
|
|
write_vectors()
|
|
else:
|
|
unittest.main()
|