diff --git a/tests/test_file_prompt.py b/tests/test_file_prompt.py index aed055c4..ccb6f0d2 100644 --- a/tests/test_file_prompt.py +++ b/tests/test_file_prompt.py @@ -271,6 +271,49 @@ class TestSaveToLibrarySteps(unittest.TestCase): self.assertEqual(decision.kind, "positive") self.assertEqual(decision.text, "SECRET OFF TEXT") + def test_an_off_node_on_a_negative_input_name_never_blocks(self): + """The walk skips negative inputs by #90's own rule (``neg``, + ``neg_text``, ``text_neg``, ``negative_text``).""" + for key in ("neg", "neg_text", "text_neg", "negative_text"): + with self.subTest(key=key): + graph = { + "1": { + "class_type": "PromptManagerText", + "inputs": {"text": "bad hands", "save_to_library": False}, + }, + "9": { + "class_type": "PromptManagerText", + "inputs": {"text": "a cat", "save_to_library": True}, + }, + "7": { + "class_type": "PipeEncode", + "inputs": {"pos_text": ["9", 0], key: ["1", 0]}, + }, + "4": {"class_type": "CheckpointLoaderSimple", "inputs": {}}, + "3": { + "class_type": "KSampler", + "inputs": { + "model": ["4", 0], + "positive": ["7", 0], + "negative": ["7", 1], + }, + }, + } + self.assertIsNone(file_prompt._switched_off_upstream(graph)) + self.assertNotIn( + _decide(_graph(graph)).kind, ("not_saved", "not_saved_wired") + ) + + def test_the_guards_accept_malformed_nodes(self): + graph = { + "1": {"class_type": "PromptManagerText", "inputs": None}, + "2": {"class_type": "PromptManagerText"}, + } + self.assertIsNone(file_prompt._off_text(graph, "x")) + side = {"provenance": ["not", "a", "dict"]} + with mock.patch.object(file_prompt, "side_opted_out", return_value=True): + self.assertIsNone(file_prompt._side_switch(graph, side)) + def test_the_off_text_guard_reads_a_nodes_resolved_text(self): graph = { "1": { diff --git a/utils/file_prompt.py b/utils/file_prompt.py index 0fcfedf0..a4c731d2 100644 --- a/utils/file_prompt.py +++ b/utils/file_prompt.py @@ -31,6 +31,7 @@ from .generation_params import ( from .prompt_graph import ( PROMPT_MANAGER_TYPES, _positive_roots, + is_negative_input, metadata_opted_out, resolve_text, save_setting, @@ -281,7 +282,9 @@ def _side_switch(graph: Any, side: Any) -> Optional[str]: """The switch outcome of the PromptManager nodes on a side's path.""" if not isinstance(graph, dict) or not side_opted_out(graph, side): return None - path = (side.get("provenance") or {}).get("path") or [] + provenance = side.get("provenance") + path = provenance.get("path") if isinstance(provenance, dict) else None + path = path if isinstance(path, list) else [] nodes = {str(step.get("node_id")) for step in path if isinstance(step, dict)} & set( _prompt_manager_ids(graph) ) @@ -337,7 +340,7 @@ def _switched_off_upstream(graph: Any) -> Optional[str]: found.add(node_id) inputs = node.get("inputs") for key, value in (inputs.items() if isinstance(inputs, dict) else ()): - if key in _NOT_TEXT_INPUTS or "negative" in str(key).lower(): + if key in _NOT_TEXT_INPUTS or is_negative_input(key): continue if isinstance(value, (list, tuple)) and len(value) == 2: if isinstance(value[0], (str, int)) and isinstance(value[1], int): @@ -353,7 +356,8 @@ def _off_text(graph: Any, text: str) -> Optional[str]: for node_id in _prompt_manager_ids(graph): node_text = resolve_text(graph, node_id) if node_text is None: - literal = graph[node_id].get("inputs", {}).get("text") + inputs = graph[node_id].get("inputs") + literal = inputs.get("text") if isinstance(inputs, dict) else None node_text = literal if isinstance(literal, str) else None if node_text is not None and node_text.strip() == wanted: matched.append(save_setting(graph, node_id)) diff --git a/utils/prompt_graph.py b/utils/prompt_graph.py index b21021af..88a45c54 100644 --- a/utils/prompt_graph.py +++ b/utils/prompt_graph.py @@ -41,9 +41,14 @@ _TEXT_KEY = re.compile(r"^(text|string|prompt)(_?[a-z0-9]+)?$", re.IGNORECASE) _NEGATIVE_KEY = re.compile(r"(^|_)neg(ative)?($|_)", re.IGNORECASE) +def is_negative_input(key: Any) -> bool: + """Whether an input name carries a negative prompt (#90's one rule).""" + return isinstance(key, str) and bool(_NEGATIVE_KEY.search(key)) + + def follows_input(key: Any) -> bool: """Whether an input name carries positive conditioning or prompt text.""" - if not isinstance(key, str) or _NEGATIVE_KEY.search(key): + if not isinstance(key, str) or is_negative_input(key): return False return key == "positive" or bool( _CONDITIONING_KEY.match(key) or _TEXT_KEY.match(key)