Files
ComfyAssets-ComfyUI_PromptM…/tests/test_save_to_library.py
T
Vito d9f168c317 feat(nodes): per-node Save to library switch (#90) (#427)
* feat(nodes): per-node Save to library switch (#90)

Every PromptManager node (Classic, Text, Focused, Split, Pair) gets an
optional save_to_library BOOLEAN, default on and declared last. Off, the
node still returns its normal outputs but PromptManager ignores the run:

- the node saves, counts, tracks and records nothing;
- the queue hook reads the switch from the queued graph (literal, or a
  linked PrimitiveBoolean), so a cached node is not counted either; a
  switch wired from an unreadable node is left to the node itself;
- an opted-out node is no run prompt, so no tracker entry, run key,
  embedded-metadata match or history attribution links its images, and
  a run with any opted-out positive node links none of its files;
- the scan reports such files as not_saved (never stored or queued for
  Needs review), and a file whose only opted-out node is its negative is
  stored positive-only, without the pair;
- execution history records no prompt text for an opted-out role.

PromptSearchList only reads the library and gets no switch.

The switch takes the widget index older workflows used for the editor's
own DOM slot (''), so a configure step turns any non-boolean value on:
workflows saved before the switch load with it on and no earlier widget
value moves. The Focused, Split and Pair editors draw it as a "Save to
library" checkbox in their toolbar; Classic and Text show the native
toggle.

* fix(nodes): Save to library review fixes (#90)

- A switch wired from a node the graph cannot read and off at run time
  marks the node's run key, so an earlier run's tracker entry never
  links this run's images.
- The scan reports such files as not_saved_wired ("only the run itself
  can link it") instead of not_saved.
- Workflow-only metadata ignores muted and bypassed PromptManager nodes
  and reads nodes inside subgraph definitions.
- The Focused, Split and Pair footer says when the switch comes from a
  link.

* fix(usage): count cached re-runs of runtime-linked prompt text (#347)

A PromptManager-family node whose text comes from a runtime link
(PreviewAny, an LLM, a list node) counted only the job that executed it;
later jobs served from ComfyUI's output cache counted nothing.

Each execution now records a receipt (digests of its STRING outputs and
hashes of the text it stores) per run key and queue item. On
execution_cached the receipt whose digests equal the cache entry's text is
replayed once for the job, with the node's role and what the queue hook
already saw recomputed from the job's own graph. The executing node and the
replay count through one shared own_counts. Unmatched cache entries count
nothing, as before; a node that executes after its replay in the same job
skips its own count. No schema change, no backfill.

* fix(usage): never replay a node twice in one job; dedupe receipts once complete (#347)

A repeated execution_cached id or message for the same job now counts the
node once. Receipts replace equal older ones only when their job finishes
(or the next job starts), so a list still recording [A] of [A, B] no longer
drops an older [A] receipt. Node-level tests check the receipt each
execution records.

* test(coverage): baseline for the Save to library module (#90)

* test(usage): disabled node then cached jobs counts 0 (#90, #347)

A PromptManager node with Save to library off records no cached-run
receipt and is no run prompt in the replayed job's graph, so ComfyUI
serving it from its cache on later jobs counts nothing. The prompt is
saved first by an enabled run, so a replayed count would show. Covers
PromptManager, Focused, Split, Text and the Pair.

* fix(nodes): hide the editor's native widgets through one helper (#90)

The Save to library switch copied the text fallbacks' zero-height computeSize
arrow, and neither copy was ever called, so focused-node.js function coverage
fell to 87.5 (floor 90). One hideNativeWidget helper now hides both, and the
spec checks that the hidden switch and a text fallback take no node height.
2026-10-28 18:09:16 +00:00

509 lines
20 KiB
Python

"""#90 item 3: a node's ``save_to_library`` switch.
Off: PromptManager ignores the run completely (no prompt saved, no run count,
no image link), while the node still returns its normal outputs. On (the
default, and every workflow or image made before the switch existed): as before.
"""
import logging
import os
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from tests.test_prompt_attribution import MonitorCase, NodeCase
from tests.test_execution_contexts import load_module
except ImportError: # discovered with tests/ as the top-level directory
from test_prompt_attribution import MonitorCase, NodeCase
from test_execution_contexts import load_module
from prompt_manager import PromptManager, PromptManagerFocused, PromptManagerSplit
from prompt_manager_pair import PromptManagerPair
from prompt_manager_text import PromptManagerText
from prompt_search_list import PromptSearchList
from utils.hashing import generate_prompt_hash
from utils.prompt_graph import (
intrinsic_pair_nodes,
positive_opt_outs,
run_prompt_components,
run_prompt_nodes,
save_setting,
)
from utils.run_keys import prompt_node_key
from utils.usage_tracking import (
PendingFirstUse,
QueuedRuns,
handle_queued_prompt,
on_dequeue,
queued_pair_runs,
queued_prompt_runs,
)
POSITIVE = "a lighthouse at dusk"
NEGATIVE = "blurry"
NODE_CLASSES = (
PromptManager,
PromptManagerText,
PromptManagerFocused,
PromptManagerSplit,
PromptManagerPair,
)
def run_graph(save=None, kind="PromptManager", negative_save=None):
"""A PromptManager positive feeding a KSampler; ``save`` None leaves it out."""
inputs = {"text": POSITIVE, "clip": ["4", 1]}
if save is not None:
inputs["save_to_library"] = save
negative_inputs = {"text": NEGATIVE, "clip": ["4", 1]}
if negative_save is not None:
negative_inputs["save_to_library"] = negative_save
return {
"1": {"class_type": kind, "inputs": inputs},
"2": {"class_type": "PromptManager", "inputs": negative_inputs},
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
"3": {
"class_type": "KSampler",
"inputs": {
"model": ["4", 0],
"positive": ["1", 0],
"negative": ["2", 0],
"latent_image": ["6", 0],
},
},
"6": {"class_type": "EmptyLatentImage", "inputs": {"batch_size": 1}},
}
def pair_graph(save=None):
inputs = {"text": POSITIVE, "negative_text": NEGATIVE, "clip": ["4", 1]}
if save is not None:
inputs["save_to_library"] = save
return {
"1": {"class_type": "PromptManagerPair", "inputs": inputs},
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
"3": {
"class_type": "KSampler",
"inputs": {
"model": ["4", 0],
"positive": ["1", 0],
"negative": ["1", 1],
"latent_image": ["6", 0],
},
},
"6": {"class_type": "EmptyLatentImage", "inputs": {"batch_size": 1}},
}
class TestInputDeclaration(unittest.TestCase):
def test_every_prompt_manager_node_declares_a_boolean_defaulting_on(self):
for cls in NODE_CLASSES:
optional = cls.INPUT_TYPES()["optional"]
kind, options = optional["save_to_library"]
self.assertEqual(kind, "BOOLEAN", cls.__name__)
self.assertIs(options["default"], True, cls.__name__)
# Declared last: no saved widget value of an older workflow shifts
self.assertEqual(list(optional)[-1], "save_to_library", cls.__name__)
def test_search_list_has_no_switch(self):
inputs = PromptSearchList.INPUT_TYPES()
self.assertNotIn("save_to_library", inputs.get("optional", {}))
self.assertNotIn("save_to_library", inputs.get("required", {}))
class TestSaveSetting(unittest.TestCase):
def test_missing_input_is_on(self):
self.assertIs(save_setting(run_graph(), "1"), True)
def test_literals_follow_comfyui_boolean_coercion(self):
for value, expected in (
(True, True),
(False, False),
("", False),
(0, False),
({"__value__": False}, False),
({"__value__": True}, True),
):
with self.subTest(value=value):
self.assertIs(save_setting(run_graph(save=value), "1"), expected)
def test_link_to_primitive_boolean_is_read(self):
for value in (True, False):
graph = run_graph(save=["9", 0])
graph["9"] = {"class_type": "PrimitiveBoolean", "inputs": {"value": value}}
self.assertIs(save_setting(graph, "1"), value)
def test_link_to_an_unknown_node_is_not_knowable(self):
graph = run_graph(save=["9", 0])
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
self.assertIsNone(save_setting(graph, "1"))
self.assertIsNone(save_setting(run_graph(save=["missing", 0]), "1"))
class TestGraphRoles(unittest.TestCase):
def test_opted_out_positive_is_not_a_run_prompt(self):
graph = run_graph(save=False)
self.assertEqual(run_prompt_nodes(graph), [])
self.assertEqual(run_prompt_components(graph), [])
self.assertEqual(positive_opt_outs(graph), ["1"])
def test_enabled_positive_is_unchanged(self):
for save in (None, True):
graph = run_graph(save=save)
self.assertEqual(run_prompt_nodes(graph), ["1"])
self.assertEqual(positive_opt_outs(graph), [])
def test_opted_out_negative_is_no_positive_opt_out(self):
graph = run_graph(negative_save=False)
self.assertEqual(run_prompt_nodes(graph), ["1"])
self.assertEqual(positive_opt_outs(graph), [])
def test_walk_stops_at_an_opted_out_node(self):
graph = run_graph(save=False)
graph["1"]["inputs"]["text"] = ["7", 0]
graph["7"] = {"class_type": "PromptManagerText", "inputs": {"text": "x"}}
self.assertEqual(run_prompt_nodes(graph), [])
def test_sampler_less_fallback_skips_opted_out_nodes(self):
graph = {
"1": {
"class_type": "PromptManagerText",
"inputs": {"text": "a", "save_to_library": False},
},
"2": {"class_type": "PromptManagerText", "inputs": {"text": "b"}},
}
self.assertEqual(run_prompt_nodes(graph), ["2"])
self.assertEqual(run_prompt_components(graph), [("2", "text")])
self.assertEqual(positive_opt_outs(graph), ["1"])
def test_opted_out_pair_is_not_an_intrinsic_pair(self):
self.assertEqual(intrinsic_pair_nodes(pair_graph()), ["1"])
self.assertEqual(intrinsic_pair_nodes(pair_graph(save=False)), [])
class TestQueueHook(unittest.TestCase):
def test_opted_out_node_is_never_stashed(self):
self.assertEqual(queued_prompt_runs(run_graph(save=False)), [])
self.assertEqual(queued_pair_runs(pair_graph(save=False)), [])
def test_unknown_setting_is_left_to_the_node(self):
graph = run_graph(save=["9", 0])
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
self.assertEqual(queued_prompt_runs(graph), [])
pair = pair_graph(save=["9", 0])
pair["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
self.assertEqual(queued_pair_runs(pair), [])
def test_primitive_boolean_on_still_counts(self):
graph = run_graph(save=["9", 0])
graph["9"] = {"class_type": "PrimitiveBoolean", "inputs": {"value": True}}
self.assertEqual(
queued_prompt_runs(graph), [(generate_prompt_hash(POSITIVE), POSITIVE)]
)
def test_dequeue_counts_nothing_for_a_cached_opted_out_node(self):
"""A cached node never executes: only the queued graph can say it is off."""
db = mock.Mock()
db.get_prompt_by_hash.return_value = {"id": 5}
queued, pending = QueuedRuns(), PendingFirstUse()
request = {"prompt": run_graph(save=False), "prompt_id": "job-1"}
handle_queued_prompt(request, queued, pair_queued=QueuedRuns())
on_dequeue("job-1", queued, lambda: db, pending, pair_queued=QueuedRuns())
db.record_prompt_use.assert_not_called()
self.assertEqual(pending.consume(generate_prompt_hash(POSITIVE)), 0)
class TestNodesSaveNothing(NodeCase):
def run_node(self, cls, graph, save):
node = self.node(cls)
node.record_pair = mock.Mock()
kwargs = {"prompt": graph, "unique_id": "1", "save_to_library": save}
if cls is PromptManagerText:
return node, node.process_text(POSITIVE, **kwargs)
if cls is PromptManagerPair:
return node, node.encode_prompt(
self.clip(), POSITIVE, negative_text=NEGATIVE, **kwargs
)
return node, node.encode_prompt(self.clip(), POSITIVE, **kwargs)
def test_off_saves_counts_and_tracks_nothing_but_returns_outputs(self):
for cls in NODE_CLASSES:
with self.subTest(node=cls.__name__):
graph = (
pair_graph(save=False)
if cls is PromptManagerPair
else run_graph(save=False, kind=cls.__name__)
)
node, result = self.run_node(cls, graph, False)
self.assertEqual(self.count_rows("prompts"), 0)
self.assertEqual(self.count_rows("prompt_pairs"), 0)
self.assertEqual(self.tracker.keyed_snapshot(), {})
self.assertEqual(self.tracker._prompt_queue, [])
node.comfyui_integration.register_prompt.assert_not_called()
if cls is PromptManagerText:
self.assertEqual(result, (POSITIVE,))
elif cls is PromptManagerPair:
self.assertEqual(result[0], ("cond", POSITIVE))
self.assertEqual(result[1], ("cond", NEGATIVE))
self.assertEqual(result[2:], (POSITIVE, NEGATIVE))
else:
self.assertEqual(result, (("cond", POSITIVE), POSITIVE))
def test_off_without_graph_context_still_saves_nothing(self):
node = self.node(PromptManager)
node.encode_prompt(self.clip(), POSITIVE, save_to_library=False)
self.assertEqual(self.count_rows("prompts"), 0)
self.assertEqual(self.tracker._prompt_queue, [])
def test_off_leaves_an_existing_prompt_uncounted(self):
prompt_id = self.save(POSITIVE)
self.run_node(PromptManager, run_graph(save=False), False)
row = self.db.get_prompt_by_id(prompt_id)
self.assertFalse(row.get("run_count") or 0)
def test_on_is_unchanged(self):
self.run_node(PromptManager, run_graph(save=True), True)
self.assertIsNotNone(self.db.get_prompt_by_hash(generate_prompt_hash(POSITIVE)))
self.assertIn(
prompt_node_key(run_graph(save=True), "1"), self.tracker.keyed_snapshot()
)
def test_unknown_setting_on_at_run_time_counts_at_execution(self):
"""The hook could not read a wired switch, so the node counts its run."""
graph = run_graph(save=["9", 0])
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
prompt_id = self.save(POSITIVE)
self.run_node(PromptManager, graph, True)
self.assertEqual(self.db.get_prompt_by_id(prompt_id)["run_count"], 1)
class TestWiredSwitchOffMarksItsRun(NodeCase):
"""Review finding: a wired switch off at run time keeps the node a run prompt."""
def wired_graph(self):
graph = run_graph(save=["9", 0])
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
return graph
def test_an_earlier_entry_of_the_key_never_stands_in(self):
graph = self.wired_graph()
key = prompt_node_key(graph, "1")
node = self.node(PromptManager)
node.encode_prompt(
self.clip(), POSITIVE, prompt=graph, unique_id="1", save_to_library=True
)
node.encode_prompt(
self.clip(), POSITIVE, prompt=graph, unique_id="1", save_to_library=False
)
latest, _run, _count = self.tracker.keyed_snapshot()[key]
self.assertTrue(latest.get("placeholder"))
def test_literal_off_marks_nothing(self):
node = self.node(PromptManager)
node.encode_prompt(
self.clip(),
POSITIVE,
prompt=run_graph(save=False),
unique_id="1",
save_to_library=False,
)
self.assertEqual(self.tracker.keyed_snapshot(), {})
class TestMonitorLeavesOptedOutFilesUnlinked(MonitorCase):
def test_existing_prompt_is_not_linked_from_metadata(self):
self.save(POSITIVE)
self.assertIsNone(self.process({"prompt": run_graph(save=False)}))
def test_find_prompt_reports_opted_out(self):
self.save(POSITIVE)
state, prompt = self.handler._find_prompt_from_metadata(
{"prompt": run_graph(save=False)}
)
self.assertEqual((state, prompt), ("opted_out", None))
def test_mixed_positive_settings_leave_the_file_unlinked(self):
self.save(POSITIVE)
graph = run_graph(save=True)
graph["8"] = {
"class_type": "PromptManager",
"inputs": {"text": "other", "save_to_library": False},
}
graph["5"] = {
"class_type": "KSampler",
"inputs": {
"model": ["4", 0],
"positive": ["8", 0],
"negative": ["2", 0],
"latent_image": ["6", 0],
},
}
self.assertIsNone(self.process({"prompt": graph}))
def test_workflow_only_metadata_with_switch_off_is_opted_out(self):
self.save(POSITIVE)
metadata = {
"text_encoder_nodes": [
{
"type": "PromptManager",
"inputs": [],
"widgets_values": [POSITIVE, "", "", "", "", "", False],
}
]
}
state, _prompt = self.handler._find_prompt_from_metadata(metadata)
self.assertEqual(state, "opted_out")
def test_workflow_only_ignores_muted_nodes_and_reads_subgraphs(self):
from utils.prompt_graph import metadata_opted_out
node = {
"type": "PromptManager",
"widgets_values": [POSITIVE, "", "", "", "", "", False],
}
self.assertFalse(
metadata_opted_out({"workflow": {"nodes": [{**node, "mode": 4}]}})
)
self.assertTrue(
metadata_opted_out(
{
"workflow": {
"nodes": [],
"definitions": {"subgraphs": [{"nodes": [node]}]},
}
}
)
)
def test_unknown_setting_is_left_to_the_run(self):
self.save(POSITIVE)
graph = run_graph(save=["9", 0])
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
state, _prompt = self.handler._find_prompt_from_metadata({"prompt": graph})
self.assertEqual(state, "unknown")
def test_enabled_positive_still_links(self):
prompt_id = self.save(POSITIVE)
self.assertEqual(self.process({"prompt": run_graph(save=True)}), prompt_id)
class TestLinkKeepsOptedOutNegativeOut(MonitorCase):
def setUp(self):
super().setUp()
# The real link, not the recording stub of MonitorCase
del self.handler.link_image_to_prompt
def test_opted_out_negative_is_never_stored_as_a_pair(self):
prompt_id = self.save(POSITIVE)
metadata = {"prompt": run_graph(save=True, negative_save=False)}
self.handler.link_image_to_prompt(self.image, {"id": prompt_id}, metadata)
self.assertIsNone(self.db.get_prompt_by_hash(generate_prompt_hash(NEGATIVE)))
self.assertEqual(self.count_pairs(), 0)
linked = (
self.db.model.get_connection()
.execute("SELECT prompt_id FROM generated_images")
.fetchall()
)
self.assertEqual([row[0] for row in linked], [prompt_id])
def test_opted_out_positive_links_nothing(self):
prompt_id = self.save(POSITIVE)
metadata = {"prompt": run_graph(save=False)}
self.handler.link_image_to_prompt(self.image, {"id": prompt_id}, metadata)
count = (
self.db.model.get_connection()
.execute("SELECT COUNT(*) FROM generated_images")
.fetchone()[0]
)
self.assertEqual(count, 0)
def count_pairs(self):
conn = self.db.model.get_connection()
return conn.execute("SELECT COUNT(*) FROM prompt_pairs").fetchone()[0]
class TestScanSkipsOptedOutFiles(NodeCase):
def setUp(self):
super().setUp()
from py.api import admin
self.mixin = admin.AdminRoutesMixin.__new__(admin.AdminRoutesMixin)
self.mixin.db = self.db
self.mixin.logger = logging.getLogger("test.save_to_library.scan")
self.file = Path(self.tmp.name) / "out.png"
def test_opted_out_file_stores_nothing(self):
status = self.mixin._ingest_scanned_file(
self.file, {"prompt": run_graph(save=False)}
)
self.assertEqual(status, "not_saved")
self.assertEqual(self.count_rows("prompts"), 0)
self.assertEqual(self.count_rows("generated_images"), 0)
def test_opted_out_file_is_never_queued_for_review(self):
ambiguous = []
graph = run_graph(save=False)
graph["5"] = dict(graph["3"]) # a second sampler: ambiguous otherwise
graph["5"]["inputs"] = {**graph["3"]["inputs"], "positive": ["2", 0]}
status = self.mixin._ingest_scanned_file(
self.file, {"prompt": graph}, ambiguous=ambiguous
)
self.assertEqual(status, "not_saved")
self.assertEqual(ambiguous, [])
def test_opted_out_negative_stores_the_positive_only(self):
status = self.mixin._ingest_scanned_file(
self.file, {"prompt": run_graph(save=True, negative_save=False)}
)
self.assertEqual(status, "added")
self.assertIsNotNone(self.db.get_prompt_by_hash(generate_prompt_hash(POSITIVE)))
self.assertIsNone(self.db.get_prompt_by_hash(generate_prompt_hash(NEGATIVE)))
self.assertEqual(self.count_rows("prompt_pairs"), 0)
def test_wired_switch_is_left_to_the_live_run(self):
graph = run_graph(save=["9", 0])
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
status = self.mixin._ingest_scanned_file(self.file, {"prompt": graph})
self.assertEqual(status, "not_saved_wired")
self.assertEqual(self.count_rows("prompts"), 0)
def test_status_has_a_scan_detail(self):
from py.api import admin
group, row, _line = admin._scan_outcome(self.file, [], "not_saved")
self.assertEqual(group, "no_metadata")
self.assertIn("does not save", row["detail"])
class TestHistoryRecordsNoText(unittest.TestCase):
def setUp(self):
self.m = load_module("execution_context_graph")
def test_opted_out_role_has_no_sources_and_no_text(self):
recipe = self.m.project_contexts(run_graph(save=False), None).recipes[0]
self.assertEqual(recipe.positive.sources, ())
self.assertIsNone(recipe.positive.inferred)
self.assertEqual(recipe.positive.reason, "not_saved")
# The enabled negative is recorded as before
self.assertEqual(recipe.negative.inferred.raw.value, NEGATIVE)
def test_unknown_setting_records_no_text_either(self):
graph = run_graph(save=["9", 0])
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
recipe = self.m.project_contexts(graph, None).recipes[0]
self.assertIsNone(recipe.positive.inferred)
self.assertEqual(recipe.positive.reason, "not_saved")
def test_enabled_role_is_unchanged(self):
recipe = self.m.project_contexts(run_graph(save=True), None).recipes[0]
self.assertEqual(recipe.positive.inferred.raw.value, POSITIVE)
self.assertEqual(len(recipe.positive.sources), 1)
if __name__ == "__main__":
unittest.main()