From d88cf9e7c6a5afa68bca05cd9ef742d51f298e5a Mon Sep 17 00:00:00 2001 From: Vito Date: Sat, 31 Oct 2026 05:43:48 +0000 Subject: [PATCH] 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 --- __init__.py | 3 + database/library_move.py | 1 + database/managed_connection.py | 84 ++++++++--- database/operations.py | 2 +- database/safety_backup.py | 1 + database/vocabulary.py | 1 - py/api/admin.py | 3 +- py/api/annotation_suggest.py | 1 - py/api/autotag_routes.py | 19 +-- py/api/backup_restore.py | 1 + py/api/duplicate_files.py | 3 +- py/api/duplicate_spellings.py | 2 +- py/api/image_delete.py | 3 +- py/api/image_tag_jobs.py | 12 +- py/api/images.py | 9 +- py/api/job_routes.py | 2 +- py/api/library_location.py | 2 + py/api/log_cleanup.py | 1 + py/api/lora_import_routes.py | 8 +- py/api/lora_integration.py | 1 + py/api/maintenance.py | 1 + py/api/thumbnail_auto.py | 1 + py/api/thumbnail_encode.py | 4 + py/api/workflow_storage.py | 1 + py/config_store.py | 1 + py/lora_manager_layout.py | 1 + py/lora_utils.py | 2 + tests/browser/activity-concept.spec.mjs | 1 - tests/browser/slow_reset_watch.py | 1 + tests/browser/workspace-saved-views.spec.mjs | 2 +- .../browser/workspace-sticky-chrome.spec.mjs | 2 +- .../workspace-system-diagnostics.spec.mjs | 1 - tests/js/legacy-refusals.test.js | 10 -- tests/test_api_admin.py | 2 +- tests/test_api_autotag.py | 31 +++- tests/test_api_core.py | 4 - tests/test_api_diagnostics_report.py | 2 +- tests/test_api_duplicate_safety.py | 2 +- tests/test_api_images.py | 2 +- tests/test_api_maintenance.py | 2 +- tests/test_browser_fast_reset.py | 2 +- tests/test_browser_malloc_tuning.py | 2 +- tests/test_browser_slow_reset_watch.py | 1 - tests/test_bulk_library_delete.py | 6 +- tests/test_cached_runs.py | 2 +- tests/test_database_migrations.py | 134 +++++++++++------- tests/test_diagnostics_config.py | 2 +- tests/test_execution_history_events.py | 32 +++-- tests/test_execution_output_capture.py | 2 +- tests/test_exit_cancel.py | 2 +- tests/test_exit_window_threads.py | 2 +- tests/test_file_prompt.py | 2 +- tests/test_focused_node.py | 2 +- tests/test_gallery_listing.py | 2 +- tests/test_init_package.py | 2 +- tests/test_library.py | 2 - tests/test_library_delete_routes.py | 2 +- tests/test_library_location_wiring.py | 1 + tests/test_library_move.py | 2 +- tests/test_library_move_review.py | 2 +- tests/test_local_inspect_parity.py | 22 +-- tests/test_lora_database.py | 2 +- tests/test_lora_manager_layout.py | 3 - tests/test_managed_connection.py | 112 ++++++++++++++- tests/test_media_capture.py | 2 +- tests/test_prompt_attribution.py | 2 +- tests/test_prompt_graph.py | 2 +- tests/test_prompt_pair_resolver.py | 2 +- tests/test_prompt_usage.py | 2 +- tests/test_queue_observers.py | 14 +- tests/test_restore_plans.py | 4 +- tests/test_review_image_links_tool.py | 2 +- tests/test_review_inspection.py | 8 +- tests/test_review_saver_anchor.py | 4 +- tests/test_run_attribution.py | 5 +- tests/test_save_to_library.py | 2 +- tests/test_scanned_prompts.py | 2 +- tests/test_thumbnail_jobs.py | 2 +- tests/test_trash_freedesktop.py | 2 +- tests/test_trash_macos.py | 2 +- tests/test_utils_logging_config.py | 2 +- tests/test_utils_video_metadata.py | 2 +- tests/test_workspace_system_fixture.py | 2 +- tools/repair_foreign_links.py | 6 +- utils/diagnostics.py | 11 +- utils/execution_history_writer.py | 1 + utils/image_monitor.py | 8 +- utils/library_location.py | 1 + utils/media_capture.py | 2 + utils/trash.py | 1 + utils/trash_macos.py | 1 + utils/workspace_keys.py | 3 + web/comfy/prompt_manager.js | 2 - web/js/admin.js | 2 - web/js/gallery.js | 1 - web/js/workspace/file-inspector.mjs | 1 - web/js/workspace/local-resolver-support.mjs | 2 +- web/js/workspace/local-resolver.mjs | 1 - 98 files changed, 452 insertions(+), 229 deletions(-) diff --git a/__init__.py b/__init__.py index 41c6fba7..7482e606 100644 --- a/__init__.py +++ b/__init__.py @@ -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"] diff --git a/database/library_move.py b/database/library_move.py index 91dc7274..c104ac15 100644 --- a/database/library_move.py +++ b/database/library_move.py @@ -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 diff --git a/database/managed_connection.py b/database/managed_connection.py index fb71fc6d..49e58390 100644 --- a/database/managed_connection.py +++ b/database/managed_connection.py @@ -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() diff --git a/database/operations.py b/database/operations.py index 51607876..2e1dca0b 100644 --- a/database/operations.py +++ b/database/operations.py @@ -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, diff --git a/database/safety_backup.py b/database/safety_backup.py index ad212a14..5ce36619 100644 --- a/database/safety_backup.py +++ b/database/safety_backup.py @@ -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 diff --git a/database/vocabulary.py b/database/vocabulary.py index 971112bd..206d13f6 100644 --- a/database/vocabulary.py +++ b/database/vocabulary.py @@ -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 = { diff --git a/py/api/admin.py b/py/api/admin.py index 64768544..3488a056 100644 --- a/py/api/admin.py +++ b/py/api/admin.py @@ -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 = [] diff --git a/py/api/annotation_suggest.py b/py/api/annotation_suggest.py index 9411640b..bb676d92 100644 --- a/py/api/annotation_suggest.py +++ b/py/api/annotation_suggest.py @@ -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, diff --git a/py/api/autotag_routes.py b/py/api/autotag_routes.py index 388e5e3a..9e82d30d 100644 --- a/py/api/autotag_routes.py +++ b/py/api/autotag_routes.py @@ -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 diff --git a/py/api/backup_restore.py b/py/api/backup_restore.py index a83ecbfa..ab3d030a 100644 --- a/py/api/backup_restore.py +++ b/py/api/backup_restore.py @@ -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): diff --git a/py/api/duplicate_files.py b/py/api/duplicate_files.py index 34e15ed4..9a844253 100644 --- a/py/api/duplicate_files.py +++ b/py/api/duplicate_files.py @@ -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"] diff --git a/py/api/duplicate_spellings.py b/py/api/duplicate_spellings.py index 7157e994..1e6d2aff 100644 --- a/py/api/duplicate_spellings.py +++ b/py/api/duplicate_spellings.py @@ -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)))) diff --git a/py/api/image_delete.py b/py/api/image_delete.py index 862cda26..b81b8497 100644 --- a/py/api/image_delete.py +++ b/py/api/image_delete.py @@ -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]) diff --git a/py/api/image_tag_jobs.py b/py/api/image_tag_jobs.py index c16751b0..33b679c6 100644 --- a/py/api/image_tag_jobs.py +++ b/py/api/image_tag_jobs.py @@ -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 diff --git a/py/api/images.py b/py/api/images.py index 2ded1782..afa4f319 100644 --- a/py/api/images.py +++ b/py/api/images.py @@ -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 diff --git a/py/api/job_routes.py b/py/api/job_routes.py index 82dcbfcb..ce013446 100644 --- a/py/api/job_routes.py +++ b/py/api/job_routes.py @@ -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)) diff --git a/py/api/library_location.py b/py/api/library_location.py index 8c4ec324..17d172c7 100644 --- a/py/api/library_location.py +++ b/py/api/library_location.py @@ -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): diff --git a/py/api/log_cleanup.py b/py/api/log_cleanup.py index f6248388..c1c2cd41 100644 --- a/py/api/log_cleanup.py +++ b/py/api/log_cleanup.py @@ -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 diff --git a/py/api/lora_import_routes.py b/py/api/lora_import_routes.py index a09ec689..f6eec8c6 100644 --- a/py/api/lora_import_routes.py +++ b/py/api/lora_import_routes.py @@ -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 diff --git a/py/api/lora_integration.py b/py/api/lora_integration.py index 3531d4a2..86370f5b 100644 --- a/py/api/lora_integration.py +++ b/py/api/lora_integration.py @@ -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 diff --git a/py/api/maintenance.py b/py/api/maintenance.py index e7d34020..d93a8973 100644 --- a/py/api/maintenance.py +++ b/py/api/maintenance.py @@ -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).""" diff --git a/py/api/thumbnail_auto.py b/py/api/thumbnail_auto.py index 05af0c2d..5cd662b7 100644 --- a/py/api/thumbnail_auto.py +++ b/py/api/thumbnail_auto.py @@ -533,6 +533,7 @@ class AutoThumbnailer: try: process.kill() except OSError: + # the process has already exited pass if worker is None: return True diff --git a/py/api/thumbnail_encode.py b/py/api/thumbnail_encode.py index bcbee3ef..f7a6ca54 100644 --- a/py/api/thumbnail_encode.py +++ b/py/api/thumbnail_encode.py @@ -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) diff --git a/py/api/workflow_storage.py b/py/api/workflow_storage.py index 753c197b..fde4be76 100644 --- a/py/api/workflow_storage.py +++ b/py/api/workflow_storage.py @@ -275,3 +275,4 @@ class WorkflowStorageRoutesMixin: job.record_counted("changed", 1) job.log_line(line) job.update(progress=100, status_line=line) + return None diff --git a/py/config_store.py b/py/config_store.py index 949423a3..0664f026 100644 --- a/py/config_store.py +++ b/py/config_store.py @@ -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 diff --git a/py/lora_manager_layout.py b/py/lora_manager_layout.py index d05abe59..dba820b3 100644 --- a/py/lora_manager_layout.py +++ b/py/lora_manager_layout.py @@ -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, diff --git a/py/lora_utils.py b/py/lora_utils.py index da71a3c9..4c8f6de2 100644 --- a/py/lora_utils.py +++ b/py/lora_utils.py @@ -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 diff --git a/tests/browser/activity-concept.spec.mjs b/tests/browser/activity-concept.spec.mjs index 9dd4e646..443a6c0e 100644 --- a/tests/browser/activity-concept.spec.mjs +++ b/tests/browser/activity-concept.spec.mjs @@ -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); diff --git a/tests/browser/slow_reset_watch.py b/tests/browser/slow_reset_watch.py index e3b453b6..3ff6a85f 100644 --- a/tests/browser/slow_reset_watch.py +++ b/tests/browser/slow_reset_watch.py @@ -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 diff --git a/tests/browser/workspace-saved-views.spec.mjs b/tests/browser/workspace-saved-views.spec.mjs index 1b216a4d..69258546 100644 --- a/tests/browser/workspace-saved-views.spec.mjs +++ b/tests/browser/workspace-saved-views.spec.mjs @@ -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 => { diff --git a/tests/browser/workspace-sticky-chrome.spec.mjs b/tests/browser/workspace-sticky-chrome.spec.mjs index 88323a0f..eec48a2b 100644 --- a/tests/browser/workspace-sticky-chrome.spec.mjs +++ b/tests/browser/workspace-sticky-chrome.spec.mjs @@ -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' : ''; diff --git a/tests/browser/workspace-system-diagnostics.spec.mjs b/tests/browser/workspace-system-diagnostics.spec.mjs index 2f4cc07a..8f8cf38c 100644 --- a/tests/browser/workspace-system-diagnostics.spec.mjs +++ b/tests/browser/workspace-system-diagnostics.spec.mjs @@ -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'; diff --git a/tests/js/legacy-refusals.test.js b/tests/js/legacy-refusals.test.js index 78d15c9b..23f6cf0e 100644 --- a/tests/js/legacy-refusals.test.js +++ b/tests/js/legacy-refusals.test.js @@ -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', diff --git a/tests/test_api_admin.py b/tests/test_api_admin.py index 755a5369..98f424cc 100644 --- a/tests/test_api_admin.py +++ b/tests/test_api_admin.py @@ -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 diff --git a/tests/test_api_autotag.py b/tests/test_api_autotag.py index 4b009ba8..1ef568b2 100644 --- a/tests/test_api_autotag.py +++ b/tests/test_api_autotag.py @@ -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") diff --git a/tests/test_api_core.py b/tests/test_api_core.py index 7d7d1958..96f17862 100644 --- a/tests/test_api_core.py +++ b/tests/test_api_core.py @@ -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): diff --git a/tests/test_api_diagnostics_report.py b/tests/test_api_diagnostics_report.py index 8b6ef318..be8b3c7d 100644 --- a/tests/test_api_diagnostics_report.py +++ b/tests/test_api_diagnostics_report.py @@ -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() diff --git a/tests/test_api_duplicate_safety.py b/tests/test_api_duplicate_safety.py index 8ae50edb..402b48ab 100644 --- a/tests/test_api_duplicate_safety.py +++ b/tests/test_api_duplicate_safety.py @@ -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) diff --git a/tests/test_api_images.py b/tests/test_api_images.py index c1772f66..580fb12a 100644 --- a/tests/test_api_images.py +++ b/tests/test_api_images.py @@ -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) diff --git a/tests/test_api_maintenance.py b/tests/test_api_maintenance.py index 749a3af3..f700aa64 100644 --- a/tests/test_api_maintenance.py +++ b/tests/test_api_maintenance.py @@ -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) diff --git a/tests/test_browser_fast_reset.py b/tests/test_browser_fast_reset.py index c23fae43..971c40ba 100644 --- a/tests/test_browser_fast_reset.py +++ b/tests/test_browser_fast_reset.py @@ -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 diff --git a/tests/test_browser_malloc_tuning.py b/tests/test_browser_malloc_tuning.py index d578fc9c..4345f064 100644 --- a/tests/test_browser_malloc_tuning.py +++ b/tests/test_browser_malloc_tuning.py @@ -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 diff --git a/tests/test_browser_slow_reset_watch.py b/tests/test_browser_slow_reset_watch.py index 569983b8..3d211c65 100644 --- a/tests/test_browser_slow_reset_watch.py +++ b/tests/test_browser_slow_reset_watch.py @@ -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): diff --git a/tests/test_bulk_library_delete.py b/tests/test_bulk_library_delete.py index 7fce13e1..7c1aa7b5 100644 --- a/tests/test_bulk_library_delete.py +++ b/tests/test_bulk_library_delete.py @@ -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") diff --git a/tests/test_cached_runs.py b/tests/test_cached_runs.py index bd52416c..ed726220 100644 --- a/tests/test_cached_runs.py +++ b/tests/test_cached_runs.py @@ -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__)))) diff --git a/tests/test_database_migrations.py b/tests/test_database_migrations.py index 544d0fb9..47a67b13 100644 --- a/tests/test_database_migrations.py +++ b/tests/test_database_migrations.py @@ -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')" + ), ] ) diff --git a/tests/test_diagnostics_config.py b/tests/test_diagnostics_config.py index 2fca7ac4..e3cfb3e1 100644 --- a/tests/test_diagnostics_config.py +++ b/tests/test_diagnostics_config.py @@ -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: diff --git a/tests/test_execution_history_events.py b/tests/test_execution_history_events.py index 76a1474f..f2742e6b 100644 --- a/tests/test_execution_history_events.py +++ b/tests/test_execution_history_events.py @@ -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") diff --git a/tests/test_execution_output_capture.py b/tests/test_execution_output_capture.py index 4bf86b9d..423bc26f 100644 --- a/tests/test_execution_output_capture.py +++ b/tests/test_execution_output_capture.py @@ -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") diff --git a/tests/test_exit_cancel.py b/tests/test_exit_cancel.py index e3fb5ba5..5eeab498 100644 --- a/tests/test_exit_cancel.py +++ b/tests/test_exit_cancel.py @@ -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) diff --git a/tests/test_exit_window_threads.py b/tests/test_exit_window_threads.py index 1323f4db..30e39bdd 100644 --- a/tests/test_exit_window_threads.py +++ b/tests/test_exit_window_threads.py @@ -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 diff --git a/tests/test_file_prompt.py b/tests/test_file_prompt.py index c4dadfed..8b525659 100644 --- a/tests/test_file_prompt.py +++ b/tests/test_file_prompt.py @@ -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 diff --git a/tests/test_focused_node.py b/tests/test_focused_node.py index 5e9a2ef1..3e7bbd8f 100644 --- a/tests/test_focused_node.py +++ b/tests/test_focused_node.py @@ -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 diff --git a/tests/test_gallery_listing.py b/tests/test_gallery_listing.py index ca796bfe..0e2ebf7f 100644 --- a/tests/test_gallery_listing.py +++ b/tests/test_gallery_listing.py @@ -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__": diff --git a/tests/test_init_package.py b/tests/test_init_package.py index 43661084..01ab75b5 100644 --- a/tests/test_init_package.py +++ b/tests/test_init_package.py @@ -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) diff --git a/tests/test_library.py b/tests/test_library.py index 24540f24..e7585639 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -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") diff --git a/tests/test_library_delete_routes.py b/tests/test_library_delete_routes.py index 1582cbc7..4e3b1bf0 100644 --- a/tests/test_library_delete_routes.py +++ b/tests/test_library_delete_routes.py @@ -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" diff --git a/tests/test_library_location_wiring.py b/tests/test_library_location_wiring.py index d2fa31af..a0087b30 100644 --- a/tests/test_library_location_wiring.py +++ b/tests/test_library_location_wiring.py @@ -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): diff --git a/tests/test_library_move.py b/tests/test_library_move.py index 9a450fde..868da97d 100644 --- a/tests/test_library_move.py +++ b/tests/test_library_move.py @@ -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__)))) diff --git a/tests/test_library_move_review.py b/tests/test_library_move_review.py index 57f61c31..1d1fd60d 100644 --- a/tests/test_library_move_review.py +++ b/tests/test_library_move_review.py @@ -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__)))) diff --git a/tests/test_local_inspect_parity.py b/tests/test_local_inspect_parity.py index e5ab5dde..e8c45d55 100644 --- a/tests/test_local_inspect_parity.py +++ b/tests/test_local_inspect_parity.py @@ -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): diff --git a/tests/test_lora_database.py b/tests/test_lora_database.py index 648b8213..8a913f0c 100644 --- a/tests/test_lora_database.py +++ b/tests/test_lora_database.py @@ -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 diff --git a/tests/test_lora_manager_layout.py b/tests/test_lora_manager_layout.py index 8af50902..adb0f4f2 100644 --- a/tests/test_lora_manager_layout.py +++ b/tests/test_lora_manager_layout.py @@ -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(()) diff --git a/tests/test_managed_connection.py b/tests/test_managed_connection.py index b33d437a..c5b13542 100644 --- a/tests/test_managed_connection.py +++ b/tests/test_managed_connection.py @@ -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 diff --git a/tests/test_media_capture.py b/tests/test_media_capture.py index f4cc0b53..e4790f1e 100644 --- a/tests/test_media_capture.py +++ b/tests/test_media_capture.py @@ -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 diff --git a/tests/test_prompt_attribution.py b/tests/test_prompt_attribution.py index 7cf3d101..229ff860 100644 --- a/tests/test_prompt_attribution.py +++ b/tests/test_prompt_attribution.py @@ -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__)))) diff --git a/tests/test_prompt_graph.py b/tests/test_prompt_graph.py index 1e6ffdac..1329d08b 100644 --- a/tests/test_prompt_graph.py +++ b/tests/test_prompt_graph.py @@ -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__)))) diff --git a/tests/test_prompt_pair_resolver.py b/tests/test_prompt_pair_resolver.py index f739d893..2c8f4388 100644 --- a/tests/test_prompt_pair_resolver.py +++ b/tests/test_prompt_pair_resolver.py @@ -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 diff --git a/tests/test_prompt_usage.py b/tests/test_prompt_usage.py index 41bcc7b6..4c85b637 100644 --- a/tests/test_prompt_usage.py +++ b/tests/test_prompt_usage.py @@ -286,7 +286,7 @@ class TestNodeRecordsCachedRunReceipt(UsageTestCase): } def _node(self): - from unittest import mock + import unittest.mock as mock from prompt_manager_text import PromptManagerText diff --git a/tests/test_queue_observers.py b/tests/test_queue_observers.py index e42b520e..44577a75 100644 --- a/tests/test_queue_observers.py +++ b/tests/test_queue_observers.py @@ -13,19 +13,19 @@ class FakeQueue: def __init__(self): self.result = ((0, "job", {}, {}, [], {}), 0) self.calls = [] - self.failure = None + self.failures = () self.done_result = object() def get(self, *args, **kwargs): self.calls.append(("get", args, kwargs)) - if self.failure: - raise self.failure + for failure in self.failures: + raise failure return self.result def task_done(self, *args, **kwargs): self.calls.append(("done", args, kwargs)) - if self.failure: - raise self.failure + for failure in self.failures: + raise failure return self.done_result @@ -46,7 +46,7 @@ class QueueObserversTests(unittest.TestCase): self.assertIsNone(host.get()) self.assertEqual(seen, [original]) for error in (queue.Empty(), RuntimeError("host failure")): - host.failure = error + host.failures = (error,) with self.assertRaises(type(error)) as raised: host.get(timeout=sentinel) self.assertIs(raised.exception, error) @@ -59,7 +59,7 @@ class QueueObserversTests(unittest.TestCase): self.assertIs(host.task_done(0, sentinel, status=sentinel), host.done_result) self.assertEqual(seen, [((0, sentinel), {"status": sentinel})]) error = ValueError("host failure") - host.failure = error + host.failures = (error,) with self.assertRaises(ValueError) as raised: host.task_done(1) self.assertIs(raised.exception, error) diff --git a/tests/test_restore_plans.py b/tests/test_restore_plans.py index f5825169..b9be6087 100644 --- a/tests/test_restore_plans.py +++ b/tests/test_restore_plans.py @@ -357,7 +357,7 @@ class TestRestoreAdmission(RestoreTestCase): gate.set() await health.task - restore, release = self.gated_job("restore", "exclusive", RESTORE_JOB_TITLE) + self.gated_job("restore", "exclusive", RESTORE_JOB_TITLE) resp = await self.client.post("/prompt_manager/system/health") refusal = await resp.json() self.assertEqual((resp.status, refusal["code"]), (409, "busy")) @@ -369,7 +369,7 @@ class TestRestoreAdmission(RestoreTestCase): self.assertEqual(body["running"]["title"], "Move library") self.api._jobs.list()[0].finish() - body = await self.preview(self.upload(["restored"])) + await self.preview(self.upload(["restored"])) self.gated_job("restore", "exclusive", RESTORE_JOB_TITLE) resp = await self.client.post("/prompt_manager/system/library/move") self.assertEqual(resp.status, 409) diff --git a/tests/test_review_image_links_tool.py b/tests/test_review_image_links_tool.py index 0e52fdf0..9d698500 100644 --- a/tests/test_review_image_links_tool.py +++ b/tests/test_review_image_links_tool.py @@ -116,7 +116,7 @@ class ReviewToolTests(ToolFixture): self.assertEqual(code, 0) (verdict,) = json.loads(out.split("--apply, for")[0]) self.assertEqual(verdict["verdict"], "manual") - code, _out = self.run_tool("--ids", str(self.guessed[0]), "--apply") + self.run_tool("--ids", str(self.guessed[0]), "--apply") self.assertEqual(self.links(), before) def test_prompt_selection_and_text_report(self): diff --git a/tests/test_review_inspection.py b/tests/test_review_inspection.py index ea0774a1..e42bd307 100644 --- a/tests/test_review_inspection.py +++ b/tests/test_review_inspection.py @@ -337,7 +337,7 @@ class TestDefaultProfileStillDecides(InspectionTestCase): side_effect=AssertionError("inspection choices were asked"), ), ): - _item, (status, data) = await self._apply("cat") + _, (status, data) = await self._apply("cat") self.assertEqual(status, 200, data) # The saver anchor check (#390): with the file's name, nothing matches. self.assertEqual([c[1] for c in calls], ["two.png"]) @@ -383,7 +383,7 @@ class TestApplyInspectionCandidate(InspectionTestCase): return real(metadata, file_name) with patch.object(review_capture, "resolve_inspection", side_effect=spy): - _item, (status, data) = await self._apply("make it night") + _, (status, data) = await self._apply("make it night") self.assertEqual(status, 200, data) self.assertEqual(len(calls), 1) @@ -566,7 +566,7 @@ class TestVersionChanges(InspectionTestCase): self.assertNotEqual(item["resolver_version"], old["resolver_version"]) self.assertEqual(job["scope"]["review_items"], 1) self.assertEqual(self._pending(), 1) - _item, (status, data) = await self._apply("make it night") + _, (status, data) = await self._apply("make it night") self.assertEqual(status, 200, data) self.assertEqual(self._linked_text(path), "make it night") @@ -885,7 +885,7 @@ class TestInspectionResolverVersion(InspectionTestCase): await self._scan("all") (item,) = self._items() self.assertNotEqual(item["id"], old["id"]) - _item, (status, data) = await self._apply("add a red hat") + _, (status, data) = await self._apply("add a red hat") self.assertEqual(status, 200, data) self.assertEqual(self._linked_text(path), "add a red hat") diff --git a/tests/test_review_saver_anchor.py b/tests/test_review_saver_anchor.py index c61d8112..227cda50 100644 --- a/tests/test_review_saver_anchor.py +++ b/tests/test_review_saver_anchor.py @@ -169,7 +169,7 @@ class TestInspectionItemsAnchor(InspectionTestCase): path = self._write("branch_1_00001_.png", graph=branch_graph(*PROMPTS)) await self._scan() - _item, (status, data) = await self._apply("add a red hat") + _, (status, data) = await self._apply("add a red hat") self.assertEqual(status, 200, data) self.assertEqual(self._linked_text(path), "add a red hat") @@ -316,7 +316,7 @@ class TestDefaultItemsAnchor(InspectionTestCase): with patch.object(review_capture, "_captured_inspection", return_value=None): await self._scan() - _item, (status, data) = await self._apply("cat") + _, (status, data) = await self._apply("cat") self.assertEqual(status, 200, data) self.assertEqual(self._linked_text(path), "cat") diff --git a/tests/test_run_attribution.py b/tests/test_run_attribution.py index 63d2199b..6ecc9868 100644 --- a/tests/test_run_attribution.py +++ b/tests/test_run_attribution.py @@ -7,7 +7,7 @@ import threading import time import unittest from pathlib import Path -from unittest import mock +import unittest.mock as mock from database.run_attribution import ( RecordedOutput, @@ -86,11 +86,10 @@ class RecordedRuns: class TestStoredRunPrompt(RecordedRuns, unittest.TestCase): - setUp = history_tests.HistoryDatabaseTests.setUp close_databases = history_tests.HistoryDatabaseTests.close_databases database = history_tests.HistoryDatabaseTests.database - def setUp(self): # noqa: F811 - the shared temp dir and database list first + def setUp(self): # the shared temp dir and database list first history_tests.HistoryDatabaseTests.setUp(self) self.db = self.database() self.output = str(Path(self.temp.name) / "output") diff --git a/tests/test_save_to_library.py b/tests/test_save_to_library.py index 3d8bee47..0a28a15c 100644 --- a/tests/test_save_to_library.py +++ b/tests/test_save_to_library.py @@ -10,7 +10,7 @@ import os import sys import unittest from pathlib import Path -from unittest import mock +import unittest.mock as mock sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) diff --git a/tests/test_scanned_prompts.py b/tests/test_scanned_prompts.py index c256e6c2..009bb4b7 100644 --- a/tests/test_scanned_prompts.py +++ b/tests/test_scanned_prompts.py @@ -7,7 +7,7 @@ import threading import unittest from concurrent.futures import ThreadPoolExecutor from pathlib import Path -from unittest import mock +import unittest.mock as mock try: from tests.open_handles import assert_closed diff --git a/tests/test_thumbnail_jobs.py b/tests/test_thumbnail_jobs.py index cda1832f..a74680e5 100644 --- a/tests/test_thumbnail_jobs.py +++ b/tests/test_thumbnail_jobs.py @@ -894,7 +894,7 @@ class TestLegacyStreamOnTheJob(ThumbnailJobCase): self.assertEqual(resp.status, 200) await self._wait(first) job = self.api._jobs.active("thumbnails") - _subscribed, state["left"] = self._watch_subscribers(job) + _, state["left"] = self._watch_subscribers(job) resp.close() # the gallery tab went away closed.set() await self._wait(state["left"]) # the server saw the client go diff --git a/tests/test_trash_freedesktop.py b/tests/test_trash_freedesktop.py index 10f88741..22b2ca91 100644 --- a/tests/test_trash_freedesktop.py +++ b/tests/test_trash_freedesktop.py @@ -12,7 +12,7 @@ import stat import sys import tempfile 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__)))) diff --git a/tests/test_trash_macos.py b/tests/test_trash_macos.py index 3b2cb185..7ede45c3 100644 --- a/tests/test_trash_macos.py +++ b/tests/test_trash_macos.py @@ -12,7 +12,7 @@ import sys import tempfile import unittest from types import SimpleNamespace -from unittest import mock +import unittest.mock as mock sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) diff --git a/tests/test_utils_logging_config.py b/tests/test_utils_logging_config.py index d64ee51e..4d1cf609 100644 --- a/tests/test_utils_logging_config.py +++ b/tests/test_utils_logging_config.py @@ -998,7 +998,7 @@ class TestConfiguration(IsolatedLoggerTestCase): def log_from_background_thread() -> None: try: self.manager.logger.info(message) - except BaseException as error: + except Exception as error: errors.append(error) # Allow this record through the fixture's normal main-thread filter. diff --git a/tests/test_utils_video_metadata.py b/tests/test_utils_video_metadata.py index f8918780..84222d93 100644 --- a/tests/test_utils_video_metadata.py +++ b/tests/test_utils_video_metadata.py @@ -15,7 +15,7 @@ import subprocess import sys import tempfile 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__)))) diff --git a/tests/test_workspace_system_fixture.py b/tests/test_workspace_system_fixture.py index 23189560..58f68fed 100644 --- a/tests/test_workspace_system_fixture.py +++ b/tests/test_workspace_system_fixture.py @@ -8,7 +8,7 @@ import tempfile import types import unittest from pathlib import Path -from unittest import mock +import unittest.mock as mock from aiohttp import web from aiohttp.test_utils import TestClient, TestServer diff --git a/tools/repair_foreign_links.py b/tools/repair_foreign_links.py index fe75611d..f710999e 100644 --- a/tools/repair_foreign_links.py +++ b/tools/repair_foreign_links.py @@ -506,8 +506,10 @@ def markdown(plan, prompt_texts): "", f"## Relink ({sum(i['action'] == 'relink' for i in rows)})", "", - "| row | file | linked prompt | linked text | target | target hash " - "| target text |", + ( + "| row | file | linked prompt | linked text | target | target hash " + "| target text |" + ), "|---:|---|---:|---|---|---|---|", ] for item in (i for i in rows if i["action"] == "relink"): diff --git a/utils/diagnostics.py b/utils/diagnostics.py index 61172ece..e532e9d3 100644 --- a/utils/diagnostics.py +++ b/utils/diagnostics.py @@ -386,14 +386,9 @@ class GalleryDiagnostics: except ImportError: self.logger.error(" [FAIL] PIL (Pillow): NOT AVAILABLE") - # Check sqlite3 - try: - import sqlite3 - - dependencies["sqlite3"] = True - self.logger.info(f" [PASS] sqlite3: {sqlite3.sqlite_version}") - except ImportError: - self.logger.error(" [FAIL] sqlite3: NOT AVAILABLE") + # sqlite3 is imported at the top of this module, so it is always here. + dependencies["sqlite3"] = True + self.logger.info(f" [PASS] sqlite3: {sqlite3.sqlite_version}") all_deps_ok = all(dependencies.values()) diff --git a/utils/execution_history_writer.py b/utils/execution_history_writer.py index 248b49de..715bfdad 100644 --- a/utils/execution_history_writer.py +++ b/utils/execution_history_writer.py @@ -398,6 +398,7 @@ class HistoryWriter: try: command = self._queue.get_nowait() except queue.Empty: + # no command waiting; the loop below handles command None pass if probe: try: diff --git a/utils/image_monitor.py b/utils/image_monitor.py index 5b016c7b..452bf104 100644 --- a/utils/image_monitor.py +++ b/utils/image_monitor.py @@ -1048,8 +1048,8 @@ class ImageGenerationHandler(FileSystemEventHandler): with Image.open(image_path) as img: file_info["dimensions"] = list(img.size) file_info["format"] = img.format - except Exception: - pass + except Exception as error: + self.logger.debug(f"No dimensions for {image_path}: {error}") return file_info except Exception as e: @@ -1424,8 +1424,8 @@ class ImageMonitor: if self.observer is not None: try: observer_alive = self.observer.is_alive() - except Exception: - pass + except Exception as error: + self.logger.debug(f"Could not read the observer state: {error}") return { "running": self.observer is not None, "monitored_directories": self.monitored_directories, diff --git a/utils/library_location.py b/utils/library_location.py index 4a69b6ab..0e637992 100644 --- a/utils/library_location.py +++ b/utils/library_location.py @@ -466,6 +466,7 @@ def ensure_private_dir(path: str) -> str: try: os.chmod(path, 0o700) except OSError: + # the folder exists; a filesystem without modes keeps its own pass return path diff --git a/utils/media_capture.py b/utils/media_capture.py index b826bc85..c978305b 100644 --- a/utils/media_capture.py +++ b/utils/media_capture.py @@ -181,6 +181,7 @@ def _unlink_quietly(path: str) -> None: try: os.unlink(path) except FileNotFoundError: + # already gone, which is what was wanted pass except OSError as error: logger.warning("Capture file %s could not be removed: %s", path, error) @@ -390,6 +391,7 @@ class CaptureStore: try: os.rmdir(self.directory) except FileNotFoundError: + # already gone, which is what was wanted pass except OSError as error: logger.warning( diff --git a/utils/trash.py b/utils/trash.py index 7fcf5121..309c6207 100644 --- a/utils/trash.py +++ b/utils/trash.py @@ -339,6 +339,7 @@ class FreedesktopBackend: try: os.mkdir(name, 0o700, dir_fd=parent_fd) except FileExistsError: + # the folder already exists; the checks below still vet it pass except OSError as error: raise TrashRefused(NO_TRASH, str(error)) from error diff --git a/utils/trash_macos.py b/utils/trash_macos.py index 4ce422da..0a7644a6 100644 --- a/utils/trash_macos.py +++ b/utils/trash_macos.py @@ -114,6 +114,7 @@ def inside_any_root(path: str, roots) -> bool: if _identity(current) in identities: return True except OSError: + # an unreadable folder cannot be a trash; try its parent pass parent = os.path.dirname(current) if parent == current: diff --git a/utils/workspace_keys.py b/utils/workspace_keys.py index 5087c53b..cdc37ab6 100644 --- a/utils/workspace_keys.py +++ b/utils/workspace_keys.py @@ -88,6 +88,7 @@ def _unlink(path: str) -> None: try: os.unlink(path) except OSError: + # best effort cleanup; the next sweep removes a leftover temp pass @@ -105,6 +106,7 @@ def _sweep_temps(directory: str) -> None: if os.stat(path).st_mtime < cutoff: os.unlink(path) except OSError: + # best effort: a later sweep removes what this one could not pass @@ -116,6 +118,7 @@ def _tighten(path: str) -> None: if stat.S_IMODE(os.stat(path).st_mode) & 0o077: os.chmod(path, 0o600) except OSError: + # best effort: the key still works with its old mode pass diff --git a/web/comfy/prompt_manager.js b/web/comfy/prompt_manager.js index ddd36e5e..6d875c04 100644 --- a/web/comfy/prompt_manager.js +++ b/web/comfy/prompt_manager.js @@ -354,8 +354,6 @@ app.registerExtension({ if (textWidget) { // Store reference to the widget for the closure const widgetRef = textWidget; - const originalSerializeValue = textWidget.serializeValue; - // Override serializeValue - use arrow function to capture widgetRef textWidget.serializeValue = (nodeId, widgetIndex) => { // Always return the current DOM value using our stored reference diff --git a/web/js/admin.js b/web/js/admin.js index 16039261..a3b4b7c4 100644 --- a/web/js/admin.js +++ b/web/js/admin.js @@ -2391,8 +2391,6 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; // Extract relative path from the full path // The image_path might be absolute, we need to serve it via the API const path = image.image_path; - // Try to get filename for serving - const filename = image.filename || path.split(/[/\\]/).pop(); // Use the image ID if available, otherwise use the path-based serve endpoint if (image.id) { return ApiClient.resolveUrl(`/prompt_manager/images/${image.id}/file`); diff --git a/web/js/gallery.js b/web/js/gallery.js index acb4b9a5..b889023b 100644 --- a/web/js/gallery.js +++ b/web/js/gallery.js @@ -607,7 +607,6 @@ button: true, backdrop: true, navbar: true, - title: true, // IMPORTANT: Use original images for viewing, not thumbnails url: 'data-original', // Tell ViewerJS to use data-original attribute toolbar: { diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index 4135152c..97a4f0c6 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -1,7 +1,6 @@ import { createScope, createButton } from '../shared/toolkit/core.js'; import { mountPromptBlocks, promptRoles } from './prompt-blocks.mjs'; import { createInspectionController } from './inspection-state.mjs'; -import { createSendButton } from './prompt-sender.mjs'; import { createWorkspaceIcon } from './icons.mjs'; import { navigateTo } from './state.mjs'; import { showWorkspaceToast } from './toast.mjs'; diff --git a/web/js/workspace/local-resolver-support.mjs b/web/js/workspace/local-resolver-support.mjs index 7e062cdb..aa7e0271 100644 --- a/web/js/workspace/local-resolver-support.mjs +++ b/web/js/workspace/local-resolver-support.mjs @@ -1,7 +1,7 @@ // Tables, evidence reader, bounds and outcome lines for local-resolver.mjs (#249 local Inspect, PR L3 Tasks 8-9): // the constants of utils/prompt_pair_resolver.py and utils/inspection_outcomes.py, utils/pair_execution.py // `read_pair_execution` and the semantics table check of utils/inspection_semantics.py. No DOM. -import { PyFloat, PyValueError, isDict, isList, isInt, pyJsonLoads, pyLen, pySlice, pyStrip, pyTruthy, pyEqualsOne, pyStr, pyGet } from './py-json.mjs'; +import { PyValueError, isDict, isList, isInt, pyJsonLoads, pyLen, pySlice, pyTruthy, pyEqualsOne, pyGet } from './py-json.mjs'; /** The semantics table is unusable: the server's `SemanticsError`. */ export class SemanticsError extends Error { diff --git a/web/js/workspace/local-resolver.mjs b/web/js/workspace/local-resolver.mjs index 435b1a2a..9ce727f7 100644 --- a/web/js/workspace/local-resolver.mjs +++ b/web/js/workspace/local-resolver.mjs @@ -41,7 +41,6 @@ import { UNVERIFIABLE_CODES, Unresolved, ZEROED_SOURCE, - compileSemantics, explained, isLink, nodeRef,