ArtShumov commited on
Commit
dee3744
·
1 Parent(s): fbb630d

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
Files changed (2) hide show
  1. app.py +3 -3
  2. 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, lang, mode, pose_toggle, caption_toggle, progress=gr.Progress()):
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, lang, mode, pose_toggle, caption_toggle, 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, lang, mode, pose_toggle, with_caption, progress=progress)) + (caption_update,)
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
+ )