Compare commits

...
Author SHA1 Message Date
Vito Sansevero d58fefd426 fix(deps): keep watchdog below 7
watchdog 7.0.0 (released 2026-10-31) makes the package's exit tests hang
on Windows on Python 3.12 to 3.14; the same commit passed with 6.0.0.
Cap it until observer.stop() is bounded at exit.

Refs #561
2026-10-31 05:44:54 -07:00
Vito d88cf9e7c6 fix(quality): sweep the Code Quality standard findings (#558)
* fix(quality): preserve cursor iteration and option validation

Keep prefetched rows on ManagedCursor so partial loops and explicit fetches share one position, and stop existing iterators after close or retirement. Preserve chunked reads and native SQLite result lifetime on reexecution and errors.

Catch OptionError before ValueError so legacy auto-tag requests receive the actual validation error. Remove the overwritten ViewerJS title option.

Regression tests failed before the fixes for lost rows, reads after close/retirement, and misleading option errors. Focused Python checks pass; cursor checks also pass on Python 3.13 and 3.14. Refs #321.

* fix(quality): make the test doubles legal raises and hashes

The fake queue no longer raises an attribute that starts as None. The
oversized kind records hashes instead of raising from __hash__, which
also catches a hash that production code swallows. Hostile.__bool__
raises the TypeError a truth test may raise.

* fix(quality): drop the extra saveCoverage argument

* refactor(quality): remove assignments that are overwritten before use

* refactor(quality): remove the remaining overwritten test assignments

* refactor(quality): drop imports repeated inside functions

* refactor(quality): give duplicate identity keys one shape

* refactor(quality): name the unused job event loop variable

* refactor(quality): release model gates in finally, not except BaseException

* refactor(quality): end the job runners with an explicit return

* refactor(quality): use the precise unittest asserts

* refactor(quality): import unittest.mock one way in the tests

* fix(quality): say why each empty except is safe, log the broad ones

* refactor(quality): stop binding values the tests never read

* refactor(quality): remove the unused preview aliases and tag page query

* refactor(quality): remove re-exports nothing imports

* refactor(quality): parenthesize deliberate string joins in lists

* refactor(quality): remove unused JavaScript variables and imports

* refactor(quality): use the precise assert in the model status test
2026-10-31 05:43:48 +00:00
Vito df70b5dfd6 docs: new README as the product page, and release bump to 3.4.0 (#545)
* docs: new README as the product page, keep the old one as README-legacy

Rename README.md to README-legacy.md with a short note saying what it is,
and write a new README: hero, visual feature tour, all six nodes, then
install, data locations, upgrade notes, legacy pages, limits and testing.
Images are compressed WebP in assets/readme. Point the TESTING.md link at
the section's new home.

* docs: retake README screenshots, add shortcuts, pinned bar and trash limits

* chore: release 3.4.0
2026-10-31 02:57:16 +00:00
130 changed files with 1792 additions and 1319 deletions
+1196
View File
File diff suppressed because it is too large Load Diff
+139 -1085
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -6797,7 +6797,7 @@ checkout or point this harness at a live ComfyUI checkout.
The package-qualified config import also corrects #211: the configured database
path is honored when PromptManager runs as a ComfyUI custom node. Follow the
[database path compatibility and safe backup sequence](README.md#database-path-compatibility-211)
[database path compatibility and safe backup sequence](README-legacy.md#database-path-compatibility-211)
before restarting an existing deployment.
The ledger records only new dequeued jobs and observed outcomes. Existing
+3
View File
@@ -120,6 +120,7 @@ except Exception as e:
logger = get_logger("prompt_manager.init")
logger.error(f"Failed to register API routes: {e}")
except Exception:
# logging failed too; a report must never stop the node loading
pass
# Shared database, set up with the monitoring system below. Stays None when that
@@ -411,6 +412,7 @@ except Exception as e:
f"Failed to register the exit signal: {e}"
)
except Exception:
# logging failed too; a report must never stop the node loading
pass
try:
@@ -428,6 +430,7 @@ except Exception as e:
f"Failed to register the background work teardown: {e}"
)
except Exception:
# logging failed too; a report must never stop the node loading
pass
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

+1
View File
@@ -590,6 +590,7 @@ class _Move:
try:
os.rmdir(self.plan.target_dir)
except OSError:
# not empty or already gone: the undo leaves it alone
pass
+62 -22
View File
@@ -35,38 +35,51 @@ from __future__ import annotations
import contextlib
import logging
import operator
import sys
import threading
import time
import weakref
from typing import Iterable, Iterator, List, Optional
from collections import deque
from typing import Any, Callable, Iterable, Iterator, List, Optional
import sqlite3
CLOSED_MESSAGE = "Cannot operate on a closed database."
# Rows fetched per step when a cursor is iterated: the connection is marked
# in use once per chunk instead of once per row (iteration costs about what
# a plain cursor costs). A loop that stops early leaves up to this many rows
# fetched; ``fetchone``/``fetchmany`` after a partial loop skip them.
# a plain cursor costs). Prefetched rows belong to the cursor so iteration
# and explicit fetches always advance the same position.
ITER_CHUNK = 128
class ManagedCursor(sqlite3.Cursor):
"""A cursor whose calls into SQLite count as its connection being in use."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._pending = deque()
self._closed = False
def execute(self, sql, parameters=(), /):
conn = self.connection
conn._enter()
try:
return super().execute(sql, parameters)
finally:
conn._leave()
return self._run_statement(super().execute, sql, parameters)
def executemany(self, sql, seq_of_parameters, /):
return self._run_statement(super().executemany, sql, seq_of_parameters)
def _run_statement(self, operation: Callable, *args: Any) -> ManagedCursor:
conn = self.connection
conn._enter()
description = self.description
completed = False
try:
return super().executemany(sql, seq_of_parameters)
result = operation(*args)
completed = True
return result
finally:
# Argument parsing can fail before SQLite replaces the old statement.
if completed or self.description is not description:
self._pending.clear()
conn._leave()
def executescript(self, sql_script, /):
@@ -81,6 +94,8 @@ class ManagedCursor(sqlite3.Cursor):
conn = self.connection
conn._enter()
try:
if self._pending and not self._closed:
return self._pending.popleft()
return super().fetchone()
finally:
conn._leave()
@@ -89,7 +104,20 @@ class ManagedCursor(sqlite3.Cursor):
conn = self.connection
conn._enter()
try:
return super().fetchmany(*args, **kwargs)
# Let SQLite validate the original arguments, including version-specific
# handling of zero/negative sizes, before touching prefetched rows.
rows = super().fetchmany(*args, **kwargs)
if not self._pending:
return rows
size = operator.index(
kwargs.get("size", args[0] if args else self.arraysize)
)
# Python 3.12 reads all rows for size=0; 3.13+ reads none.
if size == 0 and sys.version_info >= (3, 13):
return rows
self._pending.extend(rows)
count = len(self._pending) if size <= 0 else min(size, len(self._pending))
return [self._pending.popleft() for _ in range(count)]
finally:
conn._leave()
@@ -97,25 +125,31 @@ class ManagedCursor(sqlite3.Cursor):
conn = self.connection
conn._enter()
try:
return super().fetchall()
rows = super().fetchall()
pending = list(self._pending)
self._pending.clear()
return pending + rows
finally:
conn._leave()
def __iter__(self) -> Iterator:
return self._chunks()
def _chunks(self) -> Iterator:
while True:
rows = self.fetchmany(ITER_CHUNK)
if not rows:
return
yield from rows
return self
def __next__(self):
conn = self.connection
pending = self._pending
# Buffered Python rows need no SQLite lock, but retirement and cursor
# close must still stop an iterator obtained before either operation.
if conn._retired or self._closed:
raise sqlite3.ProgrammingError(CLOSED_MESSAGE)
if pending:
return pending.popleft()
conn._enter()
try:
return super().__next__()
pending.extend(super().fetchmany(ITER_CHUNK))
if not pending:
raise StopIteration
return pending.popleft()
finally:
conn._leave()
@@ -126,7 +160,10 @@ class ManagedCursor(sqlite3.Cursor):
except sqlite3.ProgrammingError:
return super().close() # the connection is closed or closing
try:
return super().close()
result = super().close()
self._pending = deque()
self._closed = True
return result
finally:
conn._leave()
@@ -293,6 +330,9 @@ class ManagedConnection(sqlite3.Connection):
cursors = list(self._cursors)
try:
for cursor in cursors:
if isinstance(cursor, ManagedCursor):
cursor._pending = deque()
cursor._closed = True
with contextlib.suppress(sqlite3.Error):
sqlite3.Cursor.close(cursor)
super().close()
+1 -1
View File
@@ -34,7 +34,7 @@ from .history_reads import HistoryReadOperations
from .health import HealthOperations, iter_consistency_issues
from .duplicate_copies import DuplicateCopyOperations
from .workflow_storage import WorkflowStorageOperations
from .category_import import CategoryImportOperations, EmptyReplacement # noqa: F401
from .category_import import CategoryImportOperations
from .image_categories import ImageCategoryOperations
from .workflow_documents import (
DocumentReader,
+1
View File
@@ -46,6 +46,7 @@ def remove_quietly(*paths):
if path and os.path.exists(path):
os.remove(path)
except OSError:
# cleanup after a failure must not replace that failure
pass
-1
View File
@@ -66,7 +66,6 @@ def _tag_rows(page: str, order: str = "page.id") -> str:
)
_TAG_PAGE = _tag_rows("SELECT id,name FROM tags WHERE id>? ORDER BY id LIMIT ?")
# #444 tag sorts: sort -> (key expression on tags/counted, direction). Ties and
# "created" follow the id, so every order is total and keyset pages are exact.
TAG_SORTS = {
+2 -1
View File
@@ -176,7 +176,7 @@ def _physical_file_key(path, stat):
volumes on Windows) falls back to the resolved, case-folded path.
"""
if stat.st_ino:
return ("inode", stat.st_dev, stat.st_ino)
return ("inode", (stat.st_dev, stat.st_ino))
return ("path", os.path.normcase(os.path.realpath(str(path))))
@@ -539,6 +539,7 @@ def _diagnose_output_dirs(configured_dirs, public_path=None):
candidates.append(folder_paths.get_output_directory())
except ImportError:
# outside ComfyUI there is no output folder to add
pass
output_dirs = []
-1
View File
@@ -13,7 +13,6 @@ import math
from ..tagging_defaults import ( # noqa: F401 - re-exported for the route
LLM_MODEL_TYPES as LLM_MODELS,
MAX_INSTRUCTION_BYTES,
MAX_INSTRUCTION_CHARS,
MODEL_TYPES,
WD14_MODEL_TYPES as WD14_MODELS,
validate_template,
+10 -9
View File
@@ -424,6 +424,7 @@ class AutotagRoutesMixin:
title = f"Tagging model download · {label}"
_download_titles.clear()
_download_titles[owner] = title
admitted = False
try:
job = self._jobs.start(
MODEL_DOWNLOAD_JOB_KIND,
@@ -439,13 +440,13 @@ class AutotagRoutesMixin:
self._run_model_download, service, model_type, owner
),
)
except BaseException as refused:
# JobBusy, or anything else the registry raised: give the gate back.
model_gate.release(owner)
_download_titles.pop(owner, None)
if isinstance(refused, JobBusy):
return None, busy_response(refused)
raise
admitted = True
except JobBusy as refused:
return None, busy_response(refused)
finally:
if not admitted: # JobBusy, or anything else: give the gate back.
model_gate.release(owner)
_download_titles.pop(owner, None)
def ended(_task):
model_gate.release(owner)
@@ -1041,12 +1042,12 @@ class AutotagRoutesMixin:
return self._bad_request('existing must be "skip" or "merge"')
try:
options = self._single_options(data, model_type)
except OptionError as error:
return self._bad_request(str(error))
except (ValueError, TypeError):
return self._bad_request(
"general_threshold and character_threshold must be numeric"
)
except OptionError as error:
return self._bad_request(str(error))
target, error = await self._select_autotag_target(data)
if error is not None:
return error
+1
View File
@@ -971,6 +971,7 @@ class BackupRestoreRoutesMixin:
line = restored_line(staged.summary)
job.log_line(line)
job.update(progress=100, status_line=line)
return None
async def _outlive_cancel(future):
+2 -1
View File
@@ -574,12 +574,13 @@ class DuplicateFilesMixin:
return _fail_job(job, "sources-changed", SOURCES_CHANGED)
ok, backup = await self._safety_backup_step(job, db, backup_on)
if not ok:
return
return None
try:
await self._remove_duplicates(job, db, plan, backup)
finally:
if backup:
await self._prune_backup_pool(job, db)
return None
async def _remove_duplicates(self, job, db, plan, backup):
targets = plan.payload["targets"]
+1 -1
View File
@@ -27,7 +27,7 @@ def _identity(path):
except OSError:
return None
if info.st_ino:
return ("inode", info.st_dev, info.st_ino)
return ("inode", (info.st_dev, info.st_ino))
return ("path", os.path.normcase(os.path.realpath(str(path))))
+2 -1
View File
@@ -330,12 +330,13 @@ class ImageDeleteMixin:
return _fail_job(job, "sources-changed", SOURCES_CHANGED)
ok, backup = await self._safety_backup_step(job, db, backup_on)
if not ok:
return
return None
try:
await self._delete_images(job, db, targets, mode, backup)
finally:
if backup:
await self._prune_backup_pool(job, db)
return None
async def _delete_images(self, job, db, targets, mode, backup):
job.update(total=len(targets), status_line=TITLES[mode])
+7 -5
View File
@@ -531,6 +531,7 @@ class ImageTagJobsMixin:
run = AutotagRun(
model_type, existing, options, keep_in_memory, backup_on, owner
)
admitted = False
try:
job = self._jobs.start(
AUTOTAG_BATCH_JOB_KIND,
@@ -541,11 +542,12 @@ class ImageTagJobsMixin:
groups=AUTOTAG_GROUP_NAMES,
runner=functools.partial(self._run_autotag_batch, run),
)
except BaseException as refused:
model_gate.release(owner)
if isinstance(refused, JobBusy):
return None, _busy_refusal(refused)
raise
admitted = True
except JobBusy as refused:
return None, _busy_refusal(refused)
finally:
if not admitted:
model_gate.release(owner)
job.task.add_done_callback(lambda _task: model_gate.release(owner))
return job, None
+2 -7
View File
@@ -39,10 +39,8 @@ from .thumbnail_encode import (
PREVIEW_HELD,
EncoderClosed,
PreviewWriteHeld,
replace_unless_held,
run_encoder,
sweep_stray_temps,
temp_sibling,
thumbnail_frame,
writer_settings,
write_thumbnail,
@@ -339,11 +337,6 @@ def _thumbnail_batch_size(workers):
return max(4, workers * 2)
# Preview files are written by thumbnail_encode in the configured format
# (thumbnail_settings, #317); these names stay importable from here.
_temp_sibling = temp_sibling
_replace_unless_held = replace_unless_held
# A preview that could not be replaced (#249 S5 M6). Windows refuses to rename
# over a file another process has open (WinError 5 or 32), such as a preview
# the gallery is serving; the old preview stays and the file counts as an error.
@@ -396,6 +389,7 @@ def _video_placeholder_image(thumbnail_size):
font=font,
)
except (OSError, AttributeError):
# a placeholder without its label still works
pass
return img
@@ -499,6 +493,7 @@ def _preview_names(folders, folder):
except OSError:
continue
except OSError:
# an unreadable folder lists as empty; nothing there to cache
pass
folders[folder] = names
return names
+1 -1
View File
@@ -250,7 +250,7 @@ class JobRoutesMixin:
try:
await response.write(_summary_event(job))
async with contextlib.aclosing(job.events()) as events:
async for _payload in events:
async for _ in events:
await response.write(_summary_event(job))
# The last event is published before the job takes its final state.
await response.write(_summary_event(job))
+2
View File
@@ -207,6 +207,7 @@ class LibraryLocationRoutesMixin:
try:
size = os.path.getsize(location.db_path)
except OSError:
# no library file yet: report size 0
pass
return {
"path": self._library_public_path(location.db_path),
@@ -373,6 +374,7 @@ class LibraryLocationRoutesMixin:
line += ". Logs move at the next restart."
job.log_line(line)
job.update(progress=100, status_line=line)
return None
@staticmethod
def _fail_library_move(job, code, message):
+1
View File
@@ -205,3 +205,4 @@ class LogCleanupMixin:
line += f", {failed} could not be deleted"
job.log_line(line)
job.update(processed=len(files), progress=100, status_line=line)
return None
+5 -3
View File
@@ -436,7 +436,7 @@ class LoraImportRoutesMixin:
job.log_line(f"{CANCELLED} {CACHE_NOTE}")
job.update(status_line=CANCELLED)
job.finish("cancelled")
return
return None
except Exception:
self.logger.exception("LoRA import staging failed")
return _fail_job(job, "import-failed", f"{FAILED} {CACHE_NOTE}")
@@ -455,10 +455,10 @@ class LoraImportRoutesMixin:
job.log_line(f"{CANCELLED} {CACHE_NOTE}")
job.update(status_line=CANCELLED)
job.finish("cancelled")
return
return None
ok, _backup = await self._safety_backup_step(job, db, backup_on)
if not ok:
return
return None
# I1: the files are listed and fingerprinted again right before the
# replace, so a file changed, removed or added while the downloads ran
# (LoraManager refreshing its CivitAI info) aborts with nothing deleted.
@@ -466,6 +466,7 @@ class LoraImportRoutesMixin:
return _fail_job(job, "plan-changed", f"{SOURCE_CHANGED} {CACHE_NOTE}")
job.update(status_line="Replacing the LoRA collection", progress=95)
await self._replace_in_job(job, db, plan, staged)
return None
def _count_downloads(self, job, staged):
downloaded = staged.stats.get("downloaded", 0)
@@ -518,3 +519,4 @@ class LoraImportRoutesMixin:
staged.stats.get("download_errors", 0),
)
job.update(progress=100, processed=job.total, status_line=line)
return None
+1
View File
@@ -536,6 +536,7 @@ class LoraIntegrationMixin:
except asyncio.CancelledError:
interrupted = True
except Exception:
# the await after this loop raises it for the caller
pass
if interrupted:
raise asyncio.CancelledError
+1
View File
@@ -673,6 +673,7 @@ class MaintenanceRoutesMixin:
line = _changed_line(plan.operation, result["changed"], result.get("marks", 0))
job.log_line(line)
job.update(progress=100, status_line=f"{line} · {backup_note(backup)}")
return None
def _maintenance_rows(self, operation, described):
"""Outcome rows for the changed records (root-relative paths only)."""
+1
View File
@@ -533,6 +533,7 @@ class AutoThumbnailer:
try:
process.kill()
except OSError:
# the process has already exited
pass
if worker is None:
return True
+4
View File
@@ -158,6 +158,7 @@ def _unlink_quietly(path):
try:
Path(path).unlink()
except FileNotFoundError:
# already gone, which is what was wanted
pass
except OSError as exc:
logger.debug(f"Could not remove temp file {Path(path).name}: {exc}")
@@ -221,6 +222,7 @@ def shutdown_encoders():
try:
process.kill()
except OSError:
# the process has already exited
pass
@@ -359,10 +361,12 @@ def thumbnail_frame(img, edge):
try:
img.seek(0)
except (EOFError, AttributeError, ValueError):
# not seekable or a single frame: use the current one
pass
try:
img.draft("RGB", (edge, edge)) # JPEG: decode at a reduced scale
except (AttributeError, ValueError, OSError):
# draft is only a speedup; the full decode below still works
pass
frame = img.convert("RGB") if img.mode != "RGB" else img.copy()
frame.thumbnail((edge, edge), Image.Resampling.LANCZOS)
+1
View File
@@ -275,3 +275,4 @@ class WorkflowStorageRoutesMixin:
job.record_counted("changed", 1)
job.log_line(line)
job.update(progress=100, status_line=line)
return None
+1
View File
@@ -109,6 +109,7 @@ def _write_atomically(path: str, payload: bytes) -> None:
try:
os.unlink(temp_path)
except OSError:
# nothing to clean up; the original error is raised below
pass
raise
+1
View File
@@ -642,6 +642,7 @@ def layout_summary(layout: LoraManagerLayout, home: Path) -> Dict:
relative = Path(settings_file).relative_to(home)
settings_file = os.path.join("~", str(relative))
except ValueError:
# outside the home folder: show the full path
pass
return {
"source": layout.source,
+2
View File
@@ -585,6 +585,7 @@ def _read_capped(resp, cap: int) -> Optional[bytes]:
if int(declared) > cap:
return None
except ValueError:
# a bad header says nothing; the read below enforces the cap
pass
chunks = []
@@ -707,6 +708,7 @@ def _save_atomically(image, local_path: Path) -> None:
try:
os.remove(temp)
except OSError:
# nothing to clean up; the original error is raised below
pass
raise
+2 -2
View File
@@ -1,10 +1,10 @@
[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.3.1"
version = "3.4.0"
license = {file = "LICENSE"}
requires-python = ">=3.12"
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
dependencies = ["watchdog>=2.1.0,<7", "Pillow>=8.0.0"]
[build-system]
requires = ["setuptools>=84.2.1"]
+1 -1
View File
@@ -1,5 +1,5 @@
# Runtime dependencies
watchdog>=6.0.0
watchdog>=6.0.0,<7
Pillow>=12.3.0
aiohttp>=3.14.5
+1 -1
View File
@@ -1,4 +1,4 @@
# Runtime dependencies for PromptManager.
# aiohttp, numpy and torch come from ComfyUI itself and are not listed here.
watchdog>=2.1.0 # Output directory monitoring
watchdog>=2.1.0,<7 # Output directory monitoring
Pillow>=8.0.0 # Image metadata extraction and thumbnails
-1
View File
@@ -6,7 +6,6 @@ import { THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate
// app.js 1428–1432) and its backup box with the concept's System `.notice`; the differences are D55. The job view
// has no concept counterpart: it is compared with the approved mockup `activity-scan.html` (a local, ignored file),
// whose structure and CSS values are restated below (D56). Each comparison is shown to notice a mutation.
const workspace = '/prompt_manager/workspace';
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
+1
View File
@@ -109,6 +109,7 @@ class ResetWatch:
stream.write(text)
stream.flush()
except (OSError, ValueError):
# the stream closed; a watcher must not fail the server
pass
+1 -1
View File
@@ -233,7 +233,7 @@ test('a view stored under older rules still lists and opens with its usable valu
// D153 (#447): the workspace no longer offers "Highest rated"; the API still stores such a view as it was saved.
test('a view saved under Highest rated opens on Recently added on Images and the Library', async ({ page, request }) => {
const images = await create(request, { page: 'images', name: 'Rated cats', state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'rating' } });
const library = await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [], tag_mode: 'all', folder: '' } });
await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [], tag_mode: 'all', folder: '' } });
expect((await listed(request)).map(view => view.state.sort)).toEqual(['rating', 'rating']);
const listings = [];
page.on('request', item => {
@@ -60,7 +60,7 @@ async function openReview(page, request, items = 30) {
const inViewport = (rect, page) => rect.top >= 0 && rect.bottom <= page.viewportSize().height;
test.beforeEach(async ({ page, browserName }) => { await startCoverage(page, browserName); });
test.afterEach(async ({ page, browserName }, testInfo) => { await saveCoverage(page, browserName, testInfo); });
test.afterEach(async ({ page, browserName }) => { await saveCoverage(page, browserName); });
for (const viewport of [null, PHONE]) {
const label = viewport ? ' at 390px' : '';
@@ -11,7 +11,6 @@ const REPORT_URL = '/prompt_manager/system/diagnostics';
const LOGS_URL = '/prompt_manager/logs';
const CONFIG_URL = '/prompt_manager/logs/config';
const PREVIEW_URL = '/prompt_manager/maintenance/preview';
const APPLY_URL = '/prompt_manager/maintenance/apply';
const TEST_LINK_URL = '/prompt_manager/diagnostics/test-link';
const CHANGED = 'The log files changed since the review; nothing was deleted. Review again.';
const KEY = 'sk-s12-browser-SECRET-0123456789';
-10
View File
@@ -21,14 +21,6 @@ function jsonResponse(status, body) {
json: async () => body };
}
function sseResponse(frames) {
const bytes = new TextEncoder().encode(frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join(''));
let sent = false;
return { ok: true, status: 200, body: { getReader: () => ({
read: async () => (sent ? { done: true } : (sent = true, { done: false, value: bytes })),
}) } };
}
function load(file, className, fetchUrl, extra = {}, dom = elements()) {
const timers = [];
const notifications = [];
@@ -63,8 +55,6 @@ function load(file, className, fetchUrl, extra = {}, dom = elements()) {
return { page, dom, timers, notifications, storage };
}
const BUSY = 'Scan for metadata already running';
test('admin settings save shows why config.json refused it', async () => {
const message = 'config.json cannot be read, so nothing was saved. Fix or remove the file, then save again.';
const { page, notifications } = load('admin.js', 'PromptAdmin',
+1 -1
View File
@@ -2584,7 +2584,7 @@ class TestActivityJobs(AdminAPITestCase):
async def test_cancel_after_first_batch_keeps_partial_counts(self):
"""Review M7: the real scan stops at the batch boundary, keeping batch 1."""
import threading
from unittest import mock
import unittest.mock as mock
from py.api import admin
+30 -1
View File
@@ -600,6 +600,33 @@ class TestAutotagSingleEdgeCases(AutotagAPITestCase):
{"path": str(image), "general_threshold": "high"},
)
self.assertEqual(resp.status, 400)
self.assertEqual(
(await resp.json())["error"],
"general_threshold and character_threshold must be numeric",
)
async def test_invalid_single_options_report_the_validation_error(self) -> None:
image = self._make_image(self.output_dir)
cases = (
(
{"model_type": "wd14-vit", "general_threshold": 2},
"general_threshold must be between 0 and 1",
),
(
{"model_type": "gguf", "prompt": "x" * 4001},
"instructions must be at most 4000 characters",
),
)
for options, message in cases:
with self.subTest(options=options["model_type"]):
resp = await self._post_json(
"/prompt_manager/autotag/single",
{"path": str(image), **options},
)
self.assertEqual(resp.status, 400)
self.assertEqual(
await resp.json(), {"success": False, "error": message}
)
async def test_prompt_lookup_failure_still_returns_tags(self):
image = self._make_image(self.output_dir)
@@ -1151,7 +1178,9 @@ class TestModelStatus(AutotagAPITestCase):
)
# Kept fields legacy pages and the workspace read.
for model in models.values():
self.assertTrue({"name", "description", "size", "downloaded"} <= set(model))
self.assertLessEqual(
{"name", "description", "size", "downloaded"}, set(model)
)
self.assertTrue(data["model_loaded"])
self.assertEqual(data["loaded_model_type"], "wd14-vit")
-4
View File
@@ -351,8 +351,6 @@ class TestLongJobLock(unittest.TestCase):
self.api = PromptManagerAPI(db=database)
def _run(self, coro):
import asyncio
return asyncio.run(coro)
def test_second_acquire_is_refused_until_first_releases(self):
@@ -825,8 +823,6 @@ class TestInstanceHelpers(FolderPathsFixture):
self.addCleanup(setattr, GalleryConfig, "MONITORING_DIRECTORIES", orig_dirs)
def _run(self, coro):
import asyncio
return asyncio.run(coro)
def test_run_in_executor_with_kwargs(self):
+1 -1
View File
@@ -16,7 +16,7 @@ import re
import sqlite3
import sys
import unittest
from unittest import mock
import unittest.mock as mock
from unittest.mock import MagicMock
_mock_server = MagicMock()
+1 -1
View File
@@ -381,7 +381,7 @@ class TestAnotherMount(SafetyTestCase):
self.assertTrue(other.is_file() and keeper.is_file())
async def test_a_volume_without_file_ids_is_named_truthfully(self):
keeper, other, patches = self.mount("x.png", "x.png")
self.mount("x.png", "x.png")
def zero(path, *args, **kwargs):
return _fake_stat(self.real_stat(path), st_dev=0, st_ino=0)
+1 -1
View File
@@ -3713,7 +3713,7 @@ class TestSharedListing(ImageAPITestCase):
async def test_a_root_read_again_after_the_roots_change_listens_again(self):
make_png(self.output_dir / "a.png")
record = await self._listed()
await self._listed()
self.api.reset_gallery_roots_cache()
record = await self._listed() # still configured: read and walked
self._interval(0)
+1 -1
View File
@@ -675,7 +675,7 @@ class TestBackupPoolAndDiskSpace(ApplyTestCase):
self.assertEqual(len(backups), MAX_SAFETY_BACKUPS)
self.assertNotIn(restore_style[0], backups)
self.assertNotIn(restore_style[1], backups)
self.assertTrue(set(maintenance_style) <= set(backups))
self.assertLessEqual(set(maintenance_style), set(backups))
# A restore prunes the same pool, maintenance backups included.
self.old_backup("prompts.db.backup_20020101_000000", 3000)
_prune_safety_backups(self.api.db.model.db_path)
+1 -1
View File
@@ -11,7 +11,7 @@ import time
import types
import unittest
from pathlib import Path
from unittest import mock
import unittest.mock as mock
try:
from tests.open_handles import assert_closed
+1 -1
View File
@@ -6,7 +6,7 @@ Large frees then go back to the OS instead of fragmenting per-thread arenas.
import ctypes
import sys
import unittest
from unittest import mock
import unittest.mock as mock
from tests.browser import malloc_tuning
-1
View File
@@ -46,7 +46,6 @@ class TestResetWatch(unittest.TestCase):
self.assertFalse(is_reset_path("/__test__/reset/extra"))
def test_a_stalled_reset_reports_state_and_stacks_while_it_waits(self):
stream = io.StringIO()
reported = threading.Event()
class Stream(io.StringIO):
+3 -3
View File
@@ -385,8 +385,8 @@ class TestArchivedReaders(LibraryDeleteCase):
def setUp(self):
super().setUp()
self.kept, _kn, _kp = self.paired("Kept cat", "kept blur")
self.gone, _gn, _gp = self.paired("Gone cat", "gone blur")
self.kept, _, _ = self.paired("Kept cat", "kept blur")
self.gone, _, _ = self.paired("Gone cat", "gone blur")
for pid, category, tag in ((self.kept, "keep", "k"), (self.gone, "gone", "g")):
self.db.update_prompt_metadata(pid, category=category, tags=[tag])
self.apply([self.gone])
@@ -642,7 +642,7 @@ class TestPairedDelete(LibraryDeleteCase):
# The negatives stay, and the image file stays on disk.
self.assertTrue((self.root / "owl.png").exists())
texts = [row[0] for row in rows(self.db, "SELECT text FROM prompts")]
self.assertTrue({"dark", "fog", "Bystander"} <= set(texts))
self.assertLessEqual({"dark", "fog", "Bystander"}, set(texts))
def test_a_negative_used_by_another_positive_is_archived_never_deleted(self):
shared = save(self.db, "Shared")
+1 -1
View File
@@ -14,7 +14,7 @@ import threading
import types
import unittest
from collections import namedtuple
from unittest import mock
import unittest.mock as mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+85 -49
View File
@@ -140,8 +140,10 @@ class TestIdempotentMigrations(MigrationTestCase):
def test_stale_prompts_new_from_a_failed_rebuild_does_not_block_startup(self):
self._legacy_db(
[
"INSERT INTO prompts (text, hash, workflow_name)"
" VALUES ('a', 'h1', 'wf')",
(
"INSERT INTO prompts (text, hash, workflow_name)"
" VALUES ('a', 'h1', 'wf')"
),
"CREATE TABLE prompts_new (id INTEGER PRIMARY KEY, junk TEXT)",
"INSERT INTO prompts_new (junk) VALUES ('leftover')",
]
@@ -157,10 +159,14 @@ class TestIdempotentMigrations(MigrationTestCase):
# DROP TABLE prompts with foreign keys ON cascades into generated_images
self._legacy_db(
[
"INSERT INTO prompts (text, hash, workflow_name)"
" VALUES ('a', 'h1', 'wf')",
"INSERT INTO generated_images (prompt_id, image_path, filename)"
" VALUES (1, '/out/a.png', 'a.png')",
(
"INSERT INTO prompts (text, hash, workflow_name)"
" VALUES ('a', 'h1', 'wf')"
),
(
"INSERT INTO generated_images (prompt_id, image_path, filename)"
" VALUES (1, '/out/a.png', 'a.png')"
),
]
)
@@ -172,11 +178,15 @@ class TestIdempotentMigrations(MigrationTestCase):
def test_rebuild_keeps_library_state_rows(self):
self._legacy_db(
[
"INSERT INTO prompts (text, hash, workflow_name)"
" VALUES ('a', 'h1', 'wf')",
"CREATE TABLE prompt_library_state (prompt_id INTEGER PRIMARY KEY,"
" title TEXT, FOREIGN KEY(prompt_id) REFERENCES prompts(id)"
" ON DELETE CASCADE)",
(
"INSERT INTO prompts (text, hash, workflow_name)"
" VALUES ('a', 'h1', 'wf')"
),
(
"CREATE TABLE prompt_library_state (prompt_id INTEGER PRIMARY KEY,"
" title TEXT, FOREIGN KEY(prompt_id) REFERENCES prompts(id)"
" ON DELETE CASCADE)"
),
"INSERT INTO prompt_library_state VALUES (1, 'kept')",
]
)
@@ -232,43 +242,59 @@ class TestIdempotentMigrations(MigrationTestCase):
byte_id = "6a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d"
self._legacy_db(
[
"INSERT INTO prompts (text, hash, workflow_name)"
" VALUES ('a', 'h1', 'wf')",
"CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT,"
" name TEXT NOT NULL UNIQUE)",
(
"INSERT INTO prompts (text, hash, workflow_name)"
" VALUES ('a', 'h1', 'wf')"
),
(
"CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT,"
" name TEXT NOT NULL UNIQUE)"
),
"INSERT INTO tags (id, name) VALUES (3, 'img-only')",
"CREATE TABLE media_annotation_metadata (singleton_id INTEGER"
" PRIMARY KEY CHECK(singleton_id=1), schema_version INTEGER NOT"
" NULL CHECK(schema_version=1), image_index_revision INTEGER NOT"
" NULL DEFAULT 0 CHECK(image_index_revision>=0))",
(
"CREATE TABLE media_annotation_metadata (singleton_id INTEGER"
" PRIMARY KEY CHECK(singleton_id=1), schema_version INTEGER NOT"
" NULL CHECK(schema_version=1), image_index_revision INTEGER NOT"
" NULL DEFAULT 0 CHECK(image_index_revision>=0))"
),
"INSERT INTO media_annotation_metadata VALUES (1, 1, 4)",
"CREATE TABLE media_annotation_files (id TEXT PRIMARY KEY"
" CHECK(length(id)=36), locator TEXT NOT NULL UNIQUE,"
" created_at TEXT NOT NULL)",
(
"CREATE TABLE media_annotation_files (id TEXT PRIMARY KEY"
" CHECK(length(id)=36), locator TEXT NOT NULL UNIQUE,"
" created_at TEXT NOT NULL)"
),
f"INSERT INTO media_annotation_files VALUES ('{file_id}',"
" '/out/a.png', '2026-10-22T00:00:00.000000+00:00')",
"CREATE TABLE media_annotation_bytes (id TEXT PRIMARY KEY"
" CHECK(length(id)=36), file_id TEXT NOT NULL REFERENCES"
" media_annotation_files(id) ON DELETE CASCADE, sha256 TEXT NOT"
" NULL CHECK(length(sha256)=64 AND sha256 NOT GLOB"
" '*[^0-9a-f]*'), size INTEGER NOT NULL CHECK(size>=0), mtime_ns"
" INTEGER NOT NULL, verified_at TEXT NOT NULL, first_seen_at TEXT"
" NOT NULL, tag_revision INTEGER NOT NULL DEFAULT 0"
" CHECK(tag_revision>=0), UNIQUE(file_id, sha256))",
"CREATE INDEX idx_media_annotation_bytes_fingerprint ON"
" media_annotation_bytes(file_id, size, mtime_ns)",
(
"CREATE TABLE media_annotation_bytes (id TEXT PRIMARY KEY"
" CHECK(length(id)=36), file_id TEXT NOT NULL REFERENCES"
" media_annotation_files(id) ON DELETE CASCADE, sha256 TEXT NOT"
" NULL CHECK(length(sha256)=64 AND sha256 NOT GLOB"
" '*[^0-9a-f]*'), size INTEGER NOT NULL CHECK(size>=0), mtime_ns"
" INTEGER NOT NULL, verified_at TEXT NOT NULL, first_seen_at TEXT"
" NOT NULL, tag_revision INTEGER NOT NULL DEFAULT 0"
" CHECK(tag_revision>=0), UNIQUE(file_id, sha256))"
),
(
"CREATE INDEX idx_media_annotation_bytes_fingerprint ON"
" media_annotation_bytes(file_id, size, mtime_ns)"
),
f"INSERT INTO media_annotation_bytes VALUES ('{byte_id}',"
f" '{file_id}', '{'a' * 64}', 3, 1700000000000000000,"
" '2026-10-22T00:00:00.000000+00:00',"
" '2026-10-22T00:00:00.000000+00:00', 2)",
"CREATE TABLE media_image_tags (byte_id TEXT NOT NULL REFERENCES"
" media_annotation_bytes(id) ON DELETE CASCADE, tag_id INTEGER"
" NOT NULL REFERENCES tags(id) ON DELETE CASCADE, position"
" INTEGER NOT NULL CHECK(position>=0), PRIMARY KEY(byte_id,"
" tag_id))",
(
"CREATE TABLE media_image_tags (byte_id TEXT NOT NULL REFERENCES"
" media_annotation_bytes(id) ON DELETE CASCADE, tag_id INTEGER"
" NOT NULL REFERENCES tags(id) ON DELETE CASCADE, position"
" INTEGER NOT NULL CHECK(position>=0), PRIMARY KEY(byte_id,"
" tag_id))"
),
"CREATE INDEX idx_media_image_tags_tag ON media_image_tags(tag_id)",
"CREATE UNIQUE INDEX idx_media_image_tags_position ON"
" media_image_tags(byte_id, position)",
(
"CREATE UNIQUE INDEX idx_media_image_tags_position ON"
" media_image_tags(byte_id, position)"
),
f"INSERT INTO media_image_tags VALUES ('{byte_id}', 3, 0)",
]
)
@@ -367,18 +393,28 @@ class TestIdempotentMigrations(MigrationTestCase):
def test_image_table_is_rebuilt_with_uniqueness_by_full_path(self):
self._legacy_db(
[
"INSERT INTO prompts (text, hash, workflow_name)"
" VALUES ('a', 'h1', 'wf')",
(
"INSERT INTO prompts (text, hash, workflow_name)"
" VALUES ('a', 'h1', 'wf')"
),
# same basename in two folders: both must survive
"INSERT INTO generated_images (prompt_id, image_path, filename)"
" VALUES (1, '/out/2026-01-01/a.png', 'a.png')",
"INSERT INTO generated_images (prompt_id, image_path, filename)"
" VALUES (1, '/out/2026-01-02/a.png', 'a.png')",
(
"INSERT INTO generated_images (prompt_id, image_path, filename)"
" VALUES (1, '/out/2026-01-01/a.png', 'a.png')"
),
(
"INSERT INTO generated_images (prompt_id, image_path, filename)"
" VALUES (1, '/out/2026-01-02/a.png', 'a.png')"
),
# two spellings of one file: only the newest row survives
"INSERT INTO generated_images (prompt_id, image_path, filename)"
" VALUES (1, '/out/x/../b.png', 'b.png')",
"INSERT INTO generated_images (prompt_id, image_path, filename)"
" VALUES (1, '/out/b.png', 'b.png')",
(
"INSERT INTO generated_images (prompt_id, image_path, filename)"
" VALUES (1, '/out/x/../b.png', 'b.png')"
),
(
"INSERT INTO generated_images (prompt_id, image_path, filename)"
" VALUES (1, '/out/b.png', 'b.png')"
),
]
)
+1 -1
View File
@@ -182,7 +182,7 @@ class TestAdminEndpointsUseConfigPath(unittest.TestCase):
self.assertIsNone(warning)
self.assertEqual(os.path.dirname(snapshot), folder)
with open(snapshot, "rb") as handle:
self.assertTrue(handle.read(16) == b"SQLite format 3\x00")
self.assertEqual(handle.read(16), b"SQLite format 3\x00")
finally:
os.unlink(snapshot)
finally:
+20 -12
View File
@@ -31,6 +31,20 @@ def message(kind, job_id=JOB, **fields):
return (kind, {"prompt_id": job_id, **fields})
def hash_recording_kind():
"""A str subclass for event kinds, and the list of hashes taken of it."""
hashed = []
class OversizedKind(str):
__eq__ = str.__eq__
def __hash__(self):
hashed.append(len(self))
return str.__hash__(self)
return OversizedKind, hashed
def finish(messages, completed=True):
return make_finish(SESSION, 0, EPOCH, Status("success", completed, messages), 500)
@@ -346,13 +360,10 @@ class ExecutionHistoryEventsTests(unittest.TestCase):
self.assertLessEqual(len(encode_command(command)), 2048)
def test_oversized_unknown_kind_is_ignored_without_hashing(self):
class OversizedKind(str):
def __hash__(self):
raise AssertionError("Oversized event kind must not be hashed")
kind_class, hashed = hash_recording_kind()
command = finish(
[
message(OversizedKind("x" * 1_000_000)),
message(kind_class("x" * 1_000_000)),
message("execution_success", timestamp=300),
]
)
@@ -361,19 +372,16 @@ class ExecutionHistoryEventsTests(unittest.TestCase):
(result.outcome, result.reason), ("succeeded", "terminal_success")
)
self.assertLessEqual(len(encode_command(command)), 2048)
self.assertEqual(hashed, [], "Oversized event kind must not be hashed")
def test_encoder_rejects_oversized_kind_without_hashing(self):
class OversizedKind(str):
def __hash__(self):
raise AssertionError("Oversized event kind must not be hashed")
kind_class, hashed = hash_recording_kind()
command = finish([message("execution_success")])
event = LifecycleEvent(
OversizedKind("x" * 1_000_000), JOB, None, None, None, None
)
event = LifecycleEvent(kind_class("x" * 1_000_000), JOB, None, None, None, None)
with self.assertRaises(ValueError):
encode_command(
replace(
command, evidence=TerminalEvidence((event,), False, False, False)
)
)
self.assertEqual(hashed, [], "Oversized event kind must not be hashed")
+1 -1
View File
@@ -17,7 +17,7 @@ class Hostile:
raise AssertionError("host scalar coercion")
def __bool__(self):
raise AssertionError("host truth test")
raise TypeError("host truth test")
def __eq__(self, other):
raise AssertionError("host equality")
+1 -1
View File
@@ -24,7 +24,7 @@ import tempfile
import textwrap
import threading
import unittest
from unittest import mock
import unittest.mock as mock
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
+1 -1
View File
@@ -26,7 +26,7 @@ import types
import unittest
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from unittest import mock
import unittest.mock as mock
from py.api import thumbnail_auto
from utils import image_monitor, parallel
+1 -1
View File
@@ -7,7 +7,7 @@ I/O: history comes in through ``lookup``.
import json
import unittest
from unittest import mock
import unittest.mock as mock
from database.run_attribution import RecordedOutput
from tests.test_pair_scan import flux_graph, sampled_graph
+1 -1
View File
@@ -2,7 +2,7 @@
import logging
import unittest
from unittest import mock
import unittest.mock as mock
from prompt_manager import PromptManager, PromptManagerFocused
from utils.prompt_graph import positive_prompt_nodes, run_prompt_nodes
+1 -1
View File
@@ -312,7 +312,7 @@ class RootContainsTests(unittest.TestCase):
root = os.path.normcase(os.path.realpath(tempfile.gettempdir()))
inside = os.path.join(root, "x.png")
self.assertTrue(root_contains(root, inside))
self.assertTrue(root_contains(root, inside.upper()) == (os.name == "nt"))
self.assertEqual(root_contains(root, inside.upper()), os.name == "nt")
if __name__ == "__main__":
+1 -1
View File
@@ -527,7 +527,7 @@ class TestPackageLoggerIsolation(unittest.TestCase):
def log_from_background_thread():
try:
logging.getLogger("prompt_manager.late_emitter").warning(message)
except BaseException as error:
except Exception as error:
errors.append(error)
self.handler.addFilter(pause_before_handler_lock)
-2
View File
@@ -143,8 +143,6 @@ class TestLibrary(unittest.TestCase):
self.db.get_library_positive(positive, variant_limit=201)
def test_sorts_keysets_survive_anchor_deletion_and_insertions_without_writes(self):
import sqlite3
a = self.save("Alpha", rating=2, tags=["tag"])
b = self.save("beta", rating=5)
c = self.save("charlie")
+1 -1
View File
@@ -297,7 +297,7 @@ class TestLibraryDeleteRoutes(LibraryDeleteRouteCase):
)
page = await response.json()
self.assertEqual(page["archived_total"], 3)
self.assertTrue(set(item["id"] for item in page["items"]) <= set(kept))
self.assertLessEqual(set(item["id"] for item in page["items"]), set(kept))
response = await self.client.request(
"GET",
"/prompt_manager/library?limit=2&shelf=archived"
+1
View File
@@ -135,6 +135,7 @@ class TestLogsAndCache(_EnvCase):
get_logger_manager()._setup_loggers()
except Exception:
# best effort restore in teardown; must not hide the test result
pass
def test_lora_image_cache_follows_the_data_folder(self):
+1 -1
View File
@@ -18,7 +18,7 @@ import tempfile
import threading
import types
import unittest
from unittest import mock
import unittest.mock as mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+1 -1
View File
@@ -22,7 +22,7 @@ import sqlite3
import sys
import threading
import unittest
from unittest import mock
import unittest.mock as mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+11 -11
View File
@@ -1112,10 +1112,10 @@ class LocalProjectionParityTests(unittest.TestCase):
self.assertEqual(_canonical_bytes(local), _canonical_bytes(server))
seen.update(server["record_formats"].values())
seen.add(server["workflow_graph"]["status"])
self.assertTrue(
{"reserialized-json", "original-text", "invalid", "limited"} <= seen
self.assertLessEqual(
{"reserialized-json", "original-text", "invalid", "limited"}, seen
)
self.assertTrue({"parsed", "malformed", "limited"} <= seen)
self.assertLessEqual({"parsed", "malformed", "limited"}, seen)
def _server_evidence(records, file_name=None):
@@ -1287,7 +1287,7 @@ class LocalResolverParityTests(unittest.TestCase):
def test_local_roles_match_selection_record_sets(self):
answers = self._compare(selection_cases.selection_cases())
reasons = {answer["resolution"]["reason"] for answer in answers.values()}
self.assertTrue(
self.assertLessEqual(
{
"Every sampler branch uses the same prompt pair",
"Multiple sampler branches",
@@ -1301,8 +1301,8 @@ class LocalResolverParityTests(unittest.TestCase):
"Negative prompt delimiter is absent",
"Parameters exceed the length limit",
"No A1111 parameters",
}
<= reasons
},
reasons,
)
# A stage reaching only previews is dropped: one sampler is named.
self.assertEqual(
@@ -1352,7 +1352,7 @@ class LocalResolverParityTests(unittest.TestCase):
def test_local_roles_match_golden_record_sets(self):
answers = self._compare(resolver_cases.golden_cases())
codes = {answer["resolution"]["reason_code"] for answer in answers.values()}
self.assertTrue(
self.assertLessEqual(
{
"malformed",
"unsupported-node",
@@ -1364,16 +1364,16 @@ class LocalResolverParityTests(unittest.TestCase):
"graph-ceiling",
"no-sampler",
None,
}
<= codes
},
codes,
)
sources = {
side["source"]
for answer in answers.values()
for side in answer["prompts"].values()
}
self.assertTrue(
{"execution", "graph-inferred", "zeroed-conditioning"} <= sources
self.assertLessEqual(
{"execution", "graph-inferred", "zeroed-conditioning"}, sources
)
def test_committed_resolver_vectors_are_current(self):
+1 -1
View File
@@ -197,7 +197,7 @@ class TestGetPromptSubfolders(LoraDBTestCase):
def test_subfolders_use_forward_slashes_on_every_platform(self):
"""Clients split folders on "/" whatever separator the OS produced."""
from unittest import mock
import unittest.mock as mock
import database.operations as operations
-3
View File
@@ -584,9 +584,6 @@ class CentralizedDiscoveryTests(LayoutTestCase):
def test_an_unlistable_folder_raises(self):
layout = self.load()
def refuse(error):
raise error
def failing_walk(top, onerror=None, followlinks=False):
onerror(PermissionError(13, "denied", top))
return iter(())
+111 -1
View File
@@ -15,7 +15,7 @@ import tempfile
import threading
import unittest
import weakref
from unittest import mock
import unittest.mock as mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -154,6 +154,116 @@ class TestRetire(_Case):
class TestCalls(_Case):
def test_iteration_and_fetches_share_one_position(self) -> None:
conn = self.connect()
conn.executemany("INSERT INTO t VALUES (?)", [(x,) for x in range(1, 260)])
cursor = conn.execute("SELECT x FROM t ORDER BY x")
self.assertIs(iter(cursor), cursor)
self.assertEqual(next(cursor), (0,))
self.assertEqual(cursor.fetchone(), (1,))
self.assertEqual(cursor.fetchmany(2), [(2,), (3,)])
self.assertEqual(next(iter(cursor)), (4,))
self.assertEqual(cursor.fetchall(), [(x,) for x in range(5, 260)])
self.assertEqual(list(cursor), [])
def test_a_partial_loop_can_resume_without_skipping_rows(self) -> None:
cursor = self.connect().execute(
"SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3"
)
for row in cursor:
self.assertEqual(row, (1,))
break
self.assertEqual(list(cursor), [(2,), (3,)])
def test_an_existing_iterator_refuses_reads_after_retire_or_close(self) -> None:
for retire in (False, True):
with self.subTest(retire=retire):
conn = self.connect()
cursor = conn.execute("SELECT 1 UNION ALL SELECT 2")
iterator = iter(cursor)
self.assertEqual(next(iterator), (1,))
if retire:
conn.retire()
else:
cursor.close()
with self.assertRaises(sqlite3.ProgrammingError):
next(iterator)
def test_buffered_fetchmany_matches_sqlite_sizes_and_validation(self) -> None:
conn = self.connect()
conn.executemany("INSERT INTO t VALUES (?)", [(x,) for x in range(1, 260)])
conn.commit()
with contextlib.closing(sqlite3.connect(self.path)) as plain:
for size in (None, 0, 1, 2, 127, 128, 130, 300, -1):
with self.subTest(size=size):
cursors = [
c.execute("SELECT x FROM t ORDER BY x") for c in (plain, conn)
]
for cursor in cursors:
cursor.arraysize = 2
self.assertEqual(next(iter(cursor)), (0,))
results = []
for cursor in cursors:
try:
rows = (
cursor.fetchmany()
if size is None
else cursor.fetchmany(size)
)
except ValueError as error: # Negative sizes on Python 3.14+.
rows = type(error)
results.append((rows, cursor.fetchall()))
self.assertEqual(*results)
for args, kwargs in ((("bad",), {}), ((1, 2), {}), ((), {"unknown": 1})):
cursor = conn.execute("SELECT 1 UNION ALL SELECT 2")
self.assertEqual(next(iter(cursor)), (1,))
with self.assertRaises(TypeError):
cursor.fetchmany(*args, **kwargs)
self.assertEqual(cursor.fetchone(), (2,))
def test_reexecuting_a_cursor_matches_sqlite_result_lifetime(self) -> None:
conn = self.connect()
conn.row_factory = sqlite3.Row
for method, args in (
("execute", ("SELECT 9 AS x",)),
("executemany", ("INSERT INTO t VALUES (?)", [(9,)])),
("executescript", ("SELECT 9 AS x;",)),
):
with self.subTest(method=method):
cursor = conn.execute("SELECT 1 AS x UNION ALL SELECT 2")
self.assertEqual(next(iter(cursor))["x"], 1)
self.assertIs(getattr(cursor, method)(*args), cursor)
if method == "execute":
self.assertEqual(cursor.fetchone()["x"], 9)
elif method == "executescript":
self.assertEqual(cursor.fetchone()["x"], 2)
self.assertEqual(list(cursor), [])
def test_failed_execution_matches_sqlite_result_lifetime(self) -> None:
conn = self.connect()
cases = (
("execute", (None,)),
("execute", ("invalid sql",)),
("execute", ("SELECT ?", ())),
("executemany", (None, [])),
("executemany", ("SELECT 9", [])),
("executescript", (None,)),
("executescript", ("invalid sql;",)),
)
with contextlib.closing(sqlite3.connect(self.path)) as plain:
for method, args in cases:
with self.subTest(method=method, args=args):
results = []
for connection in (plain, conn):
cursor = connection.execute("SELECT 1 UNION ALL SELECT 2")
self.assertEqual(next(iter(cursor)), (1,))
with self.assertRaises(
sqlite3.Error if isinstance(args[0], str) else TypeError
):
getattr(cursor, method)(*args)
results.append(cursor.fetchall())
self.assertEqual(*results)
def test_every_cursor_call_works_and_is_refused_once_retired(self):
conn = self.connect()
conn.row_factory = sqlite3.Row
+1 -1
View File
@@ -347,7 +347,7 @@ class CaptureStoreTestCase(unittest.TestCase):
with patch.object(CaptureStore, "_read", rewriting):
try:
cap = store.capture(self.root, "a.png", epoch=EPOCH, scope=SCOPE)
except CaptureChanged:
except capture_module.CaptureChanged:
detected = True
else:
detected = False
+1 -1
View File
@@ -14,7 +14,7 @@ import sys
import tempfile
import threading
import unittest
from unittest import mock
import unittest.mock as mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+1 -1
View File
@@ -4,7 +4,7 @@ import os
import sys
import time
import unittest
from unittest import mock
import unittest.mock as mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+1 -1
View File
@@ -9,7 +9,7 @@ import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from unittest import mock
import unittest.mock as mock
from utils import inspection_semantics
from utils import prompt_pair_resolver as resolver

Some files were not shown because too many files have changed in this diff Show More