Compare commits

..
Author SHA1 Message Date
Vito 56d1072e58 chore: release 3.5.1 (#617)
Ships the smaller Registry package and the notice that the old pages will
be removed in a future release (#615).
2026-11-02 17:23:26 +00:00
Vito e71d0ca6b6 Smaller Registry package and old-UI removal notice (#615) (#616)
* chore(package): leave development files out of the Registry package (#615)

The package held tests, tools, docs and images used only on GitHub. A
.comfyignore drops them, taking the download from 17.4 MB to about 3.9 MB, and
a test holds the rules to what ComfyUI needs at run time.

* feat(legacy): say the old pages will be removed in a future release (#615)

Each old page shows a notice with a link to where its job lives in the
workspace. The pages keep working.
2026-11-02 15:31:02 +00:00
Vito 6f442bf423 chore(deps-dev): flake8 7.5.0, pre-commit 4.7.0, coverage 7.17.0 (#614)
* chore(deps-dev): bump flake8 to 7.5.0 (#598)

* chore(deps-dev): update pre-commit to >=4.7.0 (#601)

* chore(deps-dev): update coverage to >=7.17.0 (#602)
2026-11-02 14:42:47 +00:00
Vito bf2fa3f50e chore: release 3.5.0 (#608)
Adds the thumbnail location and move (#320), Send to All (#542), skipped
tags and saved instructions (#431), the Pair category repair (#550) and
lower image memory use (#523). Raises the Pillow minimum to 10.1.0 (#604).
2026-11-01 22:13:00 +00:00
Vito 2e9322d5c4 fix(thumbnails): never let an older folder check overwrite a newer answer (#611) (#612)
Two refresh() callers could publish out of order. A check that looked
before the folder was removed could set "available" after a newer check
said "missing", and the state stayed wrong until the next probe. Each
check now has an order number, and an older answer is dropped.
2026-11-01 21:16:55 +00:00
Vito a1377cec36 ci: pin the Playwright image by digest (#609) (#610)
The v1.64.1-noble tag was re-pushed with Node 26, which fails the Node 24
guard in every browser job. Pin the last green image's digest so the
browsers, fonts and Node only change through a reviewed bump.
2026-11-01 19:58:19 +00:00
11 changed files with 272 additions and 17 deletions
+25
View File
@@ -0,0 +1,25 @@
# Left out of the Comfy Registry package (comfy node publish). Git clones still
# have everything. tests/test_package_contents.py holds these rules to what
# ComfyUI needs at run time; keep each one anchored to the root with a leading /.
# Development folders
/tests/
/tools/
/docs/
/.github/
# Screenshots used only by README-legacy.md on GitHub
/images/
# Development files
/TESTING.md
/Makefile
/requirements-dev.txt
/.coveragerc
/.flake8
/.pre-commit-config.yaml
/.gitignore
# Stray images no code uses
/image_meta_data.png
/test_metadata_file.png
+4
View File
@@ -668,6 +668,10 @@ Browser tests: `workspace-stale-source.spec.mjs` (`/__test__/sources/reverse` an
`/__test__/scope-key/rotate`, both undone by every reset); the manual-Refresh
recovery specs in `workspace-images.spec.mjs` answer every rescope 409.
## Registry package
`comfy node publish` zips the tracked files minus `.comfyignore`, so a new development file or folder at the root ships until it is listed there. `tests/test_package_contents.py` keeps each rule anchored to the root, checks that development paths are left out and that nothing ComfyUI needs at run time is. Git clones are unaffected.
## Windows portability preflight
Before changing paths, SQLite, streams, restore or cleanup:
+33 -11
View File
@@ -69,6 +69,8 @@ _state_lock = threading.Lock()
_parsed = preview_location.ParsedLocation()
_available = True
_problem = None
# The newest check whose answer was published.
_published_order = 0
_keys = {}
_key_cache = {}
_clock = time.monotonic
@@ -167,10 +169,13 @@ def refresh():
if parsed.mode != FOLDER_MODE or parsed.problem is not None:
return parsed.mode == SOURCE_MODE
try:
available, problem, marker = _probe.check(parsed.path, parsed.id)
(available, problem, marker), order = _probe.ordered_check(
parsed.path, parsed.id
)
except (OSError, TimeoutError, RuntimeError):
available, problem, marker = False, "unreachable", None
_set_state(parsed, available, problem, marker)
order = _probe.latest_order()
_set_state(parsed, available, problem, marker, order)
return available
@@ -207,11 +212,16 @@ def mark_invalid():
_available, _problem = False, "invalid"
def _set_state(parsed, available, problem, marker):
global _available, _problem
def _set_state(parsed, available, problem, marker, order):
"""Publish a check's answer, unless a check that started later already
published (two callers can finish out of order, #611)."""
global _available, _problem, _published_order
with _state_lock:
if _parsed is not parsed:
return # the setting changed meanwhile
if order < _published_order:
return
_published_order = order
_available, _problem = available, problem
keys = dict((marker or {}).get("keys") or {})
if keys != _keys:
@@ -261,14 +271,25 @@ class _Probe:
self._stop = threading.Event()
self._thread = None
self._checking = None
self._order = 0
def check(self, path, expected_id):
"""``folder_state`` within IO_BUDGET; raises TimeoutError.
"""``folder_state`` within IO_BUDGET; see :meth:`ordered_check`."""
return self.ordered_check(path, expected_id)[0]
The answer comes from a check that started after this call: one
already running may have looked before what the caller just did, so
it is waited for (never run twice at once; a stuck one times out here)
and followed by a new one."""
def latest_order(self):
"""The order number of the check started last."""
with self._lock:
return self._order
def ordered_check(self, path, expected_id):
"""``(folder_state, order)`` within IO_BUDGET; raises TimeoutError.
``order`` grows with each check started, so answers can be published
in the order they looked. The answer comes from a check that started
after this call: one already running may have looked before what the
caller just did, so it is waited for (never run twice at once; a stuck
one times out here) and followed by a new one."""
key = (path, expected_id)
with self._lock:
earlier = self._running(key)
@@ -284,7 +305,7 @@ class _Probe:
self._checking = None
if "error" in pending[2]:
raise pending[2]["error"]
return pending[2]["value"]
return pending[2]["value"], pending[2]["order"]
def _running(self, key):
"""The check of *key* still running (lock held), else None."""
@@ -300,7 +321,8 @@ class _Probe:
def _start_check(self, path, expected_id):
"""Start one check on its own thread (lock held)."""
result = {}
self._order += 1
result = {"order": self._order}
done = threading.Event()
def run():
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "promptmanager"
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
version = "3.4.1"
version = "3.5.1"
license = {file = "LICENSE"}
requires-python = ">=3.12"
dependencies = ["watchdog>=2.1.0", "Pillow>=10.1.0"]
+3 -3
View File
@@ -4,15 +4,15 @@ Pillow>=10.1.0
aiohttp>=3.11.8
# Tests and coverage
coverage>=7.16.2
coverage>=7.17.0
numpy>=1.25.0 # WD14 engine tests
# Code quality
black==26.10.1
flake8==7.4.1
flake8==7.5.0
# Security scanning
bandit[toml]>=1.9.4
# Pre-commit hooks
pre-commit>=4.6.2
pre-commit>=4.7.0
@@ -0,0 +1,47 @@
// #615: each old page says it will be removed and links to where its job lives now. The page keeps working, and the
// notice takes its own room: nothing is pushed off a page whose layout fills the window.
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
const PAGES = Object.freeze([
{ path: '/prompt_manager/admin', section: 'library', heading: 'Prompt library' },
{ path: '/prompt_manager/gallery', section: 'images', heading: 'Images' },
{ path: '/prompt_manager/gallery.html', section: 'inspect', heading: 'Inspect a file' },
]);
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset', {})).toBeOK();
await page.addInitScript(() => {
localStorage.setItem('pm_update_notice_dismissed', '3.2.0');
localStorage.setItem('gallerySettings', JSON.stringify({ checkThumbnailsAtStartup: false }));
});
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
});
for (const { path, section, heading } of PAGES) {
test(`${path} says it will be removed and links to ${section}`, async ({ page }) => {
await page.goto(path);
const notice = page.locator('#legacy-removal-notice');
await expect(notice).toBeVisible();
await expect(notice).toContainText('This page will be removed in a future release.');
await notice.getByRole('link', { name: 'Open the workspace' }).click();
await expect(page).toHaveURL(new RegExp(`/prompt_manager/workspace#/${section}$`));
await expect(page.locator('#workspace-main h1')).toHaveText(heading);
});
}
test('the notice leaves the metadata page filling the window', async ({ page }) => {
await page.goto('/prompt_manager/gallery.html');
await expect(page.locator('#legacy-removal-notice')).toBeVisible();
const { bottom, height, scroll } = await page.locator('#image-area').evaluate(area => ({
bottom: area.getBoundingClientRect().bottom,
height: window.innerHeight,
scroll: document.documentElement.scrollHeight,
}));
expect(bottom).toBeLessThanOrEqual(height);
expect(scroll).toBeLessThanOrEqual(height);
});
+104
View File
@@ -0,0 +1,104 @@
"""What the Registry package leaves out (#615).
``comfy node publish`` zips the tracked files minus ``.comfyignore``. The checks
here hold the two ways that can go wrong: a development folder creeping back
into the download, or a rule that drops something ComfyUI needs at run time.
"""
import ast
import fnmatch
import re
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RUNTIME_DIRS = ("py", "database", "utils", "web")
DEVELOPMENT = (
"tests/test_package_contents.py",
"tools/vendor/package.json",
"docs/application-redesign.md",
".github/workflows/publish.yml",
"images/x.png",
"TESTING.md",
"requirements-dev.txt",
"image_meta_data.png",
"test_metadata_file.png",
)
KEPT = (
"__init__.py",
"prompt_manager.py",
"pyproject.toml",
"requirements.txt",
"LICENSE",
"README.md",
"README-legacy.md",
"assets/readme/hero.webp",
)
def _patterns():
lines = (ROOT / ".comfyignore").read_text(encoding="utf-8").splitlines()
return [line.strip() for line in lines if line.strip() and line[0] != "#"]
def _ignored(path):
"""The rules this file allows: ``folder/`` from the root, or a root file."""
for pattern in _patterns():
if pattern.endswith("/"):
if path.startswith(pattern.lstrip("/")):
return True
elif fnmatch.fnmatchcase(path, pattern.lstrip("/")):
return True
return False
class TestPackageContents(unittest.TestCase):
def test_every_rule_is_anchored_to_the_root(self):
# An unanchored "tests/" would also drop web/js/tests, and "images/" a
# runtime folder of that name deeper down.
for pattern in _patterns():
self.assertTrue(pattern.startswith("/"), pattern)
self.assertNotIn("!", pattern)
def test_development_files_are_left_out(self):
for path in DEVELOPMENT:
self.assertTrue(_ignored(path), path)
def test_what_comfyui_needs_stays(self):
for path in KEPT:
self.assertFalse(_ignored(path), path)
for folder in RUNTIME_DIRS:
for file in (ROOT / folder).rglob("*"):
if file.is_file():
relative = file.relative_to(ROOT).as_posix()
self.assertFalse(_ignored(relative), relative)
def test_runtime_code_imports_nothing_left_out(self):
sources = list(ROOT.glob("*.py"))
for folder in ("py", "database", "utils"):
sources.extend((ROOT / folder).rglob("*.py"))
for source in sources:
tree = ast.parse(source.read_text(encoding="utf-8"))
for node in ast.walk(tree):
names = []
if isinstance(node, ast.Import):
names = [alias.name for alias in node.names]
elif isinstance(node, ast.ImportFrom) and not node.level:
names = [node.module or ""]
for name in names:
self.assertNotIn(name.split(".")[0], ("tests", "tools"), source)
def test_the_old_pages_name_their_replacement(self):
for page, section in (
("admin", "library"),
("gallery", "images"),
("metadata", "inspect"),
):
html = (ROOT / "web" / f"{page}.html").read_text(encoding="utf-8")
self.assertRegex(html, r'id="legacy-removal-notice"')
self.assertRegex(html, re.escape(f'href="workspace#/{section}"'))
if __name__ == "__main__":
sys.exit(unittest.main())
+50
View File
@@ -89,5 +89,55 @@ class TestProbeCheck(unittest.TestCase):
self.probe.check(*KEY)
class TestRefreshOrder(unittest.TestCase):
"""Only the newest check's answer is published (#611)."""
def test_an_older_check_never_overwrites_a_newer_answer(self):
parsed = preview_location.ParsedLocation(
mode=preview_store.FOLDER_MODE, path="/thumbs", id="x"
)
self.addCleanup(setattr, preview_store, "_available", preview_store._available)
self.addCleanup(setattr, preview_store, "_problem", preview_store._problem)
started = threading.Event()
release = threading.Event()
newer_published = threading.Event()
calls = []
publish = preview_store._set_state
def state(_path, _expected_id):
calls.append(1)
if len(calls) == 1:
started.set()
self.assertTrue(release.wait(WAIT))
return HERE
return GONE
def set_state(*args, **kwargs):
if args[1]: # the older check's "available" arrives last
self.assertTrue(newer_published.wait(WAIT))
publish(*args, **kwargs)
def newer():
preview_store.refresh()
newer_published.set()
with (
patch.object(preview_store, "_parsed", parsed),
patch.object(preview_location, "folder_state", state),
patch.object(preview_store, "_set_state", set_state),
):
first = threading.Thread(target=preview_store.refresh)
first.start()
self.assertTrue(started.wait(WAIT))
second = threading.Thread(target=newer)
second.start()
release.set()
second.join(WAIT)
first.join(WAIT)
self.assertFalse(first.is_alive() or second.is_alive())
self.assertFalse(preview_store._available)
self.assertEqual(preview_store._problem, "missing")
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -20,6 +20,7 @@
<script src="lib/viewerjs/viewer.min.js"></script>
</head>
<body class="bg-pm-primary text-pm min-h-screen text-[13px]">
<div id="legacy-removal-notice" role="note" class="bg-pm-surface border-b border-pm px-4 py-2 text-pm" style="font-size: 13px;">This page will be removed in a future release. Its features are moving to the new workspace. <a href="workspace#/library" style="text-decoration: underline;">Open the workspace</a></div>
<div class="min-h-screen flex flex-col">
<!-- Header -->
<header class="bg-pm-surface border-b border-pm px-4 py-2" style="min-height: 2.5rem;">
+1
View File
@@ -28,6 +28,7 @@
</style>
</head>
<body class="bg-pm-primary text-pm min-h-screen">
<div id="legacy-removal-notice" role="note" class="bg-pm-surface border-b border-pm px-4 py-2 text-pm" style="font-size: 13px;">This page will be removed in a future release. Its features are moving to the new workspace. <a href="workspace#/images" style="text-decoration: underline;">Open the workspace</a></div>
<div class="min-h-screen flex flex-col">
<!-- Header -->
<header class="bg-pm-surface border-b border-pm px-4 py-2">
+3 -2
View File
@@ -361,8 +361,9 @@ Seed: ${currentWorkflowData.seed || 'Unknown'}`;
});
</script>
</head>
<body class="bg-pm-primary text-pm font-sans overflow-hidden">
<div class="h-screen flex">
<body class="bg-pm-primary text-pm font-sans overflow-hidden" style="display: flex; flex-direction: column; height: 100vh;">
<div id="legacy-removal-notice" role="note" class="bg-pm-surface border-b border-pm px-4 py-2 text-pm" style="font-size: 13px;">This page will be removed in a future release. Its features are moving to the new workspace. <a href="workspace#/inspect" style="text-decoration: underline;">Open the workspace</a></div>
<div class="flex" style="flex: 1; min-height: 0;">
<!-- Main Image Area -->
<div id="image-area" class="flex-1 flex items-center justify-center p-3 bg-pm-primary relative border-2 border-dashed border-transparent transition-colors min-w-0">
<!-- Upload prompt overlay -->