Spaces:
Running
Running
fix(tagger): real leak root cause — handler params were shifted vs event inputs (caption_toggle got always-truthy lang_state), so NL caption ran unconditionally and the nl radio never checked the box; add AST regression guard
Browse files- app.py +3 -3
- tests/test_tagger_arg_alignment.py +102 -0
app.py
CHANGED
|
@@ -1859,7 +1859,7 @@ with gr.Blocks(**_blocks_kw) as demo:
|
|
| 1859 |
# Fold ensemble `mode` into the tagger call while keeping fmt semantics.
|
| 1860 |
# "nl" is a UI shortcut that enables the caption checkbox; the tagger
|
| 1861 |
# itself only runs the caption inside "ensemble" mode.
|
| 1862 |
-
def on_tag_image_with_mode(image, gen_threshold, char_threshold, fmt,
|
| 1863 |
mode_key = mode if isinstance(mode, str) and mode.startswith(
|
| 1864 |
("wd:", "deepdanbooru", "ensemble")
|
| 1865 |
) else "ensemble"
|
|
@@ -1898,12 +1898,12 @@ with gr.Blocks(**_blocks_kw) as demo:
|
|
| 1898 |
# inputs here: in Gradio 6, CheckboxGroup.preprocess validates the value
|
| 1899 |
# against the current choices and raises on stale selections, which
|
| 1900 |
# crashed re-runs ("Value: ... is not in the list of choices").
|
| 1901 |
-
def on_tag_rerun(image, gen_threshold, char_threshold, fmt,
|
| 1902 |
with_caption = bool(caption_toggle) or mode == "nl"
|
| 1903 |
caption_update = gr.update(value=True) if mode == "nl" else gr.skip()
|
| 1904 |
if image is None:
|
| 1905 |
return (gr.skip(),) * 8 + (caption_update,)
|
| 1906 |
-
return tuple(on_tag_image_with_mode(image, gen_threshold, char_threshold, fmt,
|
| 1907 |
|
| 1908 |
rerun_inputs = [
|
| 1909 |
tagger_image, tagger_gen_threshold, tagger_char_threshold, tagger_format,
|
|
|
|
| 1859 |
# Fold ensemble `mode` into the tagger call while keeping fmt semantics.
|
| 1860 |
# "nl" is a UI shortcut that enables the caption checkbox; the tagger
|
| 1861 |
# itself only runs the caption inside "ensemble" mode.
|
| 1862 |
+
def on_tag_image_with_mode(image, gen_threshold, char_threshold, fmt, mode, pose_toggle, caption_toggle, lang, progress=gr.Progress()):
|
| 1863 |
mode_key = mode if isinstance(mode, str) and mode.startswith(
|
| 1864 |
("wd:", "deepdanbooru", "ensemble")
|
| 1865 |
) else "ensemble"
|
|
|
|
| 1898 |
# inputs here: in Gradio 6, CheckboxGroup.preprocess validates the value
|
| 1899 |
# against the current choices and raises on stale selections, which
|
| 1900 |
# crashed re-runs ("Value: ... is not in the list of choices").
|
| 1901 |
+
def on_tag_rerun(image, gen_threshold, char_threshold, fmt, mode, pose_toggle, caption_toggle, lang, progress=gr.Progress()):
|
| 1902 |
with_caption = bool(caption_toggle) or mode == "nl"
|
| 1903 |
caption_update = gr.update(value=True) if mode == "nl" else gr.skip()
|
| 1904 |
if image is None:
|
| 1905 |
return (gr.skip(),) * 8 + (caption_update,)
|
| 1906 |
+
return tuple(on_tag_image_with_mode(image, gen_threshold, char_threshold, fmt, mode, pose_toggle, with_caption, lang, progress=progress)) + (caption_update,)
|
| 1907 |
|
| 1908 |
rerun_inputs = [
|
| 1909 |
tagger_image, tagger_gen_threshold, tagger_char_threshold, tagger_format,
|
tests/test_tagger_arg_alignment.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression guard: tagger handlers must be wired to their event inputs in
|
| 2 |
+
exactly the same order as their parameters.
|
| 3 |
+
|
| 4 |
+
A previous refactor shifted the parameter order of ``on_tag_image_with_mode``
|
| 5 |
+
and ``on_tag_rerun`` relative to the Gradio ``inputs`` lists (lang/mode/pose/
|
| 6 |
+
caption were offset by two), so the caption toggle silently received the
|
| 7 |
+
always-truthy ``lang_state`` and the NL caption ran even with the checkbox
|
| 8 |
+
off, while the "nl" radio item never checked the box.
|
| 9 |
+
"""
|
| 10 |
+
import ast
|
| 11 |
+
import os
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
|
| 15 |
+
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 16 |
+
_APP = os.path.join(_ROOT, "app.py")
|
| 17 |
+
|
| 18 |
+
_HANDLERS = {
|
| 19 |
+
"on_tag_image_with_mode": {
|
| 20 |
+
"events": ["tagger_btn.click"],
|
| 21 |
+
"params": [
|
| 22 |
+
("image", "tagger_image"),
|
| 23 |
+
("gen_threshold", "tagger_gen_threshold"),
|
| 24 |
+
("char_threshold", "tagger_char_threshold"),
|
| 25 |
+
("fmt", "tagger_format"),
|
| 26 |
+
("mode", "tagger_mode"),
|
| 27 |
+
("pose_toggle", "tagger_pose_toggle"),
|
| 28 |
+
("caption_toggle", "tagger_caption_toggle"),
|
| 29 |
+
("lang", "lang_state"),
|
| 30 |
+
],
|
| 31 |
+
},
|
| 32 |
+
"on_tag_rerun": {
|
| 33 |
+
"events": ["tagger_mode.change", "tagger_pose_toggle.change"],
|
| 34 |
+
"params": [
|
| 35 |
+
("image", "tagger_image"),
|
| 36 |
+
("gen_threshold", "tagger_gen_threshold"),
|
| 37 |
+
("char_threshold", "tagger_char_threshold"),
|
| 38 |
+
("fmt", "tagger_format"),
|
| 39 |
+
("mode", "tagger_mode"),
|
| 40 |
+
("pose_toggle", "tagger_pose_toggle"),
|
| 41 |
+
("caption_toggle", "tagger_caption_toggle"),
|
| 42 |
+
("lang", "lang_state"),
|
| 43 |
+
],
|
| 44 |
+
},
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _component_name(expr):
|
| 49 |
+
if isinstance(expr, ast.Name):
|
| 50 |
+
return expr.id
|
| 51 |
+
if isinstance(expr, ast.Attribute):
|
| 52 |
+
return expr.attr
|
| 53 |
+
return ast.unparse(expr)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _resolve_inputs(call, tree):
|
| 57 |
+
for kw in call.keywords:
|
| 58 |
+
if kw.arg != "inputs":
|
| 59 |
+
continue
|
| 60 |
+
value = kw.value
|
| 61 |
+
if isinstance(value, ast.List):
|
| 62 |
+
return [_component_name(elt) for elt in value.elts]
|
| 63 |
+
if isinstance(value, ast.Name):
|
| 64 |
+
for node in ast.walk(tree):
|
| 65 |
+
if isinstance(node, ast.Assign):
|
| 66 |
+
for target in node.targets:
|
| 67 |
+
if isinstance(target, ast.Name) and target.id == value.id:
|
| 68 |
+
if isinstance(node.value, ast.List):
|
| 69 |
+
return [_component_name(elt) for elt in node.value.elts]
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _handler_wiring():
|
| 74 |
+
with open(_APP, encoding="utf-8") as fh:
|
| 75 |
+
tree = ast.parse(fh.read())
|
| 76 |
+
expected_events = {e for h in _HANDLERS.values() for e in h["events"]}
|
| 77 |
+
wiring = {}
|
| 78 |
+
for node in ast.walk(tree):
|
| 79 |
+
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
|
| 80 |
+
continue
|
| 81 |
+
if not isinstance(node.func.value, ast.Name):
|
| 82 |
+
continue
|
| 83 |
+
event = f"{node.func.value.id}.{node.func.attr}"
|
| 84 |
+
if event not in expected_events:
|
| 85 |
+
continue
|
| 86 |
+
fn_kw = next((kw for kw in node.keywords if kw.arg == "fn"), None)
|
| 87 |
+
if fn_kw is None or not isinstance(fn_kw.value, ast.Name):
|
| 88 |
+
continue
|
| 89 |
+
wiring.setdefault(fn_kw.value.id, []).append((event, _resolve_inputs(node, tree)))
|
| 90 |
+
return wiring
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@pytest.mark.parametrize("handler", sorted(_HANDLERS))
|
| 94 |
+
def test_handler_inputs_match_signature(handler):
|
| 95 |
+
wiring = _handler_wiring()
|
| 96 |
+
assert handler in wiring, f"{handler} is not wired to any expected event"
|
| 97 |
+
expected = [comp for _, comp in _HANDLERS[handler]["params"]]
|
| 98 |
+
for event, inputs in wiring[handler]:
|
| 99 |
+
assert inputs is not None, f"{event} has no resolvable inputs list"
|
| 100 |
+
assert inputs == expected, (
|
| 101 |
+
f"{event} inputs {inputs} do not match {handler} parameters {expected}"
|
| 102 |
+
)
|