Compare commits

..
Author SHA1 Message Date
Vito Sansevero 4845d3a5f8 chore(deps-dev): update coverage to >=7.17.0 (#602) 2026-11-02 06:13:57 -08:00
Vito Sansevero d98849d3cd chore(deps-dev): update pre-commit to >=4.7.0 (#601) 2026-11-02 06:13:57 -08:00
Vito Sansevero 1926657f12 chore(deps-dev): bump flake8 to 7.5.0 (#598) 2026-11-02 06:13:57 -08:00
Vito bf2fa3f50e chore: release 3.5.0 (#608)
Adds the thumbnail location and move (#320), Send to All (#542), skipped
tags and saved instructions (#431), the Pair category repair (#550) and
lower image memory use (#523). Raises the Pillow minimum to 10.1.0 (#604).
2026-11-01 22:13:00 +00:00
Vito 2e9322d5c4 fix(thumbnails): never let an older folder check overwrite a newer answer (#611) (#612)
Two refresh() callers could publish out of order. A check that looked
before the folder was removed could set "available" after a newer check
said "missing", and the state stayed wrong until the next probe. Each
check now has an order number, and an older answer is dropped.
2026-11-01 21:16:55 +00:00
Vito a1377cec36 ci: pin the Playwright image by digest (#609) (#610)
The v1.64.1-noble tag was re-pushed with Node 26, which fails the Node 24
guard in every browser job. Pin the last green image's digest so the
browsers, fonts and Node only change through a reviewed bump.
2026-11-01 19:58:19 +00:00
Vito fa98d0644d Match ComfyUI's dependency floors (#604) and fix the stalled store test race (#603) (#606)
* chore(deps): match ComfyUI's Pillow, aiohttp and numpy floors (#604)

PromptManager never fights ComfyUI on versions: the floors follow its
requirements.txt and Dependabot no longer raises them. The full suite passes on
Pillow 10.1.0 and aiohttp 3.11.8; numpy was tested at 1.26.4, the first with
Python 3.12 wheels. A test that left response bodies unread hung the aiohttp
client before 3.11.11, so it reads them now.

* test(thumbnails): the stalled store call test runs without the probe (#603)

note_failure() wakes the probe, which finds the healthy test folder again at
once, so the next lookup could do store I/O and the test failed intermittently.
2026-11-01 16:38:29 +00:00
Vito 043403d09e feat(thumbnails): choose where thumbnails are kept and move them (#320) (#592)
* refactor(thumbnails): one resolver for where previews live on disk (#320)

Add py/api/preview_store.py: the store of a source, a preview's path,
URL parts to disk, containment (owns), a write generation and the lock
around every preview's final rename. Every preview reader, writer and
remover now asks it instead of joining <source>/thumbnails by hand:
serve, on-demand, capture (its path resolved when the write starts),
the after-scan queue, the thumbnail plans and job payloads, clear, the
temp sweep, History covers, the auto-tag listing and the owned-preview
lookups.

Source mode only: every path is the one built before, so nothing
changes on disk or on the wire. A structural test keeps new
thumbnails joins out of the backend.

* refactor(thumbnails): keep the after-scan queue's store rule and pin the write lock (#320)

A planned target keeps the plan's own containment rule (inside the
store, as the thumbnail job writes it); captures keep the serve rule.
The structural test also covers PREVIEW_FOLDER joins and the
repo-root modules; the write-lock test asserts the lock instead of
waiting on a timer; new tests cover nested roots in the after-scan
queue and a legacy upper-case preview served through the resolver.

* feat(thumbnails): keep previews in one folder, set from config.json (#320)

thumbnails.location in config.json can now name one folder for every
source's previews: <folder>/<source key>/<rel>_thumb<ext>. A source key is
a readable slug plus a short hash of the source's identity, stored in the
folder's marker (.promptmanager-thumbnails.json) with the install that
made it. Serving, on-demand and capture writes, listings, clear, delete,
duplicates, the temp sweep and History covers all follow it; a leftover in
<source>/thumbnails is never served and other text under thumbnails/ is
refused.

A missing, unplugged or foreign folder is unavailable (a probe checks it
every 5 s within 2 s; a stalled or failing store call flips it at once):
originals are served, nothing is written anywhere, the folder is never
re-created, and generate, convert and clear answer 409
location-unavailable. A bad config value is unavailable too, never a
fall-back to the sources.

No UI yet: POST /settings refuses thumbnail_location (409
location-needs-move); GET /settings reports it with its path, the
thumbnail status without one. Sources inside or around the folder and a
library move into it are refused.

* fix(thumbnails): never follow a marker or a link out of the thumbnail folder (#320)

Marker keys are data, not paths: only a single <slug>-<hex> folder name
is ever joined onto the thumbnail folder; anything else is dropped and
logged. Every preview write and delete passes one ownership check (below a
key folder, lexically and resolved, no link, junction or reparse point on
the way); make_parent refuses anything else and writes nothing while the
folder lacks this install's marker (an unmounted mount point). Clear never
descends into a junction.

The configured location is in force before any request (unchecked folder
mode is unavailable), and a startup check that fails leaves it invalid,
never the sources. Older listings that run on the event loop no longer
touch the store in folder mode. A leftover under another case of
thumbnails/ on a case-insensitive disk is refused too.

* test(memory): let the musl test fake os.confstr on Windows (#523)

The portability run on main fails since #523: Windows has no
os.confstr, so patching it without create=True raises before the test
runs.

* feat(thumbnails): move thumbnails between the sources and one folder (#320)

POST /images/thumbnails/plan reviews a move (what is copied, removed and
left, free space, too-long Windows paths, sources no longer listed) and
hands out a token; POST /jobs/thumbnails {mode: move} runs it as a
thumbnails job: hold automatic previews, copy and check every preview on
the job pool, switch with one config commit that records the old copies,
then remove them. Per-file errors still switch; a folder-wide error, a
cancel before the switch or a crash leaves nothing changed (a journal is
undone at the next start); old copies that could not go are removed by a
finishing job at the next start. Leaving a folder removes the previews of
this install's sources that are no longer listed (Q320-8).

Recreate folder and Use this folder answer an unavailable folder; the
Thumbnail maintenance scope prune removes the thumbnails of removed
sources.

* feat(system): choose where thumbnails are kept and move them (#320)

System › Sources & scans gains the Thumbnail location row (D196): Inside
each source or One folder, the folder path, the status line and Review
move…, whose review (D197) lists what is copied, removed and left before
Move thumbnails starts; a refused folder is explained under the field, a
folder of your own asks for a confirmation first. Activity shows a move
and a prune with their own tiles and step line (D198). An unavailable
folder shows a notice on Overview and Sources & scans with Recreate folder
or Use this folder (D199). Thumbnail maintenance offers Remove thumbnails
of removed sources in folder mode (D200), and Move library's review says
where the thumbnails stay (D201).

Closes #320

* fix(thumbnails): keep a move's own previews and never walk out of a store (#320)

A cleanup that waited from an earlier move never names a key folder the
new move just filled, so moving back keeps the previews. Removing empty
folders walks top-down and never into a link or junction. Any error
while copying undoes the copies; once the switch is committed, a journal
that cannot be removed no longer undoes it. Hand-edited cleanup entries
and marker keys never name a folder outside the stores; removal stops at
interpreter exit. The path field carries aria-invalid with its reason,
and a review that went stale reviews again.

* test(thumbnails): cover the prune scope, the Overview notice and the space refusal (#320)

The frontend coverage gate counted the new branches of thumbnail
maintenance, the Activity views, the notices' Change location and the
api-client's space refusal as uncovered; page and unit tests now drive
them.

* fix(thumbnails): report removals that fail and explain the quiet excepts (#320)

An old thumbnail the finishing move cannot remove (held open on Windows)
is now an error row of the job with its path, and logged; a failed copy's
temp that cannot go is logged with its path. Every except that only
passes says why, and the thread runners say their caller re-raises.

* fix(thumbnails): never plan or delete through a linked key folder (#320)

Review finding P1 on #591: in folder mode a source key folder that is a
link or junction to a folder outside was still handed to the planner, so
Convert outdated could remove an older-format file outside the thumbnail
folder. ensure_store now refuses a linked store before any planning, and
each removal of an old preview passes may_write at the moment it runs.

* fix(thumbnails): the temp sweep never walks into a link (#320)

Review finding P2 on #591: Clear thumbnails and the startup sweep removed
crashed-write temps with a plain os.walk, which on Windows walks into a
junction even without followlinks. The sweeper now skips a folder that is
a link, junction or reparse point (the top one too), and each temp passes
may_write before it is removed.

* fix(thumbnails): lock the folder marker across processes (#320)

Review finding P2 on #591: two installs sharing one thumbnail folder could
each read the marker, add a key and replace it, so the last writer
dropped the other's key. write_marker now re-reads, changes and replaces
the marker under a lock file made with O_CREAT | O_EXCL beside it (atomic
on shares, unlike flock); a lock older than 30 s, left by a writer that
died, is taken over, and a wait past 1.5 s fails as a TimeoutError like
an unreachable folder. Tested with three processes adding keys at once.

* test(thumbnails): cover the space refusal, Overview's Use this folder and the prune review (#320)

The frontend coverage floors of activity-kinds, system-overview and
thumbnail-review: a disk without the space shows the review but cannot
move (a fixture route fakes the free space), Use this folder on Overview
reloads the host values, the prune review's text and rows are pure
functions with unit tests, and Overview takes the System drafts it is
always mounted with.

* fix(thumbnails): never copy through a linked folder; refuse a target inside the folder in use (#320)

Moving back to Inside each source followed an existing linked folder in the
destination and could replace a file outside the source (Codex P1). A target
nested inside the thumbnail folder in use was accepted, and the old-copy
cleanup then removed the previews just copied there (Codex P2).

Every copy now checks the whole destination chain for links, also when the
folders exist, and the move refuses a folder inside the one in use with
location-inside-current. The cancelled branches no longer mix explicit and
implicit returns (code-quality note).

* fix(system): one full stop in the move-back review (#320)

* fix(thumbnails): a folder check answers for the folder as it is now (#320)

Probe.check() joined a check already running, or a finished one nobody had
collected. A folder deleted right after configure() woke the probe therefore
stayed available until the next probe, which failed the Overview notice spec in
the merge queue. It now waits for a running check and starts a new one, still
never two at once, and a stuck check still times out. The spec also asserts the
fixture's removal took effect.
2026-11-01 13:11:13 +00:00
Vito 7e11d9cae2 Protected rows (#586), review-bot fixes (#593) and LoRA cancel tests (#575) (#597)
* test(lora): hold the import job to test cancel before and after the commit (#575)

Squashed from PR #594. Closes #575.

* fix: address review-bot findings on merged PRs (#593)

Squashed from PR #595. Closes #593.

* fix(maintenance): list every protected item with its reason (#586)

Squashed from PR #596. Closes #586.
2026-11-01 06:42:12 +00:00
Vito 3295ff8b20 Pair category repair (#550) and saved instructions + dialog picker (#431) (#590)
* feat(maintenance): repair old Pair prompts' empty category and tags (#550)

Squashed from PR #579. Closes #550.

* feat(tagging): saved instructions with a default per engine (#431)

Squashed from PR #587.

* feat(tagging): pick saved instructions in the tagging dialogs (#431)

Squashed from PR #588. Closes #431.
2026-11-01 03:01:07 +00:00
Vito 9640ca86f8 feat(tagging): skipped tags hide matching suggestions (#431) (#585)
* feat(tagging): skipped tags hide matching suggestions (#431)

A Skipped tags list in System > Auto-tagging (D202) hides every suggested
tag whose words contain an entry's words, on every suggestion path
(Tag image, Auto-tag selected, the System batch, the legacy single and
batch routes), before the limit. Jobs snapshot the list at their start.
A read-only preview route counts what a list hides among the library's
tags and the downloaded WD14 label lists. The draft store compares list
and object drafts by value (sameDraft).

* test(tagging): count the Skipped tags block as one concept difference (#431)

* fix(tagging): skip a damaged WD14 label list in the preview (#431)

* test(tagging): classify the Skipped tags preview route as reading no media (#431)
2026-11-01 02:06:42 +00:00
Vito 8a81114dfc perf(images): run image work on two own pools and trim after jobs (#523) (#582)
* perf(images): read PNG text without decoding pixels (#523)

Pillow's PngImageFile.text loads every pixel to reach text chunks written
after IDAT. The scan's metadata batch and the image monitor now read PNG
text through utils/png_text.py, which runs Pillow's own chunk handlers
after the first IDAT and seeks over the pixel data.

The legacy .text read is the floor: a differential test compares both
readers on every fixture image, a curated corpus and a seeded fuzz. The
only difference is a file whose pixel data is undecodable or cut short:
it now keeps the text read before its pixels instead of reading nothing.
Animated PNGs still use .text.

* test(images): cover the text budget, XMP and layout guard of the PNG text walk (#523)

* perf(images): run image work on two own pools and trim after jobs (#523)

Image work no longer spreads over the default executor's threads, where
glibc kept an image-sized malloc arena per thread.

- Interactive image work (view previews behind their gate, workspace card
  facts, both inspector routes) runs on the API's fixed pm-image pool of
  3 threads; the on-demand preview pool is merged into it.
- map_parallel (scan metadata, thumbnail and convert jobs, the legacy
  gallery generation, capture previews) shares one long-lived pm-worker
  pool of Worker threads, reused between jobs. The package teardown stops
  it within its bound (part "workers").
- After a scan, thumbnail or convert job, glibc's malloc_trim(0) returns
  free pages once; a no-op on Windows, macOS and musl.
- tools/perf/memory_drive.py measures RSS, heap, threads and arenas under
  the #523 load on the disposable browser test server (manual only).

* fix(images): start no image thread after the teardown; join retired job pools (#523)
2026-11-01 01:24:27 +00:00
Vito 7a2a04b752 feat(send): send a prompt to all eligible nodes of a window (#542) (#578)
* feat(send): send a prompt to all eligible nodes of a window (#542)

The Destination node select offers "All N eligible nodes" when a window has
two or more nodes that can receive the prompt. It is never selected for you.
Apply previews every node first and writes nothing if one refuses; then it
sends one node after another in node id order, each later node with a fresh
ticket checked against the reviewed text, and reports "Sent to X of N"
naming each refusal. A lost reply stops the run and names the node whose
outcome is unknown. Recorded as D191.

* fix(send): refresh held tickets and compare the reviewed composition for All (#542)

A review held longer than the host's 30 s ticket lifetime would have refused
the first node while sending the rest. Every node now takes a fresh ticket
except the first of a pass taken in the same click, and that ticket must
show the reviewed Before texts and composition. The revision is checked
until something has been posted. Node review sections are labelled by
their h4 heading.
2026-11-01 00:41:13 +00:00
Vito cb94b12c1b fix(system): keep a restore review's upload form alive until it is answered (#583) (#584)
* fix(system): keep a restore review's upload form alive until it is answered (#583)

WebKit can read a FormData's file part after fetch() returned, and with a
request interceptor armed it does. uploadRestore held its FormData only in a
local that was dead after the call, so a garbage collection in between dropped
the form and the server got an empty file ("Uploaded file is empty"); the
Restore preview never opened. The page now holds each upload's form until the
upload is answered.

* docs(system): say what the #583 regression spec proves in each browser (#583)
2026-11-01 00:41:12 +00:00
Vito 3cb90b0c1e perf(images): read PNG text without decoding pixels (#523) (#577)
* perf(images): read PNG text without decoding pixels (#523)

Pillow's PngImageFile.text loads every pixel to reach text chunks written
after IDAT. The scan's metadata batch and the image monitor now read PNG
text through utils/png_text.py, which runs Pillow's own chunk handlers
after the first IDAT and seeks over the pixel data.

The legacy .text read is the floor: a differential test compares both
readers on every fixture image, a curated corpus and a seeded fuzz. The
only difference is a file whose pixel data is undecodable or cut short:
it now keeps the text read before its pixels instead of reading nothing.
Animated PNGs still use .text.

* test(images): cover the text budget, XMP and layout guard of the PNG text walk (#523)
2026-11-01 00:04:27 +00:00
Vito 9a43b67008 test: reset a disposed fixture package's exit actions (#580) (#581)
load_package imports the package under a test-only name; its exit
actions stayed registered after cleanup_package removed it from
sys.modules, so the suite ended with a ModuleNotFoundError from
_kill_encoders_at_exit. cleanup_package now resets the package's
exit signal.
2026-10-31 23:17:17 +00:00
116 changed files with 12406 additions and 417 deletions
+5
View File
@@ -17,6 +17,11 @@ updates:
prefix: "deps(pip)"
include: "scope"
rebase-strategy: "auto"
# These floors follow ComfyUI's requirements.txt, never the newest release.
ignore:
- dependency-name: "Pillow"
- dependency-name: "aiohttp"
- dependency-name: "numpy"
# Optional: group non-breaking updates to reduce PR noise
groups:
python-minor-and-patch:
+1 -1
View File
@@ -80,7 +80,7 @@ jobs:
# pulled them from Ubuntu's apt mirror on every run, and three runs (#349, #378, #382) crawled at
# ~26 kB/s for 11-12 minutes until the job hit its limit after every test had passed.
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
options: --user 1001
timeout-minutes: 24
steps:
+3 -3
View File
@@ -53,7 +53,7 @@ jobs:
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
# tests/vendor/browser-image.test.mjs checks it). Browsers and fonts come from the image.
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
options: --user 1001
timeout-minutes: 20
# Public-repo ubuntu-latest runners have 4 vCPUs: three Playwright workers, each with its own test
@@ -121,7 +121,7 @@ jobs:
runs-on: ubuntu-latest
# Same image as the shards: the raw files name sources by the container's workspace path.
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
options: --user 1001
timeout-minutes: 10
steps:
@@ -192,7 +192,7 @@ jobs:
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
# tests/vendor/browser-image.test.mjs checks it). Browsers and fonts come from the image.
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
options: --user 1001
timeout-minutes: 20
# Public-repo ubuntu-latest runners have 4 vCPUs: three Playwright workers, each with its own test
+200 -10
View File
@@ -111,8 +111,11 @@ distributions, browser system dependencies may need manual installation. For a
quick browser run, use `test:workflows -- --project chromium`.
CI runs the browser jobs inside the pinned image
`mcr.microsoft.com/playwright:v<@playwright/test version>-noble`
`mcr.microsoft.com/playwright:v<@playwright/test version>-noble@sha256:<digest>`
(`tests/vendor/browser-image.test.mjs` keeps the tag in step with the lockfile).
The digest is part of the pin, because Microsoft re-pushes tags: `v1.64.1-noble`
moved from Node 24 to Node 26 on 2026-11-01 (#609). To bump it, change the tag and
digest together and check the new image's Node against the Node 24 guard.
Concept comparisons measure geometry, and geometry follows the fonts a browser
falls back to: the concept names Inter, which neither the image nor a stock
runner ships. On a plain `ubuntu-latest` runner the fallback is DejaVu Sans,
@@ -349,9 +352,12 @@ and the time left. The steps, in order:
observer join get what is left, which can be 0 s. `stop()` joins watchdog's
emitters without a limit, so it runs on a daemon thread and is waited for
only until the deadline; a later stop waits for that same call (#561).
3. `close_auto_thumbnails(final=True)` stops the preview worker (30 s) and
kills ffmpeg.
4. `PromptTracker.shutdown` stops the prompt cleanup thread (2 s).
3. `close_auto_thumbnails(final=True)` stops the preview worker (30 s),
kills ffmpeg and shuts the interactive image pool down.
4. `shutdown_job_pool` (`utils/parallel.py`) cancels the long-lived job pool's
queued items and joins its threads (5 s; part `workers`, only when a job
used the pool, #523).
5. `PromptTracker.shutdown` stops the prompt cleanup thread (2 s).
A failing step, or a second Ctrl+C, is logged with its traceback and does not
skip the later steps. A clean run logs one INFO line to PromptManager's log:
@@ -456,6 +462,38 @@ A hot SQLite read that returns many rows aggregates them into one step
(`json_group_array`) instead of stepping once per row, so it does not wait for
the GIL once per row while ComfyUI generates (#354, `get_image_link_matches`).
A metadata read never decodes pixels (#523). Pillow's `PngImageFile.text` loads
every pixel to reach text chunks after IDAT; the scan (`_extract_comfyui_metadata`)
and the image monitor (`ComfyUIMetadataExtractor.extract_metadata`) read PNG text
through `utils/png_text.py`, which runs Pillow's own chunk handlers after the
first IDAT and seeks over pixel data. The legacy `.text` read is the floor:
`tests/test_png_text.py` compares both readers on every image under
`tests/fixtures`, a curated corpus (text after IDAT, zTXt, iTXt, split IDAT, bad
checksums, truncations, oversized text, APNG) and a seeded fuzz, and asserts that
neither reader decodes pixels by patching `ImageFile.load`. The only reads that
differ are files whose pixel data is undecodable or cut short: legacy found
nothing, the walk keeps the text before the pixels. Animated PNGs still use
`.text`.
Image work runs on PromptManager's own threads (#523). glibc keeps each thread's
malloc arena at its high-water mark, so image buffers spread over the default
executor's threads stay resident. Interactive image work (view previews behind their
gate, workspace card facts, both inspector routes) runs on the API's fixed
`pm-image` pool (`IMAGE_POOL_SIZE` 3, `_run_image`, `_map_image`; card facts already
cached answer without a trip; while the gate's 3 preview writes hold every thread, a
card or inspector read waits for one of them, by design of the merged pool; after the
package teardown the pool is not recreated and late calls use the default executor). Jobs (scan metadata and file facts, thumbnail and
convert jobs, the legacy gallery generation, capture previews) run through
`map_parallel` on one long-lived `pm-worker` pool of Worker threads, reused between
jobs; a new Worker threads value replaces it (the teardown also joins a replaced pool
still finishing), and a call made from a job thread runs inline. After a scan, thumbnail or convert job, or the legacy gallery generation,
`release_free_memory` (`utils/native_memory.py`) calls glibc's `malloc_trim(0)` once;
it does nothing on Windows, macOS and musl and never raises. No allocator setting
changes. `tests/test_image_pools.py` asserts the thread of each read and write and the
trim; `tests/test_utils_parallel.py` the pool's reuse, resize, nesting and shutdown.
`tools/perf/memory_drive.py` (manual, Linux, never in CI) measures RSS, heap, threads
and arenas under the #523 load against the disposable browser test server.
The `perf budget` job in `.github/workflows/test.yml` is the reference
measurement: `ubuntu-latest`, one Python, no coverage, only the wall-clock tests.
It runs on every push to `main` and on `workflow_dispatch`, never on pull
@@ -1851,8 +1889,8 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68
engines in the mock's order with legacy labels and sizes (`WD14 SwinV2 · ~400
MB`, `WD14 ViT · ~400 MB`, `GGUF JoyCaption (Recommended) · ~4.5 GB` in the
mock's Ollama slot, `HuggingFace JoyCaption · ~16 GB`); the instructions start
from the server's `default_prompt` (since #431 the saved engine's default
instructions, GGUF's for a WD14 engine).
from the chosen engine's default saved instructions (#431, D204; before, the
server's `default_prompt`).
- **D91** (plan D68, ruling T3-bulk): a **Suggestions** number field (1–100) for
every model, prefilled from `autotag.defaults.suggestion_limit` (10); a model
change keeps it.
@@ -2944,7 +2982,8 @@ change: ComfyUI needs a restart.
A Pair save, by a run or the node's Save pair button, writes the category and tags to the positive prompt only when that
save creates the prompt row (maintainer ruling). An existing prompt never changes on a run: empty, cleared or set by a person, including one first seen as
another pair's negative. The negative stays empty, the saved pair keeps its own metadata, and `save_prompt_pair` writes
no prompt metadata unless called with `fill_new_positive`. No backfill; optional repair is #550.
no prompt metadata unless called with `fill_new_positive`. No backfill on a run; the opt-in repair for prompts older
Pair runs saved is System › Maintenance › "Pair category and tags" (#550, "Pair category and tags repair" below).
Tests: `tests/test_prompt_pair_node.py`, `tests/test_api_pairs.py`, `tests/comfyui/pair-node.spec.mjs`. A Python change: ComfyUI needs a restart.
### Delete… on the archived shelf (#492)
@@ -3859,8 +3898,8 @@ workspace never asks; previews are a cache the server keeps:
- `GET /images/serve/thumbnails/<rel>_thumb<ext>` for a missing preview is refused as
text first (traversal, a backslash, a drive or stream colon, empty or dot segments, so
`D:/x`, `\\server\share` and a leading `/` never reach a joined path), then admitted
on the event loop (`_PreviewGate`): 3 previews are written at once on the API's own
`pm-preview` pool (never the default executor), one flight runs per file (the history
on the event loop (`_PreviewGate`): 3 previews are written at once on the API's
interactive `pm-image` pool (never the default executor; #523), one flight runs per file (the history
route shares the flight key and the admission), and at most 64 requests wait; one
more gets its fallback at once without listing a folder. Inside the admission the
preview maps back to its media file under the same root (exact names, then one folder
@@ -3893,7 +3932,7 @@ workspace never asks; previews are a cache the server keeps:
`exclusive` job runs (`_auto_thumbnail_blocker`), checked when a write starts (after
its slot) and again just before its rename.
- Shutdown is bounded and logged: the worker stops at a file boundary, ffmpeg is
killed, an idle worker leaves (the next output starts a fresh one), the view pool
killed, an idle worker leaves (the next output starts a fresh one), the image pool
shuts down, and after the app's cleanup a late monitor event
starts nothing. A package reload stops the workers the previous import left.
- Cards and strips load previews only: Images in every layout and size, Library cards,
@@ -4273,6 +4312,12 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
primary stays the concept's plain button; the plan it opens has the primary.
`duplicates dialog matches the concept` and `duplicates comparison detects mutations`
(system-maintenance-concept.spec) pin the mapping.
- **D192** (Maintenance, #550, beyond the concept): Database adds "Pair category and
tags" after "Unused prompt components", a plain Review plan row (no danger mark: it
deletes nothing) with the line "Fill an empty category or tags on prompts older Pair
node runs saved, from their saved pairs. Only empty fields of never-edited prompts."
`system-maintenance-concept.spec.mjs` maps it (`mapPairRow`) as a copy of Optimize
database's row.
- **D102** (Maintenance, S7, Q5, #222): Metadata cache clears only the browser entries
Prompt Manager owns as a cache (`web/js/cache-ownership.js`). The inventory of every
storage writer has none (see "Browser cache ownership" below), so the row's line adds
@@ -4370,6 +4415,23 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
choice. The host's checks still refuse a linked field or a changed workflow, and the
sender also refuses if the destination's `revision` (node object plus graph version)
moved since discovery; it is undefined on an older tab, which skips the comparison. A host error reads as a refusal, not "may have applied".
- **D191** (#542, beyond the concept; maintainer 2026-10-31, Q542-1 A to 5 A): a window with two or more
eligible nodes adds a last Destination node option "All N eligible nodes" (`ALL_NODES` in
`prompt-sender.mjs`), never selected for you (D173). Eligible is D173's filter; the nodes it leaves out
stay counted in the discovery sentence. One window only. Preview stays optional (D175): Preview changes
shows one `section.prompt-sender-node` per node, headed "#<id> <title>", in send order (node id,
numeric). Apply previews every node first and writes nothing if any refuses ("#<id> <title>: <reason>
Nothing was sent."). Then it sends one node after another. Only the first node of a pass taken in that
click keeps its ticket; every other node, and every node of a held Preview (host tickets expire after
30 s), takes a fresh ticket that is refused unless its Before texts and composition equal the reviewed
ones. That fresh preview checks the discovered revision until something has been posted, and not after,
since our own write may move the graph version. Refusals are best effort: "Sent to 2 of 3 nodes in
<window>. #12 <title>: <host's refusal>."; Apply stays available and a retry sends to every node again
(rewriting the same text). A lost reply stops the run, names that node as unknown and lists the nodes
not sent. Closing the dialog stops after the current node. The review sections are labelled by their
`h4` node heading. Unit tests in `tests/js/prompt-sender.test.js`, a fake three-node host in
`tests/browser/prompt-inspector.spec.mjs`, real Classic and Nodes 2.0 widgets in
`tests/comfyui/prompt-sender.spec.mjs`.
- **D106** (Integrations, S10, Q8): the send bridge always asks for the ComfyUI window
and node, and the nodes always follow ComfyUI's own renderer, so "Default send
destination" offers only "Ask each time" and "Host compatibility" only "Follow host
@@ -4619,6 +4681,26 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
tokens, the two rows on the concept's select row) and `saved instructions: new, edit,
per-engine default, delete, save and reload (D203)`, `a refused instructions save names
the entry and highlights the block (D203)` (workspace-system-tagging.spec).
- **D204** (Tag image and Auto-tag selected dialogs, #431, beyond the concept): for a
language-model engine a "Saved instructions" select (`#image-tag-instructions-pick`,
`#bulk-tag-instructions-pick`) sits above "Tagging instructions" (the mock's state 3),
on the concept's select field: Built-in, then the saved entries (`GET /autotag/models`
`instructions`, full text), the engine's default marked "<name> · default". It starts
at that default; a pick replaces the text. The text stays editable for the run: while
it differs from the picked entry the select shows a disabled "Edited for this run" and
a muted line under the textarea reads "Edits apply to this run only. Manage saved
instructions in System › Auto-tagging." Choosing an entry again replaces the edit.
Switching between GGUF and HF starts from the new engine's default and drops an edit;
WD14 hides both. A preset (Preview tagging review, the bulk review) shows the entry
with its text, else "Edited for this run". When the read fails the select holds only
Built-in with the client's copy (`BUILTIN_INSTRUCTIONS`, pinned equal to
`DEFAULT_TAG_TEMPLATE` by workspace-image-tagging-state.test). Request bodies are
unchanged (`options.instructions` is the textarea's text); there is no "Save as new".
The workspace no longer reads `default_prompt`. Pinned by the tagging comparison's
mapping (tagging-canonical-comparison.mjs: the select before the instructions),
`the saved instructions picker fills, edits one-off and resets per engine (D204)`
(workspace-tagging.spec) and `Auto-tag selected picks saved instructions and sends the
text shown (D204)` (workspace-tagging-bulk.spec).
- **D205** (Auto-tagging, #431; amends D134): for a language-model engine the batch
review adds a read-only "Instructions" row with the drafted engine default's name
before "Settings", which reads "Up to L tags per image"; the batch and Preview tagging
@@ -4855,6 +4937,47 @@ replaced the database since the review; the plan is dropped). The job:
prompt ids only), logs the backup by its file name, and prunes safety backups to the
newest 10. With no backup the log and the status line say "No safety backup".
#### Pair category and tags repair (#550)
`pair_metadata` ("Pair category and tags", D192; `database/pair_metadata_repair.py`) fills
the empty category and the empty tag set of prompts that Pair node runs before #546
saved, from their saved pairs. It is reviewed and applied like the operations above and
follows the safety backup policy like them (no forced backup). Rules
(`docs/egm/post-3.4.1/550-design.md`):
- Considered: prompts the Pair node created as a positive: their earliest pair (lowest
id, either role, archived included) has them as the positive and its `created_at` is
within 2 s of theirs. A prompt first saved by the PromptManager node, or first as a
negative, is out of scope and not counted.
- Sources: the prompt's non-archived pairs where it is the positive; pairs with a blank
category or no tags are ignored for that field. Category values are trimmed and
case-sensitive, tag sets compared by tag id; a field fills when exactly one value is
left. A whitespace-only category counts as empty.
- Kept, per field, in this order: archived prompt, edited prompt (`updated_at` not within
2 s of `created_at`; NULL or unreadable counts as edited), pairs that disagree, a tag
set over 20 tags. A prompt can fill one field and keep the other.
- The plan lists protected rows first, then fills, then a "Record" row, within the 50
detail rows; at most 20,000 prompts per plan ("review again for the rest").
- The fingerprint covers each considered prompt's computed result (values, tag ids and
names, source pair ids), never a pair's title, notes, rating, run count or use
stamps, so a Pair run between review and apply still applies.
- The apply's writes are guarded (`category` still blank, no `prompt_tags` row) and a
guard that hits nothing rolls the whole transaction back as `plan-changed`. It never
writes `prompt_pairs`, `prompt_pair_tags`, `updated_at`, a tag or Library state.
- The job lists 200 changed rows ("Prompt 12: category “night”; tags a, b"); its
Download report lists every one (`truncated: false`, `Job.record_counted(complete=True)`).
Protected lists one row per kept prompt in the review's wording and order ("Prompt 7:
kept, pairs disagree on category: Adult, asian"), also complete in the report.
- Activity shows a row with no path as a record: its detail as plain text, never an open
or locate action, and the filter reads the detail. A maintenance job of records (every
operation but Missing-file links) says "No records in this group.", "No listed record
matches this filter." and "Filter records…" (`groupCopy`); file jobs keep "files".
Tests: `tests/test_maintenance_pair_metadata.py`, `tests/test_api_maintenance_pair_metadata.py`,
`tests/test_jobs.py`, `tests/js/workspace-system-maintenance.test.js`,
`tests/browser/workspace-system-maintenance.spec.mjs` (hook `/__test__/system/pair-metadata`).
A Python change: ComfyUI needs a restart.
The legacy restore (`POST /prompt_manager/restore`) takes a backup of any size the disks
have room for, so a real library's safety backup restores: the upload, its staged copy
and the swap (the upload twice in the temporary folder, once more plus the current
@@ -5779,6 +5902,73 @@ are closed before `os.replace`; renames stay inside one folder; folder identity
folder are refused. The browser test server closes its own log handlers before its temp
root (which now holds the logs) is removed.
### Thumbnail location contract (#320)
Previews live in each source's `thumbnails/` (the default) or in one folder, `<folder>/<source key>/…`. Every reader,
writer and remover asks `py/api/preview_store.py`; `tests/test_preview_store.py` fails on a `thumbnails` path join
anywhere else. The setting is `thumbnails.location` in config.json, and only the Move thumbnails job writes it: POST
`/settings` answers 409 `location-needs-move`.
- **Keys and marker.** A key is a slug plus a hash of the source's identity. The folder's
`.promptmanager-thumbnails.json` stores each key with its identity and install. Only a single `<slug>-<hex>` name
is ever joined onto the folder, and anything else in a marker is dropped. Every write and delete passes
`preview_store.may_write`: below a key folder, with no link, junction or reparse point on the way
(`tests/test_preview_store_folder.py`, including tampered markers).
- **Unavailable.** A folder is unavailable when it is missing, is a link, holds no marker or holds another
folder's marker. A probe checks it every 5 s, and a store call that fails or stalls marks it at once. While it is
unavailable:
- originals are served and nothing is written anywhere;
- generate, convert, clear and prune answer 409 `location-unavailable`;
- the folder is never made again except by Recreate folder.
The configured location is in force before any request. A bad value is `invalid`, never a fall-back to the
sources.
- **Move thumbnails.** The job is a `thumbnails` job with `scope.mode` `move` (`tests/test_thumbnail_move.py`). It
runs in this order:
1. The review answers `POST /images/thumbnails/plan`; not enough space answers 507.
2. A token starts the job.
3. Automatic previews are held.
4. Copies run on the job pool in chunks of 2 or 4 files, each with its size checked and its mtime rounded up to
2 s.
5. One config commit makes the switch and records the old stores under `cleanup`. The job cannot be cancelled
from here.
6. The old copies are removed.
If it fails or is cancelled before the switch, nothing changes. A crash leaves
`<data>/data/thumbnail-move.json`, which the next start undoes. Old copies still held open are removed by the
next start's "Move thumbnails (finishing)" job. Leaving a folder also removes the previews of this install's
sources that are no longer listed (Q320-8).
- **Remove thumbnails of removed sources** (`scope.mode` `prune`). It is offered in Thumbnail maintenance only in
folder mode, and it touches only this install's keys.
- **Page tests.** `tests/browser/workspace-thumbnail-location.spec.mjs` covers the row, both moves, the in-place
refusal, the confirmation, the notice with Recreate and with Use this folder. The legacy gallery runs once in
folder mode (`workflows.spec.mjs`). The hooks are `tests/browser/thumbnail_location_fixture.py`.
Beyond the concept, against the approved static mock `docs/egm/post-3.4.1/320-mock`:
- D196: the Thumbnail location row in System › Sources & scans, after Thumbnail generation.
- The status line is "Now: inside each source." or "Now: one folder, <path>.". It does not show the mock's
"(3 sources, 8,512 thumbnails)" counts, because counting means walking every preview on each view. It says
"Using the default folder next to your library." and names old copies that wait to be removed.
- D197: the Move thumbnails review uses the shared review.
- A grid value may be a lead sentence with a list (`[lead, items]`).
- Sources no longer listed appear under "Removed after the move", where the mock has a "Not moved" row
(maintainer answer to Q320-8: remove).
- The Free space line is "<size> free", without naming the disk.
- D198: Activity's view of `mode: move` and `mode: prune`.
- Tiles for a move: Moved, Already there, Left in place, Errors. Tiles for a prune: Removed, Left in place,
Errors.
- The step line under the bar is `#job-phase`.
- Cancel shows only until the switch.
- D199: the unavailable notice on Overview and Sources & scans.
- Recreate folder appears for a missing folder, after a confirmation.
- Use this folder appears for another folder's marker.
- Change location focuses the row; on Overview it opens Sources & scans.
- D200: Thumbnail maintenance's "Remove thumbnails of removed sources" scope, with the plan line and a grid of
key folders.
- D201: Move library's review adds "Thumbnails: Stay in <path>. Move them in System › Sources & scans." in folder
mode.
### Backup and restore contract (#249 System S8)
A restore is staged, then published (`database/restore_stage.py`). The upload is
+38 -9
View File
@@ -92,10 +92,21 @@ try:
routes = config.routes
api = PromptManagerAPI()
api.add_routes(routes)
# Temp files of preview writes a crash interrupted (#317), off the
# import path: the walk may cover a large thumbnails folder.
# The thumbnail location's startup check (#320), then the temp files of
# preview writes a crash interrupted (#317), off the import path: the
# walk may cover a large thumbnails folder.
# #320: the configured thumbnail location is in force before any request
# (no I/O); the thread below checks it.
api.use_preview_location()
def _preview_startup():
api.start_preview_location()
api.recover_thumbnail_move() # an interrupted move, before the sweep
api.sweep_thumbnail_temps()
threading.Thread(
target=api.sweep_thumbnail_temps,
target=_preview_startup,
name="PromptManagerPreviewTempSweep",
daemon=True,
).start()
@@ -108,6 +119,8 @@ try:
# Build each output root's Images listing once the server's loop runs,
# so the first open does not wait for the walk (#348).
host_app.on_startup.append(api.warm_gallery_listings)
# #320: old thumbnails a move left to remove, once it is checked.
host_app.on_startup.append(api.finish_thumbnail_move)
if host_app is not None and hasattr(host_app, "on_shutdown"):
# Open Needs review change streams end before the server waits on them.
host_app.on_shutdown.append(api.close_review_events)
@@ -280,11 +293,11 @@ def _teardown_logger():
def _stop_background_work():
"""Stop the image monitor, the preview worker and the prompt cleanup
thread against one TEARDOWN_SECONDS deadline, once.
"""Stop the image monitor, the preview worker, the job worker threads
and the prompt cleanup thread against one TEARDOWN_SECONDS deadline, once.
Returns ``{"monitor", "thumbnails", "tracker"}`` booleans (True: it
stopped), leaving out parts that never started. Later calls return a
Returns ``{"monitor", "thumbnails", "workers", "tracker"}`` booleans
(True: it stopped), leaving out parts that never started. Later calls return a
copy of the first result. A Ctrl+C inside a step is logged and the
step is False; one between steps is re-raised after the result (parts
not reached are False) is stored and the ``atexit`` entry removed.
@@ -298,11 +311,14 @@ def _stop_background_work():
api,
_global_prompt_tracker,
)
from .utils import parallel
# A part whose global is set has started; False until its step says
# otherwise, so an interrupt between steps leaves an honest result.
started = (
("monitor", monitor),
("thumbnails", thumbnails),
("workers", parallel.job_pool_started() or None),
("tracker", tracker),
)
result = {name: False for name, part in started if part is not None}
@@ -345,6 +361,16 @@ def _stop_background_work():
result["thumbnails"] = _teardown_step(
"the preview worker", close_previews, logger
)
if "workers" in result:
def stop_workers():
from .utils.parallel import JOB_POOL_JOIN_SECONDS
return parallel.shutdown_job_pool(left(JOB_POOL_JOIN_SECONDS))
result["workers"] = _teardown_step(
"the job worker threads", stop_workers, logger
)
if tracker is not None:
def stop_tracker():
@@ -435,8 +461,11 @@ except Exception as e:
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
# One startup line. ASCII only: a Windows console using a legacy code page
# cannot print emoji, and a UnicodeEncodeError here would abort loading the node.
# One startup line, printed at import on purpose: it is the package's banner on
# stdout, as ComfyUI's other custom nodes print theirs. Not a log record: the
# console log handler hides INFO by default, so a logger would drop it. ASCII
# only: a Windows console using a legacy code page cannot print emoji, and a
# UnicodeEncodeError here would abort loading the node.
print(
f"\033[94m[ComfyUI-PromptManager] Version:\033[0m {get_version()}"
f" - {len(NODE_CLASS_MAPPINGS)} tools loaded"
+18 -3
View File
@@ -199,6 +199,23 @@ def link_states(rows, roots, readable):
check's ``missing_files`` and the maintenance missing-link plan both
judge links this way, so their counts agree.
"""
for row, owner, state in _placed_links(rows, roots, readable):
if state is None:
state = "present" if os.path.exists(row[1]) else "missing"
yield row, owner, state
def held_links(rows, roots, readable):
"""``(row, owner, state)`` for each link ``link_states`` holds back
(``unavailable`` or ``outside``), without looking at any file (#586)."""
for row, owner, state in _placed_links(rows, roots, readable):
if state is not None:
yield row, owner, state
def _placed_links(rows, roots, readable):
"""``link_states`` before any file is looked at: state None means the
link lies under a readable source."""
prefixes = [_root_prefix(root) for root in roots]
for row in rows:
owner = _image_owner(row[1], row[3], prefixes)
@@ -206,10 +223,8 @@ def link_states(rows, roots, readable):
yield row, None, "outside"
elif not readable[owner]:
yield row, owner, "unavailable"
elif os.path.exists(row[1]):
yield row, owner, "present"
else:
yield row, owner, "missing"
yield row, owner, None
def _unavailable_line(unavailable, *, partial):
+4
View File
@@ -78,6 +78,10 @@ MESSAGES = {
),
"same-folder": "The new folder is the node folder itself.",
"target-invalid": "The new folder cannot hold the library.",
"data-dir-served": (
"The new folder is inside Prompt Manager's thumbnail folder. Nothing was "
"moved."
),
"target-has-library": (
"The new folder already holds a Prompt Manager library. Nothing was moved."
),
+95 -24
View File
@@ -2,7 +2,8 @@
Four operations, each the native owner of a legacy ``/maintenance`` one and
built on the same queries, so a preview counts what the legacy cleanup would
change:
change (``workflow_storage`` and #550's ``pair_metadata``, which fills old
Pair prompts' empty category and tags, live in their own modules):
- ``duplicate_prompts``: prompts whose text is equal ignoring case and outer
spaces (``DUPLICATE_PROMPTS_SQL``, legacy ``cleanup_duplicates``); each
@@ -42,7 +43,7 @@ from dataclasses import dataclass, field
from pathlib import Path
from .execution_contexts import LIVE_HINT_CTES
from .health import BATCH_ROWS, link_states, readable_roots, readonly_uri
from .health import BATCH_ROWS, held_links, link_states, readable_roots, readonly_uri
from .image_removal_schema import TABLE as MARKS_TABLE
from .image_removal_schema import clear_marks, has_marks_table
from .pairs import PairDependencyError
@@ -60,6 +61,9 @@ MAINTENANCE_OPERATIONS = (
"missing_links",
# Applied as its own batched job (database/workflow_storage.py).
"workflow_storage",
# #550: fill old Pair prompts' empty category and tags
# (database/pair_metadata_repair.py).
"pair_metadata",
)
MAX_DETAILS = 50 # detail rows a plan lists
MAX_RESULT_ROWS = 200 # changed records an apply describes (the job lists 200)
@@ -126,6 +130,16 @@ _UNLINKED_PROTECTIONS_SQL = (
+ "), 0) FROM prompts p WHERE NOT EXISTS "
"(SELECT 1 FROM generated_images gi WHERE gi.prompt_id = p.id)"
)
# Each prompt no image links to, with which protections keep it (#586); the
# ones with none are filtered out in Python, so each predicate runs once.
_UNLINKED_KEPT_SQL = (
"WITH "
+ LIVE_HINT_CTES
+ " SELECT p.id, "
+ ", ".join(f"({predicate})" for predicate, _many, _one in PROTECTIONS)
+ " FROM prompts p WHERE NOT EXISTS "
"(SELECT 1 FROM generated_images gi WHERE gi.prompt_id = p.id) ORDER BY p.id"
)
# Duplicate prompt groups, as the legacy cleanup finds them (case-insensitive
# text; GROUP_CONCAT order is sorted by created_at afterwards).
@@ -155,6 +169,14 @@ _PROMPT_STATE_SQL = (
"ORDER BY id",
)
# Why the missing-link cleanup holds a link back: (one, many) (#586).
HELD_LINK_REASONS = {
"unavailable": (
"link under an unavailable source",
"links under unavailable sources",
),
"outside": ("link outside the image sources", "links outside the image sources"),
}
NO_FILES_DELETED = "No image file is deleted."
LINKS_ONLY = "Database links only; no image file is deleted."
UNUSED_DETAIL = "No image, saved pair, Library state or recorded run uses it"
@@ -168,6 +190,7 @@ CONFLICT_REASONS = {
"groups have their own Library titles",
),
}
PAIRED_DETAIL = "Kept: belongs to a saved pair"
CONFLICT_DETAILS = {
"collision": "Kept: two copies link the same image",
"title": "Kept: a copy has its own Library title",
@@ -409,6 +432,35 @@ def _source_name(root, index):
return os.path.basename(str(root).rstrip("\\/")) or f"Source {index + 1}"
def _kept_duplicates(paired, conflicted):
"""``{prompt_id, reasons}`` for each prompt of a group kept whole (#586).
The reasons are the review's details without "Kept: ".
"""
held = [(ids, PAIRED_DETAIL) for ids in paired] + [
(ids, CONFLICT_DETAILS[reason]) for ids, reason in conflicted
]
return [
{"prompt_id": prompt_id, "reasons": [detail.removeprefix("Kept: ")]}
for ids, detail in held
for prompt_id in ids
]
def _kept_unused(conn):
"""``{prompt_id, reasons}`` for each prompt the unused prune keeps (#586).
The reasons are the review's, one per protection that applies, in the
order the plan names them.
"""
kept = []
for row in conn.execute(_UNLINKED_KEPT_SQL):
reasons = [one for flag, (_p, _m, one) in zip(row[1:], PROTECTIONS) if flag]
if reasons:
kept.append({"prompt_id": row[0], "reasons": reasons})
return kept
class MaintenanceOperations:
"""Mixin for PromptDatabase: maintenance previews and their apply."""
@@ -533,6 +585,10 @@ class MaintenanceOperations:
lambda conn: (self._link_rows(conn), _mark_rows(conn)),
)
return self._plan_missing_links(rows, roots, _stale_marks(marks, roots))
if operation == "pair_metadata":
from .pair_metadata_repair import plan_pair_metadata
return _read_snapshot(self.model.db_path, plan_pair_metadata)
reader = (
self._plan_duplicate_prompts
if operation == "duplicate_prompts"
@@ -584,7 +640,7 @@ class MaintenanceOperations:
+ [
{
"label": _excerpt(texts.get(group[0])),
"detail": "Kept: belongs to a saved pair",
"detail": PAIRED_DETAIL,
}
for group in paired
]
@@ -662,6 +718,24 @@ class MaintenanceOperations:
],
)
def kept_links(self, roots):
"""``{image_path, owner, reason}`` for each link the missing-link
cleanup holds back under ``roots`` (blocking, reads only, no file
opened); ``owner`` indexes ``roots`` (None outside every source).
``reason`` is the review's (``HELD_LINK_REASONS``), one link each.
"""
roots = [str(root) for root in roots]
rows = _read_snapshot(self.model.db_path, self._link_rows)
return [
{
"image_path": row[1],
"owner": owner,
"reason": HELD_LINK_REASONS[state][0],
}
for row, owner, state in held_links(rows, roots, readable_roots(roots))
]
@staticmethod
def _links_fingerprint(candidates, stale=()):
fingerprint = _Fingerprint("missing_links")
@@ -681,23 +755,11 @@ class MaintenanceOperations:
unavailable += 1
elif state == "outside":
outside += 1
reasons = []
if unavailable:
reasons.append(
_plural(
unavailable,
"link under an unavailable source",
"links under unavailable sources",
)
)
if outside:
reasons.append(
_plural(
outside,
"link outside the image sources",
"links outside the image sources",
)
)
reasons = [
_plural(count, *HELD_LINK_REASONS[state])
for state, count in (("unavailable", unavailable), ("outside", outside))
if count
]
count = len(candidates)
parts = []
if count:
@@ -800,7 +862,11 @@ class MaintenanceOperations:
records changed and up to ``MAX_RESULT_ROWS`` descriptions of them
(``{prompt_id[, kept_id]}`` or ``{image_path}``). ``marks`` are a
missing-links plan's stale removal marks; that apply also returns
``marks``, how many it forgot.
``marks``, how many it forgot. ``pair_metadata`` returns every filled
prompt, uncapped (its job keeps the complete record, #550). The
prompt operations also return ``kept``: every prompt the plan keeps,
``{prompt_id, reasons}`` in the review's wording (#550, #586); the
links a missing-links plan keeps are read by ``kept_links``.
"""
if operation not in MAINTENANCE_OPERATIONS or operation == "workflow_storage":
raise ValueError(f"Unknown maintenance operation {operation!r}")
@@ -815,6 +881,10 @@ class MaintenanceOperations:
"unused_components": self._apply_unused_components,
}
with self._pair_transaction() as conn:
if operation == "pair_metadata":
from .pair_metadata_repair import apply_pair_metadata
return apply_pair_metadata(conn, fingerprint, targets)
if operation == "missing_links":
return self._apply_missing_links(
conn, fingerprint, targets, roots, list(marks)
@@ -822,10 +892,11 @@ class MaintenanceOperations:
return appliers[operation](conn, fingerprint, targets, roots)
def _apply_duplicate_prompts(self, conn, fingerprint, targets, _roots):
groups, _paired, _conflicted, current = self._duplicates_fingerprint(conn)
groups, paired, conflicted, current = self._duplicates_fingerprint(conn)
if current != fingerprint or groups != targets:
raise PlanChanged("duplicate_prompts")
changed, rows = 0, []
kept = _kept_duplicates(paired, conflicted)
for sorted_ids in groups:
removed, _moved = self._merge_duplicate_group(conn, sorted_ids, strict=True)
changed += removed
@@ -833,7 +904,7 @@ class MaintenanceOperations:
{"prompt_id": duplicate_id, "kept_id": sorted_ids[0]}
for duplicate_id in sorted_ids[1:]
)
return {"changed": changed, "rows": rows[:MAX_RESULT_ROWS]}
return {"changed": changed, "rows": rows[:MAX_RESULT_ROWS], "kept": kept}
def _apply_unused_components(self, conn, fingerprint, targets, _roots):
ids, current = self._unused_fingerprint(conn)
@@ -845,7 +916,7 @@ class MaintenanceOperations:
{"prompt_id": prompt_id, "text": _excerpt(texts.get(prompt_id))}
for prompt_id in ids[:MAX_RESULT_ROWS]
]
return {"changed": changed, "rows": rows}
return {"changed": changed, "rows": rows, "kept": _kept_unused(conn)}
def _apply_missing_links(self, conn, fingerprint, targets, roots, marks):
rows = []
+452
View File
@@ -0,0 +1,452 @@
"""Pair category and tags repair (#550): the ``pair_metadata`` maintenance plan.
Before #546 a Pair node run saved its category and tags on the saved pair
only; the positive prompt it created stayed Uncategorized and untagged. This
plan fills the **empty** category and the **empty** tag set of those prompts
from their saved pairs (``docs/egm/post-3.4.1/550-design.md``):
- Considered: prompts the Pair node created as a positive (their earliest
pair, in either role, has them as the positive and its ``created_at`` is
within ``PAIR_CREATED_TOLERANCE`` of theirs) whose category is blank
and/or that have no tags.
- Sources: the prompt's non-archived pairs where it is the positive. Pairs
with a blank category or no tags are ignored for that field. A field fills
when exactly one value remains (category: trimmed, case-sensitive; tags:
the tag-id set).
- Kept (protected), in this order: archived prompt, edited prompt
(``updated_at`` not within the tolerance of ``created_at``), pairs that
disagree, a tag set over ``MAX_LIBRARY_TAGS``.
The apply runs in the caller's ``BEGIN IMMEDIATE`` transaction, recomputes
the fingerprint, then writes guarded statements (the field must still be
empty) that never touch a pair, a negative or ``updated_at``. A guard that
hits nothing raises ``PlanChanged`` so the whole transaction rolls back.
"""
import datetime
from . import maintenance as _maintenance
from .library_commands import MAX_LIBRARY_TAGS
from .maintenance import (
MAX_DETAILS,
MaintenanceDraft,
PlanChanged,
_excerpt,
_Fingerprint,
_marks,
_plural,
)
OPERATION = "pair_metadata"
# Seconds: the Pair save stamps its new prompt and its pair with separate
# utc_now_iso() calls in one transaction (microseconds apart).
PAIR_CREATED_TOLERANCE = 2.0
# Fill prompts one plan applies; the rest are applied by the next review.
# Bounds the complete record a job keeps in memory (about 100 bytes a row).
MAX_PLAN_FILLS = 20000
DETAIL_VALUES = 3
DETAIL_TAGS = 5
MEDIA_LABEL = "No image file or saved pair is changed."
EMPTY_LABEL = "No Pair prompts are missing a category or tags"
RECORD_NOTE = (
"The job's report lists every filled field. Download it to keep it: "
"Activity forgets jobs after a restart."
)
# Protected classes in precedence order: (key, detail, reason one, many).
EDITED_DETAIL = (
"Kept: edited after it was saved; its empty category or tags may be intentional"
)
PROTECTED = (
(
"archived",
"Kept: archived in the Library",
"archived in the Library",
"archived in the Library",
),
(
"edited",
EDITED_DETAIL,
"edited after it was saved",
"edited after they were saved",
),
("conflict", None, "where pairs disagree", "where pairs disagree"),
(
"too-many-tags",
f"Kept: its pair has more than {MAX_LIBRARY_TAGS} tags",
f"whose pair has more than {MAX_LIBRARY_TAGS} tags",
f"whose pair has more than {MAX_LIBRARY_TAGS} tags",
),
)
_PROTECTED_ORDER = [key for key, *_rest in PROTECTED]
# Detail rows: what the user can act on (a conflict) first.
DETAIL_ORDER = ("conflict", "edited", "archived", "too-many-tags")
_CANDIDATES_SQL = """
SELECT p.id, p.hash, p.text, p.created_at, p.updated_at,
COALESCE(TRIM(p.category), '') = '' AS blank_category,
NOT EXISTS (SELECT 1 FROM prompt_tags pt WHERE pt.prompt_id = p.id)
AS untagged,
EXISTS (SELECT 1 FROM prompt_archive pa WHERE pa.prompt_id = p.id)
AS archived,
(SELECT CASE WHEN pp.positive_prompt_id = p.id
THEN pp.created_at END
FROM prompt_pairs pp
WHERE pp.positive_prompt_id = p.id OR pp.negative_prompt_id = p.id
ORDER BY pp.id LIMIT 1) AS first_pair_at
FROM prompts p
WHERE EXISTS (SELECT 1 FROM prompt_pairs pp WHERE pp.positive_prompt_id = p.id)
AND (COALESCE(TRIM(p.category), '') = ''
OR NOT EXISTS (SELECT 1 FROM prompt_tags pt WHERE pt.prompt_id = p.id))
ORDER BY p.id
"""
_SOURCES_SQL = (
"SELECT id, positive_prompt_id, TRIM(COALESCE(category, '')) FROM prompt_pairs "
"WHERE archived = 0 AND positive_prompt_id IN ({marks}) ORDER BY id"
)
_PAIR_TAGS_SQL = (
"SELECT ppt.pair_id, t.id, t.name FROM prompt_pair_tags ppt "
"JOIN tags t ON t.id = ppt.tag_id WHERE ppt.pair_id IN ({marks}) "
"ORDER BY ppt.pair_id, t.id"
)
def parse_stamp(value):
"""A stored timestamp as an aware UTC datetime, or None when unreadable."""
if not isinstance(value, str) or not value.strip():
return None
try:
stamp = datetime.datetime.fromisoformat(value.strip())
except ValueError:
return None
if stamp.tzinfo is None:
stamp = stamp.replace(tzinfo=datetime.timezone.utc)
return stamp
def _close(first, second):
"""Whether two stored stamps parse and lie within the tolerance."""
a, b = parse_stamp(first), parse_stamp(second)
if a is None or b is None:
return False
return abs((a - b).total_seconds()) <= PAIR_CREATED_TOLERANCE
def _batches(ids):
size = _maintenance.ID_BATCH
for start in range(0, len(ids), size):
yield ids[start : start + size]
def _sources(conn, ids):
"""``{prompt_id: [(pair_id, category, ((tag_id, name), ...)), ...]}``."""
pairs = {}
for batch in _batches(ids):
for pair_id, prompt_id, category in conn.execute(
_SOURCES_SQL.format(marks=_marks(batch)), batch
):
pairs.setdefault(prompt_id, []).append([pair_id, category, []])
by_pair = {pair[0]: pair for group in pairs.values() for pair in group}
pair_ids = sorted(by_pair)
for batch in _batches(pair_ids):
for pair_id, tag_id, name in conn.execute(
_PAIR_TAGS_SQL.format(marks=_marks(batch)), batch
):
by_pair[pair_id][2].append((tag_id, name))
return {
prompt_id: [(pid, category, tuple(tags)) for pid, category, tags in group]
for prompt_id, group in pairs.items()
}
def _field(values, guard):
"""``(class, value)`` of one field from its distinct values, or None."""
if not values:
return None
if guard:
return (guard, None)
if len(values) > 1:
return ("conflict", sorted(values))
return ("fill", next(iter(values)))
def _classify(row, sources):
"""The result of one considered prompt, or None when nothing applies."""
guard = None
if row["archived"]:
guard = "archived"
elif not _close(row["updated_at"], row["created_at"]):
guard = "edited"
category = tags = None
tag_pair = None
if row["blank_category"]:
category = _field({c for _p, c, _t in sources if c}, guard)
if row["untagged"]:
sets = {}
for pair_id, _category, pair_tags in sources:
if pair_tags:
sets.setdefault(frozenset(pair_tags), pair_id)
tags = _field(set(sets), guard)
if tags and tags[0] == "fill":
tag_pair = sets[tags[1]]
if len(tags[1]) > MAX_LIBRARY_TAGS:
tags = ("too-many-tags", None)
else:
tags = ("fill", tuple(sorted(tags[1])))
elif tags and tags[0] == "conflict":
tags = ("conflict", [sorted(s) for s in sorted(sets, key=sorted)])
if category is None and tags is None:
return None
return {
"id": row["id"],
"hash": row["hash"],
"text": row["text"],
"category": category,
"tags": tags,
"tag_pair": tag_pair if tags and tags[0] == "fill" else None,
"pairs": [pair_id for pair_id, _c, _t in sources],
}
def _state(conn):
"""``(results, fingerprint)``: every considered prompt's result, by id."""
considered = []
for row in conn.execute(_CANDIDATES_SQL):
row = dict(
zip(
(
"id",
"hash",
"text",
"created_at",
"updated_at",
"blank_category",
"untagged",
"archived",
"first_pair_at",
),
tuple(row),
)
)
if _close(row["created_at"], row["first_pair_at"]):
considered.append(row)
sources = _sources(conn, [row["id"] for row in considered])
fingerprint = _Fingerprint(OPERATION)
results = []
for row in considered:
result = _classify(row, sources.get(row["id"], []))
if result is None:
continue
results.append(result)
fingerprint.add(
[
result["id"],
result["hash"],
result["category"],
result["tags"],
result["tag_pair"],
result["pairs"],
]
)
return results, fingerprint.hexdigest()
def _fills(result):
return (
bool(result["category"] and result["category"][0] == "fill"),
bool(result["tags"] and result["tags"][0] == "fill"),
)
def _targets(results):
targets = []
for result in results:
category, tags = _fills(result)
if category or tags:
targets.append([result["id"], category, tags])
return targets[:MAX_PLAN_FILLS]
def _protected_classes(result):
"""The protected classes of a result's fields, in precedence order."""
found = {
field[0]
for field in (result["category"], result["tags"])
if field and field[0] != "fill"
}
return [key for key in _PROTECTED_ORDER if key in found]
def _more(values, limit):
shown = ", ".join(values[:limit])
return shown + (", …" if len(values) > limit else "")
def _fill_detail(result):
parts = []
category, tags = _fills(result)
if category:
parts.append(f"category “{result['category'][1]}”")
if tags:
names = sorted(name for _id, name in result["tags"][1])
noun = "tag" if len(names) == 1 else "tags"
parts.append(f"{len(names)} {noun}: {_more(names, DETAIL_TAGS)}")
pairs = [str(pair_id) for pair_id in result["pairs"]]
label = "pair" if len(pairs) == 1 else "pairs"
return f"Fills {' and '.join(parts)} ({label} {_more(pairs, DETAIL_VALUES)})"
def _protected_detail(result, key):
if key != "conflict":
return next(detail for k, detail, *_r in PROTECTED if k == key)
if result["category"] and result["category"][0] == "conflict":
values = result["category"][1]
return f"Kept: pairs disagree on category: {_more(values, DETAIL_VALUES)}"
return "Kept: pairs disagree on tags"
def _kept(results):
"""One row per kept prompt, its review reasons without "Kept: " (#550)."""
rows = []
for result in results:
classes = set(_protected_classes(result))
if classes:
rows.append(
{
"prompt_id": result["id"],
"reasons": [
_protected_detail(result, key).removeprefix("Kept: ")
for key in DETAIL_ORDER
if key in classes
],
}
)
return rows
def _details(results, targets):
rows = []
for key in DETAIL_ORDER:
for result in results:
if key in _protected_classes(result):
rows.append(
{
"label": _excerpt(result["text"]),
"detail": _protected_detail(result, key),
}
)
planned = {target[0] for target in targets}
for result in results:
if result["id"] in planned:
rows.append(
{"label": _excerpt(result["text"]), "detail": _fill_detail(result)}
)
if not targets:
return rows[:MAX_DETAILS]
return rows[: MAX_DETAILS - 1] + [{"label": "Record", "detail": RECORD_NOTE}]
def _scope_label(targets, total):
if not targets:
return EMPTY_LABEL
categories = sum(1 for _id, category, _tags in targets if category)
tag_sets = sum(1 for _id, _category, tags in targets if tags)
label = (
f"{_plural(len(targets), 'prompt')} to fill: "
f"{_plural(categories, 'category', 'categories')}, "
f"{_plural(tag_sets, 'tag set')}"
)
if total > len(targets):
label += (
f" (the first {len(targets):,} of {total:,}; review again for the rest)"
)
return label
def plan_pair_metadata(conn):
"""The reviewed ``pair_metadata`` plan on ``conn``'s snapshot."""
results, fingerprint = _state(conn)
targets = _targets(results)
total = sum(1 for result in results if any(_fills(result)))
counts = {key: 0 for key in _PROTECTED_ORDER}
held = 0
for result in results:
classes = _protected_classes(result)
held += bool(classes)
for key in classes:
counts[key] += 1
reasons = [
f"{counts[key]:,} {one if counts[key] == 1 else many}"
for key, _detail, one, many in PROTECTED
if counts[key]
]
return MaintenanceDraft(
operation=OPERATION,
fingerprint=fingerprint,
targets=targets,
scope={"count": len(targets), "label": _scope_label(targets, total)},
protected={"count": held, "reasons": reasons},
media={"deletes_files": False, "label": MEDIA_LABEL},
details=_details(results, targets),
)
def _fill_categories(conn, fills):
"""Set each blank category; raises PlanChanged when one is no longer blank."""
cursor = conn.executemany(
"UPDATE prompts SET category = ? WHERE id = ? "
"AND COALESCE(TRIM(category), '') = ''",
fills,
)
if cursor.rowcount != len(fills):
raise PlanChanged(OPERATION)
def _fill_tags(conn, fills, expected):
"""Copy each source pair's tags onto its untagged prompt."""
cursor = conn.executemany(
"INSERT INTO prompt_tags(prompt_id, tag_id) "
"SELECT ?, ppt.tag_id FROM prompt_pair_tags ppt "
"JOIN tags t ON t.id = ppt.tag_id WHERE ppt.pair_id = ? "
"AND NOT EXISTS (SELECT 1 FROM prompt_tags WHERE prompt_id = ?)",
fills,
)
if cursor.rowcount != expected:
raise PlanChanged(OPERATION)
def apply_pair_metadata(conn, fingerprint, targets):
"""Apply the reviewed fills on ``conn`` (inside BEGIN IMMEDIATE).
Returns ``{changed, categories, tag_sets, rows, kept}``; ``rows`` lists
every filled prompt (``{prompt_id, category?, tags?}``) and ``kept`` every
prompt the plan keeps (``{prompt_id, reasons}``, in the review's wording
and order), both never capped.
"""
results, current = _state(conn)
if current != fingerprint or _targets(results) != targets:
raise PlanChanged(OPERATION)
by_id = {result["id"]: result for result in results}
categories, tags, rows = [], [], []
tag_count = 0
for prompt_id, fill_category, fill_tags in targets:
result = by_id[prompt_id]
row = {"prompt_id": prompt_id}
if fill_category:
row["category"] = result["category"][1]
categories.append((row["category"], prompt_id))
if fill_tags:
row["tags"] = sorted(name for _id, name in result["tags"][1])
tags.append((prompt_id, result["tag_pair"], prompt_id))
tag_count += len(result["tags"][1])
rows.append(row)
if categories:
_fill_categories(conn, categories)
if tags:
_fill_tags(conn, tags, tag_count)
return {
"changed": len(rows),
"categories": len(categories),
"tag_sets": len(tags),
"rows": rows,
"kept": _kept(results),
}
+16 -11
View File
@@ -112,20 +112,22 @@ QUERY_PLAN_STATEMENTS = {
def _rewrite_row(conn, image_id, row, verified, checked):
"""Store the row's inline texts as documents; returns (changed, protected, size).
"""Store the row's inline texts as documents; returns (changed, kept, size).
``kept`` names the columns whose value stays inline (not text).
``verified`` maps document ids already read back in this batch to their
text, and ``checked`` holds the reused ids ``store_text`` already
checked, so a document shared by many rows is decompressed once.
"""
changes, replaced = {}, []
protected = size = 0
changes, replaced, kept = {}, [], []
size = 0
for index, (column, reference) in enumerate(TEXT_COLUMNS):
value, current = row[index], row[index + len(TEXT_COLUMNS)]
if value is None:
continue
if not storable(value):
protected += 1
kept.append(column)
continue
document_id = store_text(conn, value, checked)
if document_id not in verified:
@@ -143,7 +145,7 @@ def _rewrite_row(conn, image_id, row, verified, checked):
[*changes.values(), image_id],
)
release_documents(conn, replaced)
return bool(changes), protected, size
return bool(changes), kept, size
def migrate_batch(
@@ -160,9 +162,10 @@ def migrate_batch(
savepoint: a row that cannot be rewritten (``_row_error``) is rolled
back alone, stays inline and is listed in ``errors``; anything else rolls
the whole batch back and propagates. Returns ``{last_id, rows,
protected, errors, done}``: the last row looked at, rows rewritten,
inline values kept (not text), the ids kept by an error and whether
there was nothing left to look at.
protected, kept, errors, done}``: the last row looked at, rows
rewritten, inline values kept (not text) and each of them as ``(image
id, column)`` (#586), the ids kept by an error and whether there was
nothing left to look at.
"""
if conn.in_transaction:
raise RuntimeError("The migration needs its own transaction")
@@ -174,6 +177,7 @@ def migrate_batch(
"last_id": after_id,
"rows": 0,
"protected": 0,
"kept": [],
"errors": [],
"done": not ids,
}
@@ -182,7 +186,7 @@ def migrate_batch(
conn.execute("SAVEPOINT workflow_row")
try:
row = conn.execute(ROW_SQL, (image_id,)).fetchone()
changed, protected, row_size = _rewrite_row(
changed, kept, row_size = _rewrite_row(
conn, image_id, row, verified, checked
)
except Exception as error:
@@ -194,11 +198,12 @@ def migrate_batch(
checked.clear()
logger.warning(f"Image record {image_id} kept inline: {error}")
result["errors"].append(image_id)
changed, protected, row_size = False, 0, 0
changed, kept, row_size = False, [], 0
else:
conn.execute("RELEASE workflow_row")
result["rows"] += int(changed)
result["protected"] += protected
result["protected"] += len(kept)
result["kept"].extend((image_id, column) for column in kept)
result["last_id"] = image_id
size += row_size
if size >= max_bytes or _clock() - started >= max_seconds:
+31 -14
View File
@@ -11,6 +11,7 @@ Split into domain-specific mixins for maintainability:
- JobRoutesMixin: the session job registry behind Activity (py/api/jobs.py)
- ImageTagJobsMixin: bulk Add tags / Auto-tag as image-tags jobs
- ThumbnailJobsMixin: thumbnail plan, thumbnail job and the gallery stream on it
- ThumbnailLocationMixin: the thumbnail location check and payloads (#320)
- ThumbnailConvertMixin: Convert outdated thumbnails (#317), a thumbnail job mode
- AutoThumbnailMixin: previews made on first view, on capture and after a scan
- ReviewRoutesMixin: the Needs review queue, item detail and decisions
@@ -60,6 +61,11 @@ from .settings_routes import SettingsRoutesMixin
from .job_routes import JobRoutesMixin
from .image_tag_jobs import ImageTagJobsMixin
from .thumbnail_jobs import ThumbnailJobsMixin
from .thumbnail_location import ThumbnailLocationMixin
from .thumbnail_move import ThumbnailMoveMixin
from .thumbnail_move_plan import ThumbnailMovePlanMixin
from .thumbnail_move_recovery import ThumbnailMoveRecoveryMixin
from .thumbnail_prune import ThumbnailPruneMixin
from .thumbnail_convert import ThumbnailConvertMixin
from .thumbnail_auto import AutoThumbnailMixin
from .system import SystemRoutesMixin
@@ -85,6 +91,8 @@ try:
from ...database.operations import PromptDatabase
from ...utils.file_prompt import parse_comfyui_prompt
from ...utils.logging_config import get_logger
from ...utils.native_memory import release_free_memory
from ...utils.png_text import png_text
from ...utils.video_metadata import is_video_path, read_video_metadata
except ImportError:
import sys
@@ -95,6 +103,8 @@ except ImportError:
from database.operations import PromptDatabase
from utils.file_prompt import parse_comfyui_prompt
from utils.logging_config import get_logger
from utils.native_memory import release_free_memory
from utils.png_text import png_text
from utils.video_metadata import is_video_path, read_video_metadata
@@ -334,6 +344,11 @@ class PromptManagerAPI(
JobRoutesMixin,
ImageTagJobsMixin,
ThumbnailJobsMixin,
ThumbnailLocationMixin,
ThumbnailMovePlanMixin,
ThumbnailMoveMixin,
ThumbnailMoveRecoveryMixin,
ThumbnailPruneMixin,
ThumbnailConvertMixin,
AutoThumbnailMixin,
SystemRoutesMixin,
@@ -526,6 +541,14 @@ class PromptManagerAPI(
return await loop.run_in_executor(None, call)
return await loop.run_in_executor(None, func, *args)
async def _release_job_memory(self):
"""After a scan or thumbnail job: glibc's free heap pages go back to
the OS once (``malloc_trim(0)``; nothing elsewhere, #523)."""
try:
await self._run_in_executor(release_free_memory)
except Exception: # best effort: the job's result already stands
self.logger.debug("Releasing free memory failed", exc_info=True)
def _root_listing(self, root) -> RootListing:
"""The shared listing record of one output root (created empty)."""
return self._root_listings.setdefault(str(root), RootListing())
@@ -704,6 +727,7 @@ class PromptManagerAPI(
# ── Register domain-specific routes from mixins ───────────────
self._register_pair_routes(routes)
self._register_thumbnail_move_routes(routes)
self._register_library_routes(routes)
self._register_library_delete_routes(routes)
self._register_vocabulary_routes(routes)
@@ -840,20 +864,11 @@ class PromptManagerAPI(
f"{url_quote(rel_path.as_posix(), safe='/')}"
)
# Any preview the file owns, the current format first (#317)
from . import thumbnail_settings
from .prompts import thumbnail_url_for
# Any preview the file owns, the current format first
# (#317); no store I/O here in folder mode (#320).
from .images import listing_thumbnail_url
existing = thumbnail_settings.existing_thumbnail(
output_path, rel_path
)
thumbnail_url = None
if existing is not None:
thumb_rel = (
thumbnail_settings.thumbnail_stem_rel(rel_path)
+ existing.suffix
)
thumbnail_url = thumbnail_url_for(thumb_rel, existing)
thumbnail_url = listing_thumbnail_url(output_path, rel_path)
if thumbnail_url:
image["thumbnail_url"] = thumbnail_url
break # Found matching root, stop searching
@@ -1048,6 +1063,8 @@ class PromptManagerAPI(
def _extract_comfyui_metadata(self, image_path):
"""Embedded ComfyUI metadata of an image (PNG text chunks) or a video.
PNG text is read without decoding the pixels (``utils.png_text``, #523).
Videos go through ffprobe (see utils/video_metadata). Unreadable files
are a warning, not an error: a scan over a large output tree meets
truncated and foreign files, and it carries on past them.
@@ -1056,7 +1073,7 @@ class PromptManagerAPI(
return read_video_metadata(image_path)
try:
with Image.open(image_path) as img:
return dict(getattr(img, "text", None) or {})
return png_text(img)
except Exception as e:
self.logger.warning(
f"Unreadable image {os.path.basename(str(image_path))}: {e}"
+5 -3
View File
@@ -1454,6 +1454,7 @@ class AdminRoutesMixin:
verb = "cancelled" if state == "cancelled" else "finished"
job.log_line(f"Scan {verb} · {_plural(job.processed, 'file')} processed")
job.finish(state)
await self._release_job_memory()
if state == "finished":
# After the scan, so the guarded thumbnail job is admitted (#249).
await self._hand_off_scan_thumbnails(job, output_dirs)
@@ -1668,9 +1669,10 @@ class AdminRoutesMixin:
def _extract_batch_metadata_sync(self, file_batch):
"""Extract metadata from a batch of files in one executor call.
Files are read on ``PromptManagerConfig.WORKER_THREADS`` threads:
Pillow decodes the whole image to reach trailing text chunks and
releases the GIL while doing so.
Files are read on the long-lived job pool of
``PromptManagerConfig.WORKER_THREADS`` threads (#523).
PNG text is read chunk by chunk without decoding pixels (#523); file
reads and ffprobe release the GIL.
"""
from ..config import PromptManagerConfig
+11 -13
View File
@@ -19,8 +19,8 @@ from .jobs import (
)
from .maintenance import disk_plan
from .prompts import thumbnail_url_for
from . import thumbnail_settings
from .images import listing_thumbnail_url
from . import preview_store
from .. import model_gate
try:
@@ -1486,23 +1486,21 @@ class AutotagRoutesMixin:
files = await self._run_in_executor(
self._without_removed, output_path, files
)
has_thumbnails = (output_path / "thumbnails").is_dir()
location = preview_store.current()
if location.mode == preview_store.FOLDER_MODE:
has_thumbnails = location.available # no store I/O here (#320)
else:
store = preview_store.store_dir(output_path)
has_thumbnails = store is not None and store.is_dir()
images = []
for image_path in files:
rel_path = image_path.relative_to(output_path)
thumbnail_url = None
if has_thumbnails:
# Any preview the file owns, the current format first (#317)
existing = thumbnail_settings.existing_thumbnail(
output_path, rel_path
)
if existing is not None:
thumb_rel = (
thumbnail_settings.thumbnail_stem_rel(rel_path)
+ existing.suffix
)
thumbnail_url = thumbnail_url_for(thumb_rel, existing)
# Any preview the file owns, the current format first
# (#317); in folder mode without store I/O (#320).
thumbnail_url = listing_thumbnail_url(output_path, rel_path)
images.append(
{
"filename": image_path.name,
+12 -1
View File
@@ -65,6 +65,7 @@ from .duplicate_review import ( # noqa: F401 (CHANGED, OUTSIDE_SOURCE: public)
NOT_ATTEMPTED,
OUTSIDE_SOURCE,
RECORDS_FAILED,
REVIEW_KEPT_DETAILS,
ROW_DETAILS,
SAME_FILE,
SOURCES_CHANGED,
@@ -434,6 +435,7 @@ class DuplicateFilesMixin:
{
"targets": review["targets"],
"kept": review["kept"],
"held": review["held"],
"protected": review["public"]["protected"]["count"],
},
)
@@ -446,7 +448,7 @@ class DuplicateFilesMixin:
job hashes each file again right before it unlinks it.
"""
listed = self._listed_sources()
targets, kept, protected = [], [], {}
targets, kept, protected, held = [], [], {}, []
for group, keepers in selection.values():
removals = [r for r in group["files"] if r not in keepers]
for record in removals:
@@ -467,6 +469,7 @@ class DuplicateFilesMixin:
problem = _bound_problem(record, group, keeper)
if problem:
protected[problem] = protected.get(problem, 0) + 1
held.append(self._copy_row(record, REVIEW_KEPT_DETAILS[problem]))
continue
reviewed.append(self._target(group, record, keeper))
if reviewed:
@@ -488,6 +491,7 @@ class DuplicateFilesMixin:
return {
"targets": targets,
"kept": kept,
"held": held,
"fingerprint": _fingerprint(targets),
"public": _public_plan(targets, protected, records),
}
@@ -584,6 +588,13 @@ class DuplicateFilesMixin:
async def _remove_duplicates(self, job, db, plan, backup):
targets = plan.payload["targets"]
# #586: every copy the review protected, with its reason, and the
# whole group in the report.
held = plan.payload["held"]
job.record_counted("kept", len(held), held, complete=True)
if held:
copies = _plural(len(held), "copy", "copies")
job.log_line(f"Kept {copies} the review protected")
for row in plan.payload["kept"]:
job.record("kept", row, f"= {row['root']}/{row['path']} · {KEPT_COPY}")
job.update(total=len(targets), status_line="Removing duplicate files")
+12
View File
@@ -70,6 +70,18 @@ PROTECTED_REASONS = {
"files kept: their kept copy changed or is gone",
),
}
# The job's row for a copy the review protected (#586): the review's reason.
REVIEW_KEPT_DETAILS = {
MAYBE_SAME: "Kept: may be the same file reached through another mount or share",
NO_FILE_ID: "Kept: can't confirm this is a separate file on this drive",
SAME: "Kept: the same file as its kept copy",
OUTSIDE_SOURCE: "Kept: not inside a configured source",
LINK: "Kept: a link, not a regular file",
CHANGED_CONTENT: "Kept: changed since the search",
GONE: "Kept: no longer exists",
UNREADABLE: "Kept: could not be read",
KEEPER: "Kept: its kept copy changed or is gone",
}
# Job outcome rows (never an OS error text).
CHANGED = "The file changed since the review; it was kept."
KEEPER_CHANGED = "The kept copy changed or is gone; this file was kept."
+4 -1
View File
@@ -843,7 +843,10 @@ class HistoryRoutesMixin:
raise
async def execute():
pending = asyncio.create_task(self._run_in_executor(project))
# An inspection reads the file's header and text: the
# interactive image pool (#523); media stays on the default.
run = self._run_image if kind == "inspection" else self._run_in_executor
pending = asyncio.create_task(run(project))
try:
return await asyncio.shield(pending)
except asyncio.CancelledError:
+40 -15
View File
@@ -15,10 +15,10 @@ from typing import BinaryIO
from aiohttp import web
from . import thumbnail_settings
from . import preview_store, thumbnail_settings
from .images import _canonical, _public_path, SERVABLE_EXTENSIONS, VIDEO_EXTENSIONS
from .images import in_trash_below_roots
from .images import existing_media_thumbnail, media_thumbnail_rel
from .images import existing_media_thumbnail, media_preview_path
try:
from ...utils.execution_outputs import (
@@ -244,19 +244,36 @@ def resolve_current_candidate(
return _unavailable("temporary_unavailable", error=True)
def _target_authorized(target, namespace, scope, store_root=None) -> bool:
"""A preview in the thumbnail folder (#320, folder mode) is authorized by
the store of its source (``preview_store.owns``: inside the key folder,
no link); everything else by the output namespace and the roots."""
if (
store_root is not None
and preview_store.current().mode == preview_store.FOLDER_MODE
):
return preview_store.owns(store_root, target)
return _authorized(target, namespace, scope)
def _open_current_target(
target: Path, namespace: Path, scope: CurrentOutputScope, preview: bool = False
target: Path,
namespace: Path,
scope: CurrentOutputScope,
preview: bool = False,
store_root: Path | None = None,
) -> BinaryIO:
"""Open only the canonical target and verify authority against the actual handle.
A *preview* (a file under ``thumbnails/``) may also be JPEG XL (#317)."""
A *preview* (a file under ``thumbnails/``) may also be JPEG XL (#317); in
folder mode it is authorized by the store of *store_root* (#320)."""
if _canonical(target) != target:
raise CurrentLocationUnavailable("not_authorized", stale=True)
if in_trash_below_roots(target, [namespace]):
# Also at the open: the cover chooses its preview after the candidate
# check, and a preview can be a link into a trash folder (#543).
raise CurrentLocationUnavailable("missing")
if not _authorized(target, namespace, scope):
if not _target_authorized(target, namespace, scope, store_root):
raise CurrentLocationUnavailable("not_authorized")
suffix = target.suffix.lower()
if suffix not in SERVABLE_EXTENSIONS and not (
@@ -289,7 +306,7 @@ def _open_current_target(
if (
_canonical(target) != target
or (observed.st_dev, observed.st_ino) != (current.st_dev, current.st_ino)
or not _authorized(target, namespace, scope)
or not _target_authorized(target, namespace, scope, store_root)
):
raise CurrentLocationUnavailable("not_authorized", stale=True)
return os.fdopen(fd, "rb")
@@ -326,12 +343,10 @@ class _OpenedCurrentMedia:
def _preview_writable(root: Path, preview: Path) -> bool:
"""A preview may be written: ``thumbnails`` and every link segment of the
preview's folder resolve inside *root* (the serve route's rule, #249)."""
folder = root / "thumbnails"
return _canonical(folder).is_relative_to(_canonical(root)) and _canonical(
preview
).is_relative_to(_canonical(folder))
"""A preview may be written: the store of *root* and every link segment
of the preview's folder resolve inside it (the serve route's rule, #249;
``preview_store.owns``)."""
return preview_store.owns(root, preview)
def _is_jxl(path) -> bool:
@@ -381,8 +396,12 @@ def _cover_target(resolved, ensure_preview, placeholder, accepts_jxl):
return _canonical(preview)
if preview is not None:
return _canonical(preview)
preview = resolved.gallery_root / media_thumbnail_rel(relative)
if ensure_preview is not None and _preview_writable(resolved.gallery_root, preview):
preview = media_preview_path(resolved.gallery_root, relative, create=True)
if (
ensure_preview is not None
and preview is not None
and _preview_writable(resolved.gallery_root, preview)
):
ensure_preview(target, preview, is_video)
if is_video and placeholder is not None:
return _placeholder_media(placeholder)
@@ -418,7 +437,13 @@ def _prepare_current_media(
return chosen
if chosen is not None:
target, is_preview = chosen, True
handle = _open_current_target(target, resolved.namespace, scope, is_preview)
handle = _open_current_target(
target,
resolved.namespace,
scope,
is_preview,
resolved.gallery_root if is_preview else None,
)
try:
return _OpenedCurrentMedia(
handle,
+204 -31
View File
@@ -34,7 +34,8 @@ from .gallery_listing import (
WalkOutcome,
walk_root,
)
from . import thumbnail_settings
from . import preview_store, thumbnail_settings
from .thumbnail_location import location_refusal
from .thumbnail_encode import (
PREVIEW_HELD,
EncoderClosed,
@@ -1068,13 +1069,24 @@ def _iter_media_files(output_path, max_files):
def media_thumbnail_rel(rel_path, settings=None):
"""``thumbnails/<rel>_thumb<ext>`` (POSIX) for the media file *rel_path*:
the name the preview gets when it is written now (#317), images and
videos alike (``writer_settings``' extension)."""
"""``thumbnails/<rel>_thumb<ext>`` (POSIX URL text) for the media file
*rel_path*: the name the preview gets when it is written now (#317),
images and videos alike (``writer_settings``' extension). Where it is on
disk is ``media_preview_path``."""
settings = settings or writer_settings()
return thumbnail_settings.thumbnail_rel(rel_path, settings.extension)
def media_preview_path(output_path, rel_path, settings=None, *, create=False):
"""Where the preview of *rel_path* is written now (``preview_store``);
*create* for a writer (a new source gets its key). None when nothing
may be written (the thumbnail folder is unavailable)."""
settings = settings or writer_settings()
return preview_store.preview_path(
output_path, rel_path, settings.extension, create=create
)
def existing_media_thumbnail(output_path, rel_path):
"""The existing preview *rel_path* owns that a listing or cover shows
(the format written now first, then older ones), or None. The Images
@@ -1084,6 +1096,27 @@ def existing_media_thumbnail(output_path, rel_path):
)
def listing_thumbnail_url(output_path, rel_path):
"""The thumbnail URL of an older listing (``_enrich_images``, the
auto-tag scan), or None. These run on the event loop: in folder mode
(#320) the store is never touched there, so the URL names the preview
as it is written now and the serve route finds or makes it off the loop
(None while the thumbnail folder is unavailable)."""
from .prompts import thumbnail_url_for
location = preview_store.current()
if location.mode == preview_store.FOLDER_MODE:
if not location.available:
return None
quoted = urllib.parse.quote(media_thumbnail_rel(rel_path), safe="/")
return f"/prompt_manager/images/serve/{quoted}"
existing = thumbnail_settings.existing_thumbnail(output_path, rel_path)
if existing is None:
return None
thumb_rel = thumbnail_settings.thumbnail_stem_rel(rel_path) + existing.suffix
return thumbnail_url_for(thumb_rel, existing)
def media_thumbnail(output_path, rel_path, root_index, *, root_scope=None):
"""``(thumbnail_url, ready)`` for *rel_path* under *output_path* (a few
blocking stats). The Images listing, the Needs review queue cards and the
@@ -2798,13 +2831,56 @@ class ImageRoutesMixin:
output_dirs[0], filepath, int(root_param), len(outputs)
)
# Preview text is mapped by the resolver (#320); any other text,
# ``Thumbnails/...`` media on a case-sensitive disk included,
# keeps the plain join.
preview_parts = (
tuple(filepath.split("/"))
if preview_store.is_preview_text(filepath)
else None
)
location = preview_store.current()
if location.mode == preview_store.FOLDER_MODE:
if preview_parts is None and await self._run_image(
self._in_source_thumbnails, output_dirs, filepath
):
# Previews live in the thumbnail folder: anything else
# under a source's thumbnails/ is a leftover (#320), in
# any case on a case-insensitive disk.
return _forbidden()
if preview_parts is not None:
# Store I/O never runs on the event loop (a share may
# stall): the lookup runs on the image pool.
found = await self._run_image(
self._find_preview, output_dirs, preview_parts, location
)
if found == "forbidden":
return _forbidden()
if found is not None:
return self._file_response(found)
for output_path in output_dirs:
image_path = output_path / filepath
if (
preview_parts is not None
and location.mode == preview_store.FOLDER_MODE
):
break # looked up above
if preview_parts is None:
image_path = output_path / filepath
else:
image_path = preview_store.request_path(output_path, preview_parts)
if image_path is None:
continue
if not image_path.exists():
continue
# A symlink that points outside the root is a hard deny, not
# a fall-through to the next root.
if not _is_within(image_path, output_path):
# A symlink that points outside the root (a preview: outside
# the folder holding its store) is a hard deny, not a
# fall-through to the next root.
container = (
output_path
if preview_parts is None
else preview_store.store_container(output_path)
)
if not _is_within(image_path, container):
return _forbidden()
if in_trash_below_roots(image_path, output_dirs):
return _file_not_found()
@@ -2830,6 +2906,48 @@ class ImageRoutesMixin:
{"success": False, "error": _safe_error(e)}, status=500
)
@staticmethod
def _in_source_thumbnails(output_dirs, filepath):
"""Whether *filepath* lies in a source's ``thumbnails`` folder: its
first part is ``thumbnails`` exactly, or another case of it that is
the same folder (a case-insensitive disk). ``Thumbnails/`` media on
a case-sensitive disk is another folder and stays served."""
first = filepath.split("/", 1)[0]
if first == "thumbnails":
return True
if first.casefold() != "thumbnails":
return False
for output_path in output_dirs:
spelled = output_path / first
cache = preview_store.source_folder(output_path)
try:
if spelled.is_dir() and cache.is_dir() and spelled.samefile(cache):
return True
except OSError:
continue
return False
@staticmethod
def _find_preview(output_dirs, parts, location):
"""The preview file *parts* names in folder mode, ``"forbidden"``
(it resolves out of its store or through a link) or None (blocking;
the first root whose store holds it, in ``_select_root`` order)."""
for output_path in output_dirs:
image_path = preview_store.request_path(output_path, parts, location)
if image_path is None:
continue
try:
with preview_store.store_io():
if not os.path.lexists(image_path):
continue
if not preview_store.owns(output_path, image_path, location):
return "forbidden"
if image_path.is_file():
return image_path
except OSError:
return None
return None
def _is_recorded_media(self, path) -> bool:
"""Whether the database records ``path`` as a linked image.
@@ -2956,6 +3074,9 @@ class ImageRoutesMixin:
data, error = await _json_body_or_error(request)
if error is not None:
return error
refused = location_refusal()
if refused is not None:
return refused
return await self._run_held(
THUMBNAILS_JOB_KIND,
THUMBNAIL_GENERATION_TITLE,
@@ -2975,6 +3096,7 @@ class ImageRoutesMixin:
result = await self._run_in_executor(
self._generate_gallery_thumbnails_sync, output_dirs
)
await self._release_job_memory()
return web.json_response(result)
except Exception as e:
@@ -2987,6 +3109,7 @@ class ImageRoutesMixin:
"""Blocking thumbnail generation loop (run in executor)."""
settings = writer_settings(settings)
start = _time.monotonic()
thumbnails_dir = preview_store.given_store(output_path, thumbnails_dir)
plan = self._plan_thumbnails(output_path, thumbnails_dir, settings=settings)
payload = self._execute_thumbnail_plan(plan, settings, start)
payload["thumbnails_path"] = _public_path(thumbnails_dir)
@@ -2999,7 +3122,8 @@ class ImageRoutesMixin:
start = _time.monotonic()
plan = self._plan_gallery_thumbnails(output_dirs, settings=settings)
payload = self._execute_thumbnail_plan(plan, settings, start)
payload["thumbnails_path"] = _public_path(output_dirs[0] / "thumbnails")
store = preview_store.store_dir(output_dirs[0])
payload["thumbnails_path"] = None if store is None else _public_path(store)
return payload
def _execute_thumbnail_plan(self, plan: dict, settings, start: float) -> dict:
@@ -3042,9 +3166,12 @@ class ImageRoutesMixin:
if stop is not None and stop():
break
root = Path(root)
store = preview_store.ensure_store(root)
if store is None:
continue # the thumbnail folder is unavailable
plan = self._plan_thumbnails(
root,
root / "thumbnails",
store,
mode=mode,
stop=stop,
settings=settings,
@@ -3083,6 +3210,8 @@ class ImageRoutesMixin:
"format": writer_settings().format,
# Files whose automatic preview failed this session (#249).
"failed": self._auto_thumbnail_failures(),
# #320: where previews live (no path here, D6A).
"location": self._thumbnail_location_payload(with_path=False),
"collection_id": hashlib.sha256(collection.encode()).hexdigest()[
:16
],
@@ -3128,9 +3257,11 @@ class ImageRoutesMixin:
"""
settings = writer_settings(settings)
output_path = Path(output_path)
thumbnails_dir = Path(thumbnails_dir)
thumbnails_dir = preview_store.given_store(output_path, thumbnails_dir)
plan = {key: 0 for key in _PLAN_COUNTS}
plan.update(targets=[], fresh_files=[])
if thumbnails_dir is None:
return plan # the thumbnail folder is unavailable
folders = {}
removed = self._removal_check_under(output_path)
for src in _iter_media_files(output_path, MAX_GALLERY_FILES):
@@ -3220,6 +3351,9 @@ class ImageRoutesMixin:
"dir": src.parent.name,
"type": "video" if is_video else "image",
}
# Taken when the write starts: a thumbnail location switched since
# then drops the write at its rename (#320).
guard = preview_store.write_guard(guard)
dst = Path(dst)
if dst.suffix != settings.extension:
dst = dst.with_suffix(settings.extension)
@@ -3230,7 +3364,7 @@ class ImageRoutesMixin:
return {**result, "action": "error", "error": "File no longer exists"}
if not rebuild and _thumbnail_is_fresh(src, dst):
return {**result, "action": "skipped", "path": str(dst)}
dst.parent.mkdir(parents=True, exist_ok=True)
preview_store.make_parent(dst)
if is_video:
made = self._generate_video_thumbnail(
src,
@@ -3435,8 +3569,12 @@ class ImageRoutesMixin:
Invalid thumbnail directories return 400; unavailable gallery roots
return 404, and unexpected filesystem failures return 500. Holds the
registry as guarded work, like generation.
registry as guarded work, like generation. Refused (409) while the
thumbnail folder is unavailable (#320).
"""
refused = location_refusal()
if refused is not None:
return refused
return await self._run_held(
THUMBNAILS_JOB_KIND,
THUMBNAIL_CLEAR_TITLE,
@@ -3500,8 +3638,12 @@ class ImageRoutesMixin:
self.logger.debug(f"No roots to sweep for preview temp files: {exc}")
return 0
for output_path in output_dirs:
folder = Path(output_path) / "thumbnails"
if folder.is_symlink() or not folder.is_dir():
folder = preview_store.store_dir(output_path)
if (
folder is None
or preview_store.store_is_link(folder)
or not folder.is_dir()
):
continue
removed += sweep_stray_temps(folder)[0]
if removed:
@@ -3519,13 +3661,16 @@ class ImageRoutesMixin:
directories = []
for output_path in output_dirs:
output_path = Path(output_path)
thumbnails_dir = output_path / "thumbnails"
thumbnails_dir = preview_store.store_dir(output_path)
if thumbnails_dir is None:
continue
if not thumbnails_dir.exists() and not thumbnails_dir.is_symlink():
continue
container = preview_store.store_container(output_path)
if (
thumbnails_dir.is_symlink()
or not _is_within(thumbnails_dir, output_path)
or _canonical(thumbnails_dir) == _canonical(output_path)
preview_store.store_is_link(thumbnails_dir)
or not _is_within(thumbnails_dir, container)
or _canonical(thumbnails_dir) == _canonical(container)
):
raise ValueError(
"Safety check failed: invalid thumbnails directory path"
@@ -3540,7 +3685,15 @@ class ImageRoutesMixin:
temps, temp_bytes = sweep_stray_temps(thumbnails_dir, min_age=0)
cleared_count += temps
cleared_size += temp_bytes
for root, _dirs, files in os.walk(thumbnails_dir):
folder_mode = preview_store.current().mode == preview_store.FOLDER_MODE
for root, dirs, files in os.walk(thumbnails_dir):
if folder_mode:
# Never into a junction or reparse point (#320).
dirs[:] = [
d
for d in dirs
if not preview_store.store_is_link(Path(root) / d)
]
for name in files:
path = Path(root) / name
if (
@@ -3548,6 +3701,7 @@ class ImageRoutesMixin:
or path.suffix.lower()
not in thumbnail_settings.THUMBNAIL_EXTENSIONS
or path.is_symlink()
or not preview_store.may_write(path)
):
continue
try:
@@ -3997,7 +4151,8 @@ class ImageRoutesMixin:
**observed,
)
body = await self._run_in_executor(inspect)
# Header and text reads: the interactive image pool (#523).
body = await self._run_image(inspect)
if body is None:
return web.json_response(
{
@@ -4210,6 +4365,22 @@ class ImageRoutesMixin:
# (which Windows refuses on open files) is not held up by it.
return _CardFacts(width, height, *self._card_parse(path, records))
def _cached_card_facts(self, entry: tuple) -> _CardFacts | None:
path, mtime, root, index = entry[:4]
return self._workspace_card_cache.get((index, str(root)), {}).get((path, mtime))
def _card_facts_of(self, entries: list, *, parse: bool) -> list[_CardFacts]:
"""Card facts of *entries*, in order; files not cached yet are read on
the interactive image pool (#523), cached ones answer here."""
found = [self._cached_card_facts(entry) for entry in entries]
missing = [i for i, facts in enumerate(found) if facts is None]
read = self._map_image(
lambda i: self._card_facts(entries[i], parse=parse), missing
)
for i, facts in zip(missing, read):
found[i] = facts
return found
def _card_facts(self, entry: tuple, *, parse: bool) -> _CardFacts:
"""One listed image's card facts, read once per ``(path, mtime)``.
@@ -4218,11 +4389,11 @@ class ImageRoutesMixin:
and drops the rest, and a roots change drops them all. Only files of
the current scan are stored, so the cache is bounded by the listing.
"""
path, mtime, root, index = entry[:4]
root_key, key = (index, str(root)), (path, mtime)
cached = self._workspace_card_cache.get(root_key, {}).get(key)
cached = self._cached_card_facts(entry)
if cached is not None:
return cached
path, mtime, root, index = entry[:4]
root_key, key = (index, str(root)), (path, mtime)
facts = self._read_card_facts(path, parse=parse)
if facts is None:
# Not stored: a locked or half-written file may read next time.
@@ -4248,15 +4419,17 @@ class ImageRoutesMixin:
if linked
else {}
)
# Videos: the card reads no video metadata. Library previews show
# no card facts, so they skip the parse (``parse=linked``).
stills = [image for image in images if not image["is_video"]]
read = self._card_facts_of(
[entries[(image["root_index"], image["path"])] for image in stills],
parse=linked,
)
facts_of = {id(image): facts for image, facts in zip(stills, read)}
for image in images:
entry = entries[(image["root_index"], image["path"])]
# Videos: the card reads no video metadata. Library previews show
# no card facts, so they skip the parse (``parse=linked``).
facts = (
_NO_CARD_FACTS
if image["is_video"]
else self._card_facts(entry, parse=linked)
)
facts = facts_of.get(id(image), _NO_CARD_FACTS)
image["width"], image["height"] = facts.width, facts.height
if not linked:
continue
+39 -25
View File
@@ -204,6 +204,8 @@ class Job:
self.total = 0
self.counters = dict.fromkeys(groups, 0)
self.outcomes = {group: [] for group in groups}
# Groups whose report lists every row, beyond MAX_OUTCOME_ROWS (#550).
self.complete = {}
self.log = []
self._cancel_requested = False
self.last_event = None
@@ -286,42 +288,48 @@ class Job:
index = row.get("root_index")
if isinstance(index, bool) or not isinstance(index, int) or index < 0:
index = None
shaped = {
"path": path,
"root": str(row.get("root") or ""),
"root_index": index,
"detail": str(row.get("detail") or ""),
}
if group in self.complete: # a group kept whole for the report (#586)
self.complete[group].append(dict(shaped))
if len(rows) < MAX_OUTCOME_ROWS:
rows.append(
{
"path": path,
"root": str(row.get("root") or ""),
"root_index": index,
"detail": str(row.get("detail") or ""),
}
)
rows.append(shaped)
self.log_line(log_line)
def record_counted(self, group, count, rows=()):
def record_counted(self, group, count, rows=(), *, complete=False):
"""Count ``count`` outcomes in ``group`` at once, listing ``rows``.
For outcomes that need no log line each (a thumbnail job's already
fresh previews): the counter is exact, ``rows`` (``record``'s row
shape) are listed up to MAX_OUTCOME_ROWS, and nothing is logged.
``complete`` also keeps every row for the report (#550: a record of
each change that must not be cut short; the caller bounds it).
"""
if group not in self.counters:
raise ValueError(f"Unknown outcome group {group!r} for {self.kind}")
self.counters[group] += max(0, int(count))
listed = self.outcomes[group]
kept = self.complete.setdefault(group, []) if complete else None
for row in rows:
if len(listed) >= MAX_OUTCOME_ROWS:
if kept is None and len(listed) >= MAX_OUTCOME_ROWS:
break
index = row.get("root_index")
if isinstance(index, bool) or not isinstance(index, int) or index < 0:
index = None
listed.append(
{
"path": _relative_path(row.get("path")),
"root": str(row.get("root") or ""),
"root_index": index,
"detail": str(row.get("detail") or ""),
}
)
shaped = {
"path": _relative_path(row.get("path")),
"root": str(row.get("root") or ""),
"root_index": index,
"detail": str(row.get("detail") or ""),
}
if kept is not None:
kept.append(shaped)
if len(listed) < MAX_OUTCOME_ROWS:
listed.append(dict(shaped))
def finish(self, state="finished"):
"""End the job in ``state`` and release every subscriber.
@@ -406,8 +414,9 @@ class Job:
"""The downloadable report: the summary, every outcome group and the log.
Each group carries its exact count, its listed rows (at most
MAX_OUTCOME_ROWS) and whether the list is cut short. Rows and log lines
already hold root-relative paths only.
MAX_OUTCOME_ROWS, or every row of a ``complete`` group) and whether
the list is cut short. Rows and log lines already hold root-relative
paths only.
"""
summary = self.summary()
summary.pop("listed", None)
@@ -417,16 +426,21 @@ class Job:
"generated_at": self._clock(),
"job": summary,
"outcomes": {
group: {
"count": self.counters.get(group, len(rows)),
"rows": [dict(row) for row in rows],
"truncated": self.counters.get(group, len(rows)) > len(rows),
}
group: self._report_group(group, rows)
for group, rows in self.outcomes.items()
},
"log": list(self.log),
}
def _report_group(self, group, listed):
rows = self.complete.get(group, listed)
count = self.counters.get(group, len(rows))
return {
"count": count,
"rows": [dict(row) for row in rows],
"truncated": count > len(rows),
}
def report_filename(self):
"""The download name: prompt-manager-KIND-YYYYMMDD-HHMM.json (local time)."""
stamp = time.strftime("%Y%m%d-%H%M", time.localtime(self._clock()))
+23
View File
@@ -50,6 +50,7 @@ _REFUSAL_STATUS = {
"not-movable": 400,
"same-folder": 400,
"target-invalid": 400,
"data-dir-served": 400,
"target-has-library": 409,
"target-conflict": 409,
"insufficient-space": 507,
@@ -74,6 +75,24 @@ def _failure(message, status, code):
)
def _thumbnail_folder():
"""The thumbnail folder in use, or None (previews inside each source)."""
from . import preview_store
setting = preview_store.setting()
return setting.path if setting.mode == preview_store.FOLDER_MODE else None
def _inside_thumbnail_folder(path):
"""Whether *path* lies in the thumbnail folder in use (#320)."""
from . import preview_store
setting = preview_store.setting()
if setting.mode != preview_store.FOLDER_MODE or not setting.path:
return False
return ll._inside_any(path, [setting.path])
def _plural(count, one, many=None):
return f"{count:,} {one if count == 1 else (many or one + 's')}"
@@ -225,6 +244,8 @@ class LibraryLocationRoutesMixin:
),
"problem": location.problem or self._node_folder_risk(location),
"size_bytes": size,
# #320 D201: thumbnails kept in one folder stay there (D8A).
"thumbnails_folder": _thumbnail_folder(),
}
# The node folder (tests point it at a temp tree).
@@ -272,6 +293,8 @@ class LibraryLocationRoutesMixin:
def _plan_library_move(self):
"""The checked plan (blocking); raises MoveFailed."""
plan = lm.plan_move(self._live_location())
if _inside_thumbnail_folder(plan.target_dir):
raise lm.MoveFailed("data-dir-served")
if lm.space_problem(plan) is not None:
raise lm.MoveFailed("insufficient-space")
return plan
+23 -2
View File
@@ -68,6 +68,23 @@ def _count(count, singular, plural):
return f"{count} {singular if count == 1 else plural}"
# Each kept entry's row in the job (#586), in the review's "Kept: ..." words.
KEPT_DETAILS = {
"active": "Kept: the active log",
"link": "Kept: a link",
"other": "Kept: another file",
}
def _kept_files(rows):
"""``{name, kind}`` of each entry the cleanup keeps, in folder order."""
return [
{"name": row["name"], "kind": row["kind"]}
for row in rows
if row["kind"] in KEPT_DETAILS
]
def _protected_reasons(rows):
"""The plan's Protected reasons: what the cleanup keeps, zero counts left out."""
active = sum(1 for row in rows if row["kind"] == "active")
@@ -129,7 +146,7 @@ class LogCleanupMixin:
token = store.issue(
ROTATED_LOGS,
json.dumps(files, sort_keys=True),
{"files": files, "protected": protected},
{"files": files, "protected": protected, "kept": _kept_files(rows)},
(),
)
except ScopeTooLarge:
@@ -198,7 +215,11 @@ class LogCleanupMixin:
job.record(
"errors", {"path": name, "detail": detail}, f"! {name} · {detail}"
)
job.record_counted("protected", plan.payload["protected"])
kept = [
{"path": row["name"], "detail": KEPT_DETAILS[row["kind"]]}
for row in plan.payload["kept"]
]
job.record_counted("protected", len(kept), kept, complete=True)
removed, failed = len(result["removed"]), len(result["failed"])
line = f"Deleted {_count(removed, 'rotated log copy', 'rotated log copies')}"
if failed:
+95 -6
View File
@@ -1,8 +1,9 @@
"""Maintenance routes: reviewed, read-only plans (#249 System S6).
``POST /prompt_manager/maintenance/preview {operation, options:{}}`` reviews
one operation (``duplicate_prompts``, ``optimize``, ``unused_components`` or
``missing_links``; ``database/maintenance.py``; ``duplicate_files``,
one operation (``duplicate_prompts``, ``optimize``, ``unused_components``,
``missing_links`` or ``pair_metadata``, #550; ``database/maintenance.py``;
``duplicate_files``,
``py/api/duplicate_files.py``; ``rotated_logs``, the rotated log copies,
``py/api/log_cleanup.py``, which has its own apply job) off the event loop and
answers a ``Plan``: ``{success, token, operation, expires_at, scope:{count,
@@ -97,7 +98,11 @@ MAINTENANCE_TITLES = {
"unused_components": "Unused prompt components",
"missing_links": "Missing-file links",
"workflow_storage": "Store each workflow once",
# #550, beyond the concept: fills old Pair prompts' empty category and tags.
"pair_metadata": "Pair category and tags",
}
# Operations whose job keeps every changed row for its report (#550).
COMPLETE_RECORD = frozenset({"pair_metadata"})
_OPERATION_ERROR = "operation must be one of: " + ", ".join(PREVIEW_OPERATIONS)
MAINTENANCE_GROUPS = ("changed", "protected", "errors")
MAX_TOKEN_LENGTH = 128
@@ -281,9 +286,27 @@ def short_space_message(short):
)
def _changed_line(operation, count, marks=0):
def _pair_metadata_line(result):
count = result["changed"]
noun = (
"prompt from its saved pairs"
if count == 1
else "prompts from their saved pairs"
)
categories = result.get("categories", 0)
tag_sets = result.get("tag_sets", 0)
return (
f"Filled {count:,} {noun} "
f"({categories:,} {'category' if categories == 1 else 'categories'}, "
f"{tag_sets:,} {'tag set' if tag_sets == 1 else 'tag sets'})"
)
def _changed_line(operation, count, marks=0, result=None):
if operation == "optimize":
return "Optimized the database"
if operation == "pair_metadata":
return _pair_metadata_line(result or {"changed": count})
if operation == "duplicate_prompts":
noun = "duplicate prompt" if count == 1 else "duplicate prompts"
return f"Merged {count:,} {noun}"
@@ -668,13 +691,67 @@ class MaintenanceRoutesMixin:
rows = await self._run_in_executor(
self._maintenance_rows, plan.operation, result["rows"]
)
job.record_counted("changed", result["changed"], rows)
job.record_counted("protected", payload["protected"])
line = _changed_line(plan.operation, result["changed"], result.get("marks", 0))
job.record_counted(
"changed",
result["changed"],
rows,
complete=plan.operation in COMPLETE_RECORD,
)
kept = await self._run_in_executor(self._kept_rows, db, plan, result)
if kept is None:
job.record_counted("protected", payload["protected"]) # none kept
else:
# #550, #586: each kept record with its reason, like the changed
# ones, and every one of them in the report.
job.record_counted("protected", len(kept), kept, complete=True)
line = _changed_line(
plan.operation, result["changed"], result.get("marks", 0), result
)
job.log_line(line)
job.update(progress=100, status_line=f"{line} · {backup_note(backup)}")
return None
def _kept_rows(self, db, plan, result):
"""Outcome rows for the records the apply kept (blocking), or None.
Prompts are records (no path); a missing-links plan's kept links
name their file, relative to its output root, so Activity can open
and locate it (#586); one under a source that is gone keeps its path
under that source's label. None for an operation that keeps nothing
(Optimize).
"""
if plan.operation == "missing_links":
roots = _scan_roots(self._get_all_output_dirs())
judged = plan.payload["roots"]
rows = []
for item in db.kept_links(judged):
path, label, index = _scan_location(item["image_path"], roots)
if index is None and item["owner"] is not None:
# A source that is gone is not listed: name the file
# under it, with no listing index to open it by.
source = judged[item["owner"]]
where = [(os.path.abspath(source), self._public_path(source), None)]
path, label, index = _scan_location(item["image_path"], where)
rows.append(
{
"path": path,
"root": label,
"root_index": index,
"detail": f"Kept: {item['reason']}",
}
)
return rows
if "kept" not in result:
return None
return [
{
"path": "",
"detail": f"Prompt {item['prompt_id']}: kept, "
+ "; ".join(item["reasons"]),
}
for item in result["kept"]
]
def _maintenance_rows(self, operation, described):
"""Outcome rows for the changed records (root-relative paths only)."""
if operation == "missing_links":
@@ -691,6 +768,8 @@ class MaintenanceRoutesMixin:
}
)
return rows
if operation == "pair_metadata":
return [{"path": "", "detail": _filled_detail(item)} for item in described]
if operation == "duplicate_prompts":
return [
{
@@ -708,6 +787,16 @@ class MaintenanceRoutesMixin:
]
def _filled_detail(item):
"""``Prompt 12: category “portrait”; tags a, b``: one filled prompt (#550)."""
parts = []
if "category" in item:
parts.append(f"category “{item['category']}”")
if item.get("tags"):
parts.append("tags " + ", ".join(item["tags"]))
return f"Prompt {item['prompt_id']}: " + "; ".join(parts)
def backup_note(backup):
"""The status line's backup part: the file name, or that there is none."""
return f"Safety backup {backup}" if backup else NO_BACKUP
+728
View File
@@ -0,0 +1,728 @@
"""The thumbnail location setting (#320): parsing, validation, source keys
and the thumbnail folder's marker.
``preview_store`` holds the runtime state and maps paths; this module holds
the rules it applies. Nothing here keeps state.
Schema (config.json ``thumbnails.location``)::
{"mode": "folder", "path": "D:\\\\Thumbs", "id": "<uuid4>",
"install": "<uuid4>", "cleanup": []}
The default (and a missing key) is ``{"mode": "source"}``: previews inside
each source. An invalid value never falls back to the sources: it parses as
a folder location with problem ``invalid``, so nothing is written anywhere.
Layout in folder mode: ``<folder>/<source key>/<rel dir>/<stem>_thumb<ext>``.
A source key is a readable slug plus a short hash of the source's identity
(``output-3f9a2c1b``), assigned once and stored in the marker
``.promptmanager-thumbnails.json`` with the identity it belongs to.
"""
import errno
import hashlib
import json
import logging
import ntpath
import os
import posixpath
import re
import stat as stat_module
import threading
import time
import unicodedata
import uuid
from contextlib import contextmanager
from dataclasses import dataclass, field
try:
from ...utils import library_location
except ImportError: # tests import the package top-level
from utils import library_location
logger = logging.getLogger("prompt_manager.thumbnails")
SOURCE_MODE = "source"
FOLDER_MODE = "folder"
MARKER_NAME = ".promptmanager-thumbnails.json"
MARKER_VERSION = 1
# Beside the marker while one process re-reads, changes and replaces it.
MARKER_LOCK_NAME = ".promptmanager-thumbnails.lock"
MARKER_LOCK_TIMEOUT = 1.5 # a writer waits this long (under preview_store.IO_BUDGET)
MARKER_LOCK_STALE = 30.0 # a lock this old was left by a writer that died
MARKER_LOCK_POLL = 0.02
# A source key: one folder name, a slug and a 8-16 hex hash (``source_key``).
KEY_PATTERN = re.compile(r"^[a-z0-9_-]{1,24}-[0-9a-f]{8,16}$")
# A marker is a few keys; anything larger is not ours.
MAX_MARKER_BYTES = 4 * 2**20
# OS metadata files a folder may hold and still count as empty.
OS_METADATA = frozenset({"thumbs.db", "desktop.ini", ".ds_store"})
KEY_HASH_LENGTHS = (8, 12, 16)
SLUG_LENGTH = 24
# Folder + the longest key (24 + 1 + 16) + this margin must fit (Windows).
WINDOWS_FOLDER_LIMIT = 247
LONGEST_KEY = SLUG_LENGTH + 1 + KEY_HASH_LENGTHS[-1]
PATH_MARGIN = 40
# A validation stat on a share that does not answer in time is unavailable.
VALIDATION_TIMEOUT = 5.0
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
_RESERVED = re.compile(r"^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$", re.IGNORECASE)
_BAD_CHARS = re.compile(r'[<>:"|?*\x00-\x1f]')
# Problem codes, their HTTP status and the text the UI shows (no path).
MESSAGES = {
"location-not-absolute": (
400,
"Enter a full folder path, such as D:\\Thumbnails or /mnt/fast/thumbnails.",
),
"location-device-path": (
400,
"Enter the folder as a drive letter or \\\\server\\share path, "
"without \\\\?\\.",
),
"location-invalid-name": (
400,
"This folder name can't be used on Windows. Choose another name.",
),
"location-too-broad": (
400,
"Choose a folder inside a drive or share, not the drive itself.",
),
"location-served": (
400,
"Choose a folder outside your image sources and ComfyUI's output, input "
"and temp folders.",
),
"location-node-folder": (
400,
"Choose a folder outside the Prompt Manager node folder.",
),
"location-contains-source": (
400,
"This folder contains an image source. Choose a folder beside it instead.",
),
"location-same": (400, "Thumbnails are already stored here."),
"location-inside-current": (
400,
"This folder is inside the thumbnail folder in use. Choose a folder "
"outside it.",
),
"location-parent-missing": (
400,
"The folder that would hold it doesn't exist. Check the path and that its "
"drive is connected.",
),
"location-unavailable": (
409,
"This folder can't be reached right now. Check that its drive or share is "
"connected.",
),
"location-not-empty": (
409,
"This folder already holds other files. Choose an empty folder or one "
"Prompt Manager used before.",
),
"location-not-writable": (
409,
"Prompt Manager can't write to this folder. Check its permissions.",
),
"location-path-too-long": (
400,
"This path is too long for Windows. Choose a shorter folder path.",
),
"insufficient-space": (
507,
"There is not enough space for the thumbnails. Free some space or choose "
"another folder.",
),
"location-needs-move": (
409,
"Use Move thumbnails in System › Sources & scans.",
),
}
HOLDS_THUMBNAILS_REASON = "This folder holds Prompt Manager's thumbnails."
def message(code):
"""``(status, text)`` of a problem code."""
return MESSAGES[code]
# ── parsing ──────────────────────────────────────────────────────────
@dataclass(frozen=True)
class ParsedLocation:
"""A ``thumbnails.location`` value, checked as data (no filesystem)."""
mode: str = SOURCE_MODE
path: str | None = None
id: str | None = None
install: str | None = None
cleanup: tuple = field(default_factory=tuple)
problem: str | None = None
def to_config(self):
"""The config.json form (``invalid`` values are never written)."""
if self.mode == SOURCE_MODE:
value = {"mode": SOURCE_MODE}
else:
value = {"mode": FOLDER_MODE, "path": self.path, "id": self.id}
if self.install:
value["install"] = self.install
value["cleanup"] = list(self.cleanup)
return value
def _cleanup(value):
if not isinstance(value, list):
return ()
return tuple(item for item in value if isinstance(item, dict))
def parse_location(value, *, flavor=os.path):
"""The location a ``thumbnails.location`` value names.
Missing: inside each source. Anything else that is not a well-formed
location (an unknown mode, a relative path, a missing id) becomes a
folder location with problem ``invalid``: never a silent fall-back.
"""
if value is None:
return ParsedLocation()
if not isinstance(value, dict):
return ParsedLocation(mode=FOLDER_MODE, problem="invalid")
mode = value.get("mode", SOURCE_MODE)
install = value.get("install") if isinstance(value.get("install"), str) else None
cleanup = _cleanup(value.get("cleanup"))
if mode == SOURCE_MODE:
return ParsedLocation(install=install, cleanup=cleanup)
if mode != FOLDER_MODE:
return ParsedLocation(mode=FOLDER_MODE, problem="invalid", cleanup=cleanup)
path, marker_id = value.get("path"), value.get("id")
if (
not isinstance(path, str)
or form_problem(path, flavor=flavor) is not None
or not isinstance(marker_id, str)
or not marker_id.strip()
):
return ParsedLocation(
mode=FOLDER_MODE,
path=path if isinstance(path, str) else None,
problem="invalid",
cleanup=cleanup,
)
return ParsedLocation(
mode=FOLDER_MODE,
path=flavor.abspath(path),
id=marker_id,
install=install,
cleanup=cleanup,
)
# ── form (text only) ─────────────────────────────────────────────────
def _is_windows(flavor):
return flavor is ntpath or getattr(flavor, "__name__", "") == "ntpath"
def form_problem(path, *, flavor=os.path):
"""The text-only problem of *path*, or None (no filesystem access).
``\\\\?\\`` and ``\\\\.\\`` prefixes, relative, drive-relative (``C:x``)
and root-relative (``\\x``) spellings, and names Windows cannot hold
(checked on every OS: the folder may be moved to Windows).
"""
if not isinstance(path, str) or not path.strip() or path != path.lstrip():
return "location-not-absolute"
if path.replace("/", "\\").startswith(("\\\\?\\", "\\\\.\\")):
return "location-device-path"
if _is_windows(flavor):
drive, rest = ntpath.splitdrive(path)
if not drive or not rest.startswith(("\\", "/")):
return "location-not-absolute"
elif not posixpath.isabs(path):
return "location-not-absolute"
drive, rest = flavor.splitdrive(path)
for part in re.split(r"[\\/]+", rest):
if not part:
continue
if (
_RESERVED.match(part)
or part.endswith((".", " "))
or _BAD_CHARS.search(part)
):
return "location-invalid-name"
return None
def is_too_broad(path, *, home=None, flavor=os.path):
"""A filesystem or share root, or the home folder itself."""
normalized = flavor.normpath(path)
drive, rest = flavor.splitdrive(normalized)
if rest in ("", "\\", "/"):
return True
home = home if home is not None else os.path.expanduser("~")
return bool(home) and flavor.normcase(normalized) == flavor.normcase(
flavor.normpath(home)
)
def too_long_for_windows(path, long_paths=None):
"""Folder + the longest key + a margin over the folder limit (Windows
without long paths; *long_paths* injects the registry answer)."""
if long_paths is None:
long_paths = long_paths_enabled()
if long_paths:
return False
return len(path) + 1 + LONGEST_KEY + PATH_MARGIN > WINDOWS_FOLDER_LIMIT
def long_paths_enabled():
"""``LongPathsEnabled`` on Windows (True on other systems)."""
if os.name != "nt":
return True
try:
import winreg
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"SYSTEM\CurrentControlSet\Control\FileSystem",
) as key:
return winreg.QueryValueEx(key, "LongPathsEnabled")[0] == 1
except OSError:
return False
# ── links ────────────────────────────────────────────────────────────
def is_link_like(path):
"""A symlink, a junction or any reparse point (one lstat), or missing.
Raises OSError other than "not found" (an unreachable share).
"""
try:
info = os.lstat(path)
except FileNotFoundError:
return False
if stat_module.S_ISLNK(info.st_mode):
return True
attributes = getattr(info, "st_file_attributes", 0)
if attributes & FILE_ATTRIBUTE_REPARSE_POINT:
return True
is_junction = getattr(os.path, "isjunction", None)
return bool(is_junction and is_junction(path))
# ── source keys ──────────────────────────────────────────────────────
def slug(name):
"""``NFKD`` to ASCII, lower case, runs outside ``[a-z0-9_]`` as ``-``,
trimmed and cut to 24 characters; empty becomes ``source``."""
text = unicodedata.normalize("NFKD", str(name))
text = text.encode("ascii", "ignore").decode("ascii").lower()
text = re.sub(r"[^a-z0-9_]+", "-", text).strip("-")
text = text[:SLUG_LENGTH].strip("-")
return text or "source"
def source_key(spelling, identity, taken):
"""The key of a source: ``slug(basename) + "-" + sha256(identity)[:n]``.
*taken* maps existing keys to their identity; a key taken by another
identity grows its hash to 12, then 16 characters.
"""
base = slug(ntpath.basename(str(spelling).rstrip("\\/")))
digest = hashlib.sha256(str(identity).encode("utf-8")).hexdigest()
for length in KEY_HASH_LENGTHS:
key = f"{base}-{digest[:length]}"
owner = taken.get(key)
if owner is None or owner == identity:
return key
raise ValueError("no free source key")
def file_identity(path):
"""``[st_dev, st_ino]`` of an existing folder, or None (no file ids)."""
try:
info = os.stat(path)
except (OSError, ValueError):
return None
if not info.st_ino:
return None
return [info.st_dev, info.st_ino]
def find_key(keys, spelling, identity, dev_ino):
"""The marker key of a source: the same configured spelling, then the
same identity text, then the same file id; or None."""
keys = {k: v for k, v in keys.items() if valid_key(k) and isinstance(v, dict)}
for key, entry in keys.items():
if entry.get("path") == spelling:
return key
for key, entry in keys.items():
if entry.get("identity") == identity:
return key
if dev_ino:
for key, entry in keys.items():
if entry.get("dev_ino") == list(dev_ino):
return key
return None
# ── the marker ───────────────────────────────────────────────────────
def marker_path(folder):
return os.path.join(folder, MARKER_NAME)
def read_marker(folder):
"""The marker in *folder* as a dict, or None (absent or unreadable as
ours). OSError other than "not found" propagates (an unreachable share)."""
try:
with open(marker_path(folder), "r", encoding="utf-8") as handle:
text = handle.read(MAX_MARKER_BYTES + 1)
except FileNotFoundError:
return None
except (ValueError, UnicodeDecodeError):
return None
if len(text) > MAX_MARKER_BYTES:
return None
try:
data = json.loads(text)
except ValueError:
return None
if not isinstance(data, dict) or not isinstance(data.get("id"), str):
return None
data["keys"] = valid_keys(data.get("keys"))
return data
def valid_key(key):
"""Whether *key* can name a source's folder: one folder name of the
``source_key`` form, never a separator, ``..`` or a drive."""
return isinstance(key, str) and KEY_PATTERN.fullmatch(key) is not None
def valid_keys(keys):
"""The marker's keys that are safe to join onto the folder; a hand-edited
or foreign entry that is not is dropped (and logged), never followed."""
if not isinstance(keys, dict):
return {}
kept = {}
for key, entry in keys.items():
if valid_key(key) and isinstance(entry, dict):
kept[key] = entry
else:
logger.warning(
"Ignoring a thumbnail folder marker entry that is not a source " "key"
)
return kept
@contextmanager
def _marker_lock(folder):
"""Hold the folder's marker lock: a file made with ``O_CREAT | O_EXCL``
(atomic on local disks and SMB/NFS shares, unlike ``flock``), so installs
sharing the folder change the marker one at a time. A lock older than
MARKER_LOCK_STALE was left by a writer that died and is taken over;
waiting longer than MARKER_LOCK_TIMEOUT raises TimeoutError."""
lock = os.path.join(folder, MARKER_LOCK_NAME)
deadline = time.monotonic() + MARKER_LOCK_TIMEOUT
while True:
try:
handle = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
except FileExistsError:
_break_stale_lock(lock)
if time.monotonic() >= deadline:
raise TimeoutError(
errno.ETIMEDOUT, "the thumbnail folder marker is locked", lock
)
time.sleep(MARKER_LOCK_POLL)
continue
try:
os.write(handle, f"{os.getpid()}\n".encode("ascii"))
finally:
os.close(handle) # closed before any unlink (Windows)
break
try:
yield
finally:
try:
os.unlink(lock)
except FileNotFoundError:
pass # taken over as stale by another writer: nothing to release
def _break_stale_lock(lock):
"""Remove *lock* when it is older than MARKER_LOCK_STALE (its writer
died); a lock that changed since it was looked at is left alone."""
try:
before = os.stat(lock)
except FileNotFoundError:
return
if time.time() - before.st_mtime < MARKER_LOCK_STALE:
return
logger.warning(f"Taking over a stale thumbnail folder lock: {lock}")
try:
after = os.stat(lock)
if (after.st_ino, after.st_mtime_ns) == (before.st_ino, before.st_mtime_ns):
os.unlink(lock)
except FileNotFoundError:
pass # another writer took it over first
def write_marker(folder, mutate):
"""Re-read the marker, apply ``mutate(marker)`` and write it atomically
(a temp sibling renamed over it), all under the folder's marker lock so a
second install sharing the folder never drops this one's change (and
the other way round). Callers hold ``preview_store.write_lock`` (threads
of this process). Returns the marker written; TimeoutError when another
writer holds the lock too long."""
with _marker_lock(folder):
return _write_marker_locked(folder, mutate)
def _write_marker_locked(folder, mutate):
current = read_marker(folder) or {}
marker = {
"version": MARKER_VERSION,
"id": current.get("id"),
"created_by_pm": bool(current.get("created_by_pm", False)),
"keys": dict(current.get("keys") or {}),
}
mutate(marker)
if not marker.get("id"):
raise ValueError("a marker needs an id")
target = marker_path(folder)
temp = os.path.join(
folder, f".{MARKER_NAME}.{os.getpid()}-{threading.get_ident()}.tmp"
)
try:
with open(temp, "w", encoding="utf-8") as handle:
json.dump(marker, handle, indent=2, sort_keys=True)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp, target)
finally:
try:
os.unlink(temp)
except OSError:
pass # gone with the rename; a leftover is swept with the temps
return marker
def new_id():
return str(uuid.uuid4())
def init_folder(folder, *, created_by_pm=False):
"""The marker id of *folder*: an existing marker is adopted; a folder
without one gets a new marker (new id, no keys)."""
existing = read_marker(folder)
if existing is not None:
return existing["id"]
def create(marker):
if not marker.get("id"):
marker["id"] = new_id()
marker["created_by_pm"] = created_by_pm
return write_marker(folder, create)["id"]
def folder_state(path, expected_id):
"""``(available, problem, marker)`` of a configured thumbnail folder.
Available: the folder exists, is a directory and not a link, junction or
reparse point, and holds our marker with *expected_id*. Raises OSError
when the folder cannot be reached (an I/O error other than not found).
"""
try:
info = os.lstat(path)
except FileNotFoundError:
return False, "missing", None
except OSError as error:
if error.errno in (errno.ENOENT, errno.ENOTDIR):
return False, "missing", None
raise
if is_link_like(path):
return False, "invalid", None
if not stat_module.S_ISDIR(info.st_mode):
return False, "invalid", None
marker = read_marker(path)
if marker is None:
return False, "missing", None
if marker["id"] != expected_id:
return False, "marker-mismatch", marker
return True, None, marker
def holds_only_metadata(path):
"""Whether an existing folder is empty but for OS metadata files."""
with os.scandir(path) as entries:
for entry in entries:
name = entry.name.lower()
if name in OS_METADATA or name.startswith("._"):
continue
return False
return True
# ── validation ───────────────────────────────────────────────────────
@dataclass(frozen=True)
class Context:
"""What a location is checked against (gathered by the caller)."""
roots: tuple = () # every image source of every root list
contained: tuple = () # ComfyUI output/input/temp and the library folder
node_folder: str | None = None
default_path: str | None = None
current_path: str | None = None # the folder in use (folder mode)
home: str | None = None
flavor: object = os.path
long_paths: bool | None = None
environ: object = None
# ComfyUI's folder_paths module (None: none; unset: imported).
folder_paths: object = library_location._UNSET
def _timed(func, timeout):
"""``func()`` on a daemon thread; raises TimeoutError past *timeout*.
A stuck call is left to finish on its own thread."""
result = {}
done = threading.Event()
def run():
try:
result["value"] = func()
except BaseException as error: # _timed() re-raises it in the caller
result["error"] = error
finally:
done.set()
threading.Thread(target=run, name="pm-preview-stat", daemon=True).start()
if not done.wait(timeout):
raise TimeoutError("the folder did not answer in time")
if "error" in result:
raise result["error"]
return result.get("value")
def _inside(path, folders):
return library_location._inside_any(path, folders)
def _same(first, second):
return library_location.same_folder(first, second) or os.path.normcase(
os.path.realpath(first)
) == os.path.normcase(os.path.realpath(second))
def _served(path, context):
environ = os.environ if context.environ is None else context.environ
return (
library_location.data_dir_problem(
path,
folder_paths=context.folder_paths,
environ=environ,
gallery_roots=list(context.roots),
)
== "data-dir-served"
)
def _probe_write(path):
"""Create and delete a probe file in *path*; False when refused."""
probe = os.path.join(path, f".pm-probe-{uuid.uuid4().hex[:8]}.tmp")
try:
with open(probe, "wb") as handle:
handle.write(b"")
except OSError:
return False
try:
os.unlink(probe)
except OSError:
return False
return True
def location_problem(path, *, purpose, context, timeout=VALIDATION_TIMEOUT):
"""Why *path* cannot be the thumbnail folder, as a code, or None.
*purpose*: ``move`` (every rule), ``startup`` (no ``same``, ``empty``,
``writable`` or parent rules: availability covers them) or ``recreate``
(the folder is missing and is made again).
"""
flavor = context.flavor
code = form_problem(path, flavor=flavor)
if code is not None:
return code
path = flavor.abspath(path)
default = bool(context.default_path) and flavor.normcase(path) == flavor.normcase(
flavor.abspath(context.default_path)
)
if is_too_broad(path, home=context.home, flavor=flavor):
return "location-too-broad"
try:
return _timed(
lambda: _filesystem_problem(path, purpose, context, default), timeout
)
except (TimeoutError, OSError):
return "location-unavailable"
def _filesystem_problem(path, purpose, context, default):
if _served(path, context):
return "location-served"
if context.node_folder and not default and _inside(path, [context.node_folder]):
return "location-node-folder"
inner = list(context.roots) + list(context.contained)
if any(folder and _inside(folder, [path]) for folder in inner):
if not default:
return "location-contains-source"
if (
purpose == "move"
and context.current_path
and (
os.path.normcase(path) == os.path.normcase(context.current_path)
or (os.path.isdir(path) and _same(path, context.current_path))
)
):
return "location-same"
if (
purpose == "move"
and context.current_path
and _inside(path, [context.current_path])
):
# The old key folders are removed after the move, and with them a
# target nested inside one of them.
return "location-inside-current"
# Windows, or a test injecting its limit (``long_paths=False``).
if os.name == "nt" or context.long_paths is False:
if too_long_for_windows(path, context.long_paths):
return "location-path-too-long"
if purpose == "startup":
return None
if is_link_like(path):
# Never through a link: the folder itself must be a real folder.
return "location-not-empty"
exists = os.path.isdir(path)
if not exists:
if os.path.lexists(path):
return "location-not-empty"
if not default and not os.path.isdir(os.path.dirname(path)):
return "location-parent-missing"
return None
if read_marker(path) is None and not holds_only_metadata(path):
return "location-not-empty"
if not _probe_write(path):
return "location-not-writable"
return None
+728
View File
@@ -0,0 +1,728 @@
"""Where previews live on disk (#320): the one resolver.
Every mapping between an image source (a "root"), one of its media files and
that file's preview on disk goes through this module. URL text stays in
``thumbnail_settings`` (``thumbnail_rel``: ``thumbnails/<rel>_thumb<ext>``,
the shape the serve route, ``inspection-state.mjs`` and ``admin.js`` share);
this module answers where such a preview is on disk.
The store of a source is where its previews live. With the default location
(mode ``source``, "Inside each source") it is ``<source>/thumbnails``, exactly
the folder every caller joined by hand before, so nothing moves.
Writes and the switch: every preview writer takes ``write_guard`` when it
starts (it records the location's ``generation``); the final rename runs
under ``write_lock`` and only when the generation is unchanged and no hold
applies (``thumbnail_encode.replace_unless_held``). Changing the location
bumps the generation under the same lock, so no write that began before can
land in the old store afterwards. Nothing reads or commits config while
``write_lock`` is held. The hold check runs under it too, so hold checks take
no lock (the job registry's ``running()`` is a lock-free snapshot) and
``bump_generation`` is never called with a registry or config lock held.
All functions are blocking: call them off the event loop.
"""
import errno
import logging
import os
import threading
import time
from contextlib import contextmanager
from dataclasses import dataclass, replace
from pathlib import Path
from . import preview_location, thumbnail_settings
try:
from ...utils.media_folders import PREVIEW_FOLDER
except ImportError: # tests import the package top-level
from utils.media_folders import PREVIEW_FOLDER
logger = logging.getLogger("prompt_manager.thumbnails")
SOURCE_MODE = preview_location.SOURCE_MODE
FOLDER_MODE = preview_location.FOLDER_MODE
# The probe checks the folder this often, and a check (or a store call)
# taking longer than the budget marks it unreachable.
PROBE_INTERVAL = 5.0
IO_BUDGET = 2.0
PROBE_THREAD = "pm-preview-probe"
@dataclass(frozen=True)
class Location:
"""One snapshot of the thumbnail location (take one per request or job)."""
mode: str = SOURCE_MODE
path: str | None = None
available: bool = True
problem: str | None = None
generation: int = 0
write_lock = threading.Lock()
_generation = 0
# The runtime location: the parsed setting, availability and the marker keys
# last read. Swapped as a whole; readers take one snapshot.
_state_lock = threading.Lock()
_parsed = preview_location.ParsedLocation()
_available = True
_problem = None
# The newest check whose answer was published.
_published_order = 0
_keys = {}
_key_cache = {}
_clock = time.monotonic
def current():
"""The thumbnail location now."""
with _state_lock:
parsed, available, problem, generation_now = (
_parsed,
_available,
_problem,
_generation,
)
if parsed.mode == SOURCE_MODE:
return Location(generation=generation_now)
return Location(FOLDER_MODE, parsed.path, available, problem, generation_now)
def setting():
"""The parsed setting in use (``preview_location.ParsedLocation``)."""
return _parsed
def generation():
return _generation
def bump_generation():
"""Invalidate every write that started before (under ``write_lock``)."""
global _generation
with write_lock:
_generation += 1
return _generation
def _canonical(path):
"""Resolved and case-folded, so paths compare on every OS."""
return Path(os.path.normcase(os.path.realpath(str(path))))
def _within(path, folder):
return _canonical(path).is_relative_to(_canonical(folder))
# ── the setting and availability ─────────────────────────────────────
def configure(value, *, context=None, check=True):
"""Use the ``thumbnails.location`` *value* (config.json) from now on.
A folder location is validated against *context* (``startup`` rules: a
problem other than an unreachable folder makes it ``invalid``), then
checked once; the probe keeps checking it. Swapped under ``write_lock``
with a generation bump, so no write that started before lands.
"""
global _parsed, _available, _problem, _generation
parsed = preview_location.parse_location(value)
problem = parsed.problem
if parsed.mode == FOLDER_MODE and problem is None and context is not None:
code = preview_location.location_problem(
parsed.path, purpose="startup", context=context
)
if code == "location-unavailable":
problem = "unreachable"
elif code is not None:
problem = "invalid"
parsed = preview_location.ParsedLocation(
mode=FOLDER_MODE,
path=parsed.path,
id=parsed.id,
install=parsed.install,
cleanup=parsed.cleanup,
problem="invalid",
)
with write_lock:
with _state_lock:
_parsed = parsed
_available = parsed.mode == SOURCE_MODE
_problem = problem if parsed.mode == FOLDER_MODE else None
_keys.clear()
_key_cache.clear()
_generation += 1
if parsed.mode == FOLDER_MODE:
if check and parsed.problem is None:
refresh()
_probe.start()
else:
_probe.stop()
return current()
def refresh():
"""Check the folder now (bounded by IO_BUDGET); returns availability."""
parsed = _parsed
if parsed.mode != FOLDER_MODE or parsed.problem is not None:
return parsed.mode == SOURCE_MODE
try:
(available, problem, marker), order = _probe.ordered_check(
parsed.path, parsed.id
)
except (OSError, TimeoutError, RuntimeError):
available, problem, marker = False, "unreachable", None
order = _probe.latest_order()
_set_state(parsed, available, problem, marker, order)
return available
def _set_keys(parsed, marker):
"""Take the keys of a marker just written (availability unchanged)."""
with _state_lock:
if _parsed is not parsed:
return
keys = dict((marker or {}).get("keys") or {})
if keys != _keys:
_keys.clear()
_keys.update(keys)
_key_cache.clear()
def set_cleanup(entries):
"""The setting's ``cleanup`` after a removal was committed."""
global _parsed
with _state_lock:
_parsed = replace(_parsed, cleanup=tuple(entries))
def mark_invalid():
"""The setting cannot be used (a startup check that failed): folder
mode stays in force, unavailable, so nothing is written anywhere."""
global _parsed, _available, _problem
with _state_lock:
if _parsed.mode != FOLDER_MODE:
_parsed = preview_location.ParsedLocation(
mode=FOLDER_MODE, problem="invalid"
)
else:
_parsed = replace(_parsed, problem="invalid")
_available, _problem = False, "invalid"
def _set_state(parsed, available, problem, marker, order):
"""Publish a check's answer, unless a check that started later already
published (two callers can finish out of order, #611)."""
global _available, _problem, _published_order
with _state_lock:
if _parsed is not parsed:
return # the setting changed meanwhile
if order < _published_order:
return
_published_order = order
_available, _problem = available, problem
keys = dict((marker or {}).get("keys") or {})
if keys != _keys:
_keys.clear()
_keys.update(keys)
_key_cache.clear()
def note_failure():
"""A store call failed or stalled: unreachable until the probe finds the
folder again (requests do no store I/O meanwhile)."""
global _available, _problem
if _parsed.mode != FOLDER_MODE:
return
with _state_lock:
_available, _problem = False, "unreachable"
_probe.wake()
@contextmanager
def store_io():
"""Around a store call in folder mode: an I/O error (other than a file
that is not there) or a call over IO_BUDGET marks the folder
unreachable."""
if _parsed.mode != FOLDER_MODE:
yield
return
started = _clock()
try:
yield
except OSError as error:
if not isinstance(error, (FileNotFoundError, NotADirectoryError)):
note_failure()
raise
finally:
if _clock() - started > IO_BUDGET:
note_failure()
class _Probe:
"""The background check of the thumbnail folder (one thread, one check
at a time; a stuck check is left to finish on its own thread)."""
def __init__(self):
self._lock = threading.Lock()
self._wake = threading.Event()
self._stop = threading.Event()
self._thread = None
self._checking = None
self._order = 0
def check(self, path, expected_id):
"""``folder_state`` within IO_BUDGET; see :meth:`ordered_check`."""
return self.ordered_check(path, expected_id)[0]
def latest_order(self):
"""The order number of the check started last."""
with self._lock:
return self._order
def ordered_check(self, path, expected_id):
"""``(folder_state, order)`` within IO_BUDGET; raises TimeoutError.
``order`` grows with each check started, so answers can be published
in the order they looked. The answer comes from a check that started
after this call: one already running may have looked before what the
caller just did, so it is waited for (never run twice at once; a stuck
one times out here) and followed by a new one."""
key = (path, expected_id)
with self._lock:
earlier = self._running(key)
pending = None if earlier else self._start_check(path, expected_id)
if earlier:
self._wait(earlier)
with self._lock:
# One another caller started meanwhile is new enough to join.
pending = self._running(key) or self._start_check(path, expected_id)
self._wait(pending)
with self._lock:
if self._checking is pending:
self._checking = None
if "error" in pending[2]:
raise pending[2]["error"]
return pending[2]["value"], pending[2]["order"]
def _running(self, key):
"""The check of *key* still running (lock held), else None."""
pending = self._checking
if pending is None or pending[0] != key or pending[1].is_set():
return None
return pending
@staticmethod
def _wait(pending):
if not pending[1].wait(IO_BUDGET):
raise TimeoutError("the thumbnail folder did not answer in time")
def _start_check(self, path, expected_id):
"""Start one check on its own thread (lock held)."""
self._order += 1
result = {"order": self._order}
done = threading.Event()
def run():
try:
result["value"] = preview_location.folder_state(path, expected_id)
except BaseException as error: # check() re-raises it in the caller
result["error"] = error
finally:
done.set()
pending = ((path, expected_id), done, result)
self._checking = pending
threading.Thread(target=run, name=PROBE_THREAD + "-check", daemon=True).start()
return pending
def start(self):
with self._lock:
if self._thread is not None and self._thread.is_alive():
return
# Each thread has its own stop: a stopped one never resumes.
stop = self._stop = threading.Event()
thread = threading.Thread(
target=self._run, args=(stop,), name=PROBE_THREAD, daemon=True
)
try:
thread.start()
except RuntimeError:
return # refused at interpreter exit: no probe
self._thread = thread
def _run(self, stop):
while not stop.is_set():
self._wake.wait(PROBE_INTERVAL)
self._wake.clear()
if stop.is_set():
return
try:
refresh()
except Exception: # one failed check never stops the probe
logger.debug("Thumbnail folder check failed", exc_info=True)
def wake(self):
self._wake.set()
def stop(self, timeout=2.0):
"""Stop the probe; True when its thread ended."""
with self._lock:
thread, self._thread = self._thread, None
self._stop.set()
self._wake.set()
if thread is None or thread is threading.current_thread():
return True
thread.join(timeout)
return not thread.is_alive()
_probe = _Probe()
def stop_probe(timeout=2.0):
"""Stop the folder probe (the package teardown, tests)."""
return _probe.stop(timeout)
# ── stores ───────────────────────────────────────────────────────────
def source_folder(root):
"""``<source>/thumbnails``: the store in source mode, a leftover in
folder mode."""
return Path(root) / PREVIEW_FOLDER
def store_container(root, location=None):
"""The folder a store of *root* must stay inside: the source itself, or
the thumbnail folder in folder mode."""
location = location or current()
if location.mode == FOLDER_MODE:
return Path(location.path) if location.path else None
return Path(root)
def _identity(root):
return os.path.normcase(os.path.realpath(str(root)))
def _lookup_key(root):
spelling = str(root)
with _state_lock:
if spelling in _key_cache:
return _key_cache[spelling]
keys = dict(_keys)
key = preview_location.find_key(
keys,
spelling,
_identity(root),
preview_location.file_identity(root),
)
if key is not None:
with _state_lock:
_key_cache[spelling] = key
return key
def store_dir(root, location=None):
"""The store of *root*: ``<source>/thumbnails``, or in folder mode
``<folder>/<key>`` (None while the folder is unavailable or before the
source has a key: a reader then finds no preview)."""
location = location or current()
if location.mode == SOURCE_MODE:
return Path(root) / PREVIEW_FOLDER
if not location.available or not location.path:
return None
key = _lookup_key(root)
return None if key is None else _key_folder(location.path, key)
def _key_folder(folder, key):
"""``<folder>/<key>``, only for a key that is one folder name directly
below the thumbnail folder (a marker is data, never a path)."""
if not preview_location.valid_key(key):
return None
store = Path(folder) / key
if store.parent != Path(folder) or store.name != key:
return None
return store
def ensure_store(root, location=None, *, create=False):
"""``store_dir`` for a writer: in folder mode a source without a key gets
one (recorded in the marker); with *create* its key folder is made (never
the thumbnail folder itself). None while the folder is unavailable, and
for a key folder that is a link, junction or reparse point: nothing is
planned, written or removed through it."""
location = location or current()
store = store_dir(root, location)
if location.mode == SOURCE_MODE or (store is None and not location.available):
return store
if store is None:
store = _assign_key(root, location)
if store is None:
return None
try:
with store_io():
linked = preview_location.is_link_like(store)
except OSError:
return None
if linked:
logger.warning(f"Skipping the thumbnail folder {store}: it is a link")
return None
if create:
try:
with store_io():
os.mkdir(store)
except FileExistsError:
pass # already there: another writer made the key folder
except OSError:
return None
return store
def _assign_key(root, location):
parsed = _parsed
if location.path != parsed.path:
return None
spelling = str(root)
identity = _identity(root)
dev_ino = preview_location.file_identity(root)
assigned = {}
def add(marker):
if marker.get("id") != parsed.id:
raise ValueError("another folder's marker") # marker-mismatch
keys = marker["keys"]
key = preview_location.find_key(keys, spelling, identity, dev_ino)
if key is None:
taken = {k: v.get("identity") for k, v in keys.items()}
key = preview_location.source_key(spelling, identity, taken)
keys[key] = {
"path": spelling,
"identity": identity,
"dev_ino": dev_ino,
"install": parsed.install,
}
assigned["key"] = key
try:
with write_lock, store_io():
marker = preview_location.write_marker(location.path, add)
except (OSError, ValueError):
return None
_set_keys(parsed, marker)
return _key_folder(location.path, assigned["key"])
def may_write(path, location=None):
"""The one ownership check every preview write and delete passes.
Source mode: True (the callers' own root rules apply, as before). Folder
mode: *path* lies below a source key folder of the thumbnail folder,
lexically and resolved, and no link, junction or reparse point sits
anywhere from the key folder down to it (the file itself included).
"""
location = location or current()
if location.mode == SOURCE_MODE:
return True
if not location.path:
return False
folder = Path(os.path.abspath(location.path))
path = Path(os.path.abspath(path))
try:
relative = path.relative_to(folder)
except ValueError:
return False
if len(relative.parts) < 2 or ".." in relative.parts:
return False
store = _key_folder(folder, relative.parts[0])
if store is None or not _within(path, folder):
return False
try:
return _no_link_below(store, path)
except OSError:
note_failure()
return False
def make_parent(dst, location=None):
"""Make the folders a preview *dst* is written into. In folder mode only
folders below the thumbnail folder are made, never the folder itself,
and only while it holds this install's marker (an unplugged drive or an
unmounted mount point raises FileNotFoundError); a *dst* that does not
pass ``may_write`` raises PermissionError."""
location = location or current()
dst = Path(dst)
if location.mode == SOURCE_MODE or not location.path:
if location.mode != SOURCE_MODE:
raise FileNotFoundError(errno.ENOENT, "thumbnail folder unavailable")
dst.parent.mkdir(parents=True, exist_ok=True)
return
folder = Path(os.path.abspath(location.path))
with store_io():
if not location.available or not folder.is_dir():
raise FileNotFoundError(errno.ENOENT, "thumbnail folder unavailable")
marker = preview_location.read_marker(str(folder))
if marker is None or marker.get("id") != _parsed.id:
note_failure()
raise FileNotFoundError(errno.ENOENT, "thumbnail folder unavailable")
if not may_write(dst, location):
raise PermissionError(errno.EACCES, "outside the thumbnail folder")
current_folder = folder
for part in Path(os.path.abspath(dst.parent)).relative_to(folder).parts:
current_folder = current_folder / part
try:
os.mkdir(current_folder)
except FileExistsError:
pass # already there: another writer made this folder
if preview_location.is_link_like(current_folder):
raise PermissionError(errno.EACCES, "a link in the thumbnail folder")
def store_is_link(store, location=None):
"""Whether a store folder is a link (a junction or reparse point too in
folder mode): it is skipped by sweeps and refused by clears."""
location = location or current()
if location.mode == SOURCE_MODE:
return Path(store).is_symlink()
return preview_location.is_link_like(store)
def given_store(root, store):
"""A caller's explicit store argument: the default spelling
(``<root>/thumbnails``) means the resolver's store; another folder (only
tests pass one) is used as given."""
store = Path(store)
if store == Path(root) / PREVIEW_FOLDER:
return store_dir(root)
return store
def preview_path(root, media_rel, ext=None, location=None, *, create=False):
"""Where the preview of *media_rel* (relative to *root*) is, or is to be
written (*create*: a writer, which gives a new source its key), in *ext*
(the extension written now when None)."""
if create:
store = ensure_store(root, location)
else:
store = store_dir(root, location)
if store is None:
return None
if ext is None:
from .thumbnail_encode import writer_settings
ext = writer_settings().extension
return store / (thumbnail_settings.preview_stem(media_rel) + ext)
def request_path(root, parts, location=None):
"""The disk path of the URL parts ``("thumbnails", ...)``, or None when
*parts* is not under an exact ``thumbnails`` first part or the store has
no place for it. The text is checked before (``is_preview_text``)."""
parts = tuple(parts)
if len(parts) < 2 or parts[0] != PREVIEW_FOLDER:
return None
store = store_dir(root, location)
if store is None:
return None
return store.joinpath(*parts[1:])
def _no_link_below(store, path):
"""No folder from *store* down to *path* (and *store* itself) is a link,
junction or reparse point (lexical walk, one lstat each)."""
store = Path(os.path.abspath(store))
path = Path(os.path.abspath(path))
try:
relative = path.relative_to(store)
except ValueError:
return False
current = store
for part in ("",) + relative.parts:
current = current / part if part else current
if preview_location.is_link_like(current):
return False
return True
def owns(root, path, location=None):
"""Whether *path* lies inside the store of *root*, resolved: the store
stays inside its container and no link takes *path* out of the store.
In folder mode no link, junction or reparse point may sit anywhere from
the store down to *path*."""
location = location or current()
store = store_dir(root, location)
if store is None:
return False
container = store_container(root, location)
if not (_within(store, container) and _within(path, store)):
return False
if location.mode == SOURCE_MODE:
return True
return may_write(path, location)
# ── preview text ─────────────────────────────────────────────────────
def is_preview_text(filepath):
"""URL text naming a preview: ``thumbnails/<...>/<name>_thumb<ext>``.
Text only, before any join: no backslash, drive or stream colon, and no
empty, ``.`` or ``..`` segment; ``thumbnails`` exactly as the first part
and nowhere else among the folders; the extension, in any case, one a
preview may carry (``X_thumb.JPG`` of an older release included).
"""
if not filepath or "\\" in filepath or ":" in filepath:
return False
parts = filepath.split("/")
if len(parts) < 2 or parts[0] != PREVIEW_FOLDER:
return False
if any(part in ("", ".", "..") for part in parts):
return False
if PREVIEW_FOLDER in parts[1:-1]:
return False
stem, extension = os.path.splitext(parts[-1])
return (
stem.endswith(thumbnail_settings.THUMB_SUFFIX)
and len(stem) > len(thumbnail_settings.THUMB_SUFFIX)
and extension.lower() in thumbnail_settings.THUMBNAIL_EXTENSIONS
)
# ── writes ───────────────────────────────────────────────────────────
class _WriteGuard:
"""``guard()`` for one preview write: true while the location has not
changed since the write started and *hold* (if any) allows it."""
__slots__ = ("generation", "_hold")
def __init__(self, hold=None):
self.generation = _generation
self._hold = hold
def __call__(self):
if self.generation != _generation:
return False
return self._hold is None or bool(self._hold())
def write_guard(hold=None):
"""Take when a preview write starts; hand to its final rename."""
if isinstance(hold, _WriteGuard):
return hold
return _WriteGuard(hold)
@contextmanager
def committed(guard=None):
"""Around a preview's final rename: holds ``write_lock`` and yields
whether the write may land (``guard()``, re-checked under the lock)."""
with write_lock:
yield guard is None or bool(guard())
+12 -1
View File
@@ -30,6 +30,7 @@ from . import thumbnail_settings
from .admin import _current_image_monitor
from .job_routes import busy_response
from .jobs import THUMBNAILS_JOB_KIND, JobBusy
from .thumbnail_location import needs_move_response
try:
from ...utils.validators import validate_result_timeout, validate_worker_threads
@@ -342,6 +343,12 @@ class SettingsRoutesMixin:
"sources": source_status,
"monitor": monitor,
"thumbnail_encoders": encoders,
# #320: the full path only here (D6A).
"thumbnail_location": await self._run_in_executor(
functools.partial(
self._thumbnail_location_payload, with_path=True
)
),
"settings": {
"monitoring_enabled": GalleryConfig.MONITORING_ENABLED,
"save_foreign_runs": GalleryConfig.SAVE_FOREIGN_RUNS,
@@ -468,6 +475,9 @@ class SettingsRoutesMixin:
if error_response is not None:
return error_response
if "thumbnail_location" in data:
# #320: only the Move thumbnails job changes the location.
return needs_move_response()
expected_revision = data.get("expected_revision")
if expected_revision is not None and not isinstance(expected_revision, str):
return web.json_response(
@@ -576,7 +586,8 @@ class SettingsRoutesMixin:
for key in _THUMBNAIL_SETTINGS
)
job = self._jobs.active(THUMBNAILS_JOB_KIND) if changed else None
if job is None or job.scope.get("mode") != "convert":
# A move copies previews of the current settings (#320).
if job is None or job.scope.get("mode") not in ("convert", "move"):
return None
return web.json_response(
{
+22 -1
View File
@@ -30,6 +30,8 @@ import ntpath
import os
import re
from .preview_location import HOLDS_THUMBNAILS_REASON
logger = logging.getLogger("prompt_manager.api")
MAX_SOURCE_PATH = 4096 # characters
@@ -114,7 +116,26 @@ def source_problem(text):
code = source_form_error(text)
if code is not None:
return code, FORM_REASONS[code]
return GalleryConfig.gallery_root_problem(text)
code, reason = GalleryConfig.gallery_root_problem(text)
if code is None and _holds_thumbnails(GalleryConfig.resolve_gallery_root(text)):
return "holds-thumbnails", HOLDS_THUMBNAILS_REASON
return code, reason
def _holds_thumbnails(path):
"""Whether *path* is inside or around the thumbnail folder in use (#320):
a source there would list Prompt Manager's own previews."""
from . import preview_store
try:
from ...utils.library_location import _inside_any
except ImportError: # tests import the package top-level
from utils.library_location import _inside_any
setting = preview_store.setting()
if setting.mode != preview_store.FOLDER_MODE or not setting.path:
return False
return _inside_any(path, [setting.path]) or _inside_any(setting.path, [path])
def public_spelling(text):
+193 -50
View File
@@ -8,7 +8,8 @@ previews behind those URLs are made here, in three ways:
- **On first view.** ``GET /prompt_manager/images/serve/thumbnails/<rel>_thumb
<ext>`` for a preview that does not exist yet is admitted on the event loop
(``_PreviewGate``): at most ``ON_DEMAND_LIMIT`` previews are written at once,
on this API's own small pool (never the default executor), one flight runs
on this API's interactive image pool (``IMAGE_POOL_SIZE`` threads, shared
with card facts and inspector reads; never the default executor), one flight runs
per file, and the requests waiting are capped (``MAX_WAITING``); past the
cap a request gets its fallback at once. Inside the admission the preview is
mapped back to its media file under the same root (the request text is
@@ -24,7 +25,8 @@ previews behind those URLs are made here, in three ways:
- **On capture.** The image monitor hands every new output it settles to
``queue_capture_thumbnail``, which queues its preview for one background
worker thread (``AutoThumbnailer``). Each wake takes at most Worker threads
(``WORKER_THREADS``) files and writes them on that many threads; enqueuing
(``WORKER_THREADS``) files and writes them on the long-lived job pool
(``utils.parallel``, shared with scans and thumbnail jobs); enqueuing
is O(1), so the monitor and ComfyUI's queue never wait. A file already
queued is not queued again, the queue is bounded (``MAX_QUEUED``) and a
preview left out is made on its first view instead.
@@ -49,7 +51,7 @@ write starts (after its slot) and again just before its rename.
Shutdown: ``close_auto_thumbnails`` stops the worker at a file boundary,
kills a running ffmpeg, waits a bounded time (and logs when the wait ran
out) and shuts the view pool down; after the app's cleanup
out) and shuts the image pool down; after the app's cleanup
(``final=True``) nothing starts a worker again. Importing this module again
(a package reload) stops the workers the previous import left.
"""
@@ -60,12 +62,12 @@ import functools
import io
import os
import threading
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import CancelledError, ThreadPoolExecutor
from pathlib import Path
from aiohttp import web
from . import thumbnail_settings
from . import preview_store, thumbnail_settings
from .images import (
in_trash_below_roots,
MEDIA_EXTENSIONS,
@@ -76,7 +78,7 @@ from .images import (
_thumbnail_is_fresh,
_video_placeholder_image,
_worker_threads,
media_thumbnail_rel,
media_preview_path,
)
from .jobs import (
DUPLICATE_CLEANUP_JOB_KIND,
@@ -104,8 +106,11 @@ PLACEHOLDER_SIZE = (thumbnail_settings.DEFAULT_EDGE, thumbnail_settings.DEFAULT_
PLACEHOLDER_QUALITY = 85
# Source extensions in the order a shared preview name looks for them (R2).
_SOURCE_ORDER = thumbnail_settings.IMAGE_SOURCE_ORDER + tuple(sorted(VIDEO_EXTENSIONS))
# Previews views may write at once (the size of the view pool).
ON_DEMAND_LIMIT = 3
# Threads of the interactive image pool (#523): view previews, card facts and
# inspector reads. Fixed, so image buffers stay in a few threads' arenas.
IMAGE_POOL_SIZE = 3
# Previews views may write at once (the whole image pool).
ON_DEMAND_LIMIT = IMAGE_POOL_SIZE
# View requests that may wait on the gate; one more gets its fallback at once.
MAX_WAITING = 64
# How long a pool thread waits for the capture worker's flight of the same file.
@@ -124,7 +129,7 @@ AUTO_REFUSED_KINDS = frozenset(
{RESTORE_JOB_KIND, DUPLICATE_CLEANUP_JOB_KIND, SOURCE_CHANGE_JOB_KIND}
)
WORKER_NAME = "PromptManagerThumbnailWorker"
POOL_PREFIX = "pm-preview"
POOL_PREFIX = "pm-image"
# Identifies this import's workers: a reload stops the ones it did not start.
_MODULE_TOKEN = object()
@@ -153,6 +158,36 @@ def _dst_key(dst):
return flight_key(Path(dst).parent, (Path(dst).name,))
class DeferredPreview:
"""The preview of *rel* under *root*, resolved when its write starts
(#320): a queued capture follows the thumbnail location at write time,
not the one when it was queued."""
__slots__ = ("root", "rel", "strict")
def __init__(self, root, rel, *, strict=True):
self.root = Path(root)
self.rel = Path(rel)
# A capture refuses a store linked out of its container (the serve
# route's rule); a planned target keeps the plan's rule: inside the
# store, as the thumbnail job writes it.
self.strict = strict
def resolve(self):
"""The preview's path now, or None when nothing may be written."""
dst = media_preview_path(self.root, self.rel, create=True)
if dst is None:
return None
if self.strict:
return dst if preview_store.owns(self.root, dst) else None
store = preview_store.store_dir(self.root)
return dst if _is_within(dst, store) else None
def _resolved(dst):
return dst.resolve() if isinstance(dst, DeferredPreview) else Path(dst)
@functools.lru_cache(maxsize=1)
def video_placeholder_jpeg():
"""The placeholder frame as JPEG bytes (a video whose preview is missing)."""
@@ -210,10 +245,19 @@ def _candidates(parts):
def _current_preview(root, parts):
"""The preview *parts* names, in the format written now (#317): a request
for an older name (``_thumb.png`` before a conversion) is answered with
the current file."""
the current file. None when the store of *root* has no place for it."""
_folder, base, _wanted = _candidates(parts)
name = base + thumbnail_settings.THUMB_SUFFIX + writer_settings().extension
return Path(root).joinpath(*parts[:-1], name)
return preview_store.request_path(root, (*parts[:-1], name))
def _request_key(root, parts):
"""The flight key of the preview *parts* under *root*: the key of its
disk path (so the serve and history routes share one flight)."""
path = preview_store.request_path(root, parts)
if path is None:
return flight_key(root, parts)
return _dst_key(path)
def _quick_source(root, parts):
@@ -254,12 +298,14 @@ def _thumbnail_source(root, parts):
if in_trash_below_roots(found, [root]):
return None
rel = Path(*folder_parts, found.name)
dst = root / media_thumbnail_rel(rel)
dst = media_preview_path(root, rel, create=True)
if dst is None:
# Nowhere to write (the thumbnail folder is unavailable): the
# original is served and nothing is written.
return found, None, found.suffix.lower() in VIDEO_EXTENSIONS
if dst != _current_preview(root, parts):
return None
if not _is_within(root / "thumbnails", root) or not _is_within(
dst, root / "thumbnails"
):
if not preview_store.owns(root, dst):
dst = None
return found, dst, found.suffix.lower() in VIDEO_EXTENSIONS
@@ -279,6 +325,21 @@ def _scan_folder(folder, base, extensions):
return None
def _deferred(roots, src, dst):
"""The ``DeferredPreview`` of a planned ``(src, dst)``: the root whose
store the plan wrote *dst* into (nested roots plan one each); *dst* as
planned when none matches."""
src = Path(src)
for root in roots:
root = Path(root)
if not src.is_relative_to(root):
continue
rel = src.relative_to(root)
if media_preview_path(root, rel) == Path(dst):
return DeferredPreview(root, rel, strict=False)
return dst
def _stop_stale_workers():
"""Stop capture workers a previous import of this module started."""
for thread in threading.enumerate():
@@ -294,7 +355,7 @@ class AutoThumbnailer:
``generate(src, dst, is_video, *, stop, guard, track)`` writes one
preview and returns ``_generate_one``'s result dict; ``may_write()``
says whether automatic previews may be written now. Views call
``ensure`` on the view pool, the monitor calls ``enqueue`` from its
``ensure`` on the image pool, the monitor calls ``enqueue`` from its
thread.
"""
@@ -420,16 +481,23 @@ class AutoThumbnailer:
def enqueue(self, src, dst, is_video):
"""Queue *dst*'s preview for the worker; False when already queued,
full, closed or its worker is refused at interpreter exit. Never
blocks on a write."""
key = _dst_key(dst)
full, closed, nowhere to write it or its worker is refused at
interpreter exit. Never blocks on a write. *dst* may be a
``DeferredPreview``: its path is resolved again when the write
starts."""
now = _resolved(dst)
if now is None:
return False
key = _dst_key(now)
if not isinstance(dst, DeferredPreview):
dst = now
with self._changed:
if self._closed or key in self._queued:
return False
if len(self._queue) >= self._max_queued:
return False
self._queued.add(key)
self._queue.append((key, Path(src), Path(dst), bool(is_video)))
self._queue.append((key, Path(src), dst, bool(is_video)))
self._busy = True
started = True
if self._start_worker and not self._worker_running:
@@ -491,12 +559,15 @@ class AutoThumbnailer:
self._worker_running = False
self._changed.notify_all()
return
take = min(len(self._queue), max(1, int(self._workers() or 1)))
workers = max(1, int(self._workers() or 1))
take = min(len(self._queue), workers)
batch = [self._queue.popleft() for _ in range(take)]
self._batch = len(batch)
self._changed.notify_all()
try:
map_parallel(self._write_queued, batch, len(batch))
# The job pool's size is the setting; a smaller batch uses
# fewer of its threads (#523: one long-lived pool).
map_parallel(self._write_queued, batch, workers)
finally:
with self._changed:
self._batch = 0
@@ -506,6 +577,9 @@ class AutoThumbnailer:
def _write_queued(self, item):
_key, src, dst, is_video = item
try:
dst = _resolved(dst)
if dst is None:
return # nowhere to write it now: made on its first view
self.ensure(src, dst, is_video)
except Exception: # one file never stops the worker
self._logger.warning(
@@ -567,7 +641,7 @@ class _PreviewGate:
"""Admission of view requests on the event loop (#249 I2).
Waiters are coroutines: at most ``limit`` previews are written at once
on the view pool, one flight runs per key, and past ``MAX_WAITING``
on the image pool, one flight runs per key, and past ``MAX_WAITING``
waiting requests ``run`` answers ``OVER`` at once.
"""
@@ -622,13 +696,17 @@ class _PreviewGate:
flight.set_result(result)
def _on_image_thread():
return threading.current_thread().name.startswith(POOL_PREFIX + "_")
class AutoThumbnailMixin:
"""Automatic previews on the API: views, the monitor's queue, scans."""
_auto_thumbnailer = None
_auto_thumbnails_shut = False
_preview_gate_obj = None
_preview_pool_obj = None
_image_pool_obj = None
_auto_thumbnailer_lock = threading.Lock()
def _auto_thumbnails(self):
@@ -651,7 +729,7 @@ class AutoThumbnailMixin:
self._auto_thumbnails_shut = True
def close_auto_thumbnails(self, final=False, timeout=None):
"""Stop the capture worker and the view pool, and forget them.
"""Stop the capture worker and the image pool, and forget them.
Bounded by ``timeout`` (see AutoThumbnailer.close). ``final`` (the
package teardown) also keeps a late monitor event from starting a
@@ -662,7 +740,7 @@ class AutoThumbnailMixin:
if final:
self._auto_thumbnails_shut = True
thumbnailer, self._auto_thumbnailer = self._auto_thumbnailer, None
pool, self._preview_pool_obj = self._preview_pool_obj, None
pool, self._image_pool_obj = self._image_pool_obj, None
self._preview_gate_obj = None
closed = True
if thumbnailer is not None:
@@ -671,6 +749,8 @@ class AutoThumbnailMixin:
pool.shutdown(wait=False, cancel_futures=True)
if final:
shutdown_encoders() # kill JPEG XL / ffmpeg encoders still running
# The thumbnail folder's probe (#320), bounded like the rest.
closed = preview_store.stop_probe() and closed
return closed
def _auto_thumbnail_failures(self):
@@ -695,6 +775,9 @@ class AutoThumbnailMixin:
return job
if job.kind == THUMBNAILS_JOB_KIND and job.title == THUMBNAIL_CLEAR_TITLE:
return job
# Move thumbnails holds automatic previews for its whole run (D2A).
if job.kind == THUMBNAILS_JOB_KIND and job.scope.get("mode") == "move":
return job
return None
# ── the view admission ────────────────────────────────────────────
@@ -706,16 +789,67 @@ class AutoThumbnailMixin:
gate = self._preview_gate_obj = _PreviewGate(ON_DEMAND_LIMIT, self.logger)
return gate
def _preview_pool(self):
with AutoThumbnailMixin._auto_thumbnailer_lock:
if self._preview_pool_obj is None:
self._preview_pool_obj = ThreadPoolExecutor(
ON_DEMAND_LIMIT, thread_name_prefix=POOL_PREFIX
)
return self._preview_pool_obj
def _image_pool(self):
"""The interactive image pool (#523): IMAGE_POOL_SIZE threads for view
previews (behind their gate), card facts and inspector reads, so
image buffers never spread over the default executor's threads.
def _preview_pool_threads(self):
pool = self._preview_pool_obj
None after the package teardown (``close_auto_thumbnails(final=True)``):
a late request then runs on the default executor (``run_in_executor``
with None) and starts no thread the teardown no longer stops.
"""
with AutoThumbnailMixin._auto_thumbnailer_lock:
if self._image_pool_obj is None and not self._auto_thumbnails_shut:
self._image_pool_obj = ThreadPoolExecutor(
IMAGE_POOL_SIZE, thread_name_prefix=POOL_PREFIX
)
return self._image_pool_obj
async def _run_image(self, func, *args, **kwargs):
"""``_run_in_executor`` for interactive image work: the image pool.
After the teardown, or with the pool shut down in between (a test
reset), the call runs on the default executor as before.
"""
call = functools.partial(func, *args, **kwargs)
loop = asyncio.get_running_loop()
try:
future = loop.run_in_executor(self._image_pool(), call)
except RuntimeError: # submit refused: the pool was shut down
future = loop.run_in_executor(None, call)
return await future
def _map_image(self, func, items):
"""``func`` over *items* on the image pool, in order (blocking).
Called from an image pool thread, after the teardown, or with the
pool shut down in between (its queued items are cancelled), the
items it did not run run on the calling thread.
"""
items = list(items)
pool = None if not items or _on_image_thread() else self._image_pool()
if pool is None:
return [func(item) for item in items]
futures = []
try:
for item in items:
futures.append(pool.submit(func, item))
except RuntimeError:
pass # shut down in between: the rest run here
results = []
for index, item in enumerate(items):
future = futures[index] if index < len(futures) else None
if future is None or future.cancelled():
results.append(func(item))
continue
try:
results.append(future.result())
except CancelledError: # cancelled while this caller waited
results.append(func(item))
return results
def _image_pool_threads(self):
pool = self._image_pool_obj
return [] if pool is None else list(getattr(pool, "_threads", ()))
def _video_placeholder_bytes(self):
@@ -730,7 +864,7 @@ class AutoThumbnailMixin:
return await self._preview_gate().run(
flight_key(Path(dst).parent, (Path(dst).name,)),
functools.partial(self._ensure_view, src, dst, is_video),
self._preview_pool(),
self._image_pool(),
)
def _ensure_view(self, src, dst, is_video):
@@ -774,17 +908,17 @@ class AutoThumbnailMixin:
The flight answers only the outcome (a history flight may be the one
joined); the file it previews is derived here: the preview path is
the request's own, and a fallback looks its source up again on the
view pool (bounded by it, never the default executor).
image pool (bounded by it, never the default executor).
"""
if _has_traversal(filepath) or not _is_preview_request(filepath):
return None
parts = tuple(filepath.split("/"))
gate = self._preview_gate()
pool = self._preview_pool()
pool = self._image_pool()
for root in output_dirs:
root = Path(root)
outcome = await gate.run(
flight_key(root, parts),
_request_key(root, parts),
functools.partial(self._preview_on_demand, root, parts),
pool,
)
@@ -797,7 +931,7 @@ class AutoThumbnailMixin:
continue
return self._preview_response(BUSY, quick[0], None, quick[1])
dst = _current_preview(root, parts)
if outcome == READY:
if outcome == READY and dst is not None:
return self._file_response(dst)
located = await gate.loop.run_in_executor(
pool, _thumbnail_source, root, parts
@@ -820,17 +954,31 @@ class AutoThumbnailMixin:
"""
if self._auto_thumbnails_shut:
return "skipped"
located = self._capture_target(image_path)
located = self._capture_source(image_path)
if located is None:
return "skipped"
src, dst, is_video = located
src, root, rel, is_video = located
dst = DeferredPreview(root, rel).resolve()
if dst is None:
return "skipped"
if _thumbnail_is_fresh(src, dst):
return "fresh"
if self._auto_thumbnails().enqueue(src, dst, is_video):
if self._auto_thumbnails().enqueue(src, DeferredPreview(root, rel), is_video):
return "queued"
return "skipped"
def _capture_target(self, image_path):
"""``(src, dst, is_video)`` of a new output's preview now, or None."""
located = self._capture_source(image_path)
if located is None:
return None
src, root, rel, is_video = located
dst = DeferredPreview(root, rel).resolve()
return None if dst is None else (src, dst, is_video)
def _capture_source(self, image_path):
"""``(src, root, rel, is_video)`` of a media file under a current
root (not a preview, a link or in a trash folder), or None."""
path = Path(os.path.abspath(str(image_path)))
if path.suffix.lower() not in MEDIA_EXTENSIONS or not _regular_file(path):
return None
@@ -843,12 +991,7 @@ class AutoThumbnailMixin:
rel = Path(os.path.relpath(path, root))
if "thumbnails" in rel.parts[:-1] or in_trash_below_roots(path, [root]):
return None
dst = root / media_thumbnail_rel(rel)
if not _is_within(root / "thumbnails", root) or not _is_within(
dst, root / "thumbnails"
):
return None
return path, dst, path.suffix.lower() in VIDEO_EXTENSIONS
return path, root, rel, path.suffix.lower() in VIDEO_EXTENSIONS
return None
# ── after a scan ──────────────────────────────────────────────────
@@ -890,7 +1033,7 @@ class AutoThumbnailMixin:
return "none"
thumbnailer = self._auto_thumbnails()
for src, dst, is_video in plan["targets"]:
thumbnailer.enqueue(src, dst, is_video)
thumbnailer.enqueue(src, _deferred(output_dirs, src, dst), is_video)
self.logger.info(f"{missing:,} previews missing after the scan; queued")
return "queued"
+8 -1
View File
@@ -44,7 +44,7 @@ import os
import time
from pathlib import Path
from . import thumbnail_settings
from . import preview_store, thumbnail_settings
from .thumbnail_encode import ThumbnailVerifyError, verify_thumbnail, writer_settings
from .admin import _plural, _scan_location, _scan_roots, _scan_status_line
from .images import _thumbnail_is_fresh, _worker_threads, _thumbnail_batch_size
@@ -153,6 +153,7 @@ class ThumbnailConvertMixin:
job.log_line("Conversion failed · internal error")
state = "failed"
job.finish(state)
await self._release_job_memory()
async def _convert_job_body(self, job, options):
output_dirs = await self._run_in_executor(self._get_all_output_dirs)
@@ -392,6 +393,12 @@ class ThumbnailConvertMixin:
removed, with no bytes (another run or a sibling removed it)."""
removed, kept = 0, []
for path, size in old:
if not preview_store.may_write(path):
# Folder mode: never a file outside a key folder, or one
# reached through a link (checked at the moment of the delete).
self.logger.warning(f"Not removing {path}: outside the thumbnails")
kept.append(path)
continue
try:
os.remove(path)
except FileNotFoundError:
+47 -10
View File
@@ -46,7 +46,7 @@ from pathlib import Path
from PIL import Image
from . import thumbnail_settings
from . import preview_location, preview_store, thumbnail_settings
try:
from ...utils.logging_config import get_logger
@@ -101,14 +101,20 @@ def temp_sibling(dst, tag="part"):
def replace_unless_held(tmp, dst, guard=None):
"""Rename *tmp* over *dst*, unless ``guard()`` is false (PreviewWriteHeld)."""
if guard is not None and not guard():
raise PreviewWriteHeld(PREVIEW_HELD)
os.replace(tmp, dst)
"""Rename *tmp* over *dst*, unless ``guard()`` is false (PreviewWriteHeld).
The check and the rename run under ``preview_store.write_lock`` (#320): a
guard from ``preview_store.write_guard`` also refuses a write that started
before the thumbnail location changed."""
with preview_store.committed(guard) as allowed:
if not allowed:
raise PreviewWriteHeld(PREVIEW_HELD)
os.replace(tmp, dst)
# ``temp_sibling``'s names: ``.<stem>.<tag>-<pid>-<thread id><ext>``.
_TEMP_NAME = re.compile(r"^\..+\.(?:part|src)-(\d+)-\d+\.[A-Za-z0-9]+$")
_MOVE_TEMP_NAME = re.compile(r"\.pm-move-[0-9a-f]{8}\.tmp$")
# A temp at least this old (seconds) outlived its write: no encode runs longer.
STRAY_TEMP_AGE = 4 * ENCODE_TIMEOUT
# When this process started (this module's import): a temp carrying its PID
@@ -120,7 +126,10 @@ def sweep_stray_temps(folder, min_age=None):
"""Remove the temp files a crashed write left under *folder* (a preview
folder, walked without following links); returns ``(count, bytes)``.
Only ``temp_sibling`` names are touched, never a link. With *min_age*
Only ``temp_sibling`` names are touched, never a link: a folder that is a
link, junction or reparse point is not walked into (Windows walks into a
junction even without ``followlinks``), and each file passes
``preview_store.may_write`` before it goes. With *min_age*
(seconds, the startup sweep) a temp younger than that, or one this
process wrote since it started (it may still be writing it), is left;
``min_age=0`` (the clear, which holds every preview write) removes them
@@ -130,19 +139,39 @@ def sweep_stray_temps(folder, min_age=None):
now = time.time()
own = str(os.getpid())
count = size = 0
for root, _dirs, files in os.walk(folder):
try:
if preview_location.is_link_like(folder):
return count, size
except OSError as exc:
logger.debug(f"Not sweeping {folder}: {exc}")
return count, size
for root, dirs, files in os.walk(folder):
dirs[:] = [d for d in dirs if not _link_like(Path(root) / d)]
for name in files:
match = _TEMP_NAME.match(name)
if match is None:
# A move's copy temp (#320): ``<name>.pm-move-<8 hex>.tmp``.
move_temp = match is None and _MOVE_TEMP_NAME.search(name)
if match is None and not move_temp:
continue
path = Path(root) / name
try:
stat = path.lstat()
if path.is_symlink() or not path.is_file():
if (
path.is_symlink()
or not path.is_file()
or _link_like(path)
or not preview_store.may_write(path)
):
continue
if min_age and (
now - stat.st_mtime < min_age
or (match.group(1) == own and stat.st_mtime >= PROCESS_STARTED)
or move_temp
and stat.st_mtime >= PROCESS_STARTED
or (
match is not None
and match.group(1) == own
and stat.st_mtime >= PROCESS_STARTED
)
):
continue
path.unlink()
@@ -154,6 +183,14 @@ def sweep_stray_temps(folder, min_age=None):
return count, size
def _link_like(path):
"""``is_link_like``, with a path that cannot be checked counted as one."""
try:
return preview_location.is_link_like(path)
except OSError:
return True
def _unlink_quietly(path):
try:
Path(path).unlink()
+41 -4
View File
@@ -49,6 +49,7 @@ from .images import (
_worker_threads,
)
from .job_routes import busy_response
from .thumbnail_location import location_refusal
from .jobs import THUMBNAIL_GROUP_NAMES, THUMBNAILS_JOB_KIND, JobBusy
from .thumbnail_convert import CONVERT_JOB_TITLE
from .thumbnail_encode import writer_settings
@@ -131,7 +132,10 @@ class ThumbnailJobsMixin:
"""The thumbnail plan, the thumbnail job and the legacy stream on it."""
async def get_thumbnail_plan(self, request):
"""What a thumbnail run would do (read-only; capped like a run)."""
"""What a thumbnail run would do (read-only; capped like a run).
``mode=prune`` lists the removed sources' thumbnails (#320)."""
if request.query.get("mode") == "prune":
return await self.get_prune_plan()
options, error = thumbnail_options(request.query, from_query=True)
if error:
return _invalid(error)
@@ -179,15 +183,39 @@ class ThumbnailJobsMixin:
)
except (ValueError, UnicodeDecodeError):
return _invalid(_OPTIONS_ERROR)
if isinstance(body, dict) and body.get("mode") in ("move", "prune"):
return await self._start_location_job(body)
options, error = thumbnail_options(body)
if error:
return _invalid(error)
refused = location_refusal()
if refused is not None:
return refused
try:
job = self._start_thumbnail_job(options)
except JobBusy as busy:
return busy_response(busy)
return web.json_response({"success": True, "job": job.summary()}, status=202)
async def _start_location_job(self, body):
"""Move thumbnails or Remove thumbnails of removed sources (#320)."""
if body["mode"] == "prune":
refused = location_refusal()
if refused is not None:
return refused
try:
if body["mode"] == "move":
started = self.start_thumbnail_move(body)
else:
started = self.start_thumbnail_prune()
except JobBusy as busy:
return busy_response(busy)
if isinstance(started, web.Response):
return started
return web.json_response(
{"success": True, "job": started.summary()}, status=202
)
def _start_thumbnail_job(self, options):
"""Register the thumbnail job synchronously; raises JobBusy.
@@ -226,6 +254,7 @@ class ThumbnailJobsMixin:
)
state = "failed"
job.finish(state)
await self._release_job_memory()
async def _thumbnail_job_body(self, job, options):
"""Plan, then generate in worker batches; returns the final state.
@@ -458,8 +487,12 @@ class ThumbnailJobsMixin:
or attaches to the running thumbnail job, and streams its frames. A
legacy hold (the blocking generate or clear) or a record-changing
job refuses it with the stream's error frame. The job goes on when
the client goes away.
the client goes away. Refused (409) while the thumbnail folder is
unavailable (#320).
"""
refused = location_refusal()
if refused is not None:
return refused
response = web.StreamResponse(
status=200,
headers={
@@ -506,8 +539,12 @@ class ThumbnailJobsMixin:
the way.
"""
running = self._jobs.active(THUMBNAILS_JOB_KIND)
if running is not None and running.scope.get("mode") == "convert":
return None, False, running # no legacy frames to follow (#317)
if running is not None and running.scope.get("mode") in (
"convert",
"move",
"prune",
):
return None, False, running # no legacy frames to follow (#317, #320)
if running is not None and running.cancellable:
return running, False, None
busy = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded")
+162
View File
@@ -0,0 +1,162 @@
"""The thumbnail location on the API (#320): startup, the settings payload
and the refusals while the thumbnail folder is unavailable.
The setting itself is config.json ``thumbnails.location``
(``preview_location``); the runtime state is ``preview_store``. POST
``/settings`` never changes it (409 ``location-needs-move``): the Move
thumbnails job is its only writer.
"""
import os
from aiohttp import web
from . import preview_location, preview_store
try:
from ...utils import library_location
except ImportError: # tests import the package top-level
from utils import library_location
# The node folder (its web/ is served and a reinstall wipes it).
NODE_FOLDER = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
def default_path():
"""``<data folder>/data/thumbnails`` (D7B), or None when unknown."""
try:
return library_location.cache_dir("thumbnails")
except Exception: # no data folder can be decided: no default
return None
def location_refusal(location=None):
"""The 409 ``location-unavailable`` response while the thumbnail folder
is unavailable, else None (thumbnail generate, convert, clear, prune)."""
location = location or preview_store.current()
if location.mode != preview_store.FOLDER_MODE or location.available:
return None
status, text = preview_location.message("location-unavailable")
return web.json_response(
{"success": False, "code": "location-unavailable", "error": text},
status=status,
)
def needs_move_response():
status, text = preview_location.message("location-needs-move")
return web.json_response(
{"success": False, "code": "location-needs-move", "error": text},
status=status,
)
class ThumbnailLocationMixin:
"""Startup check and payloads of the thumbnail location."""
def _all_source_roots(self):
"""Every image source of every root list: the Images roots, the
configured sources and the extra gallery roots (as strings)."""
from ..config import GalleryConfig, _extra_gallery_roots
roots = [str(root) for root in self._get_all_output_dirs()]
roots += [str(root) for root in GalleryConfig.MONITORING_DIRECTORIES]
roots += list(_extra_gallery_roots())
return tuple(dict.fromkeys(root for root in roots if root))
def _thumbnail_location_context(self):
"""What a thumbnail folder is validated against (blocking)."""
contained = []
try:
import folder_paths
for name in (
"get_output_directory",
"get_input_directory",
"get_temp_directory",
):
getter = getattr(folder_paths, name, None)
folder = getter() if getter is not None else None
if folder:
contained.append(str(folder))
except ImportError:
pass # folder_paths exists only inside ComfyUI (tests run without it)
try:
contained.append(library_location.config_location()[1])
except Exception: # no data folder can be decided
self.logger.debug("No library folder for the thumbnail check")
setting = preview_store.setting()
return preview_location.Context(
roots=self._all_source_roots(),
contained=tuple(contained),
node_folder=NODE_FOLDER,
default_path=default_path(),
current_path=(
setting.path if setting.mode == preview_store.FOLDER_MODE else None
),
)
def use_preview_location(self):
"""Put the configured location in force at once, unchecked (the
package's import, before any request): folder mode is unavailable
until ``start_preview_location`` has checked it, so nothing is ever
written to the sources meanwhile. No filesystem access."""
from ..config import PromptManagerConfig
try:
return preview_store.configure(
PromptManagerConfig.THUMBNAIL_LOCATION, check=False
)
except Exception:
self.logger.warning("The thumbnail location is unusable", exc_info=True)
preview_store.mark_invalid()
return preview_store.current()
def start_preview_location(self):
"""Parse and check the configured location once (startup thread);
the probe takes over. Never raises; a check that fails leaves a
folder location unavailable (``invalid``), never the sources."""
from ..config import PromptManagerConfig
try:
value = PromptManagerConfig.THUMBNAIL_LOCATION
parsed = preview_location.parse_location(value)
context = None
if parsed.mode == preview_store.FOLDER_MODE:
context = self._thumbnail_location_context()
location = preview_store.configure(value, context=context)
except Exception:
self.logger.warning("The thumbnail location check failed", exc_info=True)
if (
preview_location.parse_location(
PromptManagerConfig.THUMBNAIL_LOCATION
).mode
== preview_store.FOLDER_MODE
):
preview_store.mark_invalid()
return preview_store.current()
if location.mode == preview_store.FOLDER_MODE and not location.available:
self.logger.warning(
"The thumbnail folder is unavailable "
f"({location.problem or 'checking'}): originals are shown and "
"no thumbnails are written until it is back"
)
return location
def _thumbnail_location_payload(self, *, with_path):
"""``{mode, available, problem}`` plus, for the settings GET only,
``path`` and ``default_path`` (D6A)."""
location = preview_store.current()
payload = {
"mode": location.mode,
"available": location.available,
"problem": location.problem,
}
if with_path:
payload["path"] = location.path
payload["default_path"] = default_path()
# Old copies a move still has to remove (#320 Part 3).
payload["cleanup_pending"] = len(preview_store.setting().cleanup)
return payload
+579
View File
@@ -0,0 +1,579 @@
"""Move thumbnails (#320 Part 3): the reviewed job that changes where previews
live, the removal of old copies after it, recovery at startup and the
"Remove thumbnails of removed sources" scope (Part 5).
Kind ``thumbnails`` with ``mode: "move"`` (D1A): admission ``guarded``, so
scans, deletes, tagging, source changes and every other thumbnail job wait
for it, while the monitor's Needs review recording runs beside it.
Automatic previews are held for the whole job (D2A, ``_auto_thumbnail_blocker``).
Phases: review (``POST /images/thumbnails/plan``, no writes but the
validation probe), start (a plan token), hold (generation bump, journal,
target folder), copy (cancellable at every file boundary, on the job pool
in small chunks, D3A), switch (one config commit sets the location and
records the old stores under ``cleanup``; not cancellable from here),
remove old copies, end. Per-file errors switch anyway (D10A); a folder-wide
error or a cancel before the switch removes what the job wrote and changes
nothing. The move is the only writer of the setting (D4B).
Moving away from a thumbnail folder also removes the previews of this
install's sources that are no longer listed (Q320-8: remove).
"""
import functools
import os
import time
from pathlib import Path
from aiohttp import web
from . import preview_location, preview_store
from . import thumbnail_move_files as files
from .jobs import THUMBNAILS_JOB_KIND
from .thumbnail_move_plan import (
MOVE_GROUPS,
MOVE_TITLE,
PLAN_TTL,
_number,
_plural,
_remove,
_roots_revision,
_volume,
_is_network,
_write_json,
)
from . import thumbnail_move_plan as move_plan
try:
from ...utils import exit_signal
from ...utils.parallel import map_parallel
except ImportError: # tests import the package top-level
from utils import exit_signal
from utils.parallel import map_parallel
class ThumbnailMoveMixin:
"""Start and run the move job; the removal of old copies."""
# ── start ─────────────────────────────────────────────────────────
def _take_move_plan(self, body):
"""The plan a start names, or ``(status, code)``."""
token = body.get("token")
plan = (self._move_plans or {}).get(token) if isinstance(token, str) else None
if plan is None or time.monotonic() - plan.created > PLAN_TTL:
return 409, "plan-stale"
roots = [str(root) for root in self._get_all_output_dirs()]
if _roots_revision(roots) != plan.revision:
return 409, "plan-stale"
if preview_store.setting() != plan.old:
return 409, "plan-stale"
if plan.confirm_required and body.get("confirmed") is not True:
return 400, "confirmation-required"
return plan
def start_thumbnail_move(self, body):
"""Start the move job (sync, on the loop); raises JobBusy. Returns
the job or a refusal response."""
if not self._move_recovered:
blocker = self._jobs.active(THUMBNAILS_JOB_KIND)
return web.json_response(
{
"success": False,
"code": "busy",
"error": "Prompt Manager is still checking an earlier move. "
"Try again in a moment.",
"running": None if blocker is None else {"id": blocker.id},
},
status=409,
)
taken = self._take_move_plan(body)
if isinstance(taken, tuple):
status, code = taken
text = {
"plan-stale": "The review is out of date. Review the move again.",
"confirmation-required": "Confirm that Prompt Manager may "
"create its folders there.",
}[code]
return web.json_response(
{"success": False, "code": code, "error": text}, status=status
)
plan = taken
self._move_plans.pop(plan.token, None)
return self._jobs.start(
THUMBNAILS_JOB_KIND,
MOVE_TITLE,
admission="guarded",
cancellable=True,
scope={
"mode": "move",
"from": plan.old.mode,
"to": plan.target["mode"],
"to_path": plan.target["path"],
},
groups=MOVE_GROUPS,
runner=functools.partial(self._run_thumbnail_move, plan=plan),
)
# ── the job ───────────────────────────────────────────────────────
async def _run_thumbnail_move(self, job, *, plan):
state = {"written": [], "created": [], "created_folder": False}
try:
await self._run_in_executor(self._move_hold, job, plan, state)
except (OSError, ValueError) as error:
await self._run_in_executor(self._move_rollback, plan, state)
self._end_move(
job, "failed", "The new folder could not be made; nothing changed."
)
self.logger.warning(f"Move thumbnails could not start: {error}")
return
left = [
{
"path": "/".join(item),
"root": s.label,
"detail": "Not a thumbnail; left in place",
}
for s in plan.sources
if s.scan is not None
for item in s.scan.left
]
if left:
job.record_counted("left", len(left), left)
items = [
(source, item)
for source in plan.sources
if source.scan is not None
for item in source.scan.copy
]
total = len(items)
job.update(total=total, processed=0, progress=0)
chunk = await self._run_in_executor(self._move_chunk, plan)
workers = self._move_workers()
done = 0
for start in range(0, total, chunk):
if job.cancelled:
await self._move_cancelled(job, plan, state)
return
batch = items[start : start + chunk]
try:
results = await self._run_in_executor(
map_parallel,
functools.partial(self._move_copy_one, state=state),
batch,
workers,
)
except exit_signal.ExitRequested:
return # exiting: the next start undoes the copies (journal)
except files.FolderGone as error:
self.logger.warning(f"Move thumbnails stopped: {error}")
await self._run_in_executor(self._move_rollback, plan, state)
self._end_move(
job,
"failed",
"The new folder could not be reached or is full; nothing changed.",
)
return
except Exception:
# Anything else before the switch: undo, never half a move.
self.logger.exception("Move thumbnails failed while copying")
await self._run_in_executor(self._move_rollback, plan, state)
self._end_move(job, "failed", "The copy failed; nothing changed.")
return
for (source, item), (outcome, detail) in zip(batch, results):
row = {
"path": "/".join(item[0]),
"root": source.label,
"detail": detail,
}
job.record(
"moved" if outcome == "moved" else outcome,
row,
f"{outcome}: {source.label} · {'/'.join(item[0])}",
)
done += len(batch)
label = batch[-1][0].label
job.update(
processed=done,
progress=int(done * 90 / total) if total else 90,
status_line=(
f"Copying {_number(done)} of {_number(total)} previews · {label}"
),
)
job.publish({"type": "progress", "summary": job.summary()})
if job.cancelled:
await self._move_cancelled(job, plan, state)
return
# The switch: not cancellable from here (an exit still stops between
# files and leaves the cleanup to the next start).
job.cancellable = False
job.update(status_line="Switching to the new location", progress=92)
job.publish({"type": "progress", "summary": job.summary()})
try:
await self._run_in_executor(self._move_switch, plan)
except Exception:
self.logger.exception("Move thumbnails could not switch")
await self._run_in_executor(self._move_rollback, plan, state)
self._end_move(
job, "failed", "The setting could not be saved; nothing changed."
)
return
await self._remove_old_copies(job, plan)
moved = job.counters.get("moved", 0) + job.counters.get("skipped", 0)
errors = job.counters.get("errors", 0)
where = plan.target["path"] or "inside each source"
parts = [f"{_number(moved)} moved"]
if errors:
parts.append(_plural(errors, "error"))
parts.append(
"old copies removed"
if not job.scope.get("cleanup_left")
else "some old copies are removed at the next start"
)
self._end_move(
job, "finished", f"Thumbnails moved to {where}: " + " · ".join(parts) + "."
)
def _end_move(self, job, state, line):
job.update(status_line=line, progress=100)
job.log_line(line)
job.publish({"type": "complete", "summary": job.summary()})
job.finish(state)
def _move_workers(self):
from .images import _worker_threads
return max(1, int(_worker_threads() or 1))
def _move_chunk(self, plan):
"""2 files at a time across volumes or to/from a share, else 4, never
more than Worker threads (D3A: the pool keeps its size)."""
sides = [plan.target["path"] or ""] + [s.old_store or "" for s in plan.sources]
sides += [s.new_store for s in plan.sources]
network = any(_is_network(side) for side in sides if side)
volumes = {_volume(side) for side in sides if side}
size = 2 if network or len(volumes) > 1 else 4
return max(1, min(size, self._move_workers()))
def _move_hold(self, job, plan, state):
"""Phase 3: bump the generation, write the journal, make the target."""
preview_store.bump_generation()
if plan.to_folder:
folder = plan.target["path"]
if not os.path.isdir(folder):
if plan.target_default:
os.makedirs(folder, exist_ok=True)
else:
os.mkdir(folder)
state["created_folder"] = True
plan.target_id = preview_location.init_folder(
folder, created_by_pm=state["created_folder"]
)
install = plan.install
def add_keys(marker):
for source in plan.sources:
if source.key and source.key not in marker["keys"]:
marker["keys"][source.key] = {
"path": source.root,
"identity": os.path.normcase(os.path.realpath(source.root)),
"dev_ino": preview_location.file_identity(source.root),
"install": install,
}
with preview_store.write_lock:
preview_location.write_marker(folder, add_keys)
_write_json(
move_plan.journal_path(),
{
"state": "copying",
"from": plan.old.to_config(),
"to": plan.target,
"stores": [s.new_store for s in plan.sources],
# Stores that do not exist yet are made by this job only:
# after a crash everything in them goes (B4).
"created_dirs": [
s.new_store for s in plan.sources if not os.path.isdir(s.new_store)
],
"created_folder": state["created_folder"],
"install": plan.install,
},
)
job.log_line(
f"Move thumbnails started: {plan.old.path or 'inside each source'} → "
f"{plan.target['path'] or 'inside each source'}"
)
def _move_copy_one(self, pair, *, state):
"""Copy one preview (a pool thread); ``(group, detail)``."""
source, (relative, size, mtime_ns) = pair
destination = Path(source.new_store).joinpath(*relative)
try:
if not os.path.isdir(destination.parent):
base = Path(source.new_store)
if not base.is_dir():
if plan_parent_missing(base):
raise files.FolderGone("the new folder is gone")
created = []
files._make_folders(base.parent, (base.name,), created)
state["created"].extend(created)
created = []
files._make_folders(base, relative[:-1], created)
state["created"].extend(created)
# Also when the folders already exist (Codex P1 on #592).
files.refuse_links(source.new_store, relative[:-1])
outcome = files.copy_one(
Path(source.old_store).joinpath(*relative),
destination,
size,
mtime_ns,
state["created"],
)
except files.CopyFailed as error:
return "errors", str(error)
if outcome == "moved":
state["written"].append(str(destination))
return "moved", "Copied and checked"
return "skipped", "Already there"
async def _move_cancelled(self, job, plan, state):
if exit_signal.requested():
return # the next start removes the partial copies (journal)
await self._run_in_executor(self._move_rollback, plan, state)
self._end_move(job, "cancelled", "Move cancelled: nothing changed.")
def _move_rollback(self, plan, state):
"""Remove what this job wrote (files, folders it made, a folder and
marker it created) and the journal: nothing changed."""
for path in state["written"]:
files.remove_file(path)
for folder in reversed(state["created"]):
files.remove_empty_folders(folder)
if plan.to_folder and state.get("created_folder"):
folder = plan.target["path"]
try:
names = os.listdir(folder)
except OSError:
names = None
if names is not None and set(names) <= {preview_location.MARKER_NAME}:
_remove(preview_location.marker_path(folder))
files.remove_empty_folders(folder)
_remove(move_plan.journal_path())
def _move_switch(self, plan):
"""Phase 5: one config commit sets the location and records the old
stores under ``cleanup``; the runtime follows under the write lock."""
from ..config import PromptManagerConfig
from ..config_store import get_config_store
entry = {
"from": plan.old.mode,
"folder": plan.old.path if plan.from_folder else None,
"stores": [s.old_store for s in plan.sources if s.old_store],
"drop_keys": self._old_keys_to_drop(plan),
"install": plan.install,
}
# An older entry never removes a store the move just filled (a move
# back while old copies still waited).
filled = {os.path.normcase(s.new_store) for s in plan.sources}
cleanup = []
for old_entry in plan.old.cleanup:
stores = [
store
for store in old_entry.get("stores") or []
if isinstance(store, str) and os.path.normcase(store) not in filled
]
folder = old_entry.get("folder")
drop = [
key
for key in old_entry.get("drop_keys") or []
if not folder
or os.path.normcase(os.path.join(folder, key)) not in filled
]
cleanup.append({**old_entry, "stores": stores, "drop_keys": drop})
cleanup.append(entry)
if plan.to_folder:
value = {
"mode": preview_store.FOLDER_MODE,
"path": plan.target["path"],
"id": plan.target_id,
"install": plan.install,
"cleanup": cleanup,
}
else:
value = {
"mode": preview_store.SOURCE_MODE,
"install": plan.install,
"cleanup": cleanup,
}
def mutate(config):
section = config.get("thumbnails")
if not isinstance(section, dict):
section = {}
section["location"] = value
config["thumbnails"] = section
return config
def publish(_config):
PromptManagerConfig.THUMBNAIL_LOCATION = value
preview_store.configure(value, check=False)
get_config_store().commit(mutate, expected_revision=None, publish=publish)
# The switch is made: what follows never undoes it.
try:
preview_store.refresh()
_remove(move_plan.journal_path())
except OSError as error:
self.logger.warning(
f"The thumbnail move journal stays until the next start: {error}"
)
def _old_keys_to_drop(self, plan):
if not plan.from_folder:
return []
keys = [s.old_store and os.path.basename(s.old_store) for s in plan.sources]
keys = [key for key in keys if key]
if plan.to_folder and os.path.normcase(plan.old.path or "") == os.path.normcase(
plan.target["path"] or ""
):
return []
return keys + [key for key, _store, _scan in plan.unlisted]
async def _remove_old_copies(self, job, plan):
"""Phase 6: run the cleanup the switch recorded (and earlier ones)."""
job.update(status_line="Removing old copies", progress=95)
job.publish({"type": "progress", "summary": job.summary()})
left = await self._run_in_executor(self._run_cleanup, job)
if left:
job.scope["cleanup_left"] = True
def _run_cleanup(self, job=None):
"""Remove every recorded old store's previews, temps and empty
folders; keep in ``cleanup`` what could not go. Returns how many
entries are left (blocking)."""
setting = preview_store.setting()
remaining = []
for entry in setting.cleanup:
if not self._cleanup_entry(entry, job):
remaining.append(entry)
if list(setting.cleanup) != remaining:
self._commit_cleanup(remaining)
return len(remaining)
def _cleanup_entry(self, entry, job):
"""One recorded removal; True when it is complete."""
folder = entry.get("folder")
stores = [
store for store in entry.get("stores") or [] if isinstance(store, str)
]
if folder:
if not os.path.isdir(folder):
return False # the old folder is not reachable: kept for later
folder_marker = preview_location.read_marker(folder) or {}
keys = folder_marker.get("keys") or {}
stores += [
os.path.join(folder, key)
for key in entry.get("drop_keys") or []
if preview_location.valid_key(key) and key in keys
]
complete = True
for store in dict.fromkeys(stores):
if not self._remove_store(store, folder, job):
complete = False
if folder and complete:
drop = set(entry.get("drop_keys") or [])
def forget(marker):
for key in drop:
marker["keys"].pop(key, None)
try:
with preview_store.write_lock:
marker = preview_location.write_marker(folder, forget)
except (OSError, ValueError):
return False
if not marker["keys"] and marker.get("created_by_pm"):
names = set(os.listdir(folder))
rest = {n for n in names if not files.is_os_metadata(n)}
if rest <= {preview_location.MARKER_NAME}:
_remove(preview_location.marker_path(folder))
files.remove_empty_folders(folder)
return complete
def _remove_store(self, store, folder, job):
"""Remove the previews and temps of one old store; True when done."""
store = Path(store)
if folder:
# A key folder directly in the old thumbnail folder, nothing else.
if not preview_location.valid_key(store.name) or os.path.normcase(
str(store.parent)
) != os.path.normcase(str(folder)):
return True
elif store.name != "thumbnails":
return True # a hand-edited entry never names another folder
if not store.exists():
return True
if preview_location.is_link_like(store):
return True # never through a link; nothing of ours there
complete = True
removed = 0
for current, dirs, names in os.walk(store):
dirs[:] = [
d for d in dirs if not preview_location.is_link_like(Path(current) / d)
]
for name in names:
path = Path(current) / name
if files.is_temp_name(name) or files._preview_parts(name):
if preview_location.is_link_like(path):
continue
if exit_signal.requested():
return False # exiting: the next start finishes it
if files.remove_file(path):
removed += 1
else:
complete = False
relative = path.relative_to(store).as_posix()
self.logger.warning(
f"Could not remove the old thumbnail {path}; it is "
"removed at the next start"
)
if job is not None:
# D10A: a removal that failed is an error the job lists.
job.record(
"errors",
{
"path": relative,
"root": store.name,
"detail": "In use by another program; removed "
"at the next start",
},
f"In use by another program; removed at the next "
f"start: {store.name}/{relative}",
)
files.remove_empty_folders(store)
if job is not None and removed:
job.log_line(f"Removed {_number(removed)} old files from {store.name}")
return complete
def _commit_cleanup(self, remaining):
from ..config import PromptManagerConfig
from ..config_store import get_config_store
def mutate(config):
section = config.get("thumbnails")
if isinstance(section, dict) and isinstance(section.get("location"), dict):
section["location"]["cleanup"] = remaining
return config
def publish(config):
value = (config.get("thumbnails") or {}).get("location")
PromptManagerConfig.THUMBNAIL_LOCATION = value
preview_store.set_cleanup(remaining)
get_config_store().commit(mutate, expected_revision=None, publish=publish)
def plan_parent_missing(store):
"""A key folder's parent (the thumbnail folder) is gone mid-copy."""
return not Path(store).parent.is_dir()
+348
View File
@@ -0,0 +1,348 @@
"""The file side of Move thumbnails (#320 Part 3): what is copied, how a
copy is made and checked, and how old copies are removed.
No job, HTTP or config code lives here; ``thumbnail_move`` drives it.
Copy set (B5): each old store is walked with ``os.scandir``, never through a
symlink, junction or reparse point (counted as "skipped link"). A regular
file is
- copied when it has the preview grammar (``<name>_thumb<ext>``, any written
or legacy extension) and its original still exists in the source;
- removed with the old copies, not copied, when it is an orphan (no
original), lies under a trash folder, or is a temp sibling of a write
(``.<stem>.part-…``) or of a move (``….pm-move-<8 hex>.tmp``);
- left in place otherwise ("Left in place"). OS metadata files (``Thumbs.db``,
``desktop.ini``, ``.DS_Store``, ``._*``) are removed only when they alone
keep a preview folder from being removed.
A copy goes to ``<name>.pm-move-<8 hex>.tmp`` beside its destination, its
size is compared, its mtime is set to the source preview's rounded up to the
next 2-second step (FAT/exFAT keep it fresh, a stale preview stays stale),
then it is renamed into place. Every handle is closed before a rename or an
unlink (Windows).
"""
import errno
import logging
import os
import re
import secrets
import shutil
import stat as stat_module
import time
from dataclasses import dataclass, field
from pathlib import Path
from . import preview_location, thumbnail_settings
from .thumbnail_encode import _TEMP_NAME
try:
from ...utils.media_folders import in_trash_folder
except ImportError: # tests import the package top-level
from utils.media_folders import in_trash_folder
logger = logging.getLogger("prompt_manager.thumbnails")
MOVE_TEMP = re.compile(r"\.pm-move-[0-9a-f]{8}\.tmp$")
MOVE_TEMP_SUFFIX = ".pm-move-{token}.tmp"
# The temp name adds this much to a destination path (".pm-move-xxxxxxxx.tmp").
TEMP_EXTRA = 21
WINDOWS_FILE_LIMIT = 259
MTIME_STEP_NS = 2_000_000_000
# A file another program holds open (Windows) is retried this often.
REMOVE_RETRIES = (0.5, 1.0, 2.0)
_WINDOWS_IN_USE = {32, 33} # ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION
def is_os_metadata(name):
folded = name.lower()
return folded in preview_location.OS_METADATA or folded.startswith("._")
def is_temp_name(name):
return bool(_TEMP_NAME.match(name) or MOVE_TEMP.search(name))
def _preview_parts(name):
"""``(base, extension)`` of a preview file name, or None."""
stem, extension = os.path.splitext(name)
suffix = thumbnail_settings.THUMB_SUFFIX
if not stem.endswith(suffix) or len(stem) <= len(suffix):
return None
if extension.lower() not in thumbnail_settings.THUMBNAIL_EXTENSIONS:
return None
return stem[: -len(suffix)], extension
def _has_original(root, rel_dir, base, extension):
"""Whether a media file in ``<root>/<rel_dir>`` owns this preview."""
folder = Path(root).joinpath(*rel_dir)
try:
with os.scandir(folder) as entries:
for entry in entries:
stem, source_ext = os.path.splitext(entry.name)
if stem != base or not source_ext:
continue
if not thumbnail_settings.owns(extension, source_ext):
continue
if entry.is_file(follow_symlinks=False):
return True
except OSError:
return False
return False
@dataclass
class StoreScan:
"""One old store's files, sorted into what the move does with them."""
store: Path
copy: list = field(default_factory=list) # (relative parts, size, mtime_ns)
orphans: list = field(default_factory=list) # relative parts
temps: list = field(default_factory=list)
left: list = field(default_factory=list)
links: int = 0
bytes: int = 0
@property
def removable(self):
return [item[0] for item in self.copy] + self.orphans + self.temps
def scan_store(store, root, *, stop=None):
"""Sort the files of *store* (the previews of the source *root*).
A store that does not exist, or is itself a link, scans empty (a link
counts once).
"""
store = Path(store)
scan = StoreScan(store)
try:
if preview_location.is_link_like(store):
scan.links += 1
return scan
if not store.is_dir():
return scan
except OSError:
return scan
_walk(scan, store, (), root, stop)
return scan
def _walk(scan, folder, rel_dir, root, stop):
try:
with os.scandir(folder) as iterator:
entries = list(iterator)
except OSError:
return
for entry in entries:
if stop is not None and stop():
return
relative = rel_dir + (entry.name,)
path = Path(entry.path)
try:
if preview_location.is_link_like(path):
scan.links += 1
continue
info = entry.stat(follow_symlinks=False)
except OSError:
continue
if stat_module.S_ISDIR(info.st_mode):
_walk(scan, path, relative, root, stop)
continue
if not stat_module.S_ISREG(info.st_mode):
scan.left.append(relative)
continue
if is_temp_name(entry.name):
scan.temps.append(relative)
continue
if is_os_metadata(entry.name):
continue # removed only when it alone keeps a folder (rmdir)
parts = _preview_parts(entry.name)
if parts is None:
scan.left.append(relative)
continue
if in_trash_folder("/".join(relative)) or not _has_original(
root, rel_dir, *parts
):
scan.orphans.append(relative)
continue
scan.copy.append((relative, info.st_size, info.st_mtime_ns))
scan.bytes += info.st_size
# ── copying ──────────────────────────────────────────────────────────
def rounded_mtime_ns(mtime_ns):
"""*mtime_ns* rounded up to the next 2-second step (FAT/exFAT)."""
return -(-mtime_ns // MTIME_STEP_NS) * MTIME_STEP_NS
def too_long(path, long_paths=None):
"""A destination (with its temp name) over Windows' file limit."""
if os.name != "nt" and long_paths is not False:
return False
if long_paths is None:
long_paths = preview_location.long_paths_enabled()
if long_paths:
return False
return len(str(path)) + TEMP_EXTRA > WINDOWS_FILE_LIMIT
class CopyFailed(Exception):
"""One preview could not be copied (a per-file error, D10A)."""
class FolderGone(Exception):
"""The destination cannot take writes any more (abort the move)."""
def _make_folders(base, relative_dir, created):
"""Make ``base/relative_dir`` one folder at a time (*base* must exist);
every folder made is added to *created*."""
current = Path(base)
for part in relative_dir:
current = current / part
try:
os.mkdir(current)
created.append(str(current))
except FileExistsError:
pass # already there: made by an earlier copy into this folder
except OSError as error:
if error.errno in (errno.ENOSPC, errno.EDQUOT, errno.EROFS):
raise FolderGone(str(error)) from error
raise CopyFailed(
"Its folder could not be made; made again on view"
) from error
if preview_location.is_link_like(current):
raise CopyFailed("a link in the destination")
def refuse_links(base, relative_dir):
"""Raise CopyFailed when *base* or an existing folder on the way to
``base/relative_dir`` is a link (never copy through one)."""
current = Path(base)
for part in (None, *relative_dir):
if part is not None:
current = current / part
if preview_location.is_link_like(current):
raise CopyFailed("a link in the destination")
def copy_one(source, destination, size, mtime_ns, created, *, long_paths=None):
"""Copy one preview; returns ``"moved"`` or ``"skipped"`` (already
there). Raises CopyFailed (this file) or FolderGone (the destination)."""
destination = Path(destination)
if too_long(destination, long_paths):
raise CopyFailed("Path too long for Windows; the original is shown")
wanted = rounded_mtime_ns(mtime_ns)
try:
existing = os.stat(destination, follow_symlinks=False)
except FileNotFoundError:
existing = None
except OSError as error:
raise FolderGone(str(error)) from error
if (
existing is not None
and stat_module.S_ISREG(existing.st_mode)
and existing.st_size == size
and existing.st_mtime_ns == wanted
):
return "skipped"
temp = destination.with_name(
destination.name + MOVE_TEMP_SUFFIX.format(token=secrets.token_hex(4))
)
try:
shutil.copyfile(source, temp)
if os.stat(temp).st_size != size:
raise CopyFailed("The copy did not match; made again on view")
os.utime(temp, ns=(wanted, wanted))
os.replace(temp, destination)
except CopyFailed:
_unlink(temp)
raise
except FileNotFoundError as error:
_unlink(temp)
if not Path(source).exists():
raise CopyFailed("Could not be read; made again on view") from error
raise FolderGone(str(error)) from error
except OSError as error:
_unlink(temp)
if error.errno in (errno.ENOSPC, errno.EDQUOT, errno.EROFS):
raise FolderGone(str(error)) from error
raise CopyFailed("Could not be copied; made again on view") from error
return "moved"
def _unlink(path):
"""Remove a copy's temp file after a failed copy; True when it is gone.
One that cannot go (a Windows lock, permissions) is logged with its path;
the next start's temp sweep removes it (``.pm-move-*.tmp``)."""
try:
os.unlink(path)
return True
except FileNotFoundError:
return True # never written, or already renamed into place
except OSError as error:
logger.warning(f"Could not remove the move's temp file {path}: {error}")
return False
# ── removing ─────────────────────────────────────────────────────────
def _in_use(error):
return getattr(error, "winerror", None) in _WINDOWS_IN_USE
def remove_file(path, *, sleep=time.sleep):
"""Remove one file; a file another program holds open (Windows) is
retried 3 times. Returns True when it is gone."""
for wait in (0.0,) + REMOVE_RETRIES:
if wait:
sleep(wait)
try:
os.unlink(path)
return True
except FileNotFoundError:
return True
except OSError as error:
if not _in_use(error):
return False
return False
def remove_empty_folders(top, *, keep_top=False):
"""``rmdir`` the empty folders below *top* (and *top*), bottom-up, never
through a link. A folder holding only OS metadata files loses them first.
Returns the folders that could not be removed."""
top = Path(top)
left = []
if not top.is_dir() or preview_location.is_link_like(top):
return left
# Top-down, pruning every link, junction or reparse point, then bottom-up:
# never into a folder outside *top* (Windows junctions included).
folders = []
for folder, dirs, _files in os.walk(top):
folder = Path(folder)
dirs[:] = [d for d in dirs if not preview_location.is_link_like(folder / d)]
folders.append(folder)
for folder in reversed(folders):
if folder == top and keep_top:
continue
try:
names = os.listdir(folder)
except OSError:
left.append(folder)
continue
if names and all(is_os_metadata(name) for name in names):
for name in names:
remove_file(folder / name)
try:
os.rmdir(folder)
except OSError:
left.append(folder)
return left
+492
View File
@@ -0,0 +1,492 @@
"""Move thumbnails, the review (#320 Part 3 phase 1): what a move would copy,
remove and leave, checked before anything is written."""
import hashlib
import json
import os
import secrets
import shutil
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from aiohttp import web
from . import preview_location, preview_store
from . import thumbnail_move_files as files
from .job_routes import busy_response
from .jobs import THUMBNAILS_JOB_KIND, JobBusy
from .thumbnail_location import default_path
try:
from ...utils import library_location
except ImportError: # tests import the package top-level
from utils import library_location
MOVE_TITLE = "Move thumbnails"
FINISHING_TITLE = "Move thumbnails (finishing)"
PRUNE_TITLE = "Remove thumbnails of removed sources"
MOVE_GROUPS = ("moved", "skipped", "left", "errors")
PRUNE_GROUPS = ("removed", "left", "errors")
PLAN_TTL = 600.0
JOURNAL_NAME = "thumbnail-move.json"
SPACE_MARGIN = 64 * 2**20
SPACE_SHARE = 0.05
LISTED = 5
SENTENCE = (
"Every thumbnail is copied and checked before Prompt Manager switches. "
"Scans, deletes and tagging wait until it finishes; closing this page does "
"not stop it."
)
def _number(value):
return f"{value:,}"
def _plural(count, one, many=None):
return f"{_number(count)} {one if count == 1 else (many or one + 's')}"
def _size(value):
size = float(value)
for unit in ("B", "KB", "MB", "GB"):
if size < 1024 or unit == "GB":
return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B"
size /= 1024
return f"{size:.1f} GB"
def _refusal(code, status=None, **extra):
known_status, text = preview_location.MESSAGES.get(code, (400, code))
return web.json_response(
{"success": False, "code": code, "error": text, **extra},
status=status or known_status,
)
def journal_path():
"""``<data folder>/data/thumbnail-move.json`` (only while a move copies)."""
return library_location.cache_dir(JOURNAL_NAME)
def _read_json(path):
try:
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def _write_json(path, data):
os.makedirs(os.path.dirname(path), exist_ok=True)
temp = f"{path}.{os.getpid()}.tmp"
with open(temp, "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp, path)
def _remove(path):
try:
os.unlink(path)
except FileNotFoundError:
pass # already gone: removing it is the goal
def _is_network(path):
text = str(path)
if text.startswith(("\\\\", "//")):
return True
if os.name == "nt":
try:
import ctypes
drive = os.path.splitdrive(os.path.abspath(text))[0] + "\\"
return ctypes.windll.kernel32.GetDriveTypeW(drive) == 4 # DRIVE_REMOTE
except (AttributeError, OSError):
return False
return False
def _volume(path):
current = Path(os.path.abspath(path))
while not current.exists() and current.parent != current:
current = current.parent
try:
return os.stat(current).st_dev
except OSError:
return None
def _free(path):
current = Path(os.path.abspath(path))
while not current.exists() and current.parent != current:
current = current.parent
try:
return shutil.disk_usage(current).free
except OSError:
return None
# ── the plan ─────────────────────────────────────────────────────────
@dataclass
class SourcePlan:
"""One listed source: its old store, its new store and what moves."""
root: str
label: str
key: str | None
old_store: str | None
new_store: str
scan: files.StoreScan | None
available: bool = True
too_long: list = field(default_factory=list)
@dataclass
class MovePlan:
"""A reviewed move: from, to, the sources and the removals."""
token: str
created: float
revision: str
old: preview_location.ParsedLocation
target: dict # {"mode", "path"}
target_id: str | None
target_exists: bool
target_default: bool
install: str
sources: list
unlisted: list # (key, store path, scan)
needs_bytes: int
confirm_required: bool
@property
def to_folder(self):
return self.target["mode"] == preview_store.FOLDER_MODE
@property
def from_folder(self):
return self.old.mode == preview_store.FOLDER_MODE
def _roots_revision(roots):
text = json.dumps(sorted(str(root) for root in roots))
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
class ThumbnailMovePlanMixin:
"""The move's review and its routes."""
_move_plans = None
# ── routes ────────────────────────────────────────────────────────
def _register_thumbnail_move_routes(self, routes):
@routes.post("/prompt_manager/images/thumbnails/plan")
async def thumbnail_move_plan_route(request):
return await self.plan_thumbnail_move(request)
@routes.post("/prompt_manager/images/thumbnails/location/recreate")
async def thumbnail_location_recreate_route(request):
return await self.recreate_thumbnail_folder(request)
@routes.post("/prompt_manager/images/thumbnails/location/adopt")
async def thumbnail_location_adopt_route(request):
return await self.adopt_thumbnail_folder(request)
# ── review ────────────────────────────────────────────────────────
async def plan_thumbnail_move(self, request):
try:
body = await request.json()
except (ValueError, UnicodeDecodeError):
body = None
if not isinstance(body, dict) or body.get("mode") != "move":
return web.json_response(
{
"success": False,
"code": "invalid-request",
"error": "mode must be move",
},
status=400,
)
target = body.get("location")
if not isinstance(target, dict) or target.get("mode") not in (
preview_store.SOURCE_MODE,
preview_store.FOLDER_MODE,
):
return web.json_response(
{
"success": False,
"code": "invalid-request",
"error": "location must name a mode",
},
status=400,
)
blocker = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded")
if blocker is not None:
return busy_response(JobBusy(blocker))
try:
outcome = await self._run_in_executor(self._build_move_plan, target)
except Exception:
self.logger.exception("The thumbnail move could not be planned")
return web.json_response(
{
"success": False,
"code": None,
"error": "The move could not be planned",
},
status=500,
)
if isinstance(outcome, str):
return _refusal(outcome)
plan, payload = outcome
if payload.get("insufficient"):
return _refusal(
"insufficient-space",
status=507,
error_detail=payload["insufficient"],
plan=payload,
)
self._store_move_plan(plan)
return web.json_response({"success": True, **payload})
def _store_move_plan(self, plan):
if self._move_plans is None:
self._move_plans = {}
now = time.monotonic()
self._move_plans = {
token: kept
for token, kept in self._move_plans.items()
if now - kept.created < PLAN_TTL
}
self._move_plans[plan.token] = plan
def _build_move_plan(self, target):
"""``(plan, payload)`` or a refusal code (blocking)."""
old = preview_store.setting()
location = preview_store.current()
install = old.install or str(uuid.uuid4())
target_path = None
default = default_path()
if target["mode"] == preview_store.FOLDER_MODE:
raw = target.get("path")
if not isinstance(raw, str):
return "location-not-absolute"
context = self._thumbnail_location_context()
code = preview_location.location_problem(
raw, purpose="move", context=context
)
if code is not None:
return code
target_path = os.path.abspath(raw)
elif old.mode == preview_store.SOURCE_MODE and not old.problem:
return "location-same"
target_default = bool(default) and os.path.normcase(
target_path or ""
) == os.path.normcase(os.path.abspath(default))
target_exists = bool(target_path) and os.path.isdir(target_path)
target_marker = (
preview_location.read_marker(target_path) if target_exists else None
)
roots = [str(root) for root in self._get_all_output_dirs()]
labels = self._source_labels(roots)
old_keys = {}
if old.mode == preview_store.FOLDER_MODE and location.available:
marker = preview_location.read_marker(old.path) or {}
old_keys = marker.get("keys") or {}
new_keys = dict((target_marker or {}).get("keys") or {})
taken = {key: entry.get("identity") for key, entry in new_keys.items()}
sources = []
for root in roots:
identity = os.path.normcase(os.path.realpath(root))
dev_ino = preview_location.file_identity(root)
old_key = None
old_store = None
available = True
if old.mode == preview_store.SOURCE_MODE:
old_store = str(preview_store.source_folder(root))
elif location.available:
old_key = preview_location.find_key(old_keys, root, identity, dev_ino)
if old_key:
old_store = os.path.join(old.path, old_key)
else:
available = False
key = None
if target_path:
key = old_key or preview_location.find_key(
new_keys, root, identity, dev_ino
)
if key is None or taken.get(key, identity) != identity:
key = preview_location.source_key(root, identity, taken)
taken[key] = identity
new_store = os.path.join(target_path, key)
else:
new_store = str(preview_store.source_folder(root))
scan = None
if old_store:
scan = files.scan_store(old_store, root)
plan = SourcePlan(
root, labels.get(root, root), key, old_store, new_store, scan, available
)
if scan is not None and target_path:
plan.too_long = [
item[0]
for item in scan.copy
if files.too_long(Path(new_store).joinpath(*item[0]))
]
sources.append(plan)
unlisted = self._unlisted_keys(old, location, old_keys, install)
needs = sum(source.scan.bytes for source in sources if source.scan is not None)
plan = MovePlan(
token=secrets.token_urlsafe(16),
created=time.monotonic(),
revision=_roots_revision(roots),
old=old,
target={"mode": target["mode"], "path": target_path},
target_id=(target_marker or {}).get("id"),
target_exists=target_exists,
target_default=target_default,
install=install,
sources=sources,
unlisted=unlisted,
needs_bytes=needs,
confirm_required=bool(target_path) and not target_default,
)
return plan, self._plan_payload(plan, location)
def _unlisted_keys(self, old, location, keys, install):
"""This install's keys whose source no longer is in any root list
(``(key, store, scan)``), in the folder in use."""
if old.mode != preview_store.FOLDER_MODE or not location.available:
return []
listed = {
os.path.normcase(os.path.realpath(root))
for root in self._all_source_roots()
}
found = []
for key, entry in sorted(keys.items()):
if not preview_location.valid_key(key) or not isinstance(entry, dict):
continue # a marker is data: never a path out of the folder
if entry.get("install") not in (install, None):
continue
if entry.get("identity") in listed:
continue
store = os.path.join(old.path, key)
found.append(
(key, store, files.scan_store(store, entry.get("path") or store))
)
return found
def _source_labels(self, roots):
from . import sources
labels = sources._comfyui_labels()
return {root: sources.source_label(root, labels) for root in roots}
def _plan_payload(self, plan, location):
sources = plan.sources
copies = sum(len(s.scan.copy) for s in sources if s.scan)
orphans = sum(len(s.scan.orphans) for s in sources if s.scan)
temps = sum(len(s.scan.temps) for s in sources if s.scan)
links = sum(s.scan.links for s in sources if s.scan)
left = [
f"{s.label} · {'/'.join(item)}"
for s in sources
if s.scan
for item in s.scan.left
]
too_long = [
f"{s.label} · {'/'.join(item)}" for s in sources for item in s.too_long
]
unlisted_count = sum(
len(scan.copy) + len(scan.orphans) for _k, _s, scan in plan.unlisted
)
free = None
insufficient = None
if plan.needs_bytes:
volumes = {}
for source in sources:
if source.scan is None or not source.scan.bytes:
continue
volume = _volume(source.new_store)
entry = volumes.setdefault(volume, [source.new_store, 0])
entry[1] += source.scan.bytes
for where, needed in volumes.values():
available = _free(where)
if available is None:
continue
free = available if free is None else min(free, available)
margin = max(SPACE_MARGIN, int(needed * SPACE_SHARE))
if needed + margin > available:
insufficient = {
"needed": needed + margin,
"free": available,
"text": (
f"There is not enough space for the thumbnails: "
f"{_size(available)} free, {_size(needed + margin)} "
"needed. Free some space or choose another folder."
),
}
payload = {
"mode": "move",
"from": {
"mode": plan.old.mode,
"path": plan.old.path,
"available": location.available,
},
"to": {
"mode": plan.target["mode"],
"path": plan.target["path"],
"exists": plan.target_exists,
"will_create": bool(plan.target["path"]) and not plan.target_exists,
"default": plan.target_default,
},
"sources": [
{
"label": s.label,
"key": s.key,
"files": len(s.scan.copy) if s.scan else 0,
"bytes": s.scan.bytes if s.scan else 0,
"too_long": len(s.too_long),
"links": s.scan.links if s.scan else 0,
"orphans": len(s.scan.orphans) if s.scan else 0,
"left_in_place": len(s.scan.left) if s.scan else 0,
"available": s.available,
}
for s in sources
],
"totals": {
"sources": len(sources),
"files": copies,
"bytes": plan.needs_bytes,
"orphans": orphans,
"temps": temps,
"links": links,
"left_in_place": len(left),
"too_long": len(too_long),
"unlisted_sources": len(plan.unlisted),
"unlisted_files": unlisted_count,
},
"too_long_listed": too_long[:LISTED],
"left_listed": left[:LISTED],
"unlisted": [
{"key": key, "files": len(scan.copy) + len(scan.orphans)}
for key, _store, scan in plan.unlisted
],
"needs_bytes": plan.needs_bytes,
"free_bytes": free,
"confirm_required": plan.confirm_required,
"token": plan.token,
"sentence": SENTENCE,
}
if insufficient:
payload["insufficient"] = insufficient
return payload
+246
View File
@@ -0,0 +1,246 @@
"""Move thumbnails after a restart and the unavailable folder's actions
(#320 Parts 3 and 4): undo an interrupted copy, finish a removal that
waited, Recreate folder and Use this folder."""
import os
import threading
from pathlib import Path
from aiohttp import web
from . import preview_location, preview_store
from . import thumbnail_move_files as files
from .job_routes import busy_response
from .jobs import THUMBNAILS_JOB_KIND, JobBusy
from .thumbnail_move_plan import (
FINISHING_TITLE,
MOVE_GROUPS,
_read_json,
_refusal,
_remove,
)
from . import thumbnail_move_plan as move_plan
class ThumbnailMoveRecoveryMixin:
"""Startup recovery, the finishing job, Recreate and Use this folder."""
_move_recover_lock = threading.Lock()
# Per API: set once recover_thumbnail_move has run (a move waits for it).
_move_recovered = False
# ── startup ───────────────────────────────────────────────────────
def recover_thumbnail_move(self):
"""Startup (B4): undo an interrupted copy, or forget a journal the
switch already made true. Idempotent; the move start waits for it."""
with self._move_recover_lock:
if self._move_recovered:
return
try:
self._recover_journal()
except Exception:
self.logger.warning(
"The interrupted thumbnail move check failed", exc_info=True
)
self._move_recovered = True
def _recover_journal(self):
path = move_plan.journal_path()
journal = _read_json(path)
if journal is None:
return
setting = preview_store.setting()
target = journal.get("to") or {}
now = setting.path if setting.mode == preview_store.FOLDER_MODE else None
if setting.mode == target.get("mode") and (
now is None
or os.path.normcase(now) == os.path.normcase(target.get("path") or "")
):
_remove(path) # the switch happened; only the journal was left
return
for store in journal.get("stores") or []:
if not isinstance(store, str) or not os.path.isdir(store):
continue
for current, dirs, names in os.walk(store):
dirs[:] = [
d
for d in dirs
if not preview_location.is_link_like(Path(current) / d)
]
for name in names:
if files.MOVE_TEMP.search(name):
files.remove_file(Path(current) / name)
for folder in reversed(journal.get("created_dirs") or []):
if not isinstance(folder, str) or not os.path.isdir(folder):
continue
for current, dirs, names in os.walk(folder):
dirs[:] = [
d
for d in dirs
if not preview_location.is_link_like(Path(current) / d)
]
for name in names:
if files._preview_parts(name) or files.is_temp_name(name):
files.remove_file(Path(current) / name)
files.remove_empty_folders(folder)
folder = target.get("path")
if journal.get("created_folder") and folder and os.path.isdir(folder):
names = set(os.listdir(folder))
if names <= {preview_location.MARKER_NAME}:
_remove(preview_location.marker_path(folder))
files.remove_empty_folders(folder)
_remove(path)
self.logger.info("An interrupted thumbnail move was undone; nothing changed")
def _cleanup_reachable(self, entry):
folder = entry.get("folder")
if folder:
return os.path.isdir(folder)
return True
async def finish_thumbnail_move(self, _app=None):
"""Startup: recover, then start "Move thumbnails (finishing)" when old
copies wait and every one is reachable (on the server's loop)."""
await self._run_in_executor(self.recover_thumbnail_move)
cleanup = list(preview_store.setting().cleanup)
if not cleanup:
return None
reachable = await self._run_in_executor(
lambda: all(self._cleanup_reachable(entry) for entry in cleanup)
)
if not reachable:
return None
try:
return self._jobs.start(
THUMBNAILS_JOB_KIND,
FINISHING_TITLE,
admission="guarded",
cancellable=False,
scope={"mode": "move", "phase": "remove"},
groups=MOVE_GROUPS,
runner=self._run_finishing,
)
except JobBusy:
return None
async def _run_finishing(self, job):
await self._remove_old_copies(job, None)
left = len(preview_store.setting().cleanup)
self._end_move(
job,
"finished",
(
"Old thumbnails removed."
if not left
else (
"Some old thumbnails are still in use; they are removed at "
"the next start."
)
),
)
# ── Recreate and Use this folder (Part 4) ─────────────────────────
async def recreate_thumbnail_folder(self, _request):
blocker = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded")
if blocker is not None:
return busy_response(JobBusy(blocker))
code = await self._run_in_executor(self._recreate_folder)
if code is not None:
status = (
409 if code in ("location-has-marker", "location-not-empty") else 400
)
if code == "location-has-marker":
return web.json_response(
{
"success": False,
"code": code,
"error": "The folder is back with its thumbnails. Nothing "
"was recreated.",
},
status=status,
)
return _refusal(code, status=status)
return web.json_response(
{
"success": True,
"location": self._thumbnail_location_payload(with_path=True),
}
)
def _recreate_folder(self):
setting = preview_store.setting()
if setting.mode != preview_store.FOLDER_MODE or not setting.path:
return "location-same"
folder = setting.path
if os.path.isdir(folder):
if preview_location.read_marker(folder) is not None:
return "location-has-marker"
if not preview_location.holds_only_metadata(folder):
return "location-not-empty"
elif not os.path.isdir(os.path.dirname(folder)):
return "location-parent-missing"
else:
try:
os.mkdir(folder)
except OSError:
return "location-not-writable"
try:
marker_id = preview_location.init_folder(folder, created_by_pm=True)
except OSError:
return "location-not-writable"
return self._commit_location_id(marker_id)
async def adopt_thumbnail_folder(self, _request):
blocker = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded")
if blocker is not None:
return busy_response(JobBusy(blocker))
code = await self._run_in_executor(self._adopt_folder)
if code is not None:
return _refusal(code)
return web.json_response(
{
"success": True,
"location": self._thumbnail_location_payload(with_path=True),
}
)
def _adopt_folder(self):
setting = preview_store.setting()
if setting.mode != preview_store.FOLDER_MODE or not setting.path:
return "location-same"
try:
marker = preview_location.read_marker(setting.path)
except OSError:
return "location-unavailable"
if marker is None:
return "location-unavailable"
return self._commit_location_id(marker["id"])
def _commit_location_id(self, marker_id):
from ..config import PromptManagerConfig
from ..config_store import get_config_store
value = dict(preview_store.setting().to_config())
value["id"] = marker_id
def mutate(config):
section = config.get("thumbnails")
if not isinstance(section, dict):
section = {}
section["location"] = value
config["thumbnails"] = section
return config
def publish(_config):
PromptManagerConfig.THUMBNAIL_LOCATION = value
preview_store.configure(value, check=False)
try:
get_config_store().commit(mutate, expected_revision=None, publish=publish)
except Exception:
self.logger.exception("The thumbnail folder could not be saved")
return "location-not-writable"
preview_store.refresh()
return None
+175
View File
@@ -0,0 +1,175 @@
"""Remove thumbnails of removed sources (#320 Part 5, D200): a Thumbnail
maintenance scope, offered only in folder mode (31A).
*Removed* means a key of this install in the thumbnail folder's marker whose
identity matches no root in any root list. The job removes the
preview-grammar files and temps of those key folders, ``rmdir``s the empty
folders, drops the keys from the marker and lists anything else as "Left in
place". Kind ``thumbnails``, ``mode: "prune"``.
"""
import functools
import os
from pathlib import Path
from aiohttp import web
from . import preview_location, preview_store
from . import thumbnail_move_files as files
from .jobs import THUMBNAILS_JOB_KIND
from .thumbnail_location import location_refusal
from .thumbnail_move_plan import _number, _plural, _size
PRUNE_TITLE = "Remove thumbnails of removed sources"
PRUNE_GROUPS = ("removed", "left", "errors")
def _prune_line(count, files_count, size):
if not count:
return "No sources were removed: nothing to remove."
return (
f"{_plural(count, 'source')} no longer listed: "
f"{_plural(files_count, 'thumbnail')} ({_size(size)}) are removed."
)
class ThumbnailPruneMixin:
"""The prune plan and job."""
def _prune_targets(self):
"""``[(key, store, scan)]`` of this install's unlisted keys (blocking)."""
setting = preview_store.setting()
location = preview_store.current()
if setting.mode != preview_store.FOLDER_MODE or not location.available:
return []
marker = preview_location.read_marker(setting.path) or {}
install = setting.install
return self._unlisted_keys(setting, location, marker.get("keys") or {}, install)
def _prune_payload(self, targets):
files_count = sum(
len(scan.copy) + len(scan.orphans) for _k, _s, scan in targets
)
size = sum(scan.bytes for _k, _s, scan in targets)
return {
"success": True,
"mode": "prune",
"sources": [
{
"key": key,
"files": len(scan.copy) + len(scan.orphans),
"bytes": scan.bytes,
"left_in_place": len(scan.left),
}
for key, _store, scan in targets
],
"targets": files_count,
"bytes": size,
"line": _prune_line(len(targets), files_count, size),
"folder": preview_store.setting().path,
}
async def get_prune_plan(self):
refused = location_refusal()
if refused is not None:
return refused
if preview_store.current().mode != preview_store.FOLDER_MODE:
return web.json_response(
{
"success": False,
"code": "invalid-request",
"error": "Thumbnails are kept inside each source: no removed "
"source has thumbnails here.",
},
status=400,
)
targets = await self._run_in_executor(self._prune_targets)
return web.json_response(
self._prune_payload(targets), headers={"Cache-Control": "no-store"}
)
def start_thumbnail_prune(self):
"""Start the prune job (sync, on the loop); raises JobBusy."""
return self._jobs.start(
THUMBNAILS_JOB_KIND,
PRUNE_TITLE,
admission="guarded",
cancellable=True,
scope={"mode": "prune"},
groups=PRUNE_GROUPS,
runner=self._run_thumbnail_prune,
)
async def _run_thumbnail_prune(self, job):
targets = await self._run_in_executor(self._prune_targets)
job.update(total=len(targets), processed=0)
setting = preview_store.setting()
for done, (key, store, scan) in enumerate(targets, 1):
if job.cancelled:
break
removed, failed = await self._run_in_executor(
functools.partial(self._prune_store, store)
)
job.record_counted(
"removed", removed, [{"path": key, "detail": "Thumbnails removed"}]
)
if failed:
job.record_counted("errors", failed)
if scan.left:
job.record_counted(
"left",
len(scan.left),
[{"path": f"{key}/{'/'.join(item)}"} for item in scan.left],
)
await self._run_in_executor(
functools.partial(self._forget_key, setting.path, key, store)
)
job.update(
processed=done,
progress=int(done * 100 / len(targets)),
status_line=f"Removing thumbnails of {_number(done)} of "
f"{_number(len(targets))} removed sources",
)
job.publish({"type": "progress", "summary": job.summary()})
line = f"{_number(job.counters.get('removed', 0))} thumbnails removed"
if job.counters.get("errors"):
line += f" · {_plural(job.counters['errors'], 'error')}"
job.update(status_line=line, progress=100)
job.log_line(line)
job.finish("cancelled" if job.cancelled else "finished")
@staticmethod
def _prune_store(store):
"""Remove the previews and temps of one key folder; ``(removed, failed)``."""
removed = failed = 0
store = Path(store)
if not store.is_dir() or preview_location.is_link_like(store):
return 0, 0
for current, dirs, names in os.walk(store):
dirs[:] = [
d for d in dirs if not preview_location.is_link_like(Path(current) / d)
]
for name in names:
path = Path(current) / name
if not (files.is_temp_name(name) or files._preview_parts(name)):
continue
if preview_location.is_link_like(path):
continue
if files.remove_file(path):
removed += 1
else:
failed += 1
files.remove_empty_folders(store)
return removed, failed
@staticmethod
def _forget_key(folder, key, store):
"""Drop *key* from the marker once its folder is gone."""
if os.path.exists(store):
return
def forget(marker):
marker["keys"].pop(key, None)
with preview_store.write_lock:
preview_location.write_marker(folder, forget)
+22 -4
View File
@@ -114,9 +114,16 @@ def validate_edge(value):
# ── names ────────────────────────────────────────────────────────────
def preview_stem(rel_path):
"""``<rel without extension>_thumb`` (POSIX): a preview's name inside the
store of its source (``preview_store``)."""
return f"{Path(rel_path).with_suffix('').as_posix()}{THUMB_SUFFIX}"
def thumbnail_stem_rel(rel_path):
"""``thumbnails/<rel without extension>_thumb`` (POSIX)."""
return f"thumbnails/{Path(rel_path).with_suffix('').as_posix()}{THUMB_SUFFIX}"
"""``thumbnails/<rel without extension>_thumb`` (POSIX): URL text only.
Where the preview is on disk is ``preview_store``'s answer."""
return f"thumbnails/{preview_stem(rel_path)}"
def thumbnail_rel(rel_path, extension=None):
@@ -207,9 +214,20 @@ def owned_in_folder(folder, stem, source_extension, preferred=None):
def _owned(root, rel_path, preferred):
from . import preview_store
rel_path = Path(rel_path)
stem = Path(root) / thumbnail_stem_rel(rel_path)
return owned_in_folder(stem.parent, stem.name, rel_path.suffix, preferred)
store = preview_store.store_dir(root)
if store is None:
return iter(())
stem = store / preview_stem(rel_path)
found = owned_in_folder(stem.parent, stem.name, rel_path.suffix, preferred)
if preview_store.current().mode == preview_store.SOURCE_MODE:
return found
# Folder mode: timed, so a stalled share marks the folder unreachable,
# and only previews no link takes out of the key folder (deletes use it).
with preview_store.store_io():
return iter([path for path in found if preview_store.may_write(path)])
def owned_thumbnails(root, rel_path, preferred=None):
+16 -1
View File
@@ -59,6 +59,9 @@ REWRITE_FAILED = (
)
RECLAIM_FAILED = "Reclaim space failed; the database is unchanged."
ROW_KEPT = "could not be rewritten; it keeps its inline text and reads as before"
# A protected value's row (#586), in the review's "not text; kept as is".
VALUE_KEPT = "stored value not text; kept as is"
KEPT_COLUMNS = {"workflow_data": "workflow", "prompt_metadata": "prompt graph"}
NOTHING_LEFT = "Every image record already stores its workflow once"
@@ -177,7 +180,19 @@ class WorkflowStorageRoutesMixin:
protected += batch["protected"]
errors += len(batch["errors"])
job.record_counted("changed", batch["rows"])
job.record_counted("protected", batch["protected"])
job.record_counted(
"protected",
batch["protected"],
[
{
"path": "",
"detail": f"Image record {image_id}: "
f"{KEPT_COLUMNS.get(column, column)}, {VALUE_KEPT}",
}
for image_id, column in batch["kept"]
],
complete=True,
)
for image_id in batch["errors"]:
job.record(
"errors",
+13
View File
@@ -940,6 +940,8 @@ class PromptManagerConfig:
THUMBNAIL_QUALITY = 80
THUMBNAIL_EDGE = 512
THUMBNAIL_EDGE_RANGE = (128, 2048)
# #320 thumbnail location (config.json ``thumbnails.location``), as read.
THUMBNAIL_LOCATION = None
@classmethod
def max_worker_threads(cls) -> int:
@@ -1001,6 +1003,12 @@ class PromptManagerConfig:
"format": cls.THUMBNAIL_FORMAT,
"quality": cls.THUMBNAIL_QUALITY,
"edge": cls.THUMBNAIL_EDGE,
# #320: as read; only the Move thumbnails job writes it.
**(
{"location": cls.THUMBNAIL_LOCATION}
if cls.THUMBNAIL_LOCATION is not None
else {}
),
},
"gallery": GalleryConfig.get_config(),
"integrations": IntegrationConfig.get_config(),
@@ -1175,6 +1183,11 @@ class PromptManagerConfig:
low, high = cls.THUMBNAIL_EDGE_RANGE
if type(edge) is int and low <= edge <= high:
cls.THUMBNAIL_EDGE = edge
# #320: kept as read (``preview_location.parse_location`` checks it;
# a bad value becomes an unavailable ``invalid`` location, never a
# silent fall-back to the sources).
if "location" in section:
cls.THUMBNAIL_LOCATION = section["location"]
# Load configuration on import
+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.4.1"
version = "3.5.0"
license = {file = "LICENSE"}
requires-python = ">=3.12"
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
dependencies = ["watchdog>=2.1.0", "Pillow>=10.1.0"]
[build-system]
requires = ["setuptools>=84.2.1"]
+7 -7
View File
@@ -1,18 +1,18 @@
# Runtime dependencies
# Runtime dependencies (Pillow, aiohttp and numpy floors match ComfyUI requirements.txt)
watchdog>=6.0.0
Pillow>=12.3.0
aiohttp>=3.14.5
Pillow>=10.1.0
aiohttp>=3.11.8
# Tests and coverage
coverage>=7.16.2
numpy>=2.2.6 # WD14 engine tests
coverage>=7.17.0
numpy>=1.25.0 # WD14 engine tests
# Code quality
black==26.10.1
flake8==7.4.1
flake8==7.5.0
# Security scanning
bandit[toml]>=1.9.4
# Pre-commit hooks
pre-commit>=4.6.2
pre-commit>=4.7.0
+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
Pillow>=8.0.0 # Image metadata extraction and thumbnails
Pillow>=10.1.0 # Image metadata extraction and thumbnails
+41
View File
@@ -320,3 +320,44 @@ test('copy toast and fallback dialog',async({page})=>{
await expect(fallback).toContainText('Automatic clipboard access requires a secure browser context.');
await page.keyboard.press('Escape');await expect(fallback).toBeHidden();await expect(copy).toBeFocused();
});
// #542 D191: "All N eligible nodes" previews every node, sends one after another and names each refusal.
test('All eligible nodes previews each node, sends in node id order and names a refusal',async({page,context},testInfo)=>{
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
const targets=['12','3','7'].map(id=>({id,label:id==='7'?'Edited in ComfyUI':'Prompt Manager (+/-)',type:'PromptManagerPair',roles:['positive','negative']}));
window.hostValues=Object.fromEntries(targets.map(target=>[target.id,{positive:`Existing ${target.id}`,negative:'Existing negative'}]));window.applied=[];
window.fixtureHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Test ComfyUI',adapter:{
listTargets:()=>targets,
preview:(id,roles)=>{const target=targets.find(item=>item.id===id);return{preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:window.hostValues[id][role],after}))},snapshot:{id,roles}};},
apply:({id,roles})=>{if(id==='7')throw new Error('Workflow or prompt changed. Preview again.');Object.assign(window.hostValues[id],roles);window.applied.push(id);return{targetId:id,values:roles};},
}});
});
try {
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();
const sender=dialog(page);const nodes=sender.getByRole('combobox',{name:'Destination node',exact:true});
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Test ComfyUI'});
await expect(nodes.locator('option')).toHaveText(['Choose a destination node','#12 Prompt Manager (+/-)','#3 Prompt Manager (+/-)','#7 Edited in ComfyUI','All 3 eligible nodes']);
await expect(nodes).toHaveValue('');
await nodes.selectOption({label:'All 3 eligible nodes'});
await expect(sender.getByRole('status')).toHaveText('Sending to all 3 eligible nodes in Test ComfyUI. Apply to send it, or preview the changes first.');
await page.screenshot({path:testInfo.outputPath('send-all-option.png')});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
const reviews=sender.locator('section.prompt-sender-node');
await expect(reviews.locator('> h4')).toHaveText(['#3 Prompt Manager (+/-)','#7 Edited in ComfyUI','#12 Prompt Manager (+/-)']);
await expect(sender.getByRole('region',{name:'#12 Prompt Manager (+/-)',exact:true})).toContainText('Existing 12');
expect(await host.evaluate(()=>window.applied)).toEqual([]);
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();
await expect(sender.getByRole('alert')).toHaveText('Sent to 2 of 3 nodes in Test ComfyUI. #7 Edited in ComfyUI: Workflow or prompt changed. Preview again.');
expect(await host.evaluate(()=>window.applied)).toEqual(['3','12']);
expect(await host.evaluate(()=>window.hostValues['7'].positive)).toBe('Existing 7');
await page.screenshot({path:testInfo.outputPath('send-all-result.png')});
await page.setViewportSize({width:390,height:844});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await expect(reviews).toHaveCount(3);
await reviews.last().scrollIntoViewIfNeeded();
expect(await page.evaluate(()=>document.querySelector('dialog.prompt-sender').scrollWidth<=document.querySelector('dialog.prompt-sender').clientWidth+1)).toBe(true);
await page.screenshot({path:testInfo.outputPath('send-all-mobile.png')});
const apply=sender.getByRole('button',{name:'Apply to ComfyUI',exact:true});await apply.focus();await expect(apply).toBeFocused();
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
});
+7
View File
@@ -22,6 +22,9 @@ from PIL.PngImagePlugin import PngInfo
from tests.browser.folder_trash import close_trash, replace_folder
from tests.browser.malloc_tuning import pin_mmap_threshold
from tests.browser.request_hold import register_request_holds
from tests.browser.thumbnail_location_fixture import (
register_thumbnail_location_commands,
)
from tests.browser import slow_reset_watch
from tests.browser.fast_reset import SEED_ON_LIVE_ENV, ScratchSeeder
from tests.browser.workspace_activity_fixture import register_activity_commands
@@ -249,6 +252,7 @@ def create_app(root: Path) -> web.Application:
library_editor_commands = register_library_editor_commands(routes, api, root)
register_vocabulary_commands(routes, api, root)
restore_sources = register_source_change_commands(routes, api)
reset_thumbnail_location = register_thumbnail_location_commands(routes, api, root)
register_tagging_commands(routes, api, root, model, on_reset=restore_sources)
register_review_commands(routes, api, root)
register_collections_commands(routes, api, root)
@@ -460,6 +464,8 @@ def create_app(root: Path) -> web.Application:
restore_sources()
# No preview queued by the last test may land in the new fixture.
await api._run_in_executor(api.close_auto_thumbnails)
# Previews inside each source again (#320).
await api._run_in_executor(reset_thumbnail_location)
library_editor_commands.reset()
await api._run_in_executor(reset_logs)
data = await request.json() if request.can_read_body else {}
@@ -604,6 +610,7 @@ def create_app(root: Path) -> web.Application:
history_commands.reset()
library_editor_commands.reset()
reveal_stub.reset()
await api._run_in_executor(reset_thumbnail_location)
history_fixture = None
manifest = await api._run_in_executor(
seeder.run,
+3 -2
View File
@@ -656,9 +656,10 @@ function mapSourcesConcept(reference, { settings, sources }) {
// D113: the range ends at the server's core count (the concept's fixed 16) and shows the real worker count.
return [{ ...row, control: { ...row.control, max: String(settings.cpu_count), value: String(settings.worker_threads) } }];
}),
// D158: the inserted row after the first setting row; D98: "Refresh files" closes the surface.
// D158: the inserted row after the first setting row; D196 (#320): the Thumbnail location block after
// "Thumbnail generation"; D98: "Refresh files" closes the surface.
surface: { ...reference.surface, tree: [...reference.surface.tree.slice(0, rowAt + 1), 'div.setting-row',
...reference.surface.tree.slice(rowAt + 1), 'div.operation'] },
...reference.surface.tree.slice(rowAt + 1), 'div.setting-block', 'div.operation'] },
operations: [...reference.operations, { ...reference.operations[0], title: REFRESH_FILES.title,
description: REFRESH_FILES.description, actions: [{ ...reference.operations[0].actions[0], text: REFRESH_FILES.label }] }],
};
@@ -64,11 +64,31 @@ function measureMaintenance(mainSelector) {
};
}
/** D102: the Metadata cache row says why its review is unavailable and its button is aria-disabled (D93's dimmed style). */
function mapMaintenanceConcept(reference) {
/**
* D192 (#550, beyond the concept): Database adds "Pair category and tags" after "Unused prompt components", shaped like
* the concept's plain Review plan row (Optimize database's).
*/
function mapPairRow(reference) {
return {
...reference,
surfaces: reference.surfaces.map(surface => ({ ...surface, rows: surface.rows.map(row => row.title !== 'Metadata cache' ? row : {
surfaces: reference.surfaces.map(surface => {
const plain = surface.rows.find(row => row.title === 'Optimize database');
const at = surface.rows.findIndex(row => row.title === 'Unused prompt components');
if (!plain || at < 0) return surface;
return { ...surface, tree: [...surface.tree, 'div.operation'], rows: [...surface.rows.slice(0, at + 1),
{ ...plain, title: 'Pair category and tags', description: 'Fill an empty category or tags on prompts older ' +
'Pair node runs saved, from their saved pairs. Only empty fields of never-edited prompts.' },
...surface.rows.slice(at + 1)] };
}),
};
}
/** D102: the Metadata cache row says why its review is unavailable and its button is aria-disabled (D93's dimmed style). */
function mapMaintenanceConcept(reference) {
const mapped = mapPairRow(reference);
return {
...mapped,
surfaces: mapped.surfaces.map(surface => ({ ...surface, rows: surface.rows.map(row => row.title !== 'Metadata cache' ? row : {
...row, description: `${row.description} Nothing is cached in this browser.`,
actions: row.actions.map(action => ({ ...action, opacity: '0.55', cursor: 'not-allowed' })) }) })),
};
@@ -209,8 +229,8 @@ test('maintenance comparison detects mutations', async ({ page, request }) => {
const expected = mapMaintenanceConcept(concept);
const production = await productionMaintenance(page, 'dark');
expect(diffInventory(expected, production)).toEqual([]);
// Unmapped, the section differs only on the D102 row.
expect(diffInventory(concept, production).map(item => item.path).sort()).toEqual([
// Mapped only for the D192 row, the section differs only on the D102 row.
expect(diffInventory(mapPairRow(concept), production).map(item => item.path).sort()).toEqual([
'surfaces.1.rows.3.actions.0.cursor', 'surfaces.1.rows.3.actions.0.opacity', 'surfaces.1.rows.3.description']);
const measure = () => page.evaluate(measureMaintenance, '#workspace-main');
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
+11 -2
View File
@@ -92,6 +92,8 @@ export function cardActionsInventory(card) {
};
}
// #431: the fixture's saved "Describe the image" entry, the default of both engines (workspace_tagging_fixture.py).
export const FIXTURE_INSTRUCTIONS_ID='d35c71be0a1e';
// D84 (plan D61): the legacy engines' values, labels and sizes, GGUF JoyCaption in the concept's Ollama slot.
const MODEL_LABELS=Object.freeze({'wd14-swinv2':['wd14-swinv2','WD14 SwinV2 · ~400 MB'],'wd14-vit':['wd14-vit','WD14 ViT · ~400 MB'],
ollama:['gguf','GGUF JoyCaption (Recommended) · ~4.5 GB'],huggingface:['hf','HuggingFace JoyCaption · ~16 GB']});
@@ -106,17 +108,24 @@ const CONCEPT_LABELS=Object.freeze({'WD14 SwinV2 · fast classifier':'WD14 SwinV
* (`chip`: a concept `.chip` inventory, the heading's size chip) around the row's own text (12px) in the checked
* row's colours (accent border, `--soft-blue`);
* - D84 (plan D61): the legacy engine labels and values, and the server's default instructions (`instructions`);
* - D204 (#431): a "Saved instructions" select (`picker`: its value and options) before the instructions, on the
* concept's select field tokens;
* - D91 (plan D68): a Suggestions count field after the model's own fields, on the concept's number field tokens
* (`numberField`: the concept's General confidence field) with the configured count (`limit`);
* - D29: the prototype's "Sample suggestions from …" status reads "Suggestions from …".
* The opened state is compared unmapped: both show the concept's on-screen "Generate suggestions with the selected
* model before approving." (its renderTagModelOptions, a1062–1076, replaces the markup's sentence at once).
*/
export function mapConceptTagging(reference,{instructions,limit=10,chip,numberField}) {
export function mapConceptTagging(reference,{instructions,limit=10,chip,numberField,
picker={value:FIXTURE_INSTRUCTIONS_ID,options:[['builtin','Built-in'],[FIXTURE_INSTRUCTIONS_ID,`${instructions} · default`]]}}) {
const [select,...own]=reference.modelPanel.fields;
// D204 (#431): a "Saved instructions" select on the concept's select field, before the instructions.
const pickField={...select,label:'Saved instructions',control:{...select.control,id:'image-tag-instructions-pick',
value:picker.value,options:picker.options}};
const suggestionsField={...numberField,label:'Suggestions',control:{...numberField.control,id:'image-suggestion-limit',
value:String(limit),min:'1',max:'100',step:'1'}};
const mappedOwn=own.map(field=>field.control.id==='image-tag-instructions'?{...field,control:{...field.control,value:instructions}}:field);
const mappedOwn=own.flatMap(field=>field.control.id==='image-tag-instructions'
?[pickField,{...field,control:{...field.control,value:instructions}}]:[field]);
const rows=reference.suggestions.rows.map(row=>({...row,
control:row.control?.kind==='checkbox'&&row.control.checked?{kind:'remove',label:`Remove ${row.name}`}:row.control,
borderTopLeftRadius:chip.borderTopLeftRadius}));
+170
View File
@@ -0,0 +1,170 @@
"""The thumbnail location on the test server (#320), undone by every reset.
Folder mode is reachable from config.json and tests only until the Move
thumbnails job lands, so page tests set it here, inside the server's root:
- ``POST /__test__/thumbnail-location`` ``{"mode": "folder"}`` (or
``"source"``): previews live in ``<root>/thumbs/<key>`` from now on (a new
folder with Prompt Manager's marker).
- ``GET /__test__/thumbnail-files``: the preview files in the thumbnail
folder and whether any source grew a ``thumbnails/`` folder.
- ``POST /__test__/thumbnail-folder/remove``: the thumbnail folder goes, as
when its drive is unplugged; the server notices it on its next check.
"""
from __future__ import annotations
import shutil
from pathlib import Path
from typing import TYPE_CHECKING, Callable
from aiohttp import web
if TYPE_CHECKING:
from py.api import PromptManagerAPI
FOLDER_NAME = "thumbs"
def register_thumbnail_location_commands(
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path
) -> Callable[[], None]:
"""Register the commands; returns the reset every reset runs."""
from py.api import preview_location, preview_store
from py.api import thumbnail_location, thumbnail_move_plan
from py.config import PromptManagerConfig
from py.config_store import get_config_store
folder = root / FOLDER_NAME
default = root / "default-thumbs"
custom = root / "custom-thumbs" # a folder a page types in
journal = root / "data" / "thumbnail-move.json"
# The move's journal and default folder live in this server's root, never
# in the checkout's data folder.
thumbnail_move_plan.journal_path = lambda: str(journal)
thumbnail_move_plan.default_path = lambda: str(default)
thumbnail_location.default_path = lambda: str(default)
api.recover_thumbnail_move()
def forget_config(config: dict) -> dict:
section = config.get("thumbnails")
if isinstance(section, dict):
section.pop("location", None)
return config
real_free = thumbnail_move_plan._free
def reset() -> None:
thumbnail_move_plan._free = real_free
preview_store.configure(None)
PromptManagerConfig.THUMBNAIL_LOCATION = None
store = get_config_store()
config, _revision = store.read()
if (
isinstance(config.get("thumbnails"), dict)
and "location" in config["thumbnails"]
):
store.commit(forget_config, expected_revision=None)
for path in (folder, default, custom):
shutil.rmtree(path, ignore_errors=True)
journal.unlink(missing_ok=True)
def use(mode: str) -> dict:
reset()
if mode == "source":
return {"mode": "source"}
folder.mkdir()
marker_id = preview_location.init_folder(str(folder), created_by_pm=True)
location = preview_store.configure(
{
"mode": "folder",
"path": str(folder),
"id": marker_id,
"install": "browser-tests",
}
)
return {"mode": location.mode, "available": location.available}
@routes.post("/__test__/thumbnail-location")
async def set_location(request: web.Request) -> web.Response:
data = await request.json()
mode = data.get("mode") if isinstance(data, dict) else None
if mode not in ("source", "folder"):
raise web.HTTPBadRequest()
return web.json_response(await api._run_in_executor(use, mode))
def files() -> dict:
setting = preview_store.setting()
where = Path(setting.path) if setting.path else folder
previews = sorted(
path.relative_to(where).as_posix()
for path in where.rglob("*_thumb.*")
if path.is_file()
)
sources = [str(path) for path in api._get_all_output_dirs()]
return {
"folder": previews,
"folder_exists": folder.exists() or default.exists() or custom.exists(),
"custom_path": str(custom),
"source_thumbnails": any(
(Path(source) / "thumbnails").exists() for source in sources
),
}
@routes.get("/__test__/thumbnail-files")
async def thumbnail_files(_request: web.Request) -> web.Response:
return web.json_response(await api._run_in_executor(files))
def remove() -> bool:
setting = preview_store.setting()
shutil.rmtree(setting.path or folder, ignore_errors=True)
return preview_store.refresh()
@routes.post("/__test__/thumbnail-folder/remove")
async def remove_folder(_request: web.Request) -> web.Response:
available = await api._run_in_executor(remove)
return web.json_response({"available": available})
def foreign() -> bool:
"""Another install recreated the folder: its marker has another id."""
setting = preview_store.setting()
def other(marker: dict) -> None:
marker["id"] = "another-install"
preview_location.write_marker(setting.path, other)
return preview_store.refresh()
@routes.post("/__test__/thumbnail-folder/foreign")
async def foreign_folder(_request: web.Request) -> web.Response:
return web.json_response({"available": await api._run_in_executor(foreign)})
def unlisted() -> None:
"""A source this install no longer lists left its thumbnails here."""
setting = preview_store.setting()
def add(marker: dict) -> None:
marker["keys"]["gone-0123abcd"] = {
"path": "/gone",
"identity": "/gone",
"install": setting.install,
}
preview_location.write_marker(setting.path, add)
gone = Path(setting.path) / "gone-0123abcd"
gone.mkdir()
(gone / "x_thumb.webp").write_bytes(b"preview")
preview_store.refresh()
@routes.post("/__test__/thumbnail-folder/unlisted")
async def unlisted_source(_request: web.Request) -> web.Response:
await api._run_in_executor(unlisted)
return web.json_response({"success": True})
@routes.post("/__test__/thumbnail-space/full")
async def space_full(_request: web.Request) -> web.Response:
"""Every target disk reports 1 byte free until the next reset."""
thumbnail_move_plan._free = lambda _path: 1
return web.json_response({"success": True})
return reset
+17
View File
@@ -261,3 +261,20 @@ test('prompt deletion requires confirmation and can retry after a network failur
await expect(await request.get(url)).toBeOK();
}
});
// #320: with previews in one folder, the legacy gallery still shows them, made
// and served from that folder (the fixture's own previews inside the sources
// are never served then).
test('the legacy gallery shows thumbnails kept in one folder', async ({ page, request }) => {
await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'folder' } })).toBeOK();
const served = page.waitForResponse(response =>
response.url().includes('/prompt_manager/images/serve/thumbnails/') && response.status() === 200);
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
const response = await served; // made in the folder before it was served
const name = decodeURIComponent(new URL(response.url()).pathname.split('/serve/thumbnails/')[1]);
const files = await (await request.get('/__test__/thumbnail-files')).json();
const stem = name.replace(/\.[^.]+$/, '');
expect(files.folder.some(file => /^[a-z0-9_-]+-[0-9a-f]{8}\//.test(file)
&& file.slice(file.indexOf('/') + 1).replace(/\.[^.]+$/, '') === stem)).toBe(true);
});
@@ -284,6 +284,23 @@ test('a restore seen finishing elsewhere closes an open restore review and disca
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(0);
});
// #583: in WebKit, while a page.route is armed, a file part of a FormData body is read only when the request goes on;
// a garbage collection in between dropped the form once nothing referenced it, and the server got an empty file
// ("Uploaded file is empty"). The page keeps the form until its upload is answered. The route holds the upload
// through a collection; without the fix WebKit lost the file every time (0/10 in the pinned image). Chromium and
// Firefox keep the body anyway, so there it only checks that a held upload still opens the review.
test('a review upload survives a garbage collection while it is in flight', async ({ page, request }) => {
const bytes = await backupBytes(request);
await page.route(`**${PREVIEW_URL}`, async route => {
await page.requestGC();
await route.continue();
});
await openBackups(page);
expect((await chooseBackup(page, bytes)).status()).toBe(200);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(1);
});
test('invalid file shows the validator message; nothing is staged', async ({ page, request }) => {
const before = await serverJson(request, '/__test__/system/digest');
await openBackups(page);
@@ -64,7 +64,9 @@ test('the section shows the concept rows; Metadata cache follows Q5 (D102)', asy
item.querySelector('.row-actions > button').textContent, item.querySelector('.row-actions > button').className]),
]))).toEqual([
['Database', [['Integrity & relationship checks', 'Check', ''], ['Duplicate prompt text', 'Review plan', ''],
['Optimize database', 'Review plan', ''], ['Unused prompt components', 'Review plan', 'danger']]],
['Optimize database', 'Review plan', ''], ['Unused prompt components', 'Review plan', 'danger'],
// D192 (#550): beyond the concept.
['Pair category and tags', 'Review plan', '']]],
['Images & cache', [['Missing-file links', 'Review plan', 'danger'], ['Duplicate image files', 'Find duplicates', ''],
['Thumbnail cache', 'Review options', ''], ['Metadata cache', 'Review plan', '']]],
]);
@@ -177,6 +179,70 @@ test("plan dialog shows the server's scope, protected, backup policy and media l
expect(await serverJson(request, '/__test__/system/digest')).toEqual(before);
});
test('D192: Pair category and tags reviews old Pair prompts, keeps an edited one and fills the other (#550)',
async ({ page, request }) => {
await expect(await request.post('/__test__/system/pair-metadata')).toBeOK();
const answers = [];
page.on('response', response => { if (response.url().endsWith(PREVIEW_URL)) answers.push(response.json()); });
await openMaintenance(page);
const pairRow = row(page, 'Pair category and tags');
await expect(pairRow.locator('p')).toHaveText('Fill an empty category or tags on prompts older Pair node runs saved, ' +
'from their saved pairs. Only empty fields of never-edited prompts.');
await pairRow.getByRole('button', { name: 'Review plan', exact: true }).click();
const plan = dialog(page);
await expect(plan.locator('#modal-body > h3')).toHaveText('Pair category and tags');
const answer = await answers[0];
expect(answer.operation).toBe('pair_metadata');
expect(await gridOf(page)).toEqual([
['Scope', '1 prompt to fill: 1 category, 1 tag set'],
['Protected', '1 edited after it was saved'],
['Backup', `Create and verify before changes (${answer.backup.destination_label})`],
['Media on disk', 'No image file or saved pair is changed.'],
]);
await expect(plan.locator('details > summary')).toHaveText('Listed records (3)');
await plan.locator('details > summary').click();
// Protected rows come first, the Record row last.
const lines = (await plan.locator('details > pre').textContent()).split('\n');
expect(lines).toHaveLength(3);
expect(lines[0]).toBe('S550 harbor · Kept: edited after it was saved; its empty category or tags may be intentional');
expect(lines[1]).toMatch(/^S550 lantern · Fills category “night” and 2 tags: s550-glow, s550-warm \(pair \d+\)$/);
expect(lines[2]).toMatch(/^Record · The job's report lists every filled field\. Download it to keep it/);
await plan.getByRole('button', { name: 'Apply reviewed operation', exact: true }).click();
await expect(dialog(page)).toHaveCount(0);
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
const job = await ended(request, page.url().split('/').pop());
expect([job.title, job.state, job.counters.changed, job.counters.protected])
.toEqual(['Pair category and tags', 'finished', 1, 1]);
const main = page.locator('#workspace-main');
await expect(main.locator('h1')).toHaveText('Pair category and tags');
await expect(main.getByRole('button', { name: 'Download report', exact: true })).toBeVisible();
// #550 live test: the Changed group lists the path-less record rows as plain text, never an open action.
const results = main.getByRole('region', { name: 'Results', exact: true });
await results.locator('.outcome-list > button').filter({ hasText: 'Changed' }).click();
const records = results.locator('.outcome-files > .file-row');
await expect(records).toHaveCount(1);
await expect(records.first()).toHaveText(/^Prompt \d+: category “night”; tags s550-glow, s550-warm$/);
expect(await records.first().evaluate(node => node.localName)).toBe('div');
await expect(results.locator('.outcome-files button')).toHaveCount(0);
await expect(results.getByRole('searchbox', { name: 'Filter results' })).toHaveAttribute('placeholder', 'Filter records…');
// Protected lists each kept prompt with the review's reason (#550 live re-check).
await results.locator('.outcome-list > button').filter({ hasText: 'Protected' }).click();
await expect(records).toHaveCount(1);
await expect(records.first()).toHaveText(
/^Prompt \d+: kept, edited after it was saved; its empty category or tags may be intentional$/);
// An empty group says records, not files.
await results.locator('.outcome-list > button').filter({ hasText: 'Errors' }).click();
await expect(results.locator('.outcome-files > p.empty-state')).toHaveText('No records in this group.');
const library = await serverJson(request, '/prompt_manager/library');
const lantern = library.items.find(item => item.text === 'S550 lantern');
const harbor = library.items.find(item => item.text === 'S550 harbor');
const filled = (await serverJson(request, `/prompt_manager/library/${lantern.id}?archive=all`)).positive;
expect([filled.category, [...filled.tags].sort()]).toEqual(['night', ['s550-glow', 's550-warm']]);
expect((await serverJson(request, `/prompt_manager/library/${harbor.id}?archive=all`)).positive.category).toBeFalsy();
await page.goto(`${workspace}#/library`);
await expect(page.locator(`article[data-prompt-id="${lantern.id}"] .card-title > .chip`)).toHaveText('night');
});
test('a plan with nothing to do shows only Close, never Apply', async ({ page, request }) => {
const applies = [];
page.on('request', item => { if (item.url().endsWith(APPLY_URL)) applies.push(item.url()); });
@@ -341,3 +341,21 @@ test('a running scan makes the start report busy', async ({ page, request }) =>
await request.post(`/__test__/jobs/${scan.job.id}/finish`, { data: { state: 'finished' } });
}
});
// D204 (#431): Auto-tag selected has the same picker; the job sends the text the dialog shows.
test('Auto-tag selected picks saved instructions and sends the text shown (D204)', async ({ page }) => {
await select(page, FIRST);
await barButton(page, 'Auto-tag selected').click();
await expect(title(page)).toHaveText('Auto-tag selected images');
const body = modal(page).locator('#modal-body');
await body.getByRole('combobox', { name: 'Tagging model', exact: true }).selectOption('gguf');
const pick = body.getByRole('combobox', { name: 'Saved instructions', exact: true });
await expect(pick).toHaveAttribute('id', 'bulk-tag-instructions-pick');
await expect(pick.locator('option')).toHaveText(['Built-in', 'Describe the image · default']);
const text = body.getByRole('textbox', { name: 'Tagging instructions', exact: true });
await text.fill('Bulk one-off.');
await expect(pick.locator('option:checked')).toHaveText('Edited for this run');
const started = jobStart(page);
await actionButtons(page).filter({ hasText: 'Start auto-tagging' }).click();
expect((await started).postDataJSON().options.instructions).toBe('Bulk one-off.');
});
+47 -1
View File
@@ -4,7 +4,7 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { saveImageTags } from './concept-inventory.mjs';
import { taggingInventory } from './tagging-canonical-comparison.mjs';
import { FIXTURE_INSTRUCTIONS_ID, taggingInventory } from './tagging-canonical-comparison.mjs';
const images = '/prompt_manager/workspace#/images';
const MODULE = '/prompt_manager/js/workspace/image-tagging.mjs';
@@ -861,3 +861,49 @@ test('suggestions omit skipped tags and say how many were hidden', async ({ page
await expect(chips(page).locator(':scope > span:first-child')).toHaveText(['sandstone', 'arch', 'sky', 'dune']);
await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(`${CHIP_HINT} 2 skipped tags were not suggested.`);
});
// D204 (#431): the Saved instructions select above Tagging instructions: the engine default, a pick, a one-off edit
// ("Edited for this run" and the muted note), an engine switch resets, WD14 hides both; Generate sends the text shown.
test('the saved instructions picker fills, edits one-off and resets per engine (D204)', async ({ page, request }) => {
const settings = await (await request.get('/prompt_manager/settings')).json();
const builtin = settings.settings.autotag_builtin_instructions;
await openTag(page);
const options = dialog(page).locator('#image-tag-model-options');
const pick = options.getByRole('combobox', { name: 'Saved instructions', exact: true });
const text = options.getByRole('textbox', { name: 'Tagging instructions', exact: true });
const note = options.locator('.instructions-edited');
await expect(pick).toHaveCount(0);
await model(page).selectOption('gguf');
await expect(pick.locator('option')).toHaveText(['Built-in', 'Describe the image · default']);
await expect(pick).toHaveAttribute('id', 'image-tag-instructions-pick');
await expect(text).toHaveValue('Describe the image');
await expect(note).toBeHidden();
// A keyboard pick (the select focused) keeps focus on the select the fields are re-rendered with.
await pick.focus();
await pick.selectOption('builtin');
await expect(text).toHaveValue(builtin);
await expect(pick).toBeFocused();
await text.fill('Only the sky.');
await expect(text).toHaveAttribute('aria-describedby', 'image-tag-instructions-pick-note');
await expect(pick.locator('option:checked')).toHaveText('Edited for this run');
await expect(pick.locator('option[value="edited"]')).toBeDisabled();
await expect(note).toHaveText('Edits apply to this run only. Manage saved instructions in System › Auto-tagging.');
await expect(text).toBeFocused();
// Generate sends the edited text; the saved entries are unchanged.
const sent = page.waitForRequest(item => item.url().endsWith('/image-annotations/suggest'));
await generate(page).click();
expect((await sent).postDataJSON().options.instructions).toBe('Only the sky.');
// Choosing an entry replaces the edit; switching engines starts from the new engine's default.
await pick.selectOption('builtin');
await expect(text).toHaveValue(builtin);
await expect(note).toBeHidden();
await text.fill('Edited again.');
await model(page).selectOption('hf');
await expect(text).toHaveValue('Describe the image');
await expect(pick).toHaveValue(FIXTURE_INSTRUCTIONS_ID);
await model(page).selectOption('wd14-vit');
await expect(pick).toHaveCount(0);
await expect(text).toHaveCount(0);
expect((await (await request.get('/prompt_manager/settings')).json()).settings.autotag_instructions)
.toEqual(settings.settings.autotag_instructions);
});
@@ -0,0 +1,213 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept } from './concept-inventory.mjs';
// #320 Thumbnail location (D196–D201, the approved 320 mock) on the concept-shaped real fixture: the row in System ›
// Sources & scans reviews and runs Move thumbnails both ways, the review refuses a folder in place, and an unplugged
// thumbnail folder shows the notice whose Recreate folder brings it back. The hooks are thumbnail_location_fixture.py's.
const workspace = '/prompt_manager/workspace';
const NOTICE_KEY = 'pm_update_notice_dismissed';
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await seedConcept(request);
await expect(await request.post('/__test__/system/reset')).toBeOK();
await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'source' } })).toBeOK();
page.locationErrors = [];
page.on('pageerror', error => page.locationErrors.push(error.message));
await page.addInitScript(key => { if (location.protocol.startsWith('http')) localStorage.setItem(key, '3.2.0'); }, NOTICE_KEY);
});
test.afterEach(async ({ page, request, browserName }) => {
await saveCoverage(page, browserName);
await request.post('/__test__/thumbnail-location', { data: { mode: 'source' } });
expect(page.locationErrors).toEqual([]);
});
const block = page => page.locator('#location-block');
const dialog = page => page.locator('dialog#modal[open]');
async function files(request) {
const response = await request.get('/__test__/thumbnail-files');
await expect(response).toBeOK();
return response.json();
}
async function openSources(page) {
await page.goto(`${workspace}#/system/sources`);
await expect(block(page).locator('#location-status')).not.toHaveText('');
}
/** Review the row's choice and start the move; resolves once its job view says it finished. */
async function moveFromRow(page) {
const planned = page.waitForResponse(response => response.url().endsWith('/images/thumbnails/plan')
&& response.request().method() === 'POST');
await block(page).getByRole('button', { name: 'Review move…' }).click();
expect((await planned).status()).toBe(200);
await expect(dialog(page).locator('h2')).toHaveText('Move thumbnails');
await dialog(page).getByRole('button', { name: 'Move thumbnails' }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Move thumbnails');
await expect(page.locator('#workspace-main')).toContainText('Thumbnails moved to');
}
test('the row moves thumbnails into one folder and back', async ({ page, request }) => {
await openSources(page);
const row = block(page);
await expect(row.getByRole('radio', { name: /Inside each source/ })).toBeChecked();
await expect(row.locator('#location-status')).toHaveText('Now: inside each source.');
await expect(row.getByRole('button', { name: 'Review move…' })).toHaveAttribute('aria-disabled', 'true');
await expect(row.locator('#location-path')).toBeDisabled();
await row.getByRole('radio', { name: /One folder/ }).check();
await expect(row.locator('#location-path')).toBeEnabled();
await expect(row.locator('#location-path')).toHaveValue(/default-thumbs$/);
await expect(row.locator('#location-status')).toHaveText(/^Now: inside each source\. The suggested folder is next to your library/);
await expect(row.getByRole('button', { name: 'Review move…' })).toHaveAttribute('aria-disabled', 'false');
await moveFromRow(page);
await expect(page.locator('#job-phase')).toHaveText(/^Thumbnails are now read from the new location/);
const moved = await files(request);
expect(moved.folder.length).toBeGreaterThan(0);
expect(moved.folder.every(name => /^[a-z0-9_-]+-[0-9a-f]{8}\//.test(name))).toBe(true);
expect(moved.source_thumbnails).toBe(false);
// Images shows thumbnails served from the folder.
const served = page.waitForResponse(response => response.url().includes('/images/serve/thumbnails/')
&& response.status() === 200);
await page.goto(`${workspace}#/images`);
await served;
await openSources(page);
await expect(block(page).locator('#location-status')).toHaveText(/^Now: one folder, .*default-thumbs\. Using the default folder/);
await block(page).getByRole('radio', { name: /Inside each source/ }).check();
await moveFromRow(page);
const back = await files(request);
expect(back.source_thumbnails).toBe(true);
expect(back.folder_exists).toBe(false);
});
test('a folder the server refuses is explained under the field', async ({ page }) => {
await openSources(page);
const row = block(page);
await row.getByRole('radio', { name: /One folder/ }).check();
await row.locator('#location-path').fill('thumbnails');
await row.getByRole('button', { name: 'Review move…' }).click();
await expect(row.locator('#location-problem')).toHaveText(
'Enter a full folder path, such as D:\\Thumbnails or /mnt/fast/thumbnails.');
await expect(row.locator('#location-path')).toHaveAttribute('aria-invalid', 'true');
await expect(row.locator('#location-path')).toHaveAttribute('aria-describedby', 'location-problem');
await expect(dialog(page)).toHaveCount(0);
await row.locator('#location-path').fill('/somewhere/else');
await expect(row.locator('#location-problem')).toHaveText('');
});
test('an unplugged folder shows the notice and Recreate folder brings it back', async ({ page, request }) => {
await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'folder' } })).toBeOK();
const removed = await request.post('/__test__/thumbnail-folder/remove');
expect((await removed.json()).available).toBe(false);
await openSources(page);
const notice = page.locator('#unavailable-notice');
await expect(notice.locator('h2')).toHaveText('The thumbnail folder is unavailable');
await expect(notice).toContainText('is missing. Originals are shown and no thumbnails are written until it is back.');
await expect(block(page).locator('#location-status')).toHaveText(/· unavailable\. Originals are shown\.$/);
await notice.getByRole('button', { name: 'Recreate folder' }).click();
await expect(dialog(page).locator('h2')).toHaveText('Recreate the thumbnail folder?');
const recreated = page.waitForResponse(response => response.url().endsWith('/location/recreate'));
await dialog(page).getByRole('button', { name: 'Recreate folder' }).click();
expect((await recreated).status()).toBe(200);
await expect(notice).toHaveCount(0);
await expect(block(page).locator('#location-status')).toHaveText(/^Now: one folder, .*thumbs\.$/);
});
test('a folder of your own needs the confirmation before it moves', async ({ page, request }) => {
const custom = (await files(request)).custom_path;
await openSources(page);
const row = block(page);
await row.getByRole('radio', { name: /One folder/ }).check();
await row.locator('#location-path').fill(custom);
await row.getByRole('button', { name: 'Review move…' }).click();
const review = dialog(page);
await expect(review.locator('dt')).toContainText(['From', 'To', 'Thumbnails', 'Free space', 'Removed after the move']);
await expect(review.locator('dd').nth(1)).toHaveText(`${custom} (new folder)`);
const move = review.getByRole('button', { name: 'Move thumbnails' });
await expect(move).toHaveAttribute('aria-disabled', 'true');
await review.getByRole('checkbox', { name: /may create its own folders/ }).check();
await expect(move).toHaveAttribute('aria-disabled', 'false');
await move.click();
await expect(page.locator('#workspace-main')).toContainText('Thumbnails moved to');
expect((await files(request)).folder.length).toBeGreaterThan(0);
});
test('another folder’s marker offers Use this folder', async ({ page, request }) => {
await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'folder' } })).toBeOK();
await expect(await request.post('/__test__/thumbnail-folder/foreign')).toBeOK();
await openSources(page);
const notice = page.locator('#unavailable-notice');
await expect(notice).toContainText('holds another thumbnail folder');
await notice.getByRole('button', { name: 'Use this folder' }).click();
await expect(notice).toHaveCount(0);
await expect(block(page).locator('#location-status')).toHaveText(/^Now: one folder, .*thumbs\.$/);
});
test('Overview shows the notice and Change location opens the row', async ({ page, request }) => {
await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'folder' } })).toBeOK();
const removed = await request.post('/__test__/thumbnail-folder/remove');
expect((await removed.json()).available).toBe(false);
await page.goto(`${workspace}#/system/overview`);
const notice = page.locator('#unavailable-notice');
await expect(notice.locator('h2')).toHaveText('The thumbnail folder is unavailable');
await notice.getByRole('button', { name: 'Change location' }).click();
await expect(page).toHaveURL(/#\/system\/sources$/);
const sourcesNotice = page.locator('#unavailable-notice');
await sourcesNotice.getByRole('button', { name: 'Change location' }).click();
await expect(block(page).getByRole('radio', { name: /One folder/ })).toBeChecked();
await expect(block(page).locator('#location-path')).toBeFocused();
});
test('Overview’s Use this folder takes the folder and the notice goes', async ({ page, request }) => {
await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'folder' } })).toBeOK();
await expect(await request.post('/__test__/thumbnail-folder/foreign')).toBeOK();
await page.goto(`${workspace}#/system/overview`);
const notice = page.locator('#unavailable-notice');
await expect(notice).toContainText('holds another thumbnail folder');
const adopted = page.waitForResponse(response => response.url().endsWith('/location/adopt'));
await notice.getByRole('button', { name: 'Use this folder' }).click();
expect((await adopted).status()).toBe(200);
await expect(notice).toHaveCount(0);
});
test('Thumbnail maintenance removes the thumbnails of removed sources in folder mode', async ({ page, request }) => {
await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'folder' } })).toBeOK();
await expect(await request.post('/__test__/thumbnail-folder/unlisted')).toBeOK();
await openSources(page);
const generation = page.locator('#workspace-main .operation').filter({ hasText: 'Thumbnail generation' });
await generation.getByRole('button', { name: 'Review options' }).click();
const scope = dialog(page).locator('#thumb-mode');
await expect(scope.locator('option')).toHaveText(['Missing thumbnails only', 'Rebuild all (explicit opt-in)',
'Convert outdated (removes older formats)', 'Remove thumbnails of removed sources']);
await scope.selectOption('prune');
await expect(dialog(page).locator('[role=status]')).toHaveText(
/^1 source no longer listed: 1 thumbnail \(.+\) are removed\.$/);
await expect(dialog(page).locator('dl.meta-grid dt')).toHaveText(['gone-0123abcd']);
await expect(dialog(page)).toContainText('If a source comes back, its thumbnails are made again as you browse.');
await scope.selectOption('missing');
await expect(dialog(page).locator('dl.meta-grid')).toBeHidden();
await scope.selectOption('prune');
await dialog(page).getByRole('button', { name: 'Start' }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Remove thumbnails of removed sources');
await expect(page.locator('#workspace-main')).toContainText('1 thumbnails removed');
});
test('a disk without the space shows the review but cannot move', async ({ page, request }) => {
await expect(await request.post('/__test__/thumbnail-space/full')).toBeOK();
await openSources(page);
const row = block(page);
await row.getByRole('radio', { name: /One folder/ }).check();
const planned = page.waitForResponse(response => response.url().endsWith('/images/thumbnails/plan'));
await row.getByRole('button', { name: 'Review move…' }).click();
expect((await planned).status()).toBe(507);
const review = dialog(page);
await expect(review.locator('.review-refusal')).toHaveText(/^There is not enough space for the thumbnails: 1 B free/);
await expect(review.getByRole('button', { name: 'Move thumbnails' })).toHaveAttribute('aria-disabled', 'true');
await review.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog(page)).toHaveCount(0);
});
+24
View File
@@ -41,6 +41,11 @@ read. These per-server hooks only change what a System test needs on top:
- ``POST /__test__/system/duplicate-prompts`` (S7) adds two prompts whose
text differs only in case and spacing (a duplicate prompt text group);
``{"again": true}`` adds a third, so a reviewed plan no longer matches;
- ``POST /__test__/system/pair-metadata`` (#550) saves two saved pairs the
way the Pair node did before #546 (the pair holds the category and tags,
its new positive prompt none): ``S550 lantern`` (category ``night``, tags
``s550-glow``, ``s550-warm``) fills; ``S550 harbor`` was edited after it was
saved, so the plan keeps it;
- ``POST /__test__/system/duplicate-files`` (S7) writes one PNG's bytes three
times: ``output/s7-dupes/same.png``, ``input/s7-dupes/same.png`` (the same
relative path in the other source) and ``output/s7-dupes/copy.png``;
@@ -344,6 +349,25 @@ def register_system_commands(app, routes, api, root: Path):
conn.commit()
return web.json_response({"success": True})
@routes.post("/__test__/system/pair-metadata")
async def pair_metadata(request):
def seed():
api.db.save_prompt_pair(
"S550 lantern",
"S550 blurry",
category="night",
tags=["s550-glow", "s550-warm"],
)
api.db.save_prompt_pair("S550 harbor", category="night")
with api.db.model.get_connection() as conn:
conn.execute(
"UPDATE prompts SET updated_at='2099-01-01T00:00:00+00:00' "
"WHERE text='S550 harbor'"
)
await api._run_in_executor(seed)
return web.json_response({"success": True})
@routes.post("/__test__/system/duplicate-files")
async def duplicate_files(request):
body = await request.json() if request.can_read_body else {}
+31 -2
View File
@@ -131,11 +131,40 @@ test('native image inspector previews and sends a pair into real visible widgets
const twinId=await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);return node.id;});
await find0.click();
const targets=sender.getByRole('combobox',{name:'Destination node',exact:true});
await expect(targets.locator('option')).toHaveCount(3);
await expect(targets.locator('option')).toHaveCount(4);
await expect(targets).toHaveValue('');
await expect(apply).toBeDisabled();
await expect(targets.locator('option').nth(2)).toHaveText(new RegExp(`^#${twinId} `));
await page.evaluate(async twinId=>{const {app}=await import('/scripts/app.js');app.graph.remove(app.graph.getNodeById(twinId));},twinId);
// #542 D191: "All 2 eligible nodes" writes both real nodes, one after another; a linked third node is not offered.
const linkedId=await page.evaluate(async()=>{
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);
const widget=node.widgets.find(widget=>widget.name==='text');
if(!node.inputs.some(input=>input.name==='text'))convertToInput(node,widget);
const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);
source.connect(0,node,node.inputs.findIndex(input=>input.name==='text'));
for(const each of app.graph._nodes.filter(item=>item.type==='PromptManagerPair')){const text=each.widgets.find(widget=>widget.name==='text');if(text)text.value=`Before all ${each.id}`;}
return node.id;
});
await find0.click();
await expect(targets.locator('option')).toHaveText(['Choose a destination node',new RegExp(`^#${id} `),new RegExp(`^#${twinId} `),'All 2 eligible nodes']);
await expect(sender.getByRole('status')).toContainText('1 other Prompt Manager node cannot receive this prompt.');
await targets.selectOption({label:'All 2 eligible nodes'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await expect(sender.locator('section.prompt-sender-node > h4')).toHaveText([new RegExp(`^#${id} `),new RegExp(`^#${twinId} `)]);
await apply.click();
// The tab title gains ComfyUI's unsaved mark once the workflow changed, so the window is matched loosely.
await expect(sender.getByRole('status')).toHaveText(/^Sent to 2 of 2 nodes in .*ComfyUI\.$/);
await expect(apply).toBeDisabled();
const all=await page.evaluate(async ids=>{
const {app}=await import('/scripts/app.js');const output=(await app.graphToPrompt()).output;
return ids.map(nodeId=>{const node=app.graph.getNodeById(nodeId);const serialized=node.serialize();const index=node.widgets.findIndex(widget=>widget.name==='negative_text');
return{prompt:output[String(nodeId)]?.inputs.negative_text,serialized:serialized.widgets_values[index],text:node.widgets.find(widget=>widget.name==='text')?.value};});
},[id,twinId,linkedId]);
expect(all.slice(0,2)).toEqual([{prompt:negative,serialized:negative,text:positive},{prompt:negative,serialized:negative,text:positive}]);
expect(all[2].text).toBe(`Before all ${linkedId}`);
expect(await page.evaluate(()=>window.senderEffects.queue-window.senderSendBaseline.queue)).toBe(0);
await page.evaluate(async ids=>{const {app}=await import('/scripts/app.js');for(const nodeId of ids)app.graph.remove(app.graph.getNodeById(nodeId));},[twinId,linkedId]);
// #535: the status line says why a window cannot receive.
const find=sender.getByRole('button',{name:'Find ComfyUI windows',exact:true});
const preview=sender.getByRole('button',{name:'Preview changes',exact:true});
+11 -1
View File
@@ -126,6 +126,9 @@
"POST /prompt_manager/images/removed/return",
"POST /prompt_manager/images/rescope",
"POST /prompt_manager/images/reveal",
"POST /prompt_manager/images/thumbnails/location/adopt",
"POST /prompt_manager/images/thumbnails/location/recreate",
"POST /prompt_manager/images/thumbnails/plan",
"POST /prompt_manager/jobs/autotag",
"POST /prompt_manager/jobs/duplicates",
"POST /prompt_manager/jobs/image-tags",
@@ -217,6 +220,13 @@
"thumbnail_encoders",
"thumbnail_encoders.jpeg",
"thumbnail_encoders.jxl",
"thumbnail_encoders.webp"
"thumbnail_encoders.webp",
"thumbnail_location",
"thumbnail_location.available",
"thumbnail_location.cleanup_pending",
"thumbnail_location.default_path",
"thumbnail_location.mode",
"thumbnail_location.path",
"thumbnail_location.problem"
]
}
+10
View File
@@ -371,6 +371,16 @@ test("ApiError keeps the job that applied a used plan (plan-used, System S7)", (
}
});
test("ApiError keeps a move refused for space with its review (#320)", () => {
const plan = { totals: { files: 3 }, insufficient: { text: "Not enough space" } };
assert.equal(new ApiError(507, "no space", "insufficient-space", null, { plan }).plan, plan);
for (const bad of [null, "x", [1], undefined]) {
assert.equal(new ApiError(507, "no space", "insufficient-space", null, { plan: bad }).plan, null);
}
assert.equal(new ApiError(409, "busy", "busy", null, { plan }).plan, null);
assert.equal(new ApiError(507, "no space", "insufficient-space").plan, null);
});
test("ApiError keeps a validated running job on busy and a validated source on invalid-source", () => {
const body = { running: { id: "a".repeat(32), title: "Scan for metadata" }, source: "output/gone" };
const busy = new ApiError(409, "busy", "busy", null, body);
+184
View File
@@ -225,3 +225,187 @@ test('acknowledgement cannot claim an extra role beyond approved changes',async(
const {createSendSession}=await source;const s=setup(createSendSession,{apply:async()=>({targetId:'one',values:{positive:'new',negative:'also changed'}})});
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.apply();assert.equal(s.session.state.phase,'error');assert.match(s.session.state.message,/unexpected acknowledgement/);s.session.dispose();
});
// #542: "All N eligible nodes" sends to every eligible node of one window, previewing every node first (Q542-2 C).
function allHost({nodes=['10','9','2'],revision,preview,apply,discover}={}) {
const calls=[];const sent=new Set();
const targets=nodes.map(id=>pm(id,['positive'],revision?{revision}:{}));
const client={
discover:discover ?? (async()=>[window1(targets)]),
preview:async(host,id,roles)=>{calls.push(['preview',id]);if(preview)return preview(id,calls,sent);return{ticket:`t-${id}-${calls.length}`,revision,target:{id,label:`Node ${id}`},fields:[{role:'positive',before:sent.has(id)?'new':'old',after:roles.positive}]};},
apply:async(host,ticket)=>{const id=ticket.split('-')[1];calls.push(['apply',id,ticket]);if(apply)return apply(id,calls);sent.add(id);return{targetId:id,values:{positive:'new'}};},
dispose:()=>calls.push(['dispose']),
};
return {client,calls,sent};
}
async function allSession(createSendSession,options) {
const host=allHost(options);const session=createSendSession({client:host.client,roles:{positive:'new'},onChange:()=>{}});
await session.discover();return {...host,session};
}
const order=calls=>calls.filter(call=>call[0]!=='dispose').map(call=>`${call[0]} ${call[1]}`);
test('All is offered only with two or more eligible nodes and is never selected for you',async()=>{
const {createSendSession,ALL_NODES}=await source;
const one=await allSession(createSendSession,{nodes:['1']});
one.session.selectTarget(ALL_NODES);assert.equal(one.session.state.targetId,'');assert.equal(one.calls.length,0);one.session.dispose();
const two=await allSession(createSendSession,{nodes:['1','2']});
assert.equal(two.session.state.targetId,'');
two.session.selectTarget(ALL_NODES);assert.equal(two.session.state.targetId,ALL_NODES);
assert.match(two.session.state.message,/all 2 eligible nodes in Flow - ComfyUI/);
await two.session.discover();assert.equal(two.session.state.targetId,'');two.session.dispose();
});
test('Apply to All previews every node first, then sends one after another in node id order',async()=>{
const {createSendSession,ALL_NODES}=await source;const s=await allSession(createSendSession);
s.session.selectTarget(ALL_NODES);await s.session.apply();
// Each node takes a fresh ticket, the first too: the host keeps only its latest 100 (#593).
assert.deepEqual(order(s.calls),['preview 2','preview 9','preview 10','preview 2','apply 2','preview 9','apply 9','preview 10','apply 10']);
assert.equal(s.session.state.phase,'success');assert.equal(s.session.state.message,'Sent to 3 of 3 nodes in Flow - ComfyUI.');
await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,3);s.session.dispose();
});
test('a double click on Apply to All sends to each node once',async()=>{
const {createSendSession,ALL_NODES}=await source;const s=await allSession(createSendSession,{nodes:['1','2']});
s.session.selectTarget(ALL_NODES);await Promise.all([s.session.apply(),s.session.apply()]);
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['1','2']);s.session.dispose();
});
test('a node refusing its preview stops All before anything is written and names the node',async()=>{
const {createSendSession,ALL_NODES}=await source;
const s=await allSession(createSendSession,{preview:id=>{if(id==='9')throw Error('positive is connected to an upstream input.');return{ticket:`t-${id}`,target:{id},fields:[{role:'positive',before:'old',after:'new'}]};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.equal(s.calls.filter(call=>call[0]==='apply').length,0);assert.equal(s.session.state.phase,'error');
assert.match(s.session.state.message,/^#9 Node 9: positive is connected to an upstream input\. Nothing was sent\./);
assert.equal(s.session.state.outcomeUnknown,false);s.session.dispose();
});
test('a refusal while sending to All is reported by node and the other nodes are still sent',async()=>{
const {createSendSession,ALL_NODES}=await source;
const s=await allSession(createSendSession,{apply:id=>{if(id==='9')throw Error('Workflow or prompt changed. Preview again.');return{targetId:id,values:{positive:'new'}};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2','9','10']);
assert.equal(s.session.state.phase,'error');assert.equal(s.session.state.outcomeUnknown,false);
assert.equal(s.session.state.message,'Sent to 2 of 3 nodes in Flow - ComfyUI. #9 Node 9: Workflow or prompt changed. Preview again.');s.session.dispose();
});
test('a lost reply on one node of All leaves the later nodes unsent and that outcome unknown',async()=>{
const {createSendSession,ALL_NODES}=await source;
const s=await allSession(createSendSession,{apply:id=>{if(id==='9')throw Error('Bridge request timed out');return{targetId:id,values:{positive:'new'}};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2','9']);
assert.equal(s.session.state.outcomeUnknown,true);
assert.match(s.session.state.message,/^Sent to 1 of 3 nodes in Flow - ComfyUI\. No confirmation received from #9 Node 9; the update may have applied\. Not sent: #10 Node 10\. Check ComfyUI, then preview again\. Bridge request timed out$/);
await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,2);s.session.dispose();
});
test('closing the dialog during All stops after the current node',async()=>{
const {createSendSession,ALL_NODES}=await source;let release;
const s=await allSession(createSendSession,{apply:id=>id==='2'?new Promise(done=>release=()=>done({targetId:id,values:{positive:'new'}})):{targetId:id,values:{positive:'new'}}});
s.session.selectTarget(ALL_NODES);const pending=s.session.apply();
for(let i=0;i<50 && !release;i++)await tick();assert.ok(release,'the first node was sent');
s.session.dispose();release();await pending;
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2']);
});
test('All accepts the revision our own write moved, but not a workflow changed before the run',async()=>{
const {createSendSession,ALL_NODES}=await source;
// The host moves the graph version on every write: later nodes preview with the new revision.
let version=4;
const bumped=await allSession(createSendSession,{revision:'1:4',preview:id=>({ticket:`t-${id}`,revision:`1:${version}`,target:{id},fields:[{role:'positive',before:'old',after:'new'}]}),apply:id=>{version++;return{targetId:id,values:{positive:'new'}};}});
bumped.session.selectTarget(ALL_NODES);await bumped.session.apply();
assert.equal(bumped.session.state.message,'Sent to 3 of 3 nodes in Flow - ComfyUI.');bumped.session.dispose();
const moved=await allSession(createSendSession,{revision:'1:4',preview:id=>({ticket:`t-${id}`,revision:'1:5',target:{id},fields:[{role:'positive',before:'old',after:'new'}]})});
moved.session.selectTarget(ALL_NODES);await moved.session.apply();
assert.match(moved.session.state.message,/workflow changed since this window was found/i);assert.equal(moved.calls.filter(call=>call[0]==='apply').length,0);moved.session.dispose();
});
test('a node whose text changed after the preview pass is refused, not overwritten',async()=>{
const {createSendSession,ALL_NODES}=await source;let edited=false;
const s=await allSession(createSendSession,{preview:id=>({ticket:`t-${id}`,target:{id},fields:[{role:'positive',before:id==='10'&&edited?'typed in ComfyUI':'old',after:'new'}]}),apply:id=>{edited=true;return{targetId:id,values:{positive:'new'}};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2','9']);
assert.match(s.session.state.message,/^Sent to 2 of 3 nodes in Flow - ComfyUI\. #10 Node 10: Its prompt changed since the preview\./);s.session.dispose();
});
test('Preview with All shows every node in order, and Apply sends fresh tickets that match the review',async()=>{
const {createSendSession,ALL_NODES}=await source;const s=await allSession(createSendSession);
s.session.selectTarget(ALL_NODES);await s.session.preview();
assert.equal(s.session.state.phase,'ready');assert.equal(s.session.state.preview,null);
assert.deepEqual(s.session.state.previews.map(item=>item.label),['#2 Node 2','#9 Node 9','#10 Node 10']);
assert.match(s.session.state.message,/Sending to all 3 eligible nodes in Flow - ComfyUI\. Review the complete changes, then apply\./);
const first=s.session.state.previews[0].preview.ticket;
await s.session.apply();
// A held review may have outlived the host's 30 s tickets, so even the first node takes a fresh one.
assert.deepEqual(order(s.calls),['preview 2','preview 9','preview 10','preview 2','apply 2','preview 9','apply 9','preview 10','apply 10']);
assert.notEqual(s.calls.find(call=>call[0]==='apply')[2],first);assert.equal(s.session.state.phase,'success');
s.session.selectTarget('2');assert.deepEqual(s.session.state.previews,[]);s.session.dispose();
});
test('the dialog offers All for two or more nodes and reviews each node under its name',async()=>{
const {createPromptSender,ALL_NODES}=await source;const {createScope}=await import('../../web/js/shared/toolkit/core.js');
const dom=fakeDOM();const scope=createScope();
const {client}=allHost({nodes:['1','2']});
try {
createPromptSender({scope,clientFactory:()=>client}).open({positive:'new'});await tick();
const dialog=dom.body.children.at(-1);const target=dom.find(dialog,node=>node.id==='prompt-sender-target');
assert.deepEqual(target.children.map(option=>[option.value,option.textContent]),[['','Choose a destination node'],['1','#1 Node 1'],['2','#2 Node 2'],[ALL_NODES,'All 2 eligible nodes']]);
assert.equal(target.value,'');target.value=ALL_NODES;target.dispatchEvent(new Event('change'));
dom.find(dialog,node=>node.textContent==='Preview changes').dispatchEvent(new Event('click'));for(let i=0;i<5;i++)await tick();
const sections=[];(function walk(node){if(node.className==='prompt-sender-node')sections.push(node);node.children.forEach(walk);})(dialog);
assert.deepEqual(sections.map(section=>section.children[0].textContent),['#1 Node 1','#2 Node 2']);
dom.find(dialog,node=>node.textContent==='Apply to ComfyUI').dispatchEvent(new Event('click'));for(let i=0;i<10;i++)await tick();
assert.ok(dom.find(dialog,node=>node.textContent==='Sent to 2 of 2 nodes in Flow - ComfyUI.'));
} finally {scope.dispose();dom.restore();}
});
test('an expired review still sends every node, and a held review checks the discovered revision first',async()=>{
const {createSendSession,ALL_NODES}=await source;let reviewing=true;
// The host refuses every ticket taken during the review, as after its 30 s expiry.
const s=await allSession(createSendSession,{preview:(id,calls)=>({ticket:`t-${id}-${reviewing?'old':calls.length}`,target:{id},fields:[{role:'positive',before:'old',after:'new'}]}),apply:id=>({targetId:id,values:{positive:'new'}})});
const realApply=s.client.apply;s.client.apply=async(host,ticket,options)=>{if(ticket.endsWith('-old'))throw Error('Preview expired or unavailable; preview again');return realApply(host,ticket,options);};
s.session.selectTarget(ALL_NODES);await s.session.preview();reviewing=false;await s.session.apply();
assert.equal(s.session.state.message,'Sent to 3 of 3 nodes in Flow - ComfyUI.');s.session.dispose();
const moved=await allSession(createSendSession,{revision:'1:4',preview:(id,calls)=>({ticket:`t-${id}`,revision:calls.length>3?'1:5':'1:4',target:{id},fields:[{role:'positive',before:'old',after:'new'}]})});
moved.session.selectTarget(ALL_NODES);await moved.session.preview();assert.equal(moved.session.state.phase,'ready');await moved.session.apply();
assert.match(moved.session.state.message,/^Sent to 0 of 3 nodes in Flow - ComfyUI\. #2 Node 2: The workflow changed since this window was found/);
assert.equal(moved.calls.filter(call=>call[0]==='apply').length,0);moved.session.dispose();
});
test('a changed prepend or append after the review refuses that node',async()=>{
const {createSendSession,ALL_NODES}=await source;let edited=false;
const s=await allSession(createSendSession,{preview:id=>({ticket:`t-${id}`,target:{id},fields:[{role:'positive',before:'old',after:'new'}],composition:{prepend:id==='9'&&edited?'typed':'trigger',append:'',before:'trigger old',after:'trigger new'}}),apply:id=>{edited=true;return{targetId:id,values:{positive:'new'}};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2','10']);
assert.match(s.session.state.message,/^Sent to 2 of 3 nodes in Flow - ComfyUI\. #9 Node 9: Its prompt changed since the preview\./);s.session.dispose();
});
// #593: Send to All through the real Toolkit bridge and the real adapter, as the review bot reproduced it.
function realWindow(count) {
const listeners=new Set();
const channelFactory=()=>{const own=new Set();return{
addEventListener(type,listener){if(type==='message'){own.add(listener);listeners.add(listener);}},removeEventListener(type,listener){listeners.delete(listener);},
postMessage(data){for(const listener of [...listeners])if(!own.has(listener))queueMicrotask(()=>listener({data:structuredClone(data),origin:'https://comfy.example'}));},close(){},
};};
// Like LiteGraph, a widget write moves the version of the graph it is in.
const graph=()=>{const made={_version:1,setDirtyCanvas(){made._version++;}};made._nodes=Array.from({length:count},(_,index)=>{
const widgets=['text','prepend_text','append_text'].map(name=>({name,value:name==='text'?'old':'',inputEl:{value:name==='text'?'old':''}}));
return {id:index+1,type:'PromptManager',title:`Node ${index+1}`,widgets,widgets_values:widgets.map(widget=>widget.value),inputs:[],setDirtyCanvas(){}};
});return made;};
return {channelFactory,graph};
}
async function realSend(count,{afterFirstApply}={}) {
const {createSendSession,ALL_NODES}=await source;
const {createBridgeHost,createBridgeClient}=await import('../../web/js/shared/toolkit/prompt-bridge.js');
const {createPromptAdapter}=await import('../../web/js/prompt-bridge-adapter.js');
const win=realWindow(count);const app={graph:win.graph(),canvas:{}};
const adapter=createPromptAdapter(app,{runtime:{}});let applied=0;
const watched={...adapter,apply(snapshot){const result=adapter.apply(snapshot);if(++applied===1)afterFirstApply?.(app,win);return result;}};
const options={namespace:'/prompt_manager/',origin:'https://comfy.example',channelFactory:win.channelFactory};
const host=createBridgeHost({...options,label:'Flow - ComfyUI',adapter:watched});
const client=createBridgeClient(options);
const session=createSendSession({client,roles:{positive:'new'},onChange:()=>{}});
try {
await session.discover();session.selectTarget(ALL_NODES);await session.apply();
return {state:session.state,app};
} finally { session.dispose();host.dispose(); }
}
test('All refuses the later nodes when another workflow is loaded after the first send (#593)',async()=>{
let first;
const {state,app}=await realSend(3,{afterFirstApply:(target,win)=>{first=target.graph;target.graph=win.graph();target.graph._version=first._version;}});
// The new workflow has the same ids, text and composition, and even the same graph version: only node identity differs.
assert.equal(first._nodes[0].widgets[0].value,'new');
assert.deepEqual(app.graph._nodes.map(node=>node.widgets[0].value),['old','old','old']);
assert.equal(state.phase,'error');
assert.match(state.message,/^Sent to 1 of 3 nodes in Flow - ComfyUI\. #2 Node 2: The workflow changed since this window was found\./);
});
test('All sends to every node of a window with more than 100 of them (#593)',async()=>{
const {state,app}=await realSend(101);
assert.equal(state.message,'Sent to 101 of 101 nodes in Flow - ComfyUI.');
assert.ok(app.graph._nodes.every(node=>node.widgets[0].value==='new'));
});
+92 -3
View File
@@ -53,8 +53,12 @@ function observed(overrides = {}, capture = {}) {
...overrides,
};
}
// #431: the saved instructions; the fixture's entry is the default of both engines.
const SAVED_ENTRY = { id: '0123456789ab', name: 'Saved', text: DEFAULT_PROMPT };
const models = (overrides = {}) => ({
success: true, models: { 'wd14-swinv2': { downloaded: true } }, default_prompt: DEFAULT_PROMPT,
success: true, models: { 'wd14-swinv2': { downloaded: true } },
instructions: [{ id: 'builtin', name: 'Built-in', text: 'Built-in text.' }, SAVED_ENTRY],
default_instructions: { gguf: SAVED_ENTRY.id, hf: SAVED_ENTRY.id },
wd14_general_threshold: 0.35, wd14_character_threshold: 0.85, suggestion_limit: 10, ...overrides,
});
const SANDSTONE = [
@@ -244,10 +248,13 @@ test('dialog starts on the saved model unless a preset or the user chose one', a
});
test('an unreadable or malformed models read falls back to the documented defaults', async () => {
for (const route of [() => fail(500, null), () => response(200, models({ default_prompt: 4, suggestion_limit: 0, wd14_general_threshold: 2, wd14_character_threshold: 'x' }))]) {
for (const route of [() => fail(500, null), () => response(200, models({ instructions: 4, suggestion_limit: 0, wd14_general_threshold: 2, wd14_character_threshold: 'x' }))]) {
const { c } = await opened({ [`GET ${MODELS}`]: route });
assert.equal(c.state.phase, 'ready');
assert.deepEqual({ ...c.state.options }, { general_threshold: 0.35, character_threshold: 0.85, instructions: '', suggestion_limit: 10 });
// #431: the client's Built-in copy (D204's read failure).
const { BUILTIN_INSTRUCTIONS } = await load();
assert.deepEqual({ ...c.state.options }, { general_threshold: 0.35, character_threshold: 0.85, instructions: BUILTIN_INSTRUCTIONS,
suggestion_limit: 10 });
}
});
@@ -950,3 +957,85 @@ test('the chip hint says how many skipped tags were not suggested (#431)', async
assert.equal(c.state.chipHint, hint, String(skipped));
}
});
// D204 (#431): the Saved instructions picker of both dialogs.
const PHOTO_ENTRY = { id: '0123456789ac', name: 'Photo', text: 'Photo tags.' };
const picker = (overrides = {}) => ({ [`GET ${MODELS}`]: () => response(200, models({ model_type: 'gguf',
instructions: [{ id: 'builtin', name: 'Built-in', text: 'Built-in text.' }, SAVED_ENTRY, PHOTO_ENTRY],
default_instructions: { gguf: SAVED_ENTRY.id, hf: PHOTO_ENTRY.id }, ...overrides })) });
test('the picker starts at the engine default; a pick replaces the text and an edit is one-off (D204)', async () => {
const { c } = await opened(picker());
assert.deepEqual([c.state.instructionId, c.state.options.instructions, c.state.instructionsEdited], [SAVED_ENTRY.id, DEFAULT_PROMPT, false]);
const revision = c.state.optionsRevision;
c.pickInstructions(PHOTO_ENTRY.id);
assert.deepEqual([c.state.instructionId, c.state.options.instructions, c.state.instructionsEdited], [PHOTO_ENTRY.id, 'Photo tags.', false]);
assert.equal(c.state.optionsRevision, revision + 1, 'a pick re-renders the textarea');
c.setOption('instructions', 'Photo tags, please.');
assert.equal(c.state.instructionsEdited, true);
assert.equal(c.state.optionsRevision, revision + 1, 'typing never re-renders');
// Choosing an entry again replaces the edit without asking; an unknown id changes nothing.
c.pickInstructions(PHOTO_ENTRY.id);
assert.deepEqual([c.state.options.instructions, c.state.instructionsEdited], ['Photo tags.', false]);
c.pickInstructions('nope');
assert.equal(c.state.instructionId, PHOTO_ENTRY.id);
// Switching engines resets to the new engine's default and drops a one-off edit; WD14 is never "edited".
c.setOption('instructions', 'edited');
c.setModel('hf');
assert.deepEqual([c.state.instructionId, c.state.options.instructions, c.state.instructionsEdited], [PHOTO_ENTRY.id, 'Photo tags.', false]);
c.setModel('wd14-vit');
assert.equal(c.state.instructionsEdited, false);
c.setModel('gguf');
assert.deepEqual([c.state.instructionId, c.state.options.instructions], [SAVED_ENTRY.id, DEFAULT_PROMPT]);
c.dispose();
});
test('a preset shows the entry with its text, else "Edited for this run" (D204)', async () => {
const { c } = await opened(picker(), { preset: { model: 'hf', options: { instructions: DEFAULT_PROMPT } } });
assert.deepEqual([c.state.instructionId, c.state.instructionsEdited], [SAVED_ENTRY.id, false]);
const { c: other } = await opened(picker(), { preset: { model: 'hf', options: { instructions: 'Something else' } } });
assert.deepEqual([other.state.instructionId, other.state.options.instructions, other.state.instructionsEdited],
[PHOTO_ENTRY.id, 'Something else', true]);
for (const controller of [c, other]) controller.dispose();
});
test('a models read without instructions offers Built-in only with the client copy (D204)', async () => {
const { BUILTIN_INSTRUCTIONS, readChoices } = await load();
assert.equal(BUILTIN_INSTRUCTIONS.startsWith('Generate only comma-separated Danbooru tags'), true);
for (const value of [null, {}, { instructions: [] }, { instructions: [{ id: 'x', name: 'X', text: 'y' }] }]) {
assert.deepEqual(readChoices(value).entries.map(entry => entry.id), ['builtin'], JSON.stringify(value));
}
// A default naming no entry is Built-in.
assert.deepEqual({ ...readChoices({ instructions: [{ id: 'builtin', name: 'Built-in', text: 't' }], default_instructions: { gguf: 'gone' } }).defaults },
{ gguf: 'builtin', hf: 'builtin' });
const { c } = await opened(picker({ instructions: 'broken' }));
assert.deepEqual([c.state.instructionId, c.state.options.instructions], ['builtin', BUILTIN_INSTRUCTIONS]);
c.dispose();
});
test('the bulk options pick and reset like the Tag image dialog (D204)', async () => {
const { createTaggingOptionsController } = await load();
const options = createTaggingOptionsController({ api: harness(defaultRoutes(picker())).api });
await options.open();
assert.deepEqual([options.state.model, options.state.instructionId, options.state.options.instructions], ['gguf', SAVED_ENTRY.id, DEFAULT_PROMPT]);
options.pickInstructions(PHOTO_ENTRY.id);
assert.equal(options.request().options.instructions, 'Photo tags.');
options.setOption('instructions', 'One-off.');
assert.equal(options.state.instructionsEdited, true);
assert.equal(options.request().options.instructions, 'One-off.');
options.setModel('hf');
assert.deepEqual([options.state.instructionId, options.state.instructionsEdited], [PHOTO_ENTRY.id, false]);
options.pickInstructions('nope');
options.dispose();
options.pickInstructions(SAVED_ENTRY.id);
assert.equal(options.state.instructionId, PHOTO_ENTRY.id);
});
test('the client Built-in copy equals the server DEFAULT_TAG_TEMPLATE (#431)', async () => {
const { BUILTIN_INSTRUCTIONS } = await load();
const source = require('node:fs').readFileSync(require('node:path').join(__dirname, '../../py/tagging_defaults.py'), 'utf8');
const block = source.slice(source.indexOf('DEFAULT_TAG_TEMPLATE = ('), source.indexOf('\n)\n', source.indexOf('DEFAULT_TAG_TEMPLATE = (')));
const text = [...block.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map(([, part]) => JSON.parse(`"${part}"`)).join('');
assert.ok(text.length > 100);
assert.equal(BUILTIN_INSTRUCTIONS, text);
});
@@ -180,7 +180,9 @@ test('the bulk options start from the server defaults and wait for them before S
assert.equal(options.state.canStart, false);
assert.equal(options.state.canGenerate, false);
const opening = options.open();
answer({ wd14_general_threshold: 0.5, wd14_character_threshold: 0.9, default_prompt: 'Say it', suggestion_limit: 7,
answer({ wd14_general_threshold: 0.5, wd14_character_threshold: 0.9, suggestion_limit: 7,
instructions: [{ id: 'builtin', name: 'Built-in', text: 'Built-in text.' }, { id: '0123456789ab', name: 'Say', text: 'Say it' }],
default_instructions: { gguf: '0123456789ab', hf: 'builtin' },
models: { 'wd14-swinv2': { downloaded: true }, 'wd14-vit': { downloaded: false }, gguf: { downloaded: true } } });
await opening;
assert.equal(options.state.canStart, true);
+18
View File
@@ -128,6 +128,24 @@ test('invalid file shows the validator message; a refusal names its job; a lost
assert.deepEqual(await uploadRestore(file, { ApiError, fetchUrl: async () => response(200, PREVIEW) }), PREVIEW);
});
// #583: WebKit reads a FormData's file part by reference, possibly after a garbage collection; the page holds the form
// until the upload is answered, then lets it go, whether the server staged it, refused it or the request failed.
test('a review upload keeps its form referenced until the upload is answered', async () => {
const { uploadRestore, uploadsInFlight } = await backups;
const file = new File(['bytes'], 'b.db');
for (const answer of [async () => response(200, PREVIEW), async () => response(400, { success: false, error: 'x' }),
async () => { throw new TypeError('offline'); }]) {
let held;
const upload = uploadRestore(file, { ApiError, fetchUrl: async (url, init) => {
held = uploadsInFlight.has(init.body) && init.body.get('database_file').size === file.size;
return answer();
} }).catch(() => {});
await upload;
assert.equal(held, true);
assert.equal(uploadsInFlight.size, 0);
}
});
test('Cancel discards the stage through the api client and never throws', async () => {
const { discardRestore, PREVIEW_URL } = await backups;
const calls = [];
+54 -2
View File
@@ -21,8 +21,23 @@ test('the section lists the concept rows in its order, labels and danger marks',
assert.deepEqual(MAINTENANCE_SURFACES.map(([heading]) => heading), surfaces.map(text => text.replace('&amp;', '&')));
const production = MAINTENANCE_SURFACES.flatMap(([, rows]) => rows.map(([title, description, label, , danger = false]) =>
[title, description, label, danger]));
assert.deepEqual(production, concept.map(([title, ...rest]) => [title.replace('&amp;', '&'), ...rest]));
assert.equal(production.length, 8);
// D192 (#550, beyond the concept): "Pair category and tags" follows "Unused prompt components" in Database.
const expected = concept.map(([title, ...rest]) => [title.replace('&amp;', '&'), ...rest]);
expected.splice(expected.findIndex(([title]) => title === 'Unused prompt components') + 1, 0, PAIR_ROW);
assert.deepEqual(production, expected);
assert.equal(production.length, 9);
});
const PAIR_ROW = Object.freeze(['Pair category and tags', 'Fill an empty category or tags on prompts older Pair node runs ' +
'saved, from their saved pairs. Only empty fields of never-edited prompts.', 'Review plan', false]);
test('D192: the Pair category and tags row reviews the pair_metadata plan under its title (#550)', async () => {
const { MAINTENANCE_SURFACES } = await maintenance;
const { OPERATION_TITLES } = await review;
const [, database] = MAINTENANCE_SURFACES[0];
const row = database.find(([title]) => title === PAIR_ROW[0]);
assert.deepEqual(row, [PAIR_ROW[0], PAIR_ROW[1], PAIR_ROW[2], 'pair_metadata']);
assert.equal(OPERATION_TITLES.pair_metadata, 'Pair category and tags');
});
const PLAN = Object.freeze({
@@ -210,3 +225,40 @@ test('a rotated-log cleanup the log overtook offers Review again (S12)', async (
assert.deepEqual(reviewAgainOf({ kind: 'maintenance', state: 'failed', scope: { operation: 'rotated_logs', failure: 'plan-changed' } }),
{ operation: 'rotated_logs' });
});
// #550 live test: a records operation's rows have no path, only a detail ("Prompt 12: category …").
const RECORD_JOB = Object.freeze({
id: 'a'.repeat(32), kind: 'maintenance', title: 'Pair category and tags', state: 'finished',
admission: 'records', cancellable: false, progress: 100, processed: 0, total: 0, status_line: 'Filled 2 prompts',
scope: { operation: 'pair_metadata' }, counters: { changed: 2, protected: 0, errors: 0 },
started_at: 1, finished_at: 2,
});
test('#550: path-less record rows are kept with their detail; file rows are unchanged', async () => {
const { parseDetail, filterRows } = await jobView;
const parsed = parseDetail({ ...RECORD_JOB, outcomes: { changed: [
{ path: '', root: '', root_index: null, detail: 'Prompt 2325: category “asian”; tags asian' },
{ path: '', root: '', root_index: null, detail: '' },
{ path: 'a/b.png', root: 'output', root_index: 0, detail: 'Link removed' },
] }, log: [] });
assert.deepEqual(parsed.outcomes.changed, [
{ path: '', root: '', root_index: null, detail: 'Prompt 2325: category “asian”; tags asian', record: true },
{ path: 'a/b.png', root: 'output', root_index: 0, detail: 'Link removed', record: false },
]);
// The filter reads a record's detail and a file's path.
assert.deepEqual(filterRows(parsed.outcomes.changed, 'asian').map(row => row.detail),
['Prompt 2325: category “asian”; tags asian']);
assert.deepEqual(filterRows(parsed.outcomes.changed, 'a/b').map(row => row.path), ['a/b.png']);
});
test('#550: a records operation says records, not files, for an empty group and a filter', async () => {
const { groupCopy, EMPTY_GROUP, NO_MATCH } = await jobView;
assert.deepEqual(groupCopy(RECORD_JOB), { empty: 'No records in this group.', noMatch: 'No listed record matches this filter.',
placeholder: 'Filter records…' });
for (const operation of ['duplicate_prompts', 'unused_components']) {
assert.equal(groupCopy({ ...RECORD_JOB, scope: { operation } }).empty, 'No records in this group.');
}
const files = { empty: EMPTY_GROUP, noMatch: NO_MATCH, placeholder: 'Filter by file path…' };
assert.deepEqual(groupCopy({ ...RECORD_JOB, scope: { operation: 'missing_links' } }), files);
assert.deepEqual(groupCopy({ ...RECORD_JOB, kind: 'scan', scope: {} }), files);
});
@@ -0,0 +1,170 @@
// Thumbnail location (#320, D196–D201): the row's state and status line, the move review's rows, the unavailable
// notice's text, the Activity views of a move and a prune, the prune scope's plan line and the Move library row.
const test = require('node:test');
const assert = require('node:assert/strict');
const locationModule = import('../../web/js/workspace/system-thumbnail-location.mjs');
const kindsModule = import('../../web/js/workspace/activity-kinds.mjs');
const reviewModule = import('../../web/js/workspace/thumbnail-review.mjs');
const libraryModule = import('../../web/js/workspace/library-location.mjs');
const SOURCE = Object.freeze({ mode: 'source', path: null, default_path: '/data/thumbnails', available: true,
problem: null, cleanup_pending: 0 });
const FOLDER = Object.freeze({ mode: 'folder', path: 'E:\\Thumbnails', default_path: '/data/thumbnails',
available: true, problem: null, cleanup_pending: 0 });
const PLAN = Object.freeze({
from: { mode: 'source', path: null, available: true },
to: { mode: 'folder', path: 'E:\\Thumbnails', exists: true, will_create: false, default: false },
totals: { sources: 3, files: 8512, bytes: 1.2 * 1024 ** 3, orphans: 41, temps: 2, links: 1, left_in_place: 1,
too_long: 32, unlisted_sources: 0, unlisted_files: 0 },
too_long_listed: ['Archive 2025 · a_thumb.webp', 'Archive 2025 · b_thumb.webp'],
left_listed: ['Archive 2025 · notes.txt'],
unlisted: [],
free_bytes: 1.6 * 1024 ** 4,
confirm_required: true,
token: 't',
});
test('the status line says where previews are now', async () => {
const { statusText } = await locationModule;
assert.equal(statusText(SOURCE), 'Now: inside each source.');
assert.equal(statusText(FOLDER), 'Now: one folder, E:\\Thumbnails.');
assert.equal(statusText({ ...FOLDER, path: '/data/thumbnails' }),
'Now: one folder, /data/thumbnails. Using the default folder next to your library.');
assert.equal(statusText({ ...FOLDER, available: false, problem: 'missing' }),
'Now: one folder, E:\\Thumbnails · unavailable. Originals are shown.');
assert.match(statusText({ ...SOURCE, cleanup_pending: 1 }), /Old thumbnails in 1 place wait to be removed/);
assert.equal(statusText(undefined), '');
assert.match(statusText(SOURCE, { mode: 'folder', path: '/data/thumbnails' }),
/^Now: inside each source\. The suggested folder is next to your library/);
assert.equal(statusText(SOURCE, { mode: 'folder', path: '/other' }), 'Now: inside each source.');
});
test('Review move… is offered only for another location', async () => {
const { choiceChanged } = await locationModule;
assert.equal(choiceChanged(SOURCE, { mode: 'source' }), false);
assert.equal(choiceChanged(SOURCE, { mode: 'folder', path: ' ' }), false);
assert.equal(choiceChanged(SOURCE, { mode: 'folder', path: '/data/thumbnails' }), true);
assert.equal(choiceChanged(FOLDER, { mode: 'folder', path: 'E:\\Thumbnails' }), false);
assert.equal(choiceChanged(FOLDER, { mode: 'folder', path: 'F:\\Thumbs' }), true);
assert.equal(choiceChanged(FOLDER, { mode: 'source' }), true);
assert.equal(choiceChanged({ ...SOURCE, problem: 'invalid' }, { mode: 'source' }), true);
assert.equal(choiceChanged(undefined, { mode: 'source' }), false);
});
test('the review lists what moves, what is removed and what stays', async () => {
const { reviewRows } = await locationModule;
const rows = Object.fromEntries(reviewRows(PLAN));
assert.equal(rows.From, 'Inside each source (3 sources)');
assert.equal(rows.To, 'E:\\Thumbnails (empty folder)');
assert.equal(rows.Thumbnails, '8,512 in 3 sources · 1.2 GB');
assert.equal(rows['Free space'], '1638.4 GB free');
assert.match(rows['Too long for Windows'][0], /^32 thumbnails cannot be stored there/);
assert.deepEqual(rows['Too long for Windows'][1], [...PLAN.too_long_listed, 'and 30 more']);
assert.deepEqual(rows['Removed after the move'][1],
['41 thumbnails whose original is gone', '2 unfinished temporary files']);
assert.equal(rows['Left in place'][0], '1 file that is not a thumbnail:');
assert.equal(rows['Links skipped'], '1 link inside a thumbnails folder is not followed');
});
test('the review of a move back names the folder and the removed sources', async () => {
const { reviewRows } = await locationModule;
const back = reviewRows({ ...PLAN, from: { mode: 'folder', path: '/data/thumbnails' },
to: { mode: 'source', path: null }, unlisted: [{ key: 'old-1c22e0a4', files: 240 }],
totals: { sources: 1, files: 1, bytes: 10 }, free_bytes: undefined });
const rows = Object.fromEntries(back);
assert.equal(rows.From, '/data/thumbnails');
assert.equal(rows.To, 'Inside each source (1 source)');
assert.equal(rows['Free space'], undefined);
assert.match(rows['Removed after the move'][0], /The folder itself is removed once empty/);
assert.deepEqual(rows['Removed after the move'][1],
['1 source no longer listed: 240 thumbnails are removed; they are made again if it returns']);
// Nothing else removed: one full stop, never "created it.." (live test of #592).
const plain = Object.fromEntries(reviewRows({ ...PLAN, from: { mode: 'folder', path: '/data/thumbnails' },
to: { mode: 'source', path: null }, unlisted: [], totals: { sources: 1, files: 1, bytes: 10 } }));
assert.match(plain['Removed after the move'][0], /Prompt Manager created it\.$/);
const fresh = Object.fromEntries(reviewRows({ ...PLAN, to: { mode: 'folder', path: '/d', default: true, will_create: true } }));
assert.equal(fresh.To, '/d (new, next to your library)');
});
test('the notice says why the folder is unavailable', async () => {
const { noticeText } = await locationModule;
assert.match(noticeText({ ...FOLDER, problem: 'missing' }), /^E:\\Thumbnails is missing\. Originals are shown/);
assert.match(noticeText({ ...FOLDER, problem: 'unreachable' }), /can’t be reached/);
assert.match(noticeText({ ...FOLDER, problem: 'marker-mismatch' }), /holds another thumbnail folder/);
assert.match(noticeText({ ...FOLDER, problem: 'invalid' }), /config\.json can’t be used/);
assert.match(noticeText({ ...FOLDER, problem: null }), /is being checked/);
});
test('sizes are written as the server writes them', async () => {
const { sizeText } = await locationModule;
assert.equal(sizeText(512), '512 B');
assert.equal(sizeText(85.2 * 1024 * 1024), '85.2 MB');
});
test('a move and a prune show their own tiles in Activity', async () => {
const { viewOf, movePhase, finishToast } = await kindsModule;
const move = { kind: 'thumbnails', state: 'running', cancellable: true, scope: { mode: 'move', from: 'source' },
counters: { moved: 3, skipped: 0, left: 1, errors: 2 }, status_line: '' };
assert.deepEqual(viewOf(move).tiles.map(([group, label]) => label), ['Moved', 'Already there', 'Left in place', 'Errors']);
assert.equal(viewOf(move).cancelLabel, 'Cancel move');
assert.match(movePhase(move), /^Step 1 of 3: copying\. Thumbnails are still read from inside each source/);
assert.match(movePhase({ ...move, cancellable: false }), /can no longer be cancelled/);
assert.match(movePhase({ ...move, scope: { mode: 'move', phase: 'remove' } }), /Removing the old copies/);
assert.match(movePhase({ ...move, state: 'finished' }), /1 file that is not a thumbnail was left in place/);
assert.match(movePhase({ ...move, state: 'cancelled' }), /^Nothing changed/);
assert.match(movePhase({ ...move, state: 'finished', counters: { moved: 3, left: 2 } }),
/2 files that are not thumbnails were left in place\.$/);
assert.equal(movePhase({ ...move, state: 'finished', counters: undefined }), 'Thumbnails are now read from the new location.');
assert.match(movePhase({ ...move, scope: { mode: 'move', from: 'folder' } }), /read from the old folder until the switch/);
const done = { ...move, state: 'finished', status_line: 'Thumbnails moved to E:\\T: 3 moved · old copies removed.' };
assert.equal(finishToast(done), done.status_line);
const prune = { kind: 'thumbnails', state: 'running', cancellable: true, scope: { mode: 'prune' },
counters: { removed: 1, left: 0, errors: 0 } };
assert.deepEqual(viewOf(prune).tiles.map(([, label]) => label), ['Removed', 'Left in place', 'Errors']);
assert.equal(viewOf(move).line(move), '');
assert.equal(viewOf(done).line(done), done.status_line);
assert.equal(viewOf(move).kept(move), '3 moved');
assert.equal(finishToast({ ...move, state: 'cancelled', status_line: '' }), viewOf(move).finishToast({ ...move,
state: 'cancelled', status_line: '' }));
const pruned = { ...prune, state: 'finished', status_line: '1 thumbnails removed' };
assert.equal(viewOf(prune).line(prune), '');
assert.equal(viewOf(pruned).line(pruned), '1 thumbnails removed');
assert.equal(viewOf(pruned).line({ ...pruned, status_line: null }), '');
assert.equal(viewOf(done).line({ ...done, status_line: null }), '');
assert.equal(finishToast(pruned), '1 thumbnails removed');
assert.equal(viewOf(prune).kept(prune), '1 removed');
assert.match(finishToast({ ...prune, state: 'finished', status_line: '', title: 'Remove thumbnails of removed sources' }),
/Remove thumbnails of removed sources/);
const generate = { kind: 'thumbnails', state: 'running', cancellable: true, scope: { mode: 'missing' },
counters: { generated: 0, skipped: 0, errors: 0 } };
assert.deepEqual(viewOf(generate).tiles.map(([, label]) => label), ['Generated', 'Skipped', 'Errors']);
});
test('the prune scope reads its plan line from the server', async () => {
const { planText, PLAN_FAILED, PRUNE_OPTION } = await reviewModule;
assert.equal(PRUNE_OPTION[1], 'Remove thumbnails of removed sources');
assert.equal(planText({ mode: 'prune', line: '2 sources no longer listed: 1,240 thumbnails (85.2 MB) are removed.' }),
'2 sources no longer listed: 1,240 thumbnails (85.2 MB) are removed.');
assert.equal(planText({ mode: 'prune' }), PLAN_FAILED);
});
test('the prune review names the folder and each removed source', async () => {
const { pruneText, pruneEntries } = await reviewModule;
assert.match(pruneText({ folder: 'E:\\Thumbs' }), /^Only Prompt Manager’s thumbnails in their folders inside E:\\Thumbs are /);
assert.match(pruneText({}), /inside the thumbnail folder are removed\. If a source comes back/);
assert.deepEqual(pruneEntries({ sources: [{ key: 'gone-0123abcd', files: 1240, bytes: 85.2 * 1024 * 1024 },
{ key: 'one-0123abcd', files: 1, bytes: 512 }] }),
[['gone-0123abcd', '1,240 thumbnails · 85.2 MB'], ['one-0123abcd', '1 thumbnail · 512 B']]);
assert.deepEqual(pruneEntries(null), []);
assert.deepEqual(pruneEntries({ sources: 'x' }), []);
});
test('Move library says where thumbnails kept in one folder stay', async () => {
const { reviewGrid } = await libraryModule;
const base = { path: 'p', target: 't', size_bytes: 0, config_moves: true };
assert.equal(reviewGrid(base).some(([term]) => term === 'Thumbnails'), false);
const rows = Object.fromEntries(reviewGrid({ ...base, thumbnails_folder: '/data/thumbnails' }));
assert.equal(rows.Thumbnails, 'Stay in /data/thumbnails. Move them in System › Sources & scans.');
});
+2
View File
@@ -796,6 +796,7 @@ class TestRevalidatedCachingBehindMiddlewares(CoreRoutesTestCase):
self.assertEqual(resp.status, 200, path)
self.assertEqual(resp.headers.get("Content-Encoding"), "gzip", path)
self.assertEqual(resp.headers["Cache-Control"], "no-cache", path)
await resp.read() # aiohttp before 3.11.11 hangs on an unread body
again = await self.client.request(
"GET",
path,
@@ -806,6 +807,7 @@ class TestRevalidatedCachingBehindMiddlewares(CoreRoutesTestCase):
)
self.assertEqual(again.status, 304, path)
self.assertEqual(again.headers["Cache-Control"], "no-cache", path)
await again.read()
# ── instance helpers ──────────────────────────────────────────────────
+34
View File
@@ -452,6 +452,40 @@ class TestReview(DuplicateFilesTestCase):
)
class TestReviewProtectedRows(DuplicateFilesTestCase):
async def test_cleanup_lists_each_copy_the_review_kept(self):
if not TestReview._can_symlink(self.tmpdir): # pragma: no cover
self.skipTest("symlinks are not available")
self.trio()
job, group = await self.found_group()
# b.png becomes a link after the find: the review protects it (#586).
(self.output_dir / "b.png").unlink()
os.symlink(str(self.output_dir / "a.png"), str(self.output_dir / "b.png"))
plan = await self.review(job.id, [self.keep_of(group, "a.png")])
self.assertEqual(plan["protected"]["count"], 1)
body = await self.apply(plan["token"])
cleanup = self.api._jobs.get(body["job"]["id"])
await asyncio.wait_for(asyncio.shield(cleanup.task), DISK_BOUND_SECONDS)
self.assertEqual(cleanup.state, "finished", cleanup.log)
rows = {row["path"]: row for row in cleanup.outcomes["kept"]}
self.assertEqual(
rows["b.png"],
{
"path": "b.png",
"root": rows["a.png"]["root"],
"root_index": 0,
"detail": "Kept: a link, not a regular file",
},
)
self.assertEqual(rows["a.png"]["detail"], "Kept copy")
self.assertEqual(cleanup.counters, {"removed": 1, "kept": 2, "errors": 0})
report = cleanup.report()["outcomes"]["kept"]
self.assertEqual(
sorted(row["path"] for row in report["rows"]), ["a.png", "b.png"]
)
self.assertFalse(report["truncated"])
class TestCleanupJob(DuplicateFilesTestCase):
async def test_cleanup_is_a_records_job_with_a_safety_backup(self):
self.trio()
+2
View File
@@ -91,6 +91,8 @@ class TestLocationRoute(LibraryAPITestCase):
"size_bytes": os.path.getsize(self.api.db.model.db_path),
"target_source": "default",
"config_moves": True,
# #320 D201: previews kept inside each source (no folder).
"thumbnails_folder": None,
},
)
+24
View File
@@ -266,6 +266,30 @@ class TestApply(LogCleanupTestCase):
report = json.dumps(job.report())
self.assertNotIn(self.tmpdir, report)
async def test_apply_lists_each_kept_file(self):
linked = self.seed()
plan = await self.preview()
_summary, job = await self.run_apply(plan)
expected = [
("prompt_manager.log", "Kept: the active log"),
("prompt_manager.log.9", "Kept: another file"),
("prompt_manager.log.bak", "Kept: another file"),
]
if linked:
expected.append(("prompt_manager.log.4", "Kept: a link"))
rows = job.outcomes["protected"]
self.assertEqual(
sorted((row["path"], row["detail"]) for row in rows), sorted(expected)
)
self.assertEqual(
{(row["root"], row["root_index"]) for row in rows}, {("", None)}
)
report = job.report()["outcomes"]["protected"]
self.assertEqual(
(report["count"], len(report["rows"]), report["truncated"]),
(plan["protected"]["count"], len(expected), False),
)
async def test_apply_rows_name_each_file(self):
self.seed()
plan = await self.preview()
+181
View File
@@ -0,0 +1,181 @@
"""Pair category and tags repair through the maintenance routes (#550).
``pair_metadata`` is reviewed and applied like the other maintenance plans:
``options`` must be ``{}``, the job is titled "Pair category and tags", the
safety backup follows the ``maintenance.safety_backup`` policy, and the job's
downloadable report lists every filled prompt, never cut at 200 rows.
"""
import asyncio
import os
import sys
from unittest.mock import MagicMock
_mock_server = MagicMock()
_mock_server.PromptServer.instance.routes = MagicMock()
sys.modules.setdefault("server", _mock_server)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from py.api.jobs import MAX_OUTCOME_ROWS # noqa: E402
from py.api.maintenance import MAINTENANCE_TITLES # noqa: E402
from py.config import PromptManagerConfig # noqa: E402
try:
from tests.test_api_maintenance import WAIT, ApplyTestCase # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from test_api_maintenance import WAIT, ApplyTestCase # noqa: E402
OPERATION = "pair_metadata"
TITLE = "Pair category and tags"
class PairMetadataAPITestCase(ApplyTestCase):
def pair(self, positive, negative="", **metadata):
pair_id = self.api.db.save_prompt_pair(positive, negative, **metadata)
with self.api.db.model.get_connection() as conn:
prompt = conn.execute(
"SELECT positive_prompt_id FROM prompt_pairs WHERE id=?", (pair_id,)
).fetchone()[0]
return pair_id, prompt
def category(self, prompt_id):
with self.api.db.model.get_connection() as conn:
return conn.execute(
"SELECT category FROM prompts WHERE id=?", (prompt_id,)
).fetchone()[0]
class TestPairMetadataRoutes(PairMetadataAPITestCase):
async def test_preview_accepts_the_operation_and_no_options(self):
self.assertEqual(MAINTENANCE_TITLES[OPERATION], TITLE)
self.pair("a cat", category="portrait", tags=["a"])
plan = await self.preview(OPERATION)
self.assertEqual(plan["operation"], OPERATION)
self.assertEqual(plan["scope"]["count"], 1)
self.assertEqual(plan["backup"]["policy"], "on")
refused = await self.preview(OPERATION, 400, options={"all": True})
self.assertEqual(refused["code"], "invalid-request")
async def test_apply_fills_with_a_backup_and_names_it(self):
_pair, prompt = self.pair("a cat", "neg", category="portrait", tags=["a", "b"])
plan, summary, job = await self.run_apply(OPERATION)
self.assertEqual(summary["title"], TITLE)
self.assertEqual(summary["admission"], "records")
self.assertEqual(job.state, "finished", job.log)
self.assertEqual(self.category(prompt), "portrait")
[backup] = self.backups()
self.assertEqual(
job.status_line,
"Filled 1 prompt from its saved pairs (1 category, 1 tag set) · "
f"Safety backup {backup}",
)
self.assertEqual(
job.outcomes["changed"][0]["detail"],
f"Prompt {prompt}: category “portrait”; tags a, b",
)
self.assertEqual(job.counters["changed"], 1)
async def test_protected_prompts_are_counted(self):
_a, prompt = self.pair("conflict", "n1", category="x")
self.pair("conflict", "n2", category="y")
self.pair("fill me", category="portrait")
plan, _summary, job = await self.run_apply(OPERATION)
self.assertEqual(plan["protected"]["reasons"], ["1 where pairs disagree"])
self.assertEqual(job.counters["protected"], 1)
self.assertIsNone(self.category(prompt))
# Live test (#550): the count comes with its rows, in the review's wording.
self.assertEqual(
job.outcomes["protected"],
[
{
"path": "",
"root": "",
"root_index": None,
"detail": (
f"Prompt {prompt}: kept, pairs disagree on category: x, y"
),
}
],
)
report = job.report()["outcomes"]["protected"]
self.assertEqual((report["count"], report["truncated"]), (1, False))
async def test_report_lists_every_kept_prompt(self):
PromptManagerConfig.SAFETY_BACKUP = False
count = MAX_OUTCOME_ROWS + 5
for n in range(count):
_pair, prompt = self.pair(f"kept {n:03d}", category="portrait")
with self.api.db.model.get_connection() as conn:
conn.execute(
"UPDATE prompts SET updated_at='2099-01-01T00:00:00+00:00' "
"WHERE id=?",
(prompt,),
)
_plan, _summary, job = await self.run_apply(OPERATION)
self.assertEqual(job.counters["protected"], count)
self.assertEqual(len(job.outcomes["protected"]), MAX_OUTCOME_ROWS)
protected = job.report()["outcomes"]["protected"]
self.assertEqual(
(len(protected["rows"]), protected["truncated"]), (count, False)
)
self.assertTrue(
protected["rows"][0]["detail"].endswith(
": kept, edited after it was saved; its empty category or tags "
"may be intentional"
)
)
async def test_policy_off_writes_without_a_backup(self):
PromptManagerConfig.SAFETY_BACKUP = False
_pair, prompt = self.pair("no backup", category="portrait")
plan, _summary, job = await self.run_apply(OPERATION)
self.assertEqual(plan["backup"]["policy"], "off")
self.assertEqual(job.state, "finished", job.log)
self.assertIn("No safety backup (the policy is off)", "\n".join(job.log))
self.assertTrue(job.status_line.endswith("· No safety backup"))
self.assertEqual(self.backups(), [])
self.assertEqual(self.category(prompt), "portrait")
async def test_reviewed_with_the_policy_on_keeps_the_backup(self):
self.pair("promise", category="portrait")
plan = await self.preview(OPERATION)
PromptManagerConfig.SAFETY_BACKUP = False
body = await self.apply(plan["token"])
job = self.api._jobs.get(body["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
self.assertEqual(len(self.backups()), 1)
async def test_plan_changed_writes_nothing(self):
pair_id, prompt = self.pair("drift", category="portrait")
plan = await self.preview(OPERATION)
self.api.db.update_prompt_pair_metadata(pair_id, category="landscape")
body = await self.apply(plan["token"])
job = self.api._jobs.get(body["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
self.assertEqual(job.state, "failed")
self.assertEqual(job.last_event["code"], "plan-changed")
self.assertIsNone(self.category(prompt))
async def test_report_lists_every_filled_prompt(self):
PromptManagerConfig.SAFETY_BACKUP = False
count = MAX_OUTCOME_ROWS + 5
for n in range(count):
self.pair(f"bulk {n:03d}", category="portrait")
_plan, _summary, job = await self.run_apply(OPERATION)
self.assertEqual(job.state, "finished", job.log)
self.assertEqual(len(job.outcomes["changed"]), MAX_OUTCOME_ROWS)
response = await self.client.request(
"GET", f"/prompt_manager/jobs/{job.id}/report"
)
self.assertEqual(response.status, 200)
report = await response.json()
changed = report["outcomes"]["changed"]
self.assertEqual(changed["count"], count)
self.assertEqual(len(changed["rows"]), count)
self.assertFalse(changed["truncated"])
self.assertTrue(
all(
row["detail"].endswith("category “portrait”") for row in changed["rows"]
)
)
@@ -0,0 +1,140 @@
"""Protected maintenance items are listed in Activity, not only counted (#586).
Each record a maintenance job keeps gets one ``protected`` row with its
reason, in the wording of that operation's review, and the job's
downloadable report lists every one of them (never cut at 200 rows). A kept
image link names its file, so Activity can still open and locate it.
"""
import os
import shutil
import sys
from pathlib import Path
from unittest.mock import MagicMock
_mock_server = MagicMock()
_mock_server.PromptServer.instance.routes = MagicMock()
sys.modules.setdefault("server", _mock_server)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from py.api.jobs import MAX_OUTCOME_ROWS # noqa: E402
from py.config import GalleryConfig, PromptManagerConfig # noqa: E402
try:
from tests.test_api_maintenance import ApplyTestCase # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from test_api_maintenance import ApplyTestCase # noqa: E402
def _record(detail):
return {"path": "", "root": "", "root_index": None, "detail": detail}
class TestProtectedRows(ApplyTestCase):
def duplicate(self, text, created_at="2999-01-01"):
with self.api.db.model.get_connection() as conn:
return conn.execute(
"INSERT INTO prompts(text, created_at) VALUES(?, ?)",
(text.upper(), created_at),
).lastrowid
def assert_complete(self, job, count):
self.assertEqual(job.counters["protected"], count)
report = job.report()["outcomes"]["protected"]
self.assertEqual(
(report["count"], len(report["rows"]), report["truncated"]),
(count, count, False),
)
async def test_duplicate_prompts_lists_each_kept_copy(self):
paired = self.save("A dog")
self.api.db.save_prompt_pair("A dog", "")
paired_copy = self.duplicate("A dog")
titled = self.save("A bird")
titled_copy = self.duplicate("A bird")
with self.api.db.model.get_connection() as conn:
conn.execute(
"INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)",
(titled_copy, "Bird recipe"),
)
plan, _summary, job = await self.run_apply("duplicate_prompts")
self.assertEqual(job.state, "finished", job.log)
self.assertEqual(
sorted(row["detail"] for row in job.outcomes["protected"]),
sorted(
[
f"Prompt {paired}: kept, belongs to a saved pair",
f"Prompt {paired_copy}: kept, belongs to a saved pair",
f"Prompt {titled}: kept, a copy has its own Library title",
f"Prompt {titled_copy}: kept, a copy has its own Library title",
]
),
)
self.assertEqual(plan["protected"]["count"], 4)
self.assert_complete(job, 4)
async def test_unused_components_lists_each_kept_prompt_with_its_reasons(self):
self.save("Orphan")
tagged = self.save("Kept", tags=["__protected__"])
self.api.db.save_prompt_pair("Paired", "")
paired = self.api.db.search_prompts(text="Paired")[0]["id"]
with self.api.db.model.get_connection() as conn:
conn.execute(
"INSERT INTO prompt_tags(prompt_id, tag_id) "
"SELECT ?, id FROM tags WHERE name='__protected__'",
(paired,),
)
plan, _summary, job = await self.run_apply("unused_components")
self.assertEqual(job.state, "finished", job.log)
self.assertEqual(
sorted(job.outcomes["protected"], key=lambda row: row["detail"]),
sorted(
[
_record(f"Prompt {tagged}: kept, tagged __protected__"),
_record(
f"Prompt {paired}: kept, in a saved pair; "
"tagged __protected__"
),
],
key=lambda row: row["detail"],
),
)
self.assertEqual(plan["protected"]["count"], 2)
self.assert_complete(job, 2)
async def test_report_lists_every_kept_prompt(self):
PromptManagerConfig.SAFETY_BACKUP = False
count = MAX_OUTCOME_ROWS + 5
for n in range(count):
self.save(f"Kept {n:03d}", tags=["__protected__"])
_plan, _summary, job = await self.run_apply("unused_components")
self.assertEqual(len(job.outcomes["protected"]), MAX_OUTCOME_ROWS)
self.assert_complete(job, count)
async def test_missing_links_lists_each_kept_link_with_its_file(self):
unmounted = Path(self.tmpdir) / "unmounted"
unmounted.mkdir()
GalleryConfig.MONITORING_DIRECTORIES = [str(self.output_dir), str(unmounted)]
prompt = self.save("Links")
self.image(prompt, "gone.png", exists=False)
self.image(prompt, "sub/away.png", folder=unmounted, exists=False)
self.image(prompt, "x.png", folder=Path(self.tmpdir) / "elsewhere")
shutil.rmtree(unmounted)
plan, _summary, job = await self.run_apply("missing_links")
self.assertEqual(job.state, "finished", job.log)
self.assertEqual(plan["protected"]["count"], 2)
rows = sorted(job.outcomes["protected"], key=lambda row: row["path"])
self.assertEqual(
[(row["path"], row["detail"]) for row in rows],
[
("sub/away.png", "Kept: link under an unavailable source"),
("x.png", "Kept: link outside the image sources"),
],
)
# The gone source keeps the path under its label; outside every
# source the row names the file only.
self.assertEqual((rows[0]["root"], rows[0]["root_index"]), ("unmounted", None))
self.assertEqual((rows[1]["root"], rows[1]["root_index"]), ("", None))
self.assertNotIn(self.tmpdir, str(job.report()))
self.assert_complete(job, 2)
+487
View File
@@ -0,0 +1,487 @@
"""The thumbnail location setting (#320 PR 2): parsing, validation, source
keys, the marker, the settings payloads and reverse containment.
Spelling rules are tested on both path flavors (``ntpath`` and
``posixpath``) as text; filesystem rules run on the native one.
"""
import json
import ntpath
import os
import posixpath
import re
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.modules.setdefault("server", MagicMock())
from py.api import preview_location as pl # noqa: E402
from py.api import preview_store # noqa: E402
REPO = Path(__file__).resolve().parent.parent
class TestParse(unittest.TestCase):
def test_missing_or_source_is_inside_each_source(self):
for value in (None, {"mode": "source"}, {}):
with self.subTest(value=value):
parsed = pl.parse_location(value)
self.assertEqual((parsed.mode, parsed.problem), ("source", None))
def test_a_folder_location_is_kept_as_spelled(self):
parsed = pl.parse_location(
{"mode": "folder", "path": "D:\\Thumbs", "id": "x", "install": "i"},
flavor=ntpath,
)
self.assertEqual(
(parsed.mode, parsed.path, parsed.id, parsed.problem),
("folder", "D:\\Thumbs", "x", None),
)
def test_invalid_values_never_fall_back_to_the_sources(self):
for value in (
"folder",
{"mode": "elsewhere"},
{"mode": "folder", "path": "relative", "id": "x"},
{"mode": "folder", "path": "/abs"},
{"mode": "folder", "path": "/abs", "id": ""},
{"mode": "folder", "path": 7, "id": "x"},
):
with self.subTest(value=value):
parsed = pl.parse_location(value, flavor=posixpath)
self.assertEqual((parsed.mode, parsed.problem), ("folder", "invalid"))
class TestForm(unittest.TestCase):
def test_windows_spellings(self):
cases = {
"D:\\Thumbs": None,
"\\\\server\\share\\thumbs": None,
"C:thumbs": "location-not-absolute",
"\\thumbs": "location-not-absolute",
"thumbs": "location-not-absolute",
"": "location-not-absolute",
" D:\\Thumbs": "location-not-absolute",
"\\\\?\\D:\\Thumbs": "location-device-path",
"\\\\.\\D:\\Thumbs": "location-device-path",
"D:\\CON\\x": "location-invalid-name",
"D:\\com1.txt": "location-invalid-name",
"D:\\thumbs.": "location-invalid-name",
"D:\\thumbs ": "location-invalid-name",
"D:\\a|b": "location-invalid-name",
"D:\\a?b": "location-invalid-name",
"D:\\a\x01b": "location-invalid-name",
}
for path, code in cases.items():
with self.subTest(path=path):
self.assertEqual(pl.form_problem(path, flavor=ntpath), code)
def test_posix_spellings(self):
cases = {
"/mnt/fast/thumbs": None,
"mnt/thumbs": "location-not-absolute",
"/mnt/nul": "location-invalid-name",
"/mnt/a:b": "location-invalid-name",
"//?/x": "location-device-path",
}
for path, code in cases.items():
with self.subTest(path=path):
self.assertEqual(pl.form_problem(path, flavor=posixpath), code)
def test_roots_and_home_are_too_broad(self):
self.assertTrue(pl.is_too_broad("C:\\", home="C:\\Users\\a", flavor=ntpath))
self.assertTrue(
pl.is_too_broad("\\\\server\\share", home="C:\\Users\\a", flavor=ntpath)
)
self.assertTrue(
pl.is_too_broad("C:\\users\\A", home="C:\\Users\\a", flavor=ntpath)
)
self.assertTrue(pl.is_too_broad("/", home="/home/a", flavor=posixpath))
self.assertTrue(pl.is_too_broad("/home/a/", home="/home/a", flavor=posixpath))
self.assertFalse(
pl.is_too_broad("/home/a/thumbs", home="/home/a", flavor=posixpath)
)
def test_long_windows_paths_are_refused_without_long_paths(self):
short = "D:\\" + "a" * 100
long = "D:\\" + "a" * 200
self.assertFalse(pl.too_long_for_windows(short, long_paths=False))
self.assertTrue(pl.too_long_for_windows(long, long_paths=False))
self.assertFalse(pl.too_long_for_windows(long, long_paths=True))
class TestSourceKeys(unittest.TestCase):
def test_slug(self):
self.assertEqual(pl.slug("ComfyUI Output"), "comfyui-output")
self.assertEqual(pl.slug("Ünïcödé_Fôlder"), "unicode_folder")
self.assertEqual(pl.slug(""), "source")
self.assertEqual(pl.slug("!!!"), "source")
self.assertEqual(len(pl.slug("x" * 80)), 24)
def test_keys_are_a_slug_plus_a_hash(self):
key = pl.source_key("D:\\AI\\Output", "d:\\ai\\output", {})
self.assertRegex(key, r"^output-[0-9a-f]{8}$")
self.assertRegex(pl.source_key("D:\\", "d:\\", {}), r"^source-[0-9a-f]{8}$")
self.assertRegex(
pl.source_key("\\\\nas\\share\\Out", "\\\\nas\\share\\out", {}),
r"^out-[0-9a-f]{8}$",
)
def test_a_taken_key_grows_its_hash(self):
first = pl.source_key("/a/out", "/a/out", {})
taken = {first: "/someone/else"}
second = pl.source_key("/a/out", "/a/out", taken)
self.assertRegex(second, r"^out-[0-9a-f]{12}$")
taken[second] = "/another"
self.assertRegex(
pl.source_key("/a/out", "/a/out", taken), r"^out-[0-9a-f]{16}$"
)
self.assertEqual(pl.source_key("/a/out", "/a/out", {first: "/a/out"}), first)
def test_lookup_by_spelling_identity_then_file_id(self):
keys = {
"out-0123abcd": {
"path": "D:\\Out",
"identity": "d:\\out",
"dev_ino": [1, 2],
},
"lib-0123abcd": {"path": "E:\\Lib", "identity": "e:\\lib", "dev_ino": None},
}
self.assertEqual(pl.find_key(keys, "D:\\Out", "x", None), "out-0123abcd")
self.assertEqual(pl.find_key(keys, "Z:\\Out", "d:\\out", None), "out-0123abcd")
# A letter spelling and its UNC spelling: the same file id.
self.assertEqual(
pl.find_key(keys, "\\\\nas\\out", "\\\\nas\\out", (1, 2)), "out-0123abcd"
)
self.assertIsNone(pl.find_key(keys, "F:\\New", "f:\\new", (9, 9)))
class TestMarker(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.folder = self._tmp.name
def test_init_creates_once_and_adopts_after(self):
first = pl.init_folder(self.folder, created_by_pm=True)
self.assertEqual(pl.init_folder(self.folder), first)
marker = pl.read_marker(self.folder)
self.assertEqual(marker["id"], first)
self.assertTrue(marker["created_by_pm"])
self.assertEqual(marker["version"], 1)
def test_writes_merge_what_another_writer_added(self):
pl.init_folder(self.folder)
pl.write_marker(
self.folder, lambda m: m["keys"].update({"a-0123abcd": {"install": "1"}})
)
pl.write_marker(
self.folder, lambda m: m["keys"].update({"b-0123abcd": {"install": "2"}})
)
self.assertEqual(
sorted(pl.read_marker(self.folder)["keys"]), ["a-0123abcd", "b-0123abcd"]
)
# No temp file is left beside it.
self.assertEqual(os.listdir(self.folder), [pl.MARKER_NAME])
def test_two_processes_adding_keys_at_once_keep_every_key(self):
"""Installs sharing one folder (bot review, P2): the read-modify-write
of the marker is locked across processes, so no writer drops another's
key. Each child widens the window by pausing inside ``mutate``."""
import subprocess
pl.init_folder(self.folder)
child = (
"import sys, time\n"
"sys.path.insert(0, sys.argv[1])\n"
"from py.api import preview_location as pl\n"
"folder, tag, start = sys.argv[2], sys.argv[3], float(sys.argv[4])\n"
"while time.time() < start: time.sleep(0.001)\n"
"for n in range(8):\n"
" def add(m, n=n):\n"
" time.sleep(0.01)\n"
" m['keys'][f'{tag}-{n:08x}'] = {'install': tag}\n"
" pl.write_marker(folder, add)\n"
)
import time
start = str(time.time() + 1.0)
procs = [
subprocess.Popen( # nosec B603 - this interpreter, fixed arguments
[sys.executable, "-c", child, str(REPO), self.folder, tag, start]
)
for tag in ("a", "b", "c")
]
for proc in procs:
self.assertEqual(proc.wait(timeout=60), 0)
keys = pl.read_marker(self.folder)["keys"]
self.assertEqual(len(keys), 24, sorted(keys))
self.assertEqual(os.listdir(self.folder), [pl.MARKER_NAME])
def test_a_lock_left_by_a_crashed_writer_is_taken_over_when_stale(self):
pl.init_folder(self.folder)
lock = os.path.join(self.folder, pl.MARKER_LOCK_NAME)
with open(lock, "w", encoding="utf-8") as handle:
handle.write("crashed")
old = pl.time.time() - pl.MARKER_LOCK_STALE - 5
os.utime(lock, (old, old))
pl.write_marker(self.folder, lambda m: m["keys"].update({"a-0123abcd": {}}))
self.assertIn("a-0123abcd", pl.read_marker(self.folder)["keys"])
self.assertFalse(os.path.exists(lock))
def test_a_held_lock_times_out_without_writing(self):
pl.init_folder(self.folder)
lock = os.path.join(self.folder, pl.MARKER_LOCK_NAME)
with open(lock, "w", encoding="utf-8") as handle:
handle.write("held")
with patch.object(pl, "MARKER_LOCK_TIMEOUT", 0.05):
with self.assertRaises(TimeoutError):
pl.write_marker(self.folder, lambda m: m["keys"].update({"x": {}}))
self.assertTrue(os.path.exists(lock)) # never taken from its holder
self.assertEqual(pl.read_marker(self.folder)["keys"], {})
def test_a_folder_without_the_marker_is_missing(self):
self.assertEqual(pl.folder_state(self.folder, "x")[:2], (False, "missing"))
missing = os.path.join(self.folder, "gone")
self.assertEqual(pl.folder_state(missing, "x")[:2], (False, "missing"))
marker_id = pl.init_folder(self.folder)
self.assertEqual(pl.folder_state(self.folder, marker_id)[:2], (True, None))
self.assertEqual(
pl.folder_state(self.folder, "other")[:2], (False, "marker-mismatch")
)
@unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks")
def test_a_linked_folder_is_not_available(self):
real = os.path.join(self.folder, "real")
os.mkdir(real)
marker_id = pl.init_folder(real)
link = os.path.join(self.folder, "link")
try:
os.symlink(real, link, target_is_directory=True)
except OSError:
self.skipTest("symlinks not permitted")
self.assertEqual(pl.folder_state(link, marker_id)[:2], (False, "invalid"))
class TestValidation(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.base = os.path.realpath(self._tmp.name)
self.source = os.path.join(self.base, "output")
self.node = os.path.join(self.base, "node")
os.mkdir(self.source)
os.mkdir(self.node)
self.context = pl.Context(
roots=(self.source,),
node_folder=self.node,
default_path=os.path.join(self.node, "data", "thumbnails"),
home=os.path.join(self.base, "home"),
environ={},
long_paths=True,
folder_paths=None,
)
def problem(self, path, purpose="move", **changes):
context = self.context
if changes:
context = pl.Context(**{**context.__dict__, **changes})
return pl.location_problem(path, purpose=purpose, context=context)
def test_an_empty_new_folder_beside_the_sources_is_accepted(self):
self.assertIsNone(self.problem(os.path.join(self.base, "thumbs")))
os.mkdir(os.path.join(self.base, "empty"))
Path(self.base, "empty", "Thumbs.db").write_bytes(b"")
self.assertIsNone(self.problem(os.path.join(self.base, "empty")))
def test_codes(self):
cases = {
"relative": "location-not-absolute",
os.path.join(self.source, "thumbs"): "location-served",
os.path.join(self.node, "thumbs"): "location-node-folder",
self.base: "location-contains-source",
os.path.join(self.base, "no", "parent"): "location-parent-missing",
}
for path, code in cases.items():
with self.subTest(path=path):
self.assertEqual(self.problem(path), code)
def test_the_default_may_sit_in_the_node_folder_and_make_its_parents(self):
self.assertIsNone(self.problem(self.context.default_path))
def test_the_folder_in_use_is_the_same(self):
folder = os.path.join(self.base, "thumbs")
os.mkdir(folder)
self.assertEqual(self.problem(folder, current_path=folder), "location-same")
def test_a_folder_inside_the_folder_in_use_is_refused(self):
# Codex P2 on #592: the old key folders are removed after the move,
# and with them a target nested inside one of them.
folder = os.path.join(self.base, "thumbs")
os.mkdir(folder)
pl.init_folder(folder)
for nested in (
os.path.join(folder, "output-0123abcd", "nested"),
os.path.join(folder, "beside"),
):
with self.subTest(nested=nested):
self.assertEqual(
self.problem(nested, current_path=folder),
"location-inside-current",
)
def test_a_folder_with_other_files_is_not_empty_unless_it_is_ours(self):
folder = os.path.join(self.base, "full")
os.mkdir(folder)
Path(folder, "photo.jpg").write_bytes(b"x")
self.assertEqual(self.problem(folder), "location-not-empty")
pl.init_folder(folder)
self.assertIsNone(self.problem(folder))
@unittest.skipIf(os.name == "nt" or os.geteuid() == 0, "needs POSIX modes")
def test_a_folder_that_refuses_writes(self):
folder = os.path.join(self.base, "ro")
os.mkdir(folder)
os.chmod(folder, 0o500)
self.addCleanup(os.chmod, folder, 0o700)
self.assertEqual(self.problem(folder), "location-not-writable")
def test_a_stat_that_does_not_answer_is_unavailable(self):
release = threading.Event()
self.addCleanup(release.set)
def stuck(*_args):
release.wait(10)
with patch.object(pl, "_filesystem_problem", stuck):
code = pl.location_problem(
os.path.join(self.base, "thumbs"),
purpose="move",
context=self.context,
timeout=0.1,
)
self.assertEqual(code, "location-unavailable")
def test_a_long_windows_path(self):
path = os.path.join(self.base, "t" * 220)
self.assertEqual(self.problem(path, long_paths=False), "location-path-too-long")
def test_every_code_has_a_message_without_a_path(self):
for code, (status, text) in pl.MESSAGES.items():
with self.subTest(code=code):
self.assertIn(status, (400, 409, 507))
self.assertTrue(text.endswith("."))
class TestNoLegacySaveCallers(unittest.TestCase):
def test_save_to_file_has_no_callers(self):
offenders = []
for folder in ("py", "utils", "database"):
for path in (REPO / folder).rglob("*.py"):
for number, line in enumerate(
path.read_text(encoding="utf-8").splitlines(), 1
):
code = line.split("#", 1)[0]
if (
re.search(r"\bsave_to_file\(", code)
and "def save_to_file" not in code
):
if "PromptManagerConfig.save_to_file('" in line:
continue # its own docstring example
offenders.append(f"{path.relative_to(REPO)}:{number}")
self.assertEqual(offenders, [])
try:
from tests.test_api_images import ImageAPITestCase
except ImportError: # discovered with tests/ as the top-level directory
from test_api_images import ImageAPITestCase
class TestLocationRoutes(ImageAPITestCase):
async def asyncSetUp(self):
await super().asyncSetUp()
self.addCleanup(preview_store.configure, None)
def use_folder(self):
folder = self.comfy_dir / "thumbs"
folder.mkdir()
marker_id = pl.init_folder(str(folder), created_by_pm=True)
preview_store.configure(
{"mode": "folder", "path": str(folder), "id": marker_id}
)
return folder
async def test_settings_get_names_the_location_with_its_path(self):
folder = self.use_folder()
resp = await self.client.get("/prompt_manager/settings")
location = (await resp.json())["thumbnail_location"]
self.assertEqual(location["mode"], "folder")
self.assertEqual(location["path"], str(folder))
self.assertTrue(location["available"])
self.assertIsNone(location["problem"])
self.assertIn("default_path", location)
async def test_settings_post_never_changes_the_location(self):
resp = await self.client.post(
"/prompt_manager/settings",
json={"thumbnail_location": {"mode": "folder", "path": "/x"}},
)
self.assertEqual(resp.status, 409)
body = await resp.json()
self.assertEqual(body["code"], "location-needs-move")
self.assertNotIn("/x", body["error"])
async def test_status_has_the_location_but_no_path(self):
folder = self.use_folder()
resp = await self.client.get("/prompt_manager/images/thumbnails/status")
body = await resp.text()
self.assertNotIn(str(folder), body)
self.assertEqual(
json.loads(body)["location"],
{"mode": "folder", "available": True, "problem": None},
)
async def test_a_source_inside_or_around_the_folder_is_refused(self):
from py.api import sources
folder = self.use_folder()
inside = folder / "sub"
inside.mkdir()
with patch(
"py.config.GalleryConfig.gallery_root_problem",
classmethod(lambda cls, path: (None, "")),
):
for path in (inside, folder, self.comfy_dir):
with self.subTest(path=path):
code, reason = sources.source_problem(str(path))
self.assertEqual(code, "holds-thumbnails")
self.assertEqual(reason, pl.HOLDS_THUMBNAILS_REASON)
self.assertEqual(sources.source_problem(str(self.output_dir))[0], None)
async def test_a_library_move_into_the_folder_is_refused(self):
from py.api import library_location as routes
folder = self.use_folder()
self.assertTrue(routes._inside_thumbnail_folder(str(folder / "lib")))
self.assertFalse(routes._inside_thumbnail_folder(str(self.output_dir)))
def test_startup_makes_a_location_inside_a_source_invalid(self):
inside = self.output_dir / "thumbs"
inside.mkdir()
marker_id = pl.init_folder(str(inside))
from py.config import PromptManagerConfig
value = {"mode": "folder", "path": str(inside), "id": marker_id}
with patch.object(PromptManagerConfig, "THUMBNAIL_LOCATION", value):
location = self.api.start_preview_location()
self.assertEqual((location.available, location.problem), (False, "invalid"))
if __name__ == "__main__":
unittest.main()
+9 -2
View File
@@ -363,11 +363,11 @@ class TestWorkflowStorageOutcomes(WorkflowStorageTestCase):
prompt_id = self.old_library(count=4)
conn = self.api.db.model.get_connection()
with conn:
conn.execute(
image_id = conn.execute(
"INSERT INTO generated_images (prompt_id, image_path, filename, "
"file_path, workflow_data) VALUES (?, '/x/b', 'b', '/x/b', x'00')",
(prompt_id,),
)
).lastrowid
store_text(conn, "a document no record references")
_plan, _summary, job = await self.start()
await self.finish(job)
@@ -377,6 +377,13 @@ class TestWorkflowStorageOutcomes(WorkflowStorageTestCase):
"Rewrote 4 image records; 1 stored value not text, kept as is", log
)
self.assertIn("Removed 1 unused workflow document", log)
# #586: the kept value has its row, in the review's wording.
self.assertEqual(
[row["detail"] for row in job.outcomes["protected"]],
[f"Image record {image_id}: workflow, stored value not text; kept as is"],
)
report = job.report()["outcomes"]["protected"]
self.assertEqual((report["count"], report["truncated"]), (1, False))
async def reclaim_with(self, **patches):
self.old_library(count=4)
+5 -5
View File
@@ -908,20 +908,20 @@ class TestWorkspaceImages(ImageAPITestCase):
async def test_one_cards_parse_error_spares_the_page(self):
prompt = self._save_prompt("a fox")
for name, mtime in (("bad.png", 200), ("ok.png", 100)):
# Cards are read on the image pool (#523), in no fixed order: the
# parser fails on bad.png's own record (seed 6), not on a call count.
for name, mtime, seed in (("bad.png", 200, 6), ("ok.png", 100, 5)):
path = records_png(
self.output_dir / name, seeded_records("a fox", 5), mtime
self.output_dir / name, seeded_records("a fox", seed), mtime
)
self.api.db.link_image_to_prompt(prompt, str(path))
real_parse = header_params_of_images()
def parsing(info):
if parsing.calls == 0:
parsing.calls += 1
if '"seed": 6' in info["prompt"]:
raise RuntimeError("unexpected parser bug")
return real_parse(info)
parsing.calls = 0
with (
patch("py.api.images.header_params", side_effect=parsing),
self.assertLogs(self.api.logger, "WARNING") as logged,
+12
View File
@@ -146,6 +146,18 @@ class TestPreviewQueueRefusedAtShutdown(unittest.TestCase):
class TestMapParallelRefusedAtShutdown(unittest.TestCase):
def setUp(self):
# The job pool is long-lived (#523): start each test without one, so
# the patched pool class is the one created.
self.assertTrue(parallel.shutdown_job_pool(30))
self.addCleanup(parallel.shutdown_job_pool, 30)
def test_the_refused_pool_is_not_reused(self):
with mock.patch.object(parallel, "ThreadPoolExecutor", _ShutPool):
parallel.map_parallel(lambda n: n, [1, 2], 2)
self.assertFalse(parallel.job_pool_started())
self.assertEqual(parallel.map_parallel(lambda n: n + 1, [1, 2], 2), [2, 3])
def test_a_shut_pool_runs_the_items_on_the_calling_thread(self):
records = _capture(self, "prompt_manager.parallel")
with mock.patch.object(parallel, "ThreadPoolExecutor", _ShutPool):
+241
View File
@@ -0,0 +1,241 @@
"""Image work runs on PromptManager's own pools, never the default executor (#523).
glibc keeps each thread's malloc arena at its high-water mark, so image work
spread over the default executor's threads (min(32, cpus + 4)) kept an
image-sized buffer per thread. Interactive image work (card facts, inspector
reads, view previews) runs on the fixed interactive image pool
(``pm-image``); jobs (scan metadata, thumbnail jobs, capture previews) on the
long-lived Worker threads pool (``pm-worker``). After a scan or thumbnail
job, glibc's free pages are released once (``malloc_trim``).
"""
import asyncio
import threading
from unittest.mock import patch
import py.api as api_package
import py.api.images as images_module
import py.api.inspection_service as inspection_service
import py.api.thumbnail_auto as auto
from utils import parallel
try:
from tests.test_api_images import make_png
from tests.test_thumbnail_auto import WAIT, AutoThumbnailCase, _entry
except ImportError: # discovered with tests/ as the top-level directory
from test_api_images import make_png
from test_thumbnail_auto import WAIT, AutoThumbnailCase, _entry
IMAGE_THREAD = auto.POOL_PREFIX + "_"
JOB_THREAD = parallel.JOB_THREAD_PREFIX + "_"
class ImagePoolCase(AutoThumbnailCase):
async def asyncSetUp(self):
await super().asyncSetUp()
self.assertTrue(parallel.shutdown_job_pool(WAIT))
self.addCleanup(parallel.shutdown_job_pool, WAIT)
self.threads = []
self.lock = threading.Lock()
def spy(self, owner, name):
"""Record the thread every call of ``owner.name`` runs on."""
original = getattr(owner, name)
def recording(*args, **kwargs):
with self.lock:
self.threads.append(threading.current_thread().name)
return original(*args, **kwargs)
patcher = patch.object(owner, name, recording)
patcher.start()
self.addCleanup(patcher.stop)
def assert_threads(self, prefix):
self.assertTrue(self.threads, "the spied call never ran")
self.assertTrue(
all(name.startswith(prefix) for name in self.threads), self.threads
)
async def run_job(self, path, body=None):
response = await self.client.post(path, json=body or {})
self.assertEqual(response.status, 202, await response.text())
job = self.api._jobs.get((await response.json())["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
return job
class TestInteractiveImagePool(ImagePoolCase):
async def test_workspace_card_facts_are_read_on_the_image_pool(self):
for index in range(5):
make_png(self.output_dir / f"c{index}.png", size=(32, 32))
self.spy(images_module, "_card_header")
response = await self.client.get(
"/prompt_manager/images/output?mode=workspace&limit=100"
)
self.assertEqual(response.status, 200, await response.text())
images = (await response.json())["images"]
self.assertEqual([image["width"] for image in images], [32] * 5)
self.assert_threads(IMAGE_THREAD)
self.assertEqual(len(self.threads), 5)
async def test_cached_card_facts_need_no_image_pool_trip(self):
make_png(self.output_dir / "c.png", size=(32, 32))
url = "/prompt_manager/images/output?mode=workspace&limit=100"
await (await self.client.get(url)).read()
self.spy(images_module, "_card_header")
response = await self.client.get(url)
self.assertEqual((await response.json())["images"][0]["width"], 32)
self.assertEqual(self.threads, [])
async def test_the_inspector_reads_on_the_image_pool(self):
make_png(self.output_dir / "i.png", size=(32, 32))
self.spy(inspection_service, "read_inspection_file")
response = await self.client.get("/prompt_manager/images/inspect/i.png?root=0")
self.assertEqual(response.status, 200, await response.text())
self.assert_threads(IMAGE_THREAD)
async def test_view_previews_and_card_reads_share_three_threads(self):
names = [f"v{i}.png" for i in range(auto.ON_DEMAND_LIMIT + 3)]
for name in names:
make_png(self.output_dir / name, size=(64, 64))
calls = []
original = self.api._generate_one
def recording(*args, **kwargs):
calls.append(threading.current_thread().name)
return original(*args, **kwargs)
self.api._generate_one = recording
for name in names:
url = _entry(self.api, self.output_dir, name)["thumbnail_url"]
await (await self.client.get(url)).read()
self.assertEqual(len(calls), len(names))
self.assertTrue(all(name.startswith(IMAGE_THREAD) for name in calls), calls)
self.assertLessEqual(len(self.api._image_pool_threads()), auto.IMAGE_POOL_SIZE)
def test_map_image_keeps_order_and_runs_inline_on_a_pool_thread(self):
self.assertEqual(self.api._map_image(lambda n: n * 2, [3, 1, 2]), [6, 2, 4])
nested = self.api._image_pool().submit(
self.api._map_image, lambda _: threading.current_thread().name, [1]
)
(name,) = nested.result(WAIT)
self.assertTrue(name.startswith(IMAGE_THREAD), name)
def test_map_image_after_a_close_runs_on_the_calling_thread(self):
pool = self.api._image_pool()
pool.shutdown(wait=True)
here = threading.current_thread().name
with patch.object(self.api, "_image_pool", return_value=pool):
names = self.api._map_image(
lambda _: threading.current_thread().name, [1, 2]
)
self.assertEqual(names, [here, here])
async def test_after_the_final_close_no_image_thread_starts(self):
await asyncio.get_running_loop().run_in_executor(
None, lambda: self.api.close_auto_thumbnails(final=True)
)
self.assertIsNone(self.api._image_pool())
here = threading.current_thread().name
names = self.api._map_image(lambda _: threading.current_thread().name, [1])
self.assertEqual(names, [here])
name = await self.api._run_image(lambda: threading.current_thread().name)
self.assertFalse(name.startswith(IMAGE_THREAD), name)
self.assertEqual(self.api._image_pool_threads(), [])
def test_map_image_runs_items_a_shutdown_cancelled_here(self):
entered = threading.Barrier(auto.IMAGE_POOL_SIZE + 1)
release = threading.Event()
self.addCleanup(release.set)
pool = self.api._image_pool()
for _ in range(auto.IMAGE_POOL_SIZE): # occupy every pool thread
pool.submit(lambda: (entered.wait(WAIT), release.wait(WAIT)))
entered.wait(WAIT)
here = threading.current_thread().name
results, submitted = {}, threading.Event()
class Counting:
calls = 0
def submit(self, *args):
future = pool.submit(*args)
Counting.calls += 1
if Counting.calls == 2:
submitted.set()
return future
def call():
with patch.object(self.api, "_image_pool", return_value=Counting()):
results["names"] = self.api._map_image(
lambda _: threading.current_thread().name, [1, 2]
)
caller = threading.Thread(target=call, name="map-image-caller")
caller.start()
# The caller's two items are queued behind the busy threads; the
# shutdown cancels them and the caller runs them itself.
self.assertTrue(submitted.wait(WAIT))
pool.shutdown(wait=False, cancel_futures=True)
caller.join(WAIT)
release.set()
self.assertFalse(caller.is_alive())
self.assertEqual(results["names"], ["map-image-caller"] * 2)
self.assertNotEqual(here, "map-image-caller")
async def test_run_image_after_a_close_uses_the_default_executor(self):
pool = self.api._image_pool()
pool.shutdown(wait=True)
with patch.object(self.api, "_image_pool", return_value=pool):
name = await self.api._run_image(lambda: threading.current_thread().name)
self.assertFalse(name.startswith(IMAGE_THREAD), name)
class TestJobPool(ImagePoolCase):
def test_scan_metadata_is_read_on_the_job_pool(self):
paths = [
make_png(self.output_dir / f"s{i}.png", size=(16, 16)) for i in range(4)
]
self.spy(self.api, "_extract_comfyui_metadata")
results = self.api._extract_batch_metadata_sync(paths)
self.assertEqual([path for path, _ in results], paths)
self.assert_threads(JOB_THREAD)
async def test_thumbnail_job_writes_on_the_job_pool_and_trims_after(self):
for index in range(3):
make_png(self.output_dir / f"t{index}.png", size=(64, 64))
self.spy(self.api, "_generate_one")
with patch.object(api_package, "release_free_memory") as trim:
job = await self.run_job("/prompt_manager/jobs/thumbnails")
self.assertEqual(job.state, "finished")
self.assert_threads(JOB_THREAD)
trim.assert_called_once_with()
async def test_scan_job_trims_after_it_finishes(self):
make_png(self.output_dir / "s.png", size=(16, 16))
states = []
def trim():
states.append(self.api._jobs.active("scan"))
return True
with patch.object(api_package, "release_free_memory", side_effect=trim):
job = await self.run_job("/prompt_manager/jobs/scan")
self.assertEqual(job.state, "finished")
self.assertGreaterEqual(len(states), 1)
self.assertIsNone(states[0], "trimmed after the scan finished")
async def test_a_failing_trim_never_fails_the_job(self):
make_png(self.output_dir / "t.png", size=(64, 64))
with patch.object(
api_package, "release_free_memory", side_effect=RuntimeError("no")
):
job = await self.run_job("/prompt_manager/jobs/thumbnails")
self.assertEqual(job.state, "finished")
async def test_capture_previews_write_on_the_job_pool(self):
path = make_png(self.output_dir / "a.png", size=(64, 64))
self.spy(self.api, "_generate_one")
self.assertEqual(self.api.queue_capture_thumbnail(str(path)), "queued")
await self._idle()
self.assert_threads(JOB_THREAD)
+67
View File
@@ -136,6 +136,11 @@ def cleanup_package(package, require_stopped=True):
if registration is not None:
assert registration.dispose(3), "package history worker leaked"
close_every_database()
# The package's exit actions outlive it otherwise: at interpreter exit
# they would import from a package no longer in sys.modules (#580).
exit_signal = getattr(package, "_exit_signal", None)
if exit_signal is not None:
exit_signal.reset()
sys.modules.pop(package.__name__, None)
@@ -402,6 +407,29 @@ class TestConsoleBanner(unittest.TestCase):
self.assertNotIn("Loaded:", banner)
class TestFixtureExitActions(unittest.TestCase):
"""A disposed fixture package runs nothing at interpreter exit (#580)."""
def test_a_disposed_package_leaves_no_exit_action(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.addCleanup(close_every_database)
output_dir = os.path.join(tmp.name, "output")
os.makedirs(output_dir)
name = "prompt_manager_pkg_exit_actions"
self.addCleanup(sys.modules.pop, name, None)
with contextlib.redirect_stdout(io.StringIO()):
with load_package(name, os.path.join(tmp.name, "p.db"), output_dir) as (
package,
_,
):
signal = package._exit_signal
self.assertTrue(signal._on_exit, "the package installs exit actions")
self.assertEqual(signal._on_exit, [])
class PriorFileHandler(logging.FileHandler):
"""A caller-owned sink that stays closed once its owner closes it.
@@ -771,6 +799,45 @@ class TestPackageTeardown(PackageTeardownCase):
self.assertIsNone(seen["worker"])
self.assertTrue(all(result.values()), result)
def test_teardown_stops_the_job_pool_once_a_job_used_it(self):
"""#523: the long-lived job pool is a teardown part of its own."""
package = self.load()
parallel = sys.modules[package.__name__ + ".utils.parallel"]
self.assertEqual(parallel.map_parallel(lambda n: n * 2, [1, 2], 2), [2, 4])
# This package's own pool (the suite's top-level one may also run).
threads = list(parallel._pool[1]._threads)
self.assertTrue(threads)
result = package._stop_background_work()
self.assertEqual(list(result), ["monitor", "thumbnails", "workers", "tracker"])
self.assertTrue(all(result.values()), result)
self.assertFalse(parallel.job_pool_started())
self.assertFalse(any(t.is_alive() for t in threads))
def test_teardown_reports_a_job_thread_past_its_bound(self):
package = self.load(require_stopped=False)
parallel = sys.modules[package.__name__ + ".utils.parallel"]
entered, release = threading.Event(), threading.Event()
def hold(_):
entered.set()
release.wait(30)
caller = threading.Thread(target=parallel.map_parallel, args=(hold, [1], 1))
caller.start()
self.addCleanup(caller.join, 30)
self.addCleanup(release.set)
self.assertTrue(entered.wait(30))
with (
mock.patch.object(parallel, "JOB_POOL_JOIN_SECONDS", 0.05),
self.assertLogs("prompt_manager.init", "WARNING") as logs,
):
result = package._stop_background_work()
self.assertFalse(result["workers"])
self.assertTrue(result["tracker"])
self.assertRegex("\n".join(logs.output), r"not stopped in time .*workers")
def test_teardown_kills_ffmpeg_and_stops_the_preview_worker(self):
package = self.load()
held = self.hold_a_preview(package, advance=4.0, kill_releases=True)
+30
View File
@@ -490,6 +490,36 @@ class TestJobOutcomesAndLog(unittest.TestCase):
with self.assertRaises(ValueError):
job.record_counted("bogus", 1)
def test_record_counted_complete_keeps_every_row_for_the_report(self):
# #550: a repair's record of each change is never cut at the cap.
job = Job("maintenance", "Pair category and tags", groups=("changed", "errors"))
rows = [
{"path": "", "detail": f"Prompt {i}"} for i in range(MAX_OUTCOME_ROWS + 5)
]
job.record_counted("changed", len(rows), rows, complete=True)
self.assertEqual(len(job.outcomes["changed"]), MAX_OUTCOME_ROWS)
report = job.report()["outcomes"]
self.assertEqual(len(report["changed"]["rows"]), MAX_OUTCOME_ROWS + 5)
self.assertEqual(
report["changed"]["rows"][-1]["detail"], f"Prompt {MAX_OUTCOME_ROWS + 4}"
)
self.assertFalse(report["changed"]["truncated"])
self.assertEqual(report["errors"], {"count": 0, "rows": [], "truncated": False})
def test_record_into_a_complete_group_keeps_the_row_for_the_report(self):
# #586: a group kept whole stays whole when later rows are recorded.
job = Job("duplicate_cleanup", "Remove", groups=("kept",))
rows = [
{"path": f"f{i}.png", "detail": "Kept"} for i in range(MAX_OUTCOME_ROWS)
]
job.record_counted("kept", len(rows), rows, complete=True)
job.record("kept", {"path": "late.png", "detail": "Kept copy"}, "= late.png")
self.assertEqual(len(job.outcomes["kept"]), MAX_OUTCOME_ROWS)
report = job.report()["outcomes"]["kept"]
self.assertEqual(report["count"], MAX_OUTCOME_ROWS + 1)
self.assertEqual(report["rows"][-1]["path"], "late.png")
self.assertFalse(report["truncated"])
def test_thumbnail_job_groups(self):
self.assertEqual(
jobs_module.THUMBNAIL_GROUP_NAMES, ("generated", "skipped", "errors")
+66 -5
View File
@@ -1061,17 +1061,78 @@ class LoraImportRefusalTests(LoraImportAPITestCase):
job = await self._failed_job()
self.assertEqual(job.scope["failure"], "backup-failed")
async def test_cancel_right_after_the_start_writes_nothing(self):
async def test_cancel_while_it_stages_writes_nothing(self):
# #575: held at a download, so the cancel lands while it stages.
self._two_loras()
before = _snapshot(self.api.db)
resp, plan = await self._post(PREVIEW)
resp, body = await self._post(APPLY, {"token": plan["token"]})
job = self.api._jobs.get(body["job"]["id"])
self.api._jobs.cancel(job.id) # at once, while it stages
self.gate = threading.Event()
_plan, job = await self._apply_reviewed()
loop = asyncio.get_running_loop()
self.assertTrue(await loop.run_in_executor(None, self.downloading.wait, 10))
self.assertTrue(self.api._jobs.cancel(job.id))
self.gate.set()
await job.task
self.assertEqual(job.state, "cancelled")
self.assertEqual(_snapshot(self.api.db), before)
async def test_a_cancel_accepted_as_staging_ends_writes_nothing(self):
# #575: staging already passed its last cancel check and returned; a
# cancel accepted before the job resumes still stops it before the
# commit, so a request answered "cancellation_requested" holds.
self._two_loras()
before = _snapshot(self.api.db)
staged, release = threading.Event(), threading.Event()
real_stage = lora_import.stage
def held_stage(*args, **kwargs):
result = real_stage(*args, **kwargs)
staged.set()
release.wait(10)
return result
loop = asyncio.get_running_loop()
with patch.object(lora_import, "stage", held_stage):
_plan, job = await self._apply_reviewed()
try:
self.assertTrue(await loop.run_in_executor(None, staged.wait, 10))
self.assertTrue(self.api._jobs.cancel(job.id))
finally:
release.set()
await job.task
self.assertEqual(job.state, "cancelled", job.log)
self.assertEqual(_snapshot(self.api.db), before)
async def test_a_cancel_after_the_commit_point_is_refused(self):
# #575: once staging is done the job is no longer cancellable; a
# late cancel is refused (the route answers it as not requested) and
# the job truthfully ends "finished" with the import written.
self._two_loras()
before = _snapshot(self.api.db)
replacing, release = threading.Event(), threading.Event()
real_replace = self.api.db.replace_category_prompts
def held_replace(*args, **kwargs):
replacing.set()
release.wait(10)
return real_replace(*args, **kwargs)
loop = asyncio.get_running_loop()
with patch.object(self.api.db, "replace_category_prompts", held_replace):
_plan, job = await self._apply_reviewed()
try:
self.assertTrue(await loop.run_in_executor(None, replacing.wait, 10))
self.assertFalse(job.summary()["cancellable"])
resp = await self.client.request(
"POST", f"/prompt_manager/jobs/{job.id}/cancel"
)
body = json.loads(await resp.text())
finally:
release.set()
await job.task
self.assertFalse(body["cancellation_requested"])
self.assertEqual(job.state, "finished", job.log)
self.assertNotEqual(_snapshot(self.api.db), before)
def plan_title():
return lora_import.JOB_TITLE
+539
View File
@@ -0,0 +1,539 @@
"""Pair category and tags repair (#550): the ``pair_metadata`` maintenance plan.
Before #546 a Pair node run saved its category and tags on the saved pair
only, so the positive prompt it created stayed Uncategorized and untagged.
``pair_metadata`` reviews those prompts and fills only their empty fields
from the prompt's non-archived pairs when every pair with a value agrees.
It never touches a negative, a saved pair or ``updated_at``, and a prompt
the Pair node did not create, that was edited, archived, or whose pairs
disagree, is kept (``550-design.md``).
"""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from database import maintenance # noqa: E402
from database import pair_metadata_repair as repair # noqa: E402
try:
from tests.test_maintenance_plans import MaintenanceDatabaseCase # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from test_maintenance_plans import MaintenanceDatabaseCase # noqa: E402
OPERATION = "pair_metadata"
class PairMetadataCase(MaintenanceDatabaseCase):
def pair(self, positive, negative="", **metadata):
"""A pre-#546 Pair save: the pair gets the metadata, the prompt none."""
pair_id = self.db.save_prompt_pair(positive, negative, **metadata)
return pair_id, self.positive_of(pair_id)
def positive_of(self, pair_id):
return self.sql(
"SELECT positive_prompt_id FROM prompt_pairs WHERE id=?", pair_id
)[0][0]
def sql(self, statement, *args):
with self.db.model.get_connection() as conn:
return [tuple(row) for row in conn.execute(statement, args)]
def run_sql(self, statement, *args):
with self.db.model.get_connection() as conn:
conn.execute(statement, args)
def category(self, prompt_id):
return self.sql("SELECT category FROM prompts WHERE id=?", prompt_id)[0][0]
def tags(self, prompt_id):
return sorted(
row[0]
for row in self.sql(
"SELECT t.name FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.prompt_id=?",
prompt_id,
)
)
def preview(self, operation=OPERATION, roots=None):
return self.db.preview_maintenance(operation, [])
def apply(self, draft):
return self.db.apply_maintenance(
draft.operation, draft.fingerprint, draft.targets, []
)
def edit_stamp(self, prompt_id, updated_at="2099-01-01T00:00:00+00:00"):
self.run_sql(
"UPDATE prompts SET updated_at=? WHERE id=?", updated_at, prompt_id
)
def details(self, draft):
return [(row["label"], row["detail"]) for row in draft.details]
class TestClassification(PairMetadataCase):
def test_fills_empty_category_and_tags_from_the_pair(self):
pair_id, prompt = self.pair(
"a cat on a sofa", "blurry", category="portrait", tags=["b", "a"]
)
draft = self.preview()
self.assertEqual(draft.operation, OPERATION)
self.assertEqual(draft.targets, [[prompt, True, True]])
self.assertEqual(draft.scope["count"], 1)
self.assertEqual(
draft.scope["label"], "1 prompt to fill: 1 category, 1 tag set"
)
self.assertEqual(draft.protected, {"count": 0, "reasons": []})
self.assertEqual(
draft.media,
{
"deletes_files": False,
"label": "No image file or saved pair is changed.",
},
)
self.assertIn(
(
"a cat on a sofa",
f"Fills category “portrait” and 2 tags: a, b (pair {pair_id})",
),
self.details(draft),
)
self.assertEqual(draft.details[-1]["label"], "Record")
self.assertEqual(draft.details[-1]["detail"], repair.RECORD_NOTE)
def test_empty_plan(self):
self.save("plain prompt")
draft = self.preview()
self.assertEqual(draft.targets, [])
self.assertEqual(draft.scope["count"], 0)
self.assertEqual(
draft.scope["label"], "No Pair prompts are missing a category or tags"
)
self.assertEqual(draft.details, [])
def test_prompt_already_complete_is_not_counted(self):
_pair, prompt = self.pair("done", category="portrait", tags=["a"])
self.run_sql("UPDATE prompts SET category='portrait' WHERE id=?", prompt)
self.db.set_prompt_tags(prompt, ["a"])
self.edit_stamp(
prompt, self.sql("SELECT created_at FROM prompts WHERE id=?", prompt)[0][0]
)
self.assertEqual(self.preview().scope["count"], 0)
def test_whitespace_category_is_blank_on_prompt_and_pair(self):
_p1, blank_prompt = self.pair("ws prompt", category=" portrait ")
self.run_sql("UPDATE prompts SET category=' ' WHERE id=?", blank_prompt)
_p2, blank_pair = self.pair("ws pair", category=" ")
draft = self.preview()
self.assertEqual(draft.targets, [[blank_prompt, True, False]])
self.assertIn("“portrait”", self.details(draft)[0][1])
self.assertNotIn(blank_pair, [t[0] for t in draft.targets])
def test_mixed_fields_category_fills_while_tags_conflict(self):
_a, prompt = self.pair("mixed", "n1", category="portrait", tags=["a"])
self.pair("mixed", "n2", category="portrait", tags=["b"])
draft = self.preview()
self.assertEqual(draft.targets, [[prompt, True, False]])
self.assertEqual(draft.scope["count"], 1)
self.assertEqual(draft.protected["count"], 1)
self.assertEqual(draft.protected["reasons"], ["1 where pairs disagree"])
self.assertIn(("mixed", "Kept: pairs disagree on tags"), self.details(draft))
def test_mixed_fields_tags_fill_while_category_conflicts(self):
_a, prompt = self.pair("mixed2", "n1", category="portrait", tags=["a"])
self.pair("mixed2", "n2", category="landscape", tags=["a"])
draft = self.preview()
self.assertEqual(draft.targets, [[prompt, False, True]])
self.assertIn(
("mixed2", "Kept: pairs disagree on category: landscape, portrait"),
self.details(draft),
)
def test_category_set_and_tags_empty_fills_tags_only(self):
_a, prompt = self.pair("has cat", category="portrait", tags=["a"])
self.run_sql("UPDATE prompts SET category='mine' WHERE id=?", prompt)
draft = self.preview()
self.assertEqual(draft.targets, [[prompt, False, True]])
def test_pairs_without_a_value_are_ignored(self):
_a, prompt = self.pair("partial", "n1", category="portrait", tags=["a"])
self.pair("partial", "n2") # no category, no tags
self.assertEqual(self.preview().targets, [[prompt, True, True]])
def test_equal_values_across_pairs_fill(self):
_a, prompt = self.pair("same", "n1", category="portrait", tags=["a", "b"])
self.pair("same", "n2", category="portrait", tags=["b", "a"])
self.assertEqual(self.preview().targets, [[prompt, True, True]])
def test_category_is_case_sensitive(self):
self.pair("case", "n1", category="Portrait")
self.pair("case", "n2", category="portrait")
draft = self.preview()
self.assertEqual(draft.targets, [])
self.assertEqual(draft.protected["count"], 1)
def test_edited_prompt_is_protected(self):
cases = {
"later": "2099-01-01T00:00:00+00:00",
"null": None,
"legacy": "2020-01-01 10:00:00",
"garbage": "yesterday",
}
for name, stamp in cases.items():
with self.subTest(name=name):
_a, prompt = self.pair(f"edited {name}", category="portrait")
self.edit_stamp(prompt, stamp)
draft = self.preview()
self.assertNotIn(prompt, [t[0] for t in draft.targets])
self.assertIn(
(
f"edited {name}",
"Kept: edited after it was saved; its empty category or "
"tags may be intentional",
),
self.details(draft),
)
self.run_sql(
"DELETE FROM prompt_pairs WHERE positive_prompt_id=?", prompt
)
def test_first_release_stamps_microseconds_apart_are_never_edited(self):
_a, prompt = self.pair("caa9649f", category="portrait")
self.run_sql(
"UPDATE prompts SET created_at=?, updated_at=? WHERE id=?",
"2026-10-18T10:00:00.000100+00:00",
"2026-10-18T10:00:00.000180+00:00",
prompt,
)
self.run_sql(
"UPDATE prompt_pairs SET created_at=? WHERE positive_prompt_id=?",
"2026-10-18T10:00:00.000400+00:00",
prompt,
)
self.assertEqual(self.preview().targets, [[prompt, True, False]])
def test_edited_beats_conflict(self):
_a, prompt = self.pair("both", "n1", category="portrait")
self.pair("both", "n2", category="landscape")
self.edit_stamp(prompt)
draft = self.preview()
self.assertEqual(draft.protected["reasons"], ["1 edited after it was saved"])
def test_archived_prompt_is_protected(self):
_a, prompt = self.pair("archived", category="portrait")
self.run_sql(
"INSERT INTO prompt_archive(prompt_id, archived_at) VALUES(?, ?)",
prompt,
"2026-10-31T00:00:00+00:00",
)
draft = self.preview()
self.assertEqual(draft.targets, [])
self.assertEqual(draft.protected["reasons"], ["1 archived in the Library"])
self.assertIn(
("archived", "Kept: archived in the Library"), self.details(draft)
)
def test_archived_pairs_are_no_source(self):
pair_id, prompt = self.pair("old pair", "n1", category="portrait")
self.db.archive_prompt_pair(pair_id)
draft = self.preview()
self.assertEqual(draft.targets, [])
self.assertEqual(draft.protected["count"], 0)
# An archived pair also never disagrees with a live one.
self.pair("old pair", "n2", category="landscape")
self.assertEqual(self.preview().targets, [[prompt, True, False]])
self.assertIn("landscape", self.details(self.preview())[0][1])
def test_prompt_not_created_by_its_pair_is_out_of_scope(self):
prompt = self.save("saved by the node first")
self.run_sql(
"UPDATE prompts SET created_at=?, updated_at=? WHERE id=?",
"2026-01-01T00:00:00+00:00",
"2026-01-01T00:00:00+00:00",
prompt,
)
self.db.save_prompt_pair("saved by the node first", category="portrait")
draft = self.preview()
self.assertEqual(draft.targets, [])
self.assertEqual(draft.protected["count"], 0)
def test_negative_is_never_a_target_and_positive_role_sources_only(self):
_a, both = self.pair("dual", "shared negative", category="portrait")
neg = self.sql("SELECT id FROM prompts WHERE text='shared negative'")[0][0]
# The negative of pair A is the positive of pair B, made later: not
# Pair-created as a positive, so out of scope.
self.db.save_prompt_pair("shared negative", category="landscape")
draft = self.preview()
self.assertEqual(draft.targets, [[both, True, False]])
self.assertNotIn(neg, [t[0] for t in draft.targets])
def test_positive_first_then_negative_is_filled_from_positive_pairs_only(self):
_a, prompt = self.pair("pos first", category="portrait")
self.db.save_prompt_pair("another", "pos first", category="landscape")
draft = self.preview()
self.assertIn([prompt, True, False], draft.targets)
self.assertIn("“portrait”", dict(self.details(draft))["pos first"])
def test_more_than_twenty_tags_is_protected(self):
tags = [f"t{n:02d}" for n in range(21)]
_a, prompt = self.pair("many tags", category="portrait", tags=tags)
draft = self.preview()
self.assertEqual(draft.targets, [[prompt, True, False]])
self.assertEqual(
draft.protected["reasons"], ["1 whose pair has more than 20 tags"]
)
self.assertIn(
("many tags", "Kept: its pair has more than 20 tags"), self.details(draft)
)
def test_protected_rows_come_first_within_fifty(self):
self.pair("zz conflict", "n1", category="x")
self.pair("zz conflict", "n2", category="y")
for n in range(60):
self.pair(f"fill {n:02d}", category="portrait")
draft = self.preview()
self.assertEqual(len(draft.details), maintenance.MAX_DETAILS)
self.assertEqual(draft.details[0]["label"], "zz conflict")
self.assertEqual(draft.details[-1]["label"], "Record")
self.assertEqual(draft.scope["count"], 60)
def test_detail_rows_list_conflicts_before_other_kept_prompts(self):
_a, archived = self.pair("aa archived", category="portrait")
self.run_sql(
"INSERT INTO prompt_archive(prompt_id, archived_at) VALUES(?, ?)",
archived,
"2026-10-31T00:00:00+00:00",
)
_b, edited = self.pair("bb edited", category="portrait")
self.edit_stamp(edited)
self.pair("zz conflict", "n1", category="x")
self.pair("zz conflict", "n2", category="y")
draft = self.preview()
self.assertEqual(
[label for label, _detail in self.details(draft)],
["zz conflict", "bb edited", "aa archived"],
)
# The reasons keep the precedence order.
self.assertEqual(
draft.protected["reasons"],
[
"1 archived in the Library",
"1 edited after it was saved",
"1 where pairs disagree",
],
)
def test_same_library_same_fingerprint_and_pair_use_does_not_change_it(self):
pair_id, _prompt = self.pair("fp", category="portrait", tags=["a"])
first = self.preview().fingerprint
self.db.record_prompt_pair_use(pair_id)
self.db.update_prompt_pair_metadata(pair_id, notes="a note", title="t")
self.assertEqual(self.preview().fingerprint, first)
class TestPreviewWritesNothing(PairMetadataCase):
def test_preview_reads_one_read_only_snapshot(self):
self.pair("cat", category="portrait", tags=["a"])
before = self.sql("SELECT * FROM prompts ORDER BY id")
tags = self.sql("SELECT * FROM prompt_tags ORDER BY prompt_id, tag_id")
connect = maintenance.sqlite3.connect
opened = []
def spy(*args, **kwargs):
opened.append(args[0])
return connect(*args, **kwargs)
with patch.object(maintenance.sqlite3, "connect", side_effect=spy):
self.preview()
self.assertEqual(len(opened), 1)
self.assertTrue(str(opened[0]).endswith("?mode=ro"))
self.assertEqual(self.sql("SELECT * FROM prompts ORDER BY id"), before)
self.assertEqual(
self.sql("SELECT * FROM prompt_tags ORDER BY prompt_id, tag_id"), tags
)
class TestApply(PairMetadataCase):
def snapshot_pairs(self):
return (
self.sql("SELECT * FROM prompt_pairs ORDER BY id"),
self.sql("SELECT * FROM prompt_pair_tags ORDER BY pair_id, tag_id"),
)
def test_apply_fills_only_empty_fields_and_leaves_pairs_alone(self):
_a, prompt = self.pair("apply me", "neg", category="portrait", tags=["a", "b"])
_b, partial = self.pair("partial", "n1", category="portrait", tags=["a"])
self.pair("partial", "n2", category="portrait", tags=["c"])
neg = self.sql("SELECT id FROM prompts WHERE text='neg'")[0][0]
pairs = self.snapshot_pairs()
stamps = self.sql("SELECT id, created_at, updated_at FROM prompts ORDER BY id")
state = self.sql("SELECT * FROM prompt_library_state ORDER BY prompt_id")
tag_rows = self.sql("SELECT id, name FROM tags ORDER BY id")
draft = self.preview()
result = self.apply(draft)
self.assertEqual(result["changed"], 2)
self.assertEqual(
result["rows"],
[
{"prompt_id": prompt, "category": "portrait", "tags": ["a", "b"]},
{"prompt_id": partial, "category": "portrait"},
],
)
self.assertEqual(result["categories"], 2)
self.assertEqual(result["tag_sets"], 1)
self.assertEqual(
(self.category(prompt), self.tags(prompt)), ("portrait", ["a", "b"])
)
self.assertEqual((self.category(partial), self.tags(partial)), ("portrait", []))
self.assertEqual((self.category(neg), self.tags(neg)), (None, []))
self.assertEqual(self.snapshot_pairs(), pairs)
self.assertEqual(
self.sql("SELECT id, created_at, updated_at FROM prompts ORDER BY id"),
stamps,
)
self.assertEqual(
self.sql("SELECT * FROM prompt_library_state ORDER BY prompt_id"), state
)
self.assertEqual(self.sql("SELECT id, name FROM tags ORDER BY id"), tag_rows)
def test_apply_returns_one_kept_row_per_protected_prompt(self):
_a, conflict = self.pair("conflict", "n1", category="asian")
self.pair("conflict", "n2", category="Adult")
_b, edited = self.pair("edited", category="portrait")
self.edit_stamp(edited)
_c, both = self.pair(
"both", "n1", category="x", tags=[f"t{n}" for n in range(21)]
)
self.pair("both", "n2", category="y", tags=[f"t{n}" for n in range(21)])
_d, filled = self.pair("fill", category="portrait")
draft = self.preview()
result = self.apply(draft)
self.assertEqual(
result["kept"],
[
{
"prompt_id": conflict,
"reasons": ["pairs disagree on category: Adult, asian"],
},
{
"prompt_id": edited,
"reasons": [
"edited after it was saved; its empty category or tags "
"may be intentional"
],
},
{
"prompt_id": both,
"reasons": [
"pairs disagree on category: x, y",
"its pair has more than 20 tags",
],
},
],
)
self.assertEqual(len(result["kept"]), draft.protected["count"])
self.assertEqual(self.category(filled), "portrait")
def test_kept_rows_are_never_capped(self):
for n in range(205):
_p, prompt = self.pair(f"kept {n:03d}", category="portrait")
self.edit_stamp(prompt)
result = self.apply(self.preview())
self.assertEqual(result["changed"], 0)
self.assertEqual(len(result["kept"]), 205)
def test_second_run_finds_nothing_and_a_partial_fill_completes_later(self):
_a, prompt = self.pair("partial", "n1", category="portrait", tags=["a"])
p2, _same = self.pair("partial", "n2", category="portrait", tags=["c"])
self.apply(self.preview())
again = self.preview()
self.assertEqual(again.scope["count"], 0)
self.assertEqual(again.protected["count"], 1) # tags still disagree
self.db.update_prompt_pair_metadata(p2, tags=["a"])
self.apply(self.preview())
self.assertEqual(self.tags(prompt), ["a"])
self.assertEqual(self.preview().protected["count"], 0)
def test_drift_raises_plan_changed_and_writes_nothing(self):
def pair_category(ids):
self.db.update_prompt_pair_metadata(ids[0], category="landscape")
def prompt_category(ids):
self.run_sql("UPDATE prompts SET category='mine' WHERE id=?", ids[1])
def new_pair(ids):
self.db.save_prompt_pair("drift", "other neg", category="landscape")
def tag_rename(ids):
tag = self.sql("SELECT id FROM tags WHERE name='a'")[0][0]
self.run_sql("UPDATE tags SET name='renamed' WHERE id=?", tag)
def edited(ids):
self.edit_stamp(ids[1])
for change in (pair_category, prompt_category, new_pair, tag_rename, edited):
with self.subTest(change=change.__name__):
ids = self.pair("drift", "n", category="portrait", tags=["a"])
draft = self.preview()
change(ids)
before = self.sql("SELECT id, category FROM prompts ORDER BY id")
tags = self.sql("SELECT * FROM prompt_tags ORDER BY prompt_id")
with self.assertRaises(maintenance.PlanChanged):
self.apply(draft)
self.assertEqual(
self.sql("SELECT id, category FROM prompts ORDER BY id"), before
)
self.assertEqual(
self.sql("SELECT * FROM prompt_tags ORDER BY prompt_id"), tags
)
self.run_sql("DELETE FROM prompt_pairs")
self.run_sql("DELETE FROM prompts")
self.run_sql("DELETE FROM tags")
def test_pair_run_between_preview_and_apply_still_applies(self):
pair_id, prompt = self.pair("busy", category="portrait")
draft = self.preview()
self.db.record_prompt_pair_use(pair_id)
self.apply(draft)
self.assertEqual(self.category(prompt), "portrait")
def test_guard_failure_rolls_back_everything(self):
_a, first = self.pair("first", category="portrait")
_b, second = self.pair("second", category="portrait")
draft = self.preview()
real = repair._fill_categories
def racing(conn, fills):
# Another writer's value appears inside the transaction.
conn.execute("UPDATE prompts SET category='x' WHERE id=?", (second,))
return real(conn, fills)
with patch.object(repair, "_fill_categories", side_effect=racing):
with self.assertRaises(maintenance.PlanChanged):
self.apply(draft)
self.assertIsNone(self.category(first))
self.assertIsNone(self.category(second))
def test_more_than_two_hundred_fills_are_all_returned(self):
for n in range(205):
self.pair(f"bulk {n:03d}", category="portrait")
result = self.apply(self.preview())
self.assertEqual(result["changed"], 205)
self.assertEqual(len(result["rows"]), 205)
def test_batches_bind_few_ids(self):
for n in range(5):
self.pair(f"batch {n}", category="portrait", tags=["a"])
with patch.object(maintenance, "ID_BATCH", 2):
draft = self.preview()
result = self.apply(draft)
self.assertEqual(result["changed"], 5)
if __name__ == "__main__":
unittest.main()
+88
View File
@@ -0,0 +1,88 @@
"""``release_free_memory``: glibc's malloc_trim(0), a no-op elsewhere (#523)."""
import sys
import unittest
import unittest.mock as mock
from utils import native_memory
class ReleaseFreeMemoryTests(unittest.TestCase):
def setUp(self):
# Each test looks the symbol up again.
patcher = mock.patch.object(
native_memory, "_trim", native_memory._NOT_LOOKED_UP
)
patcher.start()
self.addCleanup(patcher.stop)
@unittest.skipUnless(native_memory._glibc(), "glibc only")
def test_glibc_trims_once_per_call(self):
self.assertTrue(native_memory.release_free_memory())
self.assertTrue(native_memory.release_free_memory())
def test_other_platforms_do_nothing(self):
for platform in ("win32", "darwin", "cygwin"):
with self.subTest(platform), mock.patch.object(sys, "platform", platform):
native_memory._trim = native_memory._NOT_LOOKED_UP
with mock.patch.object(native_memory.ctypes, "CDLL") as cdll:
self.assertFalse(native_memory.release_free_memory())
cdll.assert_not_called()
def test_musl_does_nothing(self):
with (
mock.patch.object(sys, "platform", "linux"),
# Windows has no os.confstr: this test fakes Linux there too.
mock.patch.object(
native_memory.os,
"confstr",
side_effect=ValueError("unknown name"),
create=True,
),
mock.patch.object(native_memory.ctypes, "CDLL") as cdll,
):
self.assertFalse(native_memory.release_free_memory())
cdll.assert_not_called()
def test_a_missing_symbol_is_false(self):
library = mock.Mock(spec=[]) # no malloc_trim attribute
with (
mock.patch.object(native_memory, "_glibc", return_value=True),
mock.patch.object(native_memory.ctypes, "CDLL", return_value=library),
):
self.assertFalse(native_memory.release_free_memory())
def test_an_unloadable_libc_is_false(self):
with (
mock.patch.object(native_memory, "_glibc", return_value=True),
mock.patch.object(native_memory.ctypes, "CDLL", side_effect=OSError),
):
self.assertFalse(native_memory.release_free_memory())
def test_a_failing_trim_never_raises(self):
trim = mock.Mock(side_effect=OSError("boom"))
library = mock.Mock(malloc_trim=trim)
with (
mock.patch.object(native_memory, "_glibc", return_value=True),
mock.patch.object(native_memory.ctypes, "CDLL", return_value=library),
):
self.assertFalse(native_memory.release_free_memory())
trim.assert_called_once_with(0)
def test_the_symbol_is_looked_up_once(self):
trim = mock.Mock(return_value=1)
library = mock.Mock(malloc_trim=trim)
with (
mock.patch.object(native_memory, "_glibc", return_value=True),
mock.patch.object(
native_memory.ctypes, "CDLL", return_value=library
) as cdll,
):
self.assertTrue(native_memory.release_free_memory())
self.assertTrue(native_memory.release_free_memory())
cdll.assert_called_once_with(None)
self.assertEqual(trim.call_count, 2)
if __name__ == "__main__":
unittest.main()
+455
View File
@@ -0,0 +1,455 @@
"""PNG text without a pixel decode (#523), against the legacy reader floor.
Pillow's ``PngImageFile.text`` loads (decodes) every pixel to reach text
chunks written after IDAT. ``utils.png_text.png_text`` walks the remaining
chunks instead. The legacy reader (``Image.open(path).text``, every failure
read as ``{}``) is the floor: a file it read must read the same, and the
curated corpus below must read identically. The documented difference is
pixel data, which the walk never decodes: undecodable or cut-short pixel data
made the legacy reader fail, the walk still reads the text before it.
"""
import io
import random
import struct
import tempfile
import unittest
import zlib
from pathlib import Path
from unittest.mock import patch
from PIL import Image, ImageFile, PngImagePlugin
from utils.metadata_extractor import ComfyUIMetadataExtractor
from utils.png_text import png_text
FIXTURES = Path(__file__).resolve().parent / "fixtures"
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
SIGNATURE = b"\x89PNG\r\n\x1a\n"
FUZZ_SEED = 523
FUZZ_CASES = 400
def legacy_text(path):
"""The legacy scan reader: Pillow's ``.text`` (a full decode)."""
try:
with Image.open(path) as image:
return dict(getattr(image, "text", None) or {})
except Exception:
return {}
def walked_text(path):
"""The new reader under the same failure rule."""
try:
with Image.open(path) as image:
return png_text(image)
except Exception:
return {}
def chunk(kind, data):
return (
struct.pack(">I", len(data))
+ kind
+ data
+ struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
)
def chunks_of(raw):
"""``[(kind, data)]`` of a well-formed PNG."""
found, offset = [], 8
while offset < len(raw):
length, kind = struct.unpack(">I4s", raw[offset : offset + 8])
found.append((kind, raw[offset + 8 : offset + 8 + length]))
offset += 12 + length
return found
def assemble(parts):
return SIGNATURE + b"".join(
part if isinstance(part, bytes) else chunk(*part) for part in parts
)
def base_png(mode="RGB", size=(64, 64), text=None, noise=False):
"""A PNG from Pillow (text before IDAT), as ``[(kind, data)]``."""
image = Image.new(mode, size)
if noise:
rng = random.Random(7)
image = Image.frombytes(
"RGB", size, bytes(rng.randrange(256) for _ in range(size[0] * size[1] * 3))
)
info = PngImagePlugin.PngInfo()
for key, value in (text or {}).items():
info.add_text(key, value)
buffer = io.BytesIO()
image.save(buffer, format="PNG", pnginfo=info)
return chunks_of(buffer.getvalue())
def split_idat(parts, pieces=3):
"""Spread the IDAT payload over ``pieces`` consecutive IDAT chunks."""
data = b"".join(d for k, d in parts if k == b"IDAT")
step = max(1, len(data) // pieces)
idats = [(b"IDAT", data[i : i + step]) for i in range(0, len(data), step)]
first = next(i for i, (k, _) in enumerate(parts) if k == b"IDAT")
rest = [p for p in parts if p[0] != b"IDAT"]
return rest[:first] + idats + rest[first:]
def before_iend(parts, *extra):
return parts[:-1] + list(extra) + parts[-1:]
def text_chunk(key, value):
return (b"tEXt", key.encode("latin-1") + b"\0" + value.encode("latin-1"))
def ztxt_chunk(key, value):
return (b"zTXt", key.encode() + b"\0\0" + zlib.compress(value.encode("latin-1")))
def itxt_chunk(key, value, compressed=False):
body = value.encode("utf-8")
if compressed:
body = zlib.compress(body)
flag = b"\1\0" if compressed else b"\0\0"
return (b"iTXt", key.encode() + b"\0" + flag + b"en\0" + b"Title\0" + body)
def bad_crc(kind, data):
good = chunk(kind, data)
return good[:-4] + bytes(b ^ 0xFF for b in good[-4:])
PROMPT = '{"3": {"class_type": "KSampler", "inputs": {"seed": 1}}}'
WORKFLOW = '{"nodes": [], "links": []}'
def curated():
"""Name to PNG bytes; each must read the same with both readers."""
plain = base_png(text={"prompt": PROMPT})
trailing = before_iend(
base_png(), text_chunk("prompt", PROMPT), text_chunk("workflow", WORKFLOW)
)
cases = {
"no-text": assemble(base_png()),
"text-before-pixels": assemble(plain),
"text-after-pixels": assemble(trailing),
"ztxt-after-pixels": assemble(
before_iend(base_png(), ztxt_chunk("prompt", PROMPT))
),
"itxt-plain-and-compressed": assemble(
before_iend(
base_png(),
itxt_chunk("prompt", "café " + PROMPT),
itxt_chunk("workflow", WORKFLOW, compressed=True),
)
),
"xmp-after-pixels": assemble(
before_iend(
base_png(),
itxt_chunk("XML:com.adobe.xmp", "<x:xmpmeta/>"),
text_chunk("prompt", PROMPT),
)
),
"itxt-bad-utf8": assemble(
before_iend(
base_png(),
(b"iTXt", b"prompt\0\0\0\0\0\xff\xfe"),
text_chunk("workflow", WORKFLOW),
)
),
"later-copy-wins": assemble(
before_iend(plain, text_chunk("prompt", '{"later": 1}'))
),
"empty-key-and-no-separator": assemble(
before_iend(base_png(), (b"tEXt", b"\0value"), (b"tEXt", b"lonely"))
),
"split-pixels-text-between": assemble(
split_idat(before_iend(base_png(noise=True), text_chunk("prompt", PROMPT)))
),
"palette": assemble(
before_iend(base_png(mode="P"), text_chunk("prompt", PROMPT))
),
"sixteen-bit": assemble(
before_iend(base_png(mode="I;16"), text_chunk("prompt", PROMPT))
),
"bad-crc-before-pixels": assemble(
[base_png()[0], bad_crc(*text_chunk("prompt", PROMPT))] + base_png()[1:]
),
"bad-crc-after-pixels": assemble(
base_png()[:-1] + [bad_crc(*text_chunk("prompt", PROMPT))] + base_png()[-1:]
),
"unknown-chunk-after-pixels": assemble(
before_iend(base_png(), (b"prVt", b"x" * 40), text_chunk("prompt", PROMPT))
),
"garbage-chunk-id-after-pixels": assemble(
base_png()[:-1]
+ [b"\0\0\0\4\x00\x01\x02\x03abcd\0\0\0\0"]
+ [chunk(*text_chunk("prompt", PROMPT))]
+ base_png()[-1:]
),
"oversized-ztxt-after-pixels": assemble(
before_iend(
base_png(),
(
b"zTXt",
b"prompt\0\0"
+ zlib.compress(b"a" * (PngImagePlugin.MAX_TEXT_CHUNK + 10)),
),
)
),
"pixels-length-past-end": assemble(
[p for p in base_png() if p[0] != b"IDAT" and p[0] != b"IEND"]
)
+ b"\xff\xff\x00\x00IDAT"
+ b"\0" * 64,
}
full = assemble(trailing)
idat_at = full.index(b"IDAT")
cases["truncated-in-pixels"] = full[: idat_at + 20]
cases["no-end-after-pixels"] = full[: full.rindex(b"IEND") - 4]
cases["truncated-in-trailing-text"] = full[: full.rindex(b"workflow") + 4]
cases["unknown-chunk-past-end"] = (
assemble(before_iend(base_png(), ztxt_chunk("prompt", PROMPT))[:-1])
+ chunk(b"prVt", b"x" * 40)[:-20]
)
cases["not-a-png"] = b"nope, not an image at all"
cases["signature-only"] = SIGNATURE
return cases
def corrupt_pixels():
"""Complete IDAT chunks with valid CRCs over data zlib cannot inflate."""
parts = before_iend(base_png(), text_chunk("prompt", PROMPT))
return assemble(
[(k, b"\x78\x9c" + b"\xff" * 30) if k == b"IDAT" else (k, d) for k, d in parts]
)
def apng():
frames = [Image.new("RGB", (16, 16), colour) for colour in ("red", "blue")]
info = PngImagePlugin.PngInfo()
info.add_text("prompt", PROMPT)
buffer = io.BytesIO()
frames[0].save(
buffer, format="PNG", save_all=True, append_images=frames[1:], pnginfo=info
)
return assemble(
before_iend(chunks_of(buffer.getvalue()), text_chunk("workflow", WORKFLOW))
)
def fixture_images():
return sorted(p for p in FIXTURES.rglob("*") if p.suffix.lower() in IMAGE_SUFFIXES)
class _TempFiles(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
def write(self, name, raw):
path = Path(self._tmp.name) / name
path.write_bytes(raw)
return path
class PngTextParityTests(_TempFiles):
def test_every_fixture_image_reads_like_the_legacy_reader(self):
images = fixture_images()
self.assertGreaterEqual(len(images), 25)
for path in images:
with self.subTest(path=path.relative_to(FIXTURES)):
self.assertEqual(walked_text(path), legacy_text(path))
def test_curated_corpus_reads_like_the_legacy_reader(self):
for name, raw in curated().items():
with self.subTest(name):
path = self.write(name + ".png", raw)
self.assertEqual(walked_text(path), legacy_text(path))
def test_curated_corpus_exercises_trailing_text(self):
"""The corpus is not vacuous: trailing text exists and is read."""
cases = curated()
path = self.write("t.png", cases["text-after-pixels"])
self.assertEqual(walked_text(path), {"prompt": PROMPT, "workflow": WORKFLOW})
with Image.open(path) as image:
self.assertNotIn("prompt", image.info)
self.assertEqual(
walked_text(self.write("x.png", cases["truncated-in-pixels"])), {}
)
def test_itxt_values_keep_their_language_and_type(self):
path = self.write("i.png", curated()["itxt-plain-and-compressed"])
new, old = walked_text(path), legacy_text(path)
self.assertIsInstance(new["prompt"], PngImagePlugin.iTXt)
self.assertEqual(new["prompt"].lang, old["prompt"].lang)
self.assertEqual(new["prompt"].tkey, old["prompt"].tkey)
def test_animated_png_falls_back_to_the_legacy_read(self):
path = self.write("a.png", apng())
self.assertEqual(walked_text(path), legacy_text(path))
self.assertEqual(walked_text(path)["workflow"], WORKFLOW)
def test_truncated_pixels_read_alike_when_truncated_images_load(self):
"""With LOAD_TRUNCATED_IMAGES set (a node pack may set it) both readers
accept a short file alike."""
path = self.write("t.png", curated()["truncated-in-pixels"])
with patch.object(ImageFile, "LOAD_TRUNCATED_IMAGES", True):
self.assertEqual(walked_text(path), legacy_text(path))
def test_a_text_decode_error_ends_the_walk_like_legacy(self):
"""Pillow's ``load_end`` stops at a chunk whose handler raises
``UnicodeDecodeError`` and keeps the text read so far."""
path = self.write(
"u.png",
assemble(
before_iend(
base_png(),
ztxt_chunk("prompt", PROMPT),
text_chunk("workflow", WORKFLOW),
ztxt_chunk("parameters", "steps 20"),
)
),
)
def undecodable(stream, position, length):
raise UnicodeDecodeError("latin-1", b"", 0, 1, "test")
with patch.object(PngImagePlugin.PngStream, "chunk_tEXt", undecodable):
old = legacy_text(path)
self.assertEqual(walked_text(path), old)
self.assertEqual(old, {"prompt": PROMPT})
def test_the_text_memory_budget_counts_text_before_the_pixels(self):
"""Pillow's MAX_TEXT_MEMORY spans the whole file: text before and
after the pixels, each under it, fail together in both readers."""
path = self.write(
"m.png",
assemble(
before_iend(
base_png(text={"prompt": PROMPT}), text_chunk("workflow", WORKFLOW)
)
),
)
budget = len(PROMPT) + len(WORKFLOW) - 1
with patch.object(PngImagePlugin, "MAX_TEXT_MEMORY", budget):
self.assertEqual(legacy_text(path), {})
self.assertEqual(walked_text(path), {})
with patch.object(PngImagePlugin, "MAX_TEXT_MEMORY", budget + 1):
self.assertEqual(walked_text(path), legacy_text(path))
self.assertEqual(len(walked_text(path)), 2)
def test_complete_but_undecodable_pixels_read_more_than_legacy(self):
path = self.write("c.png", corrupt_pixels())
self.assertEqual(legacy_text(path), {})
self.assertEqual(walked_text(path), {"prompt": PROMPT})
def test_cut_short_pixels_read_the_text_before_them(self):
full = assemble(base_png(text={"prompt": PROMPT}))
path = self.write("s.png", full[: full.index(b"IDAT") + 12])
self.assertEqual(legacy_text(path), {})
self.assertEqual(walked_text(path), {"prompt": PROMPT})
def test_mutated_files_never_read_less_than_legacy(self):
"""Seeded fuzz: truncations and byte flips of a file with text before,
between and after its pixel chunks. Wherever the legacy reader found
text, the walk finds exactly the same."""
base = assemble(
split_idat(
before_iend(
base_png(text={"workflow": WORKFLOW}),
ztxt_chunk("prompt", PROMPT),
itxt_chunk("parameters", "steps 20"),
)
)
)
rng = random.Random(FUZZ_SEED)
compared = 0
for case in range(FUZZ_CASES):
raw = bytearray(base)
if case % 2:
del raw[rng.randrange(8, len(raw)) :]
else:
for _ in range(rng.randrange(1, 4)):
raw[rng.randrange(8, len(raw))] = rng.randrange(256)
path = self.write(f"fuzz{case}.png", bytes(raw))
old = legacy_text(path)
if old:
compared += 1
with self.subTest(case=case):
self.assertEqual(walked_text(path), old)
self.assertGreater(compared, FUZZ_CASES // 5)
def test_non_png_images_have_no_text(self):
for name in ("tiny.jpg", "tiny.webp"):
with Image.open(FIXTURES / "local-inspect" / name) as image:
self.assertEqual(png_text(image), {})
class NoPixelDecodeTests(_TempFiles):
"""Metadata reads never decode pixels (the #523 memory finding)."""
def png_fixtures(self):
return sorted((FIXTURES / "inspection_parity").glob("*.png"))
def test_png_text_does_not_load_pixels_and_leaves_the_image_usable(self):
path = self.write("t.png", curated()["text-after-pixels"])
with Image.open(path) as image:
with patch.object(ImageFile.ImageFile, "load", autospec=True) as load:
self.assertEqual(png_text(image)["workflow"], WORKFLOW)
load.assert_not_called()
image.load()
self.assertEqual(image.size, (64, 64))
self.assertEqual(image.text["workflow"], WORKFLOW)
def test_scan_reader_does_not_decode_pixels(self):
from py.api import PromptManagerAPI
api = PromptManagerAPI.__new__(PromptManagerAPI)
fixtures = self.png_fixtures() + [
self.write("t.png", curated()["text-after-pixels"])
]
with patch.object(ImageFile.ImageFile, "load", autospec=True) as load:
found = [api._extract_comfyui_metadata(str(p)) for p in fixtures]
load.assert_not_called()
self.assertEqual(found, [legacy_text(p) for p in fixtures])
self.assertTrue(all(found))
def test_monitor_extractor_does_not_decode_pixels(self):
extractor = ComfyUIMetadataExtractor()
fixtures = self.png_fixtures()
with patch.object(ImageFile.ImageFile, "load", autospec=True) as load:
found = [extractor.extract_metadata(str(p)) for p in fixtures]
load.assert_not_called()
self.assertTrue(all(metadata and len(metadata) > 1 for metadata in found))
class MonitorExtractorParityTests(_TempFiles):
"""``extract_metadata`` answers exactly as with the legacy ``.text``."""
def legacy_extract(self, path):
def old_text(image):
return dict(getattr(image, "text", None) or {})
with patch("utils.metadata_extractor.png_text", old_text):
return ComfyUIMetadataExtractor().extract_metadata(str(path))
def test_every_fixture_and_curated_file_extracts_like_legacy(self):
paths = fixture_images() + [
self.write(name + ".png", raw) for name, raw in curated().items()
]
extractor = ComfyUIMetadataExtractor()
for path in paths:
with self.subTest(path=path.name):
self.assertEqual(
extractor.extract_metadata(str(path)), self.legacy_extract(path)
)
if __name__ == "__main__":
unittest.main()
+143
View File
@@ -0,0 +1,143 @@
"""The thumbnail folder check answers for the folder as it is now (#320).
A check that started before the caller asked can miss what just happened: #592
hit it in the merge queue, where a folder deleted right after
``configure`` woke the probe stayed "available" until the next probe.
"""
import sys
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from py.api import preview_location, preview_store # noqa: E402
WAIT = 5
HERE = (True, None, {"id": "x"})
GONE = (False, "missing", None)
KEY = ("/thumbs", "x")
class TestProbeCheck(unittest.TestCase):
def setUp(self):
self.probe = preview_store._Probe()
def test_a_finished_check_nobody_collected_is_not_the_answer(self):
with patch.object(preview_location, "folder_state", return_value=HERE):
with self.probe._lock:
_key, done, _result = self.probe._start_check(*KEY)
self.assertTrue(done.wait(WAIT))
with patch.object(preview_location, "folder_state", return_value=GONE):
self.assertEqual(self.probe.check(*KEY), GONE)
def test_a_check_already_running_is_followed_by_a_new_one(self):
started = threading.Event()
release = threading.Event()
calls = []
def state(_path, _expected_id):
calls.append(threading.current_thread().name)
if len(calls) == 1:
started.set()
self.assertTrue(release.wait(WAIT))
return HERE
return GONE
answers = []
with patch.object(preview_location, "folder_state", state):
with self.probe._lock:
self.probe._start_check(*KEY)
self.assertTrue(started.wait(WAIT))
caller = threading.Thread(
target=lambda: answers.append(self.probe.check(*KEY))
)
caller.start()
release.set()
caller.join(WAIT)
self.assertFalse(caller.is_alive())
self.assertEqual(answers, [GONE])
self.assertEqual(len(calls), 2) # one after the other, never two at once
def test_a_stuck_check_is_never_doubled(self):
release = threading.Event()
self.addCleanup(release.set)
calls = []
def state(_path, _expected_id):
calls.append(1)
release.wait(WAIT)
return HERE
with (
patch.object(preview_location, "folder_state", state),
patch.object(preview_store, "IO_BUDGET", 0.05),
):
with self.probe._lock:
self.probe._start_check(*KEY)
with self.assertRaises(TimeoutError):
self.probe.check(*KEY)
self.assertEqual(len(calls), 1)
def test_an_error_from_the_new_check_reaches_the_caller(self):
with patch.object(
preview_location, "folder_state", side_effect=OSError("share gone")
):
with self.assertRaises(OSError):
self.probe.check(*KEY)
class TestRefreshOrder(unittest.TestCase):
"""Only the newest check's answer is published (#611)."""
def test_an_older_check_never_overwrites_a_newer_answer(self):
parsed = preview_location.ParsedLocation(
mode=preview_store.FOLDER_MODE, path="/thumbs", id="x"
)
self.addCleanup(setattr, preview_store, "_available", preview_store._available)
self.addCleanup(setattr, preview_store, "_problem", preview_store._problem)
started = threading.Event()
release = threading.Event()
newer_published = threading.Event()
calls = []
publish = preview_store._set_state
def state(_path, _expected_id):
calls.append(1)
if len(calls) == 1:
started.set()
self.assertTrue(release.wait(WAIT))
return HERE
return GONE
def set_state(*args, **kwargs):
if args[1]: # the older check's "available" arrives last
self.assertTrue(newer_published.wait(WAIT))
publish(*args, **kwargs)
def newer():
preview_store.refresh()
newer_published.set()
with (
patch.object(preview_store, "_parsed", parsed),
patch.object(preview_location, "folder_state", state),
patch.object(preview_store, "_set_state", set_state),
):
first = threading.Thread(target=preview_store.refresh)
first.start()
self.assertTrue(started.wait(WAIT))
second = threading.Thread(target=newer)
second.start()
release.set()
second.join(WAIT)
first.join(WAIT)
self.assertFalse(first.is_alive() or second.is_alive())
self.assertFalse(preview_store._available)
self.assertEqual(preview_store._problem, "missing")
if __name__ == "__main__":
unittest.main()
+457
View File
@@ -0,0 +1,457 @@
"""One resolver for where previews live on disk (#320 PR 1).
``py/api/preview_store.py`` owns every mapping between an image source, a
media file and its preview on disk. In source mode (the only mode so far)
it returns exactly the paths the code built by hand before
(``<source>/thumbnails/<rel>_thumb<ext>``), so nothing changes on disk. The
structural test keeps new ``thumbnails`` path joins out of the rest of the
backend.
"""
import asyncio
import os
import re
import sys
import tempfile
import threading
import unittest
from pathlib import Path
import unittest.mock
from unittest.mock import MagicMock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.modules.setdefault("server", MagicMock())
from py.api import preview_store, thumbnail_settings # noqa: E402
from py.api.thumbnail_encode import ( # noqa: E402
PreviewWriteHeld,
replace_unless_held,
)
REPO = Path(__file__).resolve().parent.parent
WAIT = 10
class SourceModeCase(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.root = Path(self._tmp.name) / "output"
self.root.mkdir()
class TestSourceModePaths(SourceModeCase):
def test_default_location_is_inside_each_source(self):
location = preview_store.current()
self.assertEqual(location.mode, preview_store.SOURCE_MODE)
self.assertTrue(location.available)
self.assertIsNone(location.problem)
def test_store_of_a_source_is_its_thumbnails_folder(self):
self.assertEqual(preview_store.store_dir(self.root), self.root / "thumbnails")
def test_preview_path_matches_the_old_join(self):
rel = Path("sub", "deep", "a.png")
self.assertEqual(
preview_store.preview_path(self.root, rel, ".webp"),
self.root / "thumbnails/sub/deep/a_thumb.webp",
)
self.assertEqual(
preview_store.preview_path(self.root, "a.mp4", ".jpg"),
self.root / "thumbnails" / "a_thumb.jpg",
)
def test_preview_path_defaults_to_the_written_extension(self):
from py.api.thumbnail_encode import writer_settings
self.assertEqual(
preview_store.preview_path(self.root, "a.png").suffix,
writer_settings().extension,
)
def test_request_path_maps_url_parts_to_the_old_join(self):
parts = ("thumbnails", "sub", "a_thumb.webp")
self.assertEqual(
preview_store.request_path(self.root, parts), self.root.joinpath(*parts)
)
def test_request_path_is_none_for_anything_but_preview_parts(self):
self.assertIsNone(preview_store.request_path(self.root, ("sub", "a.png")))
self.assertIsNone(preview_store.request_path(self.root, ("Thumbnails", "a")))
self.assertIsNone(preview_store.request_path(self.root, ("thumbnails",)))
def test_ensure_store_is_the_store(self):
self.assertEqual(
preview_store.ensure_store(self.root), self.root / "thumbnails"
)
def test_owns_a_preview_inside_the_store(self):
inside = self.root / "thumbnails" / "sub" / "a_thumb.webp"
self.assertTrue(preview_store.owns(self.root, inside))
self.assertFalse(preview_store.owns(self.root, self.root / "a.png"))
self.assertFalse(
preview_store.owns(self.root, self.root.parent / "elsewhere.webp")
)
@unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks")
def test_owns_refuses_a_store_linked_out_of_the_source(self):
outside = Path(self._tmp.name) / "outside"
outside.mkdir()
try:
(self.root / "thumbnails").symlink_to(outside, target_is_directory=True)
except OSError:
self.skipTest("symlinks not permitted")
self.assertFalse(
preview_store.owns(self.root, self.root / "thumbnails" / "a_thumb.webp")
)
@unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks")
def test_owns_refuses_a_preview_folder_linked_out_of_the_store(self):
outside = Path(self._tmp.name) / "outside"
outside.mkdir()
(self.root / "thumbnails").mkdir()
try:
(self.root / "thumbnails" / "sub").symlink_to(
outside, target_is_directory=True
)
except OSError:
self.skipTest("symlinks not permitted")
self.assertFalse(
preview_store.owns(
self.root, self.root / "thumbnails" / "sub" / "a_thumb.webp"
)
)
def test_store_container_is_the_source(self):
self.assertEqual(preview_store.store_container(self.root), self.root)
def test_given_store_argument_is_kept_unless_it_is_the_default(self):
default = self.root / "thumbnails"
other = Path(self._tmp.name) / "custom"
self.assertEqual(preview_store.given_store(self.root, default), default)
self.assertEqual(preview_store.given_store(self.root, other), other)
class TestPreviewText(unittest.TestCase):
def test_preview_text_is_an_exact_thumbnails_prefix_and_preview_name(self):
for text in (
"thumbnails/a_thumb.webp",
"thumbnails/sub/a_thumb.jxl",
"thumbnails/a_thumb.JPG",
"thumbnails/a_thumb.Png",
):
with self.subTest(text=text):
self.assertTrue(preview_store.is_preview_text(text))
def test_other_text_is_not_preview_text(self):
for text in (
"Thumbnails/a_thumb.webp",
"thumbnails/a.png",
"thumbnails/_thumb.webp",
"thumbnails/a_thumb.txt",
"sub/thumbnails/a_thumb.webp",
"thumbnails/../a_thumb.webp",
"thumbnails//a_thumb.webp",
"thumbnails/./a_thumb.webp",
"thumbnails\\a_thumb.webp",
"thumbnails/c:a_thumb.webp",
"thumbnails/sub/thumbnails/a_thumb.webp",
"thumbnails",
"",
):
with self.subTest(text=text):
self.assertFalse(preview_store.is_preview_text(text))
class TestWritesAndTheSwitch(SourceModeCase):
def _temp_and_dst(self):
folder = self.root / "thumbnails"
folder.mkdir()
tmp = folder / ".a_thumb.part-1-1.webp"
tmp.write_bytes(b"new")
return tmp, folder / "a_thumb.webp"
def test_a_write_from_the_current_generation_lands(self):
tmp, dst = self._temp_and_dst()
replace_unless_held(tmp, dst, preview_store.write_guard())
self.assertEqual(dst.read_bytes(), b"new")
def test_a_write_whose_generation_changed_is_dropped(self):
tmp, dst = self._temp_and_dst()
guard = preview_store.write_guard()
preview_store.bump_generation()
with self.assertRaises(PreviewWriteHeld):
replace_unless_held(tmp, dst, guard)
self.assertFalse(dst.exists())
def test_the_hold_check_still_applies(self):
tmp, dst = self._temp_and_dst()
with self.assertRaises(PreviewWriteHeld):
replace_unless_held(tmp, dst, preview_store.write_guard(lambda: False))
self.assertFalse(dst.exists())
def test_the_check_and_the_rename_run_under_the_write_lock(self):
"""The hold check and the rename hold ``write_lock``, the lock the
generation bump takes: a switch cannot fall between them."""
tmp, dst = self._temp_and_dst()
in_check = threading.Event()
release = threading.Event()
seen = {}
def slow_hold():
seen["locked"] = preview_store.write_lock.locked()
in_check.set()
release.wait(WAIT)
return True
guard = preview_store.write_guard(slow_hold)
writer = threading.Thread(target=replace_unless_held, args=(tmp, dst, guard))
writer.start()
try:
self.assertTrue(in_check.wait(WAIT))
self.assertTrue(seen["locked"])
self.assertTrue(preview_store.write_lock.locked())
finally:
release.set()
writer.join(WAIT)
self.assertFalse(preview_store.write_lock.locked())
self.assertEqual(dst.read_bytes(), b"new")
bumped = preview_store.bump_generation()
self.assertEqual(preview_store.generation(), bumped)
class TestOnlyTheResolverBuildsStorePaths(unittest.TestCase):
"""No backend module joins ``thumbnails`` onto a path by hand any more."""
ALLOWED = {
Path("py/api/preview_store.py"),
Path("py/api/thumbnail_settings.py"), # URL text
}
# A path join with a literal "thumbnails" operand.
JOIN = re.compile(
r"""/\s*["']thumbnails["']"""
r"""|["']thumbnails["']\s*/"""
r"""|joinpath\([^)]*["']thumbnails["']"""
r"""|os\.path\.join\([^)]*["']thumbnails["']"""
r"""|["']thumbnails/"""
r"""|/\s*PREVIEW_FOLDER|joinpath\([^)]*PREVIEW_FOLDER"""
r"""|Path\([^)]*,\s*["']thumbnails["']"""
)
def test_no_thumbnails_join_outside_the_resolver(self):
offenders = []
files = sorted(REPO.glob("*.py"))
for folder in ("py", "utils", "database"):
files += sorted((REPO / folder).rglob("*.py"))
for path in files:
rel = path.relative_to(REPO)
if rel in self.ALLOWED:
continue
for number, line in enumerate(
path.read_text(encoding="utf-8").splitlines(), 1
):
code = line.split("#", 1)[0]
if self.JOIN.search(code):
offenders.append(f"{rel}:{number}: {line.strip()}")
self.assertEqual(offenders, [])
class TestSettingsLookupsFollowTheStore(SourceModeCase):
def test_owned_thumbnails_reads_the_store(self):
folder = self.root / "thumbnails" / "sub"
folder.mkdir(parents=True)
(folder / "a_thumb.webp").write_bytes(b"x")
self.assertEqual(
thumbnail_settings.owned_thumbnails(self.root, Path("sub", "a.png")),
[folder / "a_thumb.webp"],
)
try:
from tests.test_thumbnail_auto import AutoThumbnailCase, _entry
from tests.test_api_images import make_png
except ImportError: # discovered with tests/ as the top-level directory
from test_thumbnail_auto import AutoThumbnailCase, _entry
from test_api_images import make_png
class RedirectedStoreCase(AutoThumbnailCase):
"""Every caller follows the resolver: with the store of the output root
moved to ``<tmp>/elsewhere/output-key`` (a stand-in for a later mode),
previews are read, written, listed and removed there and only there."""
async def asyncSetUp(self):
await super().asyncSetUp()
self.container = self.comfy_dir / "elsewhere"
self.store = self.container / "output-key"
self.store.mkdir(parents=True)
self.old_store = self.output_dir / "thumbnails"
root = self.output_dir
real_store = preview_store.store_dir
real_container = preview_store.store_container
def store_dir(r, location=None):
return self.store if Path(r) == root else real_store(r, location)
def store_container(r, location=None):
if Path(r) == root:
return self.container
return real_container(r, location)
for name, fake in (
("store_dir", store_dir),
("store_container", store_container),
):
patcher = unittest.mock.patch.object(preview_store, name, fake)
patcher.start()
self.addCleanup(patcher.stop)
def _preview(self, rel, ext=".webp"):
return self.store / (thumbnail_settings.preview_stem(rel) + ext)
class TestEveryCallerFollowsTheStore(RedirectedStoreCase):
async def test_an_existing_preview_is_served_from_the_store(self):
make_png(self.output_dir / "sub" / "a.png", size=(64, 64))
preview = self._preview("sub/a.png")
make_png(preview, size=(32, 32))
url = _entry(self.api, self.output_dir, "sub/a.png")["thumbnail_url"]
self.assertIn("v=", url) # the listing found it in the store
resp = await self.client.get(url)
self.assertEqual(resp.status, 200)
self.assertEqual(await resp.read(), preview.read_bytes())
async def test_a_legacy_upper_case_preview_is_served_from_the_store(self):
make_png(self.output_dir / "k.JPG", size=(64, 64))
legacy = self.store / "k_thumb.JPG"
legacy.write_bytes(b"legacy preview")
resp = await self.client.get(
"/prompt_manager/images/serve/thumbnails/k_thumb.JPG?root=0"
)
self.assertEqual(resp.status, 200)
self.assertEqual(await resp.read(), b"legacy preview")
async def test_a_missing_preview_is_made_in_the_store_on_first_view(self):
make_png(self.output_dir / "sub" / "b.png", size=(640, 480))
url = _entry(self.api, self.output_dir, "sub/b.png")["thumbnail_url"]
resp = await self.client.get(url)
self.assertEqual(resp.status, 200)
self.assertTrue(self._preview("sub/b.png").is_file())
self.assertFalse(self.old_store.exists())
async def test_a_leftover_in_the_source_folder_is_not_served(self):
make_png(self.output_dir / "c.png", size=(640, 480))
leftover = self.old_store / "c_thumb.webp"
leftover.parent.mkdir()
leftover.write_bytes(b"old preview bytes")
resp = await self.client.get(
"/prompt_manager/images/serve/thumbnails/c_thumb.webp?root=0"
)
self.assertEqual(resp.status, 200)
self.assertNotEqual(await resp.read(), b"old preview bytes")
self.assertTrue(self._preview("c.png").is_file())
async def test_capture_writes_into_the_store(self):
path = make_png(self.output_dir / "run" / "d.png", size=(64, 64))
self.assertEqual(self.api.queue_capture_thumbnail(str(path)), "queued")
await self._idle()
self.assertTrue(self._preview("run/d.png").is_file())
self.assertFalse(self.old_store.exists())
async def test_generate_and_clear_follow_the_store(self):
make_png(self.output_dir / "e.png", size=(64, 64))
payload = await asyncio.get_running_loop().run_in_executor(
None, self.api._generate_gallery_thumbnails_sync, [self.output_dir]
)
self.assertTrue(payload["success"])
self.assertTrue(self._preview("e.png").is_file())
self.assertFalse(self.old_store.exists())
self.assertTrue(payload["thumbnails_path"].endswith("output-key"))
cleared, _size = self.api._clear_gallery_thumbnails_sync([self.output_dir])
self.assertEqual(cleared, 1)
self.assertFalse(self._preview("e.png").exists())
self.assertTrue(self.store.is_dir()) # the store itself stays
async def test_removing_a_file_removes_its_preview_in_the_store(self):
from py.api.admin import _remove_thumbnail
make_png(self.output_dir / "f.png", size=(64, 64))
preview = self._preview("f.png")
preview.write_bytes(b"x")
self.assertTrue(_remove_thumbnail(self.output_dir, Path("f.png")))
self.assertFalse(preview.exists())
async def test_the_temp_sweep_walks_the_store(self):
stray = self.store / ".g_thumb.part-1-1.webp"
stray.write_bytes(b"x")
old = os.path.getmtime(stray) - 10 * 24 * 3600
os.utime(stray, (old, old))
self.assertEqual(self.api.sweep_thumbnail_temps(), 1)
self.assertFalse(stray.exists())
async def test_history_cover_writes_into_the_store(self):
from py.api import history_current
self.assertTrue(
history_current._preview_writable(self.output_dir, self._preview("h.png"))
)
self.assertFalse(
history_current._preview_writable(
self.output_dir, self.old_store / "h_thumb.webp"
)
)
class TestCaptureResolvesAtWriteTime(RedirectedStoreCase):
def test_a_queued_capture_follows_a_store_that_moved_before_its_write(self):
from py.api import thumbnail_auto as auto
written = []
thumbnailer = auto.AutoThumbnailer(
lambda src, dst, *a, **k: written.append(dst) or {"action": "generated"},
lambda: True,
self.api.logger,
start_worker=False,
)
self.addCleanup(thumbnailer.close)
src = make_png(self.output_dir / "i.png", size=(32, 32))
deferred = auto.DeferredPreview(self.output_dir, Path("i.png"))
self.assertTrue(thumbnailer.enqueue(src, deferred, False))
moved = self.container / "moved"
moved.mkdir()
self.store = moved # the location changes before the worker runs
thumbnailer._write_queued(thumbnailer._queue.popleft())
self.assertEqual(written, [moved / "i_thumb.webp"])
def test_the_after_scan_queue_maps_each_target_to_its_own_root(self):
from py.api import thumbnail_auto as auto
nested = self.output_dir / "inner"
nested.mkdir()
src = nested / "j.png"
outer = auto._deferred(
[self.output_dir, nested], src, self._preview("inner/j.png")
)
inner = auto._deferred(
[self.output_dir, nested], src, nested / "thumbnails" / "j_thumb.webp"
)
self.assertEqual(
(outer.root, outer.rel), (self.output_dir, Path("inner/j.png"))
)
self.assertEqual((inner.root, inner.rel), (nested, Path("j.png")))
stray = self.comfy_dir / "x_thumb.webp"
self.assertEqual(auto._deferred([self.output_dir], src, stray), stray)
def test_serve_and_history_share_one_flight_key(self):
from py.api import thumbnail_auto as auto
parts = ("thumbnails", "sub", "a_thumb.webp")
self.assertEqual(
auto._request_key(self.output_dir, parts),
auto._dst_key(self._preview("sub/a.png")),
)
if __name__ == "__main__":
unittest.main()
+607
View File
@@ -0,0 +1,607 @@
"""One folder mode (#320 PR 2): previews in ``<folder>/<source key>``.
Folder mode is reachable from config.json and tests only (D4B). These tests
set it on the resolver directly (``preview_store.configure``) and drive
every route that reads, writes, lists or removes previews: they must touch
only ``<folder>/<key>``, never ``<source>/thumbnails``, never through a link,
and nothing at all while the folder is unavailable. Waits are events; no
sleeps.
"""
import asyncio
import json
import os
import sys
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from tests.test_thumbnail_auto import AutoThumbnailCase, _entry, WAIT
from tests.test_api_images import make_png
except ImportError: # discovered with tests/ as the top-level directory
from test_thumbnail_auto import AutoThumbnailCase, _entry, WAIT
from test_api_images import make_png
from py.api import preview_location, preview_store, thumbnail_settings # noqa: E402
SERVE = "/prompt_manager/images/serve/"
class FolderModeCase(AutoThumbnailCase):
"""The output root's previews live in ``<comfy>/thumbs/<key>``."""
async def asyncSetUp(self):
await super().asyncSetUp()
self.folder = self.use_folder(self.comfy_dir / "thumbs")
self.old_store = self.output_dir / "thumbnails"
def use_folder(self, folder, install="install-a"):
folder.mkdir(exist_ok=True)
marker_id = preview_location.init_folder(str(folder), created_by_pm=True)
self.addCleanup(preview_store.configure, None)
preview_store.configure(
{"mode": "folder", "path": str(folder), "id": marker_id, "install": install}
)
self.assertTrue(preview_store.current().available)
return folder
def key_folder(self):
store = preview_store.ensure_store(self.output_dir, create=True)
self.assertIsNotNone(store)
return store
def preview(self, rel, ext=".webp"):
return self.key_folder() / (thumbnail_settings.preview_stem(rel) + ext)
def marker(self):
return json.loads(
(self.folder / preview_location.MARKER_NAME).read_text(encoding="utf-8")
)
class TestFolderModeRoutes(FolderModeCase):
async def test_the_key_is_a_readable_name_plus_a_hash_in_the_marker(self):
key = self.key_folder().name
self.assertRegex(key, r"^output-[0-9a-f]{8}$")
entry = self.marker()["keys"][key]
self.assertEqual(entry["path"], str(self.output_dir))
self.assertEqual(entry["install"], "install-a")
async def test_another_case_of_the_source_finds_the_same_key(self):
key = self.key_folder()
other = Path(str(self.output_dir).swapcase())
if not other.is_dir():
self.skipTest("a case-sensitive volume")
self.assertEqual(preview_store.store_dir(other), key)
@unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks")
async def test_a_linked_spelling_of_the_source_finds_the_same_key(self):
key = self.key_folder()
link = self.comfy_dir / "output-link"
try:
link.symlink_to(self.output_dir, target_is_directory=True)
except OSError:
self.skipTest("symlinks not permitted")
self.assertEqual(preview_store.store_dir(link), key)
async def test_an_existing_preview_is_listed_and_served_from_the_key_folder(self):
make_png(self.output_dir / "sub" / "a.png", size=(64, 64))
preview = self.preview("sub/a.png")
make_png(preview, size=(32, 32))
url = _entry(self.api, self.output_dir, "sub/a.png")["thumbnail_url"]
self.assertIn("v=", url)
self.assertTrue(url.startswith(SERVE + "thumbnails/sub/a_thumb.webp"))
resp = await self.client.get(url)
self.assertEqual(resp.status, 200)
self.assertEqual(await resp.read(), preview.read_bytes())
async def test_a_missing_preview_is_made_in_the_key_folder(self):
make_png(self.output_dir / "b.png", size=(640, 480))
url = _entry(self.api, self.output_dir, "b.png")["thumbnail_url"]
resp = await self.client.get(url)
self.assertEqual(resp.status, 200)
self.assertTrue(self.preview("b.png").is_file())
self.assertFalse(self.old_store.exists())
async def test_a_leftover_in_the_source_folder_is_never_served(self):
make_png(self.output_dir / "c.png", size=(640, 480))
self.old_store.mkdir()
(self.old_store / "c_thumb.webp").write_bytes(b"leftover")
resp = await self.client.get(SERVE + "thumbnails/c_thumb.webp?root=0")
self.assertEqual(resp.status, 200)
self.assertNotEqual(await resp.read(), b"leftover")
async def test_other_text_under_thumbnails_is_refused(self):
self.old_store.mkdir()
make_png(self.old_store / "notes.png", size=(8, 8))
resp = await self.client.get(SERVE + "thumbnails/notes.png?root=0")
self.assertEqual(resp.status, 403)
async def test_traversal_through_thumbnails_is_refused(self):
for text in (
"thumbnails/../../thumbs/x_thumb.webp",
"thumbnails/..%2F..%2Fx_thumb.webp",
"thumbnails/%2e%2e/x_thumb.webp",
):
with self.subTest(text=text):
resp = await self.client.get(SERVE + text + "?root=0")
self.assertIn(resp.status, (403, 404))
@unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks")
async def test_a_link_out_of_the_key_folder_is_never_served_or_written(self):
outside = self.comfy_dir / "outside"
outside.mkdir()
make_png(outside / "d_thumb.webp", size=(8, 8))
make_png(self.output_dir / "sub" / "d.png", size=(64, 64))
try:
(self.key_folder() / "sub").symlink_to(outside, target_is_directory=True)
except OSError:
self.skipTest("symlinks not permitted")
resp = await self.client.get(SERVE + "thumbnails/sub/d_thumb.webp?root=0")
self.assertEqual(resp.status, 403)
self.assertFalse(preview_store.owns(self.output_dir, outside / "d_thumb.webp"))
self.assertFalse(
preview_store.owns(self.output_dir, self.key_folder() / "sub" / "x.webp")
)
@unittest.skipUnless(os.name == "nt", "junctions are Windows only")
async def test_a_junction_inside_a_store_is_refused(self):
import _winapi
outside = self.comfy_dir / "outside"
outside.mkdir()
(outside / "e_thumb.webp").write_bytes(b"x")
_winapi.CreateJunction(str(outside), str(self.key_folder() / "sub"))
self.assertTrue(preview_location.is_link_like(self.key_folder() / "sub"))
resp = await self.client.get(SERVE + "thumbnails/sub/e_thumb.webp?root=0")
self.assertEqual(resp.status, 403)
async def test_capture_writes_into_the_key_folder(self):
path = make_png(self.output_dir / "run" / "f.png", size=(64, 64))
self.assertEqual(self.api.queue_capture_thumbnail(str(path)), "queued")
await self._idle()
self.assertTrue(self.preview("run/f.png").is_file())
self.assertFalse(self.old_store.exists())
async def test_clear_removes_previews_only(self):
make_png(self.output_dir / "g.png", size=(64, 64))
payload = await asyncio.get_running_loop().run_in_executor(
None, self.api._generate_gallery_thumbnails_sync, [self.output_dir]
)
self.assertTrue(payload["success"])
keep = self.key_folder() / "readme.txt"
keep.write_text("not a preview", encoding="utf-8")
resp = await self.client.post("/prompt_manager/images/clear-thumbnails")
self.assertEqual(resp.status, 200)
self.assertFalse(self.preview("g.png").exists())
self.assertTrue(keep.exists())
self.assertTrue((self.folder / preview_location.MARKER_NAME).is_file())
self.assertTrue(self.output_dir.joinpath("g.png").is_file())
async def test_removing_a_file_removes_its_preview_in_the_key_folder(self):
from py.api.admin import _remove_thumbnail
make_png(self.output_dir / "h.png", size=(64, 64))
preview = self.preview("h.png")
preview.write_bytes(b"x")
self.assertTrue(_remove_thumbnail(self.output_dir, Path("h.png")))
self.assertFalse(preview.exists())
async def test_history_cover_is_authorized_by_the_store(self):
from py.api import history_current
preview = self.preview("i.png")
preview.write_bytes(b"x")
target = history_current._canonical(preview)
self.assertTrue(
history_current._target_authorized(
target, self.output_dir, None, self.output_dir
)
)
stray = self.comfy_dir / "stray_thumb.webp"
stray.write_bytes(b"x")
self.assertFalse(
history_current._target_authorized(
history_current._canonical(stray),
self.output_dir,
None,
self.output_dir,
)
)
async def test_the_thumbnail_folder_is_never_recreated_by_a_write(self):
make_png(self.output_dir / "j.png", size=(64, 64))
self.key_folder()
for child in sorted(self.folder.rglob("*"), reverse=True):
child.rmdir() if child.is_dir() else child.unlink()
self.folder.rmdir()
with self.assertRaises(FileNotFoundError):
preview_store.make_parent(self.folder / "output-x" / "sub" / "a.webp")
self.assertFalse(self.folder.exists())
class TestUnavailableFolder(FolderModeCase):
def go_missing(self):
(self.folder / preview_location.MARKER_NAME).unlink()
self.assertFalse(preview_store.refresh())
location = preview_store.current()
self.assertEqual((location.available, location.problem), (False, "missing"))
async def test_a_folder_without_its_marker_is_missing_and_writes_nothing(self):
make_png(self.output_dir / "k.png", size=(640, 480))
self.go_missing()
entry = _entry(self.api, self.output_dir, "k.png")
self.assertFalse(entry.get("thumbnail_ready", False))
resp = await self.client.get(SERVE + "thumbnails/k_thumb.webp?root=0")
self.assertEqual(resp.status, 200)
self.assertEqual(resp.headers["Cache-Control"], "no-store")
self.assertEqual(
await resp.read(), (self.output_dir / "k.png").read_bytes()
) # the original
self.assertEqual(
sorted(p.name for p in self.folder.rglob("*") if p.is_file()), []
)
self.assertFalse(self.old_store.exists())
async def test_thumbnail_work_is_refused_while_unavailable(self):
self.go_missing()
for method, url, body in (
("POST", "/prompt_manager/images/generate-thumbnails", {}),
("POST", "/prompt_manager/images/generate-thumbnails/progress", None),
("POST", "/prompt_manager/images/clear-thumbnails", None),
("POST", "/prompt_manager/jobs/thumbnails", {"mode": "missing"}),
):
with self.subTest(url=url):
resp = await self.client.request(method, url, json=body)
self.assertEqual(resp.status, 409)
self.assertEqual((await resp.json())["code"], "location-unavailable")
async def test_status_reports_the_location_without_a_path(self):
self.go_missing()
resp = await self.client.get("/prompt_manager/images/thumbnails/status")
location = (await resp.json())["location"]
self.assertEqual(
location, {"mode": "folder", "available": False, "problem": "missing"}
)
async def test_another_folders_marker_is_a_mismatch(self):
marker = self.marker()
marker["id"] = "someone-else"
(self.folder / preview_location.MARKER_NAME).write_text(
json.dumps(marker), encoding="utf-8"
)
self.assertFalse(preview_store.refresh())
self.assertEqual(preview_store.current().problem, "marker-mismatch")
async def test_the_temp_sweep_skips_an_unavailable_folder(self):
stray = self.key_folder() / ".l_thumb.part-1-1.webp"
stray.write_bytes(b"x")
old = os.path.getmtime(stray) - 10 * 24 * 3600
os.utime(stray, (old, old))
self.go_missing()
self.assertEqual(self.api.sweep_thumbnail_temps(), 0)
self.assertTrue(stray.exists())
async def test_a_stalled_store_call_marks_it_unreachable_and_stops_io(self):
# Without the probe: woken by the stall, it finds this healthy folder again
# at once, and the lookup below would rightly do store I/O.
self.assertTrue(preview_store.stop_probe())
make_png(self.output_dir / "m.png", size=(64, 64))
self.preview("m.png").write_bytes(b"x")
ticks = iter([0.0, preview_store.IO_BUDGET + 1.0])
with patch.object(preview_store, "_clock", lambda: next(ticks, 99.0)):
_entry(self.api, self.output_dir, "m.png") # one slow lookup
self.assertEqual(preview_store.current().problem, "unreachable")
calls = []
real = thumbnail_settings.owned_in_folder
with patch.object(
thumbnail_settings,
"owned_in_folder",
lambda *a, **k: calls.append(a) or real(*a, **k),
):
entry = _entry(self.api, self.output_dir, "m.png")
self.assertEqual(calls, []) # no store I/O while unreachable
self.assertNotIn("v=", entry["thumbnail_url"])
self.assertTrue(preview_store.refresh()) # the probe finds it again
async def test_a_check_that_blocks_is_unreachable_within_the_budget(self):
entered = threading.Event()
release = threading.Event()
self.addCleanup(release.set)
def blocking(path, expected_id):
entered.set()
release.wait(WAIT)
return True, None, None
with (
patch.object(preview_store, "IO_BUDGET", 0.2),
patch.object(preview_location, "folder_state", blocking),
):
self.assertFalse(preview_store.refresh())
self.assertTrue(entered.is_set())
self.assertEqual(preview_store.current().problem, "unreachable")
release.set()
async def test_an_invalid_config_value_writes_nothing_anywhere(self):
preview_store.configure({"mode": "folder", "path": "relative/thumbs"})
location = preview_store.current()
self.assertEqual((location.available, location.problem), (False, "invalid"))
make_png(self.output_dir / "n.png", size=(640, 480))
resp = await self.client.get(SERVE + "thumbnails/n_thumb.webp?root=0")
self.assertEqual(resp.status, 200)
self.assertFalse(self.old_store.exists())
class TestSharedFolders(FolderModeCase):
async def test_two_installs_keep_each_others_keys(self):
self.key_folder()
other_root = self.comfy_dir / "other"
other_root.mkdir()
setting = preview_store.setting()
preview_store.configure(
{
"mode": "folder",
"path": setting.path,
"id": setting.id,
"install": "install-b",
}
)
self.assertIsNotNone(preview_store.ensure_store(other_root))
keys = self.marker()["keys"]
installs = sorted(entry["install"] for entry in keys.values())
self.assertEqual(installs, ["install-a", "install-b"])
async def test_init_folder_adopts_an_existing_marker(self):
marker_id = self.marker()["id"]
self.assertEqual(preview_location.init_folder(str(self.folder)), marker_id)
if __name__ == "__main__":
unittest.main()
class TestHostileMarker(FolderModeCase):
"""A hand-edited or foreign marker never takes a write or a delete out of
the thumbnail folder (shared and UNC folders can be edited by others)."""
def plant(self, keys):
marker = self.marker()
marker["keys"] = keys
(self.folder / preview_location.MARKER_NAME).write_text(
json.dumps(marker), encoding="utf-8"
)
self.assertTrue(preview_store.refresh())
def outside_files(self):
return sorted(
p.relative_to(self.comfy_dir).as_posix()
for p in self.comfy_dir.rglob("*")
if p.is_file()
and not p.is_relative_to(self.folder)
and not p.is_relative_to(self.output_dir)
and p.suffix in (".webp", ".jpg", ".png")
)
async def test_keys_that_are_not_one_folder_name_are_dropped(self):
entry = {"path": str(self.output_dir), "identity": "x", "install": "i"}
self.plant(
{
"../escape-0123abcd": entry,
str(self.comfy_dir / "abs-0123abcd"): entry,
"a/b-0123abcd": entry,
"a\\b-0123abcd": entry,
"Upper-0123abcd": entry,
"nohash": entry,
"fine-0123abcd": "not a dict",
}
)
self.assertEqual(preview_location.read_marker(str(self.folder))["keys"], {})
store = self.key_folder()
self.assertEqual(store.parent, self.folder)
make_png(self.output_dir / "p.png", size=(640, 480))
resp = await self.client.get(SERVE + "thumbnails/p_thumb.webp?root=0")
self.assertEqual(resp.status, 200)
self.assertTrue((store / "p_thumb.webp").is_file())
self.assertEqual(self.outside_files(), [])
@unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks")
async def test_a_key_folder_that_is_a_link_is_never_written_or_cleared(self):
outside = self.comfy_dir / "outside"
outside.mkdir()
victim = outside / "q_thumb.webp"
victim.write_bytes(b"keep me")
store = self.key_folder()
store.rmdir()
try:
store.symlink_to(outside, target_is_directory=True)
except OSError:
self.skipTest("symlinks not permitted")
make_png(self.output_dir / "q.png", size=(640, 480))
from py.api.admin import _remove_thumbnail
self.assertFalse(_remove_thumbnail(self.output_dir, Path("q.png")))
resp = await self.client.post("/prompt_manager/images/clear-thumbnails")
self.assertIn(resp.status, (200, 400))
resp = await self.client.get(SERVE + "thumbnails/q_thumb.webp?root=0")
self.assertIn(resp.status, (200, 403))
self.assertEqual(victim.read_bytes(), b"keep me")
self.assertEqual(sorted(p.name for p in outside.iterdir()), ["q_thumb.webp"])
@unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks")
async def test_a_linked_folder_below_a_key_is_never_deleted_through(self):
outside = self.comfy_dir / "outside"
outside.mkdir()
victim = outside / "r_thumb.webp"
victim.write_bytes(b"keep me")
try:
(self.key_folder() / "sub").symlink_to(outside, target_is_directory=True)
except OSError:
self.skipTest("symlinks not permitted")
make_png(self.output_dir / "sub" / "r.png", size=(64, 64))
from py.api.admin import _remove_thumbnail
self.assertFalse(_remove_thumbnail(self.output_dir, Path("sub/r.png")))
resp = await self.client.post("/prompt_manager/images/clear-thumbnails")
self.assertEqual(resp.status, 200)
self.assertEqual(victim.read_bytes(), b"keep me")
@unittest.skipUnless(os.name == "nt", "junctions are Windows only")
async def test_a_key_folder_that_is_a_junction_is_never_written_or_cleared(self):
import _winapi
outside = self.comfy_dir / "outside"
outside.mkdir()
victim = outside / "v_thumb.webp"
victim.write_bytes(b"keep me")
store = self.key_folder()
store.rmdir()
_winapi.CreateJunction(str(outside), str(store))
make_png(self.output_dir / "v.png", size=(640, 480))
from py.api.admin import _remove_thumbnail
self.assertFalse(_remove_thumbnail(self.output_dir, Path("v.png")))
resp = await self.client.post("/prompt_manager/images/clear-thumbnails")
self.assertIn(resp.status, (200, 400))
resp = await self.client.get(SERVE + "thumbnails/v_thumb.webp?root=0")
self.assertIn(resp.status, (200, 403))
self.assertEqual(victim.read_bytes(), b"keep me")
self.assertEqual(sorted(p.name for p in outside.iterdir()), ["v_thumb.webp"])
def link_key_folder_outside(self):
"""The source's key folder replaced by a link to a folder outside."""
outside = self.comfy_dir / "outside"
outside.mkdir()
store = self.key_folder()
store.rmdir()
try:
store.symlink_to(outside, target_is_directory=True)
except OSError:
self.skipTest("symlinks not permitted")
return outside
@unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks")
async def test_convert_never_plans_or_deletes_through_a_linked_key_folder(self):
outside = self.link_key_folder_outside()
old = outside / "c_thumb.png"
old.write_bytes(b"keep me")
make_png(self.output_dir / "c.png", size=(640, 480))
# A linked store is refused before any planning (bot review, P1).
self.assertIsNone(preview_store.ensure_store(self.output_dir))
plan = self.api._plan_gallery_thumbnails([self.output_dir], mode="convert")
self.assertEqual(plan["targets"], [])
# And every delete passes the ownership check at the moment it runs.
removed, kept = self.api._remove_old_previews([(old, 7)])
self.assertEqual((removed, kept), (0, [old]))
self.assertEqual(old.read_bytes(), b"keep me")
@unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks")
async def test_the_temp_sweep_never_walks_into_a_link(self):
from py.api import thumbnail_encode
outside = self.comfy_dir / "outside"
outside.mkdir()
victim = outside / ".v_thumb.part-1-1.webp"
victim.write_bytes(b"keep me")
os.utime(victim, (1, 1))
try:
(self.key_folder() / "sub").symlink_to(outside, target_is_directory=True)
except OSError:
self.skipTest("symlinks not permitted")
real_walk = os.walk
def windows_walk(top, *args, **kwargs):
# Windows walks into a junction even with followlinks=False.
kwargs["followlinks"] = True
return real_walk(top, *args, **kwargs)
with patch.object(thumbnail_encode.os, "walk", windows_walk):
self.assertEqual(self.api.sweep_thumbnail_temps(), 0)
thumbnail_encode.sweep_stray_temps(self.key_folder(), min_age=0)
resp = await self.client.post("/prompt_manager/images/clear-thumbnails")
self.assertEqual(resp.status, 200)
self.assertEqual(victim.read_bytes(), b"keep me")
def test_the_temp_sweep_leaves_a_temp_the_store_does_not_own(self):
from py.api import thumbnail_encode
temp = self.key_folder() / ".w_thumb.part-1-1.webp"
temp.write_bytes(b"x")
with patch.object(preview_store, "may_write", return_value=False):
self.assertEqual(
thumbnail_encode.sweep_stray_temps(self.key_folder(), min_age=0), (0, 0)
)
self.assertTrue(temp.exists())
async def test_a_write_outside_the_folder_is_refused(self):
with self.assertRaises(OSError):
preview_store.make_parent(self.comfy_dir / "elsewhere" / "a_thumb.webp")
self.assertFalse((self.comfy_dir / "elsewhere").exists())
async def test_an_unmounted_folder_takes_no_write_before_the_next_check(self):
"""An unmounted mount point is an empty folder: the flag may still
say available, but without the marker nothing is made there."""
store = self.key_folder()
store.rmdir()
(self.folder / preview_location.MARKER_NAME).unlink()
self.assertTrue(preview_store.current().available) # not checked yet
with self.assertRaises(OSError):
preview_store.make_parent(store / "sub" / "s_thumb.webp")
self.assertEqual(list(self.folder.iterdir()), [])
class TestStartupNeverFallsBackToTheSources(AutoThumbnailCase):
async def test_a_folder_setting_is_in_force_before_its_check(self):
self.addCleanup(preview_store.configure, None)
folder = self.comfy_dir / "thumbs"
value = {"mode": "folder", "path": str(folder), "id": "x"}
preview_store.configure(value, check=False)
location = preview_store.current()
self.assertEqual((location.mode, location.available), ("folder", False))
make_png(self.output_dir / "t.png", size=(640, 480))
resp = await self.client.get(SERVE + "thumbnails/t_thumb.webp?root=0")
self.assertEqual(resp.status, 200)
self.assertFalse((self.output_dir / "thumbnails").exists())
async def test_a_failed_startup_check_leaves_the_folder_invalid(self):
from py.config import PromptManagerConfig
self.addCleanup(preview_store.configure, None)
value = {"mode": "folder", "path": str(self.comfy_dir / "thumbs"), "id": "x"}
with (
patch.object(PromptManagerConfig, "THUMBNAIL_LOCATION", value),
patch.object(
type(self.api),
"_thumbnail_location_context",
side_effect=RuntimeError("boom"),
),
):
self.api.start_preview_location()
location = preview_store.current()
self.assertEqual(
(location.mode, location.available, location.problem),
("folder", False, "invalid"),
)
class TestNoStoreIoOnTheEventLoop(FolderModeCase):
async def test_legacy_listings_do_not_stat_the_folder(self):
make_png(self.output_dir / "u.png", size=(64, 64))
self.preview("u.png").write_bytes(b"x")
calls = []
real = thumbnail_settings.owned_in_folder
with patch.object(
thumbnail_settings,
"owned_in_folder",
lambda *a, **k: calls.append(threading.current_thread().name)
or real(*a, **k),
):
images = self.api._enrich_images(
[{"image_path": str(self.output_dir / "u.png")}]
)
self.assertEqual(calls, [])
self.assertEqual(images[0]["thumbnail_url"], SERVE + "thumbnails/u_thumb.webp")
+8 -4
View File
@@ -153,7 +153,7 @@ class ConfigTestCase(unittest.TestCase):
path = os.path.join(tmpdir, "config.json")
with open(path, "w", encoding="utf-8") as handle:
json.dump({"autotag": {"defaults": defaults}}, handle)
before = os.stat(path).st_mtime_ns, open(path, "rb").read()
before = os.stat(path).st_mtime_ns, self.read_bytes(path)
if warnings:
with self.assertLogs("prompt_manager.config", level="WARNING") as logs:
PromptManagerConfig.load_from_file(path)
@@ -162,9 +162,13 @@ class ConfigTestCase(unittest.TestCase):
else:
PromptManagerConfig.load_from_file(path)
# Nothing is written at load.
self.assertEqual(
(os.stat(path).st_mtime_ns, open(path, "rb").read()), before
)
self.assertEqual((os.stat(path).st_mtime_ns, self.read_bytes(path)), before)
@staticmethod
def read_bytes(path):
# Closed at once: Windows cannot delete the temp dir with it open.
with open(path, "rb") as handle:
return handle.read()
def state(self):
return [dict(e) for e in AutotagConfig.INSTRUCTIONS], dict(
+4 -3
View File
@@ -291,7 +291,7 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
await self._writers(state, auto.ON_DEMAND_LIMIT)
await self._waiting(len(names))
self.assertEqual(default_calls, [], "no view waits on a default thread")
self.assertTrue(all(name.startswith("pm-preview") for name in state["threads"]))
self.assertTrue(all(name.startswith("pm-image") for name in state["threads"]))
status = await self.client.get("/prompt_manager/images/thumbnails/status")
self.assertEqual(status.status, 200, "another route answers meanwhile")
await status.read()
@@ -678,7 +678,7 @@ class TestShutdown(AutoThumbnailCase):
_entry(self.api, self.output_dir, "viewed.png")["thumbnail_url"]
)
await resp.read()
pool_threads = list(self.api._preview_pool_threads())
pool_threads = list(self.api._image_pool_threads())
self.assertTrue(pool_threads)
loop = asyncio.get_running_loop()
await loop.run_in_executor(
@@ -904,7 +904,8 @@ class TestThumbnailOnCapture(AutoThumbnailCase):
self.api.queue_capture_thumbnail(str(path))
release.set()
await self._idle()
self.assertEqual(batches, [(1, 1), (2, 2), (2, 2)])
# The job pool's size is the setting; each batch is at most that (#523).
self.assertEqual(batches, [(1, 2), (2, 2), (2, 2)])
self.assertLessEqual(state["peak"], 2)
for i in range(5):
self.assertTrue(
+542
View File
@@ -0,0 +1,542 @@
"""Move thumbnails (#320 PR 3): the reviewed job in every direction, its
failure, cancel and restart paths, Recreate and the removed-sources scope.
Each test runs against a temp config.json (``AdminAPITestCase``) and a temp
journal; jobs are awaited through their task, never a timer.
"""
import json
import os
import re
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from tests.test_api_admin import AdminAPITestCase
from tests.test_api_images import make_png
except ImportError: # discovered with tests/ as the top-level directory
from test_api_admin import AdminAPITestCase
from test_api_images import make_png
from py.api import preview_location, preview_store # noqa: E402
from py.api import thumbnail_move_files as files # noqa: E402
from py.api import thumbnail_move_plan as move_plan # noqa: E402
from py.config import PromptManagerConfig # noqa: E402
REPO = Path(__file__).resolve().parent.parent
PLAN = "/prompt_manager/images/thumbnails/plan"
START = "/prompt_manager/jobs/thumbnails"
class MoveCase(AdminAPITestCase):
async def asyncSetUp(self):
await super().asyncSetUp()
self.api._get_all_output_dirs = lambda: [self.output_dir]
self.api.recover_thumbnail_move()
self.journal = Path(self.tmpdir) / "data" / "thumbnail-move.json"
for target in (move_plan,):
patcher = patch.object(target, "journal_path", lambda: str(self.journal))
patcher.start()
self.addCleanup(patcher.stop)
default = patch(
"py.api.thumbnail_move_plan.default_path",
lambda: str(Path(self.tmpdir) / "default-thumbs"),
)
default.start()
self.addCleanup(default.stop)
self.addCleanup(preview_store.configure, None)
self.addCleanup(setattr, PromptManagerConfig, "THUMBNAIL_LOCATION", None)
self.old_store = self.output_dir / "thumbnails"
self.folder = Path(self.tmpdir) / "thumbs"
# Two media files with previews, an orphan, a temp and a note.
make_png(self.output_dir / "a.png", size=(8, 8))
make_png(self.output_dir / "sub" / "b.png", size=(8, 8))
make_png(self.old_store / "a_thumb.webp", size=(4, 4))
make_png(self.old_store / "sub" / "b_thumb.webp", size=(4, 4))
make_png(self.old_store / "gone_thumb.webp", size=(4, 4))
(self.old_store / ".a_thumb.part-1-1.webp").write_bytes(b"t")
(self.old_store / "notes.txt").write_text("mine", encoding="utf-8")
async def plan(self, location):
resp = await self.client.post(PLAN, json={"mode": "move", "location": location})
return resp.status, await resp.json()
async def move(self, location, confirmed=True):
status, plan = await self.plan(location)
self.assertEqual(status, 200, plan)
resp = await self.client.post(
START, json={"mode": "move", "token": plan["token"], "confirmed": confirmed}
)
body = await resp.json()
self.assertEqual(resp.status, 202, body)
job = self.api._jobs.get(body["job"]["id"])
await job.task
return plan, job
def config(self):
with open(self.config_path, encoding="utf-8") as handle:
return json.load(handle)
def key_files(self, folder=None):
folder = folder or self.folder
return sorted(
p.relative_to(folder).as_posix()
for p in folder.rglob("*")
if p.is_file() and p.name != preview_location.MARKER_NAME
)
class TestMoveToAFolder(MoveCase):
async def test_review_counts_what_moves_is_removed_and_stays(self):
status, plan = await self.plan({"mode": "folder", "path": str(self.folder)})
self.assertEqual(status, 200, plan)
totals = plan["totals"]
self.assertEqual(
(
totals["files"],
totals["orphans"],
totals["temps"],
totals["left_in_place"],
),
(2, 1, 1, 1),
)
self.assertTrue(plan["confirm_required"])
self.assertTrue(plan["to"]["will_create"])
self.assertFalse(self.folder.exists()) # the review writes nothing
async def test_a_move_copies_switches_and_removes_old_copies(self):
before = (self.output_dir / "a.png").read_bytes()
_plan, job = await self.move({"mode": "folder", "path": str(self.folder)})
self.assertEqual(job.state, "finished", job.log)
self.assertEqual(job.counters["moved"], 2)
keys = self.key_files()
self.assertEqual(len(keys), 2)
self.assertTrue(all(re.match(r"^output-[0-9a-f]{8}/", k) for k in keys))
location = self.config()["thumbnails"]["location"]
self.assertEqual(
(location["mode"], location["path"]), ("folder", str(self.folder))
)
self.assertEqual(location["cleanup"], [])
self.assertEqual(preview_store.current().mode, "folder")
# Old copies, the orphan and the temp went; the note stays, so its
# folder stays; originals are untouched.
self.assertEqual(
sorted(p.name for p in self.old_store.rglob("*")), ["notes.txt"]
)
self.assertEqual((self.output_dir / "a.png").read_bytes(), before)
self.assertFalse(self.journal.exists())
copied = next(self.folder.rglob("a_thumb.webp"))
self.assertEqual(copied.stat().st_mtime_ns % files.MTIME_STEP_NS, 0)
async def test_the_move_back_removes_the_folder_it_made(self):
await self.move({"mode": "folder", "path": str(self.folder)})
_plan, job = await self.move({"mode": "source"})
self.assertEqual(job.state, "finished", job.log)
self.assertTrue((self.old_store / "a_thumb.webp").is_file())
self.assertTrue((self.old_store / "sub" / "b_thumb.webp").is_file())
self.assertFalse(self.folder.exists())
self.assertEqual(self.config()["thumbnails"]["location"]["mode"], "source")
async def test_folder_to_folder_keeps_the_key_names(self):
await self.move({"mode": "folder", "path": str(self.folder)})
first = self.key_files()
other = Path(self.tmpdir) / "thumbs2"
_plan, job = await self.move({"mode": "folder", "path": str(other)})
self.assertEqual(job.state, "finished", job.log)
self.assertEqual(self.key_files(other), first)
self.assertFalse(self.folder.exists())
async def test_previews_of_sources_no_longer_listed_go_when_leaving(self):
await self.move({"mode": "folder", "path": str(self.folder)})
setting = preview_store.setting()
def add(marker):
marker["keys"]["old-0123abcd"] = {
"path": "/gone",
"identity": "/gone",
"install": setting.install,
}
preview_location.write_marker(str(self.folder), add)
make_png(self.folder / "old-0123abcd" / "x_thumb.webp", size=(4, 4))
preview_store.refresh()
status, plan = await self.plan({"mode": "source"})
self.assertEqual(status, 200)
self.assertEqual(plan["totals"]["unlisted_sources"], 1)
_, job = await self.move({"mode": "source"})
self.assertEqual(job.state, "finished", job.log)
self.assertFalse(self.folder.exists())
async def test_confirmation_and_a_stale_token(self):
status, plan = await self.plan({"mode": "folder", "path": str(self.folder)})
resp = await self.client.post(
START, json={"mode": "move", "token": plan["token"]}
)
self.assertEqual((await resp.json())["code"], "confirmation-required")
resp = await self.client.post(
START, json={"mode": "move", "token": "nope", "confirmed": True}
)
self.assertEqual(resp.status, 409)
self.assertEqual((await resp.json())["code"], "plan-stale")
async def test_a_folder_that_is_too_small_is_refused(self):
with patch.object(move_plan, "_free", lambda _path: 1):
status, body = await self.plan({"mode": "folder", "path": str(self.folder)})
self.assertEqual(status, 507)
self.assertEqual(body["code"], "insufficient-space")
async def test_validation_refusals_come_back_in_place(self):
status, body = await self.plan(
{"mode": "folder", "path": str(self.output_dir / "thumbs")}
)
self.assertEqual((status, body["code"]), (400, "location-served"))
status, body = await self.plan({"mode": "source"})
self.assertEqual(body["code"], "location-same")
class TestFailures(MoveCase):
async def test_a_per_file_error_still_switches_and_removes_its_old_copy(self):
real = files.copy_one
def failing(source, destination, *args, **kwargs):
if Path(source).name == "a_thumb.webp":
raise files.CopyFailed("Could not be read; made again on view")
return real(source, destination, *args, **kwargs)
with patch.object(files, "copy_one", failing):
_plan, job = await self.move({"mode": "folder", "path": str(self.folder)})
self.assertEqual(job.state, "finished")
self.assertEqual((job.counters["moved"], job.counters["errors"]), (1, 1))
self.assertFalse((self.old_store / "a_thumb.webp").exists())
async def test_a_folder_wide_error_changes_nothing(self):
def gone(*_args, **_kwargs):
raise files.FolderGone("disk full")
with patch.object(files, "copy_one", gone):
_plan, job = await self.move({"mode": "folder", "path": str(self.folder)})
self.assertEqual(job.state, "failed")
self.assertIn("nothing changed", job.status_line)
self.assertFalse(self.folder.exists())
self.assertTrue((self.old_store / "a_thumb.webp").is_file())
self.assertEqual(preview_store.current().mode, "source")
self.assertFalse(self.journal.exists())
async def test_cancel_before_the_switch_changes_nothing(self):
status, plan = await self.plan({"mode": "folder", "path": str(self.folder)})
real = self.api._move_copy_one
def cancel_first(pair, **kwargs):
job = self.api._jobs.active("thumbnails")
job.cancelled = True
return real(pair, **kwargs)
with patch.object(self.api, "_move_copy_one", cancel_first):
resp = await self.client.post(
START, json={"mode": "move", "token": plan["token"], "confirmed": True}
)
job = self.api._jobs.get((await resp.json())["job"]["id"])
await job.task
self.assertEqual(job.state, "cancelled")
self.assertFalse(self.folder.exists())
self.assertEqual(preview_store.current().mode, "source")
async def test_an_exit_during_the_copy_leaves_the_journal_for_the_next_start(self):
status, plan = await self.plan({"mode": "folder", "path": str(self.folder)})
real = self.api._move_copy_one
exiting = {"now": False}
def exit_after(pair, **kwargs):
result = real(pair, **kwargs)
exiting["now"] = True # the interpreter starts exiting
return result
from py.api import thumbnail_move
with (
patch.object(self.api, "_move_copy_one", exit_after),
patch.object(
thumbnail_move.exit_signal, "requested", lambda: exiting["now"]
),
):
resp = await self.client.post(
START, json={"mode": "move", "token": plan["token"], "confirmed": True}
)
job = self.api._jobs.get((await resp.json())["job"]["id"])
await job.task
self.assertTrue(self.journal.exists())
self.assertTrue(self.key_files()) # partial copies were left
# The next start undoes it: copies and the created folder go.
self.api._move_recovered = False
self.api.recover_thumbnail_move()
self.assertFalse(self.journal.exists())
self.assertFalse(self.folder.exists())
self.assertTrue((self.old_store / "a_thumb.webp").is_file())
class TestRestart(MoveCase):
async def test_a_journal_left_after_the_switch_is_forgotten(self):
await self.move({"mode": "folder", "path": str(self.folder)})
self.journal.parent.mkdir(parents=True, exist_ok=True)
self.journal.write_text(
json.dumps(
{"state": "copying", "to": {"mode": "folder", "path": str(self.folder)}}
),
encoding="utf-8",
)
self.api._move_recovered = False
self.api.recover_thumbnail_move()
self.assertFalse(self.journal.exists())
self.assertTrue(self.key_files())
async def test_old_copies_that_waited_are_removed_by_the_finishing_job(self):
with patch.object(files, "remove_file", lambda path, **_k: False):
_plan, job = await self.move({"mode": "folder", "path": str(self.folder)})
self.assertTrue(preview_store.setting().cleanup)
self.assertTrue((self.old_store / "a_thumb.webp").exists())
# Each removal that failed is an error the job counts and lists by path.
rows = job.outcomes["errors"]
self.assertEqual(job.counters["errors"], len(rows))
self.assertIn("sub/b_thumb.webp", {row["path"] for row in rows})
self.assertTrue(all("next start" in row["detail"] for row in rows))
job = await self.api.finish_thumbnail_move()
await job.task
self.assertEqual(job.title, "Move thumbnails (finishing)")
self.assertFalse((self.old_store / "a_thumb.webp").exists())
self.assertEqual(self.config()["thumbnails"]["location"]["cleanup"], [])
class TestAdmission(MoveCase):
async def test_automatic_previews_and_setting_changes_wait_for_the_move(self):
status, plan = await self.plan({"mode": "folder", "path": str(self.folder)})
seen = {}
real = self.api._move_copy_one
def observe(pair, **kwargs):
seen["blocker"] = self.api._auto_thumbnail_blocker()
return real(pair, **kwargs)
with patch.object(self.api, "_move_copy_one", observe):
resp = await self.client.post(
START, json={"mode": "move", "token": plan["token"], "confirmed": True}
)
job = self.api._jobs.get((await resp.json())["job"]["id"])
refused = self.api._thumbnail_change_refused({"THUMBNAIL_QUALITY": 5})
self.assertEqual(refused.status, 409)
await job.task
self.assertIs(seen["blocker"], job)
def test_the_move_never_trims_memory_and_copies_on_the_job_pool(self):
text = "".join(
(REPO / "py" / "api" / name).read_text(encoding="utf-8")
for name in ("thumbnail_move.py", "thumbnail_prune.py")
)
self.assertNotIn("_release_job_memory", text)
self.assertIn("map_parallel", (REPO / "py/api/thumbnail_move.py").read_text())
class TestPrune(MoveCase):
async def test_only_this_installs_removed_sources_go(self):
await self.move({"mode": "folder", "path": str(self.folder)})
install = preview_store.setting().install
def add(marker):
marker["keys"]["old-0123abcd"] = {"identity": "/gone", "install": install}
marker["keys"]["theirs-0123abcd"] = {"identity": "/x", "install": "other"}
preview_location.write_marker(str(self.folder), add)
make_png(self.folder / "old-0123abcd" / "x_thumb.webp", size=(4, 4))
make_png(self.folder / "theirs-0123abcd" / "y_thumb.webp", size=(4, 4))
preview_store.refresh()
resp = await self.client.get(PLAN + "?mode=prune")
body = await resp.json()
self.assertEqual([s["key"] for s in body["sources"]], ["old-0123abcd"])
self.assertIn("1 source no longer listed", body["line"])
resp = await self.client.post(START, json={"mode": "prune"})
job = self.api._jobs.get((await resp.json())["job"]["id"])
await job.task
self.assertFalse((self.folder / "old-0123abcd").exists())
self.assertTrue((self.folder / "theirs-0123abcd" / "y_thumb.webp").exists())
keys = preview_location.read_marker(str(self.folder))["keys"]
self.assertNotIn("old-0123abcd", keys)
class TestRecreate(MoveCase):
async def test_recreate_and_its_refusals(self):
await self.move({"mode": "folder", "path": str(self.folder)})
url = "/prompt_manager/images/thumbnails/location/recreate"
resp = await self.client.post(url)
self.assertEqual((await resp.json())["code"], "location-has-marker")
for child in sorted(self.folder.rglob("*"), reverse=True):
child.rmdir() if child.is_dir() else child.unlink()
self.folder.rmdir()
self.assertFalse(preview_store.refresh())
resp = await self.client.post(url)
self.assertEqual(resp.status, 200, await resp.text())
self.assertTrue(preview_store.current().available)
self.assertNotEqual(self.config()["thumbnails"]["location"]["id"], None)
async def test_use_this_folder_adopts_another_marker(self):
await self.move({"mode": "folder", "path": str(self.folder)})
marker = preview_location.read_marker(str(self.folder))
marker["id"] = "someone-else"
(self.folder / preview_location.MARKER_NAME).write_text(
json.dumps(marker), encoding="utf-8"
)
self.assertFalse(preview_store.refresh())
resp = await self.client.post(
"/prompt_manager/images/thumbnails/location/adopt"
)
self.assertEqual(resp.status, 200, await resp.text())
self.assertTrue(preview_store.current().available)
self.assertEqual(self.config()["thumbnails"]["location"]["id"], "someone-else")
class TestFiles(unittest.TestCase):
def test_mtime_rounds_up_to_two_seconds(self):
self.assertEqual(files.rounded_mtime_ns(0), 0)
self.assertEqual(files.rounded_mtime_ns(1), files.MTIME_STEP_NS)
self.assertEqual(
files.rounded_mtime_ns(files.MTIME_STEP_NS), files.MTIME_STEP_NS
)
def test_long_windows_destinations_are_per_file_errors(self):
self.assertTrue(files.too_long("D:\\" + "x" * 240, long_paths=False))
self.assertFalse(files.too_long("D:\\x", long_paths=False))
def test_a_failed_copys_temp_that_cannot_go_is_logged(self):
with patch.object(files.os, "unlink", side_effect=PermissionError("held")):
with self.assertLogs("prompt_manager.thumbnails", "WARNING") as logs:
self.assertFalse(files._unlink("D:/thumbs/.pm-move-1.tmp"))
self.assertIn(".pm-move-1.tmp", logs.output[0])
with patch.object(files.os, "unlink", side_effect=FileNotFoundError()):
self.assertTrue(files._unlink("gone"))
def test_a_held_open_file_is_retried_then_left(self):
waits = []
error = OSError("in use")
error.winerror = 32
with patch.object(files.os, "unlink", side_effect=error):
self.assertFalse(files.remove_file("x", sleep=waits.append))
self.assertEqual(waits, [0.5, 1.0, 2.0])
if __name__ == "__main__":
unittest.main()
class TestReviewFindings(MoveCase):
async def test_moving_back_never_removes_previews_a_waiting_cleanup_named(self):
await self.move({"mode": "folder", "path": str(self.folder)})
other = Path(self.tmpdir) / "thumbs2"
# The old copies in the first folder cannot go yet (held open).
with patch.object(files, "remove_file", lambda path, **_k: False):
await self.move({"mode": "folder", "path": str(other)})
self.assertTrue(preview_store.setting().cleanup)
_plan, job = await self.move({"mode": "folder", "path": str(self.folder)})
self.assertEqual(job.state, "finished", job.log)
self.assertEqual(len(self.key_files()), 2) # the previews moved back stay
@unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks")
def test_empty_folder_removal_never_walks_through_a_link(self):
outside = Path(self.tmpdir) / "outside"
(outside / "empty").mkdir(parents=True)
(outside / "Thumbs.db").write_bytes(b"x")
store = Path(self.tmpdir) / "store"
store.mkdir()
try:
(store / "link").symlink_to(outside, target_is_directory=True)
except OSError:
self.skipTest("symlinks not permitted")
files.remove_empty_folders(store)
self.assertTrue((outside / "empty").is_dir())
self.assertTrue((outside / "Thumbs.db").exists())
@unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks")
async def test_moving_back_never_copies_through_an_existing_linked_folder(self):
# Codex P1 on #592: an existing linked folder in the destination was
# followed, and a file outside the source was replaced.
await self.move({"mode": "folder", "path": str(self.folder)})
outside = Path(self.tmpdir) / "outside-p1"
outside.mkdir()
(outside / "b_thumb.webp").write_bytes(b"not ours")
if (self.old_store / "sub").exists():
(self.old_store / "sub").rmdir()
try:
(self.old_store / "sub").symlink_to(outside, target_is_directory=True)
except OSError:
self.skipTest("symlinks not permitted")
_plan, job = await self.move({"mode": "source", "path": None})
self.assertEqual((outside / "b_thumb.webp").read_bytes(), b"not ours")
self.assertEqual(job.counters["errors"], 1, job.log)
self.assertTrue((self.old_store / "a_thumb.webp").is_file())
@unittest.skipUnless(os.name == "nt", "junctions are Windows only")
def test_empty_folder_removal_never_walks_through_a_junction(self):
import _winapi
outside = Path(self.tmpdir) / "outside-j"
(outside / "empty").mkdir(parents=True)
(outside / "Thumbs.db").write_bytes(b"x")
store = Path(self.tmpdir) / "store-j"
store.mkdir()
_winapi.CreateJunction(str(outside), str(store / "junction"))
files.remove_empty_folders(store)
self.assertTrue((outside / "empty").is_dir())
self.assertTrue((outside / "Thumbs.db").exists())
async def test_an_unexpected_error_while_copying_changes_nothing(self):
def broken(*_args, **_kwargs):
raise PermissionError("denied")
with patch.object(files, "_make_folders", broken):
_plan, job = await self.move({"mode": "folder", "path": str(self.folder)})
self.assertEqual(job.state, "failed")
self.assertFalse(self.journal.exists())
self.assertFalse(self.folder.exists())
self.assertEqual(preview_store.current().mode, "source")
async def test_a_journal_that_cannot_be_removed_never_undoes_the_switch(self):
from py.api import thumbnail_move
real = thumbnail_move._remove
def stuck(path):
if str(path) == str(self.journal):
raise PermissionError("in use")
return real(path)
with patch.object(thumbnail_move, "_remove", stuck):
_plan, job = await self.move({"mode": "folder", "path": str(self.folder)})
self.assertEqual(job.state, "finished", job.log)
self.assertEqual(len(self.key_files()), 2)
self.assertEqual(preview_store.current().mode, "folder")
async def test_a_hostile_key_is_never_pruned(self):
await self.move({"mode": "folder", "path": str(self.folder)})
victim = Path(self.tmpdir) / "victim"
make_png(victim / "z_thumb.webp", size=(4, 4))
marker = preview_location.read_marker(str(self.folder))
marker["keys"]["../victim"] = {"identity": "/gone", "install": marker_install()}
(self.folder / preview_location.MARKER_NAME).write_text(
json.dumps(marker), encoding="utf-8"
)
preview_store.refresh()
resp = await self.client.post(START, json={"mode": "prune"})
job = self.api._jobs.get((await resp.json())["job"]["id"])
await job.task
self.assertTrue((victim / "z_thumb.webp").exists())
def test_a_hand_edited_source_store_must_be_a_thumbnails_folder(self):
elsewhere = Path(self.tmpdir) / "photos"
make_png(elsewhere / "keep_thumb.webp", size=(4, 4))
self.assertTrue(self.api._remove_store(elsewhere, None, None))
self.assertTrue((elsewhere / "keep_thumb.webp").exists())
def marker_install():
return preview_store.setting().install
+175 -4
View File
@@ -1,4 +1,8 @@
"""Tests for utils/parallel.py: bounded thread-pool map used by long jobs."""
"""Tests for utils/parallel.py: bounded thread-pool map used by long jobs.
The jobs share one long-lived pool of Worker threads (#523): its threads (and
their malloc arenas) are reused from job to job instead of a new set per call.
"""
import os
import sys
@@ -8,10 +12,24 @@ import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.parallel import map_parallel # noqa: E402
from utils import exit_signal, parallel # noqa: E402
from utils.parallel import JOB_THREAD_PREFIX, map_parallel # noqa: E402
WAIT = 30
# A map that has not returned by then is stuck (#593).
HANG_LIMIT = 5
def _job_threads():
return {
t for t in threading.enumerate() if t.name.startswith(JOB_THREAD_PREFIX + "_")
}
class TestMapParallel(unittest.TestCase):
def setUp(self):
self.assertTrue(parallel.shutdown_job_pool(WAIT))
self.addCleanup(parallel.shutdown_job_pool, WAIT)
def test_results_keep_input_order(self):
def slow_square(n):
@@ -22,12 +40,102 @@ class TestMapParallel(unittest.TestCase):
map_parallel(slow_square, [1, 2, 3, 4], workers=4), [1, 4, 9, 16]
)
def test_one_worker_runs_on_the_calling_thread(self):
def test_one_worker_runs_on_the_one_job_thread(self):
names = []
map_parallel(
lambda _: names.append(threading.current_thread().name), [1, 2, 3], 1
)
self.assertEqual(set(names), {threading.current_thread().name})
self.assertEqual(len(set(names)), 1)
self.assertTrue(names[0].startswith(JOB_THREAD_PREFIX + "_"), names)
def test_jobs_reuse_the_same_long_lived_threads(self):
"""Arenas are reused instead of a new set of threads per job."""
first, second = set(), set()
map_parallel(lambda _: first.add(threading.current_thread()), range(8), 2)
map_parallel(lambda _: second.add(threading.current_thread()), range(8), 2)
self.assertTrue(second <= first, (first, second))
self.assertLessEqual(len(_job_threads()), 2)
self.assertTrue(all(t.is_alive() for t in first | second))
def test_a_new_worker_count_replaces_the_pool(self):
map_parallel(lambda n: n, range(4), 2)
old = _job_threads()
map_parallel(lambda n: n, range(4), 3)
for thread in old:
thread.join(WAIT)
self.assertFalse(thread.is_alive(), thread.name)
self.assertLessEqual(len(_job_threads()), 3)
def test_shutdown_also_joins_a_pool_a_resize_retired(self):
entered, release = threading.Event(), threading.Event()
def hold(_):
entered.set()
release.wait(WAIT)
caller = threading.Thread(target=map_parallel, args=(hold, [1], 1))
caller.start()
self.addCleanup(caller.join, WAIT)
self.addCleanup(release.set)
self.assertTrue(entered.wait(WAIT))
map_parallel(lambda n: n, [1, 2], 2) # a new size retires the busy pool
self.assertFalse(parallel.shutdown_job_pool(0.05))
release.set()
caller.join(WAIT)
self.assertTrue(parallel.shutdown_job_pool(WAIT))
self.assertFalse(parallel.job_pool_started())
def test_an_item_run_from_a_job_thread_runs_inline(self):
"""A nested map never waits for the threads it is running on."""
def outer(n):
return map_parallel(lambda m: (m, threading.current_thread()), [n], 1)
results = map_parallel(outer, [1, 2], 1)
self.assertEqual([r[0][0] for r in results], [1, 2])
def test_shutdown_stops_the_threads_and_a_later_job_starts_fresh(self):
map_parallel(lambda n: n, range(4), 2)
threads = _job_threads()
self.assertTrue(threads)
self.assertTrue(parallel.job_pool_started())
self.assertTrue(parallel.shutdown_job_pool(WAIT))
self.assertFalse(parallel.job_pool_started())
self.assertFalse(any(t.is_alive() for t in threads))
self.assertEqual(map_parallel(lambda n: n + 1, [1], 2), [2])
def test_shutdown_reports_a_thread_still_running(self):
entered, release = threading.Event(), threading.Event()
def hold(_):
entered.set()
release.wait(WAIT)
caller = threading.Thread(target=map_parallel, args=(hold, [1], 1))
caller.start()
self.assertTrue(entered.wait(WAIT))
self.assertFalse(parallel.shutdown_job_pool(0.05))
release.set()
caller.join(WAIT)
self.assertFalse(caller.is_alive())
def test_a_failing_item_waits_for_the_running_ones(self):
"""The caller sees the error only after its other items stopped."""
running = threading.Event()
finished = []
def item(n):
if n == 0:
self.assertTrue(running.wait(WAIT))
raise ValueError("zero")
running.set()
time.sleep(0.05)
finished.append(n)
return n
with self.assertRaises(ValueError):
map_parallel(item, [0, 1], 2)
self.assertEqual(finished, [1])
def test_several_workers_use_several_threads(self):
names = set()
@@ -57,5 +165,68 @@ class TestMapParallel(unittest.TestCase):
map_parallel(boom, [1, 2, 3], workers=3)
class TestErrorAfterShutdownCancel(unittest.TestCase):
"""#593: ``shutdown_job_pool`` cancels the call's queued items without
notifying them, so the error path must not wait on them (exit hung)."""
def setUp(self):
exit_signal.reset()
self.addCleanup(exit_signal.reset)
self.assertTrue(parallel.shutdown_job_pool(WAIT))
self.addCleanup(parallel.shutdown_job_pool, WAIT)
def _run_in_thread(self, func, items):
"""``map_parallel`` on a daemon thread: (thread, outcome list), so a
hang fails the test instead of hanging the suite."""
outcome = []
def call():
try:
outcome.append(("result", map_parallel(func, items, 1)))
except Exception as error: # the outcome the test checks
outcome.append(("error", error))
thread = threading.Thread(target=call, daemon=True)
thread.start()
return thread, outcome
def _hold_first_then_cancel_rest(self, first):
"""One worker runs item 0 (``first``) while items 1 and 2 queue;
shutdown cancels the queued two, then item 0 is released."""
entered, release = threading.Event(), threading.Event()
def item(n):
if n == 0:
entered.set()
self.assertTrue(release.wait(WAIT))
return first()
return n
thread, outcome = self._run_in_thread(item, [0, 1, 2])
self.assertTrue(entered.wait(WAIT))
self.assertFalse(parallel.shutdown_job_pool(0)) # item 0 still runs
release.set()
thread.join(HANG_LIMIT)
self.assertFalse(thread.is_alive(), "map_parallel hung on cancelled items")
return outcome
def test_a_running_item_raising_after_shutdown_cancel_returns(self):
def fail():
raise ValueError("zero")
outcome = self._hold_first_then_cancel_rest(fail)
self.assertEqual(outcome[0][0], "error")
self.assertIsInstance(outcome[0][1], ValueError)
def test_exit_requested_inline_after_shutdown_cancel_returns(self):
def signal_exit():
exit_signal.request() # the cancelled items then run inline
return 0
outcome = self._hold_first_then_cancel_rest(signal_exit)
self.assertEqual(outcome[0][0], "error")
self.assertIsInstance(outcome[0][1], exit_signal.ExitRequested)
if __name__ == "__main__":
unittest.main()
+5
View File
@@ -15,6 +15,8 @@ KNOWN_FILE_READERS = frozenset(
"images.py:os.open(",
"__init__.py:Image.open(",
"logging_routes.py:os.open(",
# The thumbnail folder's marker lock file (#320), never a media file.
"preview_location.py:os.open(",
"thumbnail_auto.py:FileResponse(",
"thumbnail_encode.py:Image.open(",
}
@@ -77,6 +79,7 @@ ROUTE_AUDIT = {
"POST /prompt_manager/jobs/autotag",
"POST /prompt_manager/jobs/thumbnails",
"GET /prompt_manager/images/thumbnails/plan",
"POST /prompt_manager/images/thumbnails/plan",
"GET /prompt_manager/images/thumbnails/status",
"POST /prompt_manager/images/generate-thumbnails",
"POST /prompt_manager/images/generate-thumbnails/progress",
@@ -172,6 +175,8 @@ ROUTE_AUDIT = {
"POST /prompt_manager/cleanup",
"POST /prompt_manager/diagnostics/test-link",
"POST /prompt_manager/images/clear-thumbnails",
"POST /prompt_manager/images/thumbnails/location/adopt",
"POST /prompt_manager/images/thumbnails/location/recreate",
"POST /prompt_manager/jobs/model-download",
"POST /prompt_manager/jobs/{job_id}/cancel",
"POST /prompt_manager/library",
+4 -2
View File
@@ -8,6 +8,8 @@ import { readFileSync } from 'node:fs';
const workflow = readFileSync(new URL('../../.github/workflows/viewerjs-check.yml', import.meta.url), 'utf8');
const lock = JSON.parse(readFileSync(new URL('../../tools/vendor/package-lock.json', import.meta.url), 'utf8'));
const version = lock.packages['node_modules/@playwright/test'].version;
// The tag alone is not a pin: v1.64.1-noble was re-pushed with Node 26 (#609). The digest fixes the image's contents.
const pinnedImage = `^ container:\\n image: mcr\\.microsoft\\.com/playwright:v${version.replaceAll('.', '\\.')}-noble@sha256:[0-9a-f]{64}$`;
function job(name) {
const match = workflow.match(new RegExp(`^ ${name}:\\n([\\s\\S]*?)(?=^ [a-z][a-z-]*:\\n|(?![\\s\\S]))`, 'm'));
@@ -18,7 +20,7 @@ function job(name) {
for (const name of ['frontend-coverage', 'browser-workflows']) {
test(`${name} runs in the pinned Playwright image matching @playwright/test`, () => {
const body = job(name);
assert.match(body, new RegExp(`^ container:\\n image: mcr\\.microsoft\\.com/playwright:v${version.replaceAll('.', '\\.')}-noble$`, 'm'));
assert.match(body, new RegExp(pinnedImage, 'm'));
assert.doesNotMatch(body, /playwright install/, 'browsers and their fonts come from the image, not a runner install');
// Toolcache interpreters are the runner's own builds: their entry points name host paths that do not exist in
// the container (setup-python failed with `spawn .../bin/pip ENOENT`). Use the image's Python and Node.
@@ -39,7 +41,7 @@ test('real ComfyUI compatibility runs in the pinned Playwright image on the revi
const match = testWorkflow.match(/^ {2}comfyui:\n([\s\S]*?)(?=^ {2}[a-z][a-z-]*:\n|(?![\s\S]))/m);
assert.ok(match, 'comfyui job exists');
const body = match[1];
assert.match(body, new RegExp(`^ container:\\n image: mcr\\.microsoft\\.com/playwright:v${version.replaceAll('.', '\\.')}-noble$`, 'm'));
assert.match(body, new RegExp(pinnedImage, 'm'));
assert.doesNotMatch(body, /playwright install/, 'Chromium and its system libraries come from the image, not apt');
assert.doesNotMatch(body, /apt-get|actions\/setup-(python|node)@/);
assert.match(body, /python3 -m venv --without-pip /, 'a user-space venv on the image Python');
+2 -2
View File
@@ -10,7 +10,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
const workflow = readFileSync(new URL('../../.github/workflows/viewerjs-check.yml', import.meta.url), 'utf8');
const PINNED_IMAGE = /^\s+image: mcr\.microsoft\.com\/playwright:v\d+\.\d+\.\d+-noble$/m;
const PINNED_IMAGE = /^\s+image: mcr\.microsoft\.com\/playwright:v\d+\.\d+\.\d+-noble@sha256:[0-9a-f]{64}$/m;
function jobs(text) {
const lines = text.split('\n');
@@ -237,7 +237,7 @@ test('every npm ci restores the npm download cache keyed on the vendor lockfile
assert.match(jobSteps[cacheAt], /hashFiles\('tools\/vendor\/package-lock\.json'\)/, `${id} keys the cache on the lockfile`);
assert.match(jobSteps[installAt], /npm ci --prefix tools\/vendor --ignore-scripts/, `${id} still installs without scripts`);
// The key names the job's own pinned image, so an image bump starts a fresh cache.
const image = /^ {6}image: mcr\.microsoft\.com\/playwright:(v\d+\.\d+\.\d+)-noble$/m.exec(body)?.[1];
const image = /^ {6}image: mcr\.microsoft\.com\/playwright:(v\d+\.\d+\.\d+)-noble@sha256:[0-9a-f]{64}$/m.exec(body)?.[1];
assert.ok(image, `${id} runs in the pinned Playwright image`);
assert.match(jobSteps[cacheAt], new RegExp(`key: npm-\\$\\{\\{ runner\\.os \\}\\}-playwright-${image.replaceAll('.', '\\.')}-\\$\\{\\{ hashFiles`),
`${id} keys the cache on its image ${image}`);
+395
View File
@@ -0,0 +1,395 @@
"""Measure the server's resident memory under a busy Images/Library session (#523).
Manual only, Linux only (it reads ``/proc`` and glibc's ``malloc_info``); never
run in CI. It starts the production API inside the disposable browser test
server (``tests/browser/server.py``: its own temporary library, database and
output folder; never a live ComfyUI) and drives it for ``--minutes``:
- ``--clients`` threads loop over: a workspace grid page of 100 images, 40 of
its thumbnails, 6 inspector reads and 6 full-file reads, the Library list
with 3 entries' history and images, and the recent prompt and image lists;
- every minute, ``--batch`` new PNGs (832 to 1536 px, ComfyUI-shaped
metadata) are copied into the output folder, then a scan job and a
thumbnail job run to completion.
Every 5 s the server samples its RSS, Python heap (tracemalloc), threads and
glibc arena count. The test server pins glibc's mmap threshold (#516); this
driver turns that off so the production allocator behaviour is measured,
unless ``--mallopt`` asks for the pinned comparison run.
python tools/perf/memory_drive.py --label after --minutes 12
python tools/perf/memory_drive.py --repo /path/to/other/checkout --label before
The checkout under ``--repo`` (default: this one) is the code measured; the
driver itself only needs Pillow. The last line printed is a Markdown table row.
"""
import argparse
import ctypes
import json
import os
import random
import shutil
import subprocess # nosec B404 - starts this script's own server child
import sys
import tempfile
import threading
import time
import tracemalloc
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HERE))
PORT_ANNOUNCEMENT = "PM_TEST_SERVER_PORT="
OUTPUT_ANNOUNCEMENT = "output="
SAMPLE_SECONDS = 5
MEDIA_FILES = 120
MEDIA_SIDES = (832, 1024, 1216, 1536)
REQUEST_TIMEOUT = 120
JOB_POLL_LIMIT = 600
FINAL_STATES = ("finished", "cancelled", "failed")
M_MMAP_THRESHOLD = -3
# ── the server child ────────────────────────────────────────────────────
def _arena_count(libc):
"""glibc's arena count (``<heap nr=`` entries of ``malloc_info``)."""
buffer, size = ctypes.c_char_p(), ctypes.c_size_t()
stream = libc.open_memstream(ctypes.byref(buffer), ctypes.byref(size))
libc.malloc_info(0, stream)
libc.fclose(stream)
text = ctypes.string_at(buffer, size.value).decode()
libc.free(ctypes.cast(buffer, ctypes.c_void_p))
return text.count("<heap nr=")
def _status():
with open("/proc/self/status", encoding="ascii") as status:
return {
line.split(":")[0]: int(line.split()[1])
for line in status
if line.startswith(("VmRSS", "RssAnon", "Threads"))
}
def _sample_forever(libc, samples_path):
start = time.monotonic()
announced = False
with open(samples_path, "a", buffering=1, encoding="utf-8") as out:
while True:
folder_paths = sys.modules.get("folder_paths")
if not announced and hasattr(folder_paths, "get_output_directory"):
out.write(OUTPUT_ANNOUNCEMENT + folder_paths.get_output_directory())
out.write("\n")
announced = True
status = _status()
out.write(
json.dumps(
{
"t": round(time.monotonic() - start),
"rss": status["VmRSS"] >> 10,
"anon": status["RssAnon"] >> 10,
"heap": tracemalloc.get_traced_memory()[0] >> 20,
"threads": status["Threads"],
"arenas": _arena_count(libc),
}
)
+ "\n"
)
time.sleep(SAMPLE_SECONDS if announced else 0.5)
def serve(repo, samples_path, mallopt):
"""Run the browser test server of *repo* in this process, sampled."""
sys.path.insert(0, repo)
os.chdir(repo)
tracemalloc.start(1)
libc = ctypes.CDLL(None)
libc.open_memstream.restype = ctypes.c_void_p
libc.open_memstream.argtypes = [
ctypes.POINTER(ctypes.c_char_p),
ctypes.POINTER(ctypes.c_size_t),
]
libc.fclose.argtypes = [ctypes.c_void_p]
libc.malloc_info.argtypes = [ctypes.c_int, ctypes.c_void_p]
libc.free.argtypes = [ctypes.c_void_p]
if mallopt:
libc.mallopt(M_MMAP_THRESHOLD, 128 * 1024)
threading.Thread(
target=_sample_forever, args=(libc, samples_path), daemon=True
).start()
from tests.browser import server
if not mallopt:
server.pin_mmap_threshold = lambda: False # production allocator
server.main(["--port", "0", "--exit-on-stdin-eof"])
# ── the driver ──────────────────────────────────────────────────────────
def make_media(folder, count=MEDIA_FILES, seed=523):
"""Synthetic ComfyUI-shaped PNGs (smooth noise, prompt and workflow)."""
from PIL import Image, PngImagePlugin
rng = random.Random(seed)
os.makedirs(folder, exist_ok=True)
for index in range(count):
side = MEDIA_SIDES[index % len(MEDIA_SIDES)]
small = Image.frombytes(
"RGB", (64, 64), bytes(rng.randrange(256) for _ in range(64 * 64 * 3))
)
image = small.resize((side, side), Image.BICUBIC)
text = f"synthetic scene {index}, soft light, detailed"
prompt = {
"3": {
"class_type": "KSampler",
"inputs": {
"seed": index,
"steps": 20,
"cfg": 7.0,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1.0,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["5", 0],
},
},
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "model.safetensors"},
},
"5": {
"class_type": "EmptyLatentImage",
"inputs": {"width": side, "height": side, "batch_size": 1},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": text, "clip": ["4", 1]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "blurry", "clip": ["4", 1]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "img", "images": ["8", 0]},
},
}
workflow = {
"nodes": [
{"id": int(k), "type": v["class_type"]} for k, v in prompt.items()
]
}
info = PngImagePlugin.PngInfo()
info.add_text("prompt", json.dumps(prompt))
info.add_text("workflow", json.dumps(workflow))
image.save(
os.path.join(folder, f"m{index:03d}.png"), pnginfo=info, compress_level=4
)
class Driver:
def __init__(self, base, output, media, batch):
self.base, self.output, self.media, self.batch = base, output, media, batch
self.errors, self.batches = [], 0
self.names = sorted(os.listdir(media))
def call(self, method, path, body=None, raw=False):
data = None if body is None else json.dumps(body).encode()
request = urllib.request.Request( # nosec B310 - loopback test server
self.base + path,
data=data,
method=method,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen( # nosec B310
request, timeout=REQUEST_TIMEOUT
) as response:
payload = response.read()
except Exception as error: # keep driving; errors are counted
self.errors.append(f"{method} {path.split('?')[0]}: {error}")
return None
return payload if raw else json.loads(payload or b"{}")
def add_batch(self):
self.batches += 1
folder = os.path.join(self.output, f"batch{self.batches:03d}")
os.makedirs(folder)
for index in range(self.batch):
name = self.names[(self.batches * self.batch + index) % len(self.names)]
shutil.copyfile(
os.path.join(self.media, name),
os.path.join(folder, f"img_{self.batches:03d}_{index:03d}.png"),
)
def wait_job(self, response):
job = ((response or {}).get("job") or {}).get("id")
for _ in range(JOB_POLL_LIMIT if job else 0):
state = self.call("GET", f"/prompt_manager/jobs/{job}")
if not state or (state.get("job") or {}).get("state") in FINAL_STATES:
return
time.sleep(1)
def scan_and_thumbnails(self):
self.add_batch()
self.wait_job(self.call("POST", "/prompt_manager/jobs/scan", {}))
self.wait_job(self.call("POST", "/prompt_manager/jobs/thumbnails", {}))
def browse(self, _index=None):
offset = random.randint(0, 3) * 100 # nosec B311 - load shape only
page = self.call(
"GET",
f"/prompt_manager/images/output?mode=workspace&limit=100&offset={offset}",
)
images = [i for i in (page or {}).get("images") or [] if isinstance(i, dict)]
urls = [i.get("thumbnail_url") or i.get("url") for i in images]
urls = [u for u in urls if u]
for url in random.sample(urls, min(40, len(urls))):
self.call("GET", url, raw=True)
for image in random.sample(images, min(6, len(images))):
self.inspect(image)
if image.get("url"):
self.call("GET", image["url"], raw=True)
library = self.call("GET", "/prompt_manager/library?limit=100") or {}
entries = [e for e in library.get("items") or [] if isinstance(e, dict)]
for entry in random.sample(entries, min(3, len(entries))):
prompt_id = entry.get("id") or entry.get("positive_id")
if prompt_id is not None:
self.call(
"GET", f"/prompt_manager/library/{prompt_id}/history", raw=True
)
self.call(
"GET", f"/prompt_manager/library/{prompt_id}/images", raw=True
)
self.call("GET", "/prompt_manager/recent?limit=50", raw=True)
self.call("GET", "/prompt_manager/images/recent?limit=50", raw=True)
def inspect(self, image):
relative, index = image.get("path"), image.get("root_index")
if not relative or not isinstance(index, int):
return
query = {"root": str(index)}
if image.get("root_scope"):
query["root_scope"] = image["root_scope"]
self.call(
"GET",
"/prompt_manager/images/inspect/"
+ urllib.parse.quote(relative)
+ "?"
+ urllib.parse.urlencode(query),
raw=True,
)
def _wait_for_output(samples_path, process):
while process.poll() is None:
with open(samples_path, encoding="utf-8") as samples:
for line in samples:
if line.startswith(OUTPUT_ANNOUNCEMENT):
return line[len(OUTPUT_ANNOUNCEMENT) :].strip()
time.sleep(0.2)
raise RuntimeError("the server exited before it started")
def summarize(samples_path, label, batches, errors):
rows = []
with open(samples_path, encoding="utf-8") as samples:
for line in samples:
if line.startswith("{"):
rows.append(json.loads(line))
if not rows:
raise RuntimeError("no samples were taken")
rss = [row["rss"] for row in rows]
late = [row for row in rows if row["t"] >= 120] or rows
end = rows[-1]
return (
f"| {label} | {rss[0]} → {end['rss']} | {min(r['rss'] for r in late)}–"
f"{max(r['rss'] for r in late)} | {max(rss)} | {rows[0]['heap']} → "
f"{end['heap']} | {max(r['threads'] for r in rows)} | "
f"{max(r['arenas'] for r in rows)} | {batches} | {len(errors)} |"
)
def drive(args):
work = args.out or tempfile.mkdtemp(prefix="pm-memory-")
media = os.path.join(work, "media")
if not os.path.isdir(media):
print(f"Writing {MEDIA_FILES} PNGs to {media}", flush=True)
make_media(media)
samples_path = os.path.join(work, f"{args.label}.samples")
open(samples_path, "w", encoding="utf-8").close()
command = [sys.executable, os.path.abspath(__file__), "--serve", samples_path]
command += ["--repo", os.path.abspath(args.repo)]
command += ["--mallopt"] if args.mallopt else []
with open(os.path.join(work, f"{args.label}.stderr"), "w") as stderr:
process = subprocess.Popen( # nosec B603
command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr
)
try:
port = None
for line in process.stdout:
text = line.decode(errors="replace")
if text.startswith(PORT_ANNOUNCEMENT):
port = int(text[len(PORT_ANNOUNCEMENT) :])
break
if port is None:
raise RuntimeError("the server announced no port")
output = _wait_for_output(samples_path, process)
driver = Driver(f"http://127.0.0.1:{port}", output, media, args.batch)
run(driver, args)
finally:
process.stdin.close()
process.wait(60)
print(f"Samples: {samples_path}")
print(
"| run | RSS start → end (MB) | after 2 min | max | heap (MB) | threads "
"| arenas | batches | errors |"
)
print(summarize(samples_path, args.label, driver.batches, driver.errors))
def run(driver, args):
driver.call("POST", "/__test__/reset", {})
driver.scan_and_thumbnails()
deadline = time.monotonic() + args.minutes * 60
next_batch = time.monotonic() + 60
with ThreadPoolExecutor(args.clients) as pool:
while time.monotonic() < deadline:
list(pool.map(driver.browse, range(args.clients)))
if time.monotonic() > next_batch:
driver.scan_and_thumbnails()
next_batch = time.monotonic() + 60
time.sleep(SAMPLE_SECONDS + 1) # one more sample after the load stops
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--repo", default=REPO, help="checkout to measure")
parser.add_argument("--label", default="run")
parser.add_argument("--minutes", type=float, default=12)
parser.add_argument("--clients", type=int, default=6)
parser.add_argument("--batch", type=int, default=60, help="new PNGs a minute")
parser.add_argument("--out", help="work folder (media, samples); default temp")
parser.add_argument(
"--mallopt", action="store_true", help="pin the mmap threshold (comparison)"
)
parser.add_argument("--serve", metavar="SAMPLES", help=argparse.SUPPRESS)
args = parser.parse_args(argv)
if not sys.platform.startswith("linux"):
parser.error("Linux only: it reads /proc and glibc's malloc_info")
if args.serve:
serve(os.path.abspath(args.repo), args.serve, args.mallopt)
else:
drive(args)
if __name__ == "__main__":
main()
+6
View File
@@ -653,6 +653,12 @@
"functions": 95,
"statements": 98
},
"workspace/system-thumbnail-location.mjs": {
"lines": 80,
"branches": 65,
"functions": 75,
"statements": 80
},
"workspace/system-thumbnails.mjs": {
"lines": 98,
"branches": 95,
+4 -1
View File
@@ -113,7 +113,10 @@ def _start_daemon_observer(observer) -> None:
def start() -> None:
try:
observer.start()
except BaseException as e: # re-raised on the caller's thread
# BaseException on purpose: a KeyboardInterrupt or SystemExit raised
# by start() is not lost on this thread; _start_daemon_observer
# re-raises it on the caller's thread below.
except BaseException as e:
raised.append(e)
starter = threading.Thread(
+12 -9
View File
@@ -37,6 +37,7 @@ from typing import Optional, Dict, Any
from PIL import Image
from .logging_config import get_logger
from .png_text import png_text
class ComfyUIMetadataExtractor:
@@ -88,12 +89,14 @@ class ComfyUIMetadataExtractor:
# Add basic file information
metadata["file_info"] = self.get_file_info(image_path, image)
# Extract ComfyUI-specific metadata from PNG text chunks
if hasattr(image, "text") and image.text:
# Extract ComfyUI-specific metadata from PNG text chunks,
# read without decoding the pixels (#523)
text = png_text(image)
if text:
# Look for ComfyUI workflow data
if "workflow" in image.text:
if "workflow" in text:
try:
workflow_data = json.loads(image.text["workflow"])
workflow_data = json.loads(text["workflow"])
metadata["workflow"] = workflow_data
# Extract text encoder nodes from workflow
@@ -107,9 +110,9 @@ class ComfyUIMetadataExtractor:
self.logger.warning(f"Failed to parse workflow JSON: {e}")
# Look for prompt data
if "prompt" in image.text:
if "prompt" in text:
try:
prompt_data = json.loads(image.text["prompt"])
prompt_data = json.loads(text["prompt"])
metadata["prompt"] = prompt_data
except json.JSONDecodeError as e:
self.logger.warning(f"Failed to parse prompt JSON: {e}")
@@ -129,13 +132,13 @@ class ComfyUIMetadataExtractor:
]
for field in metadata_fields:
if field in image.text:
if field in text:
try:
# Try to parse as JSON first
metadata[field] = json.loads(image.text[field])
metadata[field] = json.loads(text[field])
except (json.JSONDecodeError, TypeError):
# Store as string if not valid JSON
metadata[field] = image.text[field]
metadata[field] = text[field]
return (
metadata
+65
View File
@@ -0,0 +1,65 @@
"""Give glibc's free heap pages back to the OS after a long image job (#523).
Image work (decode, resize, encode) leaves image-sized free blocks in the
malloc arenas of the threads that did it; glibc keeps those pages resident.
``release_free_memory`` calls ``malloc_trim(0)`` once, which returns the free
pages of every arena and changes no allocator setting (no ``mallopt``, no
``MALLOC_ARENA_MAX``: ComfyUI owns this process's allocator).
glibc only: on Windows, macOS and musl (Alpine) it does nothing. It never
raises; a missing or failing ``malloc_trim`` is a quiet ``False``.
"""
import ctypes
import os
import sys
import threading
_lock = threading.Lock()
# Not looked up yet; after the lookup, the function or None.
_NOT_LOOKED_UP = object()
_trim = _NOT_LOOKED_UP
def _glibc() -> bool:
"""Whether this process runs on glibc (musl has no CS_GNU_LIBC_VERSION)."""
if not sys.platform.startswith("linux"):
return False
try:
version = os.confstr("CS_GNU_LIBC_VERSION")
except (AttributeError, ValueError, OSError):
return False
return bool(version) and version.startswith("glibc")
def _malloc_trim():
"""glibc's ``malloc_trim``, looked up once; None where there is none."""
global _trim
with _lock:
if _trim is _NOT_LOOKED_UP:
_trim = _lookup()
return _trim
def _lookup():
if not _glibc():
return None
try:
trim = ctypes.CDLL(None).malloc_trim
except (OSError, AttributeError):
return None
trim.argtypes = [ctypes.c_size_t]
trim.restype = ctypes.c_int
return trim
def release_free_memory() -> bool:
"""Return free heap pages to the OS once (glibc); True when it ran."""
trim = _malloc_trim()
if trim is None:
return False
try:
trim(0)
except Exception: # a foreign libc symbol: never fail the caller's job
return False
return True
+116 -22
View File
@@ -15,18 +15,36 @@ create new thread at interpreter shutdown") only once the interpreter is
marked finalizing, after the ``atexit`` callbacks. ``refused_at_shutdown``
recognises both; ``note_refusal`` logs one DEBUG line per place, with no
traceback.
One long-lived pool (#523): every ``map_parallel`` call shares one pool of
``workers`` threads (``JOB_THREAD_PREFIX``), kept between jobs, instead of a
new pool per call. Image work then reuses the same few threads, and their
malloc arenas, rather than leaving each call's image-sized buffers in a new
set of arenas. A different ``workers`` count (the Worker threads setting
changed) replaces the pool; the old one finishes what it holds and exits.
``shutdown_job_pool`` stops it (the package teardown); the next call starts
a fresh one.
"""
import logging
import threading
from concurrent.futures import ThreadPoolExecutor
import time
from concurrent.futures import ThreadPoolExecutor, wait
from .exit_signal import checked
SHUTDOWN_REFUSAL = "interpreter shutdown"
# ``submit`` on a pool already shut down (this module's teardown or a resize).
SHUT_POOL_REFUSAL = "after shutdown"
JOB_THREAD_PREFIX = "pm-worker"
# The package teardown's bound for the job threads to finish their item.
JOB_POOL_JOIN_SECONDS = 5
_logger = logging.getLogger("prompt_manager.parallel")
_noted = set()
_noted_lock = threading.Lock()
_pool_lock = threading.Lock()
_pool = None # (workers, ThreadPoolExecutor)
_retired = [] # pools replaced by a new size, finishing what they hold
def refused_at_shutdown(error):
@@ -45,39 +63,115 @@ def note_refusal(logger, what):
logger.debug(f"{what}: refused, the interpreter is exiting")
def map_parallel(func, items, workers):
"""Apply ``func`` to every item on up to ``workers`` threads, keeping order.
def job_pool_started():
"""Whether a job pool exists (the teardown stops it)."""
return _pool is not None or bool(_retired)
``workers`` of 1 (or fewer) runs on the calling thread with no pool. An
exception in ``func`` propagates to the caller, so callers that must
survive bad input wrap ``func`` themselves. A pool refused at interpreter
exit runs the items it did not start on the calling thread (#375). An item
def _job_pool(workers):
"""The shared pool of ``workers`` threads, replacing one of another size."""
global _pool
with _pool_lock:
if _pool is not None and _pool[0] == workers:
return _pool[1]
retired = _pool
_pool = (
workers,
ThreadPoolExecutor(workers, thread_name_prefix=JOB_THREAD_PREFIX),
)
current = _pool[1]
if retired is not None:
retired[1].shutdown(wait=False) # it still runs what it holds
with _pool_lock:
_retired[:] = [p for p in _retired if _alive(p)] + [retired[1]]
return current
def shutdown_job_pool(timeout=None):
"""Stop the job pool (and any pool a resize retired): queued items are
cancelled (their callers run them on their own thread), running ones are
joined up to ``timeout`` seconds. True when no job thread is left."""
global _pool
with _pool_lock:
current, _pool = _pool, None
executors = _retired[:] + ([] if current is None else [current[1]])
_retired.clear()
threads = []
for executor in executors:
# After shutdown: it takes the lock ``submit`` holds while it starts
# and records a thread, so every thread is listed and none starts.
executor.shutdown(wait=False, cancel_futures=True)
threads.extend(getattr(executor, "_threads", ()))
deadline = None if timeout is None else time.monotonic() + timeout
for thread in threads:
thread.join(None if deadline is None else max(0.0, deadline - time.monotonic()))
return not any(thread.is_alive() for thread in threads)
def _alive(executor):
return any(t.is_alive() for t in getattr(executor, "_threads", ()))
def _forget(executor):
"""Drop *executor* as the shared pool (a later call starts a new one)."""
global _pool
with _pool_lock:
if _pool is not None and _pool[1] is executor:
_pool = None
def _on_job_thread():
return threading.current_thread().name.startswith(JOB_THREAD_PREFIX + "_")
def map_parallel(func, items, workers):
"""Apply ``func`` to every item on the shared pool of ``workers``
threads, keeping order.
``workers`` below 1 counts as 1. A call made from a job thread runs on
that thread, so nested work never waits for its own pool. An exception
in ``func`` propagates to the caller once the call's other items have
stopped (those not started are dropped), so callers that must survive
bad input wrap ``func`` themselves. A pool refused at interpreter exit
runs the items it did not start on the calling thread (#375). An item
that has not started when the interpreter starts exiting raises
ExitRequested instead (#376).
"""
func = checked(func)
items = list(items)
workers = max(1, int(workers or 1))
if workers == 1 or len(items) <= 1:
if not items or _on_job_thread():
return [func(item) for item in items]
max_workers = min(workers, len(items))
with ThreadPoolExecutor(max_workers, thread_name_prefix="pm-worker") as pool:
futures = []
try:
for item in items:
futures.append(pool.submit(func, item))
except RuntimeError as error:
if not refused_at_shutdown(error):
raise
pool = _job_pool(max(1, int(workers or 1)))
futures = []
try:
for item in items:
futures.append(pool.submit(func, item))
except RuntimeError as error:
if refused_at_shutdown(error):
note_refusal(_logger, "A worker pool")
# Drop what was queued but not started; it runs here instead.
# (A refused Thread.start leaves its item queued without a future
# here: it is cancelled too, unless a busy pool thread took it in
# between, the one case where an item can run twice.)
pool.shutdown(wait=True, cancel_futures=True)
else:
return [future.result() for future in futures]
return _run_rest(func, items, futures)
_forget(pool)
pool.shutdown(wait=False, cancel_futures=True)
elif SHUT_POOL_REFUSAL not in str(error):
raise
return _collect(func, items, futures)
def _collect(func, items, futures):
"""``_run_rest``; on an error, first drop the call's items not started
and wait for its running ones, as the old per-call pool's exit did."""
try:
return _run_rest(func, items, futures)
except BaseException: # re-raised below, after the running items stop
# Wait only for what could not be cancelled (running or done). A
# future ``shutdown(cancel_futures=True)`` already cancelled stays
# CANCELLED, never notified, and ``wait`` would block on it forever
# (#593); ``cancel()`` is True for those as well.
wait([future for future in futures if not future.cancel()])
raise
def _run_rest(func, items, futures):
+116
View File
@@ -0,0 +1,116 @@
"""PNG text chunks without decoding pixels (#523).
Pillow's ``PngImageFile.text`` calls ``load()``, which decodes every pixel, to
reach text chunks written after the image data. On a scan or a busy image
monitor that is a full-size buffer per file for a few kilobytes of text.
``png_text`` returns the same dictionary as ``image.text`` for an opened,
single-frame PNG, by walking the chunks after the first IDAT with Pillow's own
chunk handlers (same decoding, limits and duplicate rule) and seeking over the
pixel data. It follows Pillow's ``load_end`` loop: chunk checksums after the
pixels are not checked, an unreadable chunk header ends the walk, and another
chunk that runs past the end of the file fails like ``ImageFile._safe_read``
(unless ``ImageFile.LOAD_TRUNCATED_IMAGES``).
The one difference is pixel data, which is never decoded: pixel data that is
undecodable, or cut short, no longer hides the text read before it. Pillow
itself accepts a file cut inside its last pixel chunk once the decoder has
all rows, and only a decode can tell those apart, so the walk never reads
less than ``.text`` did (the PR #170 "legacy parity floor").
Anything else (other formats, animated PNGs, an image already loaded) is
answered by Pillow itself.
"""
import os
import struct
from PIL import ImageFile
_PIXEL_CHUNKS = (b"IDAT", b"fdAT")
# Signature, IHDR chunk and IDAT chunk header: the earliest pixel data offset.
_FIRST_DATA_OFFSET = 8 + 25 + 8
def png_text(image) -> dict:
"""``dict(image.text)`` for an opened image, without a pixel decode.
The image stays usable: its file position and Pillow state are restored.
Non-PNG images answer ``{}`` (only PNG has text chunks in Pillow).
"""
if not _walkable(image):
return dict(getattr(image, "text", None) or {})
fp = image.fp
resume = fp.tell()
try:
return _walk_after_pixels(image.png, fp, image.tile[0][2] - 8)
finally:
fp.seek(resume)
def _walkable(image) -> bool:
"""A plain single-frame PNG whose header pass stopped at its first IDAT."""
tile = getattr(image, "tile", None)
return (
image.format == "PNG"
and getattr(image, "png", None) is not None
and getattr(image, "fp", None) is not None
and not getattr(image, "is_animated", False)
and isinstance(tile, list)
and len(tile) == 1
and tile[0][0] == "zip"
# The first IDAT's data offset; anything else is not a layout we know.
and isinstance(tile[0][2], int)
and tile[0][2] >= _FIRST_DATA_OFFSET
)
def _walk_after_pixels(stream, fp, offset: int) -> dict:
"""Pillow's ``load_end`` chunk loop from the first IDAT header, skipping pixels.
``stream`` is the image's own ``PngStream``: its text so far and its text
memory budget carry on exactly as a ``load()`` would carry them. The text
and info dictionaries are copied first, so ``image.text`` and
``image.info`` are unchanged. Like ``load_end``, a handler for a chunk
misplaced after the pixels (a second IHDR or PLTE) may still update the
stream's other header fields; Pillow ignores them once the image is open.
"""
end = fp.seek(0, os.SEEK_END)
saved = stream.im_text, stream.im_info, stream.text_memory
stream.im_text, stream.im_info = dict(saved[0]), dict(saved[1])
try:
fp.seek(offset)
while True:
try:
kind, position, length = stream.read()
except (struct.error, SyntaxError):
break
if kind == b"IEND":
break
try:
handled = kind not in _PIXEL_CHUNKS and _handle(
stream, kind, position, length
)
except UnicodeDecodeError:
break
if not handled:
if position + length > end:
if kind in _PIXEL_CHUNKS or ImageFile.LOAD_TRUNCATED_IMAGES:
break
raise OSError("Truncated File Read")
fp.seek(position + length + 4)
continue
fp.read(4) # checksum: not verified after the pixels, as in Pillow
return stream.im_text
finally:
stream.im_text, stream.im_info, stream.text_memory = saved
def _handle(stream, kind: bytes, position: int, length: int) -> bool:
"""Run Pillow's handler; ``False`` when the chunk's bytes must be skipped."""
try:
stream.call(kind, position, length)
except (EOFError, AttributeError):
# A chunk Pillow reads past (EOFError) or has no handler for.
return False
return True
+7
View File
@@ -62,6 +62,13 @@
(typeof job.title === "string" && job.title.length > 0 && job.title.length <= 200))) {
this.job = { id: job.id, title: job.title };
}
// A move refused for space (#320) carries its review, so the page
// can show what would have moved and why it cannot start.
this.plan = null;
if (this.code === "insufficient-space" && current && current.plan &&
typeof current.plan === "object" && !Array.isArray(current.plan)) {
this.plan = current.plan;
}
this.source = null;
if (this.code === "invalid-source" && current && typeof current.source === "string" &&
current.source.length > 0 && current.source.length <= 4096) {
+42 -10
View File
@@ -11,6 +11,9 @@
* - Results (`section.surface`): the outcome list (`button[aria-pressed]` with counts, in the mockup's order), a path
* filter, and the selected outcome's listed rows (at most 200 per outcome, so "Showing N of M"). Rows under Added and
* Already known open the file in the existing viewer through its listing identity (D57); the others are plain text.
* - #550: a row with no path is a record (a maintenance apply's "Prompt 12: category …"): its detail is shown as plain
* text, never opened; the filter reads it. A maintenance plan of records (every operation but Missing-file links)
* says "records" where a file job says "files" (`groupCopy`).
* - Log: `details.term` "Scan log", collapsed, holding the raw timestamped lines in `pre[tabindex=0]`.
* - D89: the tiles, the outcome list, the openable rows and the log and cancel labels are the job kind's (`jobView`):
* an image tagging job shows Tagged, Unchanged, Skipped and Errors, opens Tagged and Unchanged rows, and reads
@@ -44,6 +47,10 @@ export const DETAIL_INTERVAL_MS = 1000;
export const NOT_FOUND = 'This job is not in this session’s Activity. Jobs are kept until ComfyUI restarts.';
export const EMPTY_GROUP = 'No files in this group.';
export const NO_MATCH = 'No listed file matches this filter.';
export const EMPTY_RECORDS = 'No records in this group.';
export const NO_RECORD_MATCH = 'No listed record matches this filter.';
const FILE_FILTER = 'Filter by file path…';
const RECORD_FILTER = 'Filter records…';
export const VIEWER_HINT = 'each row opens the image in the viewer';
export const FILE_GONE = 'This file is no longer in Images. Rescan from Import and try again.';
export const OPEN_FAILED = 'The file could not be opened. Try again.';
@@ -68,10 +75,11 @@ export function parseDetail(raw) {
const groups = raw.outcomes !== null && typeof raw.outcomes === 'object' ? raw.outcomes : {};
for (const [group, rows] of Object.entries(groups)) {
if (!Array.isArray(rows)) continue;
outcomes[group] = rows.filter(row => row && isText(row.path) && row.path)
// A file row has a path; a record row (#550) has none, only its detail.
outcomes[group] = rows.filter(row => row && isText(row.path) && (row.path || (isText(row.detail) && row.detail)))
.map(row => ({ path: row.path, root: isText(row.root) ? row.root : '',
root_index: Number.isSafeInteger(row.root_index) && row.root_index >= 0 ? row.root_index : null,
detail: isText(row.detail) ? row.detail : '' }));
detail: isText(row.detail) ? row.detail : '', record: !row.path }));
}
const log = Array.isArray(raw.log) ? raw.log.filter(isText) : [];
return { job, outcomes, log };
@@ -129,11 +137,23 @@ export function resultsNote(shown, total, openable) {
return parts.length === 1 && openable && shown >= total ? `${text[0].toUpperCase()}${text.slice(1)}.` : text;
}
/** Rows whose path (or root label and path) holds the case-folded filter. */
/** Rows whose path (or root label and path), or a record's detail (#550), holds the case-folded filter. */
export function filterRows(rows, filter) {
const wanted = filter.trim().toLocaleLowerCase();
if (!wanted) return rows;
return rows.filter(row => `${row.root}/${row.path}`.toLocaleLowerCase().includes(wanted));
return rows.filter(row => (row.record ? row.detail : `${row.root}/${row.path}`).toLocaleLowerCase().includes(wanted));
}
/** #550: whether a job lists records (a maintenance plan of records), not files. */
export function listsRecords(job) {
return job?.kind === 'maintenance' && job.scope?.operation !== 'missing_links';
}
/** The empty-group, no-match and filter copy: "records" for a records job, "files" otherwise (#550). */
export function groupCopy(job) {
return listsRecords(job)
? { empty: EMPTY_RECORDS, noMatch: NO_RECORD_MATCH, placeholder: RECORD_FILTER }
: { empty: EMPTY_GROUP, noMatch: NO_MATCH, placeholder: FILE_FILTER };
}
/** The first outcome with files, so a view opens on what happened; the first outcome when nothing has yet. */
@@ -170,13 +190,16 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
bar.max = 100;
const stats = element('dl', undefined, 'stat-row');
const tileValues = new Map();
progressPart.node.append(bar, stats);
// #320 D198: a kind with phases (Move thumbnails) says which one runs and what it means, under the bar.
const phase = element('p', '', 'small');
phase.id = 'job-phase';
progressPart.node.append(bar, phase, stats);
const resultsPart = section('Results', 'job-results-title');
const search = element('label', undefined, 'search-field');
const input = element('input');
input.type = 'search';
input.placeholder = 'Filter by file path…';
input.placeholder = FILE_FILTER;
input.setAttribute('aria-label', 'Filter results');
search.append(input);
resultsPart.heading.append(search);
@@ -228,6 +251,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
function layout(job) {
view = viewOf(job);
if (!view.tiles.length) stats.remove();
if (typeof view.phase !== 'function') phase.remove();
for (const [group, label, tone] of view.tiles) {
const tile = element('div', undefined, tone ? `stat ${tone}` : 'stat');
const value = element('dd', '0');
@@ -280,9 +304,13 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
// Listed rows only ever grow, so a row keeps its node across reads: a focused row (or the viewer's trigger) stays.
let rowNodes = new Map();
function rowNode(row, index, openable) {
const key = `${selected}\n${index}\n${row.root}\n${row.path}`;
const key = `${selected}\n${index}\n${row.root}\n${row.path}\n${row.record ? row.detail : ''}`;
let node = rowNodes.get(key);
if (!node) {
if (!node && row.record) {
// #550: a record is its detail, plain text, with no open or locate action.
node = element('div', undefined, 'file-row record-row');
node.append(element('span', row.detail, 'why'));
} else if (!node) {
node = openable ? createButton('', 'file-row') : element('div', undefined, 'file-row');
node.append(element('span', row.path, 'path'), element('span', row.detail, 'why'));
if (openable) node.row = row;
@@ -300,8 +328,11 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
const nodes = [...kept.values()];
// The counter is exact; at most 200 of its files are listed, and the filter narrows those.
const total = Math.max(job.counters[selected] ?? 0, rows.length);
if (!nodes.length) nodes.push(element('p', rows.length ? NO_MATCH : EMPTY_GROUP, 'empty-state'));
const note = wanted.length ? resultsNote(wanted.length, total, openable) : '';
const copy = groupCopy(job);
input.placeholder = copy.placeholder;
if (!nodes.length) nodes.push(element('p', rows.length ? copy.noMatch : copy.empty, 'empty-state'));
const opens = openable && wanted.some(row => !row.record);
const note = wanted.length ? resultsNote(wanted.length, total, opens) : '';
if (note) nodes.push(element('p', note, 'small'));
for (const child of [...files.children]) if (!nodes.includes(child)) child.remove();
nodes.forEach((node, index) => {
@@ -341,6 +372,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
cancel.remove();
}
status.textContent = progressLine(job);
if (typeof view?.phase === 'function') phase.textContent = view.phase(job);
// D111: no value while the total is unknown; a job that ended with nothing counted shows how it ended.
showProgress(bar, job.total > 0 || job.state !== 'finished' ? job : { ...job, progress: 100 });
bar.setAttribute('aria-label', scan ? 'Scan progress' : `${job.title} progress`);

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