Compare commits
170
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
387bca2c51 | ||
|
|
e9fe586442 | ||
|
|
6d9f3fc8fb | ||
|
|
911b801883 | ||
|
|
a576cff69f | ||
|
|
cf0cef3f25 | ||
|
|
7b6430e20a | ||
|
|
611c19d2f4 | ||
|
|
2763e9f9fa | ||
|
|
3bb21c621c | ||
|
|
374ce6e7a8 | ||
|
|
e2ec6df3d4 | ||
|
|
38daedf131 | ||
|
|
1e1b92a1ba | ||
|
|
ef66d7bd3e | ||
|
|
f00d6408d8 | ||
|
|
6962fecf86 | ||
|
|
90d706c943 | ||
|
|
d17be56e1b | ||
|
|
94366938d1 | ||
|
|
e13ea3c92f | ||
|
|
da322f8c94 | ||
|
|
6c7e4d5b35 | ||
|
|
aa768f57dd | ||
|
|
f45da0e1ce | ||
|
|
d70dab56e5 | ||
|
|
772f1639a9 | ||
|
|
c01d8e3c12 | ||
|
|
96f19636e6 | ||
|
|
8146250274 | ||
|
|
00284d92da | ||
|
|
6fc6e683eb | ||
|
|
2cf9f42e73 | ||
|
|
7f45bc6df7 | ||
|
|
ad32a51497 | ||
|
|
0e95dc3431 | ||
|
|
8dfa290417 | ||
|
|
ff090c5ebb | ||
|
|
d0c747a609 | ||
|
|
37962e30eb | ||
|
|
a22272f7a5 | ||
|
|
fdda1c4ad5 | ||
|
|
42f9c344f8 | ||
|
|
6ea8c591b7 | ||
|
|
d60e4ca52f | ||
|
|
30f83f2401 | ||
|
|
6a5834e243 | ||
|
|
0a2c274a27 | ||
|
|
07f6087963 | ||
|
|
f7e6fed1af | ||
|
|
9e8864e453 | ||
|
|
0e527fd80a | ||
|
|
0cb941e894 | ||
|
|
e1f1121cb8 | ||
|
|
efe2138e4d | ||
|
|
b616a26df1 | ||
|
|
8652bb70d6 | ||
|
|
d0aa7d7080 | ||
|
|
15bbfc4044 | ||
|
|
75b0ebe69c | ||
|
|
c9ca4be8b8 | ||
|
|
7f92c4d5b8 | ||
|
|
28c386d529 | ||
|
|
bb34b9cf3b | ||
|
|
be205f3dcc | ||
|
|
ed40be066c | ||
|
|
fb092e7c25 | ||
|
|
55376b4752 | ||
|
|
113d663fce | ||
|
|
85aff0179a | ||
|
|
15d5a9197a | ||
|
|
e1bf06cb3d | ||
|
|
bbe7a4c217 | ||
|
|
ccedeba7ec | ||
|
|
fde3546574 | ||
|
|
7bcc64e0df | ||
|
|
b799485b74 | ||
|
|
db1de5eddd | ||
|
|
2992b7809d | ||
|
|
f7e0c9be2b | ||
|
|
5ef0ded757 | ||
|
|
4054449650 | ||
|
|
2737a4b1ef | ||
|
|
224fcc7803 | ||
|
|
5c5fe512c8 | ||
|
|
a7a79eb75b | ||
|
|
2a7b53a47b | ||
|
|
5b67143782 | ||
|
|
a17ecd02c1 | ||
|
|
6271d12426 | ||
|
|
45d23efcf1 | ||
|
|
4deda8542d | ||
|
|
4548beb723 | ||
|
|
4f62066c63 | ||
|
|
3e3a457fda | ||
|
|
091689f9f5 | ||
|
|
bd6c136608 | ||
|
|
af9ee4e671 | ||
|
|
6ef040b4b8 | ||
|
|
a5a1169829 | ||
|
|
9e337ed02a | ||
|
|
ab5e211019 | ||
|
|
760b2a0f48 | ||
|
|
778f4670be | ||
|
|
f485825bfb | ||
|
|
9e575ab48c | ||
|
|
2774ed921b | ||
|
|
4a2cb376da | ||
|
|
846b0b64b9 | ||
|
|
557d79c173 | ||
|
|
42aed8e9e3 | ||
|
|
9688e94b4c | ||
|
|
a7d842a51c | ||
|
|
4f273e9b21 | ||
|
|
8b8d1f0b5e | ||
|
|
0655af3713 | ||
|
|
f94eb86321 | ||
|
|
344640eb26 | ||
|
|
b137c998b3 | ||
|
|
f23fc5d966 | ||
|
|
927f1f1df7 | ||
|
|
a8c96a2c81 | ||
|
|
15e828b7a6 | ||
|
|
c3aa41d679 | ||
|
|
c50a7292c7 | ||
|
|
47d3f4d279 | ||
|
|
3fe2ee5075 | ||
|
|
4dbc553b04 | ||
|
|
8fa490a82a | ||
|
|
d4f13e40b6 | ||
|
|
86815913c1 | ||
|
|
85f93c437f | ||
|
|
940d2cc714 | ||
|
|
b8a5eaf9a6 | ||
|
|
4ba3fdbe35 | ||
|
|
33a66da2f1 | ||
|
|
eb75978da4 | ||
|
|
448227c218 | ||
|
|
1f6ddcf62a | ||
|
|
10bab420bb | ||
|
|
067320e9c5 | ||
|
|
3e925cc9ac | ||
|
|
ffc26709ad | ||
|
|
985bd15968 | ||
|
|
75fad23464 | ||
|
|
4f70b85ccc | ||
|
|
a4bb5dc2d7 | ||
|
|
8abeeaa953 | ||
|
|
da239bb7e1 | ||
|
|
2e3079c01b | ||
|
|
986663cd97 | ||
|
|
11d25afa56 | ||
|
|
3c8c0b9505 | ||
|
|
b8d6b9072d | ||
|
|
3033d088b4 | ||
|
|
c11d98fa17 | ||
|
|
ad5f03d475 | ||
|
|
ba36814894 | ||
|
|
56eed388a8 | ||
|
|
ffaf5fd3d8 | ||
|
|
2df2c63994 | ||
|
|
b5e6bec141 | ||
|
|
bd56e0420d | ||
|
|
d072245ab9 | ||
|
|
058aa98507 | ||
|
|
227bdf015f | ||
|
|
d074cd5968 | ||
|
|
a4373c0949 | ||
|
|
95661a5da1 | ||
|
|
e2a3df86e1 |
+66
@@ -0,0 +1,66 @@
|
||||
# Coverage configuration for pytest-cov
|
||||
[run]
|
||||
source = .
|
||||
omit =
|
||||
*/tests/*
|
||||
*/venv/*
|
||||
*/env/*
|
||||
*/__pycache__/*
|
||||
*/migrations/*
|
||||
*/conftest.py
|
||||
*/setup.py
|
||||
*/manage.py
|
||||
*/.tox/*
|
||||
*/node_modules/*
|
||||
*/static/*
|
||||
*/media/*
|
||||
*/htmlcov/*
|
||||
*/.claude/*
|
||||
restart_gallery.py
|
||||
standalone_tagger.py
|
||||
|
||||
branch = true
|
||||
parallel = true
|
||||
|
||||
[report]
|
||||
# Regexes for lines to exclude from consideration
|
||||
exclude_lines =
|
||||
# Have to re-enable the standard pragma
|
||||
pragma: no cover
|
||||
|
||||
# Don't complain about missing debug-only code:
|
||||
def __repr__
|
||||
if self\.debug
|
||||
|
||||
# Don't complain if tests don't hit defensive assertion code:
|
||||
raise AssertionError
|
||||
raise NotImplementedError
|
||||
|
||||
# Don't complain if non-runnable code isn't run:
|
||||
if 0:
|
||||
if __name__ == .__main__.:
|
||||
|
||||
# Don't complain about abstract methods, they aren't run:
|
||||
@(abc\.)?abstractmethod
|
||||
|
||||
# Don't complain about type checking imports
|
||||
if TYPE_CHECKING:
|
||||
|
||||
# Don't complain about logger calls
|
||||
\.logger\.(debug|info|warning|error|critical)
|
||||
|
||||
ignore_errors = true
|
||||
show_missing = true
|
||||
skip_covered = false
|
||||
precision = 2
|
||||
|
||||
[html]
|
||||
directory = htmlcov
|
||||
title = ComfyUI PromptManager Test Coverage Report
|
||||
|
||||
[xml]
|
||||
output = coverage.xml
|
||||
|
||||
[json]
|
||||
output = coverage.json
|
||||
pretty_print = true
|
||||
@@ -0,0 +1,6 @@
|
||||
[flake8]
|
||||
max-line-length = 88
|
||||
extend-ignore = E203, W503
|
||||
exclude = reference/, .git, __pycache__, web/, venv/, htmlcov/, build/, dist/, .claude/, docs/, standalone_tagger.py
|
||||
per-file-ignores =
|
||||
tests/*:E402
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"required_status_checks": {
|
||||
"strict": true,
|
||||
"contexts": [
|
||||
"py3.10 on ubuntu-latest",
|
||||
"py3.11 on ubuntu-latest",
|
||||
"py3.12 on ubuntu-latest",
|
||||
"py3.10 on windows-latest",
|
||||
"py3.11 on windows-latest",
|
||||
"py3.12 on windows-latest",
|
||||
"py3.10 on macos-latest",
|
||||
"py3.11 on macos-latest",
|
||||
"py3.12 on macos-latest",
|
||||
"coverage gate (85%)",
|
||||
"black and flake8",
|
||||
"frontend modules",
|
||||
"viewerjs"
|
||||
]
|
||||
},
|
||||
"enforce_admins": true,
|
||||
"required_pull_request_reviews": null,
|
||||
"restrictions": null,
|
||||
"allow_force_pushes": false,
|
||||
"allow_deletions": false
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
# Python dependencies (requirements.txt / setup.cfg / pyproject.toml)
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "sunday"
|
||||
time: "06:00"
|
||||
timezone: "America/Los_Angeles"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "security"
|
||||
commit-message:
|
||||
prefix: "deps(pip)"
|
||||
include: "scope"
|
||||
rebase-strategy: "auto"
|
||||
# Optional: group non-breaking updates to reduce PR noise
|
||||
groups:
|
||||
python-minor-and-patch:
|
||||
applies-to: "version-updates"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# GitHub Actions workflow dependencies
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "sunday"
|
||||
time: "06:15"
|
||||
timezone: "America/Los_Angeles"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
commit-message:
|
||||
prefix: "deps(actions)"
|
||||
include: "scope"
|
||||
rebase-strategy: "auto"
|
||||
groups:
|
||||
actions-minor-and-patch:
|
||||
applies-to: "version-updates"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# Maintainer browser test dependencies; Viewer.js itself has an asset-aware updater.
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/tools/vendor"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
allow:
|
||||
- dependency-name: "@playwright/test"
|
||||
labels:
|
||||
- "dependencies"
|
||||
commit-message:
|
||||
prefix: "deps(vendor-tools)"
|
||||
@@ -0,0 +1,62 @@
|
||||
name: Code Quality
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-quality-${{ hashFiles('requirements-dev.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-quality-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Check formatting with Black
|
||||
run: black --check --diff .
|
||||
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
# Fail on syntax errors and undefined names
|
||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
# Warnings as non-blocking
|
||||
flake8 . --count --exit-zero --max-line-length=88 --statistics --exclude=reference/,.git,__pycache__,web/
|
||||
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install bandit
|
||||
run: pip install bandit[toml]
|
||||
|
||||
- name: Security scan
|
||||
run: bandit -r . -ll --exclude=./tests,./reference,./venv -q || true
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
if: ${{ github.repository_owner == 'ComfyAssets' }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: true
|
||||
- name: Publish Custom Node
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Tests
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: py${{ matrix.python-version }} on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-dev.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
|
||||
coverage:
|
||||
name: coverage gate (85%)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-dev.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Measure coverage
|
||||
run: |
|
||||
python -m coverage run -m unittest discover -s tests
|
||||
python -m coverage combine
|
||||
python -m coverage report --fail-under=85
|
||||
|
||||
lint:
|
||||
name: black and flake8
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install black flake8
|
||||
- run: black --check .
|
||||
- run: flake8 .
|
||||
|
||||
js-test:
|
||||
name: frontend modules
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# Pure-JS frontend modules, tested with the runner's preinstalled Node
|
||||
- name: Run JS tests
|
||||
run: node --test tests/js/*.test.js
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Viewer.js vendor and compatibility
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
viewerjs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-dev.txt
|
||||
- run: python -m pip install -r requirements-dev.txt
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: tools/vendor/package-lock.json
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
- run: npm run --prefix tools/vendor check
|
||||
- run: npm run --prefix tools/vendor test
|
||||
- run: npx --prefix tools/vendor playwright install --with-deps chromium firefox webkit
|
||||
- name: Frontend unit, viewer, workflow, and coverage checks
|
||||
run: npm run --prefix tools/vendor test:coverage
|
||||
- name: Firefox and WebKit page workflows
|
||||
run: npm run --prefix tools/vendor test:workflows -- --project firefox --project webkit
|
||||
- name: Upload browser traces and frontend coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-test-reports
|
||||
path: |
|
||||
tools/vendor/test-results/
|
||||
coverage/js/
|
||||
!coverage/js/raw/**
|
||||
retention-days: 14
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Update vendored Viewer.js
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 7 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Stable version to vendor; leave blank for the latest npm release'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: viewerjs-update
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
actions: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: tools/vendor/package-lock.json
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
- name: Update the dependency and regenerate assets
|
||||
env:
|
||||
VIEWER_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [ -n "$VIEWER_VERSION" ]; then
|
||||
npm run --prefix tools/vendor update -- --version "$VIEWER_VERSION"
|
||||
else
|
||||
npm run --prefix tools/vendor update
|
||||
fi
|
||||
- name: Detect changes
|
||||
id: changes
|
||||
run: |
|
||||
if git diff --quiet -- tools/vendor/package.json tools/vendor/package-lock.json web/lib/viewerjs; then
|
||||
echo 'changed=false' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'changed=true' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- if: steps.changes.outputs.changed == 'true'
|
||||
run: |
|
||||
npm run --prefix tools/vendor check
|
||||
npm run --prefix tools/vendor test
|
||||
node --test tests/js/*.test.js
|
||||
npx --prefix tools/vendor playwright install --with-deps chromium
|
||||
npm run --prefix tools/vendor test:browser
|
||||
- name: Open the update PR
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
env:
|
||||
BASE_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
version=$(node -p "require('./web/lib/viewerjs/provenance.json').version")
|
||||
branch="deps/viewerjs-$version"
|
||||
existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --state all --head "$branch" --json number --jq 'length')
|
||||
if [ "$existing" != '0' ]; then
|
||||
echo "A PR already exists for Viewer.js $version; leaving it for review."
|
||||
exit 0
|
||||
fi
|
||||
remote=$(git ls-remote origin "refs/heads/$branch")
|
||||
if [ -z "$remote" ]; then
|
||||
git switch -c "$branch"
|
||||
git add tools/vendor/package.json tools/vendor/package-lock.json web/lib/viewerjs
|
||||
git -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com' commit -m "deps: update vendored Viewer.js to $version"
|
||||
git push origin "HEAD:refs/heads/$branch"
|
||||
fi
|
||||
gh pr create --repo "$GITHUB_REPOSITORY" --base "$BASE_BRANCH" --head "$branch" --title "deps: update vendored Viewer.js to $version" --body-file tools/vendor/update-pr.md
|
||||
gh workflow run viewerjs-check.yml --repo "$GITHUB_REPOSITORY" --ref "$branch"
|
||||
+36
@@ -1,3 +1,29 @@
|
||||
# Tailwind standalone CLI (downloaded by `make css-setup`)
|
||||
tailwindcss
|
||||
|
||||
# AI tooling / local config
|
||||
.claude/
|
||||
.playwright-mcp/
|
||||
.serena/
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
|
||||
# Local files
|
||||
# Local PromptManager state (config.json holds API keys and machine paths)
|
||||
config.json
|
||||
resume.sh
|
||||
*.backup_*
|
||||
promptmanager_logs/
|
||||
custom_data/
|
||||
data/
|
||||
docs/
|
||||
logs/
|
||||
standalone_tagger.py
|
||||
prompts.db
|
||||
prompts.db-shm
|
||||
prompts.db-wal
|
||||
prompts.db-journal
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
@@ -16,6 +42,8 @@ eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
!web/lib/
|
||||
!web/lib/**
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
@@ -172,3 +200,11 @@ cython_debug/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Maintainer-only JavaScript tooling and browser test artifacts
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Combined frontend coverage reports
|
||||
coverage/
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: black
|
||||
name: black
|
||||
entry: black --line-length=88 --target-version=py310
|
||||
language: system
|
||||
types: [python]
|
||||
|
||||
- id: flake8
|
||||
name: flake8
|
||||
entry: flake8 --show-source
|
||||
language: system
|
||||
types: [python]
|
||||
|
||||
- id: bandit
|
||||
name: bandit
|
||||
entry: bash -c 'bandit "$@" -ll -q 2>&1; true'
|
||||
language: system
|
||||
types: [python]
|
||||
exclude: ^(tests/|reference/)
|
||||
|
||||
- id: tests
|
||||
name: tests
|
||||
entry: python -m unittest discover tests/ -v
|
||||
language: system
|
||||
pass_filenames: false
|
||||
stages: [pre-push]
|
||||
@@ -0,0 +1,43 @@
|
||||
TAILWIND_VERSION := 4.1.18
|
||||
TAILWIND_CLI := ./tailwindcss
|
||||
UNAME_S := $(shell uname -s)
|
||||
UNAME_M := $(shell uname -m)
|
||||
|
||||
ifeq ($(UNAME_S),Darwin)
|
||||
ifeq ($(UNAME_M),arm64)
|
||||
TAILWIND_PLATFORM := tailwindcss-macos-arm64
|
||||
else
|
||||
TAILWIND_PLATFORM := tailwindcss-macos-x64
|
||||
endif
|
||||
else
|
||||
ifeq ($(UNAME_M),aarch64)
|
||||
TAILWIND_PLATFORM := tailwindcss-linux-arm64
|
||||
else
|
||||
TAILWIND_PLATFORM := tailwindcss-linux-x64
|
||||
endif
|
||||
endif
|
||||
|
||||
TAILWIND_URL := https://github.com/tailwindlabs/tailwindcss/releases/download/v$(TAILWIND_VERSION)/$(TAILWIND_PLATFORM)
|
||||
|
||||
.PHONY: css css-watch css-setup clean-css
|
||||
|
||||
## Download Tailwind standalone CLI (dev only, not committed)
|
||||
css-setup:
|
||||
@if [ ! -f $(TAILWIND_CLI) ]; then \
|
||||
echo "Downloading Tailwind CSS v$(TAILWIND_VERSION) CLI..."; \
|
||||
curl -sLo $(TAILWIND_CLI) $(TAILWIND_URL) && chmod +x $(TAILWIND_CLI); \
|
||||
else \
|
||||
echo "Tailwind CLI already present"; \
|
||||
fi
|
||||
|
||||
## Rebuild compiled CSS (run after changing Tailwind classes in HTML/JS)
|
||||
css: css-setup
|
||||
$(TAILWIND_CLI) -i web/lib/tailwind/input.css -o web/lib/tailwind/styles.css --minify
|
||||
|
||||
## Watch mode for development
|
||||
css-watch: css-setup
|
||||
$(TAILWIND_CLI) -i web/lib/tailwind/input.css -o web/lib/tailwind/styles.css --watch
|
||||
|
||||
## Remove downloaded CLI binary
|
||||
clean-css:
|
||||
rm -f $(TAILWIND_CLI)
|
||||
@@ -1,48 +1,12 @@
|
||||
# ComfyUI Prompt Manager
|
||||
|
||||
A comprehensive ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, automatic image gallery system, and powerful ComfyUI workflow metadata analysis using SQLite.
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
# 🚀 Prompt Manager v2 Rewrite
|
||||
|
||||
We’ve been hard at work on a **complete rewrite** of Prompt Manager.
|
||||
The new version brings over all features from v1, introduces a dark theme (v2 look), and adds **negative prompt support**.
|
||||
|
||||
## ✨ What’s New
|
||||
- Unified dark theme UI (v2)
|
||||
- Table view (for power users) and Card view (for quick browsing)
|
||||
- Negative prompt field with clear highlighting
|
||||
- Streamlined toolbar & filters
|
||||
|
||||
---
|
||||
|
||||
## 📊 Progress
|
||||
|
||||
**Rewrite completion status:**
|
||||
|
||||
- [█████████░░] 85% done
|
||||
|
||||
✅ Core features migrated
|
||||
✅ Dark UI overhaul
|
||||
✅ Table + Card views
|
||||
✅ Negative prompt handling
|
||||
⬜ API integration & real data wiring
|
||||
⬜ Community feedback + polish pass
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Call for Beta Testers
|
||||
|
||||
We’d love your help testing Prompt Manager v2 before release!
|
||||
|
||||
- Try the new UI locally
|
||||
- Report bugs, UI quirks, or feature requests
|
||||
- Share feedback on usability & performance
|
||||
|
||||
👉 Open an [Issue](../../issues) with feedback or join the discussion in [Discussions](../../discussions).
|
||||
|
||||
---
|
||||
|
||||
💡 Thanks to everyone who has supported this project—your input drives its evolution!
|
||||
A comprehensive ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, automatic image gallery system, folder-based organization, LoRA Manager integration, and powerful ComfyUI workflow metadata analysis using SQLite.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -67,12 +31,24 @@ A text-focused variant that outputs raw STRING for maximum flexibility:
|
||||
- **🔗 Text Concatenation**: Built-in prepend/append functionality for dynamic text assembly
|
||||
- **⚡ Lightweight Processing**: No CLIP encoding overhead for text-only workflows
|
||||
|
||||
### **PromptSearchList** (Batch Processing Node)
|
||||
|
||||
A search node that outputs prompts as a list for batch processing workflows:
|
||||
|
||||
- **🔍 Database Search**: Search prompts by text, category, tags, and rating
|
||||
- **📋 List Output**: Uses `OUTPUT_IS_LIST` for native batch processing support
|
||||
- **🔗 Batch Compatible**: Connect directly to batch processing nodes like String OutputList
|
||||
- **⚡ Read-Only**: Lightweight search operation without database writes
|
||||
|
||||
This node enables powerful batch workflows by allowing you to search your prompt database and process multiple prompts in a single execution.
|
||||
|
||||
### **Shared Features**
|
||||
|
||||
Both nodes include the complete PromptManager feature set:
|
||||
|
||||
- **💾 Persistent Storage**: Automatically saves all prompts to a local SQLite database
|
||||
- **🔍 Advanced Search**: Query past prompts with text search, category filtering, and metadata
|
||||
- **📁 Folder Filter**: Browse and filter prompts by output subdirectory
|
||||
- **🖼️ Automatic Image Gallery**: Automatically links generated images to their prompts
|
||||
- **🏷️ Rich Metadata**: Add categories, tags, ratings, notes, and workflow names to prompts
|
||||
- **🚫 Duplicate Prevention**: Uses SHA256 hashing to detect and prevent duplicate storage
|
||||
@@ -81,6 +57,8 @@ Both nodes include the complete PromptManager feature set:
|
||||
- **🔬 Workflow Analysis**: Extract and analyze ComfyUI workflow data from PNG images
|
||||
- **📋 Metadata Viewer**: Standalone tool for analyzing ComfyUI-generated images
|
||||
- **🛠️ System Management**: Built-in diagnostics, backup/restore, and maintenance tools
|
||||
- **🏷️ AI AutoTag**: Automatically tag your image collection using WD14 or JoyCaption vision models
|
||||
- **🔗 LoRA Manager Integration**: Import LoRA metadata and preview images from [ComfyUI-Lora-Manager](https://github.com/willmiao/ComfyUI-Lora-Manager)
|
||||
|
||||

|
||||
|
||||
@@ -124,6 +102,7 @@ The comprehensive gallery system provides complete management of all ComfyUI out
|
||||
- **Performance Optimized**: Efficient scanning with thumbnail generation and lazy loading
|
||||
|
||||
#### **🎬 Full Video Support**
|
||||
|
||||
- **Native Video Playback**: HTML5 video player with custom controls for all supported formats
|
||||
- **Video Thumbnails**: Automatic thumbnail generation with OpenCV, ffmpeg, or placeholder fallback
|
||||
- **Playback Controls**: Autoplay, mute, loop settings with persistent preferences
|
||||
@@ -132,6 +111,7 @@ The comprehensive gallery system provides complete management of all ComfyUI out
|
||||
- **Mixed Media Gallery**: Videos and images integrated seamlessly in the same interface
|
||||
|
||||
#### **🔍 Professional Image Viewer (ViewerJS Integration)**
|
||||
|
||||
- **Full-Featured Viewing**: Zoom, rotation, slideshow modes with professional controls
|
||||
- **Advanced Navigation**: Smooth transitions, thumbnail strip, and keyboard shortcuts
|
||||
- **Zoom Controls**: Mouse wheel zoom, drag to pan, and touch gesture support
|
||||
@@ -150,6 +130,7 @@ The comprehensive gallery system provides complete management of all ComfyUI out
|
||||
- **Cross-Platform Support**: Works on desktop, tablet, and mobile devices
|
||||
|
||||
#### **⚡ Performance Features**
|
||||
|
||||
- **Smart Thumbnail System**: Generates optimized thumbnails for faster loading
|
||||
- **Lazy Loading**: Load content as needed for smooth browsing of large galleries
|
||||
- **Grid & List Views**: Choose between compact grid or detailed list display
|
||||
@@ -158,6 +139,9 @@ The comprehensive gallery system provides complete management of all ComfyUI out
|
||||
|
||||
_Powered by [ViewerJS](https://github.com/fengyuanchen/viewerjs) for professional image viewing capabilities._
|
||||
|
||||
Viewer.js is vendored locally. See [Viewer.js maintenance](tools/vendor/README.md)
|
||||
for reproducible updates, verification, and automated update PRs.
|
||||
|
||||
### 🔬 ComfyUI Workflow Analysis
|
||||
|
||||
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
|
||||
@@ -183,7 +167,7 @@ _Powered by [ViewerJS](https://github.com/fengyuanchen/viewerjs) for professiona
|
||||
|
||||
- **Full-text search** across all stored prompts
|
||||
- **Category filtering** for organized prompt collections
|
||||
- **Tag-based search** with support for multiple tags
|
||||
- **Tag-based search** with support for multiple tags, with autocomplete that narrows to tags seen together with the ones already typed
|
||||
- **Rating filters** to find your best prompts
|
||||
- **Date range filtering** for temporal searches
|
||||
- **Recent prompts** quick access
|
||||
@@ -279,6 +263,20 @@ For text-only workflows or when you need STRING output:
|
||||
- **Append Text**: Text added after your main prompt
|
||||
4. **Connect the STRING output** to any node that accepts text input
|
||||
|
||||
#### Using PromptSearchList (Batch Processing)
|
||||
|
||||
For batch processing workflows that need multiple prompts:
|
||||
|
||||
1. **Add the PromptSearchList node** to your workflow
|
||||
2. **Configure search filters**:
|
||||
- **text**: Search for prompts containing this text
|
||||
- **category**: Filter by specific category
|
||||
- **tags**: Comma-separated tags to filter by (e.g., "anime, detailed")
|
||||
- **min_rating**: Minimum rating (0-5) to include
|
||||
- **limit**: Maximum number of results (1-1000, default 50)
|
||||
3. **Connect the prompts output** to batch-compatible nodes like String OutputList
|
||||
4. **Each matching prompt** will be processed as a separate batch item
|
||||
|
||||
#### Shared Metadata Options
|
||||
|
||||
Both nodes support the same metadata fields:
|
||||
@@ -296,22 +294,30 @@ Both nodes will automatically save prompts to the database and link any generate
|
||||
The comprehensive gallery system provides complete access to all your ComfyUI output:
|
||||
|
||||
#### **📁 Accessing the Gallery**
|
||||
|
||||
1. **Open the admin interface** at `http://localhost:8188/prompt_manager/admin`
|
||||
2. **Click the "🖼️ Gallery" button** to open the output-wide gallery
|
||||
3. **Browse all content** from your ComfyUI output directory
|
||||
|
||||
#### **🎬 Video & Image Support**
|
||||
|
||||
- **Mixed Media Viewing**: Images and videos appear together in the same gallery
|
||||
- **Video Thumbnails**: Videos show with play button overlays and 🎬 icons
|
||||
- **Smart Filtering**: Filter by media type (All, Images, Videos) using the dropdown
|
||||
|
||||
#### **⚙️ Gallery Settings**
|
||||
|
||||
Configure your viewing experience in the settings panel:
|
||||
- **Performance Settings**: Grid columns, image quality, lazy loading
|
||||
|
||||
- **Display Settings**: Default view, items per page, grid columns, media info, infinite scrolling
|
||||
- **Performance Settings**: Image quality, lazy loading
|
||||
- **Video Settings**: Autoplay, mute by default, loop videos
|
||||
- **Thumbnail Management**: Generate optimized thumbnails for faster loading
|
||||
- **Thumbnail Management**: Generate optimized thumbnails for faster loading (runs on the configured worker threads; a run that stops at the per-run cap tells you how many remain)
|
||||
- **Find Duplicate Images**: Hashes every file on the worker threads with a progress bar; close the window and it keeps running, results wait in a pill
|
||||
- **Rescan Output Folder**: Drops the server-side listing cache and rewalks the folders
|
||||
|
||||
#### **🖼️ Image Viewing Experience**
|
||||
|
||||
1. **Click any image** to open the professional viewer with:
|
||||
- **Full-screen viewing** with zoom, rotation, and slideshow controls
|
||||
- **Integrated metadata panel** showing workflow data and generation parameters
|
||||
@@ -323,6 +329,7 @@ Configure your viewing experience in the settings panel:
|
||||
- **Space** for slideshow mode
|
||||
|
||||
#### **🎬 Video Viewing Experience**
|
||||
|
||||
1. **Click any video** to open the video player with:
|
||||
- **HTML5 video controls** with play, pause, seek, and fullscreen
|
||||
- **Custom control buttons** for mute/unmute and loop toggle
|
||||
@@ -331,6 +338,7 @@ Configure your viewing experience in the settings panel:
|
||||
2. **Playback settings** are remembered across sessions
|
||||
|
||||
#### **🔍 Advanced Features**
|
||||
|
||||
- **Real-time Search**: Search across filenames and metadata
|
||||
- **View Modes**: Switch between grid and list views
|
||||
- **Sorting Options**: Sort by date, size, or filename
|
||||
@@ -356,14 +364,119 @@ Use the standalone metadata viewer to analyze any ComfyUI-generated PNG:
|
||||
Import existing ComfyUI images into your database:
|
||||
|
||||
1. **Open admin dashboard** and click **"📸 Scan Images"**
|
||||
2. **Start scan** to analyze your entire output directory
|
||||
3. **Monitor progress** with real-time status updates
|
||||
2. **Start scan** to analyze every configured output directory
|
||||
3. **Monitor progress** with real-time status updates. The scan runs on the server: closing the window only minimizes it to a progress pill, and reloading the page re-attaches to a running scan
|
||||
4. **Review results** showing:
|
||||
- Images found and processed
|
||||
- Prompts extracted and linked
|
||||
- Any errors or issues encountered
|
||||
5. **Access imported data** through the normal gallery interface
|
||||
|
||||
The scan reads PNG text chunks and A1111-style parameters, and, when `ffprobe` is installed, the prompt ComfyUI's video savers embed in MP4/MOV/WebM files. Files are read on the number of **worker threads** set in Settings → Performance (default: half your cores), which makes a large library several times faster.
|
||||
|
||||
### 🏷️ AI AutoTag
|
||||
|
||||
Automatically tag your entire image collection using AI vision models:
|
||||
|
||||
#### **Model Options**
|
||||
|
||||
- **WD14 SwinV2 / WD14 ViT**: Fast ONNX-based classifiers (~400MB) that output Danbooru tags with confidence scores — no prompt needed, adjustable confidence thresholds
|
||||
- **JoyCaption Beta One FP16**: Full precision model for highest quality tagging (requires more VRAM)
|
||||
- **JoyCaption Beta One GGUF (FP8)**: Quantized model for lower VRAM usage with minimal quality loss
|
||||
|
||||
#### **Two Tagging Modes**
|
||||
|
||||
1. **AutoTag (Batch Mode)**: Tag your entire collection automatically
|
||||
- Click **"🏷️ AutoTag"** in the admin dashboard
|
||||
- Choose your model type (FP16 or GGUF)
|
||||
- Select how to handle already-tagged images:
|
||||
- **Skip images with existing tags**: Preserve your meticulous manual tagging work
|
||||
- **Re-tag all images**: Overwrite existing tags with fresh AI analysis
|
||||
- Customize the system prompt to match your collection style
|
||||
- Monitor real-time progress as images are processed
|
||||
|
||||
2. **Review Mode**: Tag images one-by-one with approval
|
||||
- Review each image and its AI-generated tags before applying
|
||||
- Edit, add, or remove tags before saving
|
||||
- Skip images you don't want to tag
|
||||
- Perfect for curating high-quality tag assignments
|
||||
|
||||
#### **Handling Existing Tags**
|
||||
|
||||
For users who have meticulously tagged their collections from the start:
|
||||
- AutoTag respects your existing work with the **"Skip tagged"** option
|
||||
- **"auto-scanned"** placeholder tags don't count as real tags
|
||||
- Choose to re-tag specific images while preserving others
|
||||
- Review mode asks before overwriting on each image
|
||||
|
||||
#### **Customization**
|
||||
|
||||
Adjust the system prompt to match your tagging style:
|
||||
- Focus on specific attributes (style, mood, composition, subjects)
|
||||
- Match your existing tag vocabulary
|
||||
- Optimize for your collection's theme (anime, photography, landscapes, etc.)
|
||||
|
||||
#### **Requirements**
|
||||
|
||||
- Sufficient VRAM for the chosen model (FP16 requires more, GGUF is lighter)
|
||||
- Models are downloaded automatically on first use
|
||||
- Feature requests and improvements are welcome!
|
||||
|
||||
### 📁 Folder Filter
|
||||
|
||||

|
||||
|
||||
Organize and filter your prompt library by output subdirectory. The dashboard search panel includes a **Folder** dropdown that lists all subdirectories where your images are stored.
|
||||
|
||||
- **Automatic Detection**: Folders are extracted from your image paths — no configuration needed
|
||||
- **Multi-Directory Support**: Works across all configured gallery scan directories
|
||||
- **Quick Filtering**: Select a folder from the dropdown to instantly filter prompts to that subdirectory
|
||||
|
||||
> **Note:** If you have an existing prompt library from before the folder feature was added, you will need to **rescan your image library** (click **Scan Images** in the admin dashboard) to populate folder data for your existing prompts.
|
||||
|
||||
### 🔗 LoRA Manager Integration
|
||||
|
||||

|
||||
|
||||
If you use [ComfyUI-Lora-Manager](https://github.com/willmiao/ComfyUI-Lora-Manager), PromptManager can import your LoRA metadata, trigger words, and example images directly into your prompt database. This lets you search, tag, and browse your LoRA collection alongside your regular prompts.
|
||||
|
||||
> **WIP:** LoRA Manager support is a work in progress — this was a highly requested feature. Please [open issues](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues) for any bugs or feature requests.
|
||||
|
||||
#### **Enabling the Integration**
|
||||
|
||||
1. Open the admin dashboard and click **Settings**
|
||||
2. Scroll to the **Integrations** section — PromptManager will auto-detect if LoRA Manager is installed
|
||||
|
||||

|
||||
|
||||
3. Toggle the **LoraManager** switch to enable
|
||||
4. Optionally enable **Auto-inject trigger words** to automatically append LoRA trigger words when `<lora:name:weight>` is detected in your prompts
|
||||
5. Click **Save Settings** (requires a ComfyUI restart to take effect)
|
||||
|
||||
#### **Importing LoRA Data**
|
||||
|
||||
Click **Import LoRA Data** in the settings panel to start the import. A progress popup shows real-time status as each LoRA is processed:
|
||||
|
||||
- Scans all LoRA directories (including `extra_model_paths.yaml` paths) for metadata
|
||||
- Downloads example images from CivitAI as 512px thumbnails
|
||||
- Creates a prompt entry for each LoRA with trigger words, example prompts, and preview images
|
||||
- All imported LoRAs are tagged with `lora-manager` category for easy filtering
|
||||
|
||||

|
||||
|
||||
After import, filter by the `lora-manager` category to browse your LoRA collection.
|
||||
|
||||
#### **CivitAI API Key (Optional)**
|
||||
|
||||
A CivitAI API key is **required if your library contains NSFW LoRAs** — CivitAI blocks unauthenticated access to NSFW preview images. Without a key, only SFW preview images are downloaded.
|
||||
|
||||
To add your key:
|
||||
1. Get your API key from [civitai.com/user/account](https://civitai.com/user/account)
|
||||
2. Paste it into the **CivitAI API Key** field in the Integrations settings
|
||||
3. Save and re-import to download any previously skipped images
|
||||
|
||||
> **Note:** Re-importing is safe — PromptManager skips LoRAs that were already imported. To do a fresh import, the previous `lora-manager` data is cleared automatically before re-scanning.
|
||||
|
||||
### 🌐 Web Interface Features
|
||||
|
||||
The comprehensive web interface provides:
|
||||
@@ -471,9 +584,29 @@ PromptManagerText → Text Processor → Style Applicator → Final Text Node
|
||||
"cyberpunk city" → style processing → "neon-lit cyberpunk metropolis at night"
|
||||
```
|
||||
|
||||
### PromptSearchList (Batch Processing) Examples
|
||||
|
||||
#### Example 6: Batch Processing with Tags
|
||||
|
||||
```
|
||||
Tags: "anime, detailed"
|
||||
Limit: 10
|
||||
→ Outputs: List of up to 10 prompts with both "anime" and "detailed" tags
|
||||
→ Each prompt is processed as a separate batch item
|
||||
```
|
||||
|
||||
#### Example 7: Category-Based Batch
|
||||
|
||||
```
|
||||
Category: "portraits"
|
||||
Min Rating: 4
|
||||
Limit: 20
|
||||
→ Outputs: List of up to 20 highly-rated portrait prompts for batch generation
|
||||
```
|
||||
|
||||
### Traditional Examples
|
||||
|
||||
#### Example 6: Abstract Art
|
||||
#### Example 8: Abstract Art
|
||||
|
||||
```
|
||||
Input: "Swirling colors in an abstract geometric pattern"
|
||||
@@ -524,10 +657,12 @@ CREATE TABLE generated_images (
|
||||
|
||||
- **`prompt_manager.py`** - Main ComfyUI node implementation (CLIP encoding)
|
||||
- **`prompt_manager_text.py`** - Text-only node implementation (STRING output)
|
||||
- **`prompt_search_list.py`** - Batch search node implementation (LIST output)
|
||||
- **`database/models.py`** - Database schema and connection management
|
||||
- **`database/operations.py`** - CRUD operations and search functionality
|
||||
- **`py/api.py`** - Web API endpoints for the interface
|
||||
- **`py/api/`** - Web API route modules (prompts, images, tags, lora integration, etc.)
|
||||
- **`py/config.py`** - Configuration management
|
||||
- **`py/lora_utils.py`** - LoRA Manager integration utilities
|
||||
- **`utils/hashing.py`** - SHA256 hashing for deduplication
|
||||
- **`utils/validators.py`** - Input validation and sanitization
|
||||
- **`utils/image_monitor.py`** - Automatic image detection system
|
||||
@@ -537,7 +672,9 @@ CREATE TABLE generated_images (
|
||||
- **`utils/diagnostics.py`** - System diagnostics and health checks
|
||||
- **`web/admin.html`** - Advanced admin dashboard with metadata panel
|
||||
- **`web/index.html`** - Simple web interface
|
||||
- **`web/prompt_manager.js`** - JavaScript functionality
|
||||
- **`web/comfy/prompt_manager.js`** - ComfyUI canvas extension (the only file ComfyUI loads)
|
||||
- **`web/js/admin.js`**, **`web/js/gallery.js`** - Dashboard and gallery JavaScript
|
||||
- **`web/js/tags-page.js`** - Tag management JavaScript
|
||||
- **`web/metadata.html`** - Standalone PNG metadata viewer
|
||||
|
||||
### File Structure
|
||||
@@ -547,14 +684,16 @@ ComfyUI_PromptManager/
|
||||
├── __init__.py # Node registration
|
||||
├── prompt_manager.py # Main node implementation (CLIP encoding)
|
||||
├── prompt_manager_text.py # Text-only node implementation (STRING output)
|
||||
├── prompt_search_list.py # Batch search node implementation (LIST output)
|
||||
├── database/
|
||||
│ ├── __init__.py
|
||||
│ ├── models.py # Database schema
|
||||
│ └── operations.py # Database operations
|
||||
├── py/
|
||||
│ ├── __init__.py
|
||||
│ ├── api.py # Web API endpoints
|
||||
│ └── config.py # Configuration
|
||||
│ ├── api/ # Web API route modules
|
||||
│ ├── config.py # Configuration
|
||||
│ └── lora_utils.py # LoRA Manager integration
|
||||
├── utils/
|
||||
│ ├── __init__.py
|
||||
│ ├── hashing.py # Hashing utilities
|
||||
@@ -566,9 +705,15 @@ ComfyUI_PromptManager/
|
||||
│ └── diagnostics.py # System diagnostics
|
||||
├── web/
|
||||
│ ├── admin.html # Advanced admin dashboard
|
||||
│ ├── gallery.html # Image gallery
|
||||
│ ├── index.html # Simple web interface
|
||||
│ ├── metadata.html # Standalone metadata viewer
|
||||
│ └── prompt_manager.js # JavaScript functionality
|
||||
│ ├── comfy/
|
||||
│ │ └── prompt_manager.js # ComfyUI canvas extension (WEB_DIRECTORY)
|
||||
│ └── js/
|
||||
│ ├── admin.js # Dashboard JavaScript
|
||||
│ ├── gallery.js # Gallery JavaScript
|
||||
│ └── tags-page.js # Tag management JavaScript
|
||||
├── tests/
|
||||
│ ├── __init__.py
|
||||
│ └── test_basic.py # Test suite
|
||||
@@ -582,11 +727,24 @@ ComfyUI_PromptManager/
|
||||
|
||||
### Database Settings
|
||||
|
||||
You can customize the database path by modifying the configuration:
|
||||
You can customize the database path by creating a `config.json` file in the extension root:
|
||||
|
||||
```python
|
||||
# In py/config.py
|
||||
DATABASE_PATH = "custom_path/prompts.db"
|
||||
```json
|
||||
{
|
||||
"database": {
|
||||
"default_path": "/path/to/your/prompts.db"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Relative paths are resolved against the extension directory. Absolute paths are used as-is, which is recommended for Docker deployments where the working directory may not persist:
|
||||
|
||||
```json
|
||||
{
|
||||
"database": {
|
||||
"default_path": "/data/custom_nodes/ComfyUI_PromptManager/prompts.db"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Gallery & Monitoring Settings
|
||||
@@ -617,11 +775,15 @@ Configure the web interface behavior:
|
||||
# Web UI settings (PromptManagerConfig class)
|
||||
RESULT_TIMEOUT = 5 # Seconds to auto-hide results in ComfyUI node
|
||||
WEBUI_DISPLAY_MODE = 'newtab' # 'popup' or 'newtab' for Web UI button
|
||||
INFINITE_SCROLL = False # Dashboard list loads the next page on scroll by default
|
||||
SHOW_TEST_BUTTON = False # Show API test button in node UI
|
||||
```
|
||||
|
||||
All of these are editable in the dashboard Settings modal and saved to `config.json` (which is private to the installation and holds the CivitAI key, so it is never committed).
|
||||
|
||||
### Performance Tuning
|
||||
|
||||
- **Worker threads** (Settings → Performance): threads used by the image rescan, thumbnail generation and duplicate detection. Default is half the detected cores, maximum is the core count. Pillow releases the GIL while decoding, so the scan and thumbnails scale almost linearly; keep a few cores free while ComfyUI is generating.
|
||||
- The database automatically creates indexes for optimal search performance
|
||||
- Regular `VACUUM` operations keep the database optimized
|
||||
- Consider backing up the database periodically
|
||||
@@ -664,10 +826,15 @@ db.model.backup_database("backup_prompts.db")
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
cd KikoTextEncode
|
||||
python -m pytest tests/ -v
|
||||
cd ComfyUI_PromptManager
|
||||
pip install -r requirements-dev.txt
|
||||
python -m unittest discover -s tests # Python (what CI runs)
|
||||
node --test tests/js/*.test.js # frontend pure modules
|
||||
python -m coverage run -m unittest discover -s tests && python -m coverage combine && python -m coverage report
|
||||
```
|
||||
|
||||
CI runs the suite on Linux, Windows and macOS for Python 3.10 to 3.12 with an 85% coverage gate. Tests that delete a database file first check that nothing in the process still holds it open (`tests/open_handles.py`), so a connection leak fails on Linux the way it would on Windows.
|
||||
|
||||
### Code Style
|
||||
|
||||
The project follows PEP 8 guidelines with:
|
||||
@@ -709,7 +876,7 @@ The project follows PEP 8 guidelines with:
|
||||
For debugging, you can enable verbose logging in the node:
|
||||
|
||||
```python
|
||||
# Add to kiko_text_encode.py
|
||||
# Add to prompt_manager.py
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
```
|
||||
@@ -726,15 +893,19 @@ MIT License - see LICENSE file for details.
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Completed in v3.0.0
|
||||
### Recently Completed
|
||||
|
||||
- **✅ PNG Metadata Analysis**: Complete ComfyUI workflow extraction from images
|
||||
- **✅ Standalone Metadata Viewer**: Dedicated tool for analyzing any ComfyUI image
|
||||
- **✅ Advanced Admin Dashboard**: Comprehensive management interface with modern UI
|
||||
- **✅ Integrated Metadata Panel**: Real-time workflow analysis in image viewer
|
||||
- **✅ Bulk Image Scanning**: Mass import of existing ComfyUI images
|
||||
- **✅ System Management Tools**: Backup, restore, diagnostics, and maintenance
|
||||
- **✅ Enhanced Error Handling**: Robust PNG parsing with NaN value cleaning
|
||||
- **✅ Security & Reliability Hardening (3.3.0)**: validated gallery roots, contained file serving, safe backups and restores, background scans, 85%+ test coverage on all three operating systems
|
||||
- **✅ Worker Threads**: scan, thumbnails and duplicate detection run on a configurable thread pool
|
||||
- **✅ Tag Autocomplete & Infinite Scrolling**: faster browsing in the dashboard and gallery
|
||||
- **✅ Queue-Time Run Counting (3.2.4)**: run counts and image linking follow the ComfyUI queue
|
||||
- **✅ LoRA Manager Integration**: Import LoRA metadata, trigger words, and preview images
|
||||
- **✅ Folder Filter**: Browse and filter prompts by output subdirectory
|
||||
- **✅ Multi-Directory Gallery**: Scan multiple output directories simultaneously
|
||||
- **✅ WD14 Tagger**: Fast ONNX-based auto-tagging with Danbooru tags
|
||||
- **✅ Tailwind v4 Migration**: ComfyUI theme token system for consistent styling
|
||||
- **✅ Tag Management Page**: Dedicated page for tag search, rename, merge, and delete
|
||||
- **✅ Junction Table Tags**: Normalized tag storage with proper foreign keys
|
||||
|
||||
### Planned Features
|
||||
|
||||
@@ -742,20 +913,108 @@ MIT License - see LICENSE file for details.
|
||||
- **🤝 Collaboration**: Share prompt collections with other users
|
||||
- **🧠 AI Suggestions**: Recommend similar prompts based on metadata analysis
|
||||
- **📈 Advanced Analytics**: Detailed usage statistics and trends with workflow insights
|
||||
- **🔌 Plugin System**: Support for third-party extensions and custom analyzers
|
||||
- **🎨 Enhanced Batch Processing**: Advanced bulk operations with metadata editing
|
||||
- **🔄 Workflow Templates**: Save and reuse common workflow patterns
|
||||
- **📊 Visual Analytics**: Charts and graphs for prompt effectiveness analysis
|
||||
|
||||
### Integration Ideas
|
||||
|
||||
- **Auto-tagging**: Use AI to automatically categorize prompts
|
||||
- **Workflow linking**: Connect prompts to specific workflow templates
|
||||
- **Image analysis**: Analyze generated images to improve suggestions
|
||||
- **Version control**: Track prompt iterations and effectiveness
|
||||
|
||||
## Changelog
|
||||
|
||||
### v3.3.1 (Proxy Routing Fix)
|
||||
|
||||
- **🌐 Proxy support**: the dashboard, gallery, tag page and metadata viewer now work when ComfyUI is reached through a proxy mount such as SwarmUI's `/ComfyBackendDirect/`. Scripts, images, API calls, progress streams and gallery folder filters stay under the mount, and the canvas node's buttons use ComfyUI's `fileURL()`. Direct ComfyUI URLs are unchanged. Thanks to @ghosttigerllc-bit ([#164](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/164))
|
||||
- **📦 Dependencies**: the runtime minimums stay at `Pillow>=8.0.0` and `watchdog>=2.1.0`, so updating Prompt Manager never forces upgrades in the shared ComfyUI environment
|
||||
|
||||
### v3.3.0 (Hardening, Background Jobs & Worker Threads)
|
||||
|
||||
- **🔒 Security**: gallery roots restricted to ComfyUI directories and validated wherever they are read; media-only, fail-closed image serving; Windows path escape in static routes closed; quote-safe HTML escaping with inline handlers moved to data attributes; the CivitAI key is never returned and only sent to HTTPS CivitAI hosts; auto-tag paths contained; side-effect routes are POST-only; backups include the WAL and restores are size-capped, integrity-checked and applied through SQLite's online backup API
|
||||
- **🧵 Worker Threads**: a Settings slider (default half the cores) used by the image rescan, thumbnail generation and duplicate detection. Measured: a 1 GB sample scanned in 2.5 s instead of 18 s at 8 threads
|
||||
- **🔁 Background Jobs**: the image rescan and the duplicate scan run on the server, stream progress, survive a closed tab, minimize to a progress pill and re-attach after a reload
|
||||
- **🎬 Video Prompts**: the scan reads the prompt ComfyUI's video savers embed in MP4/MOV/WebM containers (needs `ffprobe`)
|
||||
- **🖼️ Thumbnails**: written atomically and served under versioned URLs so a regenerated thumbnail never shows a stale cached copy; the per-run cap applies only to thumbnails that still need generating and the gallery says how many remain
|
||||
- **🏷️ Tag Autocomplete**: the dashboard tag box suggests tags, narrowed to those that co-occur with the tags already typed
|
||||
- **♾️ Infinite Scrolling**: optional in the gallery and the dashboard list (saved default plus a session toggle)
|
||||
- **⚙️ Gallery Settings** now apply at load; grid columns, media info and the Rescan button work as labelled
|
||||
- **🐛 Correctness**: run counts increment when ComfyUI dequeues a prompt; negative prompts are never traced; per-thread SQLite connections and idempotent migrations (an old rebuild could delete every image row); `truncate_logs` no longer rolls the log over; the ComfyUI canvas loads only the canvas extension; Windows console banners are ASCII-safe; subfolders use `/` on every platform
|
||||
- **🧪 Tests & CI**: 1,460 Python and 142 JavaScript tests (from 510 and 26), coverage 96% with an 85% gate, CI on Linux, Windows and macOS for Python 3.10–3.12, flake8 clean
|
||||
- **⚠️ API changes**: image paths in responses are relative to the ComfyUI tree (use `url`); `autotag/download`, `autotag/start` and the thumbnail progress stream are POST-only; `lora/status` returns `has_civitai_api_key` instead of the key; `/search` is paginated
|
||||
|
||||
### v3.2.4 (Recently Used Sorting & Queue-Time Run Counts)
|
||||
|
||||
- **🕒 Recently Used Sort**: sort the dashboard by last use, most used or rating, server-side across pages
|
||||
- **🔢 Run Counts**: `run_count` and `last_used_at` are recorded through the ComfyUI queue hook, so cached nodes still count
|
||||
- **🔗 Positive-Prompt Image Linking**: generated images link to the positive prompt traced through the queued graph
|
||||
|
||||
### v3.2.1 (LoRA Manager Integration)
|
||||
|
||||
- **🔗 LoRA Manager Integration**: Import LoRA metadata, trigger words, and CivitAI example images from [ComfyUI-Lora-Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) into your prompt database
|
||||
- **💉 Auto-Inject Trigger Words**: Optionally append LoRA trigger words when `<lora:name:weight>` is detected in prompts during encoding
|
||||
- **🔑 CivitAI API Key Support**: Authenticate with CivitAI to download NSFW preview images
|
||||
- **📥 Import Progress Modal**: Real-time SSE streaming progress during LoRA import with per-model status
|
||||
|
||||
> **Note:** LoRA Manager support is a WIP — this was a highly requested feature. Please [open issues](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues) for any bugs or feature requests.
|
||||
|
||||
### v3.2.0 (Folder Filter & QoL Improvements)
|
||||
|
||||
- **📁 Folder Filter**: New folder dropdown in the search panel to filter prompts by output subdirectory
|
||||
- **📂 Multi-Directory Gallery Scan**: Configure multiple output directories and browse them all in one gallery
|
||||
- **🖼️ Filmstrip Prompt Display**: Show prompt text and copy button in the filmstrip image viewer
|
||||
- **🖱️ Click-to-Close Viewer**: Click outside the image viewer to close it
|
||||
- **🐛 Bug Fixes**: Fixed database path config, UnboundLocalError in text inputs, node caching skip, diagnostics singleton lifecycle
|
||||
|
||||
### v3.1.0 (WD14 Tagger, Tailwind v4 & Major Refactors)
|
||||
|
||||
- **🏷️ WD14 Tagger Support**: Added WD14 SwinV2 and WD14 ViT as new auto-tag model options — fast ONNX-based classifiers (~400MB) that output Danbooru tags with confidence scores, no prompt needed
|
||||
- **🎚️ Threshold Controls**: Adjustable general tag (default 0.35) and character tag (default 0.85) confidence thresholds for WD14 models via UI sliders
|
||||
- **🎨 Tailwind v4 Migration**: Complete frontend redesign with ComfyUI theme token system (`--pm-*` design tokens) that adapts to ComfyUI's color palette
|
||||
- **🏗️ API Refactor**: Split monolithic `api.py` into domain-specific route modules for better maintainability
|
||||
- **🗃️ Junction Table Tags**: Migrated tag storage from JSON column to normalized junction tables with proper foreign keys
|
||||
- **📄 Tags Page**: Dedicated tag management page with search, rename, merge, and delete operations
|
||||
- **🔍 Load More Button**: Progressive prompt loading instead of traditional pagination
|
||||
- **🔒 Security Hardening**: Resolved 15 GitHub code scanning alerts, prevented stack trace exposure in SSE errors
|
||||
- **🧪 Test Coverage**: Added comprehensive unit tests for validators, hashing, metadata, config, and prompt tracker; API integration tests; database tests with CI pipeline
|
||||
- **⚙️ CI/CD Improvements**: Added code quality scanning, security scanning, Dependabot, and pre-commit hooks with Black formatting
|
||||
- **🐛 Bug Fixes**: Fixed custom image scan directory config (#108), gallery crash on thumbnail linking, broken imports after API refactor, widget value sync issues, and prepend/append metadata tag filtering in auto-tagger
|
||||
|
||||
### v3.0.26 (Top Bar Button)
|
||||
|
||||
- **🔘 Top Bar PM Button**: Added a "PM" button to the ComfyUI top bar for quick access to the admin dashboard
|
||||
- **🚀 No Node Required**: Open PromptManager without having to add the node to your workflow first
|
||||
- **🎨 Teal Button Design**: Distinctive teal-colored button positioned before the settings gear
|
||||
- **🔄 Frontend Compatibility**: Works with both new ComfyUI frontend (v1.3.1+) and legacy versions
|
||||
|
||||
### v3.0.25 (Film Strip & QoL Improvements)
|
||||
|
||||
- **🎞️ Film Strip Preview**: Backported from V2 - thumbnail strip under each prompt showing linked images
|
||||
- **🖼️ Quick Image Viewer**: Click any filmstrip thumbnail to open full-size image viewer
|
||||
- **📸 Smart Thumbnails**: Automatically uses generated thumbnails for faster loading, falls back to full images
|
||||
- **⬆️ Auto-Scroll on Pagination**: Page navigation now smoothly scrolls to top after loading
|
||||
|
||||
### v3.0.24 (Manual Prompt Creation)
|
||||
|
||||
- **➕ Add Prompt Button**: New "Add Prompt" button in the admin dashboard Bulk Actions bar
|
||||
- **📝 Direct Prompt Creation**: Create prompts directly from the UI without generating images
|
||||
- **🛡️ Protection from Pruning**: New prompts are automatically protected from orphan cleanup by default
|
||||
- **🏷️ Full Metadata Support**: Set category, tags, rating, and notes when creating prompts
|
||||
- **🔒 Orphan Protection Toggle**: Option to protect or unprotect prompts from maintenance cleanup
|
||||
|
||||
### v3.0.23 (AI AutoTag Release)
|
||||
|
||||
- **🏷️ AI AutoTag**: Automatically tag your entire image collection using JoyCaption vision models
|
||||
- **🤖 Dual Model Support**: Choose between JoyCaption Beta One FP16 (high quality) or GGUF FP8 (lower VRAM)
|
||||
- **📋 Two Tagging Modes**: Batch mode for bulk tagging, Review mode for one-by-one approval
|
||||
- **⚙️ Smart Tag Handling**: Skip already-tagged images or re-tag all - respects your existing work
|
||||
- **✏️ Customizable Prompts**: Adjust the system prompt to match your collection style and tag vocabulary
|
||||
- **🔄 Real-time Progress**: SSE-based streaming progress updates during batch operations
|
||||
- **📦 Tag Accordion UI**: Collapsible tag display shows first row with expandable section for large tag sets
|
||||
- **🎯 Review Confirmation**: Prompts before overwriting existing tags in review mode
|
||||
|
||||
### v3.0.10 (Batch Processing Node)
|
||||
|
||||
- **📋 PromptSearchList Node**: New node that searches the prompt database and outputs results as a list
|
||||
- **🔗 Batch Processing Support**: Uses `OUTPUT_IS_LIST` for native ComfyUI batch processing compatibility
|
||||
- **🔍 Full Search Capabilities**: Search by text, category, tags, minimum rating, and result limit
|
||||
- **⚡ Lightweight Operation**: Read-only search with no database writes
|
||||
- **🎯 Workflow Integration**: Connect directly to batch processing nodes like String OutputList
|
||||
|
||||
### v3.0.9 (Output-Wide Gallery & Video Support)
|
||||
|
||||
- **📁 Output-Wide Gallery Mode**: Complete gallery redesign to show ALL content from ComfyUI output directory
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# 🔐 Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security updates are provided for the actively maintained versions of
|
||||
**ComfyUI_PromptManager**.
|
||||
|
||||
| Version | Supported |
|
||||
|-----------------|-----------|
|
||||
| `main` / latest | ✅ |
|
||||
| Older releases | ❌ |
|
||||
|
||||
Users are encouraged to stay up to date with the latest version.
|
||||
|
||||
---
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
For most security-related issues, **please open a GitHub issue** in this
|
||||
repository.
|
||||
|
||||
When opening an issue:
|
||||
- Clearly describe the problem
|
||||
- Include steps to reproduce
|
||||
- Mention the affected version or commit
|
||||
- Avoid posting secrets, tokens, or private data
|
||||
|
||||
### Sensitive Issues
|
||||
|
||||
If you believe the issue:
|
||||
- Could enable remote code execution
|
||||
- Exposes credentials or private data
|
||||
- Is actively exploitable in the wild
|
||||
|
||||
Please use **GitHub Security Advisories** instead:
|
||||
- Go to the **Security** tab
|
||||
- Click **Report a vulnerability**
|
||||
|
||||
---
|
||||
|
||||
## Response Expectations
|
||||
|
||||
- Issues are typically reviewed within **a few days**
|
||||
- Valid issues will be labeled and tracked publicly
|
||||
- Fixes may be discussed openly and merged transparently
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
This policy applies to:
|
||||
- Code contained in this repository
|
||||
- Default configurations and documented usage
|
||||
|
||||
It does not cover:
|
||||
- Third-party nodes, models, or extensions
|
||||
- User workflows, prompts, or local environment issues
|
||||
|
||||
---
|
||||
|
||||
## Thank You
|
||||
|
||||
Community reports and transparency help keep the project healthy.
|
||||
Thanks for contributing responsibly.
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
# Testing and merge confidence
|
||||
|
||||
Routine changes should merge after the required checks pass and the diff is
|
||||
reviewed. Add a regression test for each bug; prove it fails before the fix.
|
||||
|
||||
## What runs
|
||||
|
||||
| Layer | Real components | Replaced boundaries |
|
||||
| --- | --- | --- |
|
||||
| Python tests | Domain logic, SQLite, API handlers, filesystem checks | ComfyUI host and ML runtimes where needed |
|
||||
| JavaScript tests | Frontend modules and controller methods | DOM/network fixtures for focused edge cases |
|
||||
| Viewer checks | Production Viewer.js and frontend methods in Chromium desktop/touch | Controlled images and page fixtures |
|
||||
| Page workflows | Production HTML, startup, click handlers, HTTP routes, SQLite, PNG files, thumbnails | ComfyUI host services and the tagging model |
|
||||
|
||||
Page workflows cover save/edit/reload, duplicate-edit rejection, gallery image
|
||||
identity, Tags selection and viewer cleanup, delayed responses, auto-tag review
|
||||
and persistence, and infinite scrolling without replacing existing cards.
|
||||
They run in Chromium, Firefox, WebKit, and Chromium touch emulation. No GPU,
|
||||
model downloads, running ComfyUI instance, or personal configuration is required.
|
||||
The server binds to localhost and creates disposable directories and a database;
|
||||
its reset endpoint exists only in the test harness.
|
||||
|
||||
## Run locally
|
||||
|
||||
Use a Python environment containing `requirements-dev.txt` and Node.js 24:
|
||||
|
||||
```sh
|
||||
python -m unittest discover -s tests -q
|
||||
npm ci --prefix tools/vendor --ignore-scripts
|
||||
npx --prefix tools/vendor playwright install --with-deps chromium firefox webkit
|
||||
npm run --prefix tools/vendor test
|
||||
npm run --prefix tools/vendor test:coverage
|
||||
npm run --prefix tools/vendor test:workflows -- --project firefox --project webkit
|
||||
```
|
||||
|
||||
`PM_TEST_PYTHON` selects the Python executable; `PM_TEST_PORT` selects the local
|
||||
port (default 8765). Set these environment variables when Python is outside PATH
|
||||
or the port is occupied. Test servers are never reused. On unsupported Linux
|
||||
distributions, browser system dependencies may need manual installation; CI uses
|
||||
Ubuntu. For a quick browser run, use `test:workflows -- --project chromium`.
|
||||
|
||||
## Coverage and failures
|
||||
|
||||
Python CI enforces 85% combined statement/branch coverage. Frontend coverage merges
|
||||
Node V8 unit coverage with Chromium page-workflow coverage through c8. Every
|
||||
`web/js/*.js` file appears, including untouched files at zero coverage. The
|
||||
controller tests provide V8 filenames so their executed code is counted too.
|
||||
|
||||
`tools/vendor/frontend-coverage-baseline.json` holds a floor for every file and
|
||||
metric. CI rejects regressions, missing measurements, and new files without a
|
||||
reviewed baseline. These floors reflect current coverage, with two percentage
|
||||
points of allowance for runtime/timing differences; they do not claim 80% coverage
|
||||
of the large controllers. New files need at least 80% line coverage when added via
|
||||
the baseline updater. Raise floors with `npm run --prefix tools/vendor
|
||||
coverage:update`; it reruns the suites and cannot lower an existing floor.
|
||||
Review baseline changes like code changes.
|
||||
|
||||
The `viewerjs` check runs on every PR, including backend-only changes. GitHub
|
||||
artifacts named `frontend-test-reports` contain HTML coverage, a JSON summary,
|
||||
and failure screenshots/traces. Open traces with Playwright's `show-trace` tool.
|
||||
|
||||
Live checks remain useful for ComfyUI host compatibility, real inference/GPU or
|
||||
model dependency changes, and visual design changes. The automated fixtures do
|
||||
not certify those environments or every existing product feature.
|
||||
|
||||
## Required merge checks
|
||||
|
||||
`.github/branch-protection.json` records the required checks: all nine Python/OS
|
||||
combinations, Python coverage, formatting, frontend modules, and `viewerjs`.
|
||||
The branch must be current with main, and administrators follow the same checks.
|
||||
It does not require an additional human approval.
|
||||
|
||||
A repository administrator applies the configuration after the new workflow is
|
||||
passing, then verifies it:
|
||||
|
||||
```sh
|
||||
gh api --method PUT repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection \
|
||||
--input .github/branch-protection.json
|
||||
gh api repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection/required_status_checks
|
||||
```
|
||||
|
||||
Update the manifest whenever required job names change. Keep the frontend check
|
||||
free of path filters so a required check is never silently skipped.
|
||||
+78
-10
@@ -1,11 +1,12 @@
|
||||
"""
|
||||
ComfyUI_PromptManager: A comprehensive ComfyUI custom node that extends text encoding
|
||||
with persistent prompt storage, advanced search capabilities, and an automatic image
|
||||
ComfyUI_PromptManager: A comprehensive ComfyUI custom node that extends text encoding
|
||||
with persistent prompt storage, advanced search capabilities, and an automatic image
|
||||
gallery system.
|
||||
|
||||
This module provides two main node types:
|
||||
- PromptManager: CLIP encoding node that outputs CONDITIONING
|
||||
- PromptManagerText: Text-only node that outputs STRING with prepend/append functionality
|
||||
- PromptManagerText: Text-only node that outputs STRING with prepend/append
|
||||
functionality
|
||||
|
||||
Both nodes share the same database backend for persistent prompt storage and include
|
||||
an automatic image gallery system that monitors ComfyUI output directories and links
|
||||
@@ -13,7 +14,7 @@ generated images to their source prompts.
|
||||
|
||||
Features:
|
||||
- SQLite-based persistent prompt storage with deduplication
|
||||
- Advanced search and categorization system
|
||||
- Advanced search and categorization system
|
||||
- Real-time image gallery with metadata extraction
|
||||
- Web-based admin dashboard for prompt management
|
||||
- Comprehensive logging and diagnostics
|
||||
@@ -37,21 +38,26 @@ def get_version():
|
||||
return "unknown"
|
||||
|
||||
|
||||
from .prompt_manager import PromptManager
|
||||
from .prompt_manager_text import PromptManagerText
|
||||
from .prompt_manager import PromptManager # noqa: E402
|
||||
from .prompt_manager_text import PromptManagerText # noqa: E402
|
||||
from .prompt_search_list import PromptSearchList # noqa: E402
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"PromptManager": PromptManager,
|
||||
"PromptManagerText": PromptManagerText,
|
||||
"PromptSearchList": PromptSearchList,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PromptManager": "Prompt Manager",
|
||||
"PromptManagerText": "Prompt Manager Text",
|
||||
"PromptSearchList": "Prompt Search List",
|
||||
}
|
||||
|
||||
# Define path to web directory for UI components
|
||||
WEB_DIRECTORY = "web"
|
||||
# Canvas extension only. ComfyUI loads every .js under this directory into the
|
||||
# canvas page, so the admin/gallery bundles and vendored libraries under web/
|
||||
# are served by our own routes instead (see py/api).
|
||||
WEB_DIRECTORY = "web/comfy"
|
||||
|
||||
# Add API routes (same pattern as ComfyUI_Assets)
|
||||
try:
|
||||
@@ -76,15 +82,77 @@ except Exception as e:
|
||||
|
||||
logger = get_logger("prompt_manager.init")
|
||||
logger.error(f"Failed to register API routes: {e}")
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Shared database, set up with the monitoring system below. Stays None when that
|
||||
# fails so the queue hook can report the database as unavailable instead of crashing.
|
||||
_global_db = None
|
||||
|
||||
# Start image monitoring globally at module import time
|
||||
# This ensures images are linked regardless of which node is used in the workflow
|
||||
try:
|
||||
from .database.operations import PromptDatabase
|
||||
from .utils.image_monitor import get_image_monitor
|
||||
from .utils.prompt_tracker import get_prompt_tracker
|
||||
from .utils.logging_config import get_logger
|
||||
|
||||
_init_logger = get_logger("prompt_manager.init")
|
||||
_init_logger.info("Starting global image monitoring system...")
|
||||
|
||||
# Initialize database and get singleton instances
|
||||
_global_db = PromptDatabase()
|
||||
_global_prompt_tracker = get_prompt_tracker(_global_db)
|
||||
_global_image_monitor = get_image_monitor(_global_db, _global_prompt_tracker)
|
||||
|
||||
# Start monitoring
|
||||
_global_image_monitor.start_monitoring()
|
||||
_init_logger.info("Global image monitoring system started successfully")
|
||||
|
||||
except Exception as e:
|
||||
try:
|
||||
from .utils.logging_config import get_logger
|
||||
|
||||
_init_logger = get_logger("prompt_manager.init")
|
||||
_init_logger.error(f"Failed to start image monitoring: {e}")
|
||||
except Exception:
|
||||
print(f"[ComfyUI-PromptManager] Warning: Failed to start image monitoring: {e}")
|
||||
|
||||
|
||||
def _db_factory():
|
||||
"""The shared database for the queue hook; called lazily per request."""
|
||||
if _global_db is None:
|
||||
raise RuntimeError("PromptManager database unavailable")
|
||||
return _global_db
|
||||
|
||||
|
||||
# Count prompt runs when ComfyUI dequeues workflows (it skips unchanged nodes, so
|
||||
# node execution alone misses re-runs). Separate from monitoring so either can fail.
|
||||
try:
|
||||
from .py.config import server_instance
|
||||
from .utils.usage_tracking import register_queue_hook
|
||||
|
||||
register_queue_hook(server_instance, _db_factory)
|
||||
except Exception as e:
|
||||
try:
|
||||
from .utils.logging_config import get_logger
|
||||
|
||||
get_logger("prompt_manager.init").error(f"Failed to register usage hook: {e}")
|
||||
except Exception:
|
||||
import logging
|
||||
|
||||
logging.getLogger("prompt_manager.init").error(
|
||||
f"Failed to register usage hook: {e}"
|
||||
)
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
# Print startup message with loaded tools
|
||||
print()
|
||||
print(f"\033[94m[ComfyUI-PromptManager] Version:\033[0m {get_version()}")
|
||||
for node_key, display_name in NODE_DISPLAY_NAME_MAPPINGS.items():
|
||||
print(f"🫶 \033[94mLoaded:\033[0m {display_name}")
|
||||
# ASCII only: a Windows console using a legacy code page cannot print emoji,
|
||||
# and a UnicodeEncodeError here would abort loading the whole node.
|
||||
print(f" \033[94mLoaded:\033[0m {display_name}")
|
||||
print(f"\033[94mTotal: {len(NODE_CLASS_MAPPINGS)} tools loaded\033[0m")
|
||||
print()
|
||||
|
||||
@@ -8,4 +8,4 @@ search capabilities, metadata management, and image tracking functionality.
|
||||
from .operations import PromptDatabase
|
||||
from .models import PromptModel
|
||||
|
||||
__all__ = ["PromptDatabase", "PromptModel"]
|
||||
__all__ = ["PromptDatabase", "PromptModel"]
|
||||
|
||||
+647
-126
@@ -2,62 +2,167 @@
|
||||
Database schema and models for KikoTextEncode prompt storage.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import datetime
|
||||
import shutil
|
||||
import sqlite3
|
||||
import os
|
||||
from typing import Optional
|
||||
import threading
|
||||
import weakref
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from typing import Set, Tuple
|
||||
|
||||
# Import logging system
|
||||
try:
|
||||
from ..utils.logging_config import get_logger
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, current_dir)
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
# last_used_at is UTC ISO text so ORDER BY can compare it as text. The backfill
|
||||
# writes milliseconds (SQLite's %f); live writes use microseconds so runs in the
|
||||
# same millisecond still order correctly. Both forms sort correctly together.
|
||||
ISO_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%f+00:00"
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
"""Current UTC time as ISO text with microseconds.
|
||||
|
||||
Example: 2026-10-07T18:39:06.758123+00:00
|
||||
"""
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="microseconds"
|
||||
)
|
||||
|
||||
|
||||
def normalize_image_path(path: str) -> str:
|
||||
"""Canonical form of an image path for generated_images.file_path.
|
||||
|
||||
Absolute, with '..' and '.' collapsed and the platform's case folding
|
||||
applied, so two spellings of one file (or the same basename in two date
|
||||
folders) compare the way the filesystem would.
|
||||
"""
|
||||
return os.path.normcase(os.path.normpath(os.path.abspath(path)))
|
||||
|
||||
|
||||
# How long a connection waits for a locked database before giving up. Thirty
|
||||
# seconds covers a scan or a thumbnail run writing while ComfyUI saves images,
|
||||
# and the slow disks of CI runners.
|
||||
BUSY_TIMEOUT_SECONDS = 30
|
||||
SQLITE_HEADER = b"SQLite format 3\x00"
|
||||
REQUIRED_PROMPT_COLUMNS = ("id", "text", "created_at")
|
||||
|
||||
|
||||
class PromptModel:
|
||||
"""Database model for prompt storage and schema management."""
|
||||
|
||||
def __init__(self, db_path: str = "prompts.db"):
|
||||
|
||||
# Paths (normalised, see _schema_key) whose schema has been created and
|
||||
# migrated in this process. Many PromptDatabase instances point at the
|
||||
# same file; only the first one pays for the schema check.
|
||||
_initialized_paths: Set[str] = set()
|
||||
_init_lock = threading.Lock()
|
||||
# Every live model, so a shutdown or a test teardown can close them all
|
||||
# before the database file is deleted (Windows refuses otherwise).
|
||||
_instances: "weakref.WeakSet[PromptModel]" = weakref.WeakSet()
|
||||
_instances_lock = threading.Lock()
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
"""
|
||||
Initialize the database model.
|
||||
|
||||
|
||||
Args:
|
||||
db_path: Path to the SQLite database file
|
||||
db_path: Absolute path to the SQLite database file
|
||||
"""
|
||||
self.logger = get_logger('prompt_manager.database.models')
|
||||
self.logger = get_logger("prompt_manager.database.models")
|
||||
self.logger.debug(f"Initializing database model with path: {db_path}")
|
||||
self.db_path = db_path
|
||||
# One connection per thread (see get_connection); every open connection
|
||||
# is also tracked here so restore_from_file can close them all.
|
||||
self._local = threading.local()
|
||||
self._connections: Set[sqlite3.Connection] = set()
|
||||
self._conn_lock = threading.Lock()
|
||||
# Bumped by _close_all_connections so threads holding a closed
|
||||
# connection reopen on their next get_connection().
|
||||
self._generation = 0
|
||||
with PromptModel._instances_lock:
|
||||
PromptModel._instances.add(self)
|
||||
self._ensure_database_exists()
|
||||
|
||||
|
||||
def _ensure_database_exists(self) -> None:
|
||||
"""
|
||||
Create database and tables if they don't exist.
|
||||
|
||||
|
||||
Sets up the database schema including tables for prompts and generated images,
|
||||
creates necessary indexes, and applies any pending migrations.
|
||||
|
||||
|
||||
Raises:
|
||||
Exception: If database creation fails
|
||||
"""
|
||||
key = self._schema_key()
|
||||
with PromptModel._init_lock:
|
||||
if key in PromptModel._initialized_paths and os.path.exists(self.db_path):
|
||||
return
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path, timeout=BUSY_TIMEOUT_SECONDS)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.execute(f"PRAGMA busy_timeout = {BUSY_TIMEOUT_SECONDS * 1000}")
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
self._create_tables(conn)
|
||||
self._create_indexes(conn)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating database: {e}")
|
||||
raise
|
||||
PromptModel._initialized_paths.add(key)
|
||||
|
||||
def _schema_key(self) -> str:
|
||||
"""Normalised identity of db_path for the once-per-process schema cache."""
|
||||
return os.path.normcase(os.path.realpath(self.db_path))
|
||||
|
||||
@classmethod
|
||||
def reset_schema_cache(cls) -> None:
|
||||
"""Forget which databases were initialised (tests and restores)."""
|
||||
with cls._init_lock:
|
||||
cls._initialized_paths.clear()
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _table_rebuild(self, conn: sqlite3.Connection):
|
||||
"""Run a copy-and-swap table rebuild with foreign keys disabled.
|
||||
|
||||
DROP TABLE on a parent table with foreign_keys ON performs an implicit
|
||||
DELETE that cascades into child rows (generated_images, prompt_tags),
|
||||
so the rebuild runs with the pragma off. The pragma is a no-op inside
|
||||
a transaction, hence the commits around it. Errors roll back and
|
||||
propagate after the pragma is restored.
|
||||
"""
|
||||
conn.commit()
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
# Explicit BEGIN: in sqlite3's legacy transaction mode DDL autocommits,
|
||||
# so without it a failed copy would leave the *_new table behind.
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
self._create_tables(conn)
|
||||
self._create_indexes(conn)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating database: {e}")
|
||||
yield
|
||||
conn.commit()
|
||||
except BaseException:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
def _create_tables(self, conn: sqlite3.Connection) -> None:
|
||||
"""
|
||||
Create the prompts and generated_images tables with all required columns.
|
||||
|
||||
|
||||
Args:
|
||||
conn: Active database connection
|
||||
|
||||
|
||||
Creates:
|
||||
- prompts table: Stores prompt text and metadata
|
||||
- generated_images table: Links generated images to their source prompts
|
||||
@@ -72,10 +177,12 @@ class PromptModel:
|
||||
tags TEXT,
|
||||
rating INTEGER CHECK(rating >= 1 AND rating <= 5),
|
||||
notes TEXT,
|
||||
hash TEXT UNIQUE
|
||||
hash TEXT UNIQUE,
|
||||
last_used_at TIMESTAMP,
|
||||
run_count INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
# Create images table for gallery functionality
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS generated_images (
|
||||
@@ -91,23 +198,51 @@ class PromptModel:
|
||||
workflow_data TEXT,
|
||||
prompt_metadata TEXT,
|
||||
parameters TEXT,
|
||||
FOREIGN KEY (prompt_id) REFERENCES prompts(id) ON DELETE CASCADE
|
||||
file_path TEXT,
|
||||
FOREIGN KEY (prompt_id) REFERENCES prompts(id) ON DELETE CASCADE,
|
||||
UNIQUE(prompt_id, file_path)
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
# Create normalized tag tables (junction table pattern)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS prompt_tags (
|
||||
prompt_id INTEGER NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (prompt_id, tag_id),
|
||||
FOREIGN KEY (prompt_id) REFERENCES prompts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# Check if we need to migrate from old schema with workflow_name
|
||||
self._migrate_workflow_name_removal(conn)
|
||||
|
||||
|
||||
# Fix foreign key data type mismatch
|
||||
self._migrate_foreign_key_types(conn)
|
||||
|
||||
|
||||
# Migrate JSON tags to normalized junction tables
|
||||
self._migrate_json_tags_to_junction(conn)
|
||||
|
||||
# Usage tracking columns (after any migration that rebuilds prompts)
|
||||
self._migrate_add_usage_columns(conn)
|
||||
|
||||
# Image uniqueness by full path instead of basename (3.2.4)
|
||||
self._migrate_image_uniqueness_by_path(conn)
|
||||
|
||||
def _create_indexes(self, conn: sqlite3.Connection) -> None:
|
||||
"""
|
||||
Create indexes for better query performance.
|
||||
|
||||
|
||||
Args:
|
||||
conn: Active database connection
|
||||
|
||||
|
||||
Creates indexes on:
|
||||
- Text content for search operations
|
||||
- Categories and tags for filtering
|
||||
@@ -115,50 +250,124 @@ class PromptModel:
|
||||
- Hash values for duplicate detection
|
||||
"""
|
||||
indexes = [
|
||||
"CREATE INDEX IF NOT EXISTS idx_prompts_text ON prompts(text)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_prompts_category ON prompts(category)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_prompts_created_at ON prompts(created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_prompts_hash ON prompts(hash)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_prompts_rating ON prompts(rating)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_prompt_images ON generated_images(prompt_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_image_path ON generated_images(image_path)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_generation_time ON generated_images(generation_time)",
|
||||
("idx_prompts_text", "prompts(text)"),
|
||||
("idx_prompts_category", "prompts(category)"),
|
||||
("idx_prompts_created_at", "prompts(created_at)"),
|
||||
("idx_prompts_hash", "prompts(hash)"),
|
||||
("idx_prompts_rating", "prompts(rating)"),
|
||||
("idx_prompts_last_used", "prompts(last_used_at)"),
|
||||
("idx_prompts_run_count", "prompts(run_count)"),
|
||||
("idx_prompt_images", "generated_images(prompt_id)"),
|
||||
("idx_image_path", "generated_images(image_path)"),
|
||||
("idx_image_file_path", "generated_images(file_path)"),
|
||||
("idx_generation_time", "generated_images(generation_time)"),
|
||||
("idx_prompt_tags_tag", "prompt_tags(tag_id)"),
|
||||
("idx_tags_name", "tags(name)"),
|
||||
]
|
||||
|
||||
for index_sql in indexes:
|
||||
conn.execute(index_sql)
|
||||
|
||||
|
||||
for name, target in indexes:
|
||||
conn.execute(f"CREATE INDEX IF NOT EXISTS {name} ON {target}")
|
||||
|
||||
def get_connection(self) -> sqlite3.Connection:
|
||||
"""
|
||||
Get a database connection with proper configuration.
|
||||
|
||||
Get the calling thread's database connection.
|
||||
|
||||
Each thread gets its own ``sqlite3.Connection`` (kept in
|
||||
``threading.local``), created on first use and reused by that thread
|
||||
thereafter. Every connection is configured with WAL journaling,
|
||||
``row_factory = sqlite3.Row``, ``PRAGMA foreign_keys = ON`` and
|
||||
``PRAGMA busy_timeout`` of BUSY_TIMEOUT_SECONDS so concurrent writers
|
||||
wait instead of failing. Callers use it as a context manager
|
||||
(``with conn:``), which commits or rolls back that thread's transaction
|
||||
and leaves the connection open. No lock is held while a connection is in use.
|
||||
|
||||
Returns:
|
||||
sqlite3.Connection: Configured database connection
|
||||
sqlite3.Connection: The current thread's configured connection
|
||||
"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row # Enable dict-like access to rows
|
||||
conn = getattr(self._local, "conn", None)
|
||||
if conn is not None and self._local.generation == self._generation:
|
||||
return conn
|
||||
|
||||
# check_same_thread=False only so restore_from_file can close other
|
||||
# threads' connections; each connection is still used by one thread.
|
||||
conn = sqlite3.connect(
|
||||
self.db_path, check_same_thread=False, timeout=BUSY_TIMEOUT_SECONDS
|
||||
)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute(f"PRAGMA busy_timeout = {BUSY_TIMEOUT_SECONDS * 1000}")
|
||||
self._local.conn = conn
|
||||
self._local.generation = self._generation
|
||||
with self._conn_lock:
|
||||
self._connections.add(conn)
|
||||
return conn
|
||||
|
||||
|
||||
@classmethod
|
||||
def close_all_instances(cls) -> None:
|
||||
"""Close every connection of every live model in this process."""
|
||||
with cls._instances_lock:
|
||||
models = list(cls._instances)
|
||||
for model in models:
|
||||
model.close_all()
|
||||
|
||||
def close_all(self) -> None:
|
||||
"""Close every thread's connection; they reopen lazily on next use.
|
||||
|
||||
Needed before deleting the database file (Windows keeps open files
|
||||
locked) and by tests that ran queries through executor threads.
|
||||
"""
|
||||
with self._conn_lock:
|
||||
connections = list(self._connections)
|
||||
self._connections.clear()
|
||||
self._generation += 1
|
||||
self._local.conn = None
|
||||
for conn in connections:
|
||||
try:
|
||||
conn.close()
|
||||
except sqlite3.Error as e:
|
||||
self.logger.warning(f"Error closing database connection: {e}")
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the calling thread's connection, if it has one.
|
||||
|
||||
Other threads keep their connections. Call this before deleting or
|
||||
replacing the database file: Windows refuses to delete an open file.
|
||||
"""
|
||||
conn = getattr(self._local, "conn", None)
|
||||
if conn is None:
|
||||
return
|
||||
self._local.conn = None
|
||||
with self._conn_lock:
|
||||
self._connections.discard(conn)
|
||||
try:
|
||||
conn.close()
|
||||
except sqlite3.Error as e:
|
||||
self.logger.warning(f"Error closing database connection: {e}")
|
||||
|
||||
def _migrate_workflow_name_removal(self, conn: sqlite3.Connection) -> None:
|
||||
"""
|
||||
Remove workflow_name column if it exists in existing database.
|
||||
|
||||
|
||||
Args:
|
||||
conn: Active database connection
|
||||
|
||||
|
||||
This migration handles legacy schema updates by removing the deprecated
|
||||
workflow_name column while preserving all other data.
|
||||
|
||||
Raises:
|
||||
sqlite3.Error: If the rebuild fails (logged, nothing half-applied)
|
||||
"""
|
||||
try:
|
||||
# Check if workflow_name column exists
|
||||
cursor = conn.execute("PRAGMA table_info(prompts)")
|
||||
columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'workflow_name' in columns:
|
||||
self.logger.info("Migrating database: removing workflow_name column")
|
||||
|
||||
# Create new table without workflow_name
|
||||
if "workflow_name" not in columns:
|
||||
return
|
||||
|
||||
self.logger.info("Migrating database: removing workflow_name column")
|
||||
with self._table_rebuild(conn):
|
||||
# A previous attempt may have died between CREATE and RENAME
|
||||
conn.execute("DROP TABLE IF EXISTS prompts_new")
|
||||
conn.execute("""
|
||||
CREATE TABLE prompts_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -172,43 +381,43 @@ class PromptModel:
|
||||
hash TEXT UNIQUE
|
||||
)
|
||||
""")
|
||||
|
||||
# Copy data from old table to new table
|
||||
conn.execute("""
|
||||
INSERT INTO prompts_new (id, text, created_at, updated_at, category, tags, rating, notes, hash)
|
||||
SELECT id, text, created_at, updated_at, category, tags, rating, notes, hash
|
||||
INSERT INTO prompts_new (id, text, created_at, updated_at,
|
||||
category, tags, rating, notes, hash)
|
||||
SELECT id, text, created_at, updated_at, category, tags, rating,
|
||||
notes, hash
|
||||
FROM prompts
|
||||
""")
|
||||
|
||||
# Drop old table and rename new one
|
||||
conn.execute("DROP TABLE prompts")
|
||||
conn.execute("ALTER TABLE prompts_new RENAME TO prompts")
|
||||
|
||||
self.logger.info("Database migration completed")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Migration error: {e}")
|
||||
# If migration fails, the table creation will handle it
|
||||
|
||||
self.logger.info("Database migration completed")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
self.logger.error(f"Migration error (workflow_name removal): {e}")
|
||||
raise
|
||||
|
||||
def _migrate_foreign_key_types(self, conn: sqlite3.Connection) -> None:
|
||||
"""
|
||||
Fix foreign key data type mismatch in generated_images table.
|
||||
|
||||
|
||||
Args:
|
||||
conn: Active database connection
|
||||
|
||||
|
||||
Converts prompt_id from TEXT to INTEGER type to match the prompts table's
|
||||
primary key type, ensuring referential integrity.
|
||||
|
||||
Raises:
|
||||
sqlite3.Error: If the rebuild fails (logged, nothing half-applied)
|
||||
"""
|
||||
try:
|
||||
# Check if generated_images table exists and has TEXT prompt_id
|
||||
cursor = conn.execute("PRAGMA table_info(generated_images)")
|
||||
columns = {column[1]: column[2] for column in cursor.fetchall()}
|
||||
|
||||
if 'prompt_id' in columns and columns['prompt_id'] == 'TEXT':
|
||||
self.logger.info("Migrating foreign key types: prompt_id TEXT -> INTEGER")
|
||||
|
||||
# Create new table with correct types
|
||||
if columns.get("prompt_id") != "TEXT":
|
||||
return
|
||||
|
||||
self.logger.info("Migrating foreign key types: prompt_id TEXT -> INTEGER")
|
||||
with self._table_rebuild(conn):
|
||||
conn.execute("DROP TABLE IF EXISTS generated_images_new")
|
||||
conn.execute("""
|
||||
CREATE TABLE generated_images_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -226,103 +435,415 @@ class PromptModel:
|
||||
FOREIGN KEY (prompt_id) REFERENCES prompts(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# Copy data, converting prompt_id from TEXT to INTEGER
|
||||
conn.execute("""
|
||||
INSERT INTO generated_images_new
|
||||
(id, prompt_id, image_path, filename, generation_time, file_size,
|
||||
INSERT INTO generated_images_new
|
||||
(id, prompt_id, image_path, filename, generation_time, file_size,
|
||||
width, height, format, workflow_data, prompt_metadata, parameters)
|
||||
SELECT id, CAST(prompt_id AS INTEGER), image_path, filename, generation_time,
|
||||
file_size, width, height, format, workflow_data, prompt_metadata, parameters
|
||||
SELECT id, CAST(prompt_id AS INTEGER), image_path, filename,
|
||||
generation_time,
|
||||
file_size, width, height, format, workflow_data,
|
||||
prompt_metadata, parameters
|
||||
FROM generated_images
|
||||
WHERE prompt_id != '' AND prompt_id IS NOT NULL
|
||||
AND CAST(prompt_id AS INTEGER) IN (SELECT id FROM prompts)
|
||||
""")
|
||||
|
||||
# Drop old table and rename new one
|
||||
conn.execute("DROP TABLE generated_images")
|
||||
conn.execute("ALTER TABLE generated_images_new RENAME TO generated_images")
|
||||
|
||||
self.logger.info("Foreign key migration completed")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Foreign key migration error: {e}")
|
||||
# If migration fails, continue with existing schema
|
||||
|
||||
def migrate_database(self) -> None:
|
||||
conn.execute(
|
||||
"ALTER TABLE generated_images_new RENAME TO generated_images"
|
||||
)
|
||||
self.logger.info("Foreign key migration completed")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
self.logger.error(f"Migration error (foreign key types): {e}")
|
||||
raise
|
||||
|
||||
def _migrate_json_tags_to_junction(self, conn: sqlite3.Connection) -> None:
|
||||
"""
|
||||
Apply any pending database migrations.
|
||||
|
||||
This method serves as an entry point for future schema migrations.
|
||||
Add new migration logic here as the database evolves.
|
||||
Populate tags and prompt_tags tables from legacy JSON tags column.
|
||||
|
||||
Runs once: skips if the tags table already has data. Uses json_each()
|
||||
to extract tag names from the JSON arrays stored in prompts.tags.
|
||||
|
||||
Raises:
|
||||
sqlite3.Error: If the migration fails (logged)
|
||||
"""
|
||||
# Future migrations can be added here
|
||||
pass
|
||||
|
||||
try:
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM tags")
|
||||
if cursor.fetchone()[0] > 0:
|
||||
return # Already migrated
|
||||
|
||||
cursor = conn.execute(
|
||||
"SELECT COUNT(*) FROM prompts "
|
||||
"WHERE tags IS NOT NULL AND tags != '' AND tags != '[]'"
|
||||
)
|
||||
if cursor.fetchone()[0] == 0:
|
||||
return # No tags to migrate
|
||||
|
||||
self.logger.info("Migrating JSON tags to normalized junction tables")
|
||||
|
||||
# Insert all unique tag names
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO tags (name) "
|
||||
"SELECT DISTINCT je.value FROM prompts, json_each(prompts.tags) AS je "
|
||||
"WHERE prompts.tags IS NOT NULL AND prompts.tags != '' AND "
|
||||
"prompts.tags != '[]'"
|
||||
)
|
||||
|
||||
# Populate junction table
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO prompt_tags (prompt_id, tag_id) "
|
||||
"SELECT p.id, t.id "
|
||||
"FROM prompts p, json_each(p.tags) AS je "
|
||||
"JOIN tags t ON t.name = je.value "
|
||||
"WHERE p.tags IS NOT NULL AND p.tags != '' AND p.tags != '[]'"
|
||||
)
|
||||
|
||||
tag_count = conn.execute("SELECT COUNT(*) FROM tags").fetchone()[0]
|
||||
link_count = conn.execute("SELECT COUNT(*) FROM prompt_tags").fetchone()[0]
|
||||
self.logger.info(
|
||||
f"Tag migration complete: {tag_count} unique tags, {link_count} "
|
||||
"prompt-tag links"
|
||||
)
|
||||
|
||||
except sqlite3.Error as e:
|
||||
self.logger.error(f"Migration error (tag junction): {e}")
|
||||
raise
|
||||
|
||||
def _migrate_add_usage_columns(self, conn: sqlite3.Connection) -> None:
|
||||
"""
|
||||
Add last_used_at and run_count (3.2.4) and estimate usage for existing rows.
|
||||
|
||||
The backfill runs only when the columns are added, i.e. on the upgrade
|
||||
from a version without usage tracking. Afterwards a NULL last_used_at
|
||||
means "never run": saving a prompt does not stamp it, only
|
||||
record_prompt_use does, and "Recently Used" relies on that.
|
||||
|
||||
The estimate comes from linked images: run_count is the image count (at
|
||||
least 1) and last_used_at the newest image time, falling back to created_at.
|
||||
Timestamps are compared with julianday() because prompts use ISO 'T'
|
||||
timestamps while generated_images uses SQLite's CURRENT_TIMESTAMP format.
|
||||
"""
|
||||
try:
|
||||
columns = {row[1] for row in conn.execute("PRAGMA table_info(prompts)")}
|
||||
added = False
|
||||
if "last_used_at" not in columns:
|
||||
self.logger.info("Adding prompt usage column last_used_at")
|
||||
conn.execute("ALTER TABLE prompts ADD COLUMN last_used_at TIMESTAMP")
|
||||
added = True
|
||||
if "run_count" not in columns:
|
||||
self.logger.info("Adding prompt usage column run_count")
|
||||
conn.execute(
|
||||
"ALTER TABLE prompts ADD COLUMN run_count INTEGER NOT NULL "
|
||||
"DEFAULT 0"
|
||||
)
|
||||
added = True
|
||||
if not added:
|
||||
return
|
||||
|
||||
cursor = conn.execute(f"""
|
||||
UPDATE prompts SET
|
||||
run_count = MAX(run_count, 1, (
|
||||
SELECT COUNT(*) FROM generated_images gi
|
||||
WHERE gi.prompt_id = prompts.id
|
||||
)),
|
||||
last_used_at = strftime('{ISO_TIMESTAMP_FORMAT}', MAX(
|
||||
COALESCE(julianday(created_at), julianday('now')),
|
||||
COALESCE((
|
||||
SELECT MAX(julianday(gi.generation_time))
|
||||
FROM generated_images gi WHERE gi.prompt_id = prompts.id
|
||||
), 0)
|
||||
))
|
||||
WHERE last_used_at IS NULL
|
||||
""")
|
||||
if cursor.rowcount:
|
||||
self.logger.info(f"Estimated usage for {cursor.rowcount} prompts")
|
||||
except sqlite3.Error as e:
|
||||
self.logger.error(f"Migration error (usage columns): {e}")
|
||||
raise
|
||||
|
||||
def _migrate_image_uniqueness_by_path(self, conn: sqlite3.Connection) -> None:
|
||||
"""
|
||||
Rebuild generated_images so an image is unique per (prompt_id, file_path).
|
||||
|
||||
Older schemas were unique per (prompt_id, filename), i.e. the basename,
|
||||
so ComfyUI's same-named files in different date folders were silently
|
||||
dropped. file_path holds normalize_image_path(image_path); rows whose
|
||||
paths normalise to the same file collapse to the newest one.
|
||||
|
||||
Raises:
|
||||
sqlite3.Error: If the rebuild fails (logged, nothing half-applied)
|
||||
"""
|
||||
try:
|
||||
columns = {
|
||||
row[1] for row in conn.execute("PRAGMA table_info(generated_images)")
|
||||
}
|
||||
if "file_path" in columns:
|
||||
return
|
||||
|
||||
self.logger.info("Migrating database: image uniqueness by full path")
|
||||
with self._table_rebuild(conn):
|
||||
rows = conn.execute(
|
||||
"SELECT id, prompt_id, image_path FROM generated_images ORDER BY id"
|
||||
).fetchall()
|
||||
keep = {}
|
||||
for row_id, prompt_id, image_path in rows:
|
||||
keep[(prompt_id, normalize_image_path(image_path))] = row_id
|
||||
|
||||
conn.execute("DROP TABLE IF EXISTS generated_images_new")
|
||||
conn.execute("""
|
||||
CREATE TABLE generated_images_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
prompt_id INTEGER NOT NULL,
|
||||
image_path TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
generation_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
file_size INTEGER,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
format TEXT,
|
||||
workflow_data TEXT,
|
||||
prompt_metadata TEXT,
|
||||
parameters TEXT,
|
||||
file_path TEXT,
|
||||
FOREIGN KEY (prompt_id) REFERENCES prompts(id) ON DELETE
|
||||
CASCADE,
|
||||
UNIQUE(prompt_id, file_path)
|
||||
)
|
||||
""")
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO generated_images_new
|
||||
(id, prompt_id, image_path, filename, generation_time, file_size,
|
||||
width, height, format, workflow_data, prompt_metadata, parameters,
|
||||
file_path)
|
||||
SELECT id, prompt_id, image_path, filename, generation_time,
|
||||
file_size,
|
||||
width, height, format, workflow_data, prompt_metadata,
|
||||
parameters,
|
||||
?
|
||||
FROM generated_images WHERE id = ?
|
||||
""",
|
||||
[(file_path, row_id) for (_, file_path), row_id in keep.items()],
|
||||
)
|
||||
conn.execute("DROP TABLE generated_images")
|
||||
conn.execute(
|
||||
"ALTER TABLE generated_images_new RENAME TO generated_images"
|
||||
)
|
||||
dropped = len(rows) - len(keep)
|
||||
if dropped:
|
||||
self.logger.info(f"Collapsed {dropped} duplicate image rows")
|
||||
self.logger.info("Image path uniqueness migration completed")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
self.logger.error(f"Migration error (image path uniqueness): {e}")
|
||||
raise
|
||||
|
||||
def vacuum_database(self) -> None:
|
||||
"""
|
||||
Optimize database by running VACUUM command.
|
||||
|
||||
|
||||
Reclaims unused space and defragments the database file,
|
||||
improving query performance and reducing file size.
|
||||
"""
|
||||
try:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
with closing(sqlite3.connect(self.db_path)) as conn:
|
||||
conn.execute("VACUUM")
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error vacuuming database: {e}")
|
||||
|
||||
|
||||
def get_database_info(self) -> dict:
|
||||
"""
|
||||
Get information about the database.
|
||||
|
||||
|
||||
Returns:
|
||||
dict: Database statistics and information
|
||||
"""
|
||||
try:
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.execute("SELECT COUNT(*) as total_prompts FROM prompts")
|
||||
total_prompts = cursor.fetchone()['total_prompts']
|
||||
|
||||
total_prompts = cursor.fetchone()["total_prompts"]
|
||||
|
||||
cursor = conn.execute(
|
||||
"SELECT COUNT(DISTINCT category) as unique_categories FROM prompts WHERE category IS NOT NULL"
|
||||
"SELECT COUNT(DISTINCT category) as unique_categories FROM "
|
||||
"prompts WHERE category IS NOT NULL"
|
||||
)
|
||||
unique_categories = cursor.fetchone()['unique_categories']
|
||||
|
||||
unique_categories = cursor.fetchone()["unique_categories"]
|
||||
|
||||
cursor = conn.execute(
|
||||
"SELECT AVG(rating) as avg_rating FROM prompts WHERE rating IS NOT NULL"
|
||||
"SELECT AVG(rating) as avg_rating FROM prompts WHERE rating IS "
|
||||
"NOT NULL"
|
||||
)
|
||||
avg_rating = cursor.fetchone()['avg_rating']
|
||||
|
||||
avg_rating = cursor.fetchone()["avg_rating"]
|
||||
|
||||
# Get database file size
|
||||
db_size = os.path.getsize(self.db_path) if os.path.exists(self.db_path) else 0
|
||||
|
||||
db_size = (
|
||||
os.path.getsize(self.db_path) if os.path.exists(self.db_path) else 0
|
||||
)
|
||||
|
||||
return {
|
||||
'total_prompts': total_prompts,
|
||||
'unique_categories': unique_categories,
|
||||
'average_rating': round(avg_rating, 2) if avg_rating else None,
|
||||
'database_size_bytes': db_size,
|
||||
'database_path': os.path.abspath(self.db_path)
|
||||
"total_prompts": total_prompts,
|
||||
"unique_categories": unique_categories,
|
||||
"average_rating": round(avg_rating, 2) if avg_rating else None,
|
||||
"database_size_bytes": db_size,
|
||||
"database_path": os.path.abspath(self.db_path),
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting database info: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def backup_database(self, backup_path: str) -> bool:
|
||||
"""
|
||||
Create a backup of the database.
|
||||
|
||||
Write a consistent copy of the database to backup_path.
|
||||
|
||||
Uses the SQLite online backup API through this thread's connection,
|
||||
so rows that are committed but still sit in the -wal file are
|
||||
included and other threads may keep reading and writing meanwhile.
|
||||
|
||||
Args:
|
||||
backup_path: Path where the backup should be saved
|
||||
|
||||
backup_path: Path where the backup should be saved (overwritten)
|
||||
|
||||
Returns:
|
||||
bool: True if backup was successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
import shutil
|
||||
shutil.copy2(self.db_path, backup_path)
|
||||
if os.path.exists(backup_path) and os.path.samefile(
|
||||
backup_path, self.db_path
|
||||
):
|
||||
self.logger.error("Refusing to back up the database onto itself")
|
||||
return False
|
||||
if os.path.exists(backup_path):
|
||||
os.remove(backup_path)
|
||||
source = self.get_connection()
|
||||
destination = sqlite3.connect(backup_path)
|
||||
try:
|
||||
source.backup(destination)
|
||||
finally:
|
||||
destination.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
except (OSError, sqlite3.Error) as e:
|
||||
self.logger.error(f"Error creating database backup: {e}")
|
||||
return False
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def verify_database_file(path: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Check that a file is a healthy prompts database before restoring it.
|
||||
|
||||
Opens the file read-only, runs PRAGMA integrity_check and confirms the
|
||||
prompts table has the columns every version of this extension needs.
|
||||
|
||||
Args:
|
||||
path: File to inspect
|
||||
|
||||
Returns:
|
||||
(ok, reason): reason is "ok" when the file is acceptable, otherwise
|
||||
a human-readable explanation. Never raises for bad input.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
return False, "File not found"
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
header = fh.read(len(SQLITE_HEADER))
|
||||
except OSError as e:
|
||||
return False, f"Cannot read file: {e}"
|
||||
if header != SQLITE_HEADER:
|
||||
return False, "Not a SQLite database file"
|
||||
|
||||
uri = Path(os.path.abspath(path)).as_uri() + "?mode=ro"
|
||||
try:
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
except sqlite3.Error as e:
|
||||
return False, f"Invalid SQLite database: {e}"
|
||||
try:
|
||||
row = conn.execute("PRAGMA integrity_check").fetchone()
|
||||
verdict = row[0] if row else "no result"
|
||||
if verdict != "ok":
|
||||
return False, f"Integrity check failed: {verdict}"
|
||||
table = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='prompts'"
|
||||
).fetchone()
|
||||
if not table:
|
||||
return False, "Database does not contain a 'prompts' table"
|
||||
columns = {r[1] for r in conn.execute("PRAGMA table_info(prompts)")}
|
||||
missing = [c for c in REQUIRED_PROMPT_COLUMNS if c not in columns]
|
||||
if missing:
|
||||
return (
|
||||
False,
|
||||
"prompts table is missing required columns: " + ", ".join(missing),
|
||||
)
|
||||
return True, "ok"
|
||||
except sqlite3.Error as e:
|
||||
return False, f"Invalid SQLite database: {e}"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _replace_contents_from(self, src_path: str) -> None:
|
||||
"""Copy every page of *src_path* into the live database.
|
||||
|
||||
Goes through SQLite's online backup API into this thread's normal
|
||||
connection, so SQLite takes the locks itself and the other
|
||||
PromptModel instances that share the file (nodes, image monitor,
|
||||
queue hook, API) simply see the new pages on their next statement.
|
||||
Nothing is swapped underneath open handles and no -wal/-shm file has
|
||||
to be deleted, which also keeps Windows happy. When SQLite refuses
|
||||
(page sizes differ while the target is in WAL mode) fall back to a
|
||||
file copy with every connection of this instance closed first.
|
||||
"""
|
||||
source = sqlite3.connect(
|
||||
Path(src_path).resolve().as_uri() + "?mode=ro", uri=True
|
||||
)
|
||||
try:
|
||||
try:
|
||||
source.backup(self.get_connection())
|
||||
return
|
||||
except sqlite3.OperationalError as e:
|
||||
self.logger.warning(
|
||||
f"Online restore failed ({e}); falling back to a file copy"
|
||||
)
|
||||
finally:
|
||||
source.close()
|
||||
self.close_all()
|
||||
shutil.copy2(src_path, self.db_path)
|
||||
for suffix in ("-wal", "-shm"):
|
||||
stale = self.db_path + suffix
|
||||
if os.path.exists(stale):
|
||||
os.remove(stale)
|
||||
|
||||
def restore_from_file(self, src_path: str) -> str:
|
||||
"""
|
||||
Replace the live database with the file at src_path.
|
||||
|
||||
The current database is first backed up next to itself as
|
||||
``<db_path>.backup_<YYYYmmdd_HHMMSS>`` (WAL content included), the
|
||||
pages of ``src_path`` are copied into the live database through
|
||||
SQLite's backup API (visible to every open connection) and the schema
|
||||
migrations run on the restored content.
|
||||
|
||||
Callers should run verify_database_file(src_path) first.
|
||||
|
||||
Args:
|
||||
src_path: SQLite file to restore
|
||||
|
||||
Returns:
|
||||
Path of the backup taken of the previous database, or "" when
|
||||
there was no database to back up.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the previous database could not be backed up
|
||||
OSError: If the restored file could not be copied into place
|
||||
"""
|
||||
backup_path = ""
|
||||
if os.path.exists(self.db_path):
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_path = f"{self.db_path}.backup_{timestamp}"
|
||||
if not self.backup_database(backup_path):
|
||||
raise RuntimeError("Could not back up the current database")
|
||||
self.logger.info(f"Current database backed up to: {backup_path}")
|
||||
|
||||
self._replace_contents_from(src_path)
|
||||
with PromptModel._init_lock:
|
||||
PromptModel._initialized_paths.discard(self._schema_key())
|
||||
self._ensure_database_exists()
|
||||
self.logger.info(f"Database restored from {src_path}")
|
||||
return backup_path
|
||||
|
||||
+1556
-397
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
+94
-319
@@ -3,22 +3,8 @@ PromptManager: Main custom node implementation that extends CLIPTextEncode
|
||||
with persistent prompt storage and search capabilities.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import webbrowser
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# Import logging system
|
||||
try:
|
||||
from .utils.logging_config import get_logger
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from utils.logging_config import get_logger
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
try:
|
||||
from comfy.comfy_types import IO, ComfyNodeABC, InputTypeDict
|
||||
@@ -35,23 +21,16 @@ except ImportError:
|
||||
InputTypeDict = dict
|
||||
|
||||
try:
|
||||
from .database.operations import PromptDatabase
|
||||
from .utils.comfyui_integration import get_comfyui_integration
|
||||
from .utils.image_monitor import ImageMonitor
|
||||
from .utils.prompt_tracker import PromptExecutionContext, PromptTracker
|
||||
from .prompt_manager_base import PromptManagerBase
|
||||
except ImportError:
|
||||
# For direct imports when not in a package
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from database.operations import PromptDatabase
|
||||
from utils.comfyui_integration import get_comfyui_integration
|
||||
from utils.image_monitor import ImageMonitor
|
||||
from utils.prompt_tracker import PromptExecutionContext, PromptTracker
|
||||
from prompt_manager_base import PromptManagerBase
|
||||
|
||||
|
||||
class PromptManager(ComfyNodeABC):
|
||||
class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
"""
|
||||
A ComfyUI custom node that functions like CLIPTextEncode but adds:
|
||||
- Persistent storage of all prompts in SQLite database
|
||||
@@ -61,17 +40,7 @@ class PromptManager(ComfyNodeABC):
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = get_logger("prompt_manager.node")
|
||||
self.logger.debug("Initializing PromptManager node")
|
||||
|
||||
self.db = PromptDatabase()
|
||||
self.prompt_tracker = PromptTracker(self.db)
|
||||
self.image_monitor = ImageMonitor(self.db, self.prompt_tracker)
|
||||
self.comfyui_integration = get_comfyui_integration()
|
||||
|
||||
# Start image monitoring automatically
|
||||
self._start_gallery_system()
|
||||
self.logger.debug("PromptManager node initialization completed")
|
||||
super().__init__(logger_name="prompt_manager.node")
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> InputTypeDict:
|
||||
@@ -82,7 +51,9 @@ class PromptManager(ComfyNodeABC):
|
||||
{
|
||||
"multiline": True,
|
||||
"dynamicPrompts": True,
|
||||
"tooltip": "The text prompt to be encoded and saved to database.",
|
||||
"tooltip": (
|
||||
"The text prompt to be encoded and saved to database."
|
||||
),
|
||||
},
|
||||
),
|
||||
"clip": (
|
||||
@@ -95,52 +66,77 @@ class PromptManager(ComfyNodeABC):
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Optional category for organizing prompts (e.g., 'landscapes', 'portraits')",
|
||||
"tooltip": (
|
||||
"Optional category for organizing prompts "
|
||||
"(e.g., 'landscapes', 'portraits')"
|
||||
),
|
||||
},
|
||||
),
|
||||
"tags": (
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Comma-separated tags for the prompt (e.g., 'anime, detailed, sunset')",
|
||||
"tooltip": (
|
||||
"Comma-separated tags for the prompt "
|
||||
"(e.g., 'anime, detailed, sunset')"
|
||||
),
|
||||
},
|
||||
),
|
||||
"search_text": (
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Search for past prompts containing this text",
|
||||
"tooltip": (
|
||||
"Search past prompts by text; add tag:name "
|
||||
'(or tag:"two words") to filter by tag'
|
||||
),
|
||||
},
|
||||
),
|
||||
"prepend_text": (
|
||||
IO.STRING,
|
||||
{
|
||||
"tooltip": "Text to prepend to the main prompt (connected STRING nodes will be added before the main text)"
|
||||
"tooltip": (
|
||||
"Text to prepend to the main prompt (connected STRING "
|
||||
"nodes will be added before the main text)"
|
||||
)
|
||||
},
|
||||
),
|
||||
"append_text": (
|
||||
IO.STRING,
|
||||
{
|
||||
"tooltip": "Text to append to the main prompt (connected STRING nodes will be added after the main text)"
|
||||
"tooltip": (
|
||||
"Text to append to the main prompt (connected STRING "
|
||||
"nodes will be added after the main text)"
|
||||
)
|
||||
},
|
||||
),
|
||||
},
|
||||
"hidden": {
|
||||
# The queued graph and this node's id: used to tell the positive
|
||||
# prompt of a run from negative-prompt PromptManager nodes
|
||||
"prompt": "PROMPT",
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (IO.CONDITIONING, IO.STRING)
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"A conditioning containing the embedded text used to guide the diffusion model.",
|
||||
"The final combined text string (with prepend/append applied) that was encoded.",
|
||||
"A conditioning containing the embedded text used to guide the diffusion "
|
||||
"model.",
|
||||
"The final combined text string (with prepend/append applied) that was "
|
||||
"encoded.",
|
||||
)
|
||||
FUNCTION = "encode"
|
||||
FUNCTION = "encode_prompt"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "🫶 ComfyAssets/🧠 Prompts"
|
||||
DESCRIPTION = (
|
||||
"Encodes a text prompt using a CLIP model into an embedding that can be used to guide "
|
||||
"the diffusion model towards generating specific images. Additionally saves all prompts "
|
||||
"Encodes a text prompt using a CLIP model into an embedding that can be used "
|
||||
"to guide the diffusion model towards generating specific images. "
|
||||
"Additionally saves all prompts "
|
||||
"to a local SQLite database with optional metadata for search and retrieval."
|
||||
)
|
||||
|
||||
def encode(
|
||||
def encode_prompt(
|
||||
self,
|
||||
clip,
|
||||
text: str,
|
||||
@@ -149,6 +145,8 @@ class PromptManager(ComfyNodeABC):
|
||||
search_text: str = "",
|
||||
prepend_text: str = "",
|
||||
append_text: str = "",
|
||||
prompt: Optional[Dict[str, Any]] = None,
|
||||
unique_id: Optional[str] = None,
|
||||
) -> Tuple[Any]:
|
||||
"""
|
||||
Encode the text prompt and save it to the database.
|
||||
@@ -163,28 +161,32 @@ class PromptManager(ComfyNodeABC):
|
||||
append_text: Text to append to the main prompt
|
||||
|
||||
Returns:
|
||||
Tuple containing the conditioning for the diffusion model and the final text string
|
||||
Tuple containing the conditioning for the diffusion model and the final
|
||||
text string
|
||||
|
||||
Raises:
|
||||
RuntimeError: If clip input is invalid
|
||||
"""
|
||||
# Combine prepend, main text, and append text
|
||||
final_text = ""
|
||||
parts = []
|
||||
if prepend_text and prepend_text.strip():
|
||||
final_text += prepend_text.strip() + " "
|
||||
final_text += text
|
||||
parts.append(prepend_text.strip())
|
||||
if text:
|
||||
parts.append(text)
|
||||
if append_text and append_text.strip():
|
||||
final_text += " " + append_text.strip()
|
||||
parts.append(append_text.strip())
|
||||
final_text = " ".join(parts)
|
||||
|
||||
# Inject LoRA trigger words if integration is enabled
|
||||
final_text = self._inject_lora_trigger_words(final_text)
|
||||
|
||||
# Use the combined text for encoding
|
||||
encoding_text = final_text
|
||||
|
||||
# For database storage, save the original main text with metadata about prepend/append
|
||||
# For database storage, save the original main text with metadata
|
||||
# about prepend/append
|
||||
storage_text = text
|
||||
|
||||
# Search functionality is now handled by the JavaScript UI
|
||||
# The search parameters are still available for backend processing if needed
|
||||
|
||||
# Validate CLIP model
|
||||
if clip is None:
|
||||
error_msg = (
|
||||
@@ -197,6 +199,7 @@ class PromptManager(ComfyNodeABC):
|
||||
|
||||
# Save prompt to database and set execution context for gallery tracking
|
||||
prompt_id = None
|
||||
extended_tags = []
|
||||
if storage_text and storage_text.strip():
|
||||
self.logger.debug(f"Processing prompt text: {storage_text[:100]}...")
|
||||
|
||||
@@ -208,35 +211,28 @@ class PromptManager(ComfyNodeABC):
|
||||
extended_tags.append(f"append:{append_text.strip()[:50]}")
|
||||
|
||||
try:
|
||||
prompt_id = self._save_prompt_to_database(
|
||||
text=storage_text.strip(), # Always strip whitespace
|
||||
prompt_id = self._track_prompt_execution(
|
||||
text=storage_text.strip(),
|
||||
encoding_text=encoding_text.strip(),
|
||||
category=category.strip() if category else None,
|
||||
tags=extended_tags if extended_tags else None,
|
||||
additional_data={
|
||||
"category": category.strip() if category else None,
|
||||
"tags": extended_tags,
|
||||
"prepend_text": (
|
||||
prepend_text.strip() if prepend_text else None
|
||||
),
|
||||
"append_text": append_text.strip() if append_text else None,
|
||||
"final_text": encoding_text.strip(),
|
||||
},
|
||||
prompt_graph=prompt,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
|
||||
# Set current prompt for image tracking
|
||||
if prompt_id:
|
||||
execution_id = self.prompt_tracker.set_current_prompt(
|
||||
prompt_text=encoding_text.strip(), # Use final combined text for tracking
|
||||
additional_data={
|
||||
"category": category.strip() if category else None,
|
||||
"tags": extended_tags,
|
||||
"prompt_id": prompt_id,
|
||||
"prepend_text": (
|
||||
prepend_text.strip() if prepend_text else None
|
||||
),
|
||||
"append_text": append_text.strip() if append_text else None,
|
||||
"final_text": encoding_text.strip(), # Store final combined text
|
||||
},
|
||||
)
|
||||
self.logger.debug(
|
||||
f"Set execution context: {execution_id} for prompt ID: {prompt_id}"
|
||||
)
|
||||
self.logger.debug(f"Tracked prompt ID: {prompt_id}")
|
||||
|
||||
except Exception as e:
|
||||
# Log error but don't fail the encoding
|
||||
self.logger.warning(f"Failed to save prompt to database: {e}")
|
||||
# Already logged above, no need for additional print
|
||||
|
||||
# Perform standard CLIP text encoding using the combined text
|
||||
self.logger.debug(
|
||||
@@ -246,7 +242,7 @@ class PromptManager(ComfyNodeABC):
|
||||
conditioning = clip.encode_from_tokens_scheduled(tokens)
|
||||
|
||||
# Register with ComfyUI integration for standard metadata compatibility
|
||||
node_id = f"promptmanager_{int(time.time() * 1000)}" # Unique node ID
|
||||
node_id = f"promptmanager_{int(time.time() * 1000)}"
|
||||
self.comfyui_integration.register_prompt(
|
||||
node_id,
|
||||
encoding_text.strip(),
|
||||
@@ -259,250 +255,29 @@ class PromptManager(ComfyNodeABC):
|
||||
},
|
||||
)
|
||||
|
||||
self.logger.debug("CLIP encoding completed successfully")
|
||||
self.logger.info(f"CLIP encoding completed, text: {repr(encoding_text)[:80]}")
|
||||
return (conditioning, encoding_text)
|
||||
|
||||
def _save_prompt_to_database(
|
||||
self, text: str, category: Optional[str] = None, tags: Optional[list] = None
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Save the prompt to the SQLite database.
|
||||
|
||||
Args:
|
||||
text: The prompt text
|
||||
category: Optional category
|
||||
tags: List of tags
|
||||
|
||||
Returns:
|
||||
The prompt ID if saved successfully, None otherwise
|
||||
"""
|
||||
try:
|
||||
# Generate hash for duplicate detection
|
||||
prompt_hash = self._generate_hash(text)
|
||||
self.logger.debug(f"Generated hash for prompt: {prompt_hash[:16]}...")
|
||||
|
||||
# Check if prompt already exists
|
||||
existing = self.db.get_prompt_by_hash(prompt_hash)
|
||||
if existing:
|
||||
self.logger.info(
|
||||
f"Found existing prompt with ID {existing['id']}, updating metadata"
|
||||
)
|
||||
# Update metadata if this is a duplicate with new info
|
||||
if any([category, tags]):
|
||||
self.db.update_prompt_metadata(
|
||||
prompt_id=existing["id"], category=category, tags=tags
|
||||
)
|
||||
self.logger.debug("Updated metadata for existing prompt")
|
||||
return existing["id"]
|
||||
|
||||
# Save new prompt
|
||||
self.logger.debug(
|
||||
f"Saving new prompt with category: {category}, tags: {tags}"
|
||||
)
|
||||
prompt_id = self.db.save_prompt(
|
||||
text=text, category=category, tags=tags, prompt_hash=prompt_hash
|
||||
)
|
||||
|
||||
if prompt_id:
|
||||
self.logger.debug(f"Successfully saved new prompt with ID: {prompt_id}")
|
||||
else:
|
||||
self.logger.warning("Failed to save prompt - no ID returned")
|
||||
|
||||
return prompt_id
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving prompt to database: {e}")
|
||||
# Already logged above, no need for additional print
|
||||
return None
|
||||
|
||||
def _generate_hash(self, text: str) -> str:
|
||||
"""
|
||||
Generate SHA256 hash for the prompt text.
|
||||
|
||||
Args:
|
||||
text: The prompt text to hash
|
||||
|
||||
Returns:
|
||||
Hexadecimal string representation of the SHA256 hash
|
||||
"""
|
||||
# Normalize text for consistent hashing (strip whitespace, normalize case)
|
||||
normalized_text = text.strip().lower()
|
||||
return hashlib.sha256(normalized_text.encode("utf-8")).hexdigest()
|
||||
|
||||
def _parse_tags(self, tags_string: str) -> Optional[list]:
|
||||
"""
|
||||
Parse comma-separated tags string into a list.
|
||||
|
||||
Args:
|
||||
tags_string: Comma-separated string of tags
|
||||
|
||||
Returns:
|
||||
List of parsed tags, or None if no valid tags found
|
||||
"""
|
||||
if not tags_string or not tags_string.strip():
|
||||
return None
|
||||
|
||||
tags = [tag.strip() for tag in tags_string.split(",") if tag.strip()]
|
||||
return tags if tags else None
|
||||
|
||||
def _search_prompts(self, search_text: str = "") -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search for past prompts by text content.
|
||||
|
||||
Args:
|
||||
search_text: Text to search for in prompt database
|
||||
|
||||
Returns:
|
||||
List of matching prompt dictionaries with metadata
|
||||
"""
|
||||
try:
|
||||
if not search_text or not search_text.strip():
|
||||
return []
|
||||
|
||||
results = self.db.search_prompts(
|
||||
text=search_text.strip(),
|
||||
category=None,
|
||||
tags=None,
|
||||
rating_min=None,
|
||||
limit=50,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error searching prompts: {e}")
|
||||
return []
|
||||
|
||||
def _open_web_interface(self):
|
||||
"""
|
||||
Open the web interface in the default browser.
|
||||
|
||||
Attempts to locate and open the web interface HTML file.
|
||||
Logs warnings if the interface is not properly configured.
|
||||
"""
|
||||
try:
|
||||
# Look for a web interface directory
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
web_dir = os.path.join(current_dir, "web_interface")
|
||||
|
||||
if os.path.exists(web_dir):
|
||||
# If web interface exists, try to start it
|
||||
index_path = os.path.join(web_dir, "index.html")
|
||||
if os.path.exists(index_path):
|
||||
webbrowser.open(f"file://{index_path}")
|
||||
self.logger.info("Web interface opened in browser")
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Web interface directory found but no index.html. Please check {web_dir} for setup instructions"
|
||||
)
|
||||
else:
|
||||
self.logger.info(
|
||||
"Web interface not yet implemented. This feature will open a web-based prompt management interface when the web_interface directory is created."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error opening web interface: {e}")
|
||||
|
||||
def search_prompts_api(self, search_text: str = "") -> List[Dict[str, Any]]:
|
||||
"""
|
||||
API method for JavaScript UI to search prompts.
|
||||
|
||||
Args:
|
||||
search_text: Text to search for in prompts
|
||||
|
||||
Returns:
|
||||
List of matching prompt dictionaries
|
||||
"""
|
||||
return self._search_prompts(search_text=search_text)
|
||||
|
||||
def get_recent_prompts_api(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
API method for JavaScript UI to get recent prompts.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of recent prompts to retrieve
|
||||
|
||||
Returns:
|
||||
List of recent prompt dictionaries ordered by creation time
|
||||
"""
|
||||
try:
|
||||
return self.db.get_recent_prompts(limit=limit)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting recent prompts: {e}")
|
||||
return []
|
||||
|
||||
def _start_gallery_system(self):
|
||||
"""
|
||||
Initialize and start the gallery monitoring system.
|
||||
|
||||
Starts the image monitor which watches for new generated images
|
||||
and links them to their source prompts in the database.
|
||||
"""
|
||||
try:
|
||||
self.logger.debug("Starting gallery system...")
|
||||
|
||||
# Start image monitoring
|
||||
self.image_monitor.start_monitoring()
|
||||
|
||||
self.logger.debug("Gallery system started successfully")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to start gallery system: {e}")
|
||||
self.logger.warning("Gallery features will be disabled")
|
||||
|
||||
def get_gallery_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get status of the gallery system.
|
||||
|
||||
Returns:
|
||||
Dictionary containing status information for image monitor and prompt tracker
|
||||
"""
|
||||
return {
|
||||
"image_monitor": self.image_monitor.get_status(),
|
||||
"prompt_tracker": self.prompt_tracker.get_status(),
|
||||
}
|
||||
|
||||
def cleanup_gallery_system(self):
|
||||
"""
|
||||
Clean up gallery system resources.
|
||||
|
||||
Stops image monitoring and releases associated resources.
|
||||
Called automatically during object destruction.
|
||||
"""
|
||||
try:
|
||||
if hasattr(self, "image_monitor"):
|
||||
self.image_monitor.stop_monitoring()
|
||||
self.logger.debug("Gallery system cleaned up")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error cleaning up gallery system: {e}")
|
||||
|
||||
def __del__(self):
|
||||
"""
|
||||
Cleanup when object is destroyed.
|
||||
|
||||
Ensures proper resource cleanup by stopping the gallery system.
|
||||
"""
|
||||
self.cleanup_gallery_system()
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, text="", category="", tags="", search_text="",
|
||||
prepend_text="", append_text="", **kwargs):
|
||||
def IS_CHANGED(
|
||||
cls,
|
||||
clip,
|
||||
text="",
|
||||
category="",
|
||||
tags="",
|
||||
search_text="",
|
||||
prepend_text="",
|
||||
append_text="",
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
ComfyUI method to determine if node needs re-execution.
|
||||
|
||||
This method now properly tracks input changes to avoid unnecessary
|
||||
re-execution while still ensuring prompts are saved when inputs change.
|
||||
|
||||
Returns:
|
||||
A hash of the input values that changes when any input changes
|
||||
|
||||
Returns a hash of input values that affect the conditioning output or
|
||||
the saved prompt's metadata. This enables proper branch execution -
|
||||
only re-execute when inputs change.
|
||||
"""
|
||||
# Create a hash of all the text inputs that affect the output
|
||||
# This ensures the node only re-executes when inputs actually change
|
||||
import hashlib
|
||||
|
||||
# Combine all text inputs that affect the conditioning output
|
||||
combined = f"{text}|{category}|{tags}|{prepend_text}|{append_text}"
|
||||
|
||||
# Return a hash that will change when inputs change
|
||||
# Note: We don't include search_text as it doesn't affect the output conditioning
|
||||
|
||||
combined = f"{text}|{prepend_text}|{append_text}|{category}|{tags}"
|
||||
return hashlib.sha256(combined.encode()).hexdigest()
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
PromptManagerBase: Shared logic for PromptManager node variants.
|
||||
|
||||
Provides database initialization, prompt saving, hashing, tag parsing,
|
||||
search, gallery system management, and cleanup — extracted from the
|
||||
duplicate code in prompt_manager.py and prompt_manager_text.py.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
from .utils.logging_config import get_logger
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
try:
|
||||
from .database.operations import PromptDatabase
|
||||
from .utils.comfyui_integration import get_comfyui_integration
|
||||
from .utils.image_monitor import get_image_monitor
|
||||
from .utils.prompt_tracker import get_prompt_tracker
|
||||
from .utils.hashing import generate_prompt_hash
|
||||
from .utils.prompt_graph import resolve_text, run_prompt_nodes
|
||||
from .utils.usage_tracking import PENDING_FIRST_USE
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from database.operations import PromptDatabase
|
||||
from utils.comfyui_integration import get_comfyui_integration
|
||||
from utils.image_monitor import get_image_monitor
|
||||
from utils.prompt_tracker import get_prompt_tracker
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.prompt_graph import resolve_text, run_prompt_nodes
|
||||
from utils.usage_tracking import PENDING_FIRST_USE
|
||||
|
||||
|
||||
class PromptManagerBase:
|
||||
"""Mixin providing shared prompt management logic for ComfyUI nodes.
|
||||
|
||||
Handles database connection, prompt saving with deduplication,
|
||||
tag parsing, search, gallery system lifecycle, and cleanup.
|
||||
Subclasses only need to define ComfyUI-specific class attributes
|
||||
(INPUT_TYPES, RETURN_TYPES, FUNCTION) and their execution method.
|
||||
"""
|
||||
|
||||
def __init__(self, logger_name: str = "prompt_manager.node"):
|
||||
self.logger = get_logger(logger_name)
|
||||
self.logger.debug(f"Initializing {self.__class__.__name__} node")
|
||||
|
||||
self.db = PromptDatabase()
|
||||
self.prompt_tracker = get_prompt_tracker(self.db)
|
||||
self.image_monitor = get_image_monitor(self.db, self.prompt_tracker)
|
||||
self.comfyui_integration = get_comfyui_integration()
|
||||
|
||||
self._start_gallery_system()
|
||||
self.logger.debug(f"{self.__class__.__name__} node initialization completed")
|
||||
|
||||
def _save_prompt_to_database(
|
||||
self,
|
||||
text: str,
|
||||
category: Optional[str] = None,
|
||||
tags: Optional[list] = None,
|
||||
count_run: bool = False,
|
||||
) -> Optional[int]:
|
||||
"""Save the prompt to the SQLite database.
|
||||
|
||||
Runs are normally counted by the queue hook (utils/usage_tracking.py), so a
|
||||
save only counts one when asked to (batch items whose text the hook could not
|
||||
see) or when the hook queued this prompt before it existed.
|
||||
|
||||
Args:
|
||||
text: The prompt text
|
||||
category: Optional category
|
||||
tags: List of tags
|
||||
count_run: Count this execution as a run of the prompt
|
||||
|
||||
Returns:
|
||||
The prompt ID if saved successfully, None otherwise
|
||||
"""
|
||||
try:
|
||||
prompt_hash = self._generate_hash(text)
|
||||
self.logger.debug(f"Generated hash for prompt: {prompt_hash[:16]}...")
|
||||
|
||||
existing = self.db.get_prompt_by_hash(prompt_hash)
|
||||
if existing:
|
||||
self.logger.info(
|
||||
f"Found existing prompt with ID {existing['id']}, updating metadata"
|
||||
)
|
||||
if any([category, tags]):
|
||||
self.db.update_prompt_metadata(
|
||||
prompt_id=existing["id"], category=category, tags=tags
|
||||
)
|
||||
self.logger.debug("Updated metadata for existing prompt")
|
||||
if count_run:
|
||||
self._record_use(existing["id"])
|
||||
return existing["id"]
|
||||
|
||||
self.logger.debug(
|
||||
f"Saving new prompt with category: {category}, tags: {tags}"
|
||||
)
|
||||
prompt_id = self.db.save_prompt(
|
||||
text=text, category=category, tags=tags, prompt_hash=prompt_hash
|
||||
)
|
||||
|
||||
if prompt_id:
|
||||
self.logger.debug(f"Successfully saved new prompt with ID: {prompt_id}")
|
||||
pending = getattr(self, "pending_first_use", PENDING_FIRST_USE)
|
||||
runs = pending.consume(prompt_hash) + (1 if count_run else 0)
|
||||
if runs:
|
||||
self._record_use(prompt_id, times=runs)
|
||||
else:
|
||||
self.logger.warning("Failed to save prompt - no ID returned")
|
||||
|
||||
return prompt_id
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving prompt to database: {e}")
|
||||
return None
|
||||
|
||||
def _prompt_role(
|
||||
self, prompt_graph: Any, unique_id: Any, text: str
|
||||
) -> Dict[str, bool]:
|
||||
"""How this node's execution relates to the run, from the queued graph.
|
||||
|
||||
Returns flags:
|
||||
is_run_prompt: feeds a sampler's positive input (images belong to it)
|
||||
seen_at_queue: the queue hook resolved exactly this text, so it already
|
||||
counted the run and the image's metadata can identify the prompt
|
||||
known: the graph and node id were available
|
||||
"""
|
||||
if not isinstance(prompt_graph, dict) or unique_id is None:
|
||||
return {"is_run_prompt": True, "seen_at_queue": False, "known": False}
|
||||
node_id = str(unique_id)
|
||||
resolved = resolve_text(prompt_graph, node_id)
|
||||
return {
|
||||
"is_run_prompt": node_id in run_prompt_nodes(prompt_graph),
|
||||
"seen_at_queue": resolved is not None
|
||||
and generate_prompt_hash(resolved) == generate_prompt_hash(text),
|
||||
"known": True,
|
||||
}
|
||||
|
||||
def _track_prompt_execution(
|
||||
self,
|
||||
text: str,
|
||||
encoding_text: str,
|
||||
category: Optional[str],
|
||||
tags: Optional[list],
|
||||
additional_data: Dict[str, Any],
|
||||
prompt_graph: Any = None,
|
||||
unique_id: Any = None,
|
||||
) -> Optional[int]:
|
||||
"""Save the prompt and, for the run's positive prompt, register it for linking.
|
||||
|
||||
Negative-prompt nodes are saved but never become the prompt their images are
|
||||
linked to. Text the queue hook could resolve is counted there and linked from
|
||||
the image's own metadata; anything else (PromptSearchList batches, unknown
|
||||
text nodes, or a resolution that doesn't match) is counted here and pushed
|
||||
onto the FIFO queue. Without graph context the pre-3.2.4 behaviour is kept.
|
||||
"""
|
||||
role = self._prompt_role(prompt_graph, unique_id, text)
|
||||
batch_item = (
|
||||
role["known"] and role["is_run_prompt"] and not role["seen_at_queue"]
|
||||
)
|
||||
prompt_id = self._save_prompt_to_database(
|
||||
text=text, category=category, tags=tags, count_run=batch_item
|
||||
)
|
||||
if prompt_id and role["is_run_prompt"]:
|
||||
self.prompt_tracker.set_current_prompt(
|
||||
prompt_text=encoding_text,
|
||||
additional_data={**additional_data, "prompt_id": prompt_id},
|
||||
push_to_queue=batch_item or not role["known"],
|
||||
)
|
||||
return prompt_id
|
||||
|
||||
def _record_use(self, prompt_id: int, times: int = 1) -> None:
|
||||
"""Count runs; a tracking failure must never block generation."""
|
||||
try:
|
||||
self.db.record_prompt_use(prompt_id, times=times)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not record use of prompt {prompt_id}: {e}")
|
||||
|
||||
def _inject_lora_trigger_words(self, text: str) -> str:
|
||||
"""Append LoRA trigger words if integration is enabled.
|
||||
|
||||
Returns the text unchanged if the integration is disabled or
|
||||
LoraManager is not installed.
|
||||
"""
|
||||
try:
|
||||
from .py.config import IntegrationConfig
|
||||
except ImportError:
|
||||
try:
|
||||
from py.config import IntegrationConfig
|
||||
except ImportError:
|
||||
return text
|
||||
|
||||
if (
|
||||
not IntegrationConfig.LORA_MANAGER_ENABLED
|
||||
or not IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED
|
||||
):
|
||||
return text
|
||||
|
||||
try:
|
||||
from .py.lora_utils import get_trigger_cache, inject_trigger_words
|
||||
except ImportError:
|
||||
try:
|
||||
from py.lora_utils import get_trigger_cache, inject_trigger_words
|
||||
except ImportError:
|
||||
return text
|
||||
|
||||
cache = get_trigger_cache()
|
||||
|
||||
# Lazy-load cache on first use
|
||||
if not cache.is_loaded and IntegrationConfig.LORA_MANAGER_PATH:
|
||||
cache.load(IntegrationConfig.LORA_MANAGER_PATH)
|
||||
|
||||
modified, injected = inject_trigger_words(text, cache)
|
||||
if injected:
|
||||
self.logger.info(f"Injected trigger words: {', '.join(injected)}")
|
||||
return modified
|
||||
|
||||
def _generate_hash(self, text: str) -> str:
|
||||
"""The prompt's deduplication hash (see utils.hashing.generate_prompt_hash)."""
|
||||
return generate_prompt_hash(text)
|
||||
|
||||
def _parse_tags(self, tags_string: str) -> Optional[list]:
|
||||
"""Parse comma-separated tags string into a list.
|
||||
|
||||
Args:
|
||||
tags_string: Comma-separated string of tags
|
||||
|
||||
Returns:
|
||||
List of parsed tags, or None if no valid tags found
|
||||
"""
|
||||
if not tags_string or not tags_string.strip():
|
||||
return None
|
||||
|
||||
tags = [tag.strip() for tag in tags_string.split(",") if tag.strip()]
|
||||
return tags if tags else None
|
||||
|
||||
def _search_prompts(self, search_text: str = "") -> List[Dict[str, Any]]:
|
||||
"""Search for past prompts by text content.
|
||||
|
||||
Args:
|
||||
search_text: Text to search for in prompt database
|
||||
|
||||
Returns:
|
||||
List of matching prompt dictionaries with metadata
|
||||
"""
|
||||
try:
|
||||
if not search_text or not search_text.strip():
|
||||
return []
|
||||
|
||||
try:
|
||||
from .py.config import PromptManagerConfig
|
||||
|
||||
max_results = PromptManagerConfig.MAX_SEARCH_RESULTS
|
||||
except Exception:
|
||||
max_results = 100
|
||||
|
||||
results = self.db.search_prompts(
|
||||
text=search_text.strip(),
|
||||
category=None,
|
||||
tags=None,
|
||||
rating_min=None,
|
||||
limit=max_results,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error searching prompts: {e}")
|
||||
return []
|
||||
|
||||
def search_prompts_api(self, search_text: str = "") -> List[Dict[str, Any]]:
|
||||
"""API method for JavaScript UI to search prompts."""
|
||||
return self._search_prompts(search_text=search_text)
|
||||
|
||||
def get_recent_prompts_api(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""API method for JavaScript UI to get recent prompts."""
|
||||
try:
|
||||
return self.db.get_recent_prompts(limit=limit)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting recent prompts: {e}")
|
||||
return []
|
||||
|
||||
def _start_gallery_system(self):
|
||||
"""Initialize and start the gallery monitoring system."""
|
||||
try:
|
||||
self.logger.debug("Starting gallery system...")
|
||||
self.image_monitor.start_monitoring()
|
||||
self.logger.debug("Gallery system started successfully")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to start gallery system: {e}")
|
||||
self.logger.warning("Gallery features will be disabled")
|
||||
|
||||
def get_gallery_status(self) -> Dict[str, Any]:
|
||||
"""Get status of the gallery system."""
|
||||
return {
|
||||
"image_monitor": self.image_monitor.get_status(),
|
||||
"prompt_tracker": self.prompt_tracker.get_status(),
|
||||
}
|
||||
|
||||
def cleanup_gallery_system(self):
|
||||
"""Clean up gallery system resources.
|
||||
|
||||
Note: The image monitor is a singleton shared across all node instances,
|
||||
so we intentionally do NOT stop it here. It should run for the entire
|
||||
ComfyUI session. Stopping it would kill monitoring for all nodes.
|
||||
"""
|
||||
self.logger.debug("Gallery system cleaned up (monitor continues running)")
|
||||
+86
-312
@@ -3,22 +3,8 @@ PromptManagerText: A text-only version of PromptManager that outputs STRING
|
||||
without CLIP encoding, while maintaining all database and search features.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import webbrowser
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# Import logging system
|
||||
try:
|
||||
from .utils.logging_config import get_logger
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from utils.logging_config import get_logger
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
try:
|
||||
from comfy.comfy_types import IO, ComfyNodeABC, InputTypeDict
|
||||
@@ -35,23 +21,16 @@ except ImportError:
|
||||
InputTypeDict = dict
|
||||
|
||||
try:
|
||||
from .database.operations import PromptDatabase
|
||||
from .utils.comfyui_integration import get_comfyui_integration
|
||||
from .utils.image_monitor import ImageMonitor
|
||||
from .utils.prompt_tracker import PromptExecutionContext, PromptTracker
|
||||
from .prompt_manager_base import PromptManagerBase
|
||||
except ImportError:
|
||||
# For direct imports when not in a package
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from database.operations import PromptDatabase
|
||||
from utils.comfyui_integration import get_comfyui_integration
|
||||
from utils.image_monitor import ImageMonitor
|
||||
from utils.prompt_tracker import PromptExecutionContext, PromptTracker
|
||||
from prompt_manager_base import PromptManagerBase
|
||||
|
||||
|
||||
class PromptManagerText(ComfyNodeABC):
|
||||
class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
"""
|
||||
A ComfyUI custom node that provides all PromptManager features but outputs
|
||||
only a STRING without CLIP encoding. Includes:
|
||||
@@ -63,17 +42,7 @@ class PromptManagerText(ComfyNodeABC):
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = get_logger("prompt_manager_text.node")
|
||||
self.logger.debug("Initializing PromptManagerText node")
|
||||
|
||||
self.db = PromptDatabase()
|
||||
self.prompt_tracker = PromptTracker(self.db)
|
||||
self.image_monitor = ImageMonitor(self.db, self.prompt_tracker)
|
||||
self.comfyui_integration = get_comfyui_integration()
|
||||
|
||||
# Start image monitoring automatically
|
||||
self._start_gallery_system()
|
||||
self.logger.debug("PromptManagerText node initialization completed")
|
||||
super().__init__(logger_name="prompt_manager_text.node")
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> InputTypeDict:
|
||||
@@ -84,7 +53,9 @@ class PromptManagerText(ComfyNodeABC):
|
||||
{
|
||||
"multiline": True,
|
||||
"dynamicPrompts": True,
|
||||
"tooltip": "The text prompt to be processed and saved to database.",
|
||||
"tooltip": (
|
||||
"The text prompt to be processed and saved to database."
|
||||
),
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -93,50 +64,74 @@ class PromptManagerText(ComfyNodeABC):
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Optional category for organizing prompts (e.g., 'landscapes', 'portraits')",
|
||||
"tooltip": (
|
||||
"Optional category for organizing prompts "
|
||||
"(e.g., 'landscapes', 'portraits')"
|
||||
),
|
||||
},
|
||||
),
|
||||
"tags": (
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Comma-separated tags for the prompt (e.g., 'anime, detailed, sunset')",
|
||||
"tooltip": (
|
||||
"Comma-separated tags for the prompt "
|
||||
"(e.g., 'anime, detailed, sunset')"
|
||||
),
|
||||
},
|
||||
),
|
||||
"search_text": (
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Search for past prompts containing this text",
|
||||
"tooltip": (
|
||||
"Search past prompts by text; add tag:name "
|
||||
'(or tag:"two words") to filter by tag'
|
||||
),
|
||||
},
|
||||
),
|
||||
"prepend_text": (
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Text to prepend to the main prompt (connected STRING nodes will be added before the main text)",
|
||||
"tooltip": (
|
||||
"Text to prepend to the main prompt (connected STRING "
|
||||
"nodes will be added before the main text)"
|
||||
),
|
||||
},
|
||||
),
|
||||
"append_text": (
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Text to append to the main prompt (connected STRING nodes will be added after the main text)",
|
||||
"tooltip": (
|
||||
"Text to append to the main prompt (connected STRING "
|
||||
"nodes will be added after the main text)"
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
"hidden": {
|
||||
# The queued graph and this node's id: used to tell the positive
|
||||
# prompt of a run from negative-prompt PromptManager nodes
|
||||
"prompt": "PROMPT",
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (IO.STRING,)
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"The final combined text string (with prepend/append applied) ready for use in other nodes.",
|
||||
"The final combined text string (with prepend/append applied) ready for use "
|
||||
"in other nodes.",
|
||||
)
|
||||
FUNCTION = "process_text"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "🫶 ComfyAssets/🧠 Prompts"
|
||||
DESCRIPTION = (
|
||||
"Processes and manages text prompts with database storage and search capabilities. "
|
||||
"Outputs a plain STRING that can be used with any node that accepts text input. "
|
||||
"Includes all PromptManager features: categorization, tagging, search, and prepend/append functionality."
|
||||
"Processes and manages text prompts with database storage and search "
|
||||
"capabilities. Outputs a plain STRING that can be used with any node that "
|
||||
"accepts text input. Includes all PromptManager features: categorization, "
|
||||
"tagging, search, and prepend/append functionality."
|
||||
)
|
||||
|
||||
def process_text(
|
||||
@@ -147,6 +142,8 @@ class PromptManagerText(ComfyNodeABC):
|
||||
search_text: str = "",
|
||||
prepend_text: str = "",
|
||||
append_text: str = "",
|
||||
prompt: Optional[Dict[str, Any]] = None,
|
||||
unique_id: Optional[str] = None,
|
||||
) -> Tuple[str]:
|
||||
"""
|
||||
Process the text prompt and save it to the database.
|
||||
@@ -163,21 +160,25 @@ class PromptManagerText(ComfyNodeABC):
|
||||
Tuple containing the final processed text string
|
||||
"""
|
||||
# Combine prepend, main text, and append text
|
||||
final_text = ""
|
||||
parts = []
|
||||
if prepend_text and prepend_text.strip():
|
||||
final_text += prepend_text.strip() + " "
|
||||
final_text += text
|
||||
parts.append(prepend_text.strip())
|
||||
if text:
|
||||
parts.append(text)
|
||||
if append_text and append_text.strip():
|
||||
final_text += " " + append_text.strip()
|
||||
parts.append(append_text.strip())
|
||||
final_text = " ".join(parts)
|
||||
|
||||
# For database storage, save the original main text with metadata about prepend/append
|
||||
# Inject LoRA trigger words if integration is enabled
|
||||
final_text = self._inject_lora_trigger_words(final_text)
|
||||
|
||||
# For database storage, save the original main text with metadata
|
||||
# about prepend/append
|
||||
storage_text = text
|
||||
|
||||
# Search functionality is now handled by the JavaScript UI
|
||||
# The search parameters are still available for backend processing if needed
|
||||
|
||||
# Save prompt to database and set execution context for gallery tracking
|
||||
prompt_id = None
|
||||
extended_tags = []
|
||||
if storage_text and storage_text.strip():
|
||||
self.logger.debug(f"Processing prompt text: {storage_text[:100]}...")
|
||||
|
||||
@@ -189,36 +190,30 @@ class PromptManagerText(ComfyNodeABC):
|
||||
extended_tags.append(f"append:{append_text.strip()[:50]}")
|
||||
|
||||
try:
|
||||
prompt_id = self._save_prompt_to_database(
|
||||
text=storage_text.strip(), # Always strip whitespace
|
||||
prompt_id = self._track_prompt_execution(
|
||||
text=storage_text.strip(),
|
||||
encoding_text=final_text.strip(),
|
||||
category=category.strip() if category else None,
|
||||
tags=extended_tags if extended_tags else None,
|
||||
additional_data={
|
||||
"category": category.strip() if category else None,
|
||||
"tags": extended_tags,
|
||||
"prepend_text": (
|
||||
prepend_text.strip() if prepend_text else None
|
||||
),
|
||||
"append_text": append_text.strip() if append_text else None,
|
||||
},
|
||||
prompt_graph=prompt,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
|
||||
# Set current prompt for image tracking
|
||||
if prompt_id:
|
||||
execution_id = self.prompt_tracker.set_current_prompt(
|
||||
prompt_text=final_text.strip(), # Use final combined text for tracking
|
||||
additional_data={
|
||||
"category": category.strip() if category else None,
|
||||
"tags": extended_tags,
|
||||
"prompt_id": prompt_id,
|
||||
"prepend_text": (
|
||||
prepend_text.strip() if prepend_text else None
|
||||
),
|
||||
"append_text": append_text.strip() if append_text else None,
|
||||
},
|
||||
)
|
||||
self.logger.debug(
|
||||
f"Set execution context: {execution_id} for prompt ID: {prompt_id}"
|
||||
)
|
||||
self.logger.debug(f"Tracked prompt ID: {prompt_id}")
|
||||
|
||||
except Exception as e:
|
||||
# Log error but don't fail the processing
|
||||
self.logger.warning(f"Failed to save prompt to database: {e}")
|
||||
|
||||
# Register with ComfyUI integration for standard metadata compatibility
|
||||
node_id = f"promptmanagertext_{int(time.time() * 1000)}" # Unique node ID
|
||||
node_id = f"promptmanagertext_{int(time.time() * 1000)}"
|
||||
self.comfyui_integration.register_prompt(
|
||||
node_id,
|
||||
final_text.strip(),
|
||||
@@ -234,246 +229,25 @@ class PromptManagerText(ComfyNodeABC):
|
||||
self.logger.debug(f"Text processing completed: {final_text[:100]}...")
|
||||
return (final_text,)
|
||||
|
||||
def _save_prompt_to_database(
|
||||
self, text: str, category: Optional[str] = None, tags: Optional[list] = None
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Save the prompt to the SQLite database.
|
||||
|
||||
Args:
|
||||
text: The prompt text
|
||||
category: Optional category
|
||||
tags: List of tags
|
||||
|
||||
Returns:
|
||||
The prompt ID if saved successfully, None otherwise
|
||||
"""
|
||||
try:
|
||||
# Generate hash for duplicate detection
|
||||
prompt_hash = self._generate_hash(text)
|
||||
self.logger.debug(f"Generated hash for prompt: {prompt_hash[:16]}...")
|
||||
|
||||
# Check if prompt already exists
|
||||
existing = self.db.get_prompt_by_hash(prompt_hash)
|
||||
if existing:
|
||||
self.logger.info(
|
||||
f"Found existing prompt with ID {existing['id']}, updating metadata"
|
||||
)
|
||||
# Update metadata if this is a duplicate with new info
|
||||
if any([category, tags]):
|
||||
self.db.update_prompt_metadata(
|
||||
prompt_id=existing["id"], category=category, tags=tags
|
||||
)
|
||||
self.logger.debug("Updated metadata for existing prompt")
|
||||
return existing["id"]
|
||||
|
||||
# Save new prompt
|
||||
self.logger.debug(
|
||||
f"Saving new prompt with category: {category}, tags: {tags}"
|
||||
)
|
||||
prompt_id = self.db.save_prompt(
|
||||
text=text, category=category, tags=tags, prompt_hash=prompt_hash
|
||||
)
|
||||
|
||||
if prompt_id:
|
||||
self.logger.debug(f"Successfully saved new prompt with ID: {prompt_id}")
|
||||
else:
|
||||
self.logger.warning("Failed to save prompt - no ID returned")
|
||||
|
||||
return prompt_id
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving prompt to database: {e}")
|
||||
return None
|
||||
|
||||
def _generate_hash(self, text: str) -> str:
|
||||
"""
|
||||
Generate SHA256 hash for the prompt text.
|
||||
|
||||
Args:
|
||||
text: The prompt text to hash
|
||||
|
||||
Returns:
|
||||
Hexadecimal string representation of the SHA256 hash
|
||||
"""
|
||||
# Normalize text for consistent hashing (strip whitespace, normalize case)
|
||||
normalized_text = text.strip().lower()
|
||||
return hashlib.sha256(normalized_text.encode("utf-8")).hexdigest()
|
||||
|
||||
def _parse_tags(self, tags_string: str) -> Optional[list]:
|
||||
"""
|
||||
Parse comma-separated tags string into a list.
|
||||
|
||||
Args:
|
||||
tags_string: Comma-separated string of tags
|
||||
|
||||
Returns:
|
||||
List of parsed tags, or None if no valid tags found
|
||||
"""
|
||||
if not tags_string or not tags_string.strip():
|
||||
return None
|
||||
|
||||
tags = [tag.strip() for tag in tags_string.split(",") if tag.strip()]
|
||||
return tags if tags else None
|
||||
|
||||
def _search_prompts(self, search_text: str = "") -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search for past prompts by text content.
|
||||
|
||||
Args:
|
||||
search_text: Text to search for in prompt database
|
||||
|
||||
Returns:
|
||||
List of matching prompt dictionaries with metadata
|
||||
"""
|
||||
try:
|
||||
if not search_text or not search_text.strip():
|
||||
return []
|
||||
|
||||
results = self.db.search_prompts(
|
||||
text=search_text.strip(),
|
||||
category=None,
|
||||
tags=None,
|
||||
rating_min=None,
|
||||
limit=50,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error searching prompts: {e}")
|
||||
return []
|
||||
|
||||
def _open_web_interface(self):
|
||||
"""
|
||||
Open the web interface in the default browser.
|
||||
|
||||
Attempts to locate and open the web interface HTML file.
|
||||
Logs warnings if the interface is not properly configured.
|
||||
"""
|
||||
try:
|
||||
# Look for a web interface directory
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
web_dir = os.path.join(current_dir, "web_interface")
|
||||
|
||||
if os.path.exists(web_dir):
|
||||
# If web interface exists, try to start it
|
||||
index_path = os.path.join(web_dir, "index.html")
|
||||
if os.path.exists(index_path):
|
||||
webbrowser.open(f"file://{index_path}")
|
||||
self.logger.info("Web interface opened in browser")
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Web interface directory found but no index.html. Please check {web_dir} for setup instructions"
|
||||
)
|
||||
else:
|
||||
self.logger.info(
|
||||
"Web interface not yet implemented. This feature will open a web-based prompt management interface when the web_interface directory is created."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error opening web interface: {e}")
|
||||
|
||||
def search_prompts_api(self, search_text: str = "") -> List[Dict[str, Any]]:
|
||||
"""
|
||||
API method for JavaScript UI to search prompts.
|
||||
|
||||
Args:
|
||||
search_text: Text to search for in prompts
|
||||
|
||||
Returns:
|
||||
List of matching prompt dictionaries
|
||||
"""
|
||||
return self._search_prompts(search_text=search_text)
|
||||
|
||||
def get_recent_prompts_api(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
API method for JavaScript UI to get recent prompts.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of recent prompts to retrieve
|
||||
|
||||
Returns:
|
||||
List of recent prompt dictionaries ordered by creation time
|
||||
"""
|
||||
try:
|
||||
return self.db.get_recent_prompts(limit=limit)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting recent prompts: {e}")
|
||||
return []
|
||||
|
||||
def _start_gallery_system(self):
|
||||
"""
|
||||
Initialize and start the gallery monitoring system.
|
||||
|
||||
Starts the image monitor which watches for new generated images
|
||||
and links them to their source prompts in the database.
|
||||
"""
|
||||
try:
|
||||
self.logger.debug("Starting gallery system...")
|
||||
|
||||
# Start image monitoring
|
||||
self.image_monitor.start_monitoring()
|
||||
|
||||
self.logger.debug("Gallery system started successfully")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to start gallery system: {e}")
|
||||
self.logger.warning("Gallery features will be disabled")
|
||||
|
||||
def get_gallery_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get status of the gallery system.
|
||||
|
||||
Returns:
|
||||
Dictionary containing status information for image monitor and prompt tracker
|
||||
"""
|
||||
return {
|
||||
"image_monitor": self.image_monitor.get_status(),
|
||||
"prompt_tracker": self.prompt_tracker.get_status(),
|
||||
}
|
||||
|
||||
def cleanup_gallery_system(self):
|
||||
"""
|
||||
Clean up gallery system resources.
|
||||
|
||||
Stops image monitoring and releases associated resources.
|
||||
Called automatically during object destruction.
|
||||
"""
|
||||
try:
|
||||
if hasattr(self, "image_monitor"):
|
||||
self.image_monitor.stop_monitoring()
|
||||
self.logger.debug("Gallery system cleaned up")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error cleaning up gallery system: {e}")
|
||||
|
||||
def __del__(self):
|
||||
"""
|
||||
Cleanup when object is destroyed.
|
||||
|
||||
Ensures proper resource cleanup by stopping the gallery system.
|
||||
"""
|
||||
self.cleanup_gallery_system()
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, text="", category="", tags="", search_text="",
|
||||
prepend_text="", append_text="", **kwargs):
|
||||
def IS_CHANGED(
|
||||
cls,
|
||||
text="",
|
||||
category="",
|
||||
tags="",
|
||||
search_text="",
|
||||
prepend_text="",
|
||||
append_text="",
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
ComfyUI method to determine if node needs re-execution.
|
||||
|
||||
This method now properly tracks input changes to avoid unnecessary
|
||||
re-execution while still ensuring prompts are saved when inputs change.
|
||||
|
||||
Returns:
|
||||
A hash of the input values that changes when any input changes
|
||||
|
||||
Returns a hash of input values that affect the text output or the
|
||||
saved prompt's metadata. This enables proper branch execution - only
|
||||
re-execute when inputs change.
|
||||
"""
|
||||
# Create a hash of all the text inputs that affect the output
|
||||
# This ensures the node only re-executes when inputs actually change
|
||||
import hashlib
|
||||
|
||||
# Combine all text inputs that affect the output
|
||||
combined = f"{text}|{category}|{tags}|{prepend_text}|{append_text}"
|
||||
|
||||
# Return a hash that will change when inputs change
|
||||
# Note: We don't include search_text as it doesn't affect the output
|
||||
|
||||
combined = f"{text}|{prepend_text}|{append_text}|{category}|{tags}"
|
||||
return hashlib.sha256(combined.encode()).hexdigest()
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
PromptSearchList: A ComfyUI node that searches prompts and outputs results as a list.
|
||||
|
||||
This node enables batch processing workflows by outputting prompt texts with
|
||||
OUTPUT_IS_LIST=True, allowing direct connection to nodes that accept list inputs.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
try:
|
||||
from .utils.logging_config import get_logger
|
||||
except ImportError:
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
try:
|
||||
from comfy.comfy_types import IO, ComfyNodeABC, InputTypeDict
|
||||
except ImportError:
|
||||
|
||||
class ComfyNodeABC:
|
||||
pass
|
||||
|
||||
class IO:
|
||||
STRING = "STRING"
|
||||
INT = "INT"
|
||||
|
||||
InputTypeDict = dict
|
||||
|
||||
try:
|
||||
from .database.operations import PromptDatabase
|
||||
except ImportError:
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from database.operations import PromptDatabase
|
||||
|
||||
|
||||
class PromptSearchList(ComfyNodeABC):
|
||||
"""
|
||||
A ComfyUI node that searches the prompt database and outputs matching
|
||||
prompts as a list for batch processing workflows.
|
||||
|
||||
Features:
|
||||
- Search by text content
|
||||
- Filter by category, tags, and minimum rating
|
||||
- Outputs as OUTPUT_IS_LIST for batch node compatibility
|
||||
- Read-only operation (no database writes)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = get_logger("prompt_search_list.node")
|
||||
self.logger.debug("Initializing PromptSearchList node")
|
||||
self.db = PromptDatabase()
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> InputTypeDict:
|
||||
return {
|
||||
"required": {},
|
||||
"optional": {
|
||||
"text": (
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Search text to match against prompt content",
|
||||
},
|
||||
),
|
||||
"category": (
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Filter by specific category",
|
||||
},
|
||||
),
|
||||
"tags": (
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": (
|
||||
"Comma-separated list of tags to filter by (partial match)"
|
||||
),
|
||||
},
|
||||
),
|
||||
"min_rating": (
|
||||
IO.INT,
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 5,
|
||||
"tooltip": "Minimum rating (0-5) to include in results",
|
||||
},
|
||||
),
|
||||
"limit": (
|
||||
IO.INT,
|
||||
{
|
||||
"default": 50,
|
||||
"min": 1,
|
||||
"max": 1000,
|
||||
"tooltip": "Maximum number of results to return",
|
||||
},
|
||||
),
|
||||
"skip_multipart": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": (
|
||||
"Skip prompts containing Clip_1/Clip_2/etc. "
|
||||
"multi-part markers"
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (IO.STRING, IO.STRING, IO.INT)
|
||||
RETURN_NAMES = ("prompts", "preview", "count")
|
||||
OUTPUT_IS_LIST = (True, False, False)
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"List of prompt texts matching the search criteria.",
|
||||
"Preview of all found prompts (for display).",
|
||||
"Number of prompts found.",
|
||||
)
|
||||
FUNCTION = "search"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "🫶 ComfyAssets/🧠 Prompts"
|
||||
DESCRIPTION = (
|
||||
"Searches the prompt database and outputs matching prompts as a list. "
|
||||
"Use this node to retrieve stored prompts for batch processing workflows. "
|
||||
"'prompts' output sends each prompt individually to downstream nodes (batch). "
|
||||
"'preview' output shows all found prompts as one text block. "
|
||||
"'count' output shows how many prompts were found."
|
||||
)
|
||||
|
||||
def search(
|
||||
self,
|
||||
text: str = "",
|
||||
category: str = "",
|
||||
tags: str = "",
|
||||
min_rating: int = 0,
|
||||
limit: int = 50,
|
||||
skip_multipart: bool = True,
|
||||
):
|
||||
"""
|
||||
Search for prompts matching the given criteria.
|
||||
|
||||
Args:
|
||||
text: Search text to match against prompt content
|
||||
category: Filter by specific category
|
||||
tags: Comma-separated list of tags to filter by
|
||||
min_rating: Minimum rating (0-5) to include
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
Dict with 'ui' display info and 'result' tuple
|
||||
"""
|
||||
try:
|
||||
# Parse tags from comma-separated string
|
||||
tags_list = None
|
||||
if tags and tags.strip():
|
||||
tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
|
||||
|
||||
# Perform search with partial tag matching for discovery
|
||||
results = self.db.search_prompts(
|
||||
text=text.strip() if text and text.strip() else None,
|
||||
category=category.strip() if category and category.strip() else None,
|
||||
tags=tags_list,
|
||||
tag_partial=True,
|
||||
rating_min=min_rating if min_rating > 0 else None,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Extract prompt text, collapsing newlines to spaces so downstream
|
||||
# nodes that split on \n (e.g. StringOutputList) treat each DB
|
||||
# entry as a single prompt.
|
||||
prompt_texts = [
|
||||
" ".join(r["text"].split()) for r in results if r.get("text")
|
||||
]
|
||||
|
||||
# Filter out multi-part prompts (Clip_1/Clip_2 markers)
|
||||
if skip_multipart:
|
||||
prompt_texts = [
|
||||
p for p in prompt_texts if not re.search(r"Clip_\d+", p)
|
||||
]
|
||||
|
||||
# Filter out prompts that are only LoRA tags with no actual content.
|
||||
# Uses subtraction instead of repeated groups to avoid backtracking.
|
||||
prompt_texts = [
|
||||
p for p in prompt_texts if re.sub(r"<lora:[^>]+>", "", p).strip()
|
||||
]
|
||||
|
||||
count = len(prompt_texts)
|
||||
|
||||
# Build a preview: numbered list of truncated prompts
|
||||
preview_lines = []
|
||||
for i, p in enumerate(prompt_texts, 1):
|
||||
truncated = p[:120] + "..." if len(p) > 120 else p
|
||||
preview_lines.append(f"[{i}] {truncated}")
|
||||
preview = "\n".join(preview_lines) if preview_lines else "No results found"
|
||||
|
||||
# Must return at least one element — ComfyUI's slice_dict
|
||||
# indexes into OUTPUT_IS_LIST outputs and crashes on empty lists.
|
||||
if not prompt_texts:
|
||||
self.logger.info("Search returned no results")
|
||||
return {
|
||||
"ui": {"text": ["No results found"]},
|
||||
"result": ([""], preview, count),
|
||||
}
|
||||
|
||||
return {
|
||||
"ui": {"text": [f"Found {count} prompts"]},
|
||||
"result": (prompt_texts, preview, count),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Search error: {e}", exc_info=True)
|
||||
return {
|
||||
"ui": {"text": [f"Search error: {e}"]},
|
||||
"result": ([""], f"Error: {e}", 0),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(
|
||||
cls, text="", category="", tags="", min_rating=0, limit=50, skip_multipart=True
|
||||
):
|
||||
"""
|
||||
Always re-execute to get fresh results from the database.
|
||||
|
||||
The database contents may have changed since the last execution,
|
||||
so we always return a unique value to trigger re-execution.
|
||||
"""
|
||||
import time
|
||||
|
||||
return time.time()
|
||||
BIN
Binary file not shown.
+1
-1
@@ -3,4 +3,4 @@ ComfyUI_PromptManager Python API modules.
|
||||
|
||||
This package contains the core API components for the web interface and configuration
|
||||
management, including REST endpoints, configuration handling, and server integration.
|
||||
"""
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,972 @@
|
||||
"""REST API module for ComfyUI PromptManager.
|
||||
|
||||
Split into domain-specific mixins for maintainability:
|
||||
- PromptRoutesMixin: prompt CRUD, tags, categories, bulk ops, export
|
||||
- ImageRoutesMixin: gallery, thumbnails, image serving and linking
|
||||
- AdminRoutesMixin: duplicates, stats, settings, diagnostics, maintenance, backup, scan
|
||||
- LoggingRoutesMixin: log management endpoints
|
||||
- AutotagRoutesMixin: auto-tagging model management and tagging
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import functools
|
||||
import gzip as gzip_module
|
||||
import json
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
|
||||
from .prompts import PromptRoutesMixin
|
||||
from .images import ImageRoutesMixin
|
||||
from .admin import AdminRoutesMixin
|
||||
from .logging_routes import LoggingRoutesMixin
|
||||
from .autotag_routes import AutotagRoutesMixin
|
||||
from .lora_integration import LoraIntegrationMixin
|
||||
|
||||
try:
|
||||
from ...database.operations import PromptDatabase
|
||||
from ...utils.logging_config import get_logger
|
||||
from ...utils.video_metadata import is_video_path, read_video_metadata
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
)
|
||||
from database.operations import PromptDatabase
|
||||
from utils.logging_config import get_logger
|
||||
from utils.video_metadata import is_video_path, read_video_metadata
|
||||
|
||||
|
||||
def _get_project_root():
|
||||
"""Get the project root directory (3 levels up from py/api/__init__.py)."""
|
||||
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def _public_path(path) -> str:
|
||||
"""Render a server path for an API response without revealing the layout.
|
||||
|
||||
Returns the path relative to the ComfyUI base directory (or to the parent
|
||||
of an opted-in extra gallery root), as a POSIX-style string, or just the
|
||||
basename when the path is not under any of those anchors.
|
||||
"""
|
||||
if not path:
|
||||
return ""
|
||||
from ..config import GalleryConfig
|
||||
|
||||
try:
|
||||
canonical = Path(os.path.normcase(os.path.realpath(str(path))))
|
||||
except (OSError, ValueError):
|
||||
return os.path.basename(str(path))
|
||||
for anchor in GalleryConfig.path_anchors():
|
||||
if canonical.is_relative_to(Path(anchor)):
|
||||
return canonical.relative_to(Path(anchor)).as_posix() or "."
|
||||
return canonical.name
|
||||
|
||||
|
||||
def _public_error(exc: BaseException) -> str:
|
||||
"""Error text safe for API responses: OSError paths reduced to basenames."""
|
||||
if isinstance(exc, OSError):
|
||||
parts = [exc.strerror or type(exc).__name__]
|
||||
if exc.filename:
|
||||
parts.append(os.path.basename(str(exc.filename)))
|
||||
return ": ".join(parts)
|
||||
return str(exc) or type(exc).__name__
|
||||
|
||||
|
||||
# ── Static file serving ────────────────────────────────────────────────
|
||||
# Only these extensions are ever served from web/lib and web/js; the map
|
||||
# doubles as the allow-list.
|
||||
STATIC_MIME_TYPES = {
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".map": "application/json",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".html": "text/html",
|
||||
}
|
||||
_STATIC_SEPARATORS = re.compile(r"[\\/]+")
|
||||
|
||||
|
||||
def _resolve_static_file(root, filepath):
|
||||
"""Real path of ``filepath`` under ``root``, or None when it must not be served.
|
||||
|
||||
The request path is untrusted. It is rejected when it is empty, carries a
|
||||
``..`` segment, is absolute on any platform (a leading slash or backslash,
|
||||
a drive letter such as ``C:`` or a UNC ``\\\\server\\share`` prefix all
|
||||
re-anchor ``os.path.join`` on Windows), has an extension outside
|
||||
STATIC_MIME_TYPES, or resolves (symlinks included) outside ``root``.
|
||||
"""
|
||||
if not isinstance(filepath, str) or not filepath:
|
||||
return None
|
||||
if (
|
||||
os.path.isabs(filepath)
|
||||
or ntpath.isabs(filepath)
|
||||
or ntpath.splitdrive(filepath)[0]
|
||||
):
|
||||
return None
|
||||
segments = _STATIC_SEPARATORS.split(filepath)
|
||||
if any(segment in ("", ".", "..") for segment in segments):
|
||||
return None
|
||||
if os.path.splitext(filepath)[1].lower() not in STATIC_MIME_TYPES:
|
||||
return None
|
||||
try:
|
||||
root_real = Path(os.path.normcase(os.path.realpath(root)))
|
||||
candidate = Path(
|
||||
os.path.normcase(os.path.realpath(os.path.join(root, *segments)))
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if candidate == root_real or not candidate.is_relative_to(root_real):
|
||||
return None
|
||||
return str(candidate)
|
||||
|
||||
|
||||
# ── JSON request bodies ────────────────────────────────────────────────
|
||||
JSON_BODY_MAX_BYTES = 1_000_000
|
||||
_JSON_READ_CHUNK = 64 * 1024
|
||||
|
||||
|
||||
def _json_error(message, status):
|
||||
return web.json_response({"success": False, "error": message}, status=status)
|
||||
|
||||
|
||||
async def _read_json_body(request, max_bytes=JSON_BODY_MAX_BYTES):
|
||||
"""Read and parse a JSON object body of at most ``max_bytes``.
|
||||
|
||||
The body is read in chunks so an oversized or chunked upload is refused
|
||||
as soon as it crosses the cap rather than buffered whole.
|
||||
|
||||
Returns:
|
||||
(data, None) on success, or (None, response) where ``response`` is a
|
||||
413 (body too large) or 400 (invalid JSON, or not a JSON object).
|
||||
"""
|
||||
too_large = _json_error(f"Request body too large (max {max_bytes} bytes)", 413)
|
||||
declared = request.content_length
|
||||
if declared is not None and declared > max_bytes:
|
||||
return None, too_large
|
||||
|
||||
chunks = []
|
||||
size = 0
|
||||
async for chunk in request.content.iter_chunked(_JSON_READ_CHUNK):
|
||||
size += len(chunk)
|
||||
if size > max_bytes:
|
||||
return None, too_large
|
||||
chunks.append(chunk)
|
||||
|
||||
try:
|
||||
data = json.loads(b"".join(chunks).decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None, _json_error("Request body must be valid JSON", 400)
|
||||
if not isinstance(data, dict):
|
||||
return None, _json_error("Request body must be a JSON object", 400)
|
||||
return data, None
|
||||
|
||||
|
||||
class _LongJobGuard:
|
||||
"""Async context manager handed out by ``_acquire_long_job``."""
|
||||
|
||||
def __init__(self, lock):
|
||||
self._lock = lock
|
||||
|
||||
async def __aenter__(self):
|
||||
# The caller checked ``locked()`` on this same loop iteration, so this
|
||||
# acquire never waits.
|
||||
await self._lock.acquire()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
self._lock.release()
|
||||
return False
|
||||
|
||||
|
||||
# ── Gzip compression middleware ────────────────────────────────────────
|
||||
_GZIP_MIN_SIZE = 1024 # Only compress bodies larger than 1 KB
|
||||
_GZIP_TYPES = frozenset(
|
||||
(
|
||||
"application/json",
|
||||
"text/html",
|
||||
"text/css",
|
||||
"application/javascript",
|
||||
"text/plain",
|
||||
)
|
||||
)
|
||||
_gzip_registered = False
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def _gzip_middleware(request, handler):
|
||||
"""Compress PromptManager responses when the client accepts gzip."""
|
||||
response = await handler(request)
|
||||
|
||||
if not request.path.startswith("/prompt_manager/"):
|
||||
return response
|
||||
|
||||
# Only handle regular Response objects (not StreamResponse/WebSocket)
|
||||
if not isinstance(response, web.Response):
|
||||
return response
|
||||
|
||||
if "gzip" not in request.headers.get("Accept-Encoding", ""):
|
||||
return response
|
||||
|
||||
if "Content-Encoding" in response.headers:
|
||||
return response
|
||||
|
||||
body = response.body
|
||||
if body is None or len(body) < _GZIP_MIN_SIZE:
|
||||
return response
|
||||
|
||||
content_type = response.content_type or ""
|
||||
if not any(ct in content_type for ct in _GZIP_TYPES):
|
||||
return response
|
||||
|
||||
compressed = gzip_module.compress(body, compresslevel=6)
|
||||
if len(compressed) >= len(body):
|
||||
return response
|
||||
|
||||
response.body = compressed
|
||||
response.headers["Content-Encoding"] = "gzip"
|
||||
response.headers["Vary"] = "Accept-Encoding"
|
||||
return response
|
||||
|
||||
|
||||
class PromptManagerAPI(
|
||||
PromptRoutesMixin,
|
||||
ImageRoutesMixin,
|
||||
AdminRoutesMixin,
|
||||
LoggingRoutesMixin,
|
||||
AutotagRoutesMixin,
|
||||
LoraIntegrationMixin,
|
||||
):
|
||||
"""REST API handler for PromptManager operations and web interface.
|
||||
|
||||
This class provides comprehensive REST API endpoints for managing prompts,
|
||||
images, and system operations. It handles database interactions, file
|
||||
operations, image processing, and web UI serving.
|
||||
|
||||
The API is designed to integrate seamlessly with ComfyUI's aiohttp server
|
||||
and provides both JSON API endpoints and static file serving for the
|
||||
web interface.
|
||||
|
||||
Attributes:
|
||||
logger: Configured logger instance for API operations
|
||||
db (PromptDatabase): Database connection and operations handler
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the PromptManager API with database connection and cleanup."""
|
||||
self.logger = get_logger("prompt_manager.api")
|
||||
self.logger.info("Initializing PromptManager API")
|
||||
|
||||
self.db = PromptDatabase()
|
||||
self._cached_output_dir = None # Lazy-cached by _find_comfyui_output_dir()
|
||||
self._html_cache = {} # Cached HTML file contents keyed by path
|
||||
self._gallery_cache = {} # dict: path_str -> (files, timestamp)
|
||||
self._gallery_cache_ttl = 30 # Cache TTL in seconds
|
||||
# Single-flight locks for long jobs, keyed by job family; see
|
||||
# _acquire_long_job(). Created lazily so no lock binds to an event
|
||||
# loop before the server's loop exists.
|
||||
self._long_jobs: dict = {}
|
||||
# The current output scan (py/api/scan_job.ScanJob), if one was started.
|
||||
self._scan_job = None
|
||||
# The current duplicate scan (also a ScanJob), if one was started.
|
||||
self._duplicate_job = None
|
||||
|
||||
# Run cleanup on initialization to remove any existing duplicates
|
||||
try:
|
||||
removed = self.db.cleanup_duplicates()
|
||||
if removed > 0:
|
||||
self.logger.info(
|
||||
f"Startup cleanup: removed {removed} duplicate prompts"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Startup cleanup failed: {e}")
|
||||
|
||||
self.logger.info("PromptManager API initialization completed")
|
||||
|
||||
_public_path = staticmethod(_public_path)
|
||||
_public_error = staticmethod(_public_error)
|
||||
_read_json_body = staticmethod(_read_json_body)
|
||||
|
||||
def _acquire_long_job(self, name):
|
||||
"""Claim the single-flight slot for a long job family, or None when busy.
|
||||
|
||||
Usage in a handler::
|
||||
|
||||
job = self._acquire_long_job("duplicates")
|
||||
if job is None:
|
||||
return self._long_job_busy_response("Duplicate scan")
|
||||
async with job:
|
||||
...
|
||||
|
||||
Families are free-form strings ("duplicates", "thumbnails",
|
||||
"autotag", ...); the lock is released when the ``async with`` block
|
||||
exits, including on exceptions. Must be called from the event loop.
|
||||
"""
|
||||
lock = self._long_jobs.setdefault(name, asyncio.Lock())
|
||||
if lock.locked():
|
||||
return None
|
||||
return _LongJobGuard(lock)
|
||||
|
||||
@staticmethod
|
||||
def _long_job_busy_response(label):
|
||||
"""409 envelope for a long job that is already running."""
|
||||
return _json_error(f"{label} already running", 409)
|
||||
|
||||
async def _run_in_executor(self, func, *args, **kwargs):
|
||||
"""Run a blocking function in the default thread pool executor.
|
||||
|
||||
Prevents synchronous database calls, file I/O, and PIL operations
|
||||
from blocking the aiohttp event loop.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
if kwargs:
|
||||
call = functools.partial(func, *args, **kwargs)
|
||||
return await loop.run_in_executor(None, call)
|
||||
return await loop.run_in_executor(None, func, *args)
|
||||
|
||||
def invalidate_gallery_cache(self):
|
||||
"""Invalidate the cached gallery file listing.
|
||||
|
||||
Called by the image monitor when new files are detected.
|
||||
"""
|
||||
self._gallery_cache = {}
|
||||
|
||||
def add_routes(self, routes):
|
||||
"""Register all API routes with the ComfyUI server.
|
||||
|
||||
Args:
|
||||
routes: aiohttp RouteTableDef object from ComfyUI server instance.
|
||||
"""
|
||||
|
||||
# Test route to verify registration works
|
||||
@routes.get("/prompt_manager/test")
|
||||
async def test_route(request):
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "PromptManager API is working!",
|
||||
"timestamp": str(datetime.datetime.now()),
|
||||
}
|
||||
)
|
||||
|
||||
# ── Web UI serving routes ─────────────────────────────────────
|
||||
|
||||
@routes.get("/prompt_manager/web")
|
||||
async def serve_web_ui(request):
|
||||
return self._serve_html_page("index.html", "Web UI", cache=False)
|
||||
|
||||
@routes.get("/prompt_manager/gallery.html")
|
||||
async def serve_gallery_ui(request):
|
||||
return self._serve_html_page("metadata.html", "Gallery")
|
||||
|
||||
@routes.get("/prompt_manager/admin")
|
||||
async def serve_admin_ui(request):
|
||||
return self._serve_html_page("admin.html", "Admin UI")
|
||||
|
||||
@routes.get("/prompt_manager/gallery")
|
||||
async def serve_gallery_admin_ui(request):
|
||||
return self._serve_html_page("gallery.html", "Gallery")
|
||||
|
||||
# ── Static file serving ───────────────────────────────────────
|
||||
|
||||
@routes.get("/prompt_manager/lib/{filepath:.*}")
|
||||
async def serve_lib_static(request):
|
||||
"""Serve static library files (JS, CSS) from web/lib directory."""
|
||||
return self._serve_static_file("lib", request.match_info.get("filepath"))
|
||||
|
||||
@routes.get("/prompt_manager/js/{filepath:.*}")
|
||||
async def serve_js_static(request):
|
||||
"""Serve static JavaScript files from web/js directory."""
|
||||
return self._serve_static_file("js", request.match_info.get("filepath"))
|
||||
|
||||
# ── Register domain-specific routes from mixins ───────────────
|
||||
|
||||
self._register_prompt_routes(routes)
|
||||
self._register_image_routes(routes)
|
||||
self._register_admin_routes(routes)
|
||||
self._register_logging_routes(routes)
|
||||
self._register_autotag_routes(routes)
|
||||
self._register_lora_routes(routes)
|
||||
|
||||
# Register gzip compression middleware (once)
|
||||
global _gzip_registered
|
||||
if not _gzip_registered:
|
||||
try:
|
||||
from ..config import server_instance
|
||||
|
||||
server_instance.app.middlewares.append(_gzip_middleware)
|
||||
_gzip_registered = True
|
||||
self.logger.info("Gzip compression middleware registered")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not register gzip middleware: {e}")
|
||||
|
||||
self.logger.info("All routes registered with decorator pattern")
|
||||
|
||||
def _serve_static_file(self, subdir, filepath):
|
||||
"""Serve ``web/<subdir>/<filepath>``; 403 outside it, 404 when missing."""
|
||||
root = os.path.join(_get_project_root(), "web", subdir)
|
||||
file_path = _resolve_static_file(root, filepath)
|
||||
if file_path is None:
|
||||
return web.Response(text="Forbidden", status=403)
|
||||
if not os.path.isfile(file_path):
|
||||
return web.Response(text="Not Found", status=404)
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
with open(file_path, "rb") as f:
|
||||
content = f.read()
|
||||
return web.Response(body=content, content_type=STATIC_MIME_TYPES[ext])
|
||||
|
||||
def _serve_html_page(self, filename, title, cache=True):
|
||||
"""Serve ``web/<filename>``; 404 when missing, 500 when unreadable."""
|
||||
html_path = os.path.join(_get_project_root(), "web", filename)
|
||||
try:
|
||||
if cache and html_path in self._html_cache:
|
||||
content = self._html_cache[html_path]
|
||||
elif os.path.exists(html_path):
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
if cache:
|
||||
self._html_cache[html_path] = content
|
||||
else:
|
||||
return web.Response(
|
||||
text=f"<h1>{title} not found</h1>"
|
||||
"<p>HTML file not located at expected path.</p>",
|
||||
content_type="text/html",
|
||||
status=404,
|
||||
)
|
||||
return web.Response(text=content, content_type="text/html", charset="utf-8")
|
||||
except Exception:
|
||||
self.logger.exception(f"Failed to load {title}")
|
||||
return web.Response(
|
||||
text=f"<h1>Error</h1><p>Failed to load {title}. "
|
||||
"Check server logs for details.</p>",
|
||||
content_type="text/html",
|
||||
status=500,
|
||||
)
|
||||
|
||||
# ── Shared utilities used by multiple mixins ──────────────────────
|
||||
|
||||
def _enrich_images(self, images):
|
||||
"""Add url and thumbnail_url to a flat list of image dicts."""
|
||||
from urllib.parse import quote as url_quote
|
||||
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
|
||||
for image in images:
|
||||
image_path_str = image.get("image_path", "")
|
||||
if not image_path_str:
|
||||
continue
|
||||
|
||||
img_path = Path(image_path_str)
|
||||
|
||||
# Set fallback url via image ID
|
||||
if image.get("id"):
|
||||
image["url"] = f"/prompt_manager/images/{image['id']}/file"
|
||||
|
||||
# Try each root to compute relative path and thumbnail URL
|
||||
for output_path in output_dirs:
|
||||
try:
|
||||
rel_path = img_path.resolve().relative_to(output_path.resolve())
|
||||
image["relative_path"] = str(rel_path)
|
||||
image["url"] = (
|
||||
"/prompt_manager/images/serve/"
|
||||
f"{url_quote(rel_path.as_posix(), safe='/')}"
|
||||
)
|
||||
|
||||
# Check for thumbnail
|
||||
rel_no_ext = rel_path.with_suffix("")
|
||||
thumb_rel = (
|
||||
f"thumbnails/{rel_no_ext.as_posix()}_thumb{rel_path.suffix}"
|
||||
)
|
||||
from .prompts import thumbnail_url_for
|
||||
|
||||
thumbnail_url = thumbnail_url_for(
|
||||
thumb_rel, output_path / thumb_rel
|
||||
)
|
||||
if thumbnail_url:
|
||||
image["thumbnail_url"] = thumbnail_url
|
||||
break # Found matching root, stop searching
|
||||
except (ValueError, RuntimeError):
|
||||
continue # Try next root
|
||||
|
||||
return images
|
||||
|
||||
def _enrich_prompt_images(self, prompts):
|
||||
"""Add url and thumbnail_url to each image in prompt results."""
|
||||
for prompt in prompts:
|
||||
self._enrich_images(prompt.get("images", []))
|
||||
return prompts
|
||||
|
||||
def _clean_nan_recursive(self, obj):
|
||||
"""Recursively clean NaN values from nested data structures."""
|
||||
if isinstance(obj, dict):
|
||||
return {key: self._clean_nan_recursive(value) for key, value in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [self._clean_nan_recursive(item) for item in obj]
|
||||
elif isinstance(obj, float) and str(obj) == "nan":
|
||||
return None
|
||||
else:
|
||||
return obj
|
||||
|
||||
def _find_comfyui_output_dir(self):
|
||||
"""Locate the ComfyUI output directory using multiple detection strategies.
|
||||
|
||||
Checks in order:
|
||||
1. User-configured directory (GalleryConfig.MONITORING_DIRECTORIES)
|
||||
2. Upward filesystem search for ComfyUI markers
|
||||
3. Common installation path patterns
|
||||
4. Common ComfyUI installation locations
|
||||
|
||||
Results are cached after first successful lookup. The cache is
|
||||
invalidated when the user changes the configured directory via
|
||||
save_settings().
|
||||
|
||||
Returns:
|
||||
str or None: Absolute path to ComfyUI output directory, or None
|
||||
if no valid directory is found
|
||||
"""
|
||||
if self._cached_output_dir is not None:
|
||||
return self._cached_output_dir
|
||||
|
||||
# Method 0: Check user-configured directory first
|
||||
try:
|
||||
from ..config import GalleryConfig
|
||||
|
||||
if GalleryConfig.MONITORING_DIRECTORIES:
|
||||
configured = GalleryConfig.MONITORING_DIRECTORIES[0]
|
||||
# Same check as the settings endpoint: a root hand-edited
|
||||
# into config.json must not open a directory outside ComfyUI
|
||||
ok, reason = GalleryConfig.validate_gallery_root(configured)
|
||||
if ok:
|
||||
configured_dir = Path(configured).resolve()
|
||||
self.logger.info(
|
||||
f"Using configured monitoring directory: {configured_dir}"
|
||||
)
|
||||
self._cached_output_dir = str(configured_dir)
|
||||
return self._cached_output_dir
|
||||
self.logger.warning(f"Ignoring configured directory: {reason}")
|
||||
except ImportError:
|
||||
self.logger.debug(
|
||||
"GalleryConfig not available, skipping configured directory check"
|
||||
)
|
||||
|
||||
current_file = Path(__file__).resolve()
|
||||
self.logger.debug(f"Starting ComfyUI output search from: {current_file}")
|
||||
|
||||
# Method 1: Search upward from current file location
|
||||
current_dir = current_file.parent
|
||||
max_depth = 10 # Prevent infinite loops
|
||||
|
||||
for i in range(max_depth):
|
||||
# Check if current directory contains ComfyUI markers
|
||||
comfyui_markers = ["main.py", "nodes.py", "server.py"]
|
||||
if any((current_dir / marker).exists() for marker in comfyui_markers):
|
||||
output_dir = current_dir / "output"
|
||||
if output_dir.exists() and output_dir.is_dir():
|
||||
self.logger.debug(
|
||||
"Found ComfyUI output directory via upward search: "
|
||||
f"{output_dir}"
|
||||
)
|
||||
self._cached_output_dir = str(output_dir)
|
||||
return self._cached_output_dir
|
||||
|
||||
# Move up one directory
|
||||
parent = current_dir.parent
|
||||
if parent == current_dir: # Reached filesystem root
|
||||
break
|
||||
current_dir = parent
|
||||
|
||||
# Method 2: Try common installation patterns relative to this file
|
||||
# File is at: custom_nodes/ComfyUI_PromptManager/py/api/__init__.py
|
||||
base_dir = current_file.parent # .../py/api/
|
||||
possible_paths = [
|
||||
base_dir.parent.parent.parent.parent / "output", # ../../../../output
|
||||
base_dir.parent.parent.parent / "output", # ../../../output
|
||||
base_dir.parent.parent / "output", # ../../output
|
||||
base_dir.parent / "output", # ../output
|
||||
]
|
||||
|
||||
# Method 3: Add common ComfyUI installation locations
|
||||
common_locations = [
|
||||
Path.home() / "ComfyUI" / "output",
|
||||
Path.cwd() / "output",
|
||||
Path.cwd() / ".." / "output",
|
||||
Path.cwd() / ".." / ".." / "output",
|
||||
]
|
||||
|
||||
all_paths = possible_paths + common_locations
|
||||
|
||||
for path in all_paths:
|
||||
try:
|
||||
abs_path = path.resolve()
|
||||
if abs_path.exists() and abs_path.is_dir():
|
||||
self.logger.debug(f"Found ComfyUI output directory: {abs_path}")
|
||||
self._cached_output_dir = str(abs_path)
|
||||
return self._cached_output_dir
|
||||
except (OSError, RuntimeError):
|
||||
continue # Skip invalid paths
|
||||
|
||||
self.logger.warning("ComfyUI output directory not found. Searched paths:")
|
||||
for path in all_paths:
|
||||
try:
|
||||
self.logger.warning(f" - {path.resolve()} (exists: {path.exists()})")
|
||||
except (OSError, RuntimeError):
|
||||
self.logger.warning(f" - {path} (invalid path)")
|
||||
|
||||
return None
|
||||
|
||||
def _get_all_output_dirs(self):
|
||||
"""Get the directories images may be served from.
|
||||
|
||||
Configured gallery roots are filtered through
|
||||
``GalleryConfig.validate_gallery_root`` so a root that was hand-edited
|
||||
into config.json (or persisted before validation existed) can never
|
||||
expose files outside ComfyUI's directories.
|
||||
|
||||
Returns:
|
||||
List[Path]: Validated directory paths, possibly empty.
|
||||
"""
|
||||
from ..config import GalleryConfig
|
||||
|
||||
configured = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
output_dirs = []
|
||||
seen = set()
|
||||
for d in configured:
|
||||
ok, reason = GalleryConfig.validate_gallery_root(d)
|
||||
if not ok:
|
||||
self.logger.warning(f"Ignoring configured gallery root: {reason}")
|
||||
continue
|
||||
real = os.path.realpath(d)
|
||||
key = os.path.normcase(real) # one entry per directory, however spelled
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
output_dirs.append(Path(real))
|
||||
|
||||
if output_dirs:
|
||||
return output_dirs
|
||||
|
||||
if configured:
|
||||
# Every configured root was rejected: never let auto-detection
|
||||
# resurrect it, serve ComfyUI's own output directory instead.
|
||||
return self._comfyui_output_dir_list()
|
||||
|
||||
fallback = self._find_comfyui_output_dir()
|
||||
return [Path(fallback)] if fallback else []
|
||||
|
||||
def _comfyui_output_dir_list(self):
|
||||
"""ComfyUI's output directory as a one-element list, or [] if unknown."""
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
except (ImportError, AttributeError, OSError):
|
||||
return []
|
||||
if isinstance(output_dir, str) and os.path.isdir(output_dir):
|
||||
return [Path(os.path.realpath(output_dir))]
|
||||
return []
|
||||
|
||||
def _extract_comfyui_metadata(self, image_path):
|
||||
"""Embedded ComfyUI metadata of an image (PNG text chunks) or a video.
|
||||
|
||||
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.
|
||||
"""
|
||||
if is_video_path(image_path):
|
||||
return read_video_metadata(image_path)
|
||||
try:
|
||||
with Image.open(image_path) as img:
|
||||
return dict(getattr(img, "text", None) or {})
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
f"Unreadable image {os.path.basename(str(image_path))}: {e}"
|
||||
)
|
||||
return {}
|
||||
|
||||
def _parse_comfyui_prompt(self, metadata):
|
||||
"""Parse ComfyUI and A1111 prompt data from metadata."""
|
||||
result = {
|
||||
"prompt": None,
|
||||
"workflow": None,
|
||||
"parameters": {},
|
||||
"positive_prompt": None,
|
||||
"negative_prompt": None,
|
||||
}
|
||||
|
||||
# Check for A1111/Forge style parameters first.
|
||||
# The chunk is one block: a possibly multi-line positive prompt, then a
|
||||
# "Negative prompt:" section, then a trailing "Steps: ..." settings line.
|
||||
# Taking lines[0] as the positive prompt truncates any multi-line prompt
|
||||
# to one line, so split on the settings line instead.
|
||||
if "parameters" in metadata:
|
||||
import re as _re
|
||||
|
||||
params = metadata["parameters"]
|
||||
body = params
|
||||
settings = ""
|
||||
_sm = _re.search(r"\nSteps:\s", body)
|
||||
if _sm:
|
||||
settings = body[_sm.start() + 1 :].strip()
|
||||
body = body[: _sm.start()]
|
||||
negative = None
|
||||
_nm = _re.search(r"(?im)^\s*negative prompt:\s*", body)
|
||||
if _nm:
|
||||
negative = body[_nm.end() :].strip()
|
||||
body = body[: _nm.start()]
|
||||
elif body.lower().lstrip().startswith("negative prompt:"):
|
||||
negative = body.lower().lstrip()[len("negative prompt:") :].strip()
|
||||
body = ""
|
||||
result["positive_prompt"] = body.strip() or None
|
||||
if negative:
|
||||
result["negative_prompt"] = negative
|
||||
# Store raw parameters too
|
||||
result["parameters"]["parameters"] = params
|
||||
if settings:
|
||||
for _f in ("Steps", "Sampler", "Schedule type", "CFG scale", "Seed"):
|
||||
_fm = _re.search(
|
||||
r"(?i)(?:^|,\s*)" + _re.escape(_f) + r":\s*([^,]+)", settings
|
||||
)
|
||||
if _fm:
|
||||
result["parameters"][_f.lower().replace(" ", "_")] = _fm.group(
|
||||
1
|
||||
).strip()
|
||||
|
||||
# If no A1111 format found, proceed with ComfyUI parsing
|
||||
if result["positive_prompt"] is None:
|
||||
# Check for direct prompt field
|
||||
if "prompt" in metadata:
|
||||
try:
|
||||
prompt_data = json.loads(metadata["prompt"])
|
||||
result["prompt"] = prompt_data
|
||||
except json.JSONDecodeError:
|
||||
result["prompt"] = metadata["prompt"]
|
||||
|
||||
# Check for workflow
|
||||
if "workflow" in metadata:
|
||||
try:
|
||||
workflow_data = json.loads(metadata["workflow"])
|
||||
result["workflow"] = workflow_data
|
||||
except json.JSONDecodeError:
|
||||
result["workflow"] = metadata["workflow"]
|
||||
|
||||
# Check for other common ComfyUI fields
|
||||
common_fields = [
|
||||
"positive",
|
||||
"negative",
|
||||
"steps",
|
||||
"cfg",
|
||||
"sampler",
|
||||
"scheduler",
|
||||
"seed",
|
||||
]
|
||||
for field in common_fields:
|
||||
if field in metadata:
|
||||
try:
|
||||
result["parameters"][field] = json.loads(metadata[field])
|
||||
except json.JSONDecodeError:
|
||||
result["parameters"][field] = metadata[field]
|
||||
|
||||
return result
|
||||
|
||||
def _extract_readable_prompt(self, parsed_data):
|
||||
"""Extract human-readable prompt text from ComfyUI/A1111 data."""
|
||||
|
||||
def safe_to_string(value):
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
elif isinstance(value, list):
|
||||
return " ".join(str(item) for item in value if item)
|
||||
elif value is not None:
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
# First check if we already extracted a positive prompt (A1111 format)
|
||||
if parsed_data.get("positive_prompt"):
|
||||
return parsed_data["positive_prompt"]
|
||||
|
||||
# Check if prompt is already a string
|
||||
if isinstance(parsed_data.get("prompt"), str):
|
||||
return parsed_data["prompt"]
|
||||
|
||||
# Check if prompt is a simple value that can be converted
|
||||
if parsed_data.get("prompt") and not isinstance(
|
||||
parsed_data.get("prompt"), dict
|
||||
):
|
||||
return safe_to_string(parsed_data["prompt"])
|
||||
|
||||
prompt_data = parsed_data.get("prompt")
|
||||
if isinstance(prompt_data, dict):
|
||||
# Use the enhanced logic from parse-metadata.py
|
||||
positive_prompt = self._extract_positive_prompt_from_comfyui_data(
|
||||
prompt_data
|
||||
)
|
||||
if positive_prompt:
|
||||
return positive_prompt
|
||||
|
||||
# Check workflow data if available
|
||||
workflow_data = parsed_data.get("workflow")
|
||||
if isinstance(workflow_data, dict):
|
||||
positive_prompt = self._extract_positive_prompt_from_comfyui_data(
|
||||
workflow_data
|
||||
)
|
||||
if positive_prompt:
|
||||
return positive_prompt
|
||||
|
||||
# Check parameters for positive prompt
|
||||
if parsed_data.get("parameters", {}).get("positive"):
|
||||
return safe_to_string(parsed_data["parameters"]["positive"])
|
||||
|
||||
return None
|
||||
|
||||
def _get_node_inputs(self, node):
|
||||
"""Safely get inputs from a node, handling both dict and list formats.
|
||||
|
||||
Old format: inputs is a dict with direct key-value pairs
|
||||
inputs = {"text": "my prompt", "seed": 123}
|
||||
|
||||
New format: inputs is a list of connection objects
|
||||
inputs = [
|
||||
{"name": "text", "type": "STRING", "link": null,
|
||||
"widget": {"name": "text"}},
|
||||
{"name": "clip", "type": "CLIP", "link": 11}
|
||||
]
|
||||
"""
|
||||
if not isinstance(node, dict):
|
||||
return {}
|
||||
|
||||
inputs = node.get("inputs", {})
|
||||
|
||||
# If inputs is already a dict, return it
|
||||
if isinstance(inputs, dict):
|
||||
return inputs
|
||||
|
||||
# If inputs is a list, convert to dict format
|
||||
if isinstance(inputs, list):
|
||||
inputs_dict = {}
|
||||
for input_item in inputs:
|
||||
if isinstance(input_item, dict) and "name" in input_item:
|
||||
name = input_item["name"]
|
||||
inputs_dict[name] = input_item
|
||||
return inputs_dict
|
||||
|
||||
return {}
|
||||
|
||||
def _find_text_in_node(self, node):
|
||||
"""Try to find text content in a node using various strategies.
|
||||
|
||||
Handles both old and new workflow formats.
|
||||
"""
|
||||
if not isinstance(node, dict):
|
||||
return None
|
||||
|
||||
# Strategy 1: Check normalized inputs for 'text' field
|
||||
inputs = self._get_node_inputs(node)
|
||||
if "text" in inputs and isinstance(inputs["text"], str):
|
||||
return inputs["text"]
|
||||
|
||||
# Strategy 2: For text encoder nodes, check widgets_values
|
||||
class_type = node.get("class_type", node.get("type", ""))
|
||||
text_encoder_types = [
|
||||
"CLIPTextEncode",
|
||||
"CLIPTextEncodeSDXL",
|
||||
"CLIPTextEncodeSDXLRefiner",
|
||||
"CLIPTextEncodeFlux",
|
||||
"PromptManager",
|
||||
"PromptManagerText",
|
||||
"BNK_CLIPTextEncoder",
|
||||
"Text Encoder",
|
||||
"CLIP Text Encode",
|
||||
]
|
||||
|
||||
if any(
|
||||
encoder_type.lower() in class_type.lower()
|
||||
for encoder_type in text_encoder_types
|
||||
):
|
||||
widgets_values = node.get("widgets_values", [])
|
||||
if widgets_values and len(widgets_values) > 0:
|
||||
if isinstance(widgets_values[0], str) and widgets_values[0].strip():
|
||||
return widgets_values[0]
|
||||
|
||||
return None
|
||||
|
||||
def _extract_positive_prompt_from_comfyui_data(self, data):
|
||||
"""Extract positive prompt from ComfyUI data.
|
||||
|
||||
Handles both old and new formats.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
# Build nodes dictionary
|
||||
nodes_by_id = {}
|
||||
if "nodes" in data:
|
||||
# Handle nodes array format
|
||||
for node in data["nodes"]:
|
||||
if isinstance(node, dict):
|
||||
nid = node.get("id")
|
||||
if nid is not None:
|
||||
nodes_by_id[nid] = node
|
||||
else:
|
||||
# Handle flat dictionary format (node_id -> node_data)
|
||||
for nid_str, node in data.items():
|
||||
try:
|
||||
nid = int(nid_str)
|
||||
except (ValueError, TypeError):
|
||||
nid = nid_str
|
||||
if isinstance(node, dict):
|
||||
if "id" in node:
|
||||
nid = node["id"]
|
||||
nodes_by_id[nid] = node
|
||||
|
||||
if not nodes_by_id:
|
||||
return None
|
||||
|
||||
# First, try to find positive/negative connection pattern
|
||||
pos_id = None
|
||||
for node in nodes_by_id.values():
|
||||
if isinstance(node, dict):
|
||||
inputs = self._get_node_inputs(node)
|
||||
if "positive" in inputs and "negative" in inputs:
|
||||
try:
|
||||
pos_input = inputs["positive"]
|
||||
if isinstance(pos_input, list) and len(pos_input) > 0:
|
||||
pos_id = int(pos_input[0])
|
||||
break
|
||||
except (ValueError, TypeError, IndexError):
|
||||
continue
|
||||
|
||||
# Get text from the positive node
|
||||
if pos_id is not None and pos_id in nodes_by_id:
|
||||
text_val = self._find_text_in_node(nodes_by_id[pos_id])
|
||||
if text_val:
|
||||
return text_val
|
||||
|
||||
# Fallback: find any text encoder node with text content
|
||||
text_nodes = []
|
||||
for node in nodes_by_id.values():
|
||||
if isinstance(node, dict):
|
||||
text_val = self._find_text_in_node(node)
|
||||
if text_val:
|
||||
# Try to determine if this is positive or negative
|
||||
node_title = node.get("title", "").lower()
|
||||
if "neg" not in node_title and "negative" not in node_title:
|
||||
# Prioritize non-negative prompts
|
||||
text_nodes.insert(0, text_val)
|
||||
else:
|
||||
text_nodes.append(text_val)
|
||||
|
||||
# Return the first positive-looking prompt
|
||||
if text_nodes:
|
||||
return text_nodes[0]
|
||||
|
||||
return None
|
||||
+1490
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,746 @@
|
||||
"""AutoTag API routes for PromptManager."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
from .prompts import thumbnail_url_for
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
_INTERNAL_ERROR = "An internal error occurred. Check server logs for details."
|
||||
_BUSY_ERROR = "Auto-tagging is already running"
|
||||
|
||||
# Only these may be handed to the tagging engine or listed by the scanner.
|
||||
_MEDIA_EXTENSIONS = frozenset(
|
||||
{".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff", ".tif"}
|
||||
)
|
||||
# Bounds for scan_output_dir: a runaway output tree must not pin the server.
|
||||
_SCAN_MAX_FILES = 50_000
|
||||
_SCAN_MAX_DEPTH = 12
|
||||
|
||||
# One batch run at a time; a second autotag/start gets 409 while this is held.
|
||||
_autotag_batch_lock = threading.Lock()
|
||||
|
||||
|
||||
def _file_exists(path) -> bool:
|
||||
try:
|
||||
return Path(str(path)).exists()
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def path_is_within(candidate, root) -> bool:
|
||||
"""True when ``candidate`` is strictly inside ``root`` on the real filesystem.
|
||||
|
||||
Both sides go through ``os.path.realpath`` (symlinks, ``..``) and
|
||||
``os.path.normcase`` (Windows case/separator folding) before the
|
||||
``Path.is_relative_to`` check, so ``/out2`` is never treated as inside
|
||||
``/out`` and a symlink inside ``root`` pointing elsewhere is rejected.
|
||||
"""
|
||||
try:
|
||||
child = Path(os.path.normcase(os.path.realpath(str(candidate))))
|
||||
parent = Path(os.path.normcase(os.path.realpath(str(root))))
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return False
|
||||
return child != parent and child.is_relative_to(parent)
|
||||
|
||||
|
||||
class AutotagRoutesMixin:
|
||||
"""Mixin providing auto-tagging API endpoints."""
|
||||
|
||||
def _register_autotag_routes(self, routes):
|
||||
@routes.get("/prompt_manager/autotag/models")
|
||||
async def get_autotag_models_route(request):
|
||||
return await self.get_autotag_models(request)
|
||||
|
||||
@routes.post("/prompt_manager/autotag/download/{model_type}")
|
||||
async def download_autotag_model_route(request):
|
||||
return await self.download_autotag_model(request)
|
||||
|
||||
@routes.post("/prompt_manager/autotag/start")
|
||||
async def start_autotag_route(request):
|
||||
return await self.start_autotag(request)
|
||||
|
||||
@routes.post("/prompt_manager/autotag/single")
|
||||
async def autotag_single_route(request):
|
||||
return await self.autotag_single(request)
|
||||
|
||||
@routes.post("/prompt_manager/autotag/apply")
|
||||
async def apply_autotag_route(request):
|
||||
return await self.apply_autotag(request)
|
||||
|
||||
@routes.post("/prompt_manager/autotag/unload")
|
||||
async def unload_autotag_model_route(request):
|
||||
return await self.unload_autotag_model(request)
|
||||
|
||||
@routes.get("/prompt_manager/scan_output_dir")
|
||||
async def scan_output_dir_route(request):
|
||||
return await self.scan_output_dir(request)
|
||||
|
||||
async def get_autotag_models(self, request):
|
||||
"""Get status of available AutoTag models."""
|
||||
try:
|
||||
from ..autotag import get_autotag_service
|
||||
|
||||
service = get_autotag_service()
|
||||
models_status = service.get_models_status()
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"models": models_status,
|
||||
"default_prompt": service.default_prompt,
|
||||
"model_loaded": service.is_model_loaded(),
|
||||
"loaded_model_type": service.get_loaded_model_type(),
|
||||
"wd14_general_threshold": service.wd14_general_threshold,
|
||||
"wd14_character_threshold": service.wd14_character_threshold,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Get autotag models error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(e)}, status=500
|
||||
)
|
||||
|
||||
async def _stream_sse(self, request, events):
|
||||
"""Write an async iterator of dict events as server-sent events.
|
||||
|
||||
A client disconnect raises ConnectionResetError from ``write``; the
|
||||
event generator is then closed so its cleanup runs before re-raising
|
||||
(aiohttp logs premature disconnects at debug level).
|
||||
"""
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
reason="OK",
|
||||
headers={
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
await response.prepare(request)
|
||||
try:
|
||||
async for event in events:
|
||||
await response.write(f"data: {json.dumps(event)}\n\n".encode("utf-8"))
|
||||
except (ConnectionResetError, asyncio.CancelledError):
|
||||
self.logger.info("Client disconnected; stopping autotag stream")
|
||||
await events.aclose()
|
||||
raise
|
||||
await response.write_eof()
|
||||
return response
|
||||
|
||||
async def download_autotag_model(self, request):
|
||||
"""Download an AutoTag model with streaming progress (POST only)."""
|
||||
model_type = request.match_info.get("model_type")
|
||||
|
||||
async def events():
|
||||
try:
|
||||
from ..autotag import get_autotag_service
|
||||
|
||||
service = get_autotag_service()
|
||||
if model_type not in service.models_config:
|
||||
yield {
|
||||
"type": "error",
|
||||
"message": f"Invalid model type: {model_type}",
|
||||
}
|
||||
return
|
||||
|
||||
yield {
|
||||
"type": "progress",
|
||||
"progress": 0,
|
||||
"status": "Starting download...",
|
||||
}
|
||||
|
||||
def progress_callback(status: str, progress: float):
|
||||
pass
|
||||
|
||||
success = await self._run_in_executor(
|
||||
service.download_model, model_type, progress_callback
|
||||
)
|
||||
if success:
|
||||
yield {
|
||||
"type": "complete",
|
||||
"progress": 100,
|
||||
"status": "Download complete",
|
||||
}
|
||||
else:
|
||||
yield {"type": "error", "message": "Download failed"}
|
||||
except Exception:
|
||||
self.logger.exception("Download model error")
|
||||
yield {
|
||||
"type": "error",
|
||||
"message": _INTERNAL_ERROR,
|
||||
}
|
||||
|
||||
return await self._stream_sse(request, events())
|
||||
|
||||
async def _read_start_params(self, request) -> Optional[dict]:
|
||||
"""Merge query-string and optional JSON-body parameters for autotag/start.
|
||||
|
||||
Returns None when a body is present but is not a JSON object.
|
||||
"""
|
||||
params = dict(request.query)
|
||||
if request.can_read_body:
|
||||
try:
|
||||
body = await request.json()
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
if not isinstance(body, dict):
|
||||
return None
|
||||
params.update(body)
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def _real_tags(tags) -> list:
|
||||
"""Normalise a tag field to a list without bookkeeping markers."""
|
||||
if isinstance(tags, str):
|
||||
tags = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
return [
|
||||
t
|
||||
for t in (tags or [])
|
||||
if t != "auto-scanned"
|
||||
and not t.startswith("prepend:")
|
||||
and not t.startswith("append:")
|
||||
]
|
||||
|
||||
async def _apply_tags_to_prompt(self, prompt_id, tags) -> bool:
|
||||
"""Merge ``tags`` into the prompt; True when something new was added."""
|
||||
existing_prompt = await self._run_in_executor(
|
||||
self.db.get_prompt_by_id, prompt_id
|
||||
)
|
||||
if not existing_prompt:
|
||||
return False
|
||||
existing_tags = existing_prompt.get("tags", [])
|
||||
if isinstance(existing_tags, str):
|
||||
existing_tags = [t.strip() for t in existing_tags.split(",") if t.strip()]
|
||||
new_tags = [t for t in tags if t not in existing_tags]
|
||||
if not new_tags:
|
||||
return False
|
||||
await self._run_in_executor(
|
||||
self.db.update_prompt_metadata, prompt_id, tags=existing_tags + new_tags
|
||||
)
|
||||
return True
|
||||
|
||||
async def _autotag_batch_events(self, service, opts: dict):
|
||||
"""Yield SSE progress dicts while tagging every image linked to a prompt."""
|
||||
import time as _time
|
||||
|
||||
model_type = opts["model_type"]
|
||||
keep_in_memory = opts["keep_in_memory"]
|
||||
status = service.get_models_status()
|
||||
if not status.get(model_type, {}).get("downloaded"):
|
||||
yield {"type": "error", "message": f"Model {model_type} not downloaded"}
|
||||
return
|
||||
|
||||
yield {"type": "progress", "progress": 0, "status": "Loading model..."}
|
||||
try:
|
||||
await self._run_in_executor(service.load_model, model_type, True)
|
||||
except Exception:
|
||||
self.logger.exception("Failed to load model")
|
||||
yield {
|
||||
"type": "error",
|
||||
"message": "Failed to load model. Check server logs for details.",
|
||||
}
|
||||
return
|
||||
|
||||
if opts["custom_prompt"]:
|
||||
service.custom_prompt = opts["custom_prompt"]
|
||||
|
||||
yield {
|
||||
"type": "progress",
|
||||
"progress": 5,
|
||||
"status": "Model loaded. Fetching all images from database...",
|
||||
}
|
||||
images = await self._run_in_executor(self.db.get_all_images)
|
||||
total = len(images)
|
||||
if total == 0:
|
||||
yield {
|
||||
"type": "complete",
|
||||
"processed": 0,
|
||||
"tagged": 0,
|
||||
"skipped": 0,
|
||||
"errors": 0,
|
||||
"skipped_outside_roots": 0,
|
||||
"status": "No images with linked prompts found",
|
||||
}
|
||||
service.unload_model()
|
||||
return
|
||||
|
||||
yield {
|
||||
"type": "progress",
|
||||
"progress": 10,
|
||||
"status": f"Found {total} images. Processing...",
|
||||
}
|
||||
|
||||
counts = {
|
||||
"processed": 0,
|
||||
"tagged": 0,
|
||||
"skipped": 0,
|
||||
"errors": 0,
|
||||
"skipped_outside_roots": 0,
|
||||
}
|
||||
tagged_prompt_ids = set()
|
||||
last_update = _time.monotonic()
|
||||
finished = False
|
||||
|
||||
def progress_event(i, message):
|
||||
return {
|
||||
"type": "progress",
|
||||
"progress": 10 + int((i + 1) / total * 85),
|
||||
"status": message,
|
||||
"processed": counts["processed"],
|
||||
"tagged": counts["tagged"],
|
||||
"skipped": counts["skipped"],
|
||||
"skipped_outside_roots": counts["skipped_outside_roots"],
|
||||
}
|
||||
|
||||
try:
|
||||
for i, image_data in enumerate(images):
|
||||
image_path = image_data.get("image_path")
|
||||
prompt_id = image_data.get("prompt_id")
|
||||
skip_reason = None
|
||||
allowed_path = None
|
||||
if not image_path or not prompt_id:
|
||||
skip_reason = "unlinked"
|
||||
elif prompt_id in tagged_prompt_ids:
|
||||
skip_reason = "prompt already processed"
|
||||
else:
|
||||
# Rows can point anywhere (restored DB, hand edits): only
|
||||
# media files inside the served output roots are tagged.
|
||||
allowed_path = self._resolve_allowed_image_path(str(image_path))
|
||||
if allowed_path is None:
|
||||
if _file_exists(image_path):
|
||||
skip_reason = "outside allowed directories"
|
||||
counts["skipped_outside_roots"] += 1
|
||||
else:
|
||||
skip_reason = "file missing"
|
||||
elif opts["skip_tagged"] and self._real_tags(
|
||||
image_data.get("prompt_tags", [])
|
||||
):
|
||||
tagged_prompt_ids.add(prompt_id)
|
||||
skip_reason = "already tagged"
|
||||
|
||||
if skip_reason is not None:
|
||||
counts["skipped"] += 1
|
||||
now = _time.monotonic()
|
||||
if (now - last_update) >= 0.5 or i == total - 1:
|
||||
yield progress_event(
|
||||
i, f"Skipping {i + 1}/{total} ({skip_reason})..."
|
||||
)
|
||||
await asyncio.sleep(0.01)
|
||||
last_update = now
|
||||
continue
|
||||
|
||||
try:
|
||||
tags = await self._run_in_executor(
|
||||
service.generate_tags,
|
||||
allowed_path,
|
||||
general_threshold=opts["general_threshold"],
|
||||
character_threshold=opts["character_threshold"],
|
||||
)
|
||||
counts["processed"] += 1
|
||||
if tags and await self._apply_tags_to_prompt(prompt_id, tags):
|
||||
counts["tagged"] += 1
|
||||
else:
|
||||
counts["skipped"] += 1
|
||||
tagged_prompt_ids.add(prompt_id)
|
||||
except Exception as img_err:
|
||||
self.logger.error(
|
||||
f"Error processing {os.path.basename(str(image_path))}: "
|
||||
f"{img_err}"
|
||||
)
|
||||
counts["errors"] += 1
|
||||
counts["processed"] += 1
|
||||
|
||||
yield progress_event(i, f"Processing {i + 1}/{total}...")
|
||||
await asyncio.sleep(0.01)
|
||||
last_update = _time.monotonic()
|
||||
|
||||
finished = True
|
||||
if not keep_in_memory:
|
||||
service.unload_model()
|
||||
yield {
|
||||
"type": "complete",
|
||||
"progress": 100,
|
||||
**counts,
|
||||
"status": "Complete",
|
||||
"model_status": (
|
||||
"Model kept in memory" if keep_in_memory else "Model unloaded"
|
||||
),
|
||||
"model_loaded": keep_in_memory,
|
||||
}
|
||||
finally:
|
||||
if not finished and not keep_in_memory:
|
||||
service.unload_model()
|
||||
|
||||
async def start_autotag(self, request):
|
||||
"""Start batch auto-tagging with streaming progress (POST only)."""
|
||||
params = await self._read_start_params(request)
|
||||
if params is None:
|
||||
return self._bad_request("Request body must be a JSON object")
|
||||
|
||||
opts = {
|
||||
"model_type": params.get("model_type", "gguf"),
|
||||
"custom_prompt": params.get("prompt", ""),
|
||||
"skip_tagged": str(params.get("skip_tagged", "true")).lower() == "true",
|
||||
"keep_in_memory": str(params.get("keep_in_memory", "true")).lower()
|
||||
== "true",
|
||||
"general_threshold": params.get("general_threshold"),
|
||||
"character_threshold": params.get("character_threshold"),
|
||||
}
|
||||
try:
|
||||
for key in ("general_threshold", "character_threshold"):
|
||||
if opts[key] is not None:
|
||||
opts[key] = float(opts[key])
|
||||
except (ValueError, TypeError):
|
||||
return self._bad_request(
|
||||
"general_threshold and character_threshold must be numeric"
|
||||
)
|
||||
|
||||
async def events():
|
||||
try:
|
||||
from ..autotag import get_autotag_service
|
||||
|
||||
async for event in self._autotag_batch_events(
|
||||
get_autotag_service(), opts
|
||||
):
|
||||
yield event
|
||||
except Exception:
|
||||
self.logger.exception("AutoTag error")
|
||||
yield {
|
||||
"type": "error",
|
||||
"message": _INTERNAL_ERROR,
|
||||
}
|
||||
|
||||
if not _autotag_batch_lock.acquire(blocking=False):
|
||||
return web.json_response(
|
||||
{"success": False, "error": _BUSY_ERROR}, status=409
|
||||
)
|
||||
try:
|
||||
return await self._stream_sse(request, events())
|
||||
finally:
|
||||
_autotag_batch_lock.release()
|
||||
|
||||
def _resolve_allowed_image_path(self, raw_path: str) -> Optional[str]:
|
||||
"""Return the real path when it is a media file inside an output directory."""
|
||||
if Path(str(raw_path)).suffix.lower() not in _MEDIA_EXTENSIONS:
|
||||
return None
|
||||
for output_dir in self._get_all_output_dirs():
|
||||
if path_is_within(raw_path, output_dir):
|
||||
real = os.path.realpath(raw_path)
|
||||
return real if os.path.isfile(real) else None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _forbidden(raw_path: str):
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": (
|
||||
f"{os.path.basename(str(raw_path).rstrip('/' + os.sep))} is not "
|
||||
"an image inside the configured output directories"
|
||||
),
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
|
||||
async def _select_autotag_target(self, data: dict):
|
||||
"""Pick the image to tag from an autotag/single body.
|
||||
|
||||
Returns ``(image_path, prompt_id, None)`` on success or
|
||||
``(None, None, error_response)`` when the request must be rejected.
|
||||
"""
|
||||
image_id = data.get("image_id")
|
||||
if image_id is not None:
|
||||
if isinstance(image_id, bool) or not isinstance(image_id, (int, str)):
|
||||
return None, None, self._bad_request("image_id must be an integer")
|
||||
try:
|
||||
image_id = int(image_id)
|
||||
except (TypeError, ValueError):
|
||||
return None, None, self._bad_request("image_id must be an integer")
|
||||
record = await self._run_in_executor(self.db.get_image_by_id, image_id)
|
||||
if not record or not record.get("image_path"):
|
||||
return (
|
||||
None,
|
||||
None,
|
||||
web.json_response(
|
||||
{"success": False, "error": f"Image {image_id} not found"},
|
||||
status=404,
|
||||
),
|
||||
)
|
||||
stored_path = record["image_path"]
|
||||
resolved = self._resolve_allowed_image_path(stored_path)
|
||||
if resolved is None:
|
||||
return None, None, self._forbidden(stored_path)
|
||||
return resolved, record.get("prompt_id"), None
|
||||
|
||||
raw_path = data.get("path") or data.get("image_path")
|
||||
if not raw_path or not isinstance(raw_path, str):
|
||||
return None, None, self._bad_request("image_id or path is required")
|
||||
|
||||
if "root_index" in data:
|
||||
root_index = data["root_index"]
|
||||
if isinstance(root_index, bool) or not isinstance(root_index, int):
|
||||
return None, None, self._bad_request("root_index must be an integer")
|
||||
roots = self._get_all_output_dirs()
|
||||
if not 0 <= root_index < len(roots):
|
||||
return None, None, self._forbidden(raw_path)
|
||||
root = roots[root_index]
|
||||
if Path(raw_path).is_absolute() or ".." in raw_path.replace(
|
||||
"\\", "/"
|
||||
).split("/"):
|
||||
return None, None, self._forbidden(raw_path)
|
||||
raw_path = str(Path(root) / raw_path)
|
||||
if not path_is_within(raw_path, root):
|
||||
return None, None, self._forbidden(raw_path)
|
||||
|
||||
resolved = self._resolve_allowed_image_path(raw_path)
|
||||
if resolved is None:
|
||||
return None, None, self._forbidden(raw_path)
|
||||
|
||||
prompt_id = None
|
||||
try:
|
||||
prompt_id = await self._run_in_executor(
|
||||
self.db.get_prompt_id_for_image, raw_path
|
||||
)
|
||||
if prompt_id is None and resolved != raw_path:
|
||||
prompt_id = await self._run_in_executor(
|
||||
self.db.get_prompt_id_for_image, resolved
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not find linked prompt: {e}")
|
||||
return resolved, prompt_id, None
|
||||
|
||||
@staticmethod
|
||||
def _bad_request(message: str):
|
||||
return web.json_response({"success": False, "error": message}, status=400)
|
||||
|
||||
async def autotag_single(self, request):
|
||||
"""Generate tags for a single image selected by image_id or gallery path."""
|
||||
try:
|
||||
data = await request.json()
|
||||
if not isinstance(data, dict):
|
||||
return self._bad_request("Request body must be a JSON object")
|
||||
model_type = data.get("model_type", "gguf")
|
||||
custom_prompt = data.get("prompt")
|
||||
use_gpu = data.get("use_gpu", True)
|
||||
general_threshold = data.get("general_threshold")
|
||||
character_threshold = data.get("character_threshold")
|
||||
try:
|
||||
if general_threshold is not None:
|
||||
general_threshold = float(general_threshold)
|
||||
if character_threshold is not None:
|
||||
character_threshold = float(character_threshold)
|
||||
except (ValueError, TypeError):
|
||||
return self._bad_request(
|
||||
"general_threshold and character_threshold must be numeric"
|
||||
)
|
||||
|
||||
image_path, prompt_id, error = await self._select_autotag_target(data)
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
from ..autotag import get_autotag_service
|
||||
|
||||
service = get_autotag_service()
|
||||
|
||||
if (
|
||||
not service.is_model_loaded()
|
||||
or service.get_loaded_model_type() != model_type
|
||||
):
|
||||
await self._run_in_executor(service.load_model, model_type, use_gpu)
|
||||
|
||||
if custom_prompt:
|
||||
service.custom_prompt = custom_prompt
|
||||
|
||||
tags = await self._run_in_executor(
|
||||
service.generate_tags,
|
||||
image_path,
|
||||
general_threshold=general_threshold,
|
||||
character_threshold=character_threshold,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"tags": tags,
|
||||
"prompt_id": prompt_id,
|
||||
"filename": os.path.basename(image_path),
|
||||
}
|
||||
)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return self._bad_request("Request body must be valid JSON")
|
||||
except Exception as e:
|
||||
self.logger.error(f"AutoTag single error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(e)}, status=500
|
||||
)
|
||||
|
||||
async def apply_autotag(self, request):
|
||||
"""Apply selected tags to a prompt."""
|
||||
try:
|
||||
data = await request.json()
|
||||
prompt_id = data.get("prompt_id")
|
||||
tags = data.get("tags", [])
|
||||
|
||||
if not prompt_id:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "prompt_id is required"}, status=400
|
||||
)
|
||||
|
||||
if not tags:
|
||||
return web.json_response(
|
||||
{"success": True, "message": "No tags to apply"}
|
||||
)
|
||||
|
||||
prompt = await self._run_in_executor(self.db.get_prompt_by_id, prompt_id)
|
||||
if not prompt:
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"Prompt {prompt_id} not found"},
|
||||
status=404,
|
||||
)
|
||||
|
||||
existing_tags = prompt.get("tags", [])
|
||||
if isinstance(existing_tags, str):
|
||||
existing_tags = [
|
||||
t.strip() for t in existing_tags.split(",") if t.strip()
|
||||
]
|
||||
|
||||
new_tags = [t for t in tags if t not in existing_tags]
|
||||
all_tags = existing_tags + new_tags
|
||||
|
||||
await self._run_in_executor(
|
||||
self.db.update_prompt_metadata, prompt_id, tags=all_tags
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{"success": True, "added_tags": new_tags, "total_tags": len(all_tags)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Apply autotag error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(e)}, status=500
|
||||
)
|
||||
|
||||
async def unload_autotag_model(self, request):
|
||||
"""Manually unload the AutoTag model from memory."""
|
||||
try:
|
||||
from ..autotag import get_autotag_service
|
||||
|
||||
service = get_autotag_service()
|
||||
|
||||
if not service.is_model_loaded():
|
||||
return web.json_response(
|
||||
{"success": True, "message": "No model was loaded"}
|
||||
)
|
||||
|
||||
model_type = service.get_loaded_model_type()
|
||||
service.unload_model()
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"{model_type.upper()} model unloaded successfully",
|
||||
"model_loaded": False,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Unload autotag model error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(e)}, status=500
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _collect_scan_files(output_path: Path):
|
||||
"""Media files under ``output_path`` from a bounded, symlink-free walk.
|
||||
|
||||
Descends at most ``_SCAN_MAX_DEPTH`` levels, skips ``thumbnails``
|
||||
trees and symlinked files, and stops after ``_SCAN_MAX_FILES`` files.
|
||||
Returns ``(paths, truncated)``.
|
||||
"""
|
||||
found = []
|
||||
base_depth = len(output_path.parts)
|
||||
for dirpath, dirnames, filenames in os.walk(output_path):
|
||||
depth = len(Path(dirpath).parts) - base_depth
|
||||
dirnames[:] = sorted(
|
||||
name
|
||||
for name in dirnames
|
||||
if name != "thumbnails" and depth + 1 <= _SCAN_MAX_DEPTH
|
||||
)
|
||||
for name in sorted(filenames):
|
||||
if Path(name).suffix.lower() not in _MEDIA_EXTENSIONS:
|
||||
continue
|
||||
candidate = Path(dirpath) / name
|
||||
if candidate.is_symlink():
|
||||
continue
|
||||
if len(found) >= _SCAN_MAX_FILES:
|
||||
return found, True
|
||||
found.append(candidate)
|
||||
return found, False
|
||||
|
||||
async def scan_output_dir(self, request):
|
||||
"""Scan ComfyUI output directory for images."""
|
||||
try:
|
||||
output_dir = self._find_comfyui_output_dir()
|
||||
if not output_dir:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "ComfyUI output directory not found"},
|
||||
status=404,
|
||||
)
|
||||
|
||||
output_path = Path(output_dir)
|
||||
files, truncated = await self._run_in_executor(
|
||||
self._collect_scan_files, output_path
|
||||
)
|
||||
has_thumbnails = (output_path / "thumbnails").is_dir()
|
||||
|
||||
images = []
|
||||
for image_path in files:
|
||||
rel_path = image_path.relative_to(output_path)
|
||||
thumbnail_url = None
|
||||
if has_thumbnails:
|
||||
thumb_rel = (
|
||||
f"thumbnails/{rel_path.with_suffix('').as_posix()}"
|
||||
f"_thumb{image_path.suffix}"
|
||||
)
|
||||
thumbnail_url = thumbnail_url_for(
|
||||
thumb_rel, output_path / thumb_rel
|
||||
)
|
||||
images.append(
|
||||
{
|
||||
"filename": image_path.name,
|
||||
"path": self._public_path(image_path),
|
||||
"relative_path": rel_path.as_posix(),
|
||||
"url": "/prompt_manager/images/serve/"
|
||||
+ quote(rel_path.as_posix(), safe="/"),
|
||||
"thumbnail_url": thumbnail_url,
|
||||
}
|
||||
)
|
||||
|
||||
images.sort(key=lambda x: x["filename"])
|
||||
|
||||
self.logger.info(f"Found {len(images)} images in output directory")
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"images": images,
|
||||
"count": len(images),
|
||||
"truncated": truncated,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Scan output dir error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(e)}, status=500
|
||||
)
|
||||
+1546
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
||||
"""Logging API routes for PromptManager."""
|
||||
|
||||
import os
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
# Keys PromptManagerLogger.update_config() understands. Anything else is
|
||||
# rejected so a client cannot inject arbitrary entries into the logging config.
|
||||
LOG_CONFIG_KEYS = frozenset(
|
||||
{
|
||||
"level",
|
||||
"max_file_size",
|
||||
"backup_count",
|
||||
"console_logging",
|
||||
"file_logging",
|
||||
"buffer_size",
|
||||
}
|
||||
)
|
||||
_LOG_INT_KEYS = frozenset({"max_file_size", "backup_count", "buffer_size"})
|
||||
_LOG_BOOL_KEYS = frozenset({"console_logging", "file_logging"})
|
||||
_LOG_LEVEL_NAMES = frozenset({"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"})
|
||||
# Inclusive bounds for the integer keys that size the log files on disk
|
||||
_MEGABYTE = 1024 * 1024
|
||||
_LOG_INT_BOUNDS = {
|
||||
"max_file_size": (1 * _MEGABYTE, 1024 * _MEGABYTE),
|
||||
"backup_count": (0, 50),
|
||||
}
|
||||
_LOG_INT_BOUND_LABELS = {
|
||||
"max_file_size": "between 1 MB and 1024 MB (in bytes)",
|
||||
"backup_count": "between 0 and 50",
|
||||
}
|
||||
|
||||
DEFAULT_LOG_LIMIT = 100
|
||||
MAX_LOG_LIMIT = 500
|
||||
|
||||
|
||||
def _parse_limit(raw):
|
||||
"""Parse a ``limit`` query value, clamped to [1, MAX_LOG_LIMIT].
|
||||
|
||||
Raises:
|
||||
ValueError: when ``raw`` is not an integer.
|
||||
"""
|
||||
if raw is None or raw == "":
|
||||
return DEFAULT_LOG_LIMIT
|
||||
limit = int(raw)
|
||||
return max(1, min(limit, MAX_LOG_LIMIT))
|
||||
|
||||
|
||||
def _validate_log_config(data):
|
||||
"""Validate a log-config payload against LOG_CONFIG_KEYS.
|
||||
|
||||
Returns:
|
||||
(clean, error): ``clean`` is the normalised dict to apply, ``error``
|
||||
a message describing the first problem (``clean`` is then ``None``).
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return None, "Request body must be a JSON object"
|
||||
|
||||
unknown = sorted(key for key in data if key not in LOG_CONFIG_KEYS)
|
||||
if unknown:
|
||||
return None, (
|
||||
f"Unknown logging config keys: {', '.join(unknown)}. "
|
||||
f"Allowed: {', '.join(sorted(LOG_CONFIG_KEYS))}"
|
||||
)
|
||||
|
||||
clean = {}
|
||||
for key, value in data.items():
|
||||
if key == "level":
|
||||
if not isinstance(value, str) or value.upper() not in _LOG_LEVEL_NAMES:
|
||||
return None, (
|
||||
f"Invalid log level. Must be one of: {sorted(_LOG_LEVEL_NAMES)}"
|
||||
)
|
||||
clean[key] = value.upper()
|
||||
elif key in _LOG_INT_KEYS:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
return None, f"{key} must be a non-negative integer"
|
||||
bounds = _LOG_INT_BOUNDS.get(key)
|
||||
if bounds and not bounds[0] <= value <= bounds[1]:
|
||||
return None, f"{key} must be {_LOG_INT_BOUND_LABELS[key]}"
|
||||
clean[key] = value
|
||||
elif key in _LOG_BOOL_KEYS:
|
||||
if not isinstance(value, bool):
|
||||
return None, f"{key} must be a boolean"
|
||||
clean[key] = value
|
||||
return clean, None
|
||||
|
||||
|
||||
class LoggingRoutesMixin:
|
||||
"""Mixin providing logging-related API endpoints."""
|
||||
|
||||
def _register_logging_routes(self, routes):
|
||||
@routes.get("/prompt_manager/logs")
|
||||
async def get_logs_route(request):
|
||||
return await self.get_logs(request)
|
||||
|
||||
@routes.get("/prompt_manager/logs/files")
|
||||
async def get_log_files_route(request):
|
||||
return await self.get_log_files(request)
|
||||
|
||||
@routes.get("/prompt_manager/logs/download/{filename}")
|
||||
async def download_log_route(request):
|
||||
return await self.download_log_file(request)
|
||||
|
||||
@routes.post("/prompt_manager/logs/truncate")
|
||||
async def truncate_logs_route(request):
|
||||
return await self.truncate_logs(request)
|
||||
|
||||
@routes.get("/prompt_manager/logs/config")
|
||||
async def get_log_config_route(request):
|
||||
return await self.get_log_config(request)
|
||||
|
||||
@routes.post("/prompt_manager/logs/config")
|
||||
async def update_log_config_route(request):
|
||||
return await self.update_log_config(request)
|
||||
|
||||
@routes.get("/prompt_manager/logs/stats")
|
||||
async def get_log_stats_route(request):
|
||||
return await self.get_log_stats(request)
|
||||
|
||||
def _get_logger_manager(self):
|
||||
"""Get the logger manager instance."""
|
||||
try:
|
||||
from ...utils.logging_config import get_logger_manager
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
current_dir = os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
)
|
||||
sys.path.insert(0, current_dir)
|
||||
from utils.logging_config import get_logger_manager
|
||||
return get_logger_manager()
|
||||
|
||||
async def get_logs(self, request):
|
||||
"""Get recent log entries."""
|
||||
try:
|
||||
logger_manager = self._get_logger_manager()
|
||||
|
||||
try:
|
||||
limit = _parse_limit(request.query.get("limit"))
|
||||
except ValueError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "limit must be an integer", "logs": []},
|
||||
status=400,
|
||||
)
|
||||
level = request.query.get("level", None)
|
||||
|
||||
logs = logger_manager.get_recent_logs(limit=limit, level=level)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"logs": logs,
|
||||
"count": len(logs),
|
||||
"level_filter": level,
|
||||
"limit": limit,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Get logs error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(e), "logs": []},
|
||||
status=500,
|
||||
)
|
||||
|
||||
async def get_log_files(self, request):
|
||||
"""Get information about available log files."""
|
||||
try:
|
||||
logger_manager = self._get_logger_manager()
|
||||
log_files = [
|
||||
{**entry, "path": self._public_path(entry.get("path"))}
|
||||
for entry in logger_manager.get_log_files()
|
||||
]
|
||||
|
||||
return web.json_response(
|
||||
{"success": True, "files": log_files, "count": len(log_files)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Get log files error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(e), "files": []},
|
||||
status=500,
|
||||
)
|
||||
|
||||
async def download_log_file(self, request):
|
||||
"""Download a specific log file."""
|
||||
try:
|
||||
filename = request.match_info["filename"]
|
||||
logger_manager = self._get_logger_manager()
|
||||
|
||||
if not filename or ".." in filename or "/" in filename or "\\" in filename:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid filename"}, status=400
|
||||
)
|
||||
|
||||
log_file_path = logger_manager.log_dir / filename
|
||||
|
||||
if not log_file_path.exists():
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Log file not found"}, status=404
|
||||
)
|
||||
|
||||
with open(log_file_path, "rb") as f:
|
||||
file_content = f.read()
|
||||
|
||||
return web.Response(
|
||||
body=file_content,
|
||||
content_type="text/plain",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Content-Length": str(len(file_content)),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Download log file error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(e)}, status=500
|
||||
)
|
||||
|
||||
async def truncate_logs(self, request):
|
||||
"""Truncate all log files."""
|
||||
try:
|
||||
logger_manager = self._get_logger_manager()
|
||||
results = logger_manager.truncate_logs()
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Truncated {len(results['truncated'])} log files",
|
||||
"results": results,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Truncate logs error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(e)}, status=500
|
||||
)
|
||||
|
||||
async def get_log_config(self, request):
|
||||
"""Get current logging configuration."""
|
||||
try:
|
||||
logger_manager = self._get_logger_manager()
|
||||
config = logger_manager.get_config()
|
||||
|
||||
return web.json_response({"success": True, "config": config})
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Get log config error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(e)}, status=500
|
||||
)
|
||||
|
||||
async def update_log_config(self, request):
|
||||
"""Update logging configuration (whitelisted keys only)."""
|
||||
try:
|
||||
data, error_response = await self._read_json_body(request)
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
clean, error = _validate_log_config(data)
|
||||
if error:
|
||||
return web.json_response({"success": False, "error": error}, status=400)
|
||||
|
||||
logger_manager = self._get_logger_manager()
|
||||
logger_manager.update_config(clean)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Logging configuration updated",
|
||||
"config": logger_manager.get_config(),
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Update log config error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Failed to update logging configuration"},
|
||||
status=500,
|
||||
)
|
||||
|
||||
async def get_log_stats(self, request):
|
||||
"""Get logging statistics."""
|
||||
try:
|
||||
logger_manager = self._get_logger_manager()
|
||||
stats = dict(logger_manager.get_log_stats())
|
||||
stats["log_directory"] = self._public_path(stats.get("log_directory"))
|
||||
|
||||
return web.json_response({"success": True, "stats": stats})
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Get log stats error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(e)}, status=500
|
||||
)
|
||||
@@ -0,0 +1,456 @@
|
||||
"""LoraManager integration API routes for PromptManager."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
_LORA_PATH_ERROR = (
|
||||
"LoraManager path must be a directory under ComfyUI's custom_nodes "
|
||||
"whose name contains 'lora'"
|
||||
)
|
||||
|
||||
|
||||
class LoraIntegrationMixin:
|
||||
"""Mixin providing LoraManager detection, scanning, and trigger word endpoints."""
|
||||
|
||||
def _register_lora_routes(self, routes):
|
||||
@routes.get("/prompt_manager/lora/detect")
|
||||
async def lora_detect_route(request):
|
||||
return await self.lora_detect(request)
|
||||
|
||||
@routes.get("/prompt_manager/lora/status")
|
||||
async def lora_status_route(request):
|
||||
return await self.lora_status(request)
|
||||
|
||||
@routes.post("/prompt_manager/lora/enable")
|
||||
async def lora_enable_route(request):
|
||||
return await self.lora_enable(request)
|
||||
|
||||
@routes.post("/prompt_manager/lora/scan")
|
||||
async def lora_scan_route(request):
|
||||
return await self.lora_scan(request)
|
||||
|
||||
@routes.get("/prompt_manager/lora/trigger-words")
|
||||
async def lora_trigger_words_route(request):
|
||||
return await self.lora_trigger_words(request)
|
||||
|
||||
@routes.post("/prompt_manager/lora/refresh-cache")
|
||||
async def lora_refresh_cache_route(request):
|
||||
return await self.lora_refresh_cache(request)
|
||||
|
||||
def _error_response(self, exc: Exception, status: int = 500):
|
||||
return web.json_response(
|
||||
{"success": False, "error": self._public_error(exc)}, status=status
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _bad_lora_request(message: str):
|
||||
return web.json_response({"success": False, "error": message}, status=400)
|
||||
|
||||
# ── Detection ────────────────────────────────────────────────────
|
||||
|
||||
async def lora_detect(self, request):
|
||||
"""Auto-detect LoraManager installation."""
|
||||
try:
|
||||
from ..lora_utils import detect_lora_manager
|
||||
|
||||
path = await self._run_in_executor(detect_lora_manager)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"detected": path is not None,
|
||||
"path": self._public_path(path) if path else "",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"LoraManager detection failed: {e}")
|
||||
return self._error_response(e)
|
||||
|
||||
# ── Status ───────────────────────────────────────────────────────
|
||||
|
||||
async def lora_status(self, request):
|
||||
"""Get current LoraManager integration status."""
|
||||
try:
|
||||
from ..config import IntegrationConfig
|
||||
from ..lora_utils import detect_lora_manager, get_trigger_cache
|
||||
|
||||
config = IntegrationConfig.get_config()["lora_manager"]
|
||||
cache = get_trigger_cache()
|
||||
|
||||
# Check if the configured path is still valid
|
||||
detected_path = await self._run_in_executor(
|
||||
detect_lora_manager, config.get("path", "")
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"enabled": config["enabled"],
|
||||
"path": self._public_path(config["path"]),
|
||||
"trigger_words_enabled": config["trigger_words_enabled"],
|
||||
"has_civitai_api_key": bool(config.get("civitai_api_key")),
|
||||
"detected": detected_path is not None,
|
||||
"detected_path": (
|
||||
self._public_path(detected_path) if detected_path else ""
|
||||
),
|
||||
"trigger_cache_loaded": cache.is_loaded,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"LoraManager status check failed: {e}")
|
||||
return self._error_response(e)
|
||||
|
||||
# ── Enable / Disable ─────────────────────────────────────────────
|
||||
|
||||
async def _validated_lora_manager_path(self, path):
|
||||
"""Canonical LoraManager path for ``path`` or ``(None, error_response)``.
|
||||
|
||||
The user's path (or, when empty, the auto-detected install) must be a
|
||||
LoraManager-looking directory that is a direct child of ComfyUI's
|
||||
``custom_nodes`` with ``lora`` in its name; the trees under it are
|
||||
later served as images, so nothing outside custom_nodes may be used.
|
||||
"""
|
||||
from ..lora_utils import detect_lora_manager, resolve_lora_manager_path
|
||||
|
||||
candidate = ""
|
||||
if path:
|
||||
candidate = await self._run_in_executor(resolve_lora_manager_path, path)
|
||||
if candidate is None:
|
||||
return None, self._bad_lora_request(_LORA_PATH_ERROR)
|
||||
|
||||
resolved = await self._run_in_executor(detect_lora_manager, candidate)
|
||||
if not resolved:
|
||||
return None, self._bad_lora_request(
|
||||
"LoraManager not found at the specified path"
|
||||
)
|
||||
|
||||
allowed = await self._run_in_executor(resolve_lora_manager_path, resolved)
|
||||
if allowed is None:
|
||||
return None, self._bad_lora_request(_LORA_PATH_ERROR)
|
||||
return allowed, None
|
||||
|
||||
async def lora_enable(self, request):
|
||||
"""Enable or disable LoraManager integration and save to config.json."""
|
||||
try:
|
||||
data = await request.json()
|
||||
enabled = data.get("enabled", False)
|
||||
path = data.get("path", "")
|
||||
trigger_words = data.get("trigger_words_enabled", False)
|
||||
|
||||
from ..config import IntegrationConfig, PromptManagerConfig
|
||||
from ..lora_utils import get_trigger_cache
|
||||
|
||||
# The key is never echoed back to the UI, so a blank or absent
|
||||
# field means "unchanged"; clearing is an explicit request.
|
||||
civitai_key = IntegrationConfig.CIVITAI_API_KEY
|
||||
submitted_key = data.get("civitai_api_key")
|
||||
if submitted_key is not None and not isinstance(submitted_key, str):
|
||||
return self._bad_lora_request("civitai_api_key must be a string")
|
||||
if data.get("clear_civitai_api_key"):
|
||||
civitai_key = ""
|
||||
elif submitted_key and submitted_key.strip():
|
||||
civitai_key = submitted_key.strip()
|
||||
|
||||
if not isinstance(path, str):
|
||||
return self._bad_lora_request("path must be a string")
|
||||
|
||||
# If enabling, validate the path
|
||||
if enabled:
|
||||
path, error = await self._validated_lora_manager_path(path)
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
# Update in-memory config
|
||||
IntegrationConfig.LORA_MANAGER_ENABLED = enabled
|
||||
IntegrationConfig.LORA_MANAGER_PATH = path
|
||||
IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED = trigger_words
|
||||
IntegrationConfig.CIVITAI_API_KEY = civitai_key
|
||||
|
||||
# Persist to the configured config.json
|
||||
PromptManagerConfig.save_to_file()
|
||||
|
||||
# Load trigger word cache if enabling
|
||||
cache = get_trigger_cache()
|
||||
if enabled and trigger_words and path:
|
||||
count = await self._run_in_executor(cache.load, path)
|
||||
self.logger.info(f"Trigger word cache loaded: {count} LoRAs")
|
||||
elif not enabled:
|
||||
cache.clear()
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"enabled": enabled,
|
||||
"path": self._public_path(path),
|
||||
"trigger_words_enabled": trigger_words,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"LoraManager enable/disable failed: {e}")
|
||||
return self._error_response(e)
|
||||
|
||||
# ── Scan LoRA example images ─────────────────────────────────────
|
||||
|
||||
async def lora_scan(self, request):
|
||||
"""Scan LoraManager metadata and import LoRA info + preview images.
|
||||
|
||||
Streams progress as SSE, matching the existing scan pattern. Errors
|
||||
raised once the stream is open are reported as an ``error`` event.
|
||||
"""
|
||||
try:
|
||||
from ..config import IntegrationConfig
|
||||
from ..lora_utils import resolve_lora_manager_path
|
||||
|
||||
if not IntegrationConfig.LORA_MANAGER_ENABLED:
|
||||
return self._bad_lora_request("LoraManager integration is not enabled")
|
||||
|
||||
lm_path = IntegrationConfig.LORA_MANAGER_PATH
|
||||
if not lm_path:
|
||||
return self._bad_lora_request("LoraManager path not configured")
|
||||
|
||||
allowed = await self._run_in_executor(resolve_lora_manager_path, lm_path)
|
||||
if allowed is None:
|
||||
return self._bad_lora_request(_LORA_PATH_ERROR)
|
||||
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
reason="OK",
|
||||
headers={"Content-Type": "text/event-stream"},
|
||||
)
|
||||
await response.prepare(request)
|
||||
|
||||
async def send_progress(data):
|
||||
line = f"data: {json.dumps(data)}\n\n"
|
||||
await response.write(line.encode("utf-8"))
|
||||
|
||||
try:
|
||||
await self._lora_scan_stream(allowed, send_progress)
|
||||
except Exception as e:
|
||||
self.logger.exception("LoRA scan failed")
|
||||
await send_progress({"type": "error", "message": self._public_error(e)})
|
||||
|
||||
await response.write_eof()
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"LoRA scan failed: {e}")
|
||||
return self._error_response(e)
|
||||
|
||||
async def _lora_scan_stream(self, lm_path, send_progress):
|
||||
"""Import every LoRA under ``lm_path``, reporting through ``send_progress``."""
|
||||
from ..config import IntegrationConfig
|
||||
from ..lora_utils import (
|
||||
download_civitai_images,
|
||||
find_lora_directories,
|
||||
get_example_prompt_from_metadata,
|
||||
get_lora_image_cache_dir,
|
||||
get_preview_images_from_metadata,
|
||||
get_trigger_words_from_metadata,
|
||||
get_model_name_from_metadata,
|
||||
read_lora_metadata,
|
||||
)
|
||||
|
||||
await send_progress(
|
||||
{
|
||||
"type": "progress",
|
||||
"status": "Clearing previous lora-manager imports...",
|
||||
"progress": 0,
|
||||
}
|
||||
)
|
||||
|
||||
# Clear previous imports so reimport is always clean
|
||||
await self._run_in_executor(self.db.delete_prompts_by_category, "lora-manager")
|
||||
|
||||
await send_progress(
|
||||
{
|
||||
"type": "progress",
|
||||
"status": "Finding LoRA directories...",
|
||||
"progress": 2,
|
||||
}
|
||||
)
|
||||
|
||||
lora_dirs = await self._run_in_executor(find_lora_directories, lm_path)
|
||||
|
||||
# Collect all metadata files
|
||||
meta_files = []
|
||||
for d in lora_dirs:
|
||||
dir_path = Path(d)
|
||||
meta_files.extend(dir_path.rglob("*.metadata.json"))
|
||||
|
||||
total = len(meta_files)
|
||||
imported = 0
|
||||
skipped = 0
|
||||
|
||||
await send_progress(
|
||||
{
|
||||
"type": "progress",
|
||||
"status": f"Found {total} LoRA metadata files",
|
||||
"progress": 5,
|
||||
"total": total,
|
||||
}
|
||||
)
|
||||
|
||||
cache_dir = get_lora_image_cache_dir()
|
||||
|
||||
for i, meta_file in enumerate(meta_files):
|
||||
metadata = await self._run_in_executor(read_lora_metadata, meta_file)
|
||||
if not metadata:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
model_name = get_model_name_from_metadata(metadata)
|
||||
trigger_words = get_trigger_words_from_metadata(metadata)
|
||||
|
||||
# Collect all images: local previews + downloaded civitai examples
|
||||
preview_paths = await self._run_in_executor(
|
||||
get_preview_images_from_metadata, metadata, meta_file
|
||||
)
|
||||
civitai_paths = await self._run_in_executor(
|
||||
download_civitai_images,
|
||||
metadata,
|
||||
meta_file,
|
||||
cache_dir,
|
||||
IntegrationConfig.CIVITAI_API_KEY,
|
||||
)
|
||||
|
||||
# Merge, local first, dedup
|
||||
seen = set(preview_paths)
|
||||
all_images = list(preview_paths)
|
||||
for cp in civitai_paths:
|
||||
if cp not in seen:
|
||||
all_images.append(cp)
|
||||
seen.add(cp)
|
||||
|
||||
# Build prompt text: prefer example prompt, then model name
|
||||
example_prompt = get_example_prompt_from_metadata(metadata)
|
||||
prompt_text = example_prompt or model_name
|
||||
|
||||
# Build tags
|
||||
tags = ["lora-manager", f"lora:{model_name}"]
|
||||
tags.extend(trigger_words)
|
||||
|
||||
# Save to database via existing mechanism
|
||||
try:
|
||||
import hashlib
|
||||
|
||||
prompt_hash = hashlib.sha256(
|
||||
prompt_text.strip().lower().encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
existing = await self._run_in_executor(
|
||||
self.db.get_prompt_by_hash, prompt_hash
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Link all images
|
||||
for pp in all_images:
|
||||
await self._run_in_executor(
|
||||
self.db.link_image_to_prompt,
|
||||
existing["id"],
|
||||
pp,
|
||||
)
|
||||
skipped += 1
|
||||
else:
|
||||
prompt_id = await self._run_in_executor(
|
||||
self.db.save_prompt,
|
||||
prompt_text,
|
||||
"lora-manager", # category
|
||||
tags,
|
||||
None, # rating
|
||||
None, # notes
|
||||
prompt_hash,
|
||||
)
|
||||
|
||||
if prompt_id:
|
||||
for pp in all_images:
|
||||
await self._run_in_executor(
|
||||
self.db.link_image_to_prompt,
|
||||
prompt_id,
|
||||
pp,
|
||||
)
|
||||
imported += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to import LoRA {model_name}: {e}")
|
||||
skipped += 1
|
||||
|
||||
# Progress update for every LoRA
|
||||
progress = int(5 + (90 * (i + 1) / max(total, 1)))
|
||||
img_count = len(all_images)
|
||||
status = f"{model_name}"
|
||||
if img_count:
|
||||
status += f" ({img_count} images)"
|
||||
await send_progress(
|
||||
{
|
||||
"type": "progress",
|
||||
"status": status,
|
||||
"progress": progress,
|
||||
"processed": i + 1,
|
||||
"imported": imported,
|
||||
"skipped": skipped,
|
||||
}
|
||||
)
|
||||
|
||||
await send_progress(
|
||||
{
|
||||
"type": "complete",
|
||||
"progress": 100,
|
||||
"total": total,
|
||||
"imported": imported,
|
||||
"skipped": skipped,
|
||||
}
|
||||
)
|
||||
|
||||
# ── Trigger word endpoints ───────────────────────────────────────
|
||||
|
||||
async def lora_trigger_words(self, request):
|
||||
"""Look up trigger words for a specific LoRA name."""
|
||||
try:
|
||||
from ..config import IntegrationConfig
|
||||
from ..lora_utils import get_trigger_cache
|
||||
|
||||
if not IntegrationConfig.LORA_MANAGER_ENABLED:
|
||||
return self._bad_lora_request("LoraManager integration not enabled")
|
||||
|
||||
lora_name = request.query.get("name", "")
|
||||
if not lora_name:
|
||||
return self._bad_lora_request("Missing 'name' query parameter")
|
||||
|
||||
cache = get_trigger_cache()
|
||||
if not cache.is_loaded:
|
||||
lm_path = IntegrationConfig.LORA_MANAGER_PATH
|
||||
if lm_path:
|
||||
await self._run_in_executor(cache.load, lm_path)
|
||||
|
||||
words = cache.get_trigger_words(lora_name)
|
||||
return web.json_response(
|
||||
{"success": True, "lora": lora_name, "trigger_words": words}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Trigger word lookup failed: {e}")
|
||||
return self._error_response(e)
|
||||
|
||||
async def lora_refresh_cache(self, request):
|
||||
"""Force-refresh the trigger word cache from disk."""
|
||||
try:
|
||||
from ..config import IntegrationConfig
|
||||
from ..lora_utils import get_trigger_cache
|
||||
|
||||
if not IntegrationConfig.LORA_MANAGER_ENABLED:
|
||||
return self._bad_lora_request("LoraManager integration not enabled")
|
||||
|
||||
lm_path = IntegrationConfig.LORA_MANAGER_PATH
|
||||
if not lm_path:
|
||||
return self._bad_lora_request("LoraManager path not configured")
|
||||
|
||||
cache = get_trigger_cache()
|
||||
count = await self._run_in_executor(cache.load, lm_path)
|
||||
return web.json_response(
|
||||
{"success": True, "loras_with_trigger_words": count}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Trigger cache refresh failed: {e}")
|
||||
return self._error_response(e)
|
||||
+1210
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
"""Progress fan-out for the background output scan.
|
||||
|
||||
The scan runs as an asyncio task that outlives the HTTP request that started
|
||||
it, so an accidental tab close does not abandon a half-finished scan. Every
|
||||
SSE subscriber (the request that started the scan, a reconnecting page, a
|
||||
second tab) receives the latest event as a snapshot and then every event
|
||||
published after it, until the job finishes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
class ScanJob:
|
||||
"""One scan run: its latest event, completion flag and live subscribers."""
|
||||
|
||||
def __init__(self):
|
||||
self.last_event = None
|
||||
self.finished = False
|
||||
self.task = None
|
||||
self._subscribers = set()
|
||||
|
||||
@property
|
||||
def running(self):
|
||||
return not self.finished
|
||||
|
||||
@property
|
||||
def subscriber_count(self):
|
||||
return len(self._subscribers)
|
||||
|
||||
def publish(self, payload):
|
||||
"""Record ``payload`` as the latest event and hand it to every subscriber."""
|
||||
self.last_event = payload
|
||||
for queue in list(self._subscribers):
|
||||
queue.put_nowait(payload)
|
||||
|
||||
def finish(self):
|
||||
"""Mark the job done and release every subscriber after its queued events."""
|
||||
self.finished = True
|
||||
for queue in list(self._subscribers):
|
||||
queue.put_nowait(None)
|
||||
|
||||
async def events(self):
|
||||
"""Yield the latest event, then each later one, until the job finishes.
|
||||
|
||||
Consume it under ``contextlib.aclosing`` so a consumer that stops early
|
||||
(a closed socket) drops its queue at once instead of at garbage collection.
|
||||
"""
|
||||
queue = asyncio.Queue()
|
||||
self._subscribers.add(queue)
|
||||
try:
|
||||
if self.last_event is not None:
|
||||
yield self.last_event
|
||||
if self.finished:
|
||||
return
|
||||
while True:
|
||||
payload = await queue.get()
|
||||
if payload is None:
|
||||
return
|
||||
yield payload
|
||||
finally:
|
||||
self._subscribers.discard(queue)
|
||||
+945
@@ -0,0 +1,945 @@
|
||||
"""
|
||||
AutoTag Service Module
|
||||
|
||||
Provides JoyCaption-based automatic tagging for images using LLM models.
|
||||
Refactored from standalone_tagger.py for integration with PromptManager API.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import gc
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from PIL import Image
|
||||
|
||||
# A loader takes (model_type, use_gpu) and returns ("gguf" | "wd14" | "hf", backend)
|
||||
ModelLoader = Callable[[str, bool], Tuple[str, Any]]
|
||||
|
||||
# Try to import logging from utils, fallback to standard logging
|
||||
try:
|
||||
from ..utils.logging_config import get_logger
|
||||
except ImportError:
|
||||
import logging
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
logger = logging.getLogger(name)
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
)
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
return logger
|
||||
|
||||
|
||||
# Model configurations - same as standalone_tagger.py
|
||||
MODELS = {
|
||||
"gguf": {
|
||||
"name": "GGUF (Recommended)",
|
||||
"description": "Quantized model, ~4.5GB, faster loading",
|
||||
"size": "~4.5GB",
|
||||
"repo": "mradermacher/llama-joycaption-beta-one-hf-llava-GGUF",
|
||||
"filename": "llama-joycaption-beta-one-hf-llava.IQ4_XS.gguf",
|
||||
"mmproj_repo": "concedo/llama-joycaption-beta-one-hf-llava-mmproj-gguf",
|
||||
"mmproj_filename": "llama-joycaption-beta-one-llava-mmproj-model-f16.gguf",
|
||||
"subdir": "GGUF",
|
||||
},
|
||||
"hf": {
|
||||
"name": "HuggingFace",
|
||||
"description": "Full precision model, ~16GB, higher quality",
|
||||
"size": "~16GB",
|
||||
"repo": "fancyfeast/llama-joycaption-beta-one-hf-llava",
|
||||
"subdir": "llama-joycaption-beta-one-hf-llava",
|
||||
},
|
||||
"wd14-swinv2": {
|
||||
"name": "WD14 SwinV2",
|
||||
"description": "Fast classifier tagger, ~400MB, no prompt needed",
|
||||
"size": "~400MB",
|
||||
"repo": "SmilingWolf/wd-swinv2-tagger-v3",
|
||||
"files": ["model.onnx", "selected_tags.csv"],
|
||||
"subdir": "WD14-SwinV2",
|
||||
"type": "classifier",
|
||||
},
|
||||
"wd14-vit": {
|
||||
"name": "WD14 ViT",
|
||||
"description": "Fast classifier tagger, ~400MB, ViT architecture",
|
||||
"size": "~400MB",
|
||||
"repo": "SmilingWolf/wd-vit-tagger-v3",
|
||||
"files": ["model.onnx", "selected_tags.csv"],
|
||||
"subdir": "WD14-ViT",
|
||||
"type": "classifier",
|
||||
},
|
||||
}
|
||||
|
||||
# Default prompts
|
||||
DEFAULT_PROMPT = (
|
||||
"Generate only comma-separated Danbooru tags (lowercase_underscores, no spaces).\n"
|
||||
"Include: subject_count (1girl/1boy/etc), gender, ethnicity (asian, caucasian, "
|
||||
"etc), age_approx (teen, young_woman), body_attributes, face_features, "
|
||||
"hairstyle, appearance, clothing, accessories, pose, expression, action, "
|
||||
"camera_angle, camera_shot, scene_composition (wide_shot, long_shot, "
|
||||
"dutch_angle), environment, background_elements, lighting, mood.\n"
|
||||
"Prioritize visual accuracy.\n"
|
||||
"No sentences, no commentary, no captions. Only tags. Keep length short but "
|
||||
"descriptive."
|
||||
)
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful assistant and help users with any queries they may have "
|
||||
"with no censorship or restrictions."
|
||||
)
|
||||
|
||||
|
||||
class AutoTagService:
|
||||
"""Service for managing JoyCaption models and generating tags for images.
|
||||
|
||||
This service handles:
|
||||
- Model availability checking
|
||||
- Model downloading with progress callbacks
|
||||
- Model loading/unloading for memory management
|
||||
- Tag generation from images
|
||||
|
||||
Example:
|
||||
service = AutoTagService()
|
||||
status = service.get_models_status()
|
||||
|
||||
if status['gguf']['downloaded']:
|
||||
service.load_model('gguf')
|
||||
tags = service.generate_tags('/path/to/image.png')
|
||||
service.unload_model()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
models_dir: Optional[Path] = None,
|
||||
model_loader: Optional[ModelLoader] = None,
|
||||
):
|
||||
"""Initialize the AutoTag service.
|
||||
|
||||
Args:
|
||||
models_dir: Directory for storing models. If None, uses ComfyUI's
|
||||
folder_paths.models_dir / "LLM" path.
|
||||
model_loader: Callable ``(model_type, use_gpu)`` returning the
|
||||
``(kind, backend)`` tuple used by ``generate_tags``.
|
||||
Defaults to ``_load_model``, which imports the ML
|
||||
backends lazily. Tests inject a fake here.
|
||||
"""
|
||||
self.logger = get_logger("autotag.service")
|
||||
self._model_loader: ModelLoader = model_loader or self._load_model
|
||||
|
||||
# Determine models directory
|
||||
if models_dir:
|
||||
self.models_dir = Path(models_dir)
|
||||
else:
|
||||
# Use ComfyUI's folder_paths system
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
self.models_dir = Path(folder_paths.models_dir) / "LLM"
|
||||
except ImportError:
|
||||
# Fallback for standalone usage (not running in ComfyUI)
|
||||
self.logger.warning("folder_paths not available, using fallback path")
|
||||
self.models_dir = (
|
||||
Path(__file__).parent.parent.parent.parent / "models" / "LLM"
|
||||
)
|
||||
|
||||
self.logger.info(f"AutoTag service initialized. Models dir: {self.models_dir}")
|
||||
|
||||
# Current loaded tagger instance
|
||||
self._tagger = None
|
||||
self._current_model_type: Optional[str] = None
|
||||
self._custom_prompt: str = DEFAULT_PROMPT
|
||||
|
||||
# WD14 threshold defaults
|
||||
self._wd14_general_threshold: float = 0.35
|
||||
self._wd14_character_threshold: float = 0.85
|
||||
|
||||
@property
|
||||
def models_config(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get the models configuration dictionary."""
|
||||
return MODELS
|
||||
|
||||
@property
|
||||
def default_prompt(self) -> str:
|
||||
"""Get the default tag generation prompt."""
|
||||
return DEFAULT_PROMPT
|
||||
|
||||
@property
|
||||
def custom_prompt(self) -> str:
|
||||
"""Get the current custom prompt."""
|
||||
return self._custom_prompt
|
||||
|
||||
@custom_prompt.setter
|
||||
def custom_prompt(self, value: str):
|
||||
"""Set a custom prompt for tag generation."""
|
||||
self._custom_prompt = value
|
||||
|
||||
@property
|
||||
def wd14_general_threshold(self) -> float:
|
||||
return self._wd14_general_threshold
|
||||
|
||||
@wd14_general_threshold.setter
|
||||
def wd14_general_threshold(self, value: float):
|
||||
self._wd14_general_threshold = max(0.0, min(1.0, value))
|
||||
|
||||
@property
|
||||
def wd14_character_threshold(self) -> float:
|
||||
return self._wd14_character_threshold
|
||||
|
||||
@wd14_character_threshold.setter
|
||||
def wd14_character_threshold(self, value: float):
|
||||
self._wd14_character_threshold = max(0.0, min(1.0, value))
|
||||
|
||||
def get_models_status(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get availability status for all model types.
|
||||
|
||||
Returns:
|
||||
Dictionary with status for each model type:
|
||||
{
|
||||
'gguf': {
|
||||
'name': 'GGUF (Recommended)',
|
||||
'description': '...',
|
||||
'size': '~4.5GB',
|
||||
'downloaded': True,
|
||||
'model_exists': True,
|
||||
'mmproj_exists': True, # GGUF only
|
||||
'model_path': '/path/to/model'
|
||||
},
|
||||
'hf': {...}
|
||||
}
|
||||
"""
|
||||
status = {}
|
||||
|
||||
for model_type, config in MODELS.items():
|
||||
model_status = {
|
||||
"name": config["name"],
|
||||
"description": config["description"],
|
||||
"size": config["size"],
|
||||
"downloaded": False,
|
||||
"model_path": None,
|
||||
}
|
||||
|
||||
if model_type == "gguf":
|
||||
model_exists, mmproj_exists = self._check_gguf_models()
|
||||
model_status["model_exists"] = model_exists
|
||||
model_status["mmproj_exists"] = mmproj_exists
|
||||
model_status["downloaded"] = model_exists and mmproj_exists
|
||||
if model_status["downloaded"]:
|
||||
model_status["model_path"] = str(
|
||||
self.models_dir / config["subdir"] / config["filename"]
|
||||
)
|
||||
elif model_type.startswith("wd14"):
|
||||
model_status["downloaded"] = self._check_wd14_model(model_type)
|
||||
if model_status["downloaded"]:
|
||||
model_status["model_path"] = str(
|
||||
self.models_dir / config["subdir"] / "model.onnx"
|
||||
)
|
||||
else: # hf
|
||||
model_status["downloaded"] = self._check_hf_model()
|
||||
if model_status["downloaded"]:
|
||||
# Get the actual path (local or cache)
|
||||
model_status["model_path"] = str(self._get_hf_model_path())
|
||||
|
||||
status[model_type] = model_status
|
||||
|
||||
return status
|
||||
|
||||
def _check_gguf_models(self) -> Tuple[bool, bool]:
|
||||
"""Check if GGUF model and mmproj files exist.
|
||||
|
||||
Returns:
|
||||
Tuple of (model_exists, mmproj_exists)
|
||||
"""
|
||||
config = MODELS["gguf"]
|
||||
gguf_dir = self.models_dir / config["subdir"]
|
||||
model_path = gguf_dir / config["filename"]
|
||||
mmproj_path = gguf_dir / config["mmproj_filename"]
|
||||
return model_path.exists(), mmproj_path.exists()
|
||||
|
||||
def _check_hf_model(self) -> bool:
|
||||
"""Check if HuggingFace model exists.
|
||||
|
||||
Checks both the local models directory and the HuggingFace cache.
|
||||
|
||||
Returns:
|
||||
True if model directory contains config.json
|
||||
"""
|
||||
config = MODELS["hf"]
|
||||
|
||||
# Check local directory first
|
||||
model_dir = self.models_dir / config["subdir"]
|
||||
self.logger.debug(f"Checking local HF model path: {model_dir}")
|
||||
if (model_dir / "config.json").exists():
|
||||
self.logger.debug("Found HF model in local directory")
|
||||
return True
|
||||
|
||||
# Check HuggingFace cache as fallback
|
||||
hf_cache_path = self._get_hf_cache_path(config["repo"])
|
||||
if hf_cache_path:
|
||||
self.logger.debug(f"Found HF model in cache: {hf_cache_path}")
|
||||
return True
|
||||
|
||||
self.logger.debug("HF model not found in local dir or cache")
|
||||
return False
|
||||
|
||||
def _get_hf_cache_path(self, repo_id: str) -> Optional[Path]:
|
||||
"""Get the path to a model in the HuggingFace cache.
|
||||
|
||||
Args:
|
||||
repo_id: The HuggingFace repo ID
|
||||
(e.g., 'fancyfeast/llama-joycaption-beta-one-hf-llava')
|
||||
|
||||
Returns:
|
||||
Path to the cached model directory, or None if not found
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
except ImportError:
|
||||
self.logger.debug("huggingface_hub not available for cache check")
|
||||
return None
|
||||
|
||||
try:
|
||||
cache_info = scan_cache_dir()
|
||||
for repo in cache_info.repos:
|
||||
if repo.repo_id == repo_id and repo.repo_type == "model":
|
||||
# Get the latest revision's snapshot path
|
||||
for revision in repo.revisions:
|
||||
snapshot_path = revision.snapshot_path
|
||||
if (Path(snapshot_path) / "config.json").exists():
|
||||
return Path(snapshot_path)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error scanning HF cache: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _get_hf_model_path(self) -> Optional[Path]:
|
||||
"""Get the actual path to the HuggingFace model.
|
||||
|
||||
Checks local directory first, then HuggingFace cache.
|
||||
|
||||
Returns:
|
||||
Path to the model directory, or None if not found
|
||||
"""
|
||||
config = MODELS["hf"]
|
||||
|
||||
# Check local directory first
|
||||
model_dir = self.models_dir / config["subdir"]
|
||||
if (model_dir / "config.json").exists():
|
||||
return model_dir
|
||||
|
||||
# Check HuggingFace cache as fallback
|
||||
cache_path = self._get_hf_cache_path(config["repo"])
|
||||
if cache_path:
|
||||
return cache_path
|
||||
|
||||
return None
|
||||
|
||||
def _check_wd14_model(self, model_type: str) -> bool:
|
||||
"""Check if WD14 model files exist."""
|
||||
config = MODELS[model_type]
|
||||
model_dir = self.models_dir / config["subdir"]
|
||||
return all((model_dir / f).exists() for f in config["files"])
|
||||
|
||||
def _download_wd14_model(
|
||||
self,
|
||||
model_type: str,
|
||||
progress_callback: Optional[Callable[[str, float], None]] = None,
|
||||
) -> bool:
|
||||
"""Download WD14 model files from HuggingFace."""
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
config = MODELS[model_type]
|
||||
model_dir = self.models_dir / config["subdir"]
|
||||
model_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
files = config["files"]
|
||||
for i, filename in enumerate(files):
|
||||
file_path = model_dir / filename
|
||||
if not file_path.exists():
|
||||
progress = int((i / len(files)) * 80) + 10
|
||||
if progress_callback:
|
||||
progress_callback(f"Downloading {filename}...", progress)
|
||||
|
||||
self.logger.info(f"Downloading WD14 file: {filename}")
|
||||
hf_hub_download(
|
||||
repo_id=config["repo"],
|
||||
filename=filename,
|
||||
local_dir=str(model_dir),
|
||||
local_dir_use_symlinks=False,
|
||||
)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("Download complete", 100)
|
||||
return True
|
||||
|
||||
def _load_wd14_tagger(self, model_type: str, use_gpu: bool = True):
|
||||
"""Load WD14 ONNX model and tag list."""
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"onnxruntime is required for WD14 models. "
|
||||
"Install with: pip install onnxruntime (or onnxruntime-gpu for CUDA)"
|
||||
)
|
||||
import csv
|
||||
|
||||
config = MODELS[model_type]
|
||||
model_dir = self.models_dir / config["subdir"]
|
||||
model_path = model_dir / "model.onnx"
|
||||
tags_path = model_dir / "selected_tags.csv"
|
||||
|
||||
self.logger.info(f"Loading WD14 model from {model_dir}...")
|
||||
|
||||
# Set up ONNX session
|
||||
providers = []
|
||||
if use_gpu:
|
||||
providers.append("CUDAExecutionProvider")
|
||||
providers.append("CPUExecutionProvider")
|
||||
|
||||
session = ort.InferenceSession(str(model_path), providers=providers)
|
||||
|
||||
# Read tag list from CSV
|
||||
tags_list = []
|
||||
with open(tags_path, "r", encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
tags_list.append(row)
|
||||
|
||||
self.logger.info(
|
||||
f"WD14 model loaded ({len(tags_list)} tags, "
|
||||
f"provider: {session.get_providers()[0]})"
|
||||
)
|
||||
return ("wd14", (session, tags_list))
|
||||
|
||||
def _generate_wd14(
|
||||
self,
|
||||
session,
|
||||
tags_list: List[Dict[str, str]],
|
||||
image: Image.Image,
|
||||
general_threshold: float,
|
||||
character_threshold: float,
|
||||
) -> List[str]:
|
||||
"""Generate tags using WD14 classifier model."""
|
||||
import numpy as np
|
||||
|
||||
# Get model input dimensions
|
||||
input_shape = session.get_inputs()[0].shape
|
||||
target_size = input_shape[2] # e.g. 448
|
||||
|
||||
# Pad to square with white background
|
||||
max_dim = max(image.size)
|
||||
padded = Image.new("RGB", (max_dim, max_dim), (255, 255, 255))
|
||||
padded.paste(
|
||||
image, ((max_dim - image.width) // 2, (max_dim - image.height) // 2)
|
||||
)
|
||||
|
||||
# Resize to model input size
|
||||
padded = padded.resize((target_size, target_size), Image.Resampling.BILINEAR)
|
||||
|
||||
# Convert to numpy: RGB -> BGR, float32, add batch dim
|
||||
img_array = np.array(padded, dtype=np.float32)
|
||||
img_array = img_array[:, :, ::-1] # RGB to BGR
|
||||
img_array = np.expand_dims(img_array, axis=0)
|
||||
|
||||
# Run inference
|
||||
input_name = session.get_inputs()[0].name
|
||||
output = session.run(None, {input_name: img_array})
|
||||
probabilities = output[0][0] # First output, first batch
|
||||
|
||||
# Filter tags by category and threshold
|
||||
result_tags = []
|
||||
for i, tag_info in enumerate(tags_list):
|
||||
if i >= len(probabilities):
|
||||
break
|
||||
prob = float(probabilities[i])
|
||||
category = tag_info.get("category", "0")
|
||||
tag_name = tag_info.get("name", "").strip()
|
||||
|
||||
if not tag_name:
|
||||
continue
|
||||
|
||||
# Category 0 = general, 4 = character, 9 = rating
|
||||
if category == "0" and prob >= general_threshold:
|
||||
result_tags.append((tag_name, prob))
|
||||
elif category == "4" and prob >= character_threshold:
|
||||
result_tags.append((tag_name, prob))
|
||||
|
||||
# Sort by confidence descending
|
||||
result_tags.sort(key=lambda x: x[1], reverse=True)
|
||||
return [tag for tag, _ in result_tags]
|
||||
|
||||
def download_model(
|
||||
self,
|
||||
model_type: str,
|
||||
progress_callback: Optional[Callable[[str, float], None]] = None,
|
||||
) -> bool:
|
||||
"""Download a model with optional progress updates.
|
||||
|
||||
Args:
|
||||
model_type: Either 'gguf' or 'hf'
|
||||
progress_callback: Optional callback(status_message, progress_percent)
|
||||
|
||||
Returns:
|
||||
True if download successful, False otherwise
|
||||
|
||||
Raises:
|
||||
ValueError: If model_type is not valid
|
||||
"""
|
||||
if model_type not in MODELS:
|
||||
raise ValueError(
|
||||
f"Invalid model type: {model_type}. "
|
||||
f"Must be one of: {', '.join(MODELS.keys())}"
|
||||
)
|
||||
|
||||
try:
|
||||
from huggingface_hub import ( # noqa: F401 - availability probe
|
||||
hf_hub_download,
|
||||
snapshot_download,
|
||||
)
|
||||
except ImportError:
|
||||
self.logger.error("huggingface_hub not installed")
|
||||
if progress_callback:
|
||||
progress_callback("Error: huggingface_hub not installed", 0)
|
||||
return False
|
||||
|
||||
try:
|
||||
if model_type == "gguf":
|
||||
return self._download_gguf_models(progress_callback)
|
||||
elif model_type.startswith("wd14"):
|
||||
return self._download_wd14_model(model_type, progress_callback)
|
||||
else:
|
||||
return self._download_hf_model(progress_callback)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Download failed: {e}")
|
||||
if progress_callback:
|
||||
progress_callback(f"Error: {str(e)}", 0)
|
||||
return False
|
||||
|
||||
def _download_gguf_models(
|
||||
self, progress_callback: Optional[Callable[[str, float], None]] = None
|
||||
) -> bool:
|
||||
"""Download GGUF model and mmproj files."""
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
config = MODELS["gguf"]
|
||||
gguf_dir = self.models_dir / config["subdir"]
|
||||
gguf_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
model_path = gguf_dir / config["filename"]
|
||||
mmproj_path = gguf_dir / config["mmproj_filename"]
|
||||
|
||||
# Download main model
|
||||
if not model_path.exists():
|
||||
if progress_callback:
|
||||
progress_callback(f"Downloading {config['filename']}...", 10)
|
||||
|
||||
self.logger.info(f"Downloading GGUF model: {config['filename']}")
|
||||
hf_hub_download(
|
||||
repo_id=config["repo"],
|
||||
filename=config["filename"],
|
||||
local_dir=str(gguf_dir),
|
||||
local_dir_use_symlinks=False,
|
||||
)
|
||||
self.logger.info("GGUF model downloaded")
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("Main model ready", 50)
|
||||
|
||||
# Download mmproj
|
||||
if not mmproj_path.exists():
|
||||
if progress_callback:
|
||||
progress_callback(f"Downloading {config['mmproj_filename']}...", 60)
|
||||
|
||||
self.logger.info(f"Downloading mmproj: {config['mmproj_filename']}")
|
||||
hf_hub_download(
|
||||
repo_id=config["mmproj_repo"],
|
||||
filename=config["mmproj_filename"],
|
||||
local_dir=str(gguf_dir),
|
||||
local_dir_use_symlinks=False,
|
||||
)
|
||||
self.logger.info("mmproj downloaded")
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("Download complete", 100)
|
||||
|
||||
return True
|
||||
|
||||
def _download_hf_model(
|
||||
self, progress_callback: Optional[Callable[[str, float], None]] = None
|
||||
) -> bool:
|
||||
"""Download HuggingFace model."""
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
config = MODELS["hf"]
|
||||
model_dir = self.models_dir / config["subdir"]
|
||||
|
||||
if not self._check_hf_model():
|
||||
if progress_callback:
|
||||
progress_callback(f"Downloading {config['repo']}...", 10)
|
||||
|
||||
self.logger.info(f"Downloading HF model: {config['repo']}")
|
||||
snapshot_download(
|
||||
repo_id=config["repo"],
|
||||
local_dir=str(model_dir),
|
||||
local_dir_use_symlinks=False,
|
||||
)
|
||||
self.logger.info("HF model downloaded")
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("Download complete", 100)
|
||||
|
||||
return True
|
||||
|
||||
def load_model(self, model_type: str, use_gpu: bool = True) -> bool:
|
||||
"""Load a model into memory for tag generation.
|
||||
|
||||
Args:
|
||||
model_type: Either 'gguf' or 'hf'
|
||||
use_gpu: Whether to use GPU acceleration (default True)
|
||||
|
||||
Returns:
|
||||
True if model loaded successfully
|
||||
|
||||
Raises:
|
||||
ValueError: If model_type is invalid
|
||||
RuntimeError: If model not downloaded or loading fails
|
||||
"""
|
||||
if model_type not in MODELS:
|
||||
raise ValueError(f"Invalid model type: {model_type}")
|
||||
|
||||
# Unload existing model first
|
||||
if self._tagger is not None:
|
||||
self.unload_model()
|
||||
|
||||
status = self.get_models_status()
|
||||
if not status[model_type]["downloaded"]:
|
||||
raise RuntimeError(f"Model {model_type} not downloaded")
|
||||
|
||||
try:
|
||||
self._tagger = self._model_loader(model_type, use_gpu)
|
||||
self._current_model_type = model_type
|
||||
self.logger.info(f"Model {model_type} loaded successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to load model {model_type}: {e}")
|
||||
self._tagger = None
|
||||
self._current_model_type = None
|
||||
raise RuntimeError(f"Failed to load model: {e}")
|
||||
|
||||
def _load_model(self, model_type: str, use_gpu: bool = True) -> Tuple[str, Any]:
|
||||
"""Default loader: dispatch to a backend, importing it only now."""
|
||||
if model_type == "gguf":
|
||||
return self._load_gguf_tagger(use_gpu)
|
||||
if model_type.startswith("wd14"):
|
||||
return self._load_wd14_tagger(model_type, use_gpu)
|
||||
return self._load_hf_tagger()
|
||||
|
||||
def _load_gguf_tagger(self, use_gpu: bool = True):
|
||||
"""Load GGUF-based tagger."""
|
||||
from llama_cpp import Llama
|
||||
from llama_cpp.llama_chat_format import Llava15ChatHandler
|
||||
|
||||
config = MODELS["gguf"]
|
||||
gguf_dir = self.models_dir / config["subdir"]
|
||||
model_path = gguf_dir / config["filename"]
|
||||
mmproj_path = gguf_dir / config["mmproj_filename"]
|
||||
|
||||
self.logger.info("Loading GGUF model...")
|
||||
n_gpu_layers = -1 if use_gpu else 0
|
||||
|
||||
tagger = Llama(
|
||||
model_path=str(model_path),
|
||||
n_ctx=4096,
|
||||
n_batch=2048,
|
||||
n_threads=4,
|
||||
n_gpu_layers=n_gpu_layers,
|
||||
verbose=False,
|
||||
chat_handler=Llava15ChatHandler(clip_model_path=str(mmproj_path)),
|
||||
offload_kqv=True,
|
||||
)
|
||||
|
||||
self.logger.info("GGUF model loaded")
|
||||
return ("gguf", tagger)
|
||||
|
||||
def _load_hf_tagger(self, quantization: str = "8bit"):
|
||||
"""Load HuggingFace-based tagger."""
|
||||
import torch
|
||||
from transformers import (
|
||||
AutoProcessor,
|
||||
LlavaForConditionalGeneration,
|
||||
BitsAndBytesConfig,
|
||||
)
|
||||
|
||||
# Get the actual model path (local or cache)
|
||||
model_path = self._get_hf_model_path()
|
||||
if model_path is None:
|
||||
raise RuntimeError(
|
||||
"HuggingFace model not found in local directory or cache"
|
||||
)
|
||||
|
||||
self.logger.info(f"Loading HuggingFace model from {model_path}...")
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
processor = AutoProcessor.from_pretrained(str(model_path))
|
||||
model_kwargs = {"device_map": "cuda" if device == "cuda" else "cpu"}
|
||||
|
||||
if quantization == "8bit":
|
||||
qnt_config = BitsAndBytesConfig(
|
||||
load_in_8bit=True,
|
||||
bnb_8bit_compute_dtype=torch.float16,
|
||||
bnb_8bit_use_double_quant=True,
|
||||
llm_int8_skip_modules=["vision_tower", "multi_modal_projector"],
|
||||
)
|
||||
model = LlavaForConditionalGeneration.from_pretrained(
|
||||
str(model_path),
|
||||
torch_dtype=torch.float16,
|
||||
quantization_config=qnt_config,
|
||||
**model_kwargs,
|
||||
)
|
||||
else:
|
||||
model = LlavaForConditionalGeneration.from_pretrained(
|
||||
str(model_path), torch_dtype=torch.bfloat16, **model_kwargs
|
||||
)
|
||||
|
||||
model.eval()
|
||||
self.logger.info("HuggingFace model loaded")
|
||||
# Track the compute dtype for pixel_values conversion
|
||||
compute_dtype = torch.float16 if quantization == "8bit" else torch.bfloat16
|
||||
return ("hf", (model, processor, device, compute_dtype))
|
||||
|
||||
def unload_model(self):
|
||||
"""Unload the current model and free memory."""
|
||||
if self._tagger is not None:
|
||||
self.logger.info(f"Unloading model: {self._current_model_type}")
|
||||
del self._tagger
|
||||
self._tagger = None
|
||||
self._current_model_type = None
|
||||
gc.collect()
|
||||
|
||||
# Clear the CUDA cache only if torch is already loaded; importing
|
||||
# it here would pull in a heavy dependency the backend never used.
|
||||
torch = sys.modules.get("torch")
|
||||
if torch is not None and torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
self.logger.info("Model unloaded, memory freed")
|
||||
|
||||
def is_model_loaded(self) -> bool:
|
||||
"""Check if a model is currently loaded."""
|
||||
return self._tagger is not None
|
||||
|
||||
def get_loaded_model_type(self) -> Optional[str]:
|
||||
"""Get the type of currently loaded model."""
|
||||
return self._current_model_type
|
||||
|
||||
def generate_tags(
|
||||
self,
|
||||
image_path: str,
|
||||
prompt: Optional[str] = None,
|
||||
general_threshold: Optional[float] = None,
|
||||
character_threshold: Optional[float] = None,
|
||||
) -> List[str]:
|
||||
"""Generate tags for an image.
|
||||
|
||||
Args:
|
||||
image_path: Path to the image file
|
||||
prompt: Custom prompt for tag generation (LLM models only).
|
||||
Uses default if None.
|
||||
general_threshold: Confidence threshold for general tags (WD14 only).
|
||||
character_threshold: Confidence threshold for character tags (WD14 only).
|
||||
|
||||
Returns:
|
||||
List of generated tags
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no model is loaded
|
||||
FileNotFoundError: If image doesn't exist
|
||||
"""
|
||||
if self._tagger is None:
|
||||
raise RuntimeError("No model loaded. Call load_model() first.")
|
||||
|
||||
if not os.path.exists(image_path):
|
||||
raise FileNotFoundError(errno.ENOENT, "Image not found", str(image_path))
|
||||
|
||||
use_prompt = prompt or self._custom_prompt
|
||||
|
||||
# Load image
|
||||
image = Image.open(image_path)
|
||||
if image.mode != "RGB":
|
||||
image = image.convert("RGB")
|
||||
|
||||
# Generate based on model type
|
||||
model_type, tagger_obj = self._tagger
|
||||
|
||||
if model_type == "gguf":
|
||||
raw_tags = self._generate_gguf(tagger_obj, image, use_prompt)
|
||||
return self._parse_tags(raw_tags)
|
||||
elif model_type == "wd14":
|
||||
session, tags_list = tagger_obj
|
||||
gen_thresh = (
|
||||
general_threshold
|
||||
if general_threshold is not None
|
||||
else self._wd14_general_threshold
|
||||
)
|
||||
char_thresh = (
|
||||
character_threshold
|
||||
if character_threshold is not None
|
||||
else self._wd14_character_threshold
|
||||
)
|
||||
return self._generate_wd14(
|
||||
session, tags_list, image, gen_thresh, char_thresh
|
||||
)
|
||||
else:
|
||||
model, processor, device, compute_dtype = tagger_obj
|
||||
raw_tags = self._generate_hf(
|
||||
model, processor, device, compute_dtype, image, use_prompt
|
||||
)
|
||||
return self._parse_tags(raw_tags)
|
||||
|
||||
def _generate_gguf(self, model, image: Image.Image, prompt: str) -> str:
|
||||
"""Generate tags using GGUF model."""
|
||||
import base64
|
||||
import io
|
||||
|
||||
# Resize image
|
||||
image = image.resize((336, 336), Image.Resampling.BILINEAR)
|
||||
|
||||
# Encode to base64
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
img_base64 = base64.b64encode(buffer.read()).decode("utf-8")
|
||||
data_uri = f"data:image/png;base64,{img_base64}"
|
||||
|
||||
# Create message
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {"url": data_uri}},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# Generate
|
||||
response = model.create_chat_completion(
|
||||
messages=messages,
|
||||
max_tokens=512,
|
||||
temperature=0.6,
|
||||
top_p=0.9,
|
||||
stop=["</s>", "User:", "Assistant:"],
|
||||
stream=False,
|
||||
)
|
||||
|
||||
return response["choices"][0]["message"]["content"].strip()
|
||||
|
||||
def _generate_hf(
|
||||
self,
|
||||
model,
|
||||
processor,
|
||||
device: str,
|
||||
compute_dtype,
|
||||
image: Image.Image,
|
||||
prompt: str,
|
||||
) -> str:
|
||||
"""Generate tags using HuggingFace model."""
|
||||
import torch
|
||||
|
||||
# Resize image
|
||||
image = image.resize((336, 336), Image.Resampling.LANCZOS)
|
||||
|
||||
convo = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
convo_string = processor.apply_chat_template(
|
||||
convo, tokenize=False, add_generation_prompt=True
|
||||
)
|
||||
|
||||
inputs = processor(text=[convo_string], images=[image], return_tensors="pt").to(
|
||||
device
|
||||
)
|
||||
|
||||
# Convert pixel_values to the model's compute dtype
|
||||
# (float16 for 8-bit quantized, bfloat16 for non-quantized)
|
||||
if "pixel_values" in inputs and inputs["pixel_values"] is not None:
|
||||
inputs["pixel_values"] = inputs["pixel_values"].to(compute_dtype)
|
||||
|
||||
with torch.inference_mode(), torch.cuda.amp.autocast(enabled=True):
|
||||
generate_ids = model.generate(
|
||||
**inputs,
|
||||
max_new_tokens=512,
|
||||
do_sample=True,
|
||||
temperature=0.6,
|
||||
top_p=0.9,
|
||||
use_cache=True,
|
||||
)[0]
|
||||
|
||||
generate_ids = generate_ids[inputs["input_ids"].shape[1] :]
|
||||
return processor.tokenizer.decode(
|
||||
generate_ids, skip_special_tokens=True
|
||||
).strip()
|
||||
|
||||
def _parse_tags(self, raw_output: str) -> List[str]:
|
||||
"""Parse raw model output into a list of clean tags.
|
||||
|
||||
Args:
|
||||
raw_output: Raw text output from the model
|
||||
|
||||
Returns:
|
||||
List of cleaned, deduplicated tags
|
||||
"""
|
||||
# Patterns to exclude
|
||||
exclude_prefixes = (
|
||||
"copyright:",
|
||||
"meta:",
|
||||
"photo_",
|
||||
"photo:",
|
||||
"prepend:",
|
||||
"append:",
|
||||
)
|
||||
|
||||
# Split by common delimiters
|
||||
tags = []
|
||||
|
||||
# Handle comma-separated tags
|
||||
for part in raw_output.split(","):
|
||||
tag = part.strip().lower()
|
||||
# Remove any quotes or extra characters
|
||||
tag = tag.strip("\"'")
|
||||
# Replace spaces with underscores (Danbooru style)
|
||||
tag = tag.replace(" ", "_")
|
||||
# Remove empty tags
|
||||
if tag and len(tag) > 1:
|
||||
# Filter out unwanted tag patterns
|
||||
if not tag.startswith(exclude_prefixes):
|
||||
tags.append(tag)
|
||||
|
||||
# Deduplicate while preserving order
|
||||
seen = set()
|
||||
unique_tags = []
|
||||
for tag in tags:
|
||||
if tag not in seen:
|
||||
seen.add(tag)
|
||||
unique_tags.append(tag)
|
||||
|
||||
return unique_tags
|
||||
|
||||
|
||||
# Singleton instance for API use
|
||||
_service_instance: Optional[AutoTagService] = None
|
||||
|
||||
|
||||
def get_autotag_service() -> AutoTagService:
|
||||
"""Get or create the singleton AutoTagService instance."""
|
||||
global _service_instance
|
||||
if _service_instance is None:
|
||||
_service_instance = AutoTagService()
|
||||
return _service_instance
|
||||
+480
-161
@@ -22,39 +22,144 @@ Example:
|
||||
extension_name = "PromptManager"
|
||||
|
||||
# Get server instance and routes (same pattern as ComfyUI_Assets)
|
||||
from server import PromptServer
|
||||
from server import PromptServer # noqa: E402
|
||||
|
||||
server_instance = PromptServer.instance
|
||||
routes = server_instance.routes
|
||||
|
||||
# Extension info
|
||||
extension_uri = None # Will be set in __init__.py
|
||||
|
||||
import os
|
||||
from typing import Dict, Any, List
|
||||
import os # noqa: E402
|
||||
from pathlib import Path # noqa: E402
|
||||
from typing import Any, Dict, List, Optional, Tuple # noqa: E402
|
||||
|
||||
# Import logging system
|
||||
try:
|
||||
from ..utils.logging_config import get_logger
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, current_dir)
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
# Initialize logger for config operations
|
||||
config_logger = get_logger('prompt_manager.config')
|
||||
config_logger = get_logger("prompt_manager.config")
|
||||
|
||||
# Environment variable listing extra directories (os.pathsep-separated) that
|
||||
# may be used as gallery roots in addition to ComfyUI's own directories.
|
||||
EXTRA_GALLERY_ROOTS_ENV = "PROMPT_MANAGER_EXTRA_GALLERY_ROOTS"
|
||||
|
||||
# Environment variable overriding where config.json is read from and saved to.
|
||||
CONFIG_PATH_ENV = "PROMPT_MANAGER_CONFIG_PATH"
|
||||
|
||||
# ComfyUI folder_paths getters whose directories are valid gallery parents.
|
||||
_COMFYUI_DIRECTORY_GETTERS = (
|
||||
"get_output_directory",
|
||||
"get_input_directory",
|
||||
"get_temp_directory",
|
||||
"get_user_directory",
|
||||
)
|
||||
|
||||
|
||||
def _canonical_path(path: str) -> str:
|
||||
"""Return a path normalised for comparison (realpath + normcase)."""
|
||||
return os.path.normcase(os.path.realpath(path))
|
||||
|
||||
|
||||
def write_private_json(path: str, data: Any) -> None:
|
||||
"""Write ``data`` as JSON to ``path`` readable by the current user only.
|
||||
|
||||
config.json can hold an API key (integrations.lora_manager), so it is
|
||||
created with mode 0600 and, on POSIX, an existing more open file is
|
||||
tightened too. Windows has no POSIX modes; there the chmod is a best
|
||||
effort that only affects the read-only bit. Parent directories are
|
||||
created as needed. Raises OSError when the file cannot be written.
|
||||
"""
|
||||
import json
|
||||
|
||||
parent_dir = os.path.dirname(path)
|
||||
if parent_dir:
|
||||
os.makedirs(parent_dir, exist_ok=True)
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
if os.name == "posix":
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def _is_filesystem_root(path: str) -> bool:
|
||||
"""True for '/' on POSIX and drive roots such as 'C:\\' on Windows."""
|
||||
return os.path.dirname(path) == path
|
||||
|
||||
|
||||
def _import_folder_paths():
|
||||
"""ComfyUI's folder_paths module, or None outside ComfyUI."""
|
||||
try:
|
||||
import folder_paths
|
||||
except ImportError:
|
||||
return None
|
||||
return folder_paths
|
||||
|
||||
|
||||
def _comfyui_base_directory() -> Optional[str]:
|
||||
"""ComfyUI's base directory: folder_paths.base_path, else the output dir's
|
||||
parent."""
|
||||
folder_paths = _import_folder_paths()
|
||||
if folder_paths is None:
|
||||
return None
|
||||
base = getattr(folder_paths, "base_path", None)
|
||||
if isinstance(base, str) and base:
|
||||
return base
|
||||
getter = getattr(folder_paths, "get_output_directory", None)
|
||||
if getter is None:
|
||||
return None
|
||||
try:
|
||||
output_dir = getter()
|
||||
except Exception:
|
||||
return None
|
||||
if isinstance(output_dir, str) and output_dir:
|
||||
return os.path.dirname(os.path.realpath(output_dir))
|
||||
return None
|
||||
|
||||
|
||||
def _comfyui_directories() -> List[str]:
|
||||
"""Directories reported by ComfyUI's folder_paths module, if importable."""
|
||||
folder_paths = _import_folder_paths()
|
||||
if folder_paths is None:
|
||||
return []
|
||||
|
||||
found = []
|
||||
for getter_name in _COMFYUI_DIRECTORY_GETTERS:
|
||||
getter = getattr(folder_paths, getter_name, None)
|
||||
if getter is None:
|
||||
continue
|
||||
try:
|
||||
directory = getter()
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(directory, str) and directory:
|
||||
found.append(directory)
|
||||
return found
|
||||
|
||||
|
||||
def _extra_gallery_roots() -> List[str]:
|
||||
"""Directories the user opted in through PROMPT_MANAGER_EXTRA_GALLERY_ROOTS."""
|
||||
raw = os.environ.get(EXTRA_GALLERY_ROOTS_ENV, "")
|
||||
return [entry.strip() for entry in raw.split(os.pathsep) if entry.strip()]
|
||||
|
||||
|
||||
class GalleryConfig:
|
||||
"""Configuration class for the gallery monitoring and image processing system.
|
||||
|
||||
|
||||
This class manages all settings related to automatic image monitoring,
|
||||
prompt tracking, database cleanup, web interface display, and performance
|
||||
optimization for the gallery functionality.
|
||||
|
||||
|
||||
All configuration values are class attributes that can be modified at runtime
|
||||
or loaded from external configuration files.
|
||||
|
||||
|
||||
Attributes:
|
||||
MONITORING_ENABLED (bool): Enable/disable automatic image monitoring
|
||||
MONITORING_DIRECTORIES (List[str]): Directories to monitor for new images
|
||||
@@ -71,86 +176,89 @@ class GalleryConfig:
|
||||
MAX_CONCURRENT_PROCESSING (int): Maximum concurrent image processing tasks
|
||||
METADATA_EXTRACTION_TIMEOUT (int): Timeout for metadata extraction operations
|
||||
"""
|
||||
|
||||
|
||||
# Image monitoring settings
|
||||
MONITORING_ENABLED = True
|
||||
MONITORING_DIRECTORIES = [] # Auto-detect if empty
|
||||
SUPPORTED_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.gif']
|
||||
SUPPORTED_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp", ".gif"]
|
||||
PROCESSING_DELAY = 2.0 # Seconds to wait before processing new files
|
||||
|
||||
|
||||
# Prompt tracking settings
|
||||
PROMPT_TIMEOUT = 120 # Seconds to keep prompt context active
|
||||
PROMPT_TIMEOUT = (
|
||||
600 # Seconds to keep prompt context active (10 min for long generations)
|
||||
)
|
||||
CLEANUP_INTERVAL = 300 # Seconds between cleanup of expired prompts
|
||||
|
||||
|
||||
# Database settings
|
||||
AUTO_CLEANUP_MISSING_FILES = True
|
||||
MAX_IMAGE_AGE_DAYS = 365 # Clean up images older than this
|
||||
|
||||
|
||||
# Web interface settings
|
||||
IMAGES_PER_PAGE = 20
|
||||
THUMBNAIL_SIZE = 256
|
||||
ENABLE_SEARCH = True
|
||||
ENABLE_METADATA_VIEW = True
|
||||
|
||||
|
||||
# Performance settings
|
||||
MAX_CONCURRENT_PROCESSING = 3
|
||||
METADATA_EXTRACTION_TIMEOUT = 10 # Seconds
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_config(cls) -> Dict[str, Any]:
|
||||
"""Get the complete gallery configuration as a structured dictionary.
|
||||
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: A nested dictionary containing all gallery configuration
|
||||
sections: monitoring, tracking, database, web_interface, and performance.
|
||||
Each section contains the relevant configuration parameters as key-value pairs.
|
||||
|
||||
sections: monitoring, tracking, database, web_interface, and
|
||||
performance. Each section contains the relevant configuration
|
||||
parameters as key-value pairs.
|
||||
|
||||
Example:
|
||||
config = GalleryConfig.get_config()
|
||||
monitoring_enabled = config['monitoring']['enabled']
|
||||
images_per_page = config['web_interface']['images_per_page']
|
||||
"""
|
||||
return {
|
||||
'monitoring': {
|
||||
'enabled': cls.MONITORING_ENABLED,
|
||||
'directories': cls.MONITORING_DIRECTORIES,
|
||||
'extensions': cls.SUPPORTED_EXTENSIONS,
|
||||
'processing_delay': cls.PROCESSING_DELAY
|
||||
"monitoring": {
|
||||
"enabled": cls.MONITORING_ENABLED,
|
||||
"directories": cls.MONITORING_DIRECTORIES,
|
||||
"extensions": cls.SUPPORTED_EXTENSIONS,
|
||||
"processing_delay": cls.PROCESSING_DELAY,
|
||||
},
|
||||
'tracking': {
|
||||
'prompt_timeout': cls.PROMPT_TIMEOUT,
|
||||
'cleanup_interval': cls.CLEANUP_INTERVAL
|
||||
"tracking": {
|
||||
"prompt_timeout": cls.PROMPT_TIMEOUT,
|
||||
"cleanup_interval": cls.CLEANUP_INTERVAL,
|
||||
},
|
||||
'database': {
|
||||
'auto_cleanup': cls.AUTO_CLEANUP_MISSING_FILES,
|
||||
'max_image_age_days': cls.MAX_IMAGE_AGE_DAYS
|
||||
"database": {
|
||||
"auto_cleanup": cls.AUTO_CLEANUP_MISSING_FILES,
|
||||
"max_image_age_days": cls.MAX_IMAGE_AGE_DAYS,
|
||||
},
|
||||
'web_interface': {
|
||||
'images_per_page': cls.IMAGES_PER_PAGE,
|
||||
'thumbnail_size': cls.THUMBNAIL_SIZE,
|
||||
'enable_search': cls.ENABLE_SEARCH,
|
||||
'enable_metadata_view': cls.ENABLE_METADATA_VIEW
|
||||
"web_interface": {
|
||||
"images_per_page": cls.IMAGES_PER_PAGE,
|
||||
"thumbnail_size": cls.THUMBNAIL_SIZE,
|
||||
"enable_search": cls.ENABLE_SEARCH,
|
||||
"enable_metadata_view": cls.ENABLE_METADATA_VIEW,
|
||||
},
|
||||
"performance": {
|
||||
"max_concurrent_processing": cls.MAX_CONCURRENT_PROCESSING,
|
||||
"metadata_extraction_timeout": cls.METADATA_EXTRACTION_TIMEOUT,
|
||||
},
|
||||
'performance': {
|
||||
'max_concurrent_processing': cls.MAX_CONCURRENT_PROCESSING,
|
||||
'metadata_extraction_timeout': cls.METADATA_EXTRACTION_TIMEOUT
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@classmethod
|
||||
def update_config(cls, new_config: Dict[str, Any]):
|
||||
"""Update gallery configuration attributes from a dictionary.
|
||||
|
||||
|
||||
Takes a nested dictionary with gallery configuration sections and updates
|
||||
the corresponding class attributes. Only updates attributes that are
|
||||
present in the input dictionary, leaving others unchanged.
|
||||
|
||||
|
||||
Args:
|
||||
new_config (Dict[str, Any]): Nested dictionary containing gallery
|
||||
configuration updates. Should follow the same structure as returned
|
||||
by get_config(). Valid top-level keys are: 'monitoring', 'tracking',
|
||||
'database', 'web_interface', 'performance'.
|
||||
|
||||
|
||||
Example:
|
||||
gallery_settings = {
|
||||
'monitoring': {'enabled': False},
|
||||
@@ -158,58 +266,218 @@ class GalleryConfig:
|
||||
}
|
||||
GalleryConfig.update_config(gallery_settings)
|
||||
"""
|
||||
monitoring = new_config.get('monitoring', {})
|
||||
if 'enabled' in monitoring:
|
||||
cls.MONITORING_ENABLED = monitoring['enabled']
|
||||
if 'directories' in monitoring:
|
||||
cls.MONITORING_DIRECTORIES = monitoring['directories']
|
||||
if 'extensions' in monitoring:
|
||||
cls.SUPPORTED_EXTENSIONS = monitoring['extensions']
|
||||
if 'processing_delay' in monitoring:
|
||||
cls.PROCESSING_DELAY = monitoring['processing_delay']
|
||||
|
||||
tracking = new_config.get('tracking', {})
|
||||
if 'prompt_timeout' in tracking:
|
||||
cls.PROMPT_TIMEOUT = tracking['prompt_timeout']
|
||||
if 'cleanup_interval' in tracking:
|
||||
cls.CLEANUP_INTERVAL = tracking['cleanup_interval']
|
||||
|
||||
database = new_config.get('database', {})
|
||||
if 'auto_cleanup' in database:
|
||||
cls.AUTO_CLEANUP_MISSING_FILES = database['auto_cleanup']
|
||||
if 'max_image_age_days' in database:
|
||||
cls.MAX_IMAGE_AGE_DAYS = database['max_image_age_days']
|
||||
|
||||
web_interface = new_config.get('web_interface', {})
|
||||
if 'images_per_page' in web_interface:
|
||||
cls.IMAGES_PER_PAGE = web_interface['images_per_page']
|
||||
if 'thumbnail_size' in web_interface:
|
||||
cls.THUMBNAIL_SIZE = web_interface['thumbnail_size']
|
||||
if 'enable_search' in web_interface:
|
||||
cls.ENABLE_SEARCH = web_interface['enable_search']
|
||||
if 'enable_metadata_view' in web_interface:
|
||||
cls.ENABLE_METADATA_VIEW = web_interface['enable_metadata_view']
|
||||
|
||||
performance = new_config.get('performance', {})
|
||||
if 'max_concurrent_processing' in performance:
|
||||
cls.MAX_CONCURRENT_PROCESSING = performance['max_concurrent_processing']
|
||||
if 'metadata_extraction_timeout' in performance:
|
||||
cls.METADATA_EXTRACTION_TIMEOUT = performance['metadata_extraction_timeout']
|
||||
monitoring = new_config.get("monitoring", {})
|
||||
if "enabled" in monitoring:
|
||||
cls.MONITORING_ENABLED = monitoring["enabled"]
|
||||
if "directories" in monitoring:
|
||||
directories = cls._validated_directories(monitoring["directories"])
|
||||
if directories is not None:
|
||||
cls.MONITORING_DIRECTORIES = directories
|
||||
if "extensions" in monitoring:
|
||||
cls.SUPPORTED_EXTENSIONS = monitoring["extensions"]
|
||||
if "processing_delay" in monitoring:
|
||||
cls.PROCESSING_DELAY = monitoring["processing_delay"]
|
||||
|
||||
tracking = new_config.get("tracking", {})
|
||||
if "prompt_timeout" in tracking:
|
||||
cls.PROMPT_TIMEOUT = tracking["prompt_timeout"]
|
||||
if "cleanup_interval" in tracking:
|
||||
cls.CLEANUP_INTERVAL = tracking["cleanup_interval"]
|
||||
|
||||
database = new_config.get("database", {})
|
||||
if "auto_cleanup" in database:
|
||||
cls.AUTO_CLEANUP_MISSING_FILES = database["auto_cleanup"]
|
||||
if "max_image_age_days" in database:
|
||||
cls.MAX_IMAGE_AGE_DAYS = database["max_image_age_days"]
|
||||
|
||||
web_interface = new_config.get("web_interface", {})
|
||||
if "images_per_page" in web_interface:
|
||||
cls.IMAGES_PER_PAGE = web_interface["images_per_page"]
|
||||
if "thumbnail_size" in web_interface:
|
||||
cls.THUMBNAIL_SIZE = web_interface["thumbnail_size"]
|
||||
if "enable_search" in web_interface:
|
||||
cls.ENABLE_SEARCH = web_interface["enable_search"]
|
||||
if "enable_metadata_view" in web_interface:
|
||||
cls.ENABLE_METADATA_VIEW = web_interface["enable_metadata_view"]
|
||||
|
||||
performance = new_config.get("performance", {})
|
||||
if "max_concurrent_processing" in performance:
|
||||
cls.MAX_CONCURRENT_PROCESSING = performance["max_concurrent_processing"]
|
||||
if "metadata_extraction_timeout" in performance:
|
||||
cls.METADATA_EXTRACTION_TIMEOUT = performance["metadata_extraction_timeout"]
|
||||
|
||||
@classmethod
|
||||
def _validated_directories(cls, directories: Any) -> Optional[List[str]]:
|
||||
"""Gallery roots from a config payload that pass validate_gallery_root.
|
||||
|
||||
A hand-edited config.json is as untrusted as a settings request:
|
||||
entries that fail validation are dropped with a warning (naming only
|
||||
the basename), and a value that is not a list is ignored entirely
|
||||
(``None`` is returned so the current roots stay as they are).
|
||||
"""
|
||||
if not isinstance(directories, list):
|
||||
config_logger.warning(
|
||||
"Ignoring gallery monitoring directories: expected a list"
|
||||
)
|
||||
return None
|
||||
kept = []
|
||||
for entry in directories:
|
||||
ok, reason = cls.validate_gallery_root(entry)
|
||||
if ok:
|
||||
kept.append(entry)
|
||||
continue
|
||||
label = os.path.basename(entry) if isinstance(entry, str) else entry
|
||||
config_logger.warning(f"Ignoring gallery root {label!r}: {reason}")
|
||||
return kept
|
||||
|
||||
@classmethod
|
||||
def allowed_gallery_parents(cls) -> List[str]:
|
||||
"""Canonical directories under which gallery roots may live.
|
||||
|
||||
Filesystem roots are never allowed as parents, even when listed in
|
||||
the environment, because that would re-open the whole disk.
|
||||
"""
|
||||
parents = []
|
||||
for candidate in _comfyui_directories() + _extra_gallery_roots():
|
||||
try:
|
||||
canonical = _canonical_path(candidate)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if _is_filesystem_root(canonical) or not os.path.isdir(canonical):
|
||||
continue
|
||||
if canonical not in parents:
|
||||
parents.append(canonical)
|
||||
return parents
|
||||
|
||||
@classmethod
|
||||
def path_anchors(cls) -> List[str]:
|
||||
"""Canonical directories that relative gallery paths are resolved against.
|
||||
|
||||
The ComfyUI base directory comes first, followed by the parent of each
|
||||
directory listed in PROMPT_MANAGER_EXTRA_GALLERY_ROOTS, so an extra
|
||||
root is addressed by its own name (``gallery/sub``) rather than by an
|
||||
absolute path. The same anchors drive the public (relative) form of
|
||||
paths in API responses.
|
||||
"""
|
||||
anchors = []
|
||||
base = _comfyui_base_directory()
|
||||
candidates = [base] if base else []
|
||||
candidates.extend(os.path.dirname(root) for root in _extra_gallery_roots())
|
||||
for candidate in candidates:
|
||||
try:
|
||||
canonical = _canonical_path(candidate)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if canonical not in anchors:
|
||||
anchors.append(canonical)
|
||||
return anchors
|
||||
|
||||
@classmethod
|
||||
def resolve_gallery_root(cls, path: str) -> str:
|
||||
"""Canonical absolute path for a gallery root given in any accepted form.
|
||||
|
||||
Absolute paths are canonicalised as-is. Relative paths are tried
|
||||
against each :meth:`path_anchors` entry and the first existing match
|
||||
wins; otherwise the first anchor (or the current directory when there
|
||||
is none) is used, so the caller's existence check reports it.
|
||||
"""
|
||||
path = path.strip()
|
||||
if os.path.isabs(path):
|
||||
return _canonical_path(path)
|
||||
anchors = cls.path_anchors()
|
||||
for anchor in anchors:
|
||||
candidate = os.path.join(anchor, path)
|
||||
if os.path.exists(candidate):
|
||||
return _canonical_path(candidate)
|
||||
return _canonical_path(os.path.join(anchors[0], path) if anchors else path)
|
||||
|
||||
@classmethod
|
||||
def validate_gallery_root(cls, path: Any) -> Tuple[bool, str]:
|
||||
"""Check whether ``path`` may be used as a gallery root.
|
||||
|
||||
Returns:
|
||||
(True, "") when the directory lies inside one of
|
||||
:meth:`allowed_gallery_parents`; otherwise (False, reason).
|
||||
"""
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
return False, "Gallery root must be a non-empty path"
|
||||
|
||||
try:
|
||||
canonical = cls.resolve_gallery_root(path)
|
||||
except (OSError, ValueError):
|
||||
return False, "Gallery root could not be resolved"
|
||||
|
||||
if _is_filesystem_root(canonical):
|
||||
return False, "Gallery root cannot be a filesystem root"
|
||||
if canonical == _canonical_path(os.path.expanduser("~")):
|
||||
return False, "Gallery root cannot be the home directory"
|
||||
if not os.path.exists(canonical):
|
||||
return False, "Gallery root does not exist"
|
||||
if not os.path.isdir(canonical):
|
||||
return False, "Gallery root is not a directory"
|
||||
|
||||
candidate = Path(canonical)
|
||||
for parent in cls.allowed_gallery_parents():
|
||||
if candidate.is_relative_to(Path(parent)):
|
||||
return True, ""
|
||||
|
||||
return (
|
||||
False,
|
||||
"Gallery root must be inside a ComfyUI directory "
|
||||
f"(output, input, temp, user) or one listed in {EXTRA_GALLERY_ROOTS_ENV}",
|
||||
)
|
||||
|
||||
|
||||
class IntegrationConfig:
|
||||
"""Configuration for third-party extension integrations.
|
||||
|
||||
Manages opt-in integration settings for extensions like LoraManager.
|
||||
All integrations are disabled by default so PromptManager works standalone.
|
||||
"""
|
||||
|
||||
# LoraManager integration
|
||||
LORA_MANAGER_ENABLED = False
|
||||
LORA_MANAGER_PATH = "" # Auto-detected if empty
|
||||
LORA_TRIGGER_WORDS_ENABLED = False # Auto-inject trigger words into prompts
|
||||
CIVITAI_API_KEY = "" # Required to download NSFW example images
|
||||
|
||||
@classmethod
|
||||
def get_config(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"lora_manager": {
|
||||
"enabled": cls.LORA_MANAGER_ENABLED,
|
||||
"path": cls.LORA_MANAGER_PATH,
|
||||
"trigger_words_enabled": cls.LORA_TRIGGER_WORDS_ENABLED,
|
||||
"civitai_api_key": cls.CIVITAI_API_KEY,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def update_config(cls, new_config: Dict[str, Any]):
|
||||
lora = new_config.get("lora_manager", {})
|
||||
if "enabled" in lora:
|
||||
cls.LORA_MANAGER_ENABLED = lora["enabled"]
|
||||
if "path" in lora:
|
||||
cls.LORA_MANAGER_PATH = lora["path"]
|
||||
if "trigger_words_enabled" in lora:
|
||||
cls.LORA_TRIGGER_WORDS_ENABLED = lora["trigger_words_enabled"]
|
||||
if "civitai_api_key" in lora:
|
||||
cls.CIVITAI_API_KEY = lora["civitai_api_key"]
|
||||
|
||||
|
||||
class PromptManagerConfig:
|
||||
"""Main configuration class for PromptManager core functionality.
|
||||
|
||||
|
||||
This class manages configuration for database operations, web UI behavior,
|
||||
performance settings, and integrates gallery configuration. It provides
|
||||
methods for loading and saving configuration from/to JSON files.
|
||||
|
||||
|
||||
The configuration is organized into logical sections:
|
||||
- Database: Settings for SQLite operations and data management
|
||||
- Web UI: User interface behavior and display options
|
||||
- Performance: Optimization and resource management settings
|
||||
- Gallery: Embedded gallery configuration (via GalleryConfig)
|
||||
|
||||
|
||||
Attributes:
|
||||
DEFAULT_DB_PATH (str): Default path for the SQLite database file
|
||||
ENABLE_DUPLICATE_DETECTION (bool): Enable automatic duplicate detection
|
||||
@@ -221,82 +489,127 @@ class PromptManagerConfig:
|
||||
ENABLE_FUZZY_SEARCH (bool): Enable fuzzy search capabilities
|
||||
AUTO_BACKUP_INTERVAL (int): Hours between automatic database backups
|
||||
"""
|
||||
|
||||
|
||||
# Database settings
|
||||
DEFAULT_DB_PATH = "prompts.db"
|
||||
ENABLE_DUPLICATE_DETECTION = True
|
||||
ENABLE_AUTO_SAVE = True
|
||||
|
||||
|
||||
# Web UI settings
|
||||
RESULT_TIMEOUT = 5 # Seconds to auto-hide results in ComfyUI node
|
||||
SHOW_TEST_BUTTON = False # Show API test button in node UI
|
||||
WEBUI_DISPLAY_MODE = 'newtab' # 'popup' or 'newtab'
|
||||
|
||||
WEBUI_DISPLAY_MODE = "newtab" # 'popup' or 'newtab'
|
||||
INFINITE_SCROLL = False # Dashboard list: load the next page on scroll by default
|
||||
|
||||
# Performance settings
|
||||
MAX_SEARCH_RESULTS = 100
|
||||
ENABLE_FUZZY_SEARCH = False # Requires fuzzywuzzy
|
||||
AUTO_BACKUP_INTERVAL = 24 # Hours
|
||||
|
||||
# Threads for the output scan and thumbnail generation; half the cores by
|
||||
# default so ComfyUI's own generation keeps CPU headroom. 1..max_worker_threads().
|
||||
DEFAULT_WORKER_THREADS = max(1, (os.cpu_count() or 1) // 2)
|
||||
WORKER_THREADS = DEFAULT_WORKER_THREADS
|
||||
|
||||
@classmethod
|
||||
def max_worker_threads(cls) -> int:
|
||||
"""Largest accepted worker-thread count: the detected core count."""
|
||||
return os.cpu_count() or 1
|
||||
|
||||
@classmethod
|
||||
def clamp_worker_threads(cls, value, fallback: int) -> int:
|
||||
"""Coerce a worker count from config.json into ``[1, max_worker_threads()]``.
|
||||
|
||||
Non-numeric values (and booleans) return ``fallback`` so a hand-edited
|
||||
file cannot disable the setting.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return fallback
|
||||
if isinstance(value, str) and value.strip().isdigit():
|
||||
value = int(value.strip())
|
||||
if not isinstance(value, int):
|
||||
return fallback
|
||||
return max(1, min(value, cls.max_worker_threads()))
|
||||
|
||||
@classmethod
|
||||
def get_config(cls) -> Dict[str, Any]:
|
||||
"""Get the complete PromptManager configuration as a structured dictionary.
|
||||
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: A nested dictionary containing all configuration sections:
|
||||
- database: Database-related settings
|
||||
- web_ui: Web interface configuration
|
||||
- performance: Performance and optimization settings
|
||||
- gallery: Complete gallery configuration (from GalleryConfig)
|
||||
|
||||
|
||||
Example:
|
||||
config = PromptManagerConfig.get_config()
|
||||
db_path = config['database']['default_path']
|
||||
max_results = config['performance']['max_search_results']
|
||||
"""
|
||||
return {
|
||||
'database': {
|
||||
'default_path': cls.DEFAULT_DB_PATH,
|
||||
'enable_duplicate_detection': cls.ENABLE_DUPLICATE_DETECTION,
|
||||
'enable_auto_save': cls.ENABLE_AUTO_SAVE
|
||||
"database": {
|
||||
"default_path": cls.DEFAULT_DB_PATH,
|
||||
"enable_duplicate_detection": cls.ENABLE_DUPLICATE_DETECTION,
|
||||
"enable_auto_save": cls.ENABLE_AUTO_SAVE,
|
||||
},
|
||||
'web_ui': {
|
||||
'result_timeout': cls.RESULT_TIMEOUT,
|
||||
'show_test_button': cls.SHOW_TEST_BUTTON,
|
||||
'webui_display_mode': cls.WEBUI_DISPLAY_MODE
|
||||
"web_ui": {
|
||||
"result_timeout": cls.RESULT_TIMEOUT,
|
||||
"show_test_button": cls.SHOW_TEST_BUTTON,
|
||||
"webui_display_mode": cls.WEBUI_DISPLAY_MODE,
|
||||
"infinite_scroll": cls.INFINITE_SCROLL,
|
||||
},
|
||||
'performance': {
|
||||
'max_search_results': cls.MAX_SEARCH_RESULTS,
|
||||
'enable_fuzzy_search': cls.ENABLE_FUZZY_SEARCH,
|
||||
'auto_backup_interval': cls.AUTO_BACKUP_INTERVAL
|
||||
"performance": {
|
||||
"max_search_results": cls.MAX_SEARCH_RESULTS,
|
||||
"enable_fuzzy_search": cls.ENABLE_FUZZY_SEARCH,
|
||||
"auto_backup_interval": cls.AUTO_BACKUP_INTERVAL,
|
||||
"worker_threads": cls.WORKER_THREADS,
|
||||
},
|
||||
'gallery': GalleryConfig.get_config()
|
||||
"gallery": GalleryConfig.get_config(),
|
||||
"integrations": IntegrationConfig.get_config(),
|
||||
}
|
||||
|
||||
|
||||
@classmethod
|
||||
def load_from_file(cls, config_path: str):
|
||||
def get_config_path(cls) -> str:
|
||||
"""Path of the persisted config.json.
|
||||
|
||||
Honours ``PROMPT_MANAGER_CONFIG_PATH`` when set; otherwise the file
|
||||
lives at the repository root next to ``pyproject.toml``.
|
||||
"""
|
||||
override = os.environ.get(CONFIG_PATH_ENV, "").strip()
|
||||
if override:
|
||||
return override
|
||||
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
return os.path.join(repo_root, "config.json")
|
||||
|
||||
@classmethod
|
||||
def load_from_file(cls, config_path: Optional[str] = None):
|
||||
"""Load configuration settings from a JSON file.
|
||||
|
||||
|
||||
Reads configuration from the specified JSON file and updates the current
|
||||
configuration attributes. If the file doesn't exist or contains invalid
|
||||
JSON, logs an appropriate message and continues with default values.
|
||||
|
||||
|
||||
Args:
|
||||
config_path (str): Path to the JSON configuration file to load.
|
||||
Can be relative or absolute path.
|
||||
|
||||
Can be relative or absolute path. Defaults to
|
||||
:meth:`get_config_path`.
|
||||
|
||||
Raises:
|
||||
The method handles all exceptions internally and logs errors rather
|
||||
than propagating them, ensuring the system continues with defaults.
|
||||
|
||||
|
||||
Example:
|
||||
PromptManagerConfig.load_from_file('custom_config.json')
|
||||
PromptManagerConfig.load_from_file('/path/to/config.json')
|
||||
"""
|
||||
import json
|
||||
|
||||
|
||||
if config_path is None:
|
||||
config_path = cls.get_config_path()
|
||||
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r') as f:
|
||||
with open(config_path, "r") as f:
|
||||
config = json.load(f)
|
||||
cls.update_config(config)
|
||||
config_logger.info(f"Loaded configuration from {config_path}")
|
||||
@@ -304,52 +617,50 @@ class PromptManagerConfig:
|
||||
config_logger.error(f"Error loading config from {config_path}: {e}")
|
||||
else:
|
||||
config_logger.info(f"Config file not found: {config_path}, using defaults")
|
||||
|
||||
|
||||
@classmethod
|
||||
def save_to_file(cls, config_path: str):
|
||||
def save_to_file(cls, config_path: Optional[str] = None):
|
||||
"""Save the current configuration to a JSON file.
|
||||
|
||||
|
||||
Serializes the complete configuration (including gallery settings) to
|
||||
a JSON file. Creates the directory structure if it doesn't exist.
|
||||
|
||||
|
||||
Args:
|
||||
config_path (str): Path where the JSON configuration file should be saved.
|
||||
Parent directories will be created if they don't exist.
|
||||
|
||||
Defaults to :meth:`get_config_path`.
|
||||
|
||||
Raises:
|
||||
The method handles all exceptions internally and logs errors rather
|
||||
than propagating them.
|
||||
|
||||
|
||||
Example:
|
||||
PromptManagerConfig.save_to_file('backup_config.json')
|
||||
PromptManagerConfig.save_to_file('/etc/comfyui/prompt_manager.json')
|
||||
"""
|
||||
import json
|
||||
|
||||
if config_path is None:
|
||||
config_path = cls.get_config_path()
|
||||
|
||||
try:
|
||||
config = cls.get_config()
|
||||
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
||||
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
write_private_json(config_path, cls.get_config())
|
||||
config_logger.info(f"Saved configuration to {config_path}")
|
||||
except Exception as e:
|
||||
config_logger.error(f"Error saving config to {config_path}: {e}")
|
||||
|
||||
|
||||
@classmethod
|
||||
def update_config(cls, new_config: Dict[str, Any]):
|
||||
"""Update configuration attributes from a dictionary.
|
||||
|
||||
|
||||
Takes a nested dictionary with configuration sections and updates
|
||||
the corresponding class attributes. Only updates attributes that
|
||||
are present in the input dictionary, leaving others unchanged.
|
||||
|
||||
|
||||
Args:
|
||||
new_config (Dict[str, Any]): Nested dictionary containing configuration
|
||||
updates. Should follow the same structure as returned by get_config().
|
||||
Valid top-level keys are: 'database', 'web_ui', 'performance', 'gallery'.
|
||||
|
||||
Valid top-level keys are: 'database', 'web_ui', 'performance',
|
||||
'gallery'.
|
||||
|
||||
Example:
|
||||
new_settings = {
|
||||
'database': {'default_path': 'custom.db'},
|
||||
@@ -357,39 +668,47 @@ class PromptManagerConfig:
|
||||
}
|
||||
PromptManagerConfig.update_config(new_settings)
|
||||
"""
|
||||
database = new_config.get('database', {})
|
||||
if 'default_path' in database:
|
||||
cls.DEFAULT_DB_PATH = database['default_path']
|
||||
if 'enable_duplicate_detection' in database:
|
||||
cls.ENABLE_DUPLICATE_DETECTION = database['enable_duplicate_detection']
|
||||
if 'enable_auto_save' in database:
|
||||
cls.ENABLE_AUTO_SAVE = database['enable_auto_save']
|
||||
|
||||
web_ui = new_config.get('web_ui', {})
|
||||
if 'result_timeout' in web_ui:
|
||||
cls.RESULT_TIMEOUT = web_ui['result_timeout']
|
||||
if 'show_test_button' in web_ui:
|
||||
cls.SHOW_TEST_BUTTON = web_ui['show_test_button']
|
||||
if 'webui_display_mode' in web_ui:
|
||||
cls.WEBUI_DISPLAY_MODE = web_ui['webui_display_mode']
|
||||
|
||||
performance = new_config.get('performance', {})
|
||||
if 'max_search_results' in performance:
|
||||
cls.MAX_SEARCH_RESULTS = performance['max_search_results']
|
||||
if 'enable_fuzzy_search' in performance:
|
||||
cls.ENABLE_FUZZY_SEARCH = performance['enable_fuzzy_search']
|
||||
if 'auto_backup_interval' in performance:
|
||||
cls.AUTO_BACKUP_INTERVAL = performance['auto_backup_interval']
|
||||
|
||||
database = new_config.get("database", {})
|
||||
if "default_path" in database:
|
||||
cls.DEFAULT_DB_PATH = database["default_path"]
|
||||
if "enable_duplicate_detection" in database:
|
||||
cls.ENABLE_DUPLICATE_DETECTION = database["enable_duplicate_detection"]
|
||||
if "enable_auto_save" in database:
|
||||
cls.ENABLE_AUTO_SAVE = database["enable_auto_save"]
|
||||
|
||||
web_ui = new_config.get("web_ui", {})
|
||||
if "result_timeout" in web_ui:
|
||||
cls.RESULT_TIMEOUT = web_ui["result_timeout"]
|
||||
if "show_test_button" in web_ui:
|
||||
cls.SHOW_TEST_BUTTON = web_ui["show_test_button"]
|
||||
if "webui_display_mode" in web_ui:
|
||||
cls.WEBUI_DISPLAY_MODE = web_ui["webui_display_mode"]
|
||||
if isinstance(web_ui.get("infinite_scroll"), bool):
|
||||
cls.INFINITE_SCROLL = web_ui["infinite_scroll"]
|
||||
|
||||
performance = new_config.get("performance", {})
|
||||
if "max_search_results" in performance:
|
||||
cls.MAX_SEARCH_RESULTS = performance["max_search_results"]
|
||||
if "enable_fuzzy_search" in performance:
|
||||
cls.ENABLE_FUZZY_SEARCH = performance["enable_fuzzy_search"]
|
||||
if "auto_backup_interval" in performance:
|
||||
cls.AUTO_BACKUP_INTERVAL = performance["auto_backup_interval"]
|
||||
if "worker_threads" in performance:
|
||||
cls.WORKER_THREADS = cls.clamp_worker_threads(
|
||||
performance["worker_threads"], cls.WORKER_THREADS
|
||||
)
|
||||
|
||||
# Update gallery config
|
||||
if 'gallery' in new_config:
|
||||
GalleryConfig.update_config(new_config['gallery'])
|
||||
if "gallery" in new_config:
|
||||
GalleryConfig.update_config(new_config["gallery"])
|
||||
|
||||
# Update integration config
|
||||
if "integrations" in new_config:
|
||||
IntegrationConfig.update_config(new_config["integrations"])
|
||||
|
||||
|
||||
# Load configuration on import
|
||||
try:
|
||||
config_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
config_file = os.path.join(config_dir, 'config.json')
|
||||
PromptManagerConfig.load_from_file(config_file)
|
||||
PromptManagerConfig.load_from_file()
|
||||
except Exception as e:
|
||||
config_logger.error(f"Error during config initialization: {e}")
|
||||
config_logger.error(f"Error during config initialization: {e}")
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
"""Utilities for LoraManager integration.
|
||||
|
||||
Provides detection, metadata reading, and trigger word lookup for
|
||||
ComfyUI-Lora-Manager (https://github.com/willmiao/ComfyUI-Lora-Manager).
|
||||
|
||||
All functions are safe to call when LoraManager is not installed — they
|
||||
return empty results rather than raising.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
try:
|
||||
from ..utils.logging_config import get_logger
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("prompt_manager.lora_utils")
|
||||
|
||||
# ── LoraManager detection ────────────────────────────────────────────
|
||||
|
||||
|
||||
def find_comfyui_root() -> Optional[Path]:
|
||||
"""Walk upward from this file to find the ComfyUI root (contains main.py).
|
||||
|
||||
Tries both the resolved (real) path and the unresolved path to handle
|
||||
symlinked custom_nodes installations.
|
||||
"""
|
||||
start_paths = [Path(__file__).resolve().parent]
|
||||
|
||||
# If installed via symlink, the unresolved path leads through custom_nodes/
|
||||
raw_path = Path(__file__).parent
|
||||
if raw_path.resolve() != raw_path:
|
||||
start_paths.append(raw_path)
|
||||
|
||||
# Also try via folder_paths if available (ComfyUI runtime)
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
base = Path(folder_paths.base_path)
|
||||
if base.is_dir():
|
||||
return base
|
||||
except (ImportError, AttributeError):
|
||||
# folder_paths unavailable — not running inside ComfyUI runtime
|
||||
pass
|
||||
|
||||
for start in start_paths:
|
||||
current = start
|
||||
for _ in range(10):
|
||||
if (current / "main.py").exists() and (current / "custom_nodes").exists():
|
||||
return current
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def detect_lora_manager(custom_path: str = "") -> Optional[str]:
|
||||
"""Return the absolute path to ComfyUI-Lora-Manager if installed.
|
||||
|
||||
Args:
|
||||
custom_path: User-provided override path. Checked first.
|
||||
|
||||
Returns:
|
||||
Absolute path string, or None if not found.
|
||||
"""
|
||||
# 1. User override
|
||||
if custom_path:
|
||||
p = Path(custom_path)
|
||||
if p.is_dir() and _looks_like_lora_manager(p):
|
||||
return str(p.resolve())
|
||||
|
||||
# 2. Auto-detect via custom_nodes (case-insensitive scan)
|
||||
root = find_comfyui_root()
|
||||
if root:
|
||||
custom_nodes = root / "custom_nodes"
|
||||
if custom_nodes.is_dir():
|
||||
for entry in custom_nodes.iterdir():
|
||||
if (
|
||||
entry.is_dir()
|
||||
and "lora" in entry.name.lower()
|
||||
and "manager" in entry.name.lower()
|
||||
and _looks_like_lora_manager(entry)
|
||||
):
|
||||
return str(entry.resolve())
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _looks_like_lora_manager(path: Path) -> bool:
|
||||
"""Heuristic: does this directory look like a LoraManager install?"""
|
||||
# Must have __init__.py (ComfyUI extension) or README.md
|
||||
has_init = (path / "__init__.py").exists()
|
||||
if not has_init:
|
||||
return False
|
||||
# Check for characteristic structure: py/ dir, or any .metadata.json nearby
|
||||
return (path / "py").is_dir() or (path / "lora_manager").is_dir()
|
||||
|
||||
|
||||
def _public_path_anchors() -> List[str]:
|
||||
"""Directories the API's public (relative) paths are rendered against."""
|
||||
try:
|
||||
from .config import GalleryConfig
|
||||
except ImportError:
|
||||
try:
|
||||
from config import GalleryConfig
|
||||
except ImportError:
|
||||
return []
|
||||
try:
|
||||
return list(GalleryConfig.path_anchors())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def custom_nodes_directories() -> List[Path]:
|
||||
"""ComfyUI ``custom_nodes`` directories a LoraManager install may live in.
|
||||
|
||||
Inside ComfyUI these come from ``folder_paths`` (``base_path`` plus any
|
||||
registered ``custom_nodes`` folders); outside it, the parent of this
|
||||
package, which is ``custom_nodes`` for a normal install.
|
||||
"""
|
||||
found: List[Path] = []
|
||||
|
||||
def add(candidate) -> None:
|
||||
try:
|
||||
resolved = Path(candidate).resolve()
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return
|
||||
if resolved not in found:
|
||||
found.append(resolved)
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
except ImportError:
|
||||
folder_paths = None
|
||||
if folder_paths is not None:
|
||||
base = getattr(folder_paths, "base_path", None)
|
||||
if isinstance(base, str) and base:
|
||||
add(Path(base) / "custom_nodes")
|
||||
getter = getattr(folder_paths, "get_folder_paths", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
registered = getter("custom_nodes") or []
|
||||
except Exception:
|
||||
registered = []
|
||||
for entry in registered:
|
||||
add(entry)
|
||||
if not found:
|
||||
add(Path(__file__).resolve().parent.parent.parent)
|
||||
return found
|
||||
|
||||
|
||||
def resolve_lora_manager_path(path) -> Optional[str]:
|
||||
"""Canonical form of ``path`` when it may be used as the LoraManager install.
|
||||
|
||||
Accepted: an existing directory that is a direct child of one of
|
||||
:func:`custom_nodes_directories` and whose name contains ``lora``
|
||||
(case-insensitive). A relative path is tried against the ComfyUI root
|
||||
and each custom_nodes directory. Anything else, including a symlink
|
||||
that escapes custom_nodes, yields None.
|
||||
"""
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
return None
|
||||
path = path.strip()
|
||||
roots = []
|
||||
for root in custom_nodes_directories():
|
||||
try:
|
||||
roots.append(Path(os.path.normcase(os.path.realpath(str(root)))))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
|
||||
candidates = [path]
|
||||
if not os.path.isabs(path):
|
||||
# Public (API) paths are relative to these anchors; the ComfyUI root
|
||||
# and the custom_nodes directories cover hand-typed relative paths.
|
||||
bases = list(_public_path_anchors())
|
||||
comfy_root = find_comfyui_root()
|
||||
if comfy_root:
|
||||
bases.append(comfy_root)
|
||||
bases.extend(root.parent for root in roots)
|
||||
bases.extend(roots)
|
||||
candidates = [os.path.join(str(base), path) for base in bases]
|
||||
|
||||
for candidate in candidates:
|
||||
try:
|
||||
canonical = Path(os.path.normcase(os.path.realpath(candidate)))
|
||||
is_dir = canonical.is_dir()
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
continue
|
||||
if not is_dir or "lora" not in canonical.name.lower():
|
||||
continue
|
||||
if canonical.parent in roots:
|
||||
return str(canonical)
|
||||
return None
|
||||
|
||||
|
||||
# ── Metadata reading ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def find_lora_directories(lora_manager_path: str) -> List[str]:
|
||||
"""Find directories that contain LoRA models (with .metadata.json files).
|
||||
|
||||
Searches: ComfyUI models/loras, extra_model_paths.yaml lora dirs,
|
||||
and the LoraManager extension dir itself.
|
||||
"""
|
||||
dirs = set()
|
||||
lm_path = Path(lora_manager_path)
|
||||
|
||||
root = find_comfyui_root()
|
||||
if root:
|
||||
# Default models/loras
|
||||
models_loras = root / "models" / "loras"
|
||||
if models_loras.is_dir():
|
||||
dirs.add(str(models_loras.resolve()))
|
||||
|
||||
# Extra model paths from ComfyUI config
|
||||
for extra_dir in _get_extra_lora_paths(root):
|
||||
if extra_dir.is_dir():
|
||||
dirs.add(str(extra_dir.resolve()))
|
||||
|
||||
# Also try folder_paths at runtime (catches all configured paths)
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
for p in folder_paths.get_folder_paths("loras"):
|
||||
pp = Path(p)
|
||||
if pp.is_dir():
|
||||
dirs.add(str(pp.resolve()))
|
||||
except (ImportError, AttributeError):
|
||||
# folder_paths unavailable — not running inside ComfyUI runtime
|
||||
pass
|
||||
|
||||
# Check for any .metadata.json in the LoraManager dir tree
|
||||
for meta in lm_path.rglob("*.metadata.json"):
|
||||
dirs.add(str(meta.parent.resolve()))
|
||||
|
||||
return sorted(dirs)
|
||||
|
||||
|
||||
def _get_extra_lora_paths(comfyui_root: Path) -> List[Path]:
|
||||
"""Parse extra_model_paths.yaml for additional LoRA directories."""
|
||||
results = []
|
||||
for name in ("extra_model_paths.yaml", "extra_model_paths.yml"):
|
||||
config_file = comfyui_root / name
|
||||
if not config_file.exists():
|
||||
continue
|
||||
try:
|
||||
import yaml
|
||||
|
||||
config = yaml.safe_load(config_file.read_text())
|
||||
if not isinstance(config, dict):
|
||||
continue
|
||||
for section in config.values():
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
base = Path(section.get("base_path", ""))
|
||||
loras_val = section.get("loras", "")
|
||||
if not loras_val:
|
||||
continue
|
||||
for line in str(loras_val).strip().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
p = Path(line)
|
||||
if not p.is_absolute():
|
||||
p = base / line
|
||||
if p.is_dir():
|
||||
results.append(p)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse {config_file}: {e}")
|
||||
return results
|
||||
|
||||
|
||||
def read_lora_metadata(metadata_path: Path) -> Optional[Dict]:
|
||||
"""Read and parse a single .metadata.json file.
|
||||
|
||||
Returns:
|
||||
Parsed dict, or None on failure.
|
||||
"""
|
||||
try:
|
||||
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.debug(f"Failed to read {metadata_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _get_civitai(metadata: Dict) -> Dict:
|
||||
"""Safely get the civitai dict, handling None values."""
|
||||
return metadata.get("civitai") or {}
|
||||
|
||||
|
||||
def get_trigger_words_from_metadata(metadata: Dict) -> List[str]:
|
||||
"""Extract trigger words from a parsed LoraManager metadata dict."""
|
||||
civitai = _get_civitai(metadata)
|
||||
words = civitai.get("trainedWords", [])
|
||||
if isinstance(words, list):
|
||||
return [w.strip() for w in words if isinstance(w, str) and w.strip()]
|
||||
return []
|
||||
|
||||
|
||||
def get_example_prompt_from_metadata(metadata: Dict) -> Optional[str]:
|
||||
"""Extract an example prompt from civitai image metadata.
|
||||
|
||||
Looks at civitai.images[].meta.prompt for the first available example.
|
||||
"""
|
||||
civitai = _get_civitai(metadata)
|
||||
images = civitai.get("images", []) or []
|
||||
for img in images:
|
||||
if not isinstance(img, dict):
|
||||
continue
|
||||
meta = img.get("meta")
|
||||
if isinstance(meta, dict):
|
||||
prompt = meta.get("prompt", "")
|
||||
if isinstance(prompt, str) and prompt.strip():
|
||||
return prompt.strip()
|
||||
return None
|
||||
|
||||
|
||||
def get_civitai_image_urls(metadata: Dict) -> List[str]:
|
||||
"""Extract all civitai example image URLs from metadata."""
|
||||
civitai = _get_civitai(metadata)
|
||||
urls = []
|
||||
for img in civitai.get("images", []) or []:
|
||||
if not isinstance(img, dict):
|
||||
continue
|
||||
url = img.get("url", "")
|
||||
if isinstance(url, str) and url.strip():
|
||||
urls.append(url.strip())
|
||||
return urls
|
||||
|
||||
|
||||
def get_model_name_from_metadata(metadata: Dict) -> str:
|
||||
"""Extract the model display name from metadata."""
|
||||
name = metadata.get("model_name", "")
|
||||
if not name:
|
||||
civitai = _get_civitai(metadata)
|
||||
model = civitai.get("model") or {}
|
||||
name = model.get("name", "")
|
||||
if not name:
|
||||
name = metadata.get("file_name", "unknown")
|
||||
return name
|
||||
|
||||
|
||||
def get_preview_images_from_metadata(metadata: Dict, metadata_path: Path) -> List[str]:
|
||||
"""Find all local preview/example image paths for a LoRA.
|
||||
|
||||
Returns:
|
||||
List of absolute path strings to image files.
|
||||
"""
|
||||
results = []
|
||||
lora_dir = metadata_path.parent
|
||||
file_name = metadata.get("file_name", "")
|
||||
if not file_name:
|
||||
stem = metadata_path.name.replace(".metadata.json", "")
|
||||
file_name = stem
|
||||
|
||||
base_name = Path(file_name).stem
|
||||
|
||||
# Check standard preview naming conventions
|
||||
for ext in (
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".preview.png",
|
||||
".preview.jpg",
|
||||
".preview.jpeg",
|
||||
):
|
||||
candidate = lora_dir / f"{base_name}{ext}"
|
||||
if candidate.exists():
|
||||
results.append(str(candidate.resolve()))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_preview_image_from_metadata(
|
||||
metadata: Dict, metadata_path: Path
|
||||
) -> Optional[str]:
|
||||
"""Find the first preview image path for a LoRA (backward compat)."""
|
||||
images = get_preview_images_from_metadata(metadata, metadata_path)
|
||||
return images[0] if images else None
|
||||
|
||||
|
||||
_THUMB_MAX_SIZE = 512
|
||||
|
||||
# Only these hosts may ever receive the CivitAI API key, and only over HTTPS.
|
||||
CIVITAI_HOSTS = frozenset(
|
||||
{"civitai.com", "www.civitai.com", "api.civitai.com", "image.civitai.com"}
|
||||
)
|
||||
MAX_CIVITAI_DOWNLOAD_BYTES = 50 * 1024 * 1024
|
||||
_DOWNLOAD_CHUNK_BYTES = 64 * 1024
|
||||
|
||||
|
||||
class _RefuseRedirects(urllib.request.HTTPRedirectHandler):
|
||||
"""Never follow a redirect: urllib would re-send the Bearer header to it."""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
logger.warning(f"Refusing redirect from {req.full_url} to {newurl}")
|
||||
return None
|
||||
|
||||
|
||||
def _build_opener(*handlers) -> urllib.request.OpenerDirector:
|
||||
"""Build the outbound opener; extra handlers let tests serve canned responses."""
|
||||
return urllib.request.build_opener(_RefuseRedirects, *handlers)
|
||||
|
||||
|
||||
_opener = _build_opener()
|
||||
|
||||
|
||||
def _open_url(req: urllib.request.Request, timeout: float = 10):
|
||||
"""Open ``req`` through the redirect-refusing opener."""
|
||||
return _opener.open(req, timeout=timeout)
|
||||
|
||||
|
||||
def is_civitai_url(url: str) -> bool:
|
||||
"""True when ``url`` is an HTTPS URL on an allow-listed CivitAI host."""
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
port = parts.port
|
||||
except ValueError:
|
||||
return False
|
||||
hostname = (parts.hostname or "").lower()
|
||||
return (
|
||||
parts.scheme == "https"
|
||||
and hostname in CIVITAI_HOSTS
|
||||
and parts.username is None
|
||||
and parts.password is None
|
||||
and port in (None, 443)
|
||||
)
|
||||
|
||||
|
||||
def _read_capped(resp, cap: int) -> Optional[bytes]:
|
||||
"""Read a response body, refusing anything larger than ``cap`` bytes."""
|
||||
declared = resp.headers.get("Content-Length")
|
||||
if declared is not None:
|
||||
try:
|
||||
if int(declared) > cap:
|
||||
return None
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
chunks = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = resp.read(_DOWNLOAD_CHUNK_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > cap:
|
||||
return None
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _download_one(url: str, local_path: Path, api_key: str) -> Optional[str]:
|
||||
"""Download a single CivitAI image, resize to thumbnail, save as JPEG.
|
||||
|
||||
Refuses any URL that is not HTTPS on an allow-listed CivitAI host so the
|
||||
API key is never sent elsewhere, and refuses bodies larger than
|
||||
MAX_CIVITAI_DOWNLOAD_BYTES.
|
||||
"""
|
||||
if not is_civitai_url(url):
|
||||
logger.warning(f"Refusing non-CivitAI download URL: {url}")
|
||||
return None
|
||||
try:
|
||||
headers = {"User-Agent": "ComfyUI-PromptManager/1.0"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with _open_url(req, timeout=10) as resp:
|
||||
raw = _read_capped(resp, MAX_CIVITAI_DOWNLOAD_BYTES)
|
||||
if raw is None:
|
||||
logger.warning(f"Refusing oversized download: {url}")
|
||||
return None
|
||||
|
||||
# Resize to thumbnail to save disk space
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(BytesIO(raw))
|
||||
img.thumbnail((_THUMB_MAX_SIZE, _THUMB_MAX_SIZE), Image.LANCZOS)
|
||||
img = img.convert("RGB")
|
||||
img.save(str(local_path), "JPEG", quality=85)
|
||||
|
||||
return str(local_path.resolve())
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to download {url}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def download_civitai_images(
|
||||
metadata: Dict, metadata_path: Path, cache_dir: Path, api_key: str = ""
|
||||
) -> List[str]:
|
||||
"""Download civitai example images to a local cache directory.
|
||||
|
||||
Uses thumbnail URLs (512px) instead of full-size originals, and
|
||||
downloads in parallel (up to 8 concurrent) for speed.
|
||||
|
||||
Args:
|
||||
api_key: CivitAI API key for authenticated downloads (NSFW content).
|
||||
|
||||
Returns:
|
||||
List of absolute paths to downloaded image files.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
civitai = _get_civitai(metadata)
|
||||
images = civitai.get("images", []) or []
|
||||
if not images:
|
||||
return []
|
||||
|
||||
file_name = metadata.get("file_name", "")
|
||||
if not file_name:
|
||||
file_name = metadata_path.name.replace(".metadata.json", "")
|
||||
lora_stem = Path(file_name).stem
|
||||
|
||||
lora_cache = cache_dir / lora_stem
|
||||
lora_cache.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build download tasks
|
||||
cached = []
|
||||
tasks = [] # (url, local_path)
|
||||
for img in images:
|
||||
if not isinstance(img, dict):
|
||||
continue
|
||||
url = img.get("url", "")
|
||||
if not isinstance(url, str) or not is_civitai_url(url):
|
||||
continue
|
||||
|
||||
url_hash = hashlib.md5(url.encode()).hexdigest()[:12]
|
||||
local_path = lora_cache / f"{url_hash}.jpg"
|
||||
|
||||
if local_path.exists():
|
||||
cached.append(str(local_path.resolve()))
|
||||
else:
|
||||
tasks.append((url, local_path))
|
||||
|
||||
if not tasks:
|
||||
return cached
|
||||
|
||||
# Download in parallel
|
||||
downloaded = []
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
futures = [
|
||||
pool.submit(_download_one, url, path, api_key) for url, path in tasks
|
||||
]
|
||||
for fut in futures:
|
||||
result = fut.result()
|
||||
if result:
|
||||
downloaded.append(result)
|
||||
|
||||
return cached + downloaded
|
||||
|
||||
|
||||
def get_lora_image_cache_dir() -> Path:
|
||||
"""Get the directory used to cache downloaded LoRA example images."""
|
||||
# Store in the extension's own directory
|
||||
ext_root = Path(__file__).resolve().parent.parent
|
||||
cache = ext_root / "data" / "lora_images"
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
return cache
|
||||
|
||||
|
||||
def get_example_images_dir(lora_manager_path: str) -> Optional[str]:
|
||||
"""Find the LoraManager example_images directory."""
|
||||
lm_path = Path(lora_manager_path)
|
||||
|
||||
# Direct subdirectory
|
||||
candidate = lm_path / "example_images"
|
||||
if candidate.is_dir():
|
||||
return str(candidate.resolve())
|
||||
|
||||
# Search one level in user data dirs
|
||||
for child in lm_path.iterdir():
|
||||
if child.is_dir():
|
||||
sub = child / "example_images"
|
||||
if sub.is_dir():
|
||||
return str(sub.resolve())
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ── Trigger word cache & injection ───────────────────────────────────
|
||||
|
||||
_LORA_PATTERN = re.compile(r"<lora:([^:>]+):[^>]+>", re.IGNORECASE)
|
||||
|
||||
|
||||
class TriggerWordCache:
|
||||
"""Thread-safe cache mapping LoRA names to their trigger words.
|
||||
|
||||
Built lazily on first access, refreshable on demand.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._cache: Dict[str, List[str]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._loaded = False
|
||||
|
||||
def load(self, lora_manager_path: str) -> int:
|
||||
"""Scan LoRA metadata files and build the trigger word mapping.
|
||||
|
||||
Returns:
|
||||
Number of LoRAs with trigger words found.
|
||||
"""
|
||||
new_cache: Dict[str, List[str]] = {}
|
||||
|
||||
lora_dirs = find_lora_directories(lora_manager_path)
|
||||
for lora_dir in lora_dirs:
|
||||
dir_path = Path(lora_dir)
|
||||
for meta_file in dir_path.rglob("*.metadata.json"):
|
||||
metadata = read_lora_metadata(meta_file)
|
||||
if not metadata:
|
||||
continue
|
||||
|
||||
words = get_trigger_words_from_metadata(metadata)
|
||||
if not words:
|
||||
continue
|
||||
|
||||
# Key by filename stem (what appears in <lora:NAME:weight>)
|
||||
file_name = metadata.get("file_name", "")
|
||||
if file_name:
|
||||
stem = Path(file_name).stem
|
||||
new_cache[stem.lower()] = words
|
||||
|
||||
# Also key by the metadata file stem
|
||||
meta_stem = meta_file.name.replace(".metadata.json", "")
|
||||
if meta_stem.lower() not in new_cache:
|
||||
new_cache[meta_stem.lower()] = words
|
||||
|
||||
with self._lock:
|
||||
self._cache = new_cache
|
||||
self._loaded = True
|
||||
|
||||
logger.info(
|
||||
f"Trigger word cache loaded: {len(new_cache)} LoRAs with trigger words"
|
||||
)
|
||||
return len(new_cache)
|
||||
|
||||
def get_trigger_words(self, lora_name: str) -> List[str]:
|
||||
"""Look up trigger words for a LoRA by name (case-insensitive)."""
|
||||
with self._lock:
|
||||
return self._cache.get(lora_name.lower(), [])
|
||||
|
||||
@property
|
||||
def is_loaded(self) -> bool:
|
||||
with self._lock:
|
||||
return self._loaded
|
||||
|
||||
def clear(self):
|
||||
with self._lock:
|
||||
self._cache.clear()
|
||||
self._loaded = False
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
_trigger_cache = TriggerWordCache()
|
||||
|
||||
|
||||
def get_trigger_cache() -> TriggerWordCache:
|
||||
return _trigger_cache
|
||||
|
||||
|
||||
def inject_trigger_words(text: str, cache: TriggerWordCache) -> Tuple[str, List[str]]:
|
||||
"""Scan text for <lora:NAME:WEIGHT> tags and append trigger words.
|
||||
|
||||
Args:
|
||||
text: The prompt text potentially containing lora tags.
|
||||
cache: Populated TriggerWordCache instance.
|
||||
|
||||
Returns:
|
||||
Tuple of (modified_text, list_of_injected_words).
|
||||
If no trigger words found, returns the original text unchanged.
|
||||
"""
|
||||
if not cache.is_loaded:
|
||||
return text, []
|
||||
|
||||
matches = _LORA_PATTERN.findall(text)
|
||||
if not matches:
|
||||
return text, []
|
||||
|
||||
all_words = []
|
||||
for lora_name in matches:
|
||||
words = cache.get_trigger_words(lora_name)
|
||||
for w in words:
|
||||
if w.lower() not in text.lower() and w not in all_words:
|
||||
all_words.append(w)
|
||||
|
||||
if not all_words:
|
||||
return text, []
|
||||
|
||||
injected = ", ".join(all_words)
|
||||
return f"{text}, {injected}", all_words
|
||||
+14
-2
@@ -1,9 +1,14 @@
|
||||
[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.0.21"
|
||||
version = "3.3.1"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = ["# Core dependencies for PromptManager", "# Note: Most dependencies are already included with ComfyUI", "# Already included with Python standard library:", "# - sqlite3", "# - hashlib", "# - json", "# - datetime", "# - os", "# - typing", "# - threading", "# - uuid", "# Required for gallery functionality:", "watchdog>=2.1.0 # For file system monitoring", "Pillow>=8.0.0 # For image metadata extraction (usually included with ComfyUI)", "# Optional dependencies for enhanced search functionality:", "# fuzzywuzzy[speedup]>=0.18.0 # For fuzzy string matching (optional)", "# sqlalchemy>=1.4.0 # For advanced ORM features (optional)", "# Development dependencies (optional):", "# pytest>=6.0.0 # For running tests", "# black>=22.0.0 # For code formatting", "# flake8>=4.0.0 # For linting", "# mypy>=0.910 # For type checking"]
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=84.0.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/ComfyAssets/ComfyUI_PromptManager"
|
||||
@@ -13,3 +18,10 @@ Repository = "https://github.com/ComfyAssets/ComfyUI_PromptManager"
|
||||
PublisherId = "kiko9"
|
||||
DisplayName = "ComfyUI_PromptManager"
|
||||
Icon = "https://avatars.githubusercontent.com/u/213204677?s=200"
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ["py310"]
|
||||
|
||||
[tool.bandit]
|
||||
exclude_dirs = ["tests", "reference"]
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Runtime dependencies
|
||||
watchdog>=6.0.0
|
||||
Pillow>=12.3.0
|
||||
aiohttp>=3.14.5
|
||||
|
||||
# Tests and coverage
|
||||
coverage>=7.16.2
|
||||
numpy>=2.2.6 # WD14 engine tests
|
||||
|
||||
# Code quality
|
||||
black>=26.10.1
|
||||
flake8>=7.4.1
|
||||
|
||||
# Security scanning
|
||||
bandit[toml]>=1.9.4
|
||||
|
||||
# Pre-commit hooks
|
||||
pre-commit>=4.6.2
|
||||
+4
-26
@@ -1,26 +1,4 @@
|
||||
# Core dependencies for PromptManager
|
||||
# Note: Most dependencies are already included with ComfyUI
|
||||
|
||||
# Already included with Python standard library:
|
||||
# - sqlite3
|
||||
# - hashlib
|
||||
# - json
|
||||
# - datetime
|
||||
# - os
|
||||
# - typing
|
||||
# - threading
|
||||
# - uuid
|
||||
|
||||
# Required for gallery functionality:
|
||||
watchdog>=2.1.0 # For file system monitoring
|
||||
Pillow>=8.0.0 # For image metadata extraction (usually included with ComfyUI)
|
||||
|
||||
# Optional dependencies for enhanced search functionality:
|
||||
# fuzzywuzzy[speedup]>=0.18.0 # For fuzzy string matching (optional)
|
||||
# sqlalchemy>=1.4.0 # For advanced ORM features (optional)
|
||||
|
||||
# Development dependencies (optional):
|
||||
# pytest>=6.0.0 # For running tests
|
||||
# black>=22.0.0 # For code formatting
|
||||
# flake8>=4.0.0 # For linting
|
||||
# mypy>=0.910 # For type checking
|
||||
# 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
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ComfyUI_PromptManager Gallery System Restart Script.
|
||||
|
||||
This script reinitializes and restarts the automatic image gallery monitoring system
|
||||
for ComfyUI_PromptManager. Use this script after code modifications or when the
|
||||
gallery system needs to be reloaded.
|
||||
|
||||
The script performs the following operations:
|
||||
1. Initializes the database connection
|
||||
2. Creates a PromptTracker instance for linking prompts to images
|
||||
3. Sets up the ImageMonitor for real-time file system monitoring
|
||||
4. Starts monitoring ComfyUI output directories
|
||||
5. Reports system status and readiness
|
||||
|
||||
Usage:
|
||||
python restart_gallery.py
|
||||
|
||||
This is particularly useful during development when you need to restart the
|
||||
gallery monitoring system without restarting the entire ComfyUI application.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add current directory to Python path
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, current_dir)
|
||||
|
||||
try:
|
||||
# Import and initialize the gallery system
|
||||
from database.operations import PromptDatabase
|
||||
from utils.prompt_tracker import PromptTracker
|
||||
from utils.image_monitor import ImageMonitor
|
||||
|
||||
print("[RESTART] Restarting PromptManager Gallery System...")
|
||||
|
||||
# Initialize components
|
||||
db = PromptDatabase()
|
||||
tracker = PromptTracker(db)
|
||||
monitor = ImageMonitor(db, tracker)
|
||||
|
||||
print("[SUCCESS] Components initialized successfully")
|
||||
|
||||
# Test image monitoring directories
|
||||
status = monitor.get_status()
|
||||
print(f"[INFO] Monitoring status: {status}")
|
||||
|
||||
# Start monitoring
|
||||
monitor.start_monitoring()
|
||||
print("[SUCCESS] Image monitoring restarted")
|
||||
|
||||
print("\n[READY] Gallery system restart completed!")
|
||||
print("Generate some images now to test the automatic linking.")
|
||||
|
||||
except ImportError as e:
|
||||
print(f"[ERROR] Import error: {e}")
|
||||
print("Make sure you're running this from the PromptManager directory")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
+1
-1
@@ -3,4 +3,4 @@ Test suite for ComfyUI_PromptManager.
|
||||
|
||||
This package contains unit tests and integration tests for the PromptManager
|
||||
custom nodes, database operations, utility functions, and web interface components.
|
||||
"""
|
||||
"""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Browser workflow fixtures; never imported by production code."""
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Serve production pages and API routes with disposable state for browser tests."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import types
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
|
||||
class TestModel:
|
||||
default_prompt = "Describe the image"
|
||||
wd14_general_threshold = 0.35
|
||||
wd14_character_threshold = 0.85
|
||||
custom_prompt = ""
|
||||
loaded = None
|
||||
|
||||
def get_models_status(self) -> dict:
|
||||
return {
|
||||
name: {"downloaded": True, "loaded": self.loaded == name}
|
||||
for name in ("gguf", "wd14-vit", "wd14-convnext", "wd14-swinv2")
|
||||
}
|
||||
|
||||
def is_model_loaded(self) -> bool:
|
||||
return self.loaded is not None
|
||||
|
||||
def get_loaded_model_type(self) -> str | None:
|
||||
return self.loaded
|
||||
|
||||
def load_model(self, model_type: str, use_gpu: bool = True) -> bool:
|
||||
self.loaded = model_type
|
||||
return True
|
||||
|
||||
def unload_model(self) -> None:
|
||||
self.loaded = None
|
||||
|
||||
def generate_tags(self, image_path: str, **kwargs: object) -> list[str]:
|
||||
return ["generated", Path(image_path).parent.parent.name]
|
||||
|
||||
|
||||
def create_app(root: Path) -> web.Application:
|
||||
os.environ["PROMPT_MANAGER_CONFIG_PATH"] = str(root / "config.json")
|
||||
os.chdir(root)
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
server = types.ModuleType("server")
|
||||
server.PromptServer = types.SimpleNamespace(
|
||||
instance=types.SimpleNamespace(app=app, routes=routes)
|
||||
)
|
||||
sys.modules["server"] = server
|
||||
folder_paths = types.ModuleType("folder_paths")
|
||||
folder_paths.base_path = str(root)
|
||||
folder_paths.models_dir = str(root / "models")
|
||||
for name in ("output", "input", "temp", "user"):
|
||||
directory = root / name
|
||||
directory.mkdir()
|
||||
setattr(folder_paths, f"get_{name}_directory", lambda path=directory: str(path))
|
||||
sys.modules["folder_paths"] = folder_paths
|
||||
|
||||
from py.config import GalleryConfig, PromptManagerConfig
|
||||
|
||||
PromptManagerConfig.DEFAULT_DB_PATH = str(root / "browser.db")
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [str(root / "output"), str(root / "input")]
|
||||
|
||||
from py.api import PromptManagerAPI
|
||||
from py import autotag
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
model = TestModel()
|
||||
autotag.get_autotag_service = lambda: model
|
||||
api = PromptManagerAPI()
|
||||
api.add_routes(routes)
|
||||
|
||||
def seed(extra_images: int = 0) -> None:
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in ("generated_images", "prompt_tags", "tags", "prompts"):
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
for name in ("output", "input"):
|
||||
shutil.rmtree(root / name)
|
||||
(root / name).mkdir()
|
||||
for index, (directory, text, tag) in enumerate(
|
||||
[("output", "Cat portrait", "cats"), ("input", "Dog landscape", "dogs")]
|
||||
):
|
||||
image_path = root / directory / "shared" / "same.png"
|
||||
image_path.parent.mkdir(exist_ok=True)
|
||||
info = PngInfo()
|
||||
info.add_text(
|
||||
"prompt",
|
||||
json.dumps(
|
||||
{"1": {"class_type": "CLIPTextEncode", "inputs": {"text": text}}}
|
||||
),
|
||||
)
|
||||
with Image.new("RGB", (64, 64), (index * 255, 64, 128)) as image:
|
||||
image.save(image_path, pnginfo=info)
|
||||
stamp = time.time() - index * 10
|
||||
os.utime(image_path, (stamp, stamp))
|
||||
pid = api.db.save_prompt(
|
||||
text, tags=[tag], prompt_hash=generate_prompt_hash(text)
|
||||
)
|
||||
api.db.link_image_to_prompt(pid, str(image_path))
|
||||
if index == 0:
|
||||
for number in range(extra_images):
|
||||
extra = root / directory / "bulk" / f"{number:04}.png"
|
||||
extra.parent.mkdir(exist_ok=True)
|
||||
shutil.copyfile(image_path, extra)
|
||||
api.db.link_image_to_prompt(pid, str(extra))
|
||||
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
|
||||
api.invalidate_gallery_cache()
|
||||
|
||||
@routes.post("/__test__/reset")
|
||||
async def reset(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
extra = data.get("extra_images", 0)
|
||||
if (
|
||||
isinstance(extra, bool)
|
||||
or not isinstance(extra, int)
|
||||
or not 0 <= extra <= 200
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
await api._run_in_executor(seed, extra)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.get("/api/settings/Comfy.ColorPalette")
|
||||
async def palette(request: web.Request) -> web.Response:
|
||||
return web.json_response("dark")
|
||||
|
||||
async def cleanup(application: web.Application) -> None:
|
||||
api.db.close_all()
|
||||
|
||||
seed()
|
||||
app.on_cleanup.append(cleanup)
|
||||
app.add_routes(routes)
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
args = parser.parse_args()
|
||||
with tempfile.TemporaryDirectory(prefix="pm-browser-") as directory:
|
||||
web.run_app(
|
||||
create_app(Path(directory).resolve()), host="127.0.0.1", port=args.port
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,141 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
|
||||
test.beforeEach(async ({ request, page, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
page.workflowErrors = [];
|
||||
page.on('pageerror', error => page.workflowErrors.push(error.message));
|
||||
page.on('response', response => {
|
||||
if (response.status() >= 500 && response.url().includes('/prompt_manager/')) {
|
||||
page.workflowErrors.push(`${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
await page.addInitScript(() => { localStorage.setItem('pm_update_notice_dismissed', '3.2.0'); });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.workflowErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('actual gallery form saves a prompt that the admin page can edit and reload', async ({ page, request }) => {
|
||||
await page.goto('/prompt_manager/gallery');
|
||||
await page.locator('#addPromptBtn').click();
|
||||
await page.locator('#addPromptText').fill('Browser saved landscape');
|
||||
await page.locator('#addPromptCategory').fill('Browser tests');
|
||||
await page.locator('#addPromptTagInput').fill('browser-tag');
|
||||
await page.locator('#addPromptTagInput').press('Enter');
|
||||
await page.locator('#saveNewPromptBtn').click();
|
||||
await expect(page.locator('#addPromptModal')).toBeHidden();
|
||||
await page.goto('/prompt_manager/admin');
|
||||
const text = page.locator('.prompt-text').filter({ hasText: 'Browser saved landscape' });
|
||||
await expect(text).toBeVisible();
|
||||
const id = await text.getAttribute('data-id');
|
||||
await page.locator(`[data-action="edit"][data-prompt-id="${id}"]`).click();
|
||||
const editor = page.locator(`.prompt-text[data-id="${id}"]`);
|
||||
await editor.fill('Browser edited portrait');
|
||||
await editor.press('Enter');
|
||||
await expect(page.locator('.prompt-text').filter({ hasText: 'Browser edited portrait' })).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page.locator('.prompt-text').filter({ hasText: 'Browser edited portrait' })).toBeVisible();
|
||||
const saved = await request.post('/prompt_manager/save', { data: { text: 'Browser saved landscape' } });
|
||||
expect((await saved.json()).prompt_id).not.toBe(Number(id));
|
||||
});
|
||||
|
||||
test('actual gallery review generates and persists tags for the selected root', async ({ page, request }) => {
|
||||
await page.goto('/prompt_manager/gallery');
|
||||
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
|
||||
await page.locator('#autoTagBtn').click();
|
||||
const generated = page.waitForResponse(response => response.url().endsWith('/prompt_manager/autotag/single'));
|
||||
await page.locator('#startReviewBtn').click();
|
||||
const generation = await generated;
|
||||
expect(generation.request().postDataJSON().root_index).toBe(0);
|
||||
await expect(page.locator('#reviewTagsContainer')).toContainText('generated');
|
||||
const result = page.waitForResponse(response => response.url().endsWith('/prompt_manager/autotag/apply'));
|
||||
await page.locator('#applyReviewBtn').click();
|
||||
expect((await (await result).json()).success).toBe(true);
|
||||
const data = await (await request.get('/prompt_manager/recent?limit=20')).json();
|
||||
expect(data.results.find(prompt => prompt.text === 'Cat portrait').tags).toContain('generated');
|
||||
expect(data.results.find(prompt => prompt.text === 'Dog landscape').tags).not.toContain('generated');
|
||||
});
|
||||
|
||||
test('actual Tags page reloads the selected filter and opens its image viewer', async ({ page }) => {
|
||||
await page.goto('/prompt_manager/admin#/tags');
|
||||
await page.locator('[data-tag-name="cats"]').click();
|
||||
await expect(page.locator('#tagPromptsGrid')).toContainText('Cat portrait');
|
||||
await page.locator('[data-tag-name="dogs"]').click();
|
||||
await expect(page.locator('#tagPromptsGrid')).toContainText('Dog landscape');
|
||||
await expect(page.locator('#tagPromptsGrid')).not.toContainText('Cat portrait');
|
||||
await page.locator('#tagPromptsGrid img').first().click();
|
||||
await expect(page.locator('.viewer-canvas img')).toBeVisible();
|
||||
await page.locator('.viewer-container').getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await expect(page.locator('.viewer-container')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('actual gallery serves colliding filenames and shows the matching prompt', async ({ page }) => {
|
||||
await page.goto('/prompt_manager/gallery');
|
||||
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
|
||||
const images = page.locator('#galleryGrid img[data-original]');
|
||||
const first = await images.nth(0).getAttribute('data-original');
|
||||
const second = await images.nth(1).getAttribute('data-original');
|
||||
expect(first).not.toBe(second);
|
||||
await page.locator('#galleryGrid .image-item[data-image-index="0"]').click();
|
||||
await expect(page.locator('#metadata-content')).toContainText('Cat portrait');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(page.locator('#metadata-content')).toContainText('Dog landscape');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#metadata-sidebar')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('an edit collision is rejected by the backend and the page restores the original text', async ({ page }) => {
|
||||
await page.goto('/prompt_manager/admin');
|
||||
const original = page.locator('.prompt-text').filter({ hasText: 'Cat portrait' });
|
||||
const id = await original.getAttribute('data-id');
|
||||
await page.locator(`[data-action="edit"][data-prompt-id="${id}"]`).click();
|
||||
const editor = page.locator(`.prompt-text[data-id="${id}"]`);
|
||||
await editor.fill('Dog landscape');
|
||||
const rejected = page.waitForResponse(response => response.request().method() === 'PUT' && response.url().endsWith(`/prompts/${id}`));
|
||||
await editor.press('Enter');
|
||||
expect((await rejected).status()).toBe(409);
|
||||
await expect(editor).toHaveText('Cat portrait');
|
||||
await page.reload();
|
||||
await expect(page.locator(`.prompt-text[data-id="${id}"]`)).toHaveText('Cat portrait');
|
||||
});
|
||||
|
||||
test('a delayed real Tags response cannot replace a newer selection', async ({ page }) => {
|
||||
let release;
|
||||
const delayed = new Promise(resolve => { release = resolve; });
|
||||
let received;
|
||||
const waiting = new Promise(resolve => { received = resolve; });
|
||||
await page.route('**/prompt_manager/tags/cats/prompts?*', async route => {
|
||||
const response = await route.fetch();
|
||||
received();
|
||||
await delayed;
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
await page.goto('/prompt_manager/admin#/tags');
|
||||
await page.locator('[data-tag-name="cats"]').click();
|
||||
await waiting;
|
||||
await page.locator('[data-tag-name="dogs"]').click();
|
||||
await expect(page.locator('#tagPromptsGrid')).toContainText('Dog landscape');
|
||||
const completed = page.waitForResponse(response => response.url().includes('/tags/cats/prompts?'));
|
||||
release();
|
||||
await completed;
|
||||
await expect(page.locator('#tagPromptsGrid')).not.toContainText('Cat portrait');
|
||||
});
|
||||
|
||||
test('gallery infinite scroll appends a real backend page while retaining existing cards', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/reset', { data: { extra_images: 108 } })).toBeOK();
|
||||
await page.goto('/prompt_manager/gallery');
|
||||
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(100);
|
||||
await page.locator('#settingsBtn').click();
|
||||
await page.locator('#infiniteScrollToggle').check();
|
||||
await page.locator('#saveSettingsBtn').click();
|
||||
await expect(page.locator('#settingsModal')).toBeHidden();
|
||||
const first = page.locator('#galleryGrid .image-item').first();
|
||||
await first.evaluate(element => { element.dataset.preserved = 'yes'; });
|
||||
await page.locator('#galleryScroll').evaluate(element => { element.scrollTop = element.scrollHeight; });
|
||||
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(110);
|
||||
await expect(first).toHaveAttribute('data-preserved', 'yes');
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
function fixture() {
|
||||
const panel = { innerHTML: '', isConnected: true };
|
||||
const requests = new Map();
|
||||
const context = vm.createContext({ document: { readyState: 'loading', addEventListener() {},
|
||||
getElementById: id => id === 'metadata-content' ? panel : null },
|
||||
window: {}, console: { error() {} },
|
||||
});
|
||||
const Admin = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/admin.js'), 'utf8') + '\nPromptAdmin;', context, { filename: path.join(__dirname, '../../web/js/admin.js') });
|
||||
const manager = Object.create(Admin.prototype);
|
||||
manager.extractImageMetadata = url => new Promise((resolve, reject) => requests.set(url, { resolve, reject }));
|
||||
manager.updateMetadataSidebar = metadata => { panel.innerHTML = metadata?.imagePath || 'No metadata'; };
|
||||
manager.hideModal = () => {};
|
||||
const load = url => manager.loadMetadataForImage({ getAttribute: () => url });
|
||||
return { manager, panel, requests, load };
|
||||
}
|
||||
test('admin metadata and copy actions stay on the selected image', async () => {
|
||||
const { manager, panel, requests, load } = fixture();
|
||||
const old = load('old.png');
|
||||
const current = load('current.png');
|
||||
requests.get('current.png').resolve({ imagePath: 'current.png' }); await current;
|
||||
requests.get('old.png').resolve({ imagePath: 'old.png' }); await old;
|
||||
assert.equal(panel.innerHTML, 'current.png');
|
||||
assert.equal(manager.currentMetadata.imagePath, 'current.png');
|
||||
});
|
||||
test('navigation clears old action data and late errors cannot replace current metadata', async () => {
|
||||
const { manager, panel, requests, load } = fixture();
|
||||
manager.currentMetadata = { imagePath: 'previous.png' };
|
||||
const old = load('old.png');
|
||||
assert.equal(manager.currentMetadata, null);
|
||||
const current = load('current.png');
|
||||
requests.get('current.png').resolve({ imagePath: 'current.png' }); await current;
|
||||
requests.get('old.png').reject(new Error('old error')); await old;
|
||||
assert.equal(panel.innerHTML, 'current.png');
|
||||
});
|
||||
for (const close of ['close gallery', 'detach sidebar']) {
|
||||
test(`${close} prevents pending admin metadata from restoring copy state`, async () => {
|
||||
const { manager, panel, requests, load } = fixture();
|
||||
const pending = load('old.png');
|
||||
if (close === 'close gallery') manager.closeGallery();
|
||||
else panel.isConnected = false;
|
||||
requests.get('old.png').resolve({ imagePath: 'old.png' }); await pending;
|
||||
assert.equal(manager.currentMetadata, null);
|
||||
assert.notEqual(panel.innerHTML, 'old.png');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// Run with: node --test tests/js/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { createApiClient, resolveUrl, fetchUrl, ApiError, latestOnly, STALE } = require("../../web/js/api-client.js");
|
||||
|
||||
test("Prompt Manager URLs follow the page's proxy mount without losing query strings", () => {
|
||||
const url = "/prompt_manager/images/output?limit=100&subfolder=2025%2F04%2F02";
|
||||
assert.equal(resolveUrl(url, "/ComfyBackendDirect/prompt_manager/gallery"),
|
||||
"/ComfyBackendDirect" + url);
|
||||
assert.equal(resolveUrl(url, "/prompt_manager/gallery"), url);
|
||||
assert.equal(resolveUrl(url, "/nested/proxy/prompt_manager/admin"), "/nested/proxy" + url);
|
||||
assert.equal(resolveUrl("/other/api?x=1", "/ComfyBackendDirect/prompt_manager/gallery"), "/other/api?x=1");
|
||||
assert.equal(resolveUrl("/ComfyBackendDirect/prompt_manager/images/serve/a.png", "/ComfyBackendDirect/prompt_manager/gallery"),
|
||||
"/ComfyBackendDirect/prompt_manager/images/serve/a.png");
|
||||
assert.equal(resolveUrl("/prompt_manager/images/serve/a.png?root=1", "/ComfyBackendDirect/prompt_manager/gallery"),
|
||||
"/ComfyBackendDirect/prompt_manager/images/serve/a.png?root=1");
|
||||
});
|
||||
|
||||
test("fetchUrl sends Prompt Manager requests under the current proxy mount", async () => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
const previousLocation = globalThis.location;
|
||||
const calls = [];
|
||||
globalThis.location = { pathname: "/ComfyBackendDirect/prompt_manager/admin" };
|
||||
globalThis.fetch = async (url, init) => {
|
||||
calls.push([url, init]);
|
||||
return { ok: true };
|
||||
};
|
||||
try {
|
||||
await fetchUrl("/prompt_manager/settings?show=all", { method: "GET" });
|
||||
assert.deepEqual(calls, [["/ComfyBackendDirect/prompt_manager/settings?show=all", { method: "GET" }]]);
|
||||
} finally {
|
||||
globalThis.fetch = previousFetch;
|
||||
globalThis.location = previousLocation;
|
||||
}
|
||||
});
|
||||
|
||||
/** A fetch stand-in whose responses the test resolves by hand, in any order. */
|
||||
function fakeFetch() {
|
||||
const calls = [];
|
||||
const impl = (url, init = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const call = { url, init, resolve, reject, aborted: false };
|
||||
if (init.signal) {
|
||||
init.signal.addEventListener("abort", () => {
|
||||
call.aborted = true;
|
||||
reject(new DOMException("The operation was aborted.", "AbortError"));
|
||||
});
|
||||
}
|
||||
calls.push(call);
|
||||
});
|
||||
impl.calls = calls;
|
||||
return impl;
|
||||
}
|
||||
|
||||
function jsonResponse(status, body, { invalid = false } = {}) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 200 ? "OK" : `Status ${status}`,
|
||||
json: async () => {
|
||||
if (invalid) throw new SyntaxError("Unexpected token");
|
||||
return body;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("get returns the parsed body of a successful response", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
const pending = api.get("/prompt_manager/recent?page=1");
|
||||
fetch.calls[0].resolve(jsonResponse(200, { success: true, results: [1, 2] }));
|
||||
assert.deepEqual(await pending, { success: true, results: [1, 2] });
|
||||
assert.equal(fetch.calls[0].init.method, "GET");
|
||||
});
|
||||
|
||||
test("get forwards an AbortSignal to fetch", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
const controller = new AbortController();
|
||||
const pending = api.get("/x", { signal: controller.signal });
|
||||
assert.equal(fetch.calls[0].init.signal, controller.signal);
|
||||
controller.abort();
|
||||
await assert.rejects(pending, (err) => err.name === "AbortError");
|
||||
});
|
||||
|
||||
test("non-2xx responses throw ApiError carrying the status and server message", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
const pending = api.get("/x");
|
||||
fetch.calls[0].resolve(jsonResponse(404, { success: false, error: "Prompt not found" }));
|
||||
await assert.rejects(pending, (err) => {
|
||||
assert.ok(err instanceof ApiError);
|
||||
assert.ok(err instanceof Error);
|
||||
assert.equal(err.status, 404);
|
||||
assert.equal(err.message, "Prompt not found");
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test("non-2xx responses without a JSON body fall back to the status text", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
const pending = api.get("/x");
|
||||
fetch.calls[0].resolve(jsonResponse(500, null, { invalid: true }));
|
||||
await assert.rejects(pending, (err) => err.status === 500 && err.message === "Status 500");
|
||||
});
|
||||
|
||||
test("a 2xx body with success:false throws ApiError", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
const pending = api.post("/x", {});
|
||||
fetch.calls[0].resolve(jsonResponse(200, { success: false, error: "Nope" }));
|
||||
await assert.rejects(pending, (err) => err instanceof ApiError && err.status === 200 && err.message === "Nope");
|
||||
});
|
||||
|
||||
test("a 2xx body that is not JSON throws ApiError", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
const pending = api.get("/x");
|
||||
fetch.calls[0].resolve(jsonResponse(200, null, { invalid: true }));
|
||||
await assert.rejects(pending, (err) => err instanceof ApiError && err.status === 200);
|
||||
});
|
||||
|
||||
test("post, put and del send JSON bodies with the right method", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
const p1 = api.post("/a", { text: "x" });
|
||||
const p2 = api.put("/b", { rating: 3 });
|
||||
const p3 = api.del("/c");
|
||||
assert.equal(fetch.calls[0].init.method, "POST");
|
||||
assert.equal(fetch.calls[0].init.body, JSON.stringify({ text: "x" }));
|
||||
assert.equal(fetch.calls[0].init.headers["Content-Type"], "application/json");
|
||||
assert.equal(fetch.calls[1].init.method, "PUT");
|
||||
assert.equal(fetch.calls[1].init.body, JSON.stringify({ rating: 3 }));
|
||||
assert.equal(fetch.calls[2].init.method, "DELETE");
|
||||
assert.equal(fetch.calls[2].init.body, undefined);
|
||||
for (const c of fetch.calls) c.resolve(jsonResponse(200, { success: true }));
|
||||
await Promise.all([p1, p2, p3]);
|
||||
});
|
||||
|
||||
test("network failures propagate as ordinary errors", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
const pending = api.get("/x");
|
||||
fetch.calls[0].reject(new TypeError("Failed to fetch"));
|
||||
await assert.rejects(pending, (err) => err instanceof TypeError && !(err instanceof ApiError));
|
||||
});
|
||||
|
||||
test("createApiClient without an argument uses the global fetch", async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = async () => jsonResponse(200, { success: true, via: "global" });
|
||||
try {
|
||||
assert.deepEqual(await createApiClient().get("/x"), { success: true, via: "global" });
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("latestOnly: a newer call aborts the older one and the older result is STALE", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
const latest = latestOnly();
|
||||
|
||||
const first = latest((signal) => api.get("/search?q=a", { signal }));
|
||||
const second = latest((signal) => api.get("/search?q=ab", { signal }));
|
||||
|
||||
assert.equal(fetch.calls[0].aborted, true, "first request aborted");
|
||||
assert.equal(fetch.calls[1].aborted, false);
|
||||
|
||||
fetch.calls[1].resolve(jsonResponse(200, { success: true, q: "ab" }));
|
||||
assert.deepEqual(await second, { success: true, q: "ab" });
|
||||
assert.equal(await first, STALE);
|
||||
});
|
||||
|
||||
test("latestOnly: an older response that arrives after a newer request is STALE even without abort", async () => {
|
||||
const latest = latestOnly();
|
||||
let resolveFirst;
|
||||
const first = latest(() => new Promise((r) => { resolveFirst = r; }));
|
||||
const second = latest(async () => "new");
|
||||
assert.equal(await second, "new");
|
||||
resolveFirst("old");
|
||||
assert.equal(await first, STALE);
|
||||
});
|
||||
|
||||
test("latestOnly: sequential calls each return their own result", async () => {
|
||||
const latest = latestOnly();
|
||||
assert.equal(await latest(async () => 1), 1);
|
||||
assert.equal(await latest(async () => 2), 2);
|
||||
});
|
||||
|
||||
test("latestOnly: errors from the current call propagate, errors from superseded calls do not", async () => {
|
||||
const latest = latestOnly();
|
||||
let rejectFirst;
|
||||
const first = latest(() => new Promise((_, rej) => { rejectFirst = rej; }));
|
||||
const second = latest(async () => { throw new Error("boom"); });
|
||||
await assert.rejects(second, /boom/);
|
||||
rejectFirst(new Error("old failure"));
|
||||
assert.equal(await first, STALE);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// Run with: node --test tests/js/
|
||||
// Each page module is a UMD-style IIFE: CommonJS export under Node, a window
|
||||
// global in the browser. This loads every module the way a browser would and
|
||||
// checks the global name the pages rely on.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const MODULES = {
|
||||
"html-escape.js": ["HtmlEscape", "escapeHtml", "escapeAttr"],
|
||||
"data-actions.js": ["DataActions"],
|
||||
"api-client.js": ["ApiClient"],
|
||||
"list-state.js": ["ListState"],
|
||||
"sse-stream.js": ["SseStream"],
|
||||
"png-metadata.js": ["PngMetadata"],
|
||||
"view-router.js": ["ViewRouter"],
|
||||
"prompt-list-sort.js": ["PromptListSort"],
|
||||
"comfy-metadata.js": ["ComfyMetadata"],
|
||||
};
|
||||
|
||||
function loadInFakeBrowser(file) {
|
||||
const source = fs.readFileSync(path.join(__dirname, "../../web/js", file), "utf8");
|
||||
const window = {};
|
||||
window.window = window;
|
||||
const context = vm.createContext({
|
||||
window,
|
||||
globalThis: window,
|
||||
console,
|
||||
TextDecoder,
|
||||
TextEncoder,
|
||||
AbortController,
|
||||
URLSearchParams,
|
||||
Symbol,
|
||||
Object,
|
||||
Array,
|
||||
Number,
|
||||
String,
|
||||
Math,
|
||||
Set,
|
||||
Map,
|
||||
JSON,
|
||||
Error,
|
||||
Promise,
|
||||
DataView,
|
||||
Uint8Array,
|
||||
ArrayBuffer,
|
||||
});
|
||||
vm.runInContext(source, context, { filename: file });
|
||||
return window;
|
||||
}
|
||||
|
||||
for (const [file, globals] of Object.entries(MODULES)) {
|
||||
test(`${file} exposes ${globals.join(", ")} on window when module is undefined`, () => {
|
||||
const window = loadInFakeBrowser(file);
|
||||
for (const name of globals) {
|
||||
assert.ok(window[name], `${name} missing`);
|
||||
assert.ok(["object", "function"].includes(typeof window[name]), `${name} is a ${typeof window[name]}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("the browser escapeHtml global is the same function as HtmlEscape.escapeHtml", () => {
|
||||
const window = loadInFakeBrowser("html-escape.js");
|
||||
assert.equal(window.escapeHtml, window.HtmlEscape.escapeHtml);
|
||||
assert.equal(window.escapeAttr, window.escapeHtml);
|
||||
assert.equal(window.escapeHtml(`<a href="x">`), "<a href="x">");
|
||||
});
|
||||
|
||||
test("the browser ViewRouter global resolves the tags deep link", () => {
|
||||
const window = loadInFakeBrowser("view-router.js");
|
||||
assert.equal(window.ViewRouter.resolveView("#/tags/a"), "tags");
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
// Run with: node --test tests/js/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { extractGenerationParams } = require("../../web/js/comfy-metadata.js");
|
||||
|
||||
// Same shape as the workflow attached to #75: PromptManager nodes are saved as
|
||||
// CLIPTextEncode with extra inputs, and the checkpoint loader is pysssss'.
|
||||
const issue75Prompt = {
|
||||
1: {
|
||||
class_type: "KSampler",
|
||||
inputs: {
|
||||
seed: 480655185510613, steps: 25, cfg: 5.5,
|
||||
sampler_name: "euler_ancestral", scheduler: "normal", denoise: 0.8,
|
||||
model: ["20", 0], positive: ["39", 0], negative: ["40", 0], latent_image: ["36", 0],
|
||||
},
|
||||
},
|
||||
8: { class_type: "CLIPSetLastLayer", inputs: { stop_at_clip_layer: -2, clip: ["20", 1] } },
|
||||
20: {
|
||||
class_type: "CheckpointLoader|pysssss",
|
||||
inputs: { ckpt_name: "Anime\\Model-XL.safetensors", prompt: "[none]", example: "[none]" },
|
||||
},
|
||||
39: {
|
||||
class_type: "CLIPTextEncode",
|
||||
inputs: {
|
||||
text: "1girl, cat_ears, smile", category: "", tags: "", search_text: "",
|
||||
prepend_text: "masterpiece, best quality, ", append_text: "", clip: ["8", 0],
|
||||
},
|
||||
},
|
||||
40: {
|
||||
class_type: "CLIPTextEncode",
|
||||
inputs: {
|
||||
text: "3d, crowd", category: "", tags: "", search_text: "",
|
||||
prepend_text: "bad quality, worst quality, ", append_text: "", clip: ["8", 0],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test("#75: traces PromptManager prompts and pysssss checkpoint from the sampler", () => {
|
||||
const r = extractGenerationParams({ prompt: issue75Prompt });
|
||||
assert.equal(r.positivePrompt, "masterpiece, best quality, 1girl, cat_ears, smile");
|
||||
assert.equal(r.negativePrompt, "bad quality, worst quality, 3d, crowd");
|
||||
assert.equal(r.checkpoint, "Anime\\Model-XL.safetensors");
|
||||
assert.deepEqual(
|
||||
[r.seed, r.steps, r.cfgScale, r.sampler],
|
||||
[480655185510613, 25, 5.5, "euler_ancestral"],
|
||||
);
|
||||
});
|
||||
|
||||
test("#75: workflow blob does not override values from the prompt graph", () => {
|
||||
const workflow = { nodes: [{ type: "KSampler", widgets_values: [1, "randomize", 99, 7, "ddim"] }] };
|
||||
const r = extractGenerationParams({ prompt: issue75Prompt, workflow });
|
||||
assert.equal(r.steps, 25);
|
||||
assert.equal(r.cfgScale, 5.5);
|
||||
});
|
||||
|
||||
test("#75: recovers per-node text when SaveImage patch overwrote every PromptManager node", () => {
|
||||
// Pre-fix images: both nodes carry the final positive text in the prompt graph,
|
||||
// but the workflow graph still has each node's real widget values.
|
||||
const positiveFinal = "masterpiece, best quality, 1girl, smile";
|
||||
const prompt = {
|
||||
1: { class_type: "KSampler", inputs: { seed: 1, steps: 20, cfg: 7, sampler_name: "euler", positive: ["39", 0], negative: ["40", 0], model: ["20", 0] } },
|
||||
20: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } },
|
||||
39: { class_type: "PromptManager", inputs: { text: positiveFinal, prepend_text: "masterpiece, best quality,", append_text: "" } },
|
||||
40: { class_type: "PromptManager", inputs: { text: positiveFinal, prepend_text: "bad quality,", append_text: "" } },
|
||||
};
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: 39, type: "PromptManager", widgets_values: ["1girl, smile", "", "", "", "masterpiece, best quality,", ""] },
|
||||
{ id: 40, type: "PromptManager", widgets_values: ["3d, crowd", "", "", "", "bad quality,", ""] },
|
||||
],
|
||||
};
|
||||
const r = extractGenerationParams({ prompt, workflow });
|
||||
assert.equal(r.positivePrompt, "masterpiece, best quality, 1girl, smile");
|
||||
assert.equal(r.negativePrompt, "bad quality, 3d, crowd");
|
||||
});
|
||||
|
||||
test("current metadata: API text wins over a stale or unexpanded workflow widget", () => {
|
||||
// Dynamic prompt expanded at queue time: API holds what ran, workflow holds the template
|
||||
const prompt = {
|
||||
1: { class_type: "KSampler", inputs: { positive: ["39", 0], negative: ["40", 0], model: ["20", 0] } },
|
||||
20: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } },
|
||||
39: { class_type: "PromptManager", inputs: { text: "a red cat", prepend_text: "", append_text: "" } },
|
||||
40: { class_type: "PromptManager", inputs: { text: "blurry", prepend_text: "", append_text: "" } },
|
||||
};
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: 39, type: "PromptManager", widgets_values: ["a {red|blue} cat", "", "", "", "", ""] },
|
||||
{ id: 40, type: "PromptManager", widgets_values: ["blurry", "", "", "", "", ""] },
|
||||
],
|
||||
};
|
||||
const r = extractGenerationParams({ prompt, workflow });
|
||||
assert.equal(r.positivePrompt, "a red cat");
|
||||
assert.equal(r.negativePrompt, "blurry");
|
||||
});
|
||||
|
||||
test("current metadata: linked text is followed, not replaced by the widget's stale value", () => {
|
||||
const prompt = {
|
||||
1: { class_type: "KSampler", inputs: { positive: ["39", 0], negative: ["40", 0], model: ["20", 0] } },
|
||||
20: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } },
|
||||
39: { class_type: "PromptManager", inputs: { text: ["50", 0], prepend_text: "", append_text: "" } },
|
||||
40: { class_type: "PromptManager", inputs: { text: ["50", 0], prepend_text: "bad,", append_text: "" } },
|
||||
50: { class_type: "PrimitiveString", inputs: { value: "shared text" } },
|
||||
};
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: 39, type: "PromptManager", widgets_values: ["old value A", "", "", "", "", ""] },
|
||||
{ id: 40, type: "PromptManager", widgets_values: ["old value B", "", "", "", "bad,", ""] },
|
||||
],
|
||||
};
|
||||
const r = extractGenerationParams({ prompt, workflow });
|
||||
assert.equal(r.positivePrompt, "shared text");
|
||||
});
|
||||
|
||||
test("prepend/append are not doubled when text is already the combined prompt", () => {
|
||||
const prompt = {
|
||||
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
|
||||
2: { class_type: "PromptManager", inputs: { text: "best quality, a cat, 8k", prepend_text: "best quality,", append_text: "8k" } },
|
||||
3: { class_type: "CLIPTextEncode", inputs: { text: "blurry" } },
|
||||
};
|
||||
assert.equal(extractGenerationParams({ prompt }).positivePrompt, "best quality, a cat, 8k");
|
||||
});
|
||||
|
||||
test("linked prepend/append inputs are resolved (no longer baked into text since 3.2.3)", () => {
|
||||
const prompt = {
|
||||
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
|
||||
2: { class_type: "PromptManager", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: ["6", 0] } },
|
||||
3: { class_type: "CLIPTextEncode", inputs: { text: "blurry" } },
|
||||
5: { class_type: "PrimitiveString", inputs: { value: "masterpiece," } },
|
||||
6: { class_type: "PrimitiveString", inputs: { value: "8k" } },
|
||||
};
|
||||
assert.equal(extractGenerationParams({ prompt }).positivePrompt, "masterpiece, a fox 8k");
|
||||
});
|
||||
|
||||
test("negative prompt is identified by link, not by keywords", () => {
|
||||
const prompt = {
|
||||
3: { class_type: "KSampler", inputs: { seed: 1, steps: 20, cfg: 7, sampler_name: "euler", positive: ["6", 0], negative: ["7", 0], model: ["4", 0] } },
|
||||
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "sd15.safetensors" } },
|
||||
6: { class_type: "CLIPTextEncode", inputs: { text: "embedding:goodstyle, a lighthouse" } },
|
||||
7: { class_type: "CLIPTextEncode", inputs: { text: "blurry" } },
|
||||
};
|
||||
const r = extractGenerationParams({ prompt });
|
||||
assert.equal(r.positivePrompt, "embedding:goodstyle, a lighthouse");
|
||||
assert.equal(r.negativePrompt, "blurry");
|
||||
});
|
||||
|
||||
test("follows LoRA chains, ControlNet pass-through, concatenated text and primitive inputs", () => {
|
||||
const prompt = {
|
||||
1: { class_type: "KSamplerAdvanced", inputs: { noise_seed: 42, steps: ["9", 0], cfg: 6, sampler_name: "dpmpp_2m", positive: ["5", 0], negative: ["5", 1], model: ["3", 0] } },
|
||||
2: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "base.safetensors" } },
|
||||
3: { class_type: "LoraLoader", inputs: { lora_name: "style.safetensors", model: ["2", 0], clip: ["2", 1] } },
|
||||
5: { class_type: "ControlNetApplyAdvanced", inputs: { positive: ["6", 0], negative: ["7", 0], strength: 1 } },
|
||||
6: { class_type: "CLIPTextEncode", inputs: { text: ["8", 0] } },
|
||||
7: { class_type: "CLIPTextEncode", inputs: { text: "lowres" } },
|
||||
8: { class_type: "Text Concatenate", inputs: { delimiter: ", ", text_a: "a castle", text_b: "at dusk" } },
|
||||
9: { class_type: "PrimitiveInt", inputs: { value: 30 } },
|
||||
};
|
||||
const r = extractGenerationParams({ prompt });
|
||||
assert.equal(r.positivePrompt, "a castle, at dusk");
|
||||
assert.equal(r.negativePrompt, "lowres");
|
||||
assert.equal(r.checkpoint, "base.safetensors");
|
||||
assert.equal(r.seed, 42);
|
||||
assert.equal(r.steps, 30);
|
||||
});
|
||||
|
||||
test("custom sampler graphs (Flux: SamplerCustomAdvanced + CFGGuider) resolve every field", () => {
|
||||
const prompt = {
|
||||
85: { class_type: "UNETLoader", inputs: { unet_name: "flux2-klein.safetensors" } },
|
||||
135: { class_type: "Lora Loader (LoraManager)", inputs: { model: ["85", 0], clip: ["90", 0] } },
|
||||
149: { class_type: "PromptManager", inputs: { text: "a red fox", prepend_text: ["133", 0], append_text: "" } },
|
||||
152: { class_type: "SeedHistory", inputs: { seed: 1725922648 } },
|
||||
// ControlNet-style node with positive/negative but no model: must not be taken as the sampler
|
||||
200: { class_type: "ControlNetApplyAdvanced", inputs: { positive: ["149", 0], negative: ["274", 0], strength: 1 } },
|
||||
265: { class_type: "SamplerCustomAdvanced", inputs: { noise: ["267", 0], guider: ["268", 0], sampler: ["269", 0], sigmas: ["266", 0] } },
|
||||
266: { class_type: "Flux2Scheduler", inputs: { steps: 50, width: ["153", 1] } },
|
||||
267: { class_type: "RandomNoise", inputs: { noise_seed: ["152", 0] } },
|
||||
268: { class_type: "CFGGuider", inputs: { cfg: 5.0, model: ["135", 0], positive: ["149", 0], negative: ["274", 0] } },
|
||||
269: { class_type: "KSamplerSelect", inputs: { sampler_name: "uni_pc" } },
|
||||
274: { class_type: "CLIPTextEncode", inputs: { text: "watermark" } },
|
||||
};
|
||||
const r = extractGenerationParams({ prompt });
|
||||
assert.deepEqual(
|
||||
[r.positivePrompt, r.negativePrompt, r.checkpoint, r.cfgScale, r.seed, r.steps, r.sampler],
|
||||
["a red fox", "watermark", "flux2-klein.safetensors", 5.0, 1725922648, 50, "uni_pc"],
|
||||
);
|
||||
});
|
||||
|
||||
test("multi-output settings nodes (SamplerCombo) map each field to its own value", () => {
|
||||
const prompt = {
|
||||
5: {
|
||||
class_type: "KSampler",
|
||||
inputs: {
|
||||
seed: ["162", 0], steps: ["165", 2], cfg: ["165", 3], sampler_name: ["165", 0],
|
||||
positive: ["6", 0], negative: ["7", 0], model: ["4", 0],
|
||||
},
|
||||
},
|
||||
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } },
|
||||
6: { class_type: "CLIPTextEncode", inputs: { text: "a cat" } },
|
||||
7: { class_type: "CLIPTextEncode", inputs: { text: "blurry" } },
|
||||
162: { class_type: "SeedHistory", inputs: { seed: 1495646836, seed_history_ui: "" } },
|
||||
165: { class_type: "SamplerCombo", inputs: { sampler_name: "euler_ancestral", scheduler: "normal", steps: 50, cfg: 7 } },
|
||||
};
|
||||
const r = extractGenerationParams({ prompt });
|
||||
assert.deepEqual([r.seed, r.steps, r.cfgScale, r.sampler], [1495646836, 50, 7, "euler_ancestral"]);
|
||||
});
|
||||
|
||||
test("workflow-only images account for the hidden control_after_generate widget", () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: 1, type: "CheckpointLoader|pysssss", widgets_values: ["model.safetensors", "[none]"] },
|
||||
{ id: 2, type: "KSampler", widgets_values: [123, "randomize", 25, 5.5, "euler", "normal", 1] },
|
||||
],
|
||||
};
|
||||
const r = extractGenerationParams({ workflow });
|
||||
assert.deepEqual(
|
||||
[r.checkpoint, r.seed, r.steps, r.cfgScale, r.sampler],
|
||||
["model.safetensors", 123, 25, 5.5, "euler"],
|
||||
);
|
||||
});
|
||||
|
||||
test("cyclic, fan-out graphs from crafted PNGs resolve quickly", () => {
|
||||
// Every node links to itself and its neighbour through several inputs and has
|
||||
// no scalars: without a visited set this explores ~inputs^MAX_DEPTH paths.
|
||||
const loop = (id, next) => ({
|
||||
class_type: "Evil",
|
||||
inputs: {
|
||||
text_a: [id, 0], text_b: [next, 0], text_c: [id, 0], text_d: [next, 0],
|
||||
value_a: [id, 0], value_b: [next, 0], model: [next, 0],
|
||||
},
|
||||
});
|
||||
const prompt = {
|
||||
1: { class_type: "KSampler", inputs: { seed: ["2", 0], steps: ["3", 0], positive: ["2", 0], negative: ["3", 0], model: ["2", 0] } },
|
||||
2: loop("2", "3"),
|
||||
3: loop("3", "2"),
|
||||
};
|
||||
const started = Date.now();
|
||||
const r = extractGenerationParams({ prompt });
|
||||
assert.ok(Date.now() - started < 500, `took ${Date.now() - started}ms`);
|
||||
assert.equal(r.positivePrompt, "No prompt found");
|
||||
assert.equal(r.seed, "Unknown");
|
||||
});
|
||||
|
||||
test("chained encoders whose prepend and append both link onward stay linear", () => {
|
||||
const prompt = { 1: { class_type: "KSampler", inputs: { positive: ["n0", 0], negative: ["n0", 0], model: ["n0", 0] } } };
|
||||
for (let i = 0; i < 60; i++) {
|
||||
prompt[`n${i}`] = {
|
||||
class_type: "PromptManager",
|
||||
inputs: { text: `t${i}`, prepend_text: [`n${i + 1}`, 0], append_text: [`n${i + 1}`, 0] },
|
||||
};
|
||||
}
|
||||
const started = Date.now();
|
||||
extractGenerationParams({ prompt });
|
||||
assert.ok(Date.now() - started < 500, `took ${Date.now() - started}ms`);
|
||||
});
|
||||
|
||||
// UI-format link: [id, from_node, from_slot, to_node, to_slot, type]
|
||||
test("workflow-only: traces prompts, model and KSamplerAdvanced settings through Set/Get and Reroute", () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: 1, type: "UNETLoader", widgets_values: ["flux.safetensors", "default"], outputs: [{ links: [1] }] },
|
||||
{ id: 2, type: "SetNode", widgets_values: ["model"], inputs: [{ name: "MODEL", link: 1 }] },
|
||||
{ id: 3, type: "GetNode", widgets_values: ["model"], outputs: [{ links: [2] }] },
|
||||
{ id: 4, type: "PromptManager", widgets_values: ["a castle", "", "", "", "masterpiece,", ""], inputs: [], outputs: [{ links: [3] }] },
|
||||
{ id: 5, type: "Reroute", inputs: [{ name: "", link: 3 }], outputs: [{ links: [4] }] },
|
||||
{ id: 6, type: "CLIPTextEncode", widgets_values: ["lowres"], outputs: [{ links: [5] }] },
|
||||
{
|
||||
id: 7, type: "KSamplerAdvanced",
|
||||
inputs: [{ name: "model", link: 2 }, { name: "positive", link: 4 }, { name: "negative", link: 5 }],
|
||||
widgets_values: ["enable", 77, "randomize", 30, 6.5, "dpmpp_2m", "karras", 0, 10000, "disable"],
|
||||
},
|
||||
],
|
||||
links: [
|
||||
[1, 1, 0, 2, 0, "MODEL"], [2, 3, 0, 7, 0, "MODEL"], [3, 4, 0, 5, 0, "CONDITIONING"],
|
||||
[4, 5, 0, 7, 1, "CONDITIONING"], [5, 6, 0, 7, 2, "CONDITIONING"],
|
||||
],
|
||||
};
|
||||
const r = extractGenerationParams({ workflow });
|
||||
assert.deepEqual(
|
||||
[r.positivePrompt, r.negativePrompt, r.checkpoint, r.seed, r.steps, r.cfgScale, r.sampler],
|
||||
["masterpiece, a castle", "lowres", "flux.safetensors", 77, 30, 6.5, "dpmpp_2m"],
|
||||
);
|
||||
});
|
||||
|
||||
test("workflow-only: widgets_values_named and widget-backed inputs take precedence over positions", () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: 1, type: "CheckpointLoaderSimple", widgets_values: ["m.safetensors"], outputs: [{ links: [1] }] },
|
||||
{ id: 2, type: "CLIPTextEncode", widgets_values_named: { text: "named text" }, widgets_values: ["ignored"], outputs: [{ links: [2] }] },
|
||||
{ id: 3, type: "CLIPTextEncode", widgets_values: ["neg"], outputs: [{ links: [3] }] },
|
||||
{
|
||||
id: 4, type: "KSampler",
|
||||
inputs: [
|
||||
{ name: "model", link: 1 }, { name: "positive", link: 2 }, { name: "negative", link: 3 },
|
||||
{ name: "seed", widget: { name: "seed" }, link: null },
|
||||
{ name: "steps", widget: { name: "steps" }, link: null },
|
||||
{ name: "cfg", widget: { name: "cfg" }, link: null },
|
||||
{ name: "sampler_name", widget: { name: "sampler_name" }, link: null },
|
||||
],
|
||||
widgets_values: [5, "fixed", 12, 4, "euler", "normal", 1],
|
||||
},
|
||||
],
|
||||
links: [[1, 1, 0, 4, 0, "MODEL"], [2, 2, 0, 4, 1, "CONDITIONING"], [3, 3, 0, 4, 2, "CONDITIONING"]],
|
||||
};
|
||||
const r = extractGenerationParams({ workflow });
|
||||
assert.deepEqual([r.positivePrompt, r.seed, r.steps, r.cfgScale, r.sampler], ["named text", 5, 12, 4, "euler"]);
|
||||
});
|
||||
|
||||
test("workflow-only: bypassed nodes pass through, muted nodes are ignored", () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: 1, type: "CheckpointLoaderSimple", widgets_values: ["m.safetensors"], outputs: [{ links: [1] }] },
|
||||
{ id: 2, type: "LoraLoader", mode: 4, inputs: [{ name: "model", type: "MODEL", link: 1 }], outputs: [{ type: "MODEL", links: [2] }] },
|
||||
{ id: 3, type: "CLIPTextEncode", widgets_values: ["pos"], outputs: [{ links: [3] }] },
|
||||
{ id: 4, type: "CLIPTextEncode", widgets_values: ["neg"], outputs: [{ links: [4] }] },
|
||||
{ id: 5, type: "KSampler", mode: 2, inputs: [{ name: "model", link: 2 }, { name: "positive", link: 3 }, { name: "negative", link: 4 }], widgets_values: [9, "fixed", 99, 9, "ddim"] },
|
||||
{ id: 6, type: "KSampler", inputs: [{ name: "model", link: 2 }, { name: "positive", link: 3 }, { name: "negative", link: 4 }], widgets_values: [1, "fixed", 20, 7, "euler"] },
|
||||
],
|
||||
links: [[1, 1, 0, 2, 0, "MODEL"], [2, 2, 0, 6, 0, "MODEL"], [3, 3, 0, 6, 1, "CONDITIONING"], [4, 4, 0, 6, 2, "CONDITIONING"]],
|
||||
};
|
||||
const r = extractGenerationParams({ workflow });
|
||||
assert.deepEqual([r.checkpoint, r.positivePrompt, r.steps], ["m.safetensors", "pos", 20]);
|
||||
});
|
||||
|
||||
test("missing or empty metadata yields placeholders", () => {
|
||||
const r = extractGenerationParams({});
|
||||
assert.equal(r.positivePrompt, "No prompt found");
|
||||
assert.equal(r.negativePrompt, "No negative prompt found");
|
||||
assert.equal(r.checkpoint, "Unknown");
|
||||
assert.equal(extractGenerationParams(null).seed, "Unknown");
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// Run with: node --test tests/js/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { readAction, dispatch } = require("../../web/js/data-actions.js");
|
||||
|
||||
test("readAction parses promptId and index as integers", () => {
|
||||
const parsed = readAction({ action: "open-film", promptId: "42", index: "3" });
|
||||
assert.equal(parsed.action, "open-film");
|
||||
assert.equal(parsed.promptId, 42);
|
||||
assert.equal(parsed.index, 3);
|
||||
});
|
||||
|
||||
test("readAction turns missing or non-numeric ids into null", () => {
|
||||
assert.equal(readAction({ action: "edit" }).promptId, null);
|
||||
assert.equal(readAction({ action: "edit", promptId: "abc" }).promptId, null);
|
||||
assert.equal(readAction({ action: "edit", index: "" }).index, null);
|
||||
});
|
||||
|
||||
test("readAction passes string fields through untouched", () => {
|
||||
const parsed = readAction({ action: "remove-tag", tag: `a "quoted" tag`, url: "/x?y=1&z=2", copyText: "p" });
|
||||
assert.equal(parsed.tag, `a "quoted" tag`);
|
||||
assert.equal(parsed.url, "/x?y=1&z=2");
|
||||
assert.equal(parsed.copyText, "p");
|
||||
});
|
||||
|
||||
test("readAction tolerates a missing dataset", () => {
|
||||
assert.equal(readAction(undefined).action, "");
|
||||
assert.equal(readAction(null).promptId, null);
|
||||
});
|
||||
|
||||
test("dispatch calls the matching handler with the parsed action and extras", () => {
|
||||
const calls = [];
|
||||
const handled = dispatch(
|
||||
{ action: "edit", promptId: "7" },
|
||||
{ edit: (a, el) => calls.push([a.promptId, el]) },
|
||||
"element",
|
||||
);
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(calls, [[7, "element"]]);
|
||||
});
|
||||
|
||||
test("dispatch returns false and calls nothing for an unknown or empty action", () => {
|
||||
let called = 0;
|
||||
const handlers = { edit: () => called++ };
|
||||
assert.equal(dispatch({ action: "other" }, handlers), false);
|
||||
assert.equal(dispatch({}, handlers), false);
|
||||
assert.equal(dispatch({ action: "constructor" }, handlers), false, "no prototype lookups");
|
||||
assert.equal(called, 0);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
|
||||
function deferred() {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const context = vm.createContext({ document: { addEventListener() {}, getElementById() {} },
|
||||
window: {}, console: { error() {} }, escapeHtml: value => value });
|
||||
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
|
||||
const manager = Object.create(Manager.prototype);
|
||||
const requests = new Map();
|
||||
manager.extractImageMetadata = url => {
|
||||
const pending = deferred();
|
||||
requests.set(url, pending);
|
||||
return pending.promise;
|
||||
};
|
||||
manager.displayMetadata = (metadata, panel) => {
|
||||
panel.innerHTML = metadata?.positivePrompt || 'No metadata';
|
||||
manager.currentMetadata = metadata;
|
||||
};
|
||||
manager.stopViewerSlideshow = () => {};
|
||||
return { manager, requests, panel: { innerHTML: '', isConnected: true } };
|
||||
}
|
||||
|
||||
test('a late response cannot overwrite the selected image or copy state', async () => {
|
||||
const { manager, requests, panel } = fixture();
|
||||
const first = manager.loadImageMetadata('old.png', panel);
|
||||
const second = manager.loadImageMetadata('new.png', panel);
|
||||
requests.get('new.png').resolve({ positivePrompt: 'new prompt' });
|
||||
await second;
|
||||
requests.get('old.png').resolve({ positivePrompt: 'old prompt' });
|
||||
await first;
|
||||
assert.equal(panel.innerHTML, 'new prompt');
|
||||
assert.equal(manager.currentMetadata.positivePrompt, 'new prompt');
|
||||
});
|
||||
|
||||
test('navigation clears old action data while the new request is pending', async () => {
|
||||
const { manager, requests, panel } = fixture();
|
||||
manager.currentMetadata = { positivePrompt: 'old prompt' };
|
||||
const pending = manager.loadImageMetadata('new.png', panel);
|
||||
assert.equal(manager.currentMetadata, null);
|
||||
requests.get('new.png').resolve(null);
|
||||
await pending;
|
||||
assert.equal(manager.currentMetadata, null);
|
||||
});
|
||||
|
||||
test('a stale error cannot replace newer metadata', async () => {
|
||||
const { manager, requests, panel } = fixture();
|
||||
const first = manager.loadImageMetadata('old.png', panel);
|
||||
const second = manager.loadImageMetadata('new.png', panel);
|
||||
requests.get('new.png').resolve({ positivePrompt: 'new prompt' });
|
||||
await second;
|
||||
requests.get('old.png').reject(new Error('old request failed'));
|
||||
await first;
|
||||
assert.equal(panel.innerHTML, 'new prompt');
|
||||
});
|
||||
|
||||
for (const close of ['destroy viewer', 'remove modal']) {
|
||||
test(`closing via ${close} prevents pending metadata from restoring action data`, async () => {
|
||||
const { manager, requests, panel } = fixture();
|
||||
const pending = manager.loadImageMetadata('old.png', panel);
|
||||
if (close === 'destroy viewer') manager.destroyViewer();
|
||||
else panel.isConnected = false;
|
||||
requests.get('old.png').resolve({ positivePrompt: 'closed prompt' });
|
||||
await pending;
|
||||
assert.equal(manager.currentMetadata, null);
|
||||
assert.notEqual(panel.innerHTML, 'closed prompt');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
const { escapeHtml } = require('../../web/js/html-escape.js');
|
||||
|
||||
function fixture(count = 2000) {
|
||||
const elements = new Map();
|
||||
function element() {
|
||||
return {
|
||||
dataset: {}, children: [], listeners: {}, classList: { add() {}, remove() {} },
|
||||
set innerHTML(html) { this.html = html; this.children = [{ html }]; },
|
||||
get innerHTML() { return this.html || ''; },
|
||||
insertAdjacentHTML(position, html) { this.children.push({ html }); },
|
||||
addEventListener(type, handler) { (this.listeners[type] ||= []).push(handler); },
|
||||
querySelectorAll() { return []; },
|
||||
remove() {},
|
||||
};
|
||||
}
|
||||
const document = {
|
||||
addEventListener() {},
|
||||
getElementById(id) { if (!elements.has(id)) elements.set(id, element()); return elements.get(id); },
|
||||
createElement: element,
|
||||
};
|
||||
const instances = [];
|
||||
const timers = new Map();
|
||||
let timerId = 0;
|
||||
class Viewer {
|
||||
constructor(holder, options) {
|
||||
this.element = holder; this.options = { fullscreen: true, ...options };
|
||||
this.index = options.initialViewIndex; this.isShown = true;
|
||||
this.length = (holder.innerHTML.match(/<img /g) || []).length;
|
||||
instances.push(this);
|
||||
}
|
||||
requestFullscreen() { this.fullscreenRequested = true; }
|
||||
exitFullscreen() { this.fullscreenExited = true; }
|
||||
show() { this.shown = true; }
|
||||
view(index) { this.index = index; }
|
||||
clearEnforceFocus() { this.focusCleared = true; }
|
||||
destroy() { this.destroyed = true; }
|
||||
}
|
||||
const context = vm.createContext({ document, window: {}, Viewer, escapeHtml,
|
||||
console, setTimeout(callback) { timers.set(++timerId, callback); return timerId; },
|
||||
clearTimeout(id) { timers.delete(id); },
|
||||
ApiClient: { resolveUrl: value => value, STALE: Symbol() },
|
||||
ListState: { nextPageState: (state, next) => ({ ...state, ...next }) },
|
||||
});
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8');
|
||||
const Manager = vm.runInContext(source + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
|
||||
const manager = Object.create(Manager.prototype);
|
||||
Object.assign(manager, { images: Array.from({ length: count }, (_, i) => ({
|
||||
filename: `${i}.png`, url: `/original/${i}.png`, thumbnail_url: `/thumb/${i}.jpg`,
|
||||
relative_path: `${i}.png`, size: 100, modified_time: 1,
|
||||
})), viewMode: 'grid', viewer: null, currentPage: 1, limit: 100, total: count,
|
||||
getSettings: () => ({ showImageInfo: true }), });
|
||||
return { manager, document, instances, timers };
|
||||
}
|
||||
|
||||
for (const mode of ['grid', 'list']) {
|
||||
test(`${mode} append retains existing cards and adds only the new page`, () => {
|
||||
const { manager, document, timers } = fixture(100);
|
||||
manager.viewMode = mode;
|
||||
manager.renderGallery();
|
||||
const container = document.getElementById(mode === 'grid' ? 'galleryGrid' : 'galleryList');
|
||||
assert.equal(timers.size, 0);
|
||||
const first = container.children[0];
|
||||
manager.images.push({ ...manager.images[0], filename: 'new.png' });
|
||||
manager.renderGallery({ from: 100 });
|
||||
assert.equal(container.children[0], first);
|
||||
assert.equal(container.children.length, 2);
|
||||
assert.match(container.children[1].html, /new.png/);
|
||||
assert.doesNotMatch(container.children[1].html, /alt="0.png"/);
|
||||
assert.match(container.children[1].html, /data-image-index="100"/);
|
||||
});
|
||||
}
|
||||
|
||||
test('loadImages passes the previous length for append, and zero for replacement', async () => {
|
||||
const { manager } = fixture(200);
|
||||
const offsets = [];
|
||||
Object.assign(manager, { imagesRequest: async () => ({ total: 500, images: [manager.images[0]] }),
|
||||
renderGallery: options => offsets.push(options.from), updateStats() {},
|
||||
updatePagination() {}, maybeLoadMore() {}, showLoading() {}, });
|
||||
await manager.loadImages(3, { append: true });
|
||||
await manager.loadImages(1);
|
||||
assert.deepEqual(offsets, [200, 0]);
|
||||
assert.equal(manager.images.length, 1);
|
||||
});
|
||||
|
||||
test('viewer opening is bounded and selects the clicked original', () => {
|
||||
const { manager, instances } = fixture();
|
||||
manager.openViewer(1500);
|
||||
const viewer = instances[0];
|
||||
assert.ok(viewer.element.innerHTML.match(/<img /g).length <= 100);
|
||||
assert.match(viewer.element.innerHTML, /data-original="\/original\/1500.png"/);
|
||||
const urls = [...viewer.element.innerHTML.matchAll(/data-original="([^"]+)"/g)].map(m => m[1]);
|
||||
assert.equal(urls[viewer.options.initialViewIndex], '/original/1500.png');
|
||||
assert.equal(viewer.shown, true);
|
||||
});
|
||||
|
||||
test('previous/next cross window edges and wrap only at gallery edges', () => {
|
||||
const { manager, instances } = fixture();
|
||||
manager.openViewer(1500);
|
||||
const first = manager.viewer;
|
||||
first.index = 99;
|
||||
const lastUrl = [...first.element.innerHTML.matchAll(/data-original="\/original\/(\d+).png"/g)].at(-1);
|
||||
first.next(true);
|
||||
const next = manager.viewer;
|
||||
const urls = [...next.element.innerHTML.matchAll(/data-original="\/original\/(\d+).png"/g)];
|
||||
assert.equal(Number(urls[next.index][1]), Number(lastUrl[1]) + 1);
|
||||
manager.openViewer(0);
|
||||
manager.viewer.prev(true);
|
||||
const wrapped = manager.viewer;
|
||||
const wrappedUrls = [...wrapped.element.innerHTML.matchAll(/data-original="\/original\/(\d+).png"/g)];
|
||||
assert.equal(Number(wrappedUrls[wrapped.index][1]), 1999);
|
||||
assert.ok(instances.length >= 3);
|
||||
});
|
||||
|
||||
test('delegated card clicks bind once, open appended indices, and preserve video dispatch', () => {
|
||||
const { manager, document } = fixture();
|
||||
const opened = [];
|
||||
manager.openViewer = index => opened.push(index);
|
||||
manager.showVideoModal = (...args) => opened.push(args);
|
||||
manager.addFallbackClickHandlers();
|
||||
manager.addFallbackClickHandlers();
|
||||
const grid = document.getElementById('galleryGrid');
|
||||
assert.equal(grid.listeners.click.length, 1);
|
||||
const item = { dataset: { imageIndex: '1500' }, querySelector: () => ({ dataset: {
|
||||
original: '/original/1500.png', caption: 'caption', isVideo: 'false',
|
||||
} }) };
|
||||
const click = () => grid.listeners.click[0]({ target: { closest: () => item }, preventDefault() {}, stopPropagation() {} });
|
||||
click();
|
||||
item.querySelector = () => ({ dataset: { original: '/video.mp4', caption: 'video', isVideo: 'true' } });
|
||||
click();
|
||||
assert.deepEqual(opened, [1500, ['/video.mp4', 'video', 1500]]);
|
||||
});
|
||||
|
||||
test('navigation inside the window reuses the viewer and honors non-looping edges', () => {
|
||||
const { manager, instances } = fixture();
|
||||
manager.openViewer(1500);
|
||||
const viewer = manager.viewer;
|
||||
viewer.next(true);
|
||||
assert.equal(manager.viewer, viewer);
|
||||
assert.equal(viewer.index, 51);
|
||||
viewer.prev(true);
|
||||
assert.equal(viewer.index, 50);
|
||||
manager.openViewer(0);
|
||||
const edge = manager.viewer;
|
||||
edge.prev(false);
|
||||
assert.equal(manager.viewer, edge);
|
||||
assert.equal(edge.index, 0);
|
||||
assert.equal(instances.length, 2);
|
||||
});
|
||||
|
||||
test('append leaves an open viewer intact; a full render destroys it', () => {
|
||||
const { manager } = fixture();
|
||||
manager.openViewer(1500);
|
||||
const viewer = manager.viewer;
|
||||
manager.renderGallery({ from: 1900 });
|
||||
assert.equal(manager.viewer, viewer);
|
||||
manager.renderGallery();
|
||||
assert.equal(viewer.destroyed, true);
|
||||
assert.equal(manager.viewer, null);
|
||||
});
|
||||
|
||||
test('each viewed image updates sidebar metadata without waiting for DOM mutations', () => {
|
||||
const { manager, document } = fixture();
|
||||
const urls = [];
|
||||
document.querySelector = () => ({});
|
||||
const sidebar = document.getElementById('metadata-sidebar');
|
||||
sidebar.querySelector = () => ({});
|
||||
manager.loadImageMetadata = url => urls.push(url);
|
||||
manager.openViewer(1500);
|
||||
manager.viewer.options.viewed({ detail: { originalImage: { dataset: { original: '/original/1500.png' } } } });
|
||||
manager.viewer.options.viewed({ detail: { originalImage: { dataset: { original: '/original/1501.png' } } } });
|
||||
assert.deepEqual(urls, ['/original/1500.png', '/original/1501.png']);
|
||||
});
|
||||
|
||||
test('slideshow advances across windows without preloading every original and stops on hide', () => {
|
||||
const { manager, timers } = fixture();
|
||||
manager.openViewer(1500);
|
||||
let prevented = false;
|
||||
manager.viewer.options.play({ preventDefault() { prevented = true; } });
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(manager.viewer.fullscreenRequested, true);
|
||||
assert.equal(timers.size, 1);
|
||||
manager.viewer.index = 99;
|
||||
const tick = timers.values().next().value;
|
||||
timers.clear();
|
||||
tick();
|
||||
const viewer = manager.viewer;
|
||||
const urls = [...viewer.element.innerHTML.matchAll(/data-original="([^"]+)"/g)];
|
||||
assert.equal(urls[viewer.index][1], '/original/1550.png');
|
||||
assert.equal(timers.size, 1);
|
||||
viewer.options.hide();
|
||||
assert.equal(viewer.fullscreenExited, true);
|
||||
assert.equal(timers.size, 0);
|
||||
});
|
||||
|
||||
test('destroying a viewer removes its focus trap and slideshow timer', () => {
|
||||
const { manager, timers } = fixture();
|
||||
manager.openViewer(1500);
|
||||
const viewer = manager.viewer;
|
||||
viewer.options.play({ preventDefault() {} });
|
||||
manager.destroyViewer();
|
||||
assert.equal(viewer.focusCleared, true);
|
||||
assert.equal(viewer.destroyed, true);
|
||||
assert.equal(timers.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const ImageHelpers = require('../../web/js/image-helpers.js');
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise(yes => { resolve = yes; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
function fixture() {
|
||||
const elements = new Map();
|
||||
const requests = [];
|
||||
const document = { addEventListener() {}, getElementById(id) {
|
||||
if (!elements.has(id)) elements.set(id, {});
|
||||
return elements.get(id);
|
||||
} };
|
||||
const context = vm.createContext({ document, window: {}, ImageHelpers,
|
||||
escapeHtml: value => value, console: { error() {} },
|
||||
ApiClient: { resolveUrl: value => value, fetchUrl: (url, options) => {
|
||||
const request = deferred();
|
||||
requests.push({ url, body: JSON.parse(options.body), ...request });
|
||||
return request.promise;
|
||||
} },
|
||||
});
|
||||
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
|
||||
const manager = Object.create(Manager.prototype);
|
||||
const notifications = [];
|
||||
Object.assign(manager, { autoTagState: { reviewImages: [
|
||||
{ id: 'digest', path: 'same.png', root_index: 1, url: '/same.png' },
|
||||
{ id: 'other', path: 'next.png', root_index: 0, url: '/next.png' },
|
||||
], reviewIndex: 0, currentTags: [], modelType: 'gguf' },
|
||||
renderReviewTags() {}, showNotification: (...args) => notifications.push(args), hideModal() {}, loadImages() {},
|
||||
});
|
||||
const reply = (index, data) => requests[index].resolve({ json: async () => data });
|
||||
return { manager, requests, reply, notifications };
|
||||
}
|
||||
test('gallery review retains root identity and applies tags with the returned prompt ID', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
const load = manager.loadNextReviewImage();
|
||||
assert.equal(requests[0].body.root_index, 1);
|
||||
reply(0, { success: true, prompt_id: 42, tags: ['smile'] });
|
||||
await load;
|
||||
manager.loadNextReviewImage = () => {};
|
||||
const apply = manager.applyReviewTags();
|
||||
assert.deepEqual(requests[1].body, { prompt_id: 42, tags: ['smile'] });
|
||||
reply(1, { success: true });
|
||||
await apply;
|
||||
});
|
||||
test('unlinked images do not submit an invalid apply request or advance', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
const load = manager.loadNextReviewImage();
|
||||
reply(0, { success: true, prompt_id: null, tags: ['smile'] });
|
||||
await load;
|
||||
const apply = manager.applyReviewTags();
|
||||
if (requests[1]) reply(1, { success: false, error: "Unexpected apply" });
|
||||
await apply;
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(manager.autoTagState.reviewIndex, 0);
|
||||
});
|
||||
test('a failed apply keeps the current image available for retry', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
const load = manager.loadNextReviewImage();
|
||||
reply(0, { success: true, prompt_id: 42, tags: ['smile'] });
|
||||
await load;
|
||||
const apply = manager.applyReviewTags();
|
||||
reply(1, { success: false, error: 'Try again' });
|
||||
await apply;
|
||||
assert.equal(manager.autoTagState.reviewIndex, 0);
|
||||
assert.equal(requests.length, 2);
|
||||
});
|
||||
test('late generation responses cannot restore tags from a skipped or closed image', async () => {
|
||||
const { manager, reply } = fixture();
|
||||
const first = manager.loadNextReviewImage();
|
||||
manager.autoTagState.reviewIndex = 1;
|
||||
const second = manager.loadNextReviewImage();
|
||||
reply(1, { success: true, prompt_id: 2, tags: ['current'] });
|
||||
await second;
|
||||
reply(0, { success: true, prompt_id: 1, tags: ['stale'] });
|
||||
await first;
|
||||
assert.deepEqual(Array.from(manager.autoTagState.currentTags), ['current']);
|
||||
const third = manager.loadNextReviewImage();
|
||||
manager.cancelReview();
|
||||
reply(2, { success: true, prompt_id: 2, tags: ['closed'] });
|
||||
await third;
|
||||
assert.equal(manager.autoTagState.currentTags.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
const ImageHelpers = require('../../web/js/image-helpers.js');
|
||||
|
||||
test('gallery metadata requests retain root identity without including cache parameters in filenames', async () => {
|
||||
const requests = [];
|
||||
const context = vm.createContext({ document: { addEventListener() {} }, window: {}, console,
|
||||
ImageHelpers, ApiClient: { fetchUrl: async url => {
|
||||
requests.push(url);
|
||||
return { ok: true, json: async () => ({ success: true, prompt: { text: url } }) };
|
||||
} },
|
||||
});
|
||||
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
|
||||
const manager = Object.create(Manager.prototype);
|
||||
manager.extractImageMetadata = async () => null;
|
||||
manager.displayMetadata = (metadata, panel) => { panel.prompt = metadata.positivePrompt; };
|
||||
for (const root of [0, 1]) {
|
||||
const panel = {};
|
||||
const expected = `/prompt_manager/images/prompt/sub%2Fsame.png?root=${root}`;
|
||||
await manager.loadImageMetadata(`/prompt_manager/images/serve/sub/same.png?v=123&root=${root}`, panel);
|
||||
assert.equal(panel.prompt, expected);
|
||||
}
|
||||
assert.deepEqual(requests, [
|
||||
'/prompt_manager/images/prompt/sub%2Fsame.png?root=0',
|
||||
'/prompt_manager/images/prompt/sub%2Fsame.png?root=1',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// Run with: node --test tests/js/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
DEFAULT_SETTINGS,
|
||||
normalizeGallerySettings,
|
||||
gridColumnsStyle,
|
||||
LIMIT_OPTIONS,
|
||||
} = require("../../web/js/gallery-settings.js");
|
||||
|
||||
test("defaults are a complete, sane settings object", () => {
|
||||
const s = normalizeGallerySettings(null);
|
||||
assert.deepEqual(s, DEFAULT_SETTINGS);
|
||||
assert.equal(s.defaultViewMode, "grid");
|
||||
assert.equal(s.defaultLimit, 100);
|
||||
assert.equal(s.gridColumns, 8);
|
||||
assert.equal(s.showImageInfo, true);
|
||||
assert.equal(s.sidebarCollapsedByDefault, false);
|
||||
});
|
||||
|
||||
test("stored values override defaults and unknown keys are kept", () => {
|
||||
const s = normalizeGallerySettings({ defaultViewMode: "list", defaultLimit: 500, gridColumns: 10, showImageInfo: false, videoLoop: false, extra: 1 });
|
||||
assert.equal(s.defaultViewMode, "list");
|
||||
assert.equal(s.defaultLimit, 500);
|
||||
assert.equal(s.gridColumns, 10);
|
||||
assert.equal(s.showImageInfo, false);
|
||||
assert.equal(s.videoLoop, false);
|
||||
assert.equal(s.extra, 1);
|
||||
});
|
||||
|
||||
test("invalid stored values fall back to defaults instead of breaking the page", () => {
|
||||
const s = normalizeGallerySettings({ defaultViewMode: "mosaic", defaultLimit: 7, gridColumns: "lots", showImageInfo: "no" });
|
||||
assert.equal(s.defaultViewMode, "grid");
|
||||
assert.equal(s.defaultLimit, 100);
|
||||
assert.equal(s.gridColumns, 8);
|
||||
assert.equal(s.showImageInfo, true);
|
||||
});
|
||||
|
||||
test("numeric strings from <select> values are accepted and columns are clamped", () => {
|
||||
assert.equal(normalizeGallerySettings({ defaultLimit: "200" }).defaultLimit, 200);
|
||||
assert.equal(normalizeGallerySettings({ gridColumns: "10" }).gridColumns, 10);
|
||||
assert.equal(normalizeGallerySettings({ gridColumns: 40 }).gridColumns, 12);
|
||||
assert.equal(normalizeGallerySettings({ gridColumns: 1 }).gridColumns, 2);
|
||||
assert.deepEqual(LIMIT_OPTIONS, [50, 100, 200, 500]);
|
||||
});
|
||||
|
||||
test("normalize never mutates its input", () => {
|
||||
const raw = { gridColumns: 4 };
|
||||
normalizeGallerySettings(raw);
|
||||
assert.deepEqual(raw, { gridColumns: 4 });
|
||||
});
|
||||
|
||||
test("gridColumnsStyle produces an explicit grid template", () => {
|
||||
assert.equal(gridColumnsStyle(10), "repeat(10, minmax(0, 1fr))");
|
||||
assert.equal(gridColumnsStyle("6"), "repeat(6, minmax(0, 1fr))");
|
||||
assert.equal(gridColumnsStyle("x"), "repeat(8, minmax(0, 1fr))");
|
||||
});
|
||||
|
||||
test("infinite scrolling is off by default and must be a boolean", () => {
|
||||
assert.equal(DEFAULT_SETTINGS.infiniteScroll, false);
|
||||
assert.equal(normalizeGallerySettings({ infiniteScroll: true }).infiniteScroll, true);
|
||||
assert.equal(normalizeGallerySettings({ infiniteScroll: "yes" }).infiniteScroll, false);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function fixture(status, settings = {}) {
|
||||
const timers = [];
|
||||
const prompts = [];
|
||||
const requests = [];
|
||||
const buttons = {};
|
||||
const storage = new Map();
|
||||
const document = {
|
||||
addEventListener() {},
|
||||
createElement: () => ({ addEventListener() {} }),
|
||||
body: { appendChild() {}, removeChild() {} },
|
||||
getElementById: id => buttons[id] ||= { addEventListener(type, handler) { this[type] = handler; } },
|
||||
};
|
||||
const context = vm.createContext({ document, window: {}, console: { debug() {}, warn() {} },
|
||||
setTimeout: callback => timers.push(callback),
|
||||
localStorage: { setItem: (key, value) => storage.set(key, value) },
|
||||
ApiClient: { fetchUrl: async () => ({ json: async () => ({ success: true,
|
||||
total: 10000, images: Array.from({ length: 50 }, () => ({ thumbnail_url: null })),
|
||||
}) }) },
|
||||
});
|
||||
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
|
||||
const manager = Object.create(Manager.prototype);
|
||||
Object.assign(manager, { getSettings: () => ({ checkThumbnailsAtStartup: true, ...settings }),
|
||||
api: { get: async url => { requests.push(url); return status; } },
|
||||
});
|
||||
const originalPrompt = manager.showThumbnailGenerationPrompt.bind(manager);
|
||||
manager.showThumbnailGenerationPrompt = (...args) => prompts.push(args);
|
||||
async function check() {
|
||||
await manager.checkThumbnailsAtStartup();
|
||||
await timers.shift()();
|
||||
}
|
||||
return { manager, check, prompts, requests, settings, buttons, storage, originalPrompt };
|
||||
}
|
||||
|
||||
test('startup uses exact collection counts instead of extrapolating the newest 50 images', async () => {
|
||||
const f = fixture({ success: true, total_images: 10000, pending: 50, ready: 9950, collection_id: 'a' });
|
||||
await f.check();
|
||||
assert.deepEqual(f.requests, ['/prompt_manager/images/thumbnails/status']);
|
||||
assert.equal(f.prompts.length, 0);
|
||||
});
|
||||
|
||||
test('startup prompt reports the actual number needing generation', async () => {
|
||||
const f = fixture({ success: true, total_images: 10000, pending: 1200, collection_id: 'a' });
|
||||
await f.check();
|
||||
assert.equal(f.prompts.length, 1);
|
||||
assert.deepEqual(f.prompts[0].slice(0, 2), [10000, 1200]);
|
||||
});
|
||||
|
||||
test('a complete collection never prompts again', async () => {
|
||||
const f = fixture({ success: true, total_images: 10000, pending: 0, collection_id: 'a' });
|
||||
await f.check();
|
||||
await f.check();
|
||||
assert.equal(f.prompts.length, 0);
|
||||
});
|
||||
|
||||
test('Not Now persists the collection state and suppresses the unchanged prompt', async () => {
|
||||
const status = { success: true, total_images: 100, pending: 30, collection_id: 'a' };
|
||||
const f = fixture(status);
|
||||
await f.check();
|
||||
f.originalPrompt(...f.prompts[0]);
|
||||
f.buttons.thumbnailPromptNo.click();
|
||||
Object.assign(f.settings, JSON.parse(f.storage.get('gallerySettings')));
|
||||
f.prompts.length = 0;
|
||||
await f.check();
|
||||
assert.equal(f.prompts.length, 0);
|
||||
status.pending = 40;
|
||||
await f.check();
|
||||
assert.equal(f.prompts.length, 1);
|
||||
});
|
||||
|
||||
test('disabled checks do not request collection status', async () => {
|
||||
const f = fixture({}, { checkThumbnailsAtStartup: false });
|
||||
await f.check();
|
||||
assert.equal(f.requests.length, 0);
|
||||
assert.equal(f.prompts.length, 0);
|
||||
});
|
||||
|
||||
test('saving gallery settings retains the remembered thumbnail dismissal', () => {
|
||||
const f = fixture({}, { thumbnailPromptDismissed: 'remembered-state' });
|
||||
f.manager.showNotification = () => {};
|
||||
f.manager.hideSettings = () => {};
|
||||
f.manager.applySettings = () => {};
|
||||
f.manager.saveSettings();
|
||||
assert.equal(JSON.parse(f.storage.get('gallerySettings')).thumbnailPromptDismissed, 'remembered-state');
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// Run with: node --test tests/js/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { escapeHtml, escapeAttr } = require("../../web/js/html-escape.js");
|
||||
|
||||
test("escapes the five HTML-significant characters", () => {
|
||||
assert.equal(escapeHtml(`&<>"'`), "&<>"'");
|
||||
});
|
||||
|
||||
test("a double quote cannot break out of an attribute", () => {
|
||||
const payload = `" onmouseover="alert(1)`;
|
||||
const escaped = escapeHtml(payload);
|
||||
assert.ok(!escaped.includes(`"`), escaped);
|
||||
assert.equal(escaped, `" onmouseover="alert(1)`);
|
||||
assert.equal(`<a data-tag="${escaped}">`, `<a data-tag="" onmouseover="alert(1)">`);
|
||||
});
|
||||
|
||||
test("a single quote cannot break out of a single-quoted attribute", () => {
|
||||
const escaped = escapeHtml(`' onclick='alert(1)`);
|
||||
assert.ok(!escaped.includes(`'`), escaped);
|
||||
assert.equal(escaped, `' onclick='alert(1)`);
|
||||
});
|
||||
|
||||
test("a script tag is neutralised in text context", () => {
|
||||
assert.equal(escapeHtml("<script>x</script>"), "<script>x</script>");
|
||||
});
|
||||
|
||||
test("unicode and ordinary text pass through unchanged", () => {
|
||||
assert.equal(escapeHtml("héllo wörld 日本語 🙂"), "héllo wörld 日本語 🙂");
|
||||
assert.equal(escapeHtml("plain text, no specials"), "plain text, no specials");
|
||||
});
|
||||
|
||||
test("already escaped text is escaped again, never double-decoded", () => {
|
||||
assert.equal(escapeHtml("&"), "&amp;");
|
||||
});
|
||||
|
||||
test("null and undefined become the empty string", () => {
|
||||
assert.equal(escapeHtml(null), "");
|
||||
assert.equal(escapeHtml(undefined), "");
|
||||
});
|
||||
|
||||
test("numbers and booleans are coerced to strings", () => {
|
||||
assert.equal(escapeHtml(42), "42");
|
||||
assert.equal(escapeHtml(0), "0");
|
||||
assert.equal(escapeHtml(1.5), "1.5");
|
||||
assert.equal(escapeHtml(false), "false");
|
||||
});
|
||||
|
||||
test("objects are coerced through String()", () => {
|
||||
assert.equal(escapeHtml({ toString: () => "<b>" }), "<b>");
|
||||
});
|
||||
|
||||
test("escapeAttr is the same function as escapeHtml", () => {
|
||||
assert.equal(escapeAttr, escapeHtml);
|
||||
assert.equal(escapeAttr(`a"b`), "a"b");
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
// Run with: node --test tests/js/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
formatImageCaption,
|
||||
workflowDocumentHtml,
|
||||
autotagSingleBody,
|
||||
fallbackImageSource,
|
||||
} = require("../../web/js/image-helpers.js");
|
||||
|
||||
const WD14 = { modelType: "wd14-vit", generalThreshold: 0.35, characterThreshold: 0.85, prompt: "ignored" };
|
||||
const CAPTION = { modelType: "blip", generalThreshold: 0.35, characterThreshold: 0.85, prompt: "describe it" };
|
||||
|
||||
test("autotagSingleBody sends image_id for a database row", () => {
|
||||
const body = autotagSingleBody({ id: 42, prompt_id: 7, image_path: "/out/a.png" }, WD14);
|
||||
assert.equal(body.image_id, 42);
|
||||
assert.equal("path" in body, false);
|
||||
});
|
||||
|
||||
test("autotagSingleBody sends path for an output-folder entry whose id is a digest", () => {
|
||||
const body = autotagSingleBody({ id: "3f2a9c1b0d4e5f67", path: "/out/sub/a.png", filename: "a.png" }, WD14);
|
||||
assert.equal(body.path, "/out/sub/a.png");
|
||||
assert.equal("image_id" in body, false);
|
||||
});
|
||||
|
||||
test("autotagSingleBody prefers path when a numeric-looking string id has no prompt link", () => {
|
||||
const body = autotagSingleBody({ id: "1234567890abcdef", path: "/out/b.png" }, WD14);
|
||||
assert.deepEqual(Object.keys(body).filter((k) => k === "path" || k === "image_id"), ["path"]);
|
||||
});
|
||||
|
||||
test("autotagSingleBody adds wd14 thresholds and no prompt", () => {
|
||||
const body = autotagSingleBody({ id: 1 }, WD14);
|
||||
assert.equal(body.model_type, "wd14-vit");
|
||||
assert.equal(body.general_threshold, 0.35);
|
||||
assert.equal(body.character_threshold, 0.85);
|
||||
assert.equal("prompt" in body, false);
|
||||
});
|
||||
|
||||
test("autotagSingleBody adds the prompt for caption models and no thresholds", () => {
|
||||
const body = autotagSingleBody({ id: 1 }, CAPTION);
|
||||
assert.equal(body.model_type, "blip");
|
||||
assert.equal(body.prompt, "describe it");
|
||||
assert.equal("general_threshold" in body, false);
|
||||
});
|
||||
|
||||
test("autotagSingleBody does not mutate its inputs", () => {
|
||||
const image = Object.freeze({ id: 1 });
|
||||
const settings = Object.freeze({ ...WD14 });
|
||||
assert.doesNotThrow(() => autotagSingleBody(image, settings));
|
||||
});
|
||||
|
||||
const XSS = '"><img src=x onerror=1>';
|
||||
const formatFileSize = (bytes) => `${bytes} B`;
|
||||
|
||||
test("formatImageCaption joins dimensions and size with the separator", () => {
|
||||
const caption = formatImageCaption({ width: 640, height: 480, file_size: 2048 }, { formatFileSize });
|
||||
assert.equal(caption, "640×480 | 2048 B");
|
||||
});
|
||||
|
||||
test("formatImageCaption honours a custom separator and omits a missing size", () => {
|
||||
assert.equal(formatImageCaption({ width: 1, height: 2 }, { formatFileSize, separator: " • " }), "1×2");
|
||||
});
|
||||
|
||||
test("formatImageCaption falls back to Unknown size when a dimension is missing", () => {
|
||||
assert.equal(formatImageCaption({ width: 640 }, { formatFileSize }), "Unknown size");
|
||||
assert.equal(formatImageCaption({}, { formatFileSize }), "Unknown size");
|
||||
assert.equal(formatImageCaption(null, { formatFileSize }), "Unknown size");
|
||||
});
|
||||
|
||||
test("formatImageCaption escapes string dimensions so they cannot break out of an attribute", () => {
|
||||
const caption = formatImageCaption({ width: XSS, height: 10 }, { formatFileSize });
|
||||
assert.ok(!caption.includes("<"), caption);
|
||||
assert.ok(!caption.includes('"'), caption);
|
||||
assert.ok(caption.includes(""><img"), caption);
|
||||
});
|
||||
|
||||
test("formatImageCaption ignores a non-numeric file_size instead of formatting it", () => {
|
||||
const caption = formatImageCaption({ width: 1, height: 1, file_size: XSS }, { formatFileSize });
|
||||
assert.equal(caption, "1×1");
|
||||
});
|
||||
|
||||
test("formatImageCaption escapes whatever formatFileSize returns", () => {
|
||||
const caption = formatImageCaption({ width: 1, height: 1, file_size: 5 }, { formatFileSize: () => "<b>" });
|
||||
assert.equal(caption, "1×1 | <b>");
|
||||
});
|
||||
|
||||
test("workflowDocumentHtml escapes the JSON so it cannot close the pre block", () => {
|
||||
const html = workflowDocumentHtml({ note: "</pre><script>alert(1)</script>" });
|
||||
assert.ok(html.includes("</pre><script>"), html);
|
||||
assert.ok(!html.includes("</pre><script>"), html);
|
||||
assert.ok(html.startsWith("<!DOCTYPE html>"));
|
||||
assert.ok(html.includes("<title>ComfyUI Workflow Data</title>"));
|
||||
});
|
||||
|
||||
test("workflowDocumentHtml pretty-prints the workflow with two-space indentation", () => {
|
||||
const html = workflowDocumentHtml({ a: 1 });
|
||||
assert.ok(html.includes('{\n "a": 1\n}'), html);
|
||||
});
|
||||
|
||||
test("fallbackImageSource offers the original once when a thumbnail fails", () => {
|
||||
const dataset = { original: "/serve/a.png", thumbnail: "/serve/thumbnails/a_thumb.png?v=1" };
|
||||
assert.equal(fallbackImageSource("/serve/thumbnails/a_thumb.png?v=1", dataset), "/serve/a.png");
|
||||
});
|
||||
|
||||
test("fallbackImageSource gives up when the original itself failed or was already tried", () => {
|
||||
assert.equal(fallbackImageSource("/serve/a.png", { original: "/serve/a.png" }), null);
|
||||
assert.equal(fallbackImageSource("/serve/t.png", { original: "/serve/a.png", fellBack: "1" }), null);
|
||||
assert.equal(fallbackImageSource("/serve/t.png", {}), null);
|
||||
assert.equal(fallbackImageSource("/serve/t.png", undefined), null);
|
||||
});
|
||||
|
||||
const { metadataLookupUrl } = require('../../web/js/image-helpers.js');
|
||||
|
||||
test('metadata lookup keeps root identity and strips thumbnail cache query parameters', () => {
|
||||
assert.equal(metadataLookupUrl('/prompt_manager/images/serve/sub/a.png?root=1&v=123'), '/prompt_manager/images/prompt/sub%2Fa.png?root=1');
|
||||
assert.equal(metadataLookupUrl('/prompt_manager/images/serve/a.png?root=0'), '/prompt_manager/images/prompt/a.png?root=0');
|
||||
});
|
||||
|
||||
test('metadata lookup preserves encoded filename characters and works behind a proxy', () => {
|
||||
assert.equal(metadataLookupUrl('https://example.test/ComfyBackendDirect/prompt_manager/images/serve/space%20%23%3F%2520%20caf%C3%A9.png?root=1'), '/prompt_manager/images/prompt/space%20%23%3F%2520%20caf%C3%A9.png?root=1');
|
||||
assert.equal(metadataLookupUrl('/prompt_manager/images/serve/a.png?v=123'), '/prompt_manager/images/prompt/a.png');
|
||||
assert.equal(metadataLookupUrl('/elsewhere/a.png'), null);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// Run with: node --test tests/js/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { nextPageState, shouldSaveEdit, hasMorePages, shouldLoadMore } = require("../../web/js/list-state.js");
|
||||
|
||||
test("hasMorePages follows pagination.has_more only while pages keep arriving", () => {
|
||||
assert.equal(hasMorePages({ pagination: { has_more: true } }, 5000), true);
|
||||
assert.equal(hasMorePages({ pagination: { has_more: false } }, 5000), false);
|
||||
assert.equal(hasMorePages({ pagination: { has_more: true } }, 0), false, "an empty page ends the loop");
|
||||
assert.equal(hasMorePages({}, 10), false, "no pagination block means a single page");
|
||||
assert.equal(hasMorePages(null, 10), false);
|
||||
});
|
||||
|
||||
const current = { page: 2, limit: 50, total: 120, totalPages: 3 };
|
||||
|
||||
test("nextPageState moves to the requested page once the fetch succeeded", () => {
|
||||
assert.deepEqual(nextPageState(current, { page: 3, total: 120 }), { page: 3, limit: 50, total: 120, totalPages: 3 });
|
||||
});
|
||||
|
||||
test("nextPageState clamps a page beyond the end when the total shrank", () => {
|
||||
assert.deepEqual(nextPageState(current, { page: 3, total: 60 }), { page: 2, limit: 50, total: 60, totalPages: 2 });
|
||||
});
|
||||
|
||||
test("nextPageState never goes below page 1 and has at least one page", () => {
|
||||
assert.deepEqual(nextPageState(current, { page: 0, total: 0 }), { page: 1, limit: 50, total: 0, totalPages: 1 });
|
||||
assert.equal(nextPageState(current, { page: -5, total: 10 }).page, 1);
|
||||
});
|
||||
|
||||
test("nextPageState keeps the current page and total when the response omits them", () => {
|
||||
assert.deepEqual(nextPageState(current, {}), current);
|
||||
assert.deepEqual(nextPageState(current, { total: "many", page: "x" }), current);
|
||||
});
|
||||
|
||||
test("nextPageState accepts a new limit and recomputes totalPages", () => {
|
||||
assert.deepEqual(nextPageState(current, { page: 1, limit: 25, total: 120 }), { page: 1, limit: 25, total: 120, totalPages: 5 });
|
||||
assert.equal(nextPageState(current, { limit: 0 }).limit, 50, "a zero limit is ignored");
|
||||
});
|
||||
|
||||
test("nextPageState does not mutate its input", () => {
|
||||
const before = { ...current };
|
||||
nextPageState(current, { page: 1, total: 5 });
|
||||
assert.deepEqual(current, before);
|
||||
});
|
||||
|
||||
test("shouldSaveEdit is true only for a non-empty text that changed", () => {
|
||||
assert.equal(shouldSaveEdit("old", "new"), true);
|
||||
assert.equal(shouldSaveEdit("same", "same"), false);
|
||||
assert.equal(shouldSaveEdit("same", " same \n"), false, "whitespace-only changes are not saved");
|
||||
assert.equal(shouldSaveEdit("old", " "), false, "an emptied prompt is not saved");
|
||||
assert.equal(shouldSaveEdit("old", null), false);
|
||||
});
|
||||
|
||||
test("shouldLoadMore asks for the next page only near the bottom with pages left", () => {
|
||||
const base = {
|
||||
infiniteScroll: true, loading: false, page: 1, limit: 100, total: 1000,
|
||||
scrollTop: 0, clientHeight: 800, scrollHeight: 4000, threshold: 600,
|
||||
};
|
||||
assert.equal(shouldLoadMore(base), false, "far from the bottom");
|
||||
assert.equal(shouldLoadMore({ ...base, scrollTop: 2700 }), true, "within the threshold");
|
||||
assert.equal(shouldLoadMore({ ...base, scrollTop: 2700, loading: true }), false, "a load is in flight");
|
||||
assert.equal(shouldLoadMore({ ...base, scrollTop: 2700, infiniteScroll: false }), false, "setting off");
|
||||
assert.equal(shouldLoadMore({ ...base, scrollTop: 2700, page: 10 }), false, "last page reached");
|
||||
assert.equal(shouldLoadMore({ ...base, scrollHeight: 700 }), true, "content shorter than the viewport");
|
||||
assert.equal(shouldLoadMore({ ...base, total: 0, scrollHeight: 700 }), false, "nothing to load");
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
// Run with: node --test tests/js/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { parsePngTextChunks, PNG_SIGNATURE } = require("../../web/js/png-metadata.js");
|
||||
|
||||
const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
|
||||
function u32(n) {
|
||||
return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
|
||||
}
|
||||
|
||||
/** A chunk with a dummy CRC (the parser does not verify it). */
|
||||
function chunk(type, dataBytes) {
|
||||
return [...u32(dataBytes.length), ...Buffer.from(type, "latin1"), ...dataBytes, 0, 0, 0, 0];
|
||||
}
|
||||
|
||||
const IHDR = chunk("IHDR", [...u32(1), ...u32(1), 8, 6, 0, 0, 0]);
|
||||
const IEND = chunk("IEND", []);
|
||||
|
||||
function tEXt(keyword, text) {
|
||||
return chunk("tEXt", [...Buffer.from(keyword, "latin1"), 0, ...Buffer.from(text, "latin1")]);
|
||||
}
|
||||
|
||||
function iTXt(keyword, text, { compressed = false } = {}) {
|
||||
return chunk("iTXt", [
|
||||
...Buffer.from(keyword, "latin1"), 0,
|
||||
compressed ? 1 : 0, 0, // compression flag, compression method
|
||||
0, // empty language tag
|
||||
0, // empty translated keyword
|
||||
...Buffer.from(text, "utf8"),
|
||||
]);
|
||||
}
|
||||
|
||||
function png(...chunks) {
|
||||
return Uint8Array.from([...SIGNATURE, ...IHDR, ...chunks.flat(), ...IEND]);
|
||||
}
|
||||
|
||||
test("exports the PNG signature", () => {
|
||||
assert.deepEqual([...PNG_SIGNATURE], SIGNATURE);
|
||||
});
|
||||
|
||||
test("reads a tEXt chunk written by ComfyUI", () => {
|
||||
const bytes = png(tEXt("prompt", '{"a":1}'));
|
||||
assert.deepEqual(parsePngTextChunks(bytes), { prompt: '{"a":1}' });
|
||||
});
|
||||
|
||||
test("reads several text chunks and an uncompressed iTXt chunk with UTF-8 text", () => {
|
||||
const bytes = png(tEXt("prompt", '{"a":1}'), iTXt("workflow", '{"b":"日本語 🙂"}'), tEXt("Software", "ComfyUI"));
|
||||
assert.deepEqual(parsePngTextChunks(bytes), {
|
||||
prompt: '{"a":1}',
|
||||
workflow: '{"b":"日本語 🙂"}',
|
||||
Software: "ComfyUI",
|
||||
});
|
||||
});
|
||||
|
||||
test("tEXt is Latin-1, so byte 0xE9 is an e-acute", () => {
|
||||
const bytes = png(chunk("tEXt", [...Buffer.from("Comment", "latin1"), 0, 0xe9]));
|
||||
assert.deepEqual(parsePngTextChunks(bytes), { Comment: "é" });
|
||||
});
|
||||
|
||||
test("skips compressed iTXt and zTXt payloads instead of returning garbage", () => {
|
||||
const zTXt = chunk("zTXt", [...Buffer.from("Comment", "latin1"), 0, 0, 0x78, 0x9c, 1, 2, 3]);
|
||||
const bytes = png(iTXt("workflow", "zzz", { compressed: true }), zTXt, tEXt("prompt", "ok"));
|
||||
assert.deepEqual(parsePngTextChunks(bytes), { prompt: "ok" });
|
||||
});
|
||||
|
||||
test("ignores non-text chunks and a text chunk without a NUL separator", () => {
|
||||
const bytes = png(chunk("IDAT", [1, 2, 3]), chunk("tEXt", [...Buffer.from("nonul", "latin1")]), tEXt("k", "v"));
|
||||
assert.deepEqual(parsePngTextChunks(bytes), { k: "v" });
|
||||
});
|
||||
|
||||
test("accepts an ArrayBuffer as well as a Uint8Array", () => {
|
||||
const bytes = png(tEXt("prompt", "x"));
|
||||
assert.deepEqual(parsePngTextChunks(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)), { prompt: "x" });
|
||||
});
|
||||
|
||||
test("returns {} for garbage, empty and non-PNG input", () => {
|
||||
assert.deepEqual(parsePngTextChunks(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])), {});
|
||||
assert.deepEqual(parsePngTextChunks(new Uint8Array(0)), {});
|
||||
assert.deepEqual(parsePngTextChunks(Uint8Array.from(Buffer.from("GIF89a....................", "latin1"))), {});
|
||||
assert.deepEqual(parsePngTextChunks(null), {});
|
||||
assert.deepEqual(parsePngTextChunks("not bytes"), {});
|
||||
});
|
||||
|
||||
test("a file truncated inside a text chunk yields only the chunks that were complete", () => {
|
||||
const full = png(tEXt("prompt", "first"), tEXt("workflow", "second-second-second"));
|
||||
const cutInsideSecond = full.subarray(0, full.length - IEND.length - 6);
|
||||
assert.deepEqual(parsePngTextChunks(cutInsideSecond), { prompt: "first" });
|
||||
const cutInsideFirst = full.subarray(0, SIGNATURE.length + IHDR.length + 10);
|
||||
assert.deepEqual(parsePngTextChunks(cutInsideFirst), {});
|
||||
});
|
||||
|
||||
test("a chunk whose declared length overruns the file is ignored", () => {
|
||||
const bogus = [...u32(0x7fffffff), ...Buffer.from("tEXt", "latin1"), ...Buffer.from("k\0v", "latin1")];
|
||||
const bytes = Uint8Array.from([...SIGNATURE, ...IHDR, ...tEXt("ok", "1"), ...bogus]);
|
||||
assert.deepEqual(parsePngTextChunks(bytes), { ok: "1" });
|
||||
});
|
||||
|
||||
test("stops at IEND even if bytes follow it", () => {
|
||||
const bytes = Uint8Array.from([...png(tEXt("a", "1")), ...tEXt("b", "2")]);
|
||||
assert.deepEqual(parsePngTextChunks(bytes), { a: "1" });
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// Run with: node --test tests/js/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const sort = require("../../web/js/prompt-list-sort.js");
|
||||
|
||||
test("default sort is recently used and is a listed option", () => {
|
||||
assert.equal(sort.DEFAULT_SORT, "last_used_desc");
|
||||
assert.ok(sort.SORT_OPTIONS.some((o) => o.value === sort.DEFAULT_SORT));
|
||||
});
|
||||
|
||||
test("options include Recently used and Most used, each with a label", () => {
|
||||
const values = sort.SORT_OPTIONS.map((o) => o.value);
|
||||
assert.ok(values.includes("last_used_desc"));
|
||||
assert.ok(values.includes("run_count_desc"));
|
||||
for (const option of sort.SORT_OPTIONS) assert.ok(option.label.trim(), option.value);
|
||||
assert.equal(new Set(values).size, values.length, "values are unique");
|
||||
});
|
||||
|
||||
test("options cannot be mutated by callers", () => {
|
||||
assert.throws(() => sort.SORT_OPTIONS.push({ value: "x", label: "x" }));
|
||||
// Non-strict code fails silently on frozen objects, so check the value survived
|
||||
const first = sort.SORT_OPTIONS[0].value;
|
||||
sort.SORT_OPTIONS[0].value = "hacked";
|
||||
assert.equal(sort.SORT_OPTIONS[0].value, first);
|
||||
assert.ok(sort.SORT_OPTIONS.every(Object.isFrozen));
|
||||
});
|
||||
|
||||
test("normalizeSort keeps known values and falls back for anything else", () => {
|
||||
assert.equal(sort.normalizeSort("run_count_desc"), "run_count_desc");
|
||||
for (const bad of [undefined, null, "", "created_at; DROP TABLE prompts", 42, "LAST_USED_DESC"]) {
|
||||
assert.equal(sort.normalizeSort(bad), sort.DEFAULT_SORT, String(bad));
|
||||
}
|
||||
});
|
||||
|
||||
test("buildRecentUrl computes offset from page and always sends a valid sort", () => {
|
||||
assert.equal(
|
||||
sort.buildRecentUrl({ page: 3, limit: 25, sort: "rating_desc" }),
|
||||
"/prompt_manager/recent?limit=25&offset=50&page=3&sort=rating_desc",
|
||||
);
|
||||
assert.equal(
|
||||
sort.buildRecentUrl({ sort: "bogus" }),
|
||||
"/prompt_manager/recent?limit=50&offset=0&page=1&sort=last_used_desc",
|
||||
);
|
||||
});
|
||||
|
||||
test("withSort returns a copy with the sort set and leaves the input untouched", () => {
|
||||
const params = new URLSearchParams({ text: "castle", sort: "text_asc" });
|
||||
const result = sort.withSort(params, "run_count_desc");
|
||||
assert.equal(result.get("sort"), "run_count_desc");
|
||||
assert.equal(result.get("text"), "castle");
|
||||
assert.equal(params.get("sort"), "text_asc");
|
||||
assert.equal(sort.withSort(new URLSearchParams(), "nope").get("sort"), sort.DEFAULT_SORT);
|
||||
});
|
||||
|
||||
test("formatRunCount labels zero, one and many runs", () => {
|
||||
assert.equal(sort.formatRunCount(0), "Never run");
|
||||
assert.equal(sort.formatRunCount(1), "1 run");
|
||||
assert.equal(sort.formatRunCount(642), "642 runs");
|
||||
assert.equal(sort.formatRunCount(1234), "1,234 runs");
|
||||
});
|
||||
|
||||
test("formatRunCount hides the badge for missing or invalid counts", () => {
|
||||
for (const bad of [undefined, null, -1, 1.5, "3", NaN]) {
|
||||
assert.equal(sort.formatRunCount(bad), "", String(bad));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// Run with: node --test tests/js/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
initialState,
|
||||
reduceScanEvent,
|
||||
fromStatus,
|
||||
dismissAction,
|
||||
progressLabel,
|
||||
} = require("../../web/js/scan-progress.js");
|
||||
|
||||
test("initialState is idle with zero counts", () => {
|
||||
assert.deepEqual(initialState(), {
|
||||
status: "idle", progress: 0, statusText: "", processed: 0, found: 0, added: 0, linked: 0, message: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("a progress event moves the scan to running and copies the counts", () => {
|
||||
const before = initialState();
|
||||
const after = reduceScanEvent(before, {
|
||||
type: "progress", progress: 42, status: "Processing file 84/200...", processed: 84, found: 12,
|
||||
});
|
||||
assert.equal(after.status, "running");
|
||||
assert.equal(after.progress, 42);
|
||||
assert.equal(after.statusText, "Processing file 84/200...");
|
||||
assert.equal(after.processed, 84);
|
||||
assert.equal(after.found, 12);
|
||||
assert.equal(before.status, "idle", "reducer must not mutate its input");
|
||||
});
|
||||
|
||||
test("a complete event finishes the scan with the final totals", () => {
|
||||
const running = reduceScanEvent(initialState(), { type: "progress", progress: 90, status: "x", processed: 9, found: 1 });
|
||||
const done = reduceScanEvent(running, { type: "complete", processed: 10, found: 3, added: 2, linked: 1 });
|
||||
assert.equal(done.status, "done");
|
||||
assert.equal(done.progress, 100);
|
||||
assert.deepEqual([done.processed, done.found, done.added, done.linked], [10, 3, 2, 1]);
|
||||
});
|
||||
|
||||
test("an error event fails the scan and keeps the server message", () => {
|
||||
const failed = reduceScanEvent(initialState(), { type: "error", message: "No output directories found." });
|
||||
assert.equal(failed.status, "failed");
|
||||
assert.equal(failed.message, "No output directories found.");
|
||||
});
|
||||
|
||||
test("unknown events leave the state untouched", () => {
|
||||
const state = reduceScanEvent(initialState(), { type: "progress", progress: 5, status: "s", processed: 1, found: 0 });
|
||||
assert.equal(reduceScanEvent(state, { type: "mystery" }), state);
|
||||
assert.equal(reduceScanEvent(state, null), state);
|
||||
});
|
||||
|
||||
test("fromStatus rebuilds a running scan from the status endpoint", () => {
|
||||
const state = fromStatus({
|
||||
running: true,
|
||||
last_event: { type: "progress", progress: 30, status: "Processing file 3/10...", processed: 3, found: 1 },
|
||||
});
|
||||
assert.equal(state.status, "running");
|
||||
assert.equal(state.progress, 30);
|
||||
assert.equal(state.processed, 3);
|
||||
});
|
||||
|
||||
test("fromStatus is idle when nothing is running, even if a last event exists", () => {
|
||||
assert.equal(fromStatus({ running: false, last_event: { type: "complete", processed: 10 } }).status, "idle");
|
||||
assert.equal(fromStatus({ running: false, last_event: null }).status, "idle");
|
||||
assert.equal(fromStatus(null).status, "idle");
|
||||
});
|
||||
|
||||
test("dismissing the modal minimizes a running scan and closes otherwise", () => {
|
||||
const running = reduceScanEvent(initialState(), { type: "progress", progress: 1, status: "s", processed: 0, found: 0 });
|
||||
assert.equal(dismissAction(running), "minimize");
|
||||
assert.equal(dismissAction(initialState()), "close");
|
||||
assert.equal(dismissAction(reduceScanEvent(running, { type: "complete" })), "close");
|
||||
});
|
||||
|
||||
test("progressLabel summarises a running scan for the minimized pill", () => {
|
||||
const running = reduceScanEvent(initialState(), { type: "progress", progress: 42, status: "s", processed: 84, found: 12 });
|
||||
assert.equal(progressLabel(running), "Scanning 42% · 84 files · 12 prompts");
|
||||
assert.equal(progressLabel(initialState()), "");
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
// Run with: node --test tests/js/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { parseSseChunk, connect } = require("../../web/js/sse-stream.js");
|
||||
|
||||
test("parseSseChunk splits complete events and keeps the unfinished tail", () => {
|
||||
const { events, rest } = parseSseChunk('data: {"a":1}\n\ndata: {"b":2}\n\ndata: {"c"');
|
||||
assert.deepEqual(events.map((e) => e.data), ['{"a":1}', '{"b":2}']);
|
||||
assert.equal(rest, 'data: {"c"');
|
||||
});
|
||||
|
||||
test("parseSseChunk joins multi-line data, reads event names and ignores comments", () => {
|
||||
const { events, rest } = parseSseChunk(": keep-alive\nevent: progress\ndata: line1\ndata: line2\nid: 7\n\n");
|
||||
assert.deepEqual(events, [{ event: "progress", data: "line1\nline2" }]);
|
||||
assert.equal(rest, "");
|
||||
});
|
||||
|
||||
test("parseSseChunk accepts CRLF line endings and a missing space after the colon", () => {
|
||||
const { events } = parseSseChunk("data:x\r\n\r\ndata: y\r\n\r\n");
|
||||
assert.deepEqual(events.map((e) => e.data), ["x", "y"]);
|
||||
});
|
||||
|
||||
test("parseSseChunk returns nothing for an empty or whitespace buffer", () => {
|
||||
assert.deepEqual(parseSseChunk(""), { events: [], rest: "" });
|
||||
assert.deepEqual(parseSseChunk("\n\n"), { events: [], rest: "" });
|
||||
});
|
||||
|
||||
function streamOf(chunks) {
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
for (const c of chunks) controller.enqueue(encoder.encode(c));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function until(check, timeoutMs = 500) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const started = Date.now();
|
||||
(function poll() {
|
||||
if (check()) return resolve();
|
||||
if (Date.now() - started > timeoutMs) return reject(new Error("timed out"));
|
||||
setTimeout(poll, 2);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
test("connect POSTs and delivers each event to onmessage, then onclose", async () => {
|
||||
const seen = [];
|
||||
let closed = false;
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return new Response(streamOf(['data: {"n":1}\n\ndata: {"n', '":2}\n\n']), { status: 200 });
|
||||
};
|
||||
const source = connect("/prompt_manager/autotag/start?x=1", { method: "POST", fetchImpl });
|
||||
source.onmessage = (e) => seen.push(JSON.parse(e.data).n);
|
||||
source.onclose = () => { closed = true; };
|
||||
await until(() => closed);
|
||||
assert.deepEqual(seen, [1, 2]);
|
||||
assert.equal(calls[0].init.method, "POST");
|
||||
assert.equal(calls[0].url, "/prompt_manager/autotag/start?x=1");
|
||||
assert.equal(source.readyState, source.CLOSED);
|
||||
});
|
||||
|
||||
test("connect keeps streaming requests under the current proxy mount", async () => {
|
||||
const previousApiClient = globalThis.ApiClient;
|
||||
const previousLocation = globalThis.location;
|
||||
globalThis.ApiClient = require("../../web/js/api-client.js");
|
||||
globalThis.location = { pathname: "/ComfyBackendDirect/prompt_manager/gallery" };
|
||||
let requestedUrl;
|
||||
try {
|
||||
const source = connect("/prompt_manager/images/generate-thumbnails/progress?quality=80", {
|
||||
method: "POST",
|
||||
fetchImpl: async (url) => {
|
||||
requestedUrl = url;
|
||||
return new Response(streamOf([]), { status: 200 });
|
||||
},
|
||||
});
|
||||
await until(() => source.readyState === source.CLOSED);
|
||||
assert.equal(requestedUrl, "/ComfyBackendDirect/prompt_manager/images/generate-thumbnails/progress?quality=80");
|
||||
} finally {
|
||||
globalThis.ApiClient = previousApiClient;
|
||||
globalThis.location = previousLocation;
|
||||
}
|
||||
});
|
||||
|
||||
test("connect reports a non-2xx response through onerror and never onmessage", async () => {
|
||||
let error = null;
|
||||
const fetchImpl = async () => new Response("nope", { status: 405 });
|
||||
const source = connect("/x", { fetchImpl });
|
||||
source.onmessage = () => assert.fail("no message expected");
|
||||
source.onerror = (err) => { error = err; };
|
||||
await until(() => error !== null);
|
||||
assert.match(error.message, /405/);
|
||||
assert.equal(source.readyState, source.CLOSED);
|
||||
});
|
||||
|
||||
test("connect reports a network failure through onerror", async () => {
|
||||
let error = null;
|
||||
const source = connect("/x", { fetchImpl: async () => { throw new TypeError("Failed to fetch"); } });
|
||||
source.onerror = (err) => { error = err; };
|
||||
await until(() => error !== null);
|
||||
assert.equal(error.message, "Failed to fetch");
|
||||
});
|
||||
|
||||
test("close() aborts the request and suppresses later callbacks", async () => {
|
||||
let aborted = false;
|
||||
let errors = 0;
|
||||
const fetchImpl = (url, init) =>
|
||||
new Promise((resolve, reject) => {
|
||||
init.signal.addEventListener("abort", () => {
|
||||
aborted = true;
|
||||
reject(new DOMException("aborted", "AbortError"));
|
||||
});
|
||||
resolve(new Response(new ReadableStream({ start() {} }), { status: 200 }));
|
||||
});
|
||||
const source = connect("/x", { fetchImpl });
|
||||
source.onerror = () => errors++;
|
||||
await until(() => source.readyState === source.OPEN);
|
||||
source.close();
|
||||
assert.equal(aborted, true);
|
||||
assert.equal(source.readyState, source.CLOSED);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(errors, 0);
|
||||
});
|
||||
|
||||
test("connect forwards a request body and an Accept header", async () => {
|
||||
let init = null;
|
||||
const fetchImpl = async (_url, i) => { init = i; return new Response(streamOf([]), { status: 200 }); };
|
||||
const source = connect("/x", { method: "POST", body: '{"a":1}', fetchImpl });
|
||||
await until(() => source.readyState === source.CLOSED);
|
||||
assert.equal(init.body, '{"a":1}');
|
||||
assert.equal(init.headers.Accept, "text/event-stream");
|
||||
});
|
||||
|
||||
test("events arriving with no onmessage handler are dropped without error", async () => {
|
||||
const fetchImpl = async () => new Response(streamOf(["data: 1\n\n", "data: 2\n\n"]), { status: 200 });
|
||||
const source = connect("/x", { fetchImpl });
|
||||
let errors = 0;
|
||||
source.onerror = () => errors++;
|
||||
await until(() => source.readyState === source.CLOSED);
|
||||
assert.equal(errors, 0);
|
||||
});
|
||||
|
||||
test("close() during delivery stops further messages and skips onclose", async () => {
|
||||
const seen = [];
|
||||
let closed = false;
|
||||
const fetchImpl = async () => new Response(streamOf(["data: 1\n\ndata: 2\n\ndata: 3\n\n"]), { status: 200 });
|
||||
const source = connect("/x", { fetchImpl });
|
||||
source.onmessage = (e) => { seen.push(e.data); if (e.data === "1") source.close(); };
|
||||
source.onclose = () => { closed = true; };
|
||||
await until(() => source.readyState === source.CLOSED);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.deepEqual(seen, ["1"]);
|
||||
assert.equal(closed, false);
|
||||
});
|
||||
|
||||
test("close() before the response arrives discards it silently", async () => {
|
||||
let resolveFetch;
|
||||
let errors = 0;
|
||||
const fetchImpl = () => new Promise((resolve) => { resolveFetch = resolve; });
|
||||
const source = connect("/x", { fetchImpl });
|
||||
source.onerror = () => errors++;
|
||||
source.close();
|
||||
resolveFetch(new Response(streamOf(["data: late\n\n"]), { status: 200 }));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(errors, 0);
|
||||
assert.equal(source.readyState, source.CLOSED);
|
||||
});
|
||||
|
||||
test("connect defaults to GET and the global fetch", async () => {
|
||||
const original = globalThis.fetch;
|
||||
let init = null;
|
||||
globalThis.fetch = async (_url, i) => { init = i; return new Response(streamOf([]), { status: 200 }); };
|
||||
try {
|
||||
const source = connect("/x");
|
||||
await until(() => source.readyState === source.CLOSED);
|
||||
assert.equal(init.method, "GET");
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("addEventListener receives named events and unnamed ones go to 'message'", async () => {
|
||||
const named = [];
|
||||
const plain = [];
|
||||
let closed = false;
|
||||
const fetchImpl = async () =>
|
||||
new Response(
|
||||
streamOf(['event: start\ndata: {"total":3}\n\n', 'data: {"n":1}\n\n', 'event: complete\ndata: {"ok":true}\n\n']),
|
||||
{ status: 200 },
|
||||
);
|
||||
const source = connect("/prompt_manager/images/generate-thumbnails/progress", { method: "POST", fetchImpl });
|
||||
source.addEventListener("start", (e) => named.push(["start", JSON.parse(e.data).total]));
|
||||
source.addEventListener("complete", (e) => named.push(["complete", JSON.parse(e.data).ok]));
|
||||
source.addEventListener("message", (e) => plain.push(JSON.parse(e.data).n));
|
||||
source.onclose = () => { closed = true; };
|
||||
await until(() => closed);
|
||||
assert.deepEqual(named, [["start", 3], ["complete", true]]);
|
||||
assert.deepEqual(plain, [1]);
|
||||
});
|
||||
|
||||
test("removeEventListener stops delivery and onmessage still sees every event", async () => {
|
||||
const all = [];
|
||||
const progress = [];
|
||||
let closed = false;
|
||||
const fetchImpl = async () =>
|
||||
new Response(streamOf(["event: progress\ndata: 1\n\n", "event: progress\ndata: 2\n\n"]), { status: 200 });
|
||||
const source = connect("/x", { fetchImpl });
|
||||
const listener = (e) => {
|
||||
progress.push(e.data);
|
||||
source.removeEventListener("progress", listener);
|
||||
};
|
||||
source.addEventListener("progress", listener);
|
||||
source.onmessage = (e) => all.push(`${e.event}:${e.data}`);
|
||||
source.onclose = () => { closed = true; };
|
||||
await until(() => closed);
|
||||
assert.deepEqual(progress, ["1"]);
|
||||
assert.deepEqual(all, ["progress:1", "progress:2"]);
|
||||
});
|
||||
|
||||
test("a listener that closes the source stops later events", async () => {
|
||||
const seen = [];
|
||||
const fetchImpl = async () =>
|
||||
new Response(streamOf(["event: error\ndata: boom\n\n", "event: progress\ndata: 2\n\n"]), { status: 200 });
|
||||
const source = connect("/x", { fetchImpl });
|
||||
source.addEventListener("error", (e) => { seen.push(e.data); source.close(); });
|
||||
source.addEventListener("progress", (e) => seen.push(e.data));
|
||||
await until(() => source.readyState === source.CLOSED);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.deepEqual(seen, ["boom"]);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// Run with: node --test tests/js/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
splitTagInput,
|
||||
applySuggestion,
|
||||
suggestUrl,
|
||||
moveSelection,
|
||||
} = require("../../web/js/tag-autocomplete.js");
|
||||
|
||||
test("splitTagInput separates committed tags from the tag being typed", () => {
|
||||
assert.deepEqual(splitTagInput("asian, portrait, sm"), { committed: ["asian", "portrait"], current: "sm" });
|
||||
assert.deepEqual(splitTagInput("asian,"), { committed: ["asian"], current: "" });
|
||||
assert.deepEqual(splitTagInput("asian, "), { committed: ["asian"], current: "" });
|
||||
assert.deepEqual(splitTagInput("asi"), { committed: [], current: "asi" });
|
||||
assert.deepEqual(splitTagInput(""), { committed: [], current: "" });
|
||||
assert.deepEqual(splitTagInput(" asian ,, Asian , dog, "), { committed: ["asian", "dog"], current: "" });
|
||||
});
|
||||
|
||||
test("applySuggestion replaces the partial tag and leaves the cursor ready for the next one", () => {
|
||||
assert.equal(applySuggestion("asian, sm", "smile"), "asian, smile, ");
|
||||
assert.equal(applySuggestion("", "asian"), "asian, ");
|
||||
assert.equal(applySuggestion("asian, ", "portrait"), "asian, portrait, ");
|
||||
assert.equal(applySuggestion("asian, asi", "asian"), "asian, ");
|
||||
});
|
||||
|
||||
test("suggestUrl encodes the prefix and the committed tags", () => {
|
||||
assert.equal(suggestUrl("asian, sm"), "/prompt_manager/tags/suggest?q=sm&with=asian&limit=15");
|
||||
assert.equal(suggestUrl("a&b, c d, x", 5), "/prompt_manager/tags/suggest?q=x&with=a%26b%2Cc+d&limit=5");
|
||||
assert.equal(suggestUrl(""), "/prompt_manager/tags/suggest?q=&with=&limit=15");
|
||||
});
|
||||
|
||||
test("moveSelection wraps around the list and starts from nothing selected", () => {
|
||||
assert.equal(moveSelection(-1, 1, 3), 0);
|
||||
assert.equal(moveSelection(-1, -1, 3), 2);
|
||||
assert.equal(moveSelection(2, 1, 3), 0);
|
||||
assert.equal(moveSelection(0, -1, 3), 2);
|
||||
assert.equal(moveSelection(1, 1, 0), -1);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
function fixture() {
|
||||
const requests = [];
|
||||
const grid = { replaceChildren() {}, appendChild() {} };
|
||||
const context = vm.createContext({ window: {}, URLSearchParams, console: { error() {} },
|
||||
requestAnimationFrame() {}, document: { getElementById: id => id === 'tagPromptsGrid' ? grid : null,
|
||||
querySelector: () => null, createElement: () => ({ appendChild() {} }) },
|
||||
});
|
||||
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/tags-page.js'), 'utf8') + '\nTagsPageManager;', context, { filename: path.join(__dirname, '../../web/js/tags-page.js') });
|
||||
const manager = Object.create(Manager.prototype);
|
||||
Object.assign(manager, { selectedTags: ['cats'], filterMode: 'and', promptLimit: 20,
|
||||
promptOffset: 0, isLoadingPrompts: false, prompts: [], hasMorePrompts: false,
|
||||
fetchJson(url) { return new Promise((resolve, reject) => requests.push({ url, resolve, reject })); },
|
||||
renderTagPrompts() {}, appendTagPrompts() {}, updatePromptCountBadge() {}, updateLoadMoreButton() {},
|
||||
});
|
||||
const reply = (index, text, more = false) => requests[index].resolve({ success: true,
|
||||
prompts: [{ text }], pagination: { has_more: more, total: more ? 40 : 1 } });
|
||||
return { manager, requests, reply };
|
||||
}
|
||||
test('changing a filter starts a new request and late results cannot overwrite it', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
const old = manager.loadTagPrompts();
|
||||
manager.selectedTags = ['dogs'];
|
||||
const current = manager.loadTagPrompts();
|
||||
assert.equal(requests.length, 2);
|
||||
assert.match(requests[1].url, /dogs/);
|
||||
reply(1, 'dog prompt'); await current;
|
||||
reply(0, 'cat prompt', true); await old;
|
||||
assert.equal(manager.prompts[0].text, 'dog prompt');
|
||||
assert.equal(manager.promptTotal, 1);
|
||||
assert.equal(manager.hasMorePrompts, false);
|
||||
});
|
||||
test('clearing selection invalidates pending results', async () => {
|
||||
const { manager, reply } = fixture();
|
||||
const old = manager.loadTagPrompts();
|
||||
manager.selectedTags = [];
|
||||
manager.clearPrompts();
|
||||
reply(0, 'cat prompt'); await old;
|
||||
assert.equal(manager.prompts.length, 0);
|
||||
assert.equal(manager.promptTotal, 0);
|
||||
assert.equal(manager.isLoadingPrompts, false);
|
||||
});
|
||||
test('a stale pagination response cannot append rows from the previous filter', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
Object.assign(manager, { prompts: [{ text: 'cat first page' }], hasMorePrompts: true, promptOffset: 20 });
|
||||
const old = manager.loadMoreTagPrompts();
|
||||
manager.selectedTags = ['dogs'];
|
||||
const current = manager.loadTagPrompts();
|
||||
assert.equal(requests.length, 2);
|
||||
reply(1, 'dog prompt'); await current;
|
||||
reply(0, 'cat second page', true); await old;
|
||||
assert.equal(manager.prompts.length, 1);
|
||||
assert.equal(manager.prompts[0].text, 'dog prompt');
|
||||
assert.equal(manager.promptOffset, 20);
|
||||
});
|
||||
test('a stale failure cannot finish the new request or clear its loading state', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
const old = manager.loadTagPrompts();
|
||||
manager.selectedTags = ['dogs'];
|
||||
const current = manager.loadTagPrompts();
|
||||
assert.equal(requests.length, 2);
|
||||
requests[0].reject(new Error('old failure')); await old;
|
||||
assert.equal(manager.isLoadingPrompts, true);
|
||||
reply(1, 'dog prompt'); await current;
|
||||
assert.equal(manager.isLoadingPrompts, false);
|
||||
assert.equal(manager.prompts[0].text, 'dog prompt');
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// Run with: node --test tests/js/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { resolveView, VIEWS } = require("../../web/js/view-router.js");
|
||||
|
||||
test("the tags deep link and its sub-routes resolve to the tags view", () => {
|
||||
assert.equal(resolveView("#/tags"), "tags");
|
||||
assert.equal(resolveView("#/tags/"), "tags");
|
||||
assert.equal(resolveView("#/tags/landscape,portrait?mode=or"), "tags");
|
||||
});
|
||||
|
||||
test("an empty, bare or unknown hash resolves to the prompts dashboard", () => {
|
||||
assert.equal(resolveView(""), "prompts");
|
||||
assert.equal(resolveView("#"), "prompts");
|
||||
assert.equal(resolveView("#/"), "prompts");
|
||||
assert.equal(resolveView("#/other"), "prompts");
|
||||
assert.equal(resolveView("#/tagsX"), "prompts", "prefix must end at a path boundary");
|
||||
assert.equal(resolveView("#tags"), "prompts");
|
||||
});
|
||||
|
||||
test("non-string input resolves to the prompts dashboard", () => {
|
||||
assert.equal(resolveView(undefined), "prompts");
|
||||
assert.equal(resolveView(null), "prompts");
|
||||
assert.equal(resolveView(42), "prompts");
|
||||
});
|
||||
|
||||
test("every resolved value is a listed view", () => {
|
||||
assert.ok(Object.isFrozen(VIEWS));
|
||||
for (const hash of ["#/tags", "", "#/x"]) assert.ok(VIEWS.includes(resolveView(hash)));
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Teardown guard: fail when a file about to be deleted is still open.
|
||||
|
||||
Windows refuses to delete an open file, so a sqlite connection that was not
|
||||
closed explicitly turns into a PermissionError in CI there. Linux would let
|
||||
the deletion through silently; this check makes the Linux run catch the same
|
||||
mistake. It inspects /proc/self/fd and is a no-op where that does not exist.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def open_handles_under(path):
|
||||
"""Basenames of this process's open files at *path* or below it."""
|
||||
fd_dir = "/proc/self/fd"
|
||||
if not os.path.isdir(fd_dir):
|
||||
return []
|
||||
real = os.path.realpath(str(path))
|
||||
hits = []
|
||||
for fd in os.listdir(fd_dir):
|
||||
try:
|
||||
target = os.readlink(os.path.join(fd_dir, fd))
|
||||
except OSError:
|
||||
continue
|
||||
if not target.startswith(os.sep):
|
||||
continue # sockets, pipes, anon inodes
|
||||
target = target.replace(" (deleted)", "")
|
||||
if (
|
||||
target == real
|
||||
or target.startswith(real + os.sep)
|
||||
or target.startswith(real + "-")
|
||||
):
|
||||
hits.append(os.path.basename(target))
|
||||
return sorted(hits)
|
||||
|
||||
|
||||
def assert_closed(testcase, *paths):
|
||||
"""Fail *testcase* if any of *paths* (files or directories) is still open."""
|
||||
hits = [hit for path in paths for hit in open_handles_under(path)]
|
||||
if hits:
|
||||
testcase.fail(
|
||||
"Files still open at teardown (Windows could not delete them): "
|
||||
+ ", ".join(hits)
|
||||
)
|
||||
@@ -0,0 +1,420 @@
|
||||
"""
|
||||
API endpoint integration tests for PromptManager.
|
||||
|
||||
Uses aiohttp test client to test request/response contracts
|
||||
without requiring a full ComfyUI server.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import AioHTTPTestCase
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from py.api import PromptManagerAPI
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
class APITestCase(AioHTTPTestCase):
|
||||
"""Base class that stands up a test aiohttp app with PromptManager routes."""
|
||||
|
||||
async def get_application(self):
|
||||
self._temp_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
|
||||
self._temp_db.close()
|
||||
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(self._temp_db.name)
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
return app
|
||||
|
||||
async def tearDownAsync(self):
|
||||
await super().tearDownAsync() # closes the aiohttp test client
|
||||
# Close every thread's connection first: Windows refuses to delete
|
||||
# an open database file, and executor threads hold connections too.
|
||||
self.api.db.close_all()
|
||||
for path in (
|
||||
self._temp_db.name,
|
||||
self._temp_db.name + "-wal",
|
||||
self._temp_db.name + "-shm",
|
||||
):
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────
|
||||
|
||||
def _save_prompt(self, text="Test prompt", category=None, tags=None, rating=None):
|
||||
return self.api.db.save_prompt(
|
||||
text=text,
|
||||
category=category,
|
||||
tags=tags or [],
|
||||
rating=rating,
|
||||
prompt_hash=generate_prompt_hash(text),
|
||||
)
|
||||
|
||||
|
||||
class TestHealthEndpoint(APITestCase):
|
||||
|
||||
async def test_test_route(self):
|
||||
resp = await self.client.request("GET", "/prompt_manager/test")
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
self.assertIn("timestamp", data)
|
||||
|
||||
|
||||
class TestRecentPrompts(APITestCase):
|
||||
|
||||
async def test_recent_empty(self):
|
||||
resp = await self.client.request("GET", "/prompt_manager/recent")
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["pagination"]["total"], 0)
|
||||
self.assertEqual(len(data["results"]), 0)
|
||||
|
||||
async def test_recent_with_data(self):
|
||||
self._save_prompt("First prompt")
|
||||
self._save_prompt("Second prompt")
|
||||
resp = await self.client.request("GET", "/prompt_manager/recent")
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertEqual(data["pagination"]["total"], 2)
|
||||
self.assertEqual(len(data["results"]), 2)
|
||||
|
||||
async def test_recent_pagination(self):
|
||||
for i in range(15):
|
||||
self._save_prompt(f"Prompt {i:02d}")
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/recent?limit=5&offset=0"
|
||||
)
|
||||
data = await resp.json()
|
||||
self.assertEqual(len(data["results"]), 5)
|
||||
self.assertEqual(data["pagination"]["total"], 15)
|
||||
self.assertTrue(data["pagination"]["has_more"])
|
||||
|
||||
async def test_recent_last_page(self):
|
||||
for i in range(7):
|
||||
self._save_prompt(f"Prompt {i}")
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/recent?limit=5&offset=5"
|
||||
)
|
||||
data = await resp.json()
|
||||
self.assertEqual(len(data["results"]), 2)
|
||||
self.assertFalse(data["pagination"]["has_more"])
|
||||
|
||||
|
||||
class TestSearch(APITestCase):
|
||||
|
||||
async def test_search_by_text(self):
|
||||
self._save_prompt("Beautiful mountain landscape")
|
||||
self._save_prompt("City skyline at night")
|
||||
resp = await self.client.request("GET", "/prompt_manager/search?text=mountain")
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(len(data["results"]), 1)
|
||||
self.assertIn("mountain", data["results"][0]["text"])
|
||||
|
||||
async def test_search_by_category(self):
|
||||
self._save_prompt("Nature scene", category="nature")
|
||||
self._save_prompt("Urban view", category="urban")
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/search?category=nature"
|
||||
)
|
||||
data = await resp.json()
|
||||
self.assertEqual(len(data["results"]), 1)
|
||||
|
||||
async def test_search_by_tag(self):
|
||||
self._save_prompt("Tagged prompt", tags=["landscape", "sunset"])
|
||||
self._save_prompt("Other prompt", tags=["portrait"])
|
||||
resp = await self.client.request("GET", "/prompt_manager/search?tags=landscape")
|
||||
data = await resp.json()
|
||||
self.assertEqual(len(data["results"]), 1)
|
||||
|
||||
async def test_search_empty_result(self):
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/search?text=nonexistent_xyz"
|
||||
)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(len(data["results"]), 0)
|
||||
|
||||
|
||||
class TestSaveAndDelete(APITestCase):
|
||||
|
||||
async def test_save_prompt(self):
|
||||
resp = await self.client.request(
|
||||
"POST",
|
||||
"/prompt_manager/save",
|
||||
json={
|
||||
"text": "New prompt via API",
|
||||
"category": "test",
|
||||
"tags": ["api", "test"],
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
self.assertIn("prompt_id", data)
|
||||
|
||||
async def test_save_empty_text(self):
|
||||
resp = await self.client.request(
|
||||
"POST", "/prompt_manager/save", json={"text": ""}
|
||||
)
|
||||
self.assertEqual(resp.status, 400)
|
||||
data = await resp.json()
|
||||
self.assertFalse(data["success"])
|
||||
|
||||
async def test_save_with_null_optional_fields(self):
|
||||
"""Regression for #143: UI sends null for empty optional fields."""
|
||||
resp = await self.client.request(
|
||||
"POST",
|
||||
"/prompt_manager/save",
|
||||
json={"text": "Null notes prompt", "category": None, "notes": None},
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
|
||||
async def test_save_with_null_text(self):
|
||||
resp = await self.client.request(
|
||||
"POST", "/prompt_manager/save", json={"text": None}
|
||||
)
|
||||
self.assertEqual(resp.status, 400)
|
||||
|
||||
async def test_delete_prompt(self):
|
||||
pid = self._save_prompt("To delete")
|
||||
resp = await self.client.request("DELETE", f"/prompt_manager/delete/{pid}")
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
|
||||
async def test_delete_nonexistent(self):
|
||||
resp = await self.client.request("DELETE", "/prompt_manager/delete/99999")
|
||||
self.assertEqual(resp.status, 404)
|
||||
data = await resp.json()
|
||||
self.assertFalse(data["success"])
|
||||
|
||||
|
||||
class TestUpdatePrompt(APITestCase):
|
||||
|
||||
async def test_update_text(self):
|
||||
pid = self._save_prompt("Original text")
|
||||
resp = await self.client.request(
|
||||
"PUT",
|
||||
f"/prompt_manager/prompts/{pid}",
|
||||
json={"text": "Updated text"},
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
|
||||
async def test_update_rating(self):
|
||||
pid = self._save_prompt("Ratable")
|
||||
resp = await self.client.request(
|
||||
"PUT",
|
||||
f"/prompt_manager/prompts/{pid}/rating",
|
||||
json={"rating": 4},
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
|
||||
async def test_add_tag(self):
|
||||
pid = self._save_prompt("Taggable", tags=["old"])
|
||||
resp = await self.client.request(
|
||||
"POST",
|
||||
f"/prompt_manager/prompts/{pid}/tags",
|
||||
json={"tag": "new_tag"},
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
|
||||
|
||||
class TestUsageSorting(APITestCase):
|
||||
"""Server-side sort for /recent and /search (3.2.4, #90.1)."""
|
||||
|
||||
def _use(self, prompt_id, times=1):
|
||||
for _ in range(times):
|
||||
self.api.db.record_prompt_use(prompt_id)
|
||||
|
||||
async def _ids(self, path):
|
||||
resp = await self.client.request("GET", path)
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
return [p["id"] for p in data["results"]]
|
||||
|
||||
async def test_recent_sorted_by_last_used_bubbles_rerun_prompt(self):
|
||||
old = self._save_prompt("Old prompt")
|
||||
self._save_prompt("New prompt")
|
||||
self._use(old)
|
||||
ids = await self._ids("/prompt_manager/recent?limit=1&sort=last_used_desc")
|
||||
self.assertEqual(ids, [old])
|
||||
|
||||
async def test_recent_sorted_by_run_count(self):
|
||||
# Most-run prompt is the oldest, so newest-first would give the opposite order
|
||||
most = self._save_prompt("Run thrice")
|
||||
least = self._save_prompt("Run once")
|
||||
self._use(most, times=3)
|
||||
self._use(least)
|
||||
ids = await self._ids("/prompt_manager/recent?sort=run_count_desc")
|
||||
self.assertEqual(ids, [most, least])
|
||||
|
||||
async def test_recent_default_is_newest_first(self):
|
||||
first = self._save_prompt("First")
|
||||
second = self._save_prompt("Second")
|
||||
self._use(first)
|
||||
self.assertEqual(await self._ids("/prompt_manager/recent"), [second, first])
|
||||
|
||||
async def test_unknown_sort_falls_back_instead_of_erroring(self):
|
||||
self._save_prompt("Only")
|
||||
ids = await self._ids(
|
||||
"/prompt_manager/recent?sort=created_at;DROP%20TABLE%20prompts"
|
||||
)
|
||||
self.assertEqual(len(ids), 1)
|
||||
|
||||
async def test_search_respects_sort(self):
|
||||
a = self._save_prompt("castle at dawn")
|
||||
self._save_prompt("castle at dusk")
|
||||
self._use(a)
|
||||
ids = await self._ids("/prompt_manager/search?text=castle&sort=last_used_desc")
|
||||
self.assertEqual(ids[0], a)
|
||||
|
||||
async def test_results_include_usage_fields(self):
|
||||
pid = self._save_prompt("Tracked")
|
||||
self._use(pid, times=2)
|
||||
resp = await self.client.request("GET", "/prompt_manager/recent")
|
||||
prompt = (await resp.json())["results"][0]
|
||||
self.assertEqual(prompt["run_count"], 2)
|
||||
self.assertTrue(prompt["last_used_at"])
|
||||
|
||||
|
||||
class TestTags(APITestCase):
|
||||
|
||||
async def test_get_all_tags(self):
|
||||
self._save_prompt("P1", tags=["alpha", "beta"])
|
||||
self._save_prompt("P2", tags=["beta", "gamma"])
|
||||
resp = await self.client.request("GET", "/prompt_manager/tags")
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
self.assertIn("tags", data)
|
||||
|
||||
async def test_get_tag_stats(self):
|
||||
self._save_prompt("P1", tags=["common", "rare"])
|
||||
self._save_prompt("P2", tags=["common"])
|
||||
resp = await self.client.request("GET", "/prompt_manager/tags/stats")
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
|
||||
async def test_rename_tag(self):
|
||||
self._save_prompt("P1", tags=["old_name"])
|
||||
resp = await self.client.request(
|
||||
"PUT",
|
||||
"/prompt_manager/tags/old_name",
|
||||
json={"new_name": "new_name"},
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
|
||||
async def test_delete_tag(self):
|
||||
self._save_prompt("P1", tags=["removable"])
|
||||
resp = await self.client.request("DELETE", "/prompt_manager/tags/removable")
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
|
||||
async def test_merge_tags(self):
|
||||
self._save_prompt("P1", tags=["target"])
|
||||
self._save_prompt("P2", tags=["source"])
|
||||
resp = await self.client.request(
|
||||
"POST",
|
||||
"/prompt_manager/tags/merge",
|
||||
json={"source_tags": ["source"], "target_tag": "target"},
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
|
||||
|
||||
class TestCategories(APITestCase):
|
||||
|
||||
async def test_get_categories(self):
|
||||
self._save_prompt("P1", category="nature")
|
||||
self._save_prompt("P2", category="urban")
|
||||
resp = await self.client.request("GET", "/prompt_manager/categories")
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
self.assertIn("categories", data)
|
||||
|
||||
|
||||
class TestStats(APITestCase):
|
||||
|
||||
async def test_get_stats(self):
|
||||
self._save_prompt("P1", category="test", tags=["t1"], rating=4)
|
||||
resp = await self.client.request("GET", "/prompt_manager/stats")
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
self.assertIn("stats", data)
|
||||
|
||||
|
||||
class TestExport(APITestCase):
|
||||
|
||||
async def test_export_json(self):
|
||||
self._save_prompt("Exportable", category="test", tags=["export"])
|
||||
resp = await self.client.request("GET", "/prompt_manager/export?format=json")
|
||||
self.assertEqual(resp.status, 200)
|
||||
# Export returns raw JSON file download, not a success envelope
|
||||
self.assertIn("Content-Disposition", resp.headers)
|
||||
text = await resp.text()
|
||||
data = json.loads(text)
|
||||
self.assertIn("prompts", data)
|
||||
self.assertEqual(len(data["prompts"]), 1)
|
||||
|
||||
|
||||
class TestResponseEnvelope(APITestCase):
|
||||
"""Verify all responses follow the {success: bool, ...} envelope."""
|
||||
|
||||
async def test_success_responses_have_success_true(self):
|
||||
self._save_prompt("Envelope test")
|
||||
endpoints = [
|
||||
("GET", "/prompt_manager/test"),
|
||||
("GET", "/prompt_manager/recent"),
|
||||
("GET", "/prompt_manager/search"),
|
||||
("GET", "/prompt_manager/categories"),
|
||||
("GET", "/prompt_manager/tags"),
|
||||
("GET", "/prompt_manager/stats"),
|
||||
]
|
||||
for method, path in endpoints:
|
||||
resp = await self.client.request(method, path)
|
||||
data = await resp.json()
|
||||
self.assertIn("success", data, f"Missing 'success' key in {method} {path}")
|
||||
self.assertTrue(
|
||||
data["success"], f"Expected success=True for {method} {path}"
|
||||
)
|
||||
|
||||
async def test_error_responses_have_success_false(self):
|
||||
resp = await self.client.request("DELETE", "/prompt_manager/delete/99999")
|
||||
data = await resp.json()
|
||||
self.assertIn("success", data)
|
||||
self.assertFalse(data["success"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
||||
"""Route tests for GET /prompt_manager/backup and POST /prompt_manager/restore.
|
||||
|
||||
The backup must include rows still sitting in the WAL, the restore must
|
||||
stream the upload to disk, reject anything that is not a healthy prompts
|
||||
database and leave the live database untouched when it does.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from database.models import PromptModel # noqa: E402
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed # noqa: E402
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed # noqa: E402
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import AioHTTPTestCase
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from py.api import PromptManagerAPI, _public_path
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
def _texts(path):
|
||||
conn = sqlite3.connect(path)
|
||||
try:
|
||||
return sorted(r[0] for r in conn.execute("SELECT text FROM prompts"))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class BackupRestoreTestCase(AioHTTPTestCase):
|
||||
async def get_application(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.db_path = os.path.join(self.tmpdir, "prompts.db")
|
||||
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(self.db_path)
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
return app
|
||||
|
||||
async def tearDownAsync(self):
|
||||
await super().tearDownAsync() # closes the aiohttp test client
|
||||
PromptModel.close_all_instances() # restore may have swapped api.db
|
||||
assert_closed(self, self.tmpdir)
|
||||
for name in os.listdir(self.tmpdir):
|
||||
os.unlink(os.path.join(self.tmpdir, name))
|
||||
os.rmdir(self.tmpdir)
|
||||
|
||||
def _save(self, text):
|
||||
return self.api.db.save_prompt(
|
||||
text=text, prompt_hash=generate_prompt_hash(text)
|
||||
)
|
||||
|
||||
def _write(self, name, data):
|
||||
path = os.path.join(self.tmpdir, name)
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(data)
|
||||
return path
|
||||
|
||||
def _make_db_file(self, texts, name="upload.db"):
|
||||
path = os.path.join(self.tmpdir, name)
|
||||
db = PromptDatabase(path)
|
||||
for text in texts:
|
||||
db.save_prompt(text=text, prompt_hash=generate_prompt_hash(text))
|
||||
db.close_all()
|
||||
return path
|
||||
|
||||
async def _restore(self, path, field="database_file"):
|
||||
with open(path, "rb") as fh:
|
||||
form = aiohttp.FormData()
|
||||
form.add_field(
|
||||
field,
|
||||
fh.read(),
|
||||
filename=os.path.basename(path),
|
||||
content_type="application/octet-stream",
|
||||
)
|
||||
return await self.client.post("/prompt_manager/restore", data=form)
|
||||
|
||||
|
||||
class TestBackupRoute(BackupRestoreTestCase):
|
||||
async def test_backup_download_contains_rows_still_in_the_wal(self):
|
||||
for n in range(3):
|
||||
self._save(f"backup me {n}")
|
||||
self.assertGreater(os.path.getsize(self.db_path + "-wal"), 0)
|
||||
created = []
|
||||
real_mkstemp = tempfile.mkstemp
|
||||
|
||||
def spy_mkstemp(*args, **kwargs):
|
||||
result = real_mkstemp(*args, **kwargs)
|
||||
created.append(result[1])
|
||||
return result
|
||||
|
||||
with patch("py.api.admin.tempfile.mkstemp", spy_mkstemp):
|
||||
resp = await self.client.get("/prompt_manager/backup")
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(resp.content_type, "application/octet-stream")
|
||||
disposition = resp.headers["Content-Disposition"]
|
||||
self.assertIn("attachment", disposition)
|
||||
self.assertIn("prompts_backup_", disposition)
|
||||
self.assertTrue(disposition.rstrip('"').endswith(".db"))
|
||||
|
||||
body = await resp.read()
|
||||
downloaded = self._write("downloaded.db", body)
|
||||
self.assertEqual(
|
||||
_texts(downloaded), ["backup me 0", "backup me 1", "backup me 2"]
|
||||
)
|
||||
# The server-side temp file is gone once the response is complete
|
||||
self.assertEqual(len(created), 1)
|
||||
self.assertFalse(os.path.exists(created[0]))
|
||||
|
||||
async def test_backup_of_missing_database_is_404(self):
|
||||
self.api.db.model.db_path = os.path.join(self.tmpdir, "nope.db")
|
||||
resp = await self.client.get("/prompt_manager/backup")
|
||||
self.assertEqual(resp.status, 404)
|
||||
data = await resp.json()
|
||||
self.assertFalse(data["success"])
|
||||
|
||||
async def test_backup_failure_is_500(self):
|
||||
self._save("x")
|
||||
with patch.object(self.api.db.model, "backup_database", return_value=False):
|
||||
resp = await self.client.get("/prompt_manager/backup")
|
||||
self.assertEqual(resp.status, 500)
|
||||
data = await resp.json()
|
||||
self.assertFalse(data["success"])
|
||||
self.assertIn("backup", data["error"].lower())
|
||||
|
||||
|
||||
class TestRestoreRoute(BackupRestoreTestCase):
|
||||
async def test_restore_replaces_database_and_reports_backup(self):
|
||||
self._save("old prompt")
|
||||
old_db = self.api.db
|
||||
upload = self._make_db_file(["restored a", "restored b"])
|
||||
|
||||
resp = await self._restore(upload)
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["prompt_count"], 2)
|
||||
# The response names the safety backup without the server's layout
|
||||
backups = glob.glob(self.db_path + ".backup_*")
|
||||
self.assertEqual(len(backups), 1)
|
||||
self.assertEqual(data["backup_created"], _public_path(backups[0]))
|
||||
self.assertNotIn(self.tmpdir, data["backup_created"])
|
||||
self.assertEqual(_texts(backups[0]), ["old prompt"])
|
||||
self.assertIsNot(self.api.db, old_db)
|
||||
self.assertEqual(self.api.db.model.db_path, self.db_path)
|
||||
|
||||
texts = sorted(p["text"] for p in self.api.db.search_prompts())
|
||||
self.assertEqual(texts, ["restored a", "restored b"])
|
||||
|
||||
async def test_restore_leaves_no_temp_files_behind(self):
|
||||
upload = self._make_db_file(["restored"])
|
||||
created = []
|
||||
real_mkstemp = tempfile.mkstemp
|
||||
|
||||
def spy_mkstemp(*args, **kwargs):
|
||||
result = real_mkstemp(*args, **kwargs)
|
||||
created.append(result[1])
|
||||
return result
|
||||
|
||||
with patch("py.api.admin.tempfile.mkstemp", spy_mkstemp):
|
||||
resp = await self._restore(upload)
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(len(created), 1)
|
||||
for suffix in ("", "-wal", "-shm", "-journal"):
|
||||
self.assertFalse(os.path.exists(created[0] + suffix))
|
||||
|
||||
async def test_text_file_is_rejected_and_database_untouched(self):
|
||||
self._save("keep me")
|
||||
upload = self._write("notes.txt", b"this is not a database")
|
||||
|
||||
resp = await self._restore(upload)
|
||||
|
||||
self.assertEqual(resp.status, 400)
|
||||
data = await resp.json()
|
||||
self.assertFalse(data["success"])
|
||||
self.assertIn("SQLite", data["error"])
|
||||
self.assertEqual([p["text"] for p in self.api.db.search_prompts()], ["keep me"])
|
||||
self.assertFalse(
|
||||
any(n.startswith("prompts.db.backup_") for n in os.listdir(self.tmpdir))
|
||||
)
|
||||
|
||||
async def test_database_without_prompts_table_is_rejected(self):
|
||||
self._save("keep me")
|
||||
path = os.path.join(self.tmpdir, "other.db")
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute("CREATE TABLE things (id INTEGER)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
resp = await self._restore(path)
|
||||
|
||||
self.assertEqual(resp.status, 400)
|
||||
data = await resp.json()
|
||||
self.assertFalse(data["success"])
|
||||
self.assertIn("prompts", data["error"])
|
||||
self.assertEqual([p["text"] for p in self.api.db.search_prompts()], ["keep me"])
|
||||
|
||||
async def test_wrong_field_name_is_rejected(self):
|
||||
upload = self._make_db_file(["restored"])
|
||||
resp = await self._restore(upload, field="file")
|
||||
self.assertEqual(resp.status, 400)
|
||||
data = await resp.json()
|
||||
self.assertFalse(data["success"])
|
||||
self.assertIn("database_file", data["error"])
|
||||
|
||||
async def test_empty_upload_is_rejected(self):
|
||||
upload = self._write("empty.db", b"")
|
||||
resp = await self._restore(upload)
|
||||
self.assertEqual(resp.status, 400)
|
||||
data = await resp.json()
|
||||
self.assertFalse(data["success"])
|
||||
self.assertIn("empty", data["error"].lower())
|
||||
|
||||
async def test_upload_over_the_size_limit_is_rejected_while_streaming(self):
|
||||
self._save("keep me")
|
||||
self.api.restore_max_bytes = 1024
|
||||
upload = self._write("big.db", b"SQLite format 3\x00" + b"\x00" * 8192)
|
||||
|
||||
resp = await self._restore(upload)
|
||||
|
||||
self.assertEqual(resp.status, 400)
|
||||
data = await resp.json()
|
||||
self.assertFalse(data["success"])
|
||||
self.assertIn("too large", data["error"].lower())
|
||||
self.assertEqual([p["text"] for p in self.api.db.search_prompts()], ["keep me"])
|
||||
|
||||
async def test_non_multipart_request_is_rejected(self):
|
||||
resp = await self.client.post("/prompt_manager/restore", data=b"raw bytes")
|
||||
self.assertIn(resp.status, (400, 500))
|
||||
data = await resp.json()
|
||||
self.assertFalse(data["success"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,809 @@
|
||||
"""Tests for the core API module (py/api/__init__.py): helpers and base routes."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
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__))))
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed # noqa: E402
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed # noqa: E402
|
||||
from database.models import PromptModel # noqa: E402
|
||||
|
||||
from aiohttp import web # noqa: E402
|
||||
from aiohttp.test_utils import AioHTTPTestCase # noqa: E402
|
||||
|
||||
import py.api as api_module # noqa: E402
|
||||
from database.operations import PromptDatabase # noqa: E402
|
||||
from py.api import PromptManagerAPI, _public_error, _public_path # noqa: E402
|
||||
from py.config import GalleryConfig # noqa: E402
|
||||
|
||||
EXTRA_ROOTS_ENV = "PROMPT_MANAGER_EXTRA_GALLERY_ROOTS"
|
||||
|
||||
|
||||
class FolderPathsFixture(unittest.TestCase):
|
||||
"""Temp ComfyUI tree exposed through a stub folder_paths module."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.tmpdir, True)
|
||||
self.addCleanup(assert_closed, self, self.tmpdir) # just before the rmtree
|
||||
self.addCleanup(PromptModel.close_all_instances)
|
||||
self.comfy_dir = Path(self.tmpdir) / "ComfyUI"
|
||||
self.output_dir = self.comfy_dir / "output"
|
||||
self.output_dir.mkdir(parents=True)
|
||||
|
||||
self._orig_folder_paths = sys.modules.get("folder_paths")
|
||||
self.addCleanup(self._restore_folder_paths)
|
||||
self._orig_extra = os.environ.pop(EXTRA_ROOTS_ENV, None)
|
||||
self.addCleanup(self._restore_extra_env)
|
||||
|
||||
self.install_folder_paths(base_path=str(self.comfy_dir))
|
||||
|
||||
def install_folder_paths(self, **attrs):
|
||||
attrs.setdefault("get_output_directory", lambda: str(self.output_dir))
|
||||
sys.modules["folder_paths"] = types.SimpleNamespace(**attrs)
|
||||
|
||||
def _restore_folder_paths(self):
|
||||
if self._orig_folder_paths is None:
|
||||
sys.modules.pop("folder_paths", None)
|
||||
else:
|
||||
sys.modules["folder_paths"] = self._orig_folder_paths
|
||||
|
||||
def _restore_extra_env(self):
|
||||
if self._orig_extra is None:
|
||||
os.environ.pop(EXTRA_ROOTS_ENV, None)
|
||||
else:
|
||||
os.environ[EXTRA_ROOTS_ENV] = self._orig_extra
|
||||
|
||||
|
||||
class TestPublicPath(FolderPathsFixture):
|
||||
|
||||
def test_path_under_base_is_relative_posix(self):
|
||||
target = self.output_dir / "renders" / "a.png"
|
||||
self.assertEqual(_public_path(str(target)), "output/renders/a.png")
|
||||
|
||||
def test_base_itself_is_dot(self):
|
||||
self.assertEqual(_public_path(str(self.comfy_dir)), ".")
|
||||
|
||||
def test_path_outside_base_is_basename_only(self):
|
||||
secret = Path(self.tmpdir) / "secret" / "passwords.txt"
|
||||
secret.parent.mkdir()
|
||||
secret.write_text("x")
|
||||
self.assertEqual(_public_path(str(secret)), "passwords.txt")
|
||||
self.assertNotIn(self.tmpdir, _public_path(str(secret)))
|
||||
|
||||
def test_path_object_accepted(self):
|
||||
self.assertEqual(_public_path(self.output_dir), "output")
|
||||
|
||||
def test_falls_back_to_output_parent_without_base_path(self):
|
||||
self.install_folder_paths() # no base_path attribute
|
||||
self.assertEqual(_public_path(str(self.output_dir / "x.png")), "output/x.png")
|
||||
|
||||
def test_env_extra_root_keeps_its_own_name(self):
|
||||
extra = Path(self.tmpdir) / "nas" / "gallery"
|
||||
(extra / "sub").mkdir(parents=True)
|
||||
os.environ[EXTRA_ROOTS_ENV] = str(extra)
|
||||
self.assertEqual(_public_path(str(extra / "sub")), "gallery/sub")
|
||||
|
||||
def test_without_folder_paths_returns_basename(self):
|
||||
sys.modules.pop("folder_paths", None)
|
||||
self.assertEqual(_public_path(str(self.output_dir / "img.png")), "img.png")
|
||||
|
||||
def test_empty_or_none_gives_empty_string(self):
|
||||
self.assertEqual(_public_path(""), "")
|
||||
self.assertEqual(_public_path(None), "")
|
||||
|
||||
|
||||
class TestPublicError(FolderPathsFixture):
|
||||
|
||||
def test_oserror_uses_strerror_and_basename(self):
|
||||
err = FileNotFoundError(
|
||||
2, "No such file or directory", str(self.tmpdir) + "/a/b.png"
|
||||
)
|
||||
text = _public_error(err)
|
||||
self.assertIn("No such file or directory", text)
|
||||
self.assertIn("b.png", text)
|
||||
self.assertNotIn(self.tmpdir, text)
|
||||
|
||||
def test_oserror_without_filename(self):
|
||||
err = PermissionError(13, "Permission denied")
|
||||
self.assertEqual(_public_error(err), "Permission denied")
|
||||
|
||||
def test_oserror_without_strerror_uses_type_name(self):
|
||||
err = OSError()
|
||||
self.assertEqual(_public_error(err), "OSError")
|
||||
|
||||
def test_non_oserror_is_str(self):
|
||||
self.assertEqual(_public_error(ValueError("bad value")), "bad value")
|
||||
|
||||
def test_non_oserror_with_empty_message_uses_type_name(self):
|
||||
self.assertEqual(_public_error(RuntimeError()), "RuntimeError")
|
||||
|
||||
|
||||
# ── gzip middleware ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGzipMiddleware(AioHTTPTestCase):
|
||||
|
||||
async def get_application(self):
|
||||
big = json.dumps({"data": "x" * 4000})
|
||||
|
||||
async def big_json(request):
|
||||
return web.json_response(text=big)
|
||||
|
||||
async def small_json(request):
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
async def already_encoded(request):
|
||||
return web.Response(
|
||||
text=big, content_type="text/plain", headers={"Content-Encoding": "br"}
|
||||
)
|
||||
|
||||
async def binary(request):
|
||||
return web.Response(body=b"\x89PNG" * 1000, content_type="image/png")
|
||||
|
||||
async def incompressible(request):
|
||||
return web.Response(body=os.urandom(4096), content_type="text/plain")
|
||||
|
||||
async def streamed(request):
|
||||
resp = web.StreamResponse()
|
||||
await resp.prepare(request)
|
||||
await resp.write(b"x" * 4000)
|
||||
await resp.write_eof()
|
||||
return resp
|
||||
|
||||
app = web.Application(middlewares=[api_module._gzip_middleware])
|
||||
app.router.add_get("/prompt_manager/big", big_json)
|
||||
app.router.add_get("/prompt_manager/small", small_json)
|
||||
app.router.add_get("/prompt_manager/encoded", already_encoded)
|
||||
app.router.add_get("/prompt_manager/binary", binary)
|
||||
app.router.add_get("/prompt_manager/random", incompressible)
|
||||
app.router.add_get("/prompt_manager/stream", streamed)
|
||||
app.router.add_get("/other/big", big_json)
|
||||
return app
|
||||
|
||||
async def _get(self, path, accept="gzip"):
|
||||
headers = {"Accept-Encoding": accept} if accept else {}
|
||||
return await self.client.request(
|
||||
"GET",
|
||||
path,
|
||||
headers=headers,
|
||||
auto_decompress=False,
|
||||
skip_auto_headers=["Accept-Encoding"],
|
||||
)
|
||||
|
||||
async def test_large_json_is_gzipped(self):
|
||||
resp = await self._get("/prompt_manager/big")
|
||||
self.assertEqual(resp.headers.get("Content-Encoding"), "gzip")
|
||||
self.assertEqual(resp.headers.get("Vary"), "Accept-Encoding")
|
||||
import gzip
|
||||
|
||||
self.assertIn(b'"data"', gzip.decompress(await resp.read()))
|
||||
|
||||
async def test_small_body_not_compressed(self):
|
||||
resp = await self._get("/prompt_manager/small")
|
||||
self.assertIsNone(resp.headers.get("Content-Encoding"))
|
||||
|
||||
async def test_other_paths_untouched(self):
|
||||
resp = await self._get("/other/big")
|
||||
self.assertIsNone(resp.headers.get("Content-Encoding"))
|
||||
|
||||
async def test_without_accept_encoding(self):
|
||||
resp = await self._get("/prompt_manager/big", accept=None)
|
||||
self.assertIsNone(resp.headers.get("Content-Encoding"))
|
||||
|
||||
async def test_already_encoded_untouched(self):
|
||||
resp = await self._get("/prompt_manager/encoded")
|
||||
self.assertEqual(resp.headers.get("Content-Encoding"), "br")
|
||||
|
||||
async def test_binary_type_untouched(self):
|
||||
resp = await self._get("/prompt_manager/binary")
|
||||
self.assertIsNone(resp.headers.get("Content-Encoding"))
|
||||
|
||||
async def test_incompressible_body_untouched(self):
|
||||
resp = await self._get("/prompt_manager/random")
|
||||
self.assertIsNone(resp.headers.get("Content-Encoding"))
|
||||
|
||||
async def test_stream_response_untouched(self):
|
||||
resp = await self._get("/prompt_manager/stream")
|
||||
self.assertIsNone(resp.headers.get("Content-Encoding"))
|
||||
self.assertEqual(len(await resp.read()), 4000)
|
||||
|
||||
|
||||
# ── JSON body reader ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestReadJsonBody(AioHTTPTestCase):
|
||||
"""_read_json_body: chunked read with a byte cap, object-only payloads."""
|
||||
|
||||
async def get_application(self):
|
||||
async def echo(request):
|
||||
data, error = await api_module._read_json_body(request)
|
||||
if error is not None:
|
||||
return error
|
||||
return web.json_response({"success": True, "keys": sorted(data)})
|
||||
|
||||
async def small(request):
|
||||
data, error = await api_module._read_json_body(request, max_bytes=64)
|
||||
if error is not None:
|
||||
return error
|
||||
return web.json_response({"success": True, "data": data})
|
||||
|
||||
app = web.Application(client_max_size=4 * 1024 * 1024)
|
||||
app.router.add_post("/echo", echo)
|
||||
app.router.add_post("/small", small)
|
||||
return app
|
||||
|
||||
async def test_valid_object_is_parsed(self):
|
||||
resp = await self.client.request("POST", "/echo", json={"b": 1, "a": 2})
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual((await resp.json())["keys"], ["a", "b"])
|
||||
|
||||
async def test_default_cap_is_one_megabyte(self):
|
||||
self.assertEqual(api_module.JSON_BODY_MAX_BYTES, 1_000_000)
|
||||
body = json.dumps({"pad": "x" * 1_000_100}).encode()
|
||||
resp = await self.client.request(
|
||||
"POST", "/echo", data=body, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
self.assertEqual(resp.status, 413)
|
||||
data = await resp.json()
|
||||
self.assertFalse(data["success"])
|
||||
self.assertIn("too large", data["error"].lower())
|
||||
|
||||
async def test_body_just_under_cap_is_accepted(self):
|
||||
payload = {"pad": "x" * (1_000_000 - 20)}
|
||||
body = json.dumps(payload).encode()
|
||||
self.assertLessEqual(len(body), 1_000_000)
|
||||
resp = await self.client.request(
|
||||
"POST", "/echo", data=body, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
|
||||
async def test_chunked_upload_without_content_length_is_capped(self):
|
||||
async def gen():
|
||||
for _ in range(10):
|
||||
yield b'{"pad": "' + b"x" * 20 + b'"}'
|
||||
|
||||
resp = await self.client.request(
|
||||
"POST",
|
||||
"/small",
|
||||
data=gen(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(resp.status, 413)
|
||||
|
||||
async def test_declared_oversize_content_length_is_413(self):
|
||||
body = b'{"pad": "' + b"x" * 200 + b'"}'
|
||||
resp = await self.client.request(
|
||||
"POST", "/small", data=body, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
self.assertEqual(resp.status, 413)
|
||||
|
||||
async def test_invalid_json_is_400(self):
|
||||
for body in (b"{oops", b"\xff\xfe", b""):
|
||||
resp = await self.client.request(
|
||||
"POST",
|
||||
"/echo",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(resp.status, 400, body)
|
||||
self.assertFalse((await resp.json())["success"])
|
||||
|
||||
async def test_non_object_json_is_400(self):
|
||||
for body in (b"[1, 2]", b'"text"', b"3", b"null", b"true"):
|
||||
resp = await self.client.request(
|
||||
"POST",
|
||||
"/echo",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(resp.status, 400, body)
|
||||
self.assertIn("object", (await resp.json())["error"])
|
||||
|
||||
def test_helper_is_exposed_on_the_api_object(self):
|
||||
self.assertIs(PromptManagerAPI._read_json_body, api_module._read_json_body)
|
||||
|
||||
|
||||
class TestLongJobLock(unittest.TestCase):
|
||||
"""_acquire_long_job: one job per family at a time, released on exit."""
|
||||
|
||||
def setUp(self):
|
||||
self.api = PromptManagerAPI()
|
||||
|
||||
def _run(self, coro):
|
||||
import asyncio
|
||||
|
||||
return asyncio.run(coro)
|
||||
|
||||
def test_second_acquire_is_refused_until_first_releases(self):
|
||||
async def scenario():
|
||||
first = self.api._acquire_long_job("duplicates")
|
||||
self.assertIsNotNone(first)
|
||||
async with first:
|
||||
self.assertIsNone(self.api._acquire_long_job("duplicates"))
|
||||
# Other families are independent
|
||||
other = self.api._acquire_long_job("thumbnails")
|
||||
self.assertIsNotNone(other)
|
||||
async with other:
|
||||
pass
|
||||
again = self.api._acquire_long_job("duplicates")
|
||||
self.assertIsNotNone(again)
|
||||
async with again:
|
||||
pass
|
||||
|
||||
self._run(scenario())
|
||||
|
||||
def test_lock_is_released_when_the_job_raises(self):
|
||||
async def scenario():
|
||||
with self.assertRaises(RuntimeError):
|
||||
async with self.api._acquire_long_job("duplicates"):
|
||||
raise RuntimeError("boom")
|
||||
self.assertIsNotNone(self.api._acquire_long_job("duplicates"))
|
||||
|
||||
self._run(scenario())
|
||||
|
||||
def test_busy_response_is_409_with_envelope(self):
|
||||
resp = self.api._long_job_busy_response("Duplicate scan")
|
||||
self.assertEqual(resp.status, 409)
|
||||
data = json.loads(resp.body)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertEqual(data["error"], "Duplicate scan already running")
|
||||
|
||||
|
||||
# ── UI and static routes ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class CoreRoutesTestCase(AioHTTPTestCase):
|
||||
|
||||
async def get_application(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
return app
|
||||
|
||||
async def tearDownAsync(self):
|
||||
await super().tearDownAsync() # closes the aiohttp test client
|
||||
self.api.db.close_all()
|
||||
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||
|
||||
def _fake_root(self):
|
||||
"""Point _get_project_root at the temp dir and return its web/ dir."""
|
||||
web_dir = Path(self.tmpdir) / "web"
|
||||
(web_dir / "js").mkdir(parents=True)
|
||||
(web_dir / "lib").mkdir()
|
||||
orig_root = api_module._get_project_root
|
||||
api_module._get_project_root = lambda: self.tmpdir
|
||||
self.addCleanup(setattr, api_module, "_get_project_root", orig_root)
|
||||
return web_dir
|
||||
|
||||
|
||||
UI_PATHS = (
|
||||
"/prompt_manager/web",
|
||||
"/prompt_manager/gallery.html",
|
||||
"/prompt_manager/admin",
|
||||
"/prompt_manager/gallery",
|
||||
)
|
||||
|
||||
|
||||
class TestUiRoutes(CoreRoutesTestCase):
|
||||
|
||||
async def test_pages_are_served(self):
|
||||
for path in UI_PATHS:
|
||||
resp = await self.client.request("GET", path)
|
||||
self.assertEqual(resp.status, 200, path)
|
||||
self.assertIn("text/html", resp.headers["Content-Type"])
|
||||
self.assertIn("<", await resp.text())
|
||||
|
||||
async def test_pages_are_cached_after_first_read(self):
|
||||
await self.client.request("GET", "/prompt_manager/admin")
|
||||
self.assertTrue(any(p.endswith("admin.html") for p in self.api._html_cache))
|
||||
resp = await self.client.request("GET", "/prompt_manager/admin")
|
||||
self.assertEqual(resp.status, 200)
|
||||
|
||||
async def test_missing_pages_are_404(self):
|
||||
self._fake_root()
|
||||
for path in UI_PATHS:
|
||||
resp = await self.client.request("GET", path)
|
||||
self.assertEqual(resp.status, 404, path)
|
||||
|
||||
async def test_unreadable_pages_are_500(self):
|
||||
web_dir = self._fake_root()
|
||||
for name in ("index.html", "metadata.html", "admin.html", "gallery.html"):
|
||||
(web_dir / name).mkdir() # a directory cannot be read as a file
|
||||
for path in UI_PATHS:
|
||||
resp = await self.client.request("GET", path)
|
||||
self.assertEqual(resp.status, 500, path)
|
||||
self.assertNotIn(self.tmpdir, await resp.text())
|
||||
|
||||
async def test_test_route(self):
|
||||
resp = await self.client.request("GET", "/prompt_manager/test")
|
||||
self.assertTrue((await resp.json())["success"])
|
||||
|
||||
|
||||
class TestStaticRoutes(CoreRoutesTestCase):
|
||||
|
||||
async def test_js_served_with_mime(self):
|
||||
resp = await self.client.request("GET", "/prompt_manager/js/admin.js")
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertIn("application/javascript", resp.headers["Content-Type"])
|
||||
|
||||
async def test_lib_served(self):
|
||||
web_dir = self._fake_root()
|
||||
(web_dir / "lib" / "x.css").write_text("body{}")
|
||||
resp = await self.client.request("GET", "/prompt_manager/lib/x.css")
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertIn("text/css", resp.headers["Content-Type"])
|
||||
self.assertEqual(await resp.text(), "body{}")
|
||||
|
||||
async def test_real_lib_file_served_with_content_type(self):
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/lib/viewerjs/viewer.min.css"
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertIn("text/css", resp.headers["Content-Type"])
|
||||
|
||||
async def test_allowed_asset_types_get_their_mime(self):
|
||||
web_dir = self._fake_root()
|
||||
expected = {
|
||||
"a.map": "application/json",
|
||||
"f.woff": "font/woff",
|
||||
"f.woff2": "font/woff2",
|
||||
"f.ttf": "font/ttf",
|
||||
"i.png": "image/png",
|
||||
"i.svg": "image/svg+xml",
|
||||
"i.ico": "image/x-icon",
|
||||
"p.html": "text/html",
|
||||
}
|
||||
for name in expected:
|
||||
(web_dir / "lib" / name).write_bytes(b"\x00\x01")
|
||||
for name, mime in expected.items():
|
||||
resp = await self.client.request("GET", f"/prompt_manager/lib/{name}")
|
||||
self.assertEqual(resp.status, 200, name)
|
||||
self.assertIn(mime, resp.headers["Content-Type"], name)
|
||||
|
||||
async def test_disallowed_extensions_are_403_even_when_present(self):
|
||||
web_dir = self._fake_root()
|
||||
for name in ("x.bin", "x.py", "x.json", "noext", "x.JS.bak"):
|
||||
(web_dir / "lib" / name).write_bytes(b"\x00")
|
||||
(web_dir / "js" / name).write_bytes(b"\x00")
|
||||
for prefix in ("lib", "js"):
|
||||
for name in ("x.bin", "x.py", "x.json", "noext", "x.JS.bak"):
|
||||
resp = await self.client.request(
|
||||
"GET", f"/prompt_manager/{prefix}/{name}"
|
||||
)
|
||||
self.assertEqual(resp.status, 403, f"{prefix}/{name}")
|
||||
|
||||
async def test_traversal_is_403(self):
|
||||
for prefix in ("lib", "js"):
|
||||
for encoded in (
|
||||
"..%2F..%2Fpyproject.toml",
|
||||
"..%5C..%5Cpyproject.toml",
|
||||
"sub%2F..%2F..%2F..%2Fpyproject.toml",
|
||||
"%2E%2E%2Fpyproject.toml",
|
||||
):
|
||||
resp = await self.client.request(
|
||||
"GET", f"/prompt_manager/{prefix}/{encoded}"
|
||||
)
|
||||
self.assertEqual(resp.status, 403, f"{prefix}/{encoded}")
|
||||
|
||||
async def test_absolute_drive_and_unc_paths_are_rejected_on_every_os(self):
|
||||
# Built literally so the Windows-only escapes are exercised everywhere:
|
||||
# os.path.join(root, "C:/x") drops root on Windows, "\\x" and UNC
|
||||
# shares re-anchor the path as well.
|
||||
candidates = (
|
||||
"C:/Windows/win.ini",
|
||||
"C:%5CWindows%5Cwin.ini",
|
||||
"c:win.ini",
|
||||
"%5C%5Cserver%5Cshare%5Cx.js",
|
||||
"%2F%2Fserver%2Fshare%2Fx.js",
|
||||
"%5CWindows%5Cwin.ini",
|
||||
"%2Fetc%2Fpasswd",
|
||||
)
|
||||
for prefix in ("lib", "js"):
|
||||
for encoded in candidates:
|
||||
resp = await self.client.request(
|
||||
"GET", f"/prompt_manager/{prefix}/{encoded}"
|
||||
)
|
||||
self.assertIn(resp.status, (403, 404), f"{prefix}/{encoded}")
|
||||
self.assertNotIn("[extensions]", await resp.text())
|
||||
|
||||
async def test_symlink_escaping_web_dir_is_403(self):
|
||||
web_dir = self._fake_root()
|
||||
secret = Path(self.tmpdir) / "secret.js"
|
||||
secret.write_text("secret")
|
||||
link = web_dir / "lib" / "link.js"
|
||||
try:
|
||||
os.symlink(secret, link)
|
||||
except (OSError, NotImplementedError):
|
||||
self.skipTest("symlinks not available")
|
||||
resp = await self.client.request("GET", "/prompt_manager/lib/link.js")
|
||||
self.assertEqual(resp.status, 403)
|
||||
|
||||
async def test_empty_path_is_not_served(self):
|
||||
for prefix in ("lib", "js"):
|
||||
resp = await self.client.request("GET", f"/prompt_manager/{prefix}/")
|
||||
self.assertIn(resp.status, (403, 404), prefix)
|
||||
|
||||
async def test_missing_is_404(self):
|
||||
for prefix in ("lib", "js"):
|
||||
resp = await self.client.request("GET", f"/prompt_manager/{prefix}/nope.js")
|
||||
self.assertEqual(resp.status, 404, prefix)
|
||||
|
||||
async def test_directory_is_404(self):
|
||||
web_dir = self._fake_root()
|
||||
(web_dir / "lib" / "dir.js").mkdir()
|
||||
resp = await self.client.request("GET", "/prompt_manager/lib/dir.js")
|
||||
self.assertEqual(resp.status, 404)
|
||||
|
||||
async def test_root_of_static_dir_is_not_listed(self):
|
||||
resp = await self.client.request("GET", "/prompt_manager/lib/tailwind")
|
||||
self.assertEqual(resp.status, 403)
|
||||
|
||||
|
||||
# ── instance helpers ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestInstanceHelpers(FolderPathsFixture):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
|
||||
self.addCleanup(self.api.db.close_all) # runs before the rmtree cleanup
|
||||
orig_dirs = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
self.addCleanup(setattr, GalleryConfig, "MONITORING_DIRECTORIES", orig_dirs)
|
||||
|
||||
def _run(self, coro):
|
||||
import asyncio
|
||||
|
||||
return asyncio.run(coro)
|
||||
|
||||
def test_run_in_executor_with_kwargs(self):
|
||||
def add(a, b=0):
|
||||
return a + b
|
||||
|
||||
self.assertEqual(self._run(self.api._run_in_executor(add, 1, b=2)), 3)
|
||||
self.assertEqual(self._run(self.api._run_in_executor(add, 1, 2)), 3)
|
||||
|
||||
def test_invalidate_gallery_cache(self):
|
||||
self.api._gallery_cache = {"x": 1}
|
||||
self.api.invalidate_gallery_cache()
|
||||
self.assertEqual(self.api._gallery_cache, {})
|
||||
|
||||
def test_enrich_images_adds_urls_and_thumbnails(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [str(self.output_dir)]
|
||||
img = self.output_dir / "sub" / "a.png"
|
||||
img.parent.mkdir()
|
||||
img.write_bytes(b"x")
|
||||
thumb = self.output_dir / "thumbnails" / "sub" / "a_thumb.png"
|
||||
thumb.parent.mkdir(parents=True)
|
||||
thumb.write_bytes(b"t")
|
||||
outside = Path(self.tmpdir) / "elsewhere.png"
|
||||
outside.write_bytes(b"o")
|
||||
|
||||
images = [
|
||||
{"id": 7, "image_path": str(img)},
|
||||
{"id": 8, "image_path": str(outside)},
|
||||
{"id": 9, "image_path": ""},
|
||||
{"image_path": str(self.output_dir / "missing.png")},
|
||||
]
|
||||
result = self.api._enrich_images(images)
|
||||
|
||||
self.assertEqual(result[0]["url"], "/prompt_manager/images/serve/sub/a.png")
|
||||
self.assertRegex(
|
||||
result[0]["thumbnail_url"],
|
||||
r"^/prompt_manager/images/serve/thumbnails/sub/a_thumb\.png\?v=\d+$",
|
||||
)
|
||||
self.assertEqual(result[0]["relative_path"], os.path.join("sub", "a.png"))
|
||||
self.assertEqual(result[1]["url"], "/prompt_manager/images/8/file")
|
||||
self.assertNotIn("relative_path", result[1])
|
||||
self.assertNotIn("url", result[2])
|
||||
self.assertEqual(result[3]["url"], "/prompt_manager/images/serve/missing.png")
|
||||
self.assertNotIn("thumbnail_url", result[3])
|
||||
|
||||
def test_enrich_prompt_images_walks_prompts(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [str(self.output_dir)]
|
||||
prompts = [
|
||||
{"images": [{"id": 1, "image_path": str(self.output_dir / "p.png")}]},
|
||||
{},
|
||||
]
|
||||
result = self.api._enrich_prompt_images(prompts)
|
||||
self.assertEqual(
|
||||
result[0]["images"][0]["url"], "/prompt_manager/images/serve/p.png"
|
||||
)
|
||||
|
||||
def test_clean_nan_recursive(self):
|
||||
nan = float("nan")
|
||||
cleaned = self.api._clean_nan_recursive(
|
||||
{"a": nan, "b": [nan, 1.5, {"c": nan}], "d": "x"}
|
||||
)
|
||||
self.assertEqual(cleaned, {"a": None, "b": [None, 1.5, {"c": None}], "d": "x"})
|
||||
|
||||
def test_find_output_dir_uses_configured_and_caches(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [str(self.output_dir)]
|
||||
self.api._cached_output_dir = None
|
||||
first = self.api._find_comfyui_output_dir()
|
||||
self.assertEqual(first, str(self.output_dir.resolve()))
|
||||
GalleryConfig.MONITORING_DIRECTORIES = []
|
||||
self.assertEqual(self.api._find_comfyui_output_dir(), first)
|
||||
|
||||
def test_find_output_dir_skips_configured_root_outside_comfyui(self):
|
||||
outside = Path(self.tmpdir) / "outside"
|
||||
outside.mkdir()
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [str(outside)]
|
||||
self.api._cached_output_dir = None
|
||||
result = self.api._find_comfyui_output_dir()
|
||||
self.assertNotEqual(
|
||||
os.path.normcase(str(result or "")), os.path.normcase(str(outside))
|
||||
)
|
||||
|
||||
def test_find_output_dir_skips_missing_configured(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [os.path.join(self.tmpdir, "nope")]
|
||||
self.api._cached_output_dir = None
|
||||
result = self.api._find_comfyui_output_dir()
|
||||
self.assertNotEqual(result, os.path.join(self.tmpdir, "nope"))
|
||||
|
||||
|
||||
# ── metadata parsing ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMetadataParsing(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.api = PromptManagerAPI()
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.tmpdir, True)
|
||||
|
||||
def test_extract_metadata_from_png_and_garbage(self):
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
path = os.path.join(self.tmpdir, "m.png")
|
||||
info = PngImagePlugin.PngInfo()
|
||||
info.add_text("prompt", "{}")
|
||||
Image.new("RGB", (1, 1)).save(path, pnginfo=info)
|
||||
self.assertEqual(self.api._extract_comfyui_metadata(path), {"prompt": "{}"})
|
||||
|
||||
garbage = os.path.join(self.tmpdir, "g.png")
|
||||
with open(garbage, "wb") as f:
|
||||
f.write(b"nope")
|
||||
self.assertEqual(self.api._extract_comfyui_metadata(garbage), {})
|
||||
|
||||
def test_parse_a1111_parameters(self):
|
||||
meta = {"parameters": "a cat\nNegative prompt: dog\nSteps: 20"}
|
||||
parsed = self.api._parse_comfyui_prompt(meta)
|
||||
self.assertEqual(parsed["positive_prompt"], "a cat")
|
||||
self.assertEqual(parsed["negative_prompt"], "dog")
|
||||
self.assertEqual(parsed["parameters"]["parameters"], meta["parameters"])
|
||||
self.assertEqual(self.api._extract_readable_prompt(parsed), "a cat")
|
||||
|
||||
def test_parse_comfyui_fields(self):
|
||||
meta = {
|
||||
"prompt": json.dumps({"1": {"class_type": "X", "inputs": {}}}),
|
||||
"workflow": "not json",
|
||||
"steps": "20",
|
||||
"cfg": "not-json-either",
|
||||
}
|
||||
parsed = self.api._parse_comfyui_prompt(meta)
|
||||
self.assertIsInstance(parsed["prompt"], dict)
|
||||
self.assertEqual(parsed["workflow"], "not json")
|
||||
self.assertEqual(parsed["parameters"]["steps"], 20)
|
||||
self.assertEqual(parsed["parameters"]["cfg"], "not-json-either")
|
||||
|
||||
parsed = self.api._parse_comfyui_prompt(
|
||||
{"prompt": "plain text", "workflow": json.dumps({"nodes": []})}
|
||||
)
|
||||
self.assertEqual(parsed["prompt"], "plain text")
|
||||
self.assertEqual(parsed["workflow"], {"nodes": []})
|
||||
|
||||
def test_extract_readable_prompt_variants(self):
|
||||
extract = self.api._extract_readable_prompt
|
||||
self.assertEqual(extract({"prompt": "plain"}), "plain")
|
||||
self.assertEqual(extract({"prompt": ["a", "", "b"]}), "a b")
|
||||
self.assertEqual(extract({"prompt": 42}), "42")
|
||||
self.assertEqual(
|
||||
extract({"prompt": None, "parameters": {"positive": ["x", "y"]}}), "x y"
|
||||
)
|
||||
self.assertIsNone(extract({"prompt": {}, "parameters": {}}))
|
||||
|
||||
graph = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "pos"}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "neg"}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
|
||||
},
|
||||
}
|
||||
self.assertEqual(extract({"prompt": graph}), "pos")
|
||||
self.assertEqual(extract({"prompt": {"x": 1}, "workflow": graph}), "pos")
|
||||
|
||||
def test_node_inputs_and_text_lookup(self):
|
||||
self.assertEqual(self.api._get_node_inputs("nope"), {})
|
||||
self.assertEqual(self.api._get_node_inputs({"inputs": 5}), {})
|
||||
self.assertEqual(self.api._get_node_inputs({"inputs": {"a": 1}}), {"a": 1})
|
||||
listed = self.api._get_node_inputs(
|
||||
{"inputs": [{"name": "text", "link": None}, {"nope": 1}, "junk"]}
|
||||
)
|
||||
self.assertEqual(list(listed), ["text"])
|
||||
|
||||
self.assertIsNone(self.api._find_text_in_node(None))
|
||||
self.assertEqual(self.api._find_text_in_node({"inputs": {"text": "hi"}}), "hi")
|
||||
self.assertEqual(
|
||||
self.api._find_text_in_node(
|
||||
{"type": "CLIPTextEncode", "widgets_values": ["w"]}
|
||||
),
|
||||
"w",
|
||||
)
|
||||
self.assertIsNone(
|
||||
self.api._find_text_in_node(
|
||||
{"type": "CLIPTextEncode", "widgets_values": [" "]}
|
||||
)
|
||||
)
|
||||
self.assertIsNone(self.api._find_text_in_node({"type": "KSampler"}))
|
||||
|
||||
def test_positive_prompt_from_nodes_array_and_fallbacks(self):
|
||||
extract = self.api._extract_positive_prompt_from_comfyui_data
|
||||
self.assertIsNone(extract("x"))
|
||||
self.assertIsNone(extract({}))
|
||||
self.assertIsNone(extract({"nodes": []}))
|
||||
|
||||
workflow = {
|
||||
"nodes": [
|
||||
{"id": 1, "type": "CLIPTextEncode", "widgets_values": ["pos"]},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"widgets_values": ["neg"],
|
||||
"title": "Negative",
|
||||
},
|
||||
{"id": 3, "type": "KSampler", "inputs": [{"name": "seed", "link": 4}]},
|
||||
"junk",
|
||||
]
|
||||
}
|
||||
self.assertEqual(extract(workflow), "pos")
|
||||
|
||||
only_negative = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"widgets_values": ["neg"],
|
||||
"title": "neg",
|
||||
},
|
||||
]
|
||||
}
|
||||
self.assertEqual(extract(only_negative), "neg")
|
||||
|
||||
bad_link = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "pos"}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": "oops", "negative": []},
|
||||
},
|
||||
"4": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["zz", 0], "negative": []},
|
||||
},
|
||||
}
|
||||
self.assertEqual(extract(bad_link), "pos")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,447 @@
|
||||
"""Route tests for the logging API (py/api/logging_routes.py)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
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 aiohttp import web # noqa: E402
|
||||
from aiohttp.test_utils import AioHTTPTestCase # noqa: E402
|
||||
|
||||
from database.operations import PromptDatabase # noqa: E402
|
||||
from py.api import PromptManagerAPI # noqa: E402
|
||||
from py.api.logging_routes import LOG_CONFIG_KEYS # noqa: E402
|
||||
|
||||
|
||||
class FakeLoggerManager:
|
||||
"""Stand-in for PromptManagerLogger with a scratch log directory."""
|
||||
|
||||
def __init__(self, log_dir):
|
||||
self.log_dir = Path(log_dir)
|
||||
self.config = {
|
||||
"level": "INFO",
|
||||
"max_file_size": 10 * 1024 * 1024,
|
||||
"backup_count": 5,
|
||||
"console_logging": True,
|
||||
"file_logging": True,
|
||||
"buffer_size": 1000,
|
||||
}
|
||||
self.update_calls = []
|
||||
self.logs = []
|
||||
|
||||
def update_config(self, new_config):
|
||||
self.update_calls.append(dict(new_config))
|
||||
self.config.update(new_config)
|
||||
|
||||
def get_config(self):
|
||||
return dict(self.config)
|
||||
|
||||
def get_recent_logs(self, limit=100, level=None):
|
||||
logs = self.logs
|
||||
if level:
|
||||
logs = [entry for entry in logs if entry["level"] == level.upper()]
|
||||
return list(reversed(logs[-limit:]))
|
||||
|
||||
def get_log_files(self):
|
||||
files = []
|
||||
for path in sorted(self.log_dir.glob("*.log*")):
|
||||
files.append(
|
||||
{
|
||||
"filename": path.name,
|
||||
"path": str(path),
|
||||
"size": path.stat().st_size,
|
||||
"modified": "2026-10-07T00:00:00",
|
||||
"is_main": path.name == "prompt_manager.log",
|
||||
}
|
||||
)
|
||||
return files
|
||||
|
||||
def truncate_logs(self):
|
||||
truncated = []
|
||||
for path in self.log_dir.glob("prompt_manager.log*"):
|
||||
path.write_text("")
|
||||
truncated.append(path.name)
|
||||
return {"truncated": truncated, "errors": []}
|
||||
|
||||
def get_log_stats(self):
|
||||
return {
|
||||
"buffer_count": len(self.logs),
|
||||
"level_counts": {},
|
||||
"log_files_count": len(self.get_log_files()),
|
||||
"total_log_size": 0,
|
||||
"log_directory": str(self.log_dir),
|
||||
"current_level": self.config["level"],
|
||||
}
|
||||
|
||||
|
||||
class LoggingAPITestCase(AioHTTPTestCase):
|
||||
|
||||
async def get_application(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.log_dir = Path(self.tmpdir) / "logs"
|
||||
self.log_dir.mkdir()
|
||||
self.manager = FakeLoggerManager(self.log_dir)
|
||||
# Public paths are rendered relative to ComfyUI's base directory. Other
|
||||
# test modules leave their own ``folder_paths`` stub behind, so declare
|
||||
# ours explicitly instead of depending on import order.
|
||||
self._saved_folder_paths = sys.modules.get("folder_paths")
|
||||
fake_folder_paths = MagicMock()
|
||||
fake_folder_paths.base_path = self.tmpdir
|
||||
fake_folder_paths.get_output_directory.return_value = os.path.join(
|
||||
self.tmpdir, "output"
|
||||
)
|
||||
sys.modules["folder_paths"] = fake_folder_paths
|
||||
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
|
||||
self.api._get_logger_manager = lambda: self.manager
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
return app
|
||||
|
||||
async def tearDownAsync(self):
|
||||
await super().tearDownAsync() # closes the aiohttp test client
|
||||
if self._saved_folder_paths is None:
|
||||
sys.modules.pop("folder_paths", None)
|
||||
else:
|
||||
sys.modules["folder_paths"] = self._saved_folder_paths
|
||||
self.api.db.close_all()
|
||||
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||
|
||||
async def _post_config(self, payload):
|
||||
return await self.client.request(
|
||||
"POST", "/prompt_manager/logs/config", json=payload
|
||||
)
|
||||
|
||||
|
||||
class TestUpdateLogConfigWhitelist(LoggingAPITestCase):
|
||||
|
||||
def test_whitelist_matches_logger_manager_keys(self):
|
||||
self.assertEqual(LOG_CONFIG_KEYS, frozenset(self.manager.config.keys()))
|
||||
|
||||
async def test_unknown_keys_rejected_and_listed(self):
|
||||
resp = await self._post_config({"level": "INFO", "evil": 1, "log_dir": "/x"})
|
||||
|
||||
self.assertEqual(resp.status, 400)
|
||||
data = await resp.json()
|
||||
self.assertFalse(data["success"])
|
||||
self.assertIn("evil", data["error"])
|
||||
self.assertIn("log_dir", data["error"])
|
||||
self.assertEqual(self.manager.update_calls, [])
|
||||
|
||||
async def test_invalid_level_rejected(self):
|
||||
resp = await self._post_config({"level": "VERBOSE"})
|
||||
self.assertEqual(resp.status, 400)
|
||||
self.assertEqual(self.manager.update_calls, [])
|
||||
|
||||
async def test_level_must_be_a_string(self):
|
||||
resp = await self._post_config({"level": 10})
|
||||
self.assertEqual(resp.status, 400)
|
||||
self.assertEqual(self.manager.update_calls, [])
|
||||
|
||||
async def test_level_is_normalised_to_upper_case(self):
|
||||
resp = await self._post_config({"level": "debug"})
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(self.manager.update_calls, [{"level": "DEBUG"}])
|
||||
|
||||
async def test_negative_int_rejected(self):
|
||||
resp = await self._post_config({"backup_count": -1})
|
||||
self.assertEqual(resp.status, 400)
|
||||
self.assertEqual(self.manager.update_calls, [])
|
||||
|
||||
async def test_numeric_string_rejected(self):
|
||||
resp = await self._post_config({"max_file_size": "1024"})
|
||||
self.assertEqual(resp.status, 400)
|
||||
self.assertEqual(self.manager.update_calls, [])
|
||||
|
||||
async def test_bool_is_not_an_int(self):
|
||||
resp = await self._post_config({"buffer_size": True})
|
||||
self.assertEqual(resp.status, 400)
|
||||
self.assertEqual(self.manager.update_calls, [])
|
||||
|
||||
async def test_flags_must_be_bool(self):
|
||||
resp = await self._post_config({"console_logging": "yes"})
|
||||
self.assertEqual(resp.status, 400)
|
||||
self.assertEqual(self.manager.update_calls, [])
|
||||
|
||||
async def test_valid_payload_is_applied(self):
|
||||
payload = {
|
||||
"level": "WARNING",
|
||||
"max_file_size": 2 * 1024 * 1024,
|
||||
"backup_count": 0,
|
||||
"console_logging": False,
|
||||
"file_logging": True,
|
||||
"buffer_size": 50,
|
||||
}
|
||||
resp = await self._post_config(payload)
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(self.manager.update_calls, [payload])
|
||||
self.assertEqual(data["config"]["level"], "WARNING")
|
||||
|
||||
async def test_max_file_size_is_bounded_to_1_to_1024_mb(self):
|
||||
mb = 1024 * 1024
|
||||
for bad in (0, mb - 1, 1024 * mb + 1, 10**12):
|
||||
resp = await self._post_config({"max_file_size": bad})
|
||||
self.assertEqual(resp.status, 400, bad)
|
||||
self.assertIn("max_file_size", (await resp.json())["error"])
|
||||
self.assertEqual(self.manager.update_calls, [])
|
||||
for ok in (mb, 1024 * mb):
|
||||
resp = await self._post_config({"max_file_size": ok})
|
||||
self.assertEqual(resp.status, 200, ok)
|
||||
self.assertEqual(
|
||||
self.manager.update_calls,
|
||||
[{"max_file_size": mb}, {"max_file_size": 1024 * mb}],
|
||||
)
|
||||
|
||||
async def test_backup_count_is_bounded_to_0_to_50(self):
|
||||
for bad in (-1, 51, 10**6):
|
||||
resp = await self._post_config({"backup_count": bad})
|
||||
self.assertEqual(resp.status, 400, bad)
|
||||
self.assertIn("backup_count", (await resp.json())["error"])
|
||||
self.assertEqual(self.manager.update_calls, [])
|
||||
for ok in (0, 50):
|
||||
resp = await self._post_config({"backup_count": ok})
|
||||
self.assertEqual(resp.status, 200, ok)
|
||||
|
||||
async def test_body_must_be_an_object(self):
|
||||
resp = await self._post_config(["level", "DEBUG"])
|
||||
self.assertEqual(resp.status, 400)
|
||||
self.assertEqual(self.manager.update_calls, [])
|
||||
|
||||
async def test_malformed_json_is_400(self):
|
||||
resp = await self.client.request(
|
||||
"POST",
|
||||
"/prompt_manager/logs/config",
|
||||
data=b"{not json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(resp.status, 400)
|
||||
self.assertEqual(self.manager.update_calls, [])
|
||||
|
||||
async def test_oversized_body_is_413(self):
|
||||
body = json.dumps({"level": "x" * 1_000_100}).encode()
|
||||
resp = await self.client.request(
|
||||
"POST",
|
||||
"/prompt_manager/logs/config",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(resp.status, 413)
|
||||
self.assertFalse((await resp.json())["success"])
|
||||
self.assertEqual(self.manager.update_calls, [])
|
||||
|
||||
|
||||
class TestNoAbsolutePathsInLogResponses(LoggingAPITestCase):
|
||||
|
||||
async def test_log_files_have_no_absolute_path(self):
|
||||
(self.log_dir / "prompt_manager.log").write_text("hello")
|
||||
|
||||
resp = await self.client.request("GET", "/prompt_manager/logs/files")
|
||||
|
||||
body = await resp.text()
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertNotIn(self.tmpdir, body)
|
||||
data = json.loads(body)
|
||||
self.assertEqual(data["files"][0]["filename"], "prompt_manager.log")
|
||||
|
||||
async def test_log_stats_directory_is_not_absolute(self):
|
||||
resp = await self.client.request("GET", "/prompt_manager/logs/stats")
|
||||
|
||||
body = await resp.text()
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertNotIn(self.tmpdir, body)
|
||||
self.assertEqual(json.loads(body)["stats"]["log_directory"], "logs")
|
||||
|
||||
async def test_error_from_manager_does_not_leak_path(self):
|
||||
def boom():
|
||||
raise FileNotFoundError(
|
||||
2, "No such file", os.path.join(self.tmpdir, "x.log")
|
||||
)
|
||||
|
||||
self.manager.get_log_stats = boom
|
||||
|
||||
resp = await self.client.request("GET", "/prompt_manager/logs/stats")
|
||||
|
||||
body = await resp.text()
|
||||
self.assertEqual(resp.status, 500)
|
||||
self.assertNotIn(self.tmpdir, body)
|
||||
self.assertIn("x.log", json.loads(body)["error"])
|
||||
|
||||
|
||||
class TestGetLogs(LoggingAPITestCase):
|
||||
|
||||
def _fill(self, n):
|
||||
for i in range(n):
|
||||
self.manager.logs.append(
|
||||
{"level": "INFO" if i % 2 else "ERROR", "message": f"m{i}"}
|
||||
)
|
||||
|
||||
async def test_default_limit_and_count(self):
|
||||
self._fill(3)
|
||||
resp = await self.client.request("GET", "/prompt_manager/logs")
|
||||
data = await resp.json()
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["count"], 3)
|
||||
self.assertEqual(data["limit"], 100)
|
||||
self.assertIsNone(data["level_filter"])
|
||||
|
||||
async def test_level_filter_passed_through(self):
|
||||
self._fill(4)
|
||||
resp = await self.client.request("GET", "/prompt_manager/logs?level=error")
|
||||
data = await resp.json()
|
||||
self.assertEqual(data["level_filter"], "error")
|
||||
self.assertEqual(data["count"], 2)
|
||||
|
||||
async def test_limit_is_clamped_to_500(self):
|
||||
resp = await self.client.request("GET", "/prompt_manager/logs?limit=99999")
|
||||
self.assertEqual((await resp.json())["limit"], 500)
|
||||
|
||||
async def test_limit_below_one_becomes_one(self):
|
||||
self._fill(3)
|
||||
resp = await self.client.request("GET", "/prompt_manager/logs?limit=0")
|
||||
data = await resp.json()
|
||||
self.assertEqual(data["limit"], 1)
|
||||
self.assertEqual(data["count"], 1)
|
||||
|
||||
async def test_non_integer_limit_is_400(self):
|
||||
resp = await self.client.request("GET", "/prompt_manager/logs?limit=ten")
|
||||
self.assertEqual(resp.status, 400)
|
||||
self.assertFalse((await resp.json())["success"])
|
||||
|
||||
async def test_manager_failure_is_500_with_empty_logs(self):
|
||||
def boom(**kwargs):
|
||||
raise RuntimeError("buffer gone")
|
||||
|
||||
self.manager.get_recent_logs = boom
|
||||
resp = await self.client.request("GET", "/prompt_manager/logs")
|
||||
data = await resp.json()
|
||||
self.assertEqual(resp.status, 500)
|
||||
self.assertEqual(data["logs"], [])
|
||||
|
||||
|
||||
class TestLogFilesAndDownload(LoggingAPITestCase):
|
||||
|
||||
async def test_files_failure_is_500(self):
|
||||
def boom():
|
||||
raise RuntimeError("no dir")
|
||||
|
||||
self.manager.get_log_files = boom
|
||||
resp = await self.client.request("GET", "/prompt_manager/logs/files")
|
||||
data = await resp.json()
|
||||
self.assertEqual(resp.status, 500)
|
||||
self.assertEqual(data["files"], [])
|
||||
|
||||
async def test_download_existing_file(self):
|
||||
(self.log_dir / "prompt_manager.log").write_bytes(b"line1\nline2\n")
|
||||
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/logs/download/prompt_manager.log"
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertIn("attachment", resp.headers["Content-Disposition"])
|
||||
self.assertEqual(await resp.text(), "line1\nline2\n")
|
||||
|
||||
async def test_download_missing_file_is_404(self):
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/logs/download/nope.log"
|
||||
)
|
||||
self.assertEqual(resp.status, 404)
|
||||
|
||||
async def test_download_rejects_traversal(self):
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/logs/download/..%2F..%2Fetc%2Fpasswd"
|
||||
)
|
||||
self.assertEqual(resp.status, 400)
|
||||
|
||||
async def test_download_rejects_backslash(self):
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/logs/download/..%5Cx.log"
|
||||
)
|
||||
self.assertEqual(resp.status, 400)
|
||||
|
||||
async def test_download_read_failure_is_500(self):
|
||||
(self.log_dir / "dir.log").mkdir()
|
||||
resp = await self.client.request("GET", "/prompt_manager/logs/download/dir.log")
|
||||
body = await resp.text()
|
||||
self.assertEqual(resp.status, 500)
|
||||
self.assertNotIn(self.tmpdir, body)
|
||||
|
||||
|
||||
class TestTruncateConfigAndStats(LoggingAPITestCase):
|
||||
|
||||
async def test_truncate_success(self):
|
||||
(self.log_dir / "prompt_manager.log").write_text("data")
|
||||
resp = await self.client.request("POST", "/prompt_manager/logs/truncate")
|
||||
data = await resp.json()
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(data["results"]["truncated"], ["prompt_manager.log"])
|
||||
self.assertEqual((self.log_dir / "prompt_manager.log").read_text(), "")
|
||||
|
||||
async def test_truncate_failure_is_500(self):
|
||||
def boom():
|
||||
raise RuntimeError("locked")
|
||||
|
||||
self.manager.truncate_logs = boom
|
||||
resp = await self.client.request("POST", "/prompt_manager/logs/truncate")
|
||||
self.assertEqual(resp.status, 500)
|
||||
|
||||
async def test_get_config(self):
|
||||
resp = await self.client.request("GET", "/prompt_manager/logs/config")
|
||||
data = await resp.json()
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(data["config"]["level"], "INFO")
|
||||
|
||||
async def test_get_config_failure_is_500(self):
|
||||
def boom():
|
||||
raise RuntimeError("gone")
|
||||
|
||||
self.manager.get_config = boom
|
||||
resp = await self.client.request("GET", "/prompt_manager/logs/config")
|
||||
self.assertEqual(resp.status, 500)
|
||||
|
||||
async def test_update_config_manager_failure_is_500(self):
|
||||
def boom(config):
|
||||
raise RuntimeError("handler error")
|
||||
|
||||
self.manager.update_config = boom
|
||||
resp = await self.client.request(
|
||||
"POST", "/prompt_manager/logs/config", json={"level": "INFO"}
|
||||
)
|
||||
self.assertEqual(resp.status, 500)
|
||||
|
||||
async def test_stats_success(self):
|
||||
resp = await self.client.request("GET", "/prompt_manager/logs/stats")
|
||||
data = await resp.json()
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(data["stats"]["current_level"], "INFO")
|
||||
|
||||
|
||||
class TestLoggerManagerResolution(LoggingAPITestCase):
|
||||
|
||||
def test_default_manager_is_the_singleton(self):
|
||||
from py.api.logging_routes import LoggingRoutesMixin
|
||||
from utils.logging_config import get_logger_manager
|
||||
|
||||
manager = LoggingRoutesMixin._get_logger_manager(self.api)
|
||||
self.assertIs(manager, get_logger_manager())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,624 @@
|
||||
"""Route tests for the LoraManager integration endpoints.
|
||||
|
||||
Filesystem detection and network downloads are patched out so the tests run
|
||||
anywhere without a LoraManager install or a CivitAI connection.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
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__))))
|
||||
|
||||
if "folder_paths" not in sys.modules:
|
||||
_stub = types.ModuleType("folder_paths")
|
||||
_stub.models_dir = tempfile.gettempdir()
|
||||
_stub.base_path = tempfile.gettempdir()
|
||||
_stub.get_output_directory = tempfile.gettempdir
|
||||
_stub.get_folder_paths = lambda name: []
|
||||
sys.modules["folder_paths"] = _stub
|
||||
|
||||
if "server" not in sys.modules:
|
||||
_server = MagicMock()
|
||||
_server.PromptServer.instance.routes = MagicMock()
|
||||
sys.modules["server"] = _server
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import AioHTTPTestCase
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from py.api import PromptManagerAPI
|
||||
from py import lora_utils
|
||||
from py.config import IntegrationConfig, PromptManagerConfig
|
||||
from py.lora_utils import get_trigger_cache
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
SECRET_KEY = "sk-civitai-super-secret-0123456789"
|
||||
|
||||
|
||||
class LoraAPITestCase(AioHTTPTestCase):
|
||||
"""Stands up the PromptManager routes with a temp DB and neutral LoRA config."""
|
||||
|
||||
async def get_application(self):
|
||||
self._temp_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
|
||||
self._temp_db.close()
|
||||
self.tmp_root = Path(tempfile.mkdtemp())
|
||||
self.custom_nodes = self.tmp_root / "custom_nodes"
|
||||
self.custom_nodes.mkdir()
|
||||
self._custom_nodes_patch = patch(
|
||||
"py.lora_utils.custom_nodes_directories",
|
||||
return_value=[self.custom_nodes],
|
||||
)
|
||||
self._custom_nodes_patch.start()
|
||||
self._real_save = PromptManagerConfig.save_to_file
|
||||
|
||||
self._config_patch = patch.multiple(
|
||||
IntegrationConfig,
|
||||
LORA_MANAGER_ENABLED=False,
|
||||
LORA_MANAGER_PATH="",
|
||||
LORA_TRIGGER_WORDS_ENABLED=False,
|
||||
CIVITAI_API_KEY="",
|
||||
)
|
||||
self._config_patch.start()
|
||||
self._detect_patch = patch(
|
||||
"py.lora_utils.detect_lora_manager", return_value=None
|
||||
)
|
||||
self.detect_mock = self._detect_patch.start()
|
||||
self._save_patch = patch.object(PromptManagerConfig, "save_to_file")
|
||||
self.save_mock = self._save_patch.start()
|
||||
get_trigger_cache().clear()
|
||||
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(self._temp_db.name)
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
return app
|
||||
|
||||
async def tearDownAsync(self):
|
||||
await super().tearDownAsync() # closes the aiohttp test client
|
||||
self._save_patch.stop()
|
||||
self._detect_patch.stop()
|
||||
self._config_patch.stop()
|
||||
self._custom_nodes_patch.stop()
|
||||
get_trigger_cache().clear()
|
||||
self.api.db.close_all()
|
||||
for path in (
|
||||
self._temp_db.name,
|
||||
self._temp_db.name + "-wal",
|
||||
self._temp_db.name + "-shm",
|
||||
):
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
shutil.rmtree(self.tmp_root, ignore_errors=True)
|
||||
|
||||
async def _post_json(self, url, body):
|
||||
return await self.client.request(
|
||||
"POST",
|
||||
url,
|
||||
data=json.dumps(body),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
|
||||
class TestLoraStatusNeverReturnsKey(LoraAPITestCase):
|
||||
|
||||
async def test_status_reports_flag_without_the_key(self):
|
||||
IntegrationConfig.CIVITAI_API_KEY = SECRET_KEY
|
||||
|
||||
resp = await self.client.request("GET", "/prompt_manager/lora/status")
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
body = await resp.text()
|
||||
data = json.loads(body)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertIs(data["has_civitai_api_key"], True)
|
||||
self.assertNotIn("civitai_api_key", data)
|
||||
self.assertNotIn(SECRET_KEY, body)
|
||||
|
||||
async def test_status_flag_is_false_without_a_key(self):
|
||||
resp = await self.client.request("GET", "/prompt_manager/lora/status")
|
||||
|
||||
data = await resp.json()
|
||||
self.assertIs(data["has_civitai_api_key"], False)
|
||||
self.assertNotIn("civitai_api_key", data)
|
||||
|
||||
|
||||
def _png_bytes():
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
buf = BytesIO()
|
||||
Image.new("RGB", (2, 2), (1, 2, 3)).save(buf, "PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, body):
|
||||
self._body = body
|
||||
self._pos = 0
|
||||
self.headers = {}
|
||||
|
||||
def read(self, amt=-1):
|
||||
if amt is None or amt < 0:
|
||||
amt = len(self._body)
|
||||
chunk = self._body[self._pos : self._pos + amt]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
def _make_lora_manager(where):
|
||||
lm = where / "ComfyUI-Lora-Manager"
|
||||
(lm / "py").mkdir(parents=True)
|
||||
(lm / "__init__.py").write_text("")
|
||||
return lm
|
||||
|
||||
|
||||
def _write_metadata(directory, stem, words=None, images=None, prompt=None):
|
||||
meta = {"file_name": f"{stem}.safetensors", "model_name": stem, "civitai": {}}
|
||||
if words is not None:
|
||||
meta["civitai"]["trainedWords"] = words
|
||||
if images is not None:
|
||||
meta["civitai"]["images"] = [
|
||||
{"url": url, "meta": {"prompt": prompt} if prompt else {}} for url in images
|
||||
]
|
||||
path = directory / f"{stem}.safetensors.metadata.json"
|
||||
path.write_text(json.dumps(meta))
|
||||
return path
|
||||
|
||||
|
||||
class TestLoraDetectRoute(LoraAPITestCase):
|
||||
|
||||
async def test_detected_path_is_public(self):
|
||||
lm = _make_lora_manager(self.custom_nodes)
|
||||
self.detect_mock.return_value = str(lm)
|
||||
resp = await self.client.request("GET", "/prompt_manager/lora/detect")
|
||||
body = await resp.text()
|
||||
data = json.loads(body)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertTrue(data["detected"])
|
||||
self.assertEqual(data["path"], self.api._public_path(str(lm)))
|
||||
self.assertNotIn(str(self.tmp_root), body)
|
||||
|
||||
async def test_not_detected(self):
|
||||
resp = await self.client.request("GET", "/prompt_manager/lora/detect")
|
||||
data = await resp.json()
|
||||
self.assertFalse(data["detected"])
|
||||
self.assertEqual(data["path"], "")
|
||||
|
||||
async def test_detection_error_is_500(self):
|
||||
self.detect_mock.side_effect = RuntimeError("disk gone")
|
||||
resp = await self.client.request("GET", "/prompt_manager/lora/detect")
|
||||
self.assertEqual(resp.status, 500)
|
||||
self.assertFalse((await resp.json())["success"])
|
||||
|
||||
|
||||
class TestLoraStatusRoute(LoraAPITestCase):
|
||||
|
||||
async def test_status_reports_detection_and_cache_with_public_paths(self):
|
||||
lm = _make_lora_manager(self.custom_nodes)
|
||||
self.detect_mock.return_value = str(lm)
|
||||
IntegrationConfig.LORA_MANAGER_ENABLED = True
|
||||
IntegrationConfig.LORA_MANAGER_PATH = str(lm)
|
||||
|
||||
resp = await self.client.request("GET", "/prompt_manager/lora/status")
|
||||
body = await resp.text()
|
||||
data = json.loads(body)
|
||||
|
||||
self.assertTrue(data["enabled"])
|
||||
self.assertTrue(data["detected"])
|
||||
public = self.api._public_path(str(lm))
|
||||
self.assertEqual(data["path"], public)
|
||||
self.assertEqual(data["detected_path"], public)
|
||||
self.assertFalse(os.path.isabs(public))
|
||||
self.assertNotIn(str(self.tmp_root), body)
|
||||
self.assertFalse(data["trigger_cache_loaded"])
|
||||
|
||||
async def test_status_error_is_500(self):
|
||||
with patch("py.lora_utils.get_trigger_cache", side_effect=RuntimeError("x")):
|
||||
resp = await self.client.request("GET", "/prompt_manager/lora/status")
|
||||
self.assertEqual(resp.status, 500)
|
||||
|
||||
async def test_status_error_hides_the_server_path(self):
|
||||
err = PermissionError(13, "Permission denied", str(self.tmp_root / "c.json"))
|
||||
with patch("py.lora_utils.get_trigger_cache", side_effect=err):
|
||||
resp = await self.client.request("GET", "/prompt_manager/lora/status")
|
||||
self.assertEqual(resp.status, 500)
|
||||
body = await resp.text()
|
||||
self.assertNotIn(str(self.tmp_root), body)
|
||||
self.assertIn("c.json", body)
|
||||
|
||||
|
||||
class TestLoraEnableRoute(LoraAPITestCase):
|
||||
|
||||
async def test_enable_with_unknown_path_is_400(self):
|
||||
resp = await self._post_json(
|
||||
"/prompt_manager/lora/enable", {"enabled": True, "path": "/nowhere"}
|
||||
)
|
||||
self.assertEqual(resp.status, 400)
|
||||
self.assertFalse(IntegrationConfig.LORA_MANAGER_ENABLED)
|
||||
self.save_mock.assert_not_called()
|
||||
|
||||
async def test_enable_persists_and_loads_trigger_cache(self):
|
||||
lm = _make_lora_manager(self.custom_nodes)
|
||||
loras = self.tmp_root / "loras"
|
||||
loras.mkdir()
|
||||
_write_metadata(loras, "neon", words=["glow"])
|
||||
self.detect_mock.return_value = str(lm)
|
||||
|
||||
with patch.object(
|
||||
lora_utils, "find_lora_directories", return_value=[str(loras)]
|
||||
):
|
||||
resp = await self._post_json(
|
||||
"/prompt_manager/lora/enable",
|
||||
{
|
||||
"enabled": True,
|
||||
"path": str(lm),
|
||||
"trigger_words_enabled": True,
|
||||
"civitai_api_key": SECRET_KEY,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
body = await resp.text()
|
||||
data = json.loads(body)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["path"], self.api._public_path(str(lm)))
|
||||
self.assertNotIn(str(self.tmp_root), body)
|
||||
self.assertNotIn(SECRET_KEY, body)
|
||||
self.assertTrue(IntegrationConfig.LORA_MANAGER_ENABLED)
|
||||
self.assertEqual(
|
||||
IntegrationConfig.LORA_MANAGER_PATH,
|
||||
os.path.normcase(os.path.realpath(str(lm))),
|
||||
)
|
||||
self.assertEqual(IntegrationConfig.CIVITAI_API_KEY, SECRET_KEY)
|
||||
self.save_mock.assert_called_once_with()
|
||||
self.assertTrue(get_trigger_cache().is_loaded)
|
||||
self.assertEqual(get_trigger_cache().get_trigger_words("neon"), ["glow"])
|
||||
|
||||
async def test_enable_accepts_the_public_relative_path(self):
|
||||
lm = _make_lora_manager(self.custom_nodes)
|
||||
self.detect_mock.return_value = str(lm)
|
||||
public = self.api._public_path(str(lm))
|
||||
self.assertFalse(os.path.isabs(public))
|
||||
|
||||
resp = await self._post_json(
|
||||
"/prompt_manager/lora/enable", {"enabled": True, "path": public}
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertTrue(IntegrationConfig.LORA_MANAGER_ENABLED)
|
||||
self.assertEqual(
|
||||
IntegrationConfig.LORA_MANAGER_PATH,
|
||||
os.path.normcase(os.path.realpath(str(lm))),
|
||||
)
|
||||
|
||||
async def test_enable_with_path_outside_custom_nodes_is_400(self):
|
||||
lm = _make_lora_manager(self.tmp_root)
|
||||
self.detect_mock.return_value = str(lm)
|
||||
|
||||
resp = await self._post_json(
|
||||
"/prompt_manager/lora/enable", {"enabled": True, "path": str(lm)}
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status, 400)
|
||||
body = await resp.text()
|
||||
self.assertFalse(json.loads(body)["success"])
|
||||
self.assertNotIn(str(self.tmp_root), body)
|
||||
self.assertFalse(IntegrationConfig.LORA_MANAGER_ENABLED)
|
||||
self.save_mock.assert_not_called()
|
||||
|
||||
async def test_enable_with_custom_node_lacking_lora_in_its_name_is_400(self):
|
||||
lm = _make_lora_manager(self.custom_nodes).parent / "ComfyUI-Other"
|
||||
(lm / "py").mkdir(parents=True)
|
||||
(lm / "__init__.py").write_text("")
|
||||
self.detect_mock.return_value = str(lm)
|
||||
|
||||
resp = await self._post_json(
|
||||
"/prompt_manager/lora/enable", {"enabled": True, "path": str(lm)}
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status, 400)
|
||||
self.assertFalse(IntegrationConfig.LORA_MANAGER_ENABLED)
|
||||
|
||||
async def test_enable_rejects_an_auto_detected_path_outside_custom_nodes(self):
|
||||
self.detect_mock.return_value = str(_make_lora_manager(self.tmp_root))
|
||||
|
||||
resp = await self._post_json("/prompt_manager/lora/enable", {"enabled": True})
|
||||
|
||||
self.assertEqual(resp.status, 400)
|
||||
self.assertFalse(IntegrationConfig.LORA_MANAGER_ENABLED)
|
||||
|
||||
async def test_enable_writes_the_configured_config_file(self):
|
||||
config_file = self.tmp_root / "cfg" / "config.json"
|
||||
self.save_mock.side_effect = self._real_save
|
||||
|
||||
with patch.dict(os.environ, {"PROMPT_MANAGER_CONFIG_PATH": str(config_file)}):
|
||||
resp = await self._post_json(
|
||||
"/prompt_manager/lora/enable",
|
||||
{"enabled": False, "trigger_words_enabled": True},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.save_mock.assert_called_once_with()
|
||||
self.assertTrue(config_file.is_file())
|
||||
saved = json.loads(config_file.read_text())["integrations"]["lora_manager"]
|
||||
self.assertFalse(saved["enabled"])
|
||||
self.assertTrue(saved["trigger_words_enabled"])
|
||||
|
||||
async def test_disable_clears_cache(self):
|
||||
get_trigger_cache()._cache = {"x": ["y"]}
|
||||
get_trigger_cache()._loaded = True
|
||||
|
||||
resp = await self._post_json("/prompt_manager/lora/enable", {"enabled": False})
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertFalse(get_trigger_cache().is_loaded)
|
||||
self.assertFalse(IntegrationConfig.LORA_MANAGER_ENABLED)
|
||||
|
||||
async def test_empty_key_keeps_the_stored_key(self):
|
||||
IntegrationConfig.CIVITAI_API_KEY = SECRET_KEY
|
||||
|
||||
resp = await self._post_json(
|
||||
"/prompt_manager/lora/enable", {"enabled": False, "civitai_api_key": ""}
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(IntegrationConfig.CIVITAI_API_KEY, SECRET_KEY)
|
||||
|
||||
async def test_absent_key_keeps_the_stored_key(self):
|
||||
IntegrationConfig.CIVITAI_API_KEY = SECRET_KEY
|
||||
|
||||
await self._post_json("/prompt_manager/lora/enable", {"enabled": False})
|
||||
|
||||
self.assertEqual(IntegrationConfig.CIVITAI_API_KEY, SECRET_KEY)
|
||||
|
||||
async def test_new_key_replaces_the_stored_key(self):
|
||||
IntegrationConfig.CIVITAI_API_KEY = SECRET_KEY
|
||||
|
||||
await self._post_json(
|
||||
"/prompt_manager/lora/enable",
|
||||
{"enabled": False, "civitai_api_key": " new-key "},
|
||||
)
|
||||
|
||||
self.assertEqual(IntegrationConfig.CIVITAI_API_KEY, "new-key")
|
||||
|
||||
async def test_clear_flag_removes_the_stored_key(self):
|
||||
IntegrationConfig.CIVITAI_API_KEY = SECRET_KEY
|
||||
|
||||
resp = await self._post_json(
|
||||
"/prompt_manager/lora/enable",
|
||||
{"enabled": False, "clear_civitai_api_key": True},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(IntegrationConfig.CIVITAI_API_KEY, "")
|
||||
|
||||
async def test_non_string_key_is_400(self):
|
||||
resp = await self._post_json(
|
||||
"/prompt_manager/lora/enable", {"enabled": False, "civitai_api_key": 123}
|
||||
)
|
||||
self.assertEqual(resp.status, 400)
|
||||
|
||||
async def test_persist_failure_is_500(self):
|
||||
self.save_mock.side_effect = OSError("read-only")
|
||||
resp = await self._post_json("/prompt_manager/lora/enable", {"enabled": False})
|
||||
self.assertEqual(resp.status, 500)
|
||||
|
||||
|
||||
class TestLoraScanRoute(LoraAPITestCase):
|
||||
|
||||
def _enable(self):
|
||||
IntegrationConfig.LORA_MANAGER_ENABLED = True
|
||||
IntegrationConfig.LORA_MANAGER_PATH = str(_make_lora_manager(self.custom_nodes))
|
||||
IntegrationConfig.CIVITAI_API_KEY = SECRET_KEY
|
||||
|
||||
async def test_configured_path_outside_custom_nodes_is_400(self):
|
||||
IntegrationConfig.LORA_MANAGER_ENABLED = True
|
||||
IntegrationConfig.LORA_MANAGER_PATH = str(_make_lora_manager(self.tmp_root))
|
||||
|
||||
resp = await self.client.request("POST", "/prompt_manager/lora/scan")
|
||||
|
||||
self.assertEqual(resp.status, 400)
|
||||
body = await resp.text()
|
||||
self.assertFalse(json.loads(body)["success"])
|
||||
self.assertNotIn(str(self.tmp_root), body)
|
||||
|
||||
async def test_scan_error_hides_the_server_path(self):
|
||||
self._enable()
|
||||
err = PermissionError(13, "Permission denied", str(self.tmp_root / "x.db"))
|
||||
with patch.object(self.api.db, "delete_prompts_by_category", side_effect=err):
|
||||
resp = await self.client.request("POST", "/prompt_manager/lora/scan")
|
||||
body = await resp.text()
|
||||
self.assertNotIn(str(self.tmp_root), body)
|
||||
self.assertIn("x.db", body)
|
||||
|
||||
@staticmethod
|
||||
def _sse_events(text):
|
||||
return [
|
||||
json.loads(line[len("data: ") :])
|
||||
for line in text.splitlines()
|
||||
if line.startswith("data: ")
|
||||
]
|
||||
|
||||
async def _scan(self, loras):
|
||||
opener_calls = []
|
||||
|
||||
def fake_open(req, timeout=None):
|
||||
opener_calls.append(req)
|
||||
return _FakeResponse(_png_bytes())
|
||||
|
||||
cache_dir = self.tmp_root / "img_cache"
|
||||
with (
|
||||
patch.object(
|
||||
lora_utils, "find_lora_directories", return_value=[str(loras)]
|
||||
),
|
||||
patch.object(
|
||||
lora_utils, "get_lora_image_cache_dir", return_value=cache_dir
|
||||
),
|
||||
patch.object(lora_utils, "_open_url", fake_open),
|
||||
):
|
||||
resp = await self.client.request("POST", "/prompt_manager/lora/scan")
|
||||
text = await resp.text()
|
||||
return resp, self._sse_events(text), opener_calls
|
||||
|
||||
async def test_not_enabled_is_400(self):
|
||||
resp = await self.client.request("POST", "/prompt_manager/lora/scan")
|
||||
self.assertEqual(resp.status, 400)
|
||||
|
||||
async def test_no_path_is_400(self):
|
||||
IntegrationConfig.LORA_MANAGER_ENABLED = True
|
||||
resp = await self.client.request("POST", "/prompt_manager/lora/scan")
|
||||
self.assertEqual(resp.status, 400)
|
||||
|
||||
async def test_scan_imports_loras_with_images_and_links_existing(self):
|
||||
self._enable()
|
||||
loras = self.tmp_root / "loras"
|
||||
loras.mkdir()
|
||||
_write_metadata(
|
||||
loras,
|
||||
"neon",
|
||||
words=["glow"],
|
||||
images=["https://civitai.com/a.png", "https://evil.example/b.png"],
|
||||
prompt="neon street",
|
||||
)
|
||||
(loras / "neon.png").write_bytes(_png_bytes())
|
||||
_write_metadata(loras, "plain")
|
||||
(loras / "broken.metadata.json").write_text("{{{")
|
||||
existing_id = self.api.db.save_prompt(
|
||||
text="plain",
|
||||
category=None,
|
||||
tags=[],
|
||||
rating=None,
|
||||
prompt_hash=generate_prompt_hash("plain"),
|
||||
)
|
||||
|
||||
resp, events, opener_calls = await self._scan(loras)
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
done = events[-1]
|
||||
self.assertEqual(done["type"], "complete")
|
||||
self.assertEqual(done["total"], 3)
|
||||
self.assertEqual(done["imported"], 1)
|
||||
self.assertEqual(done["skipped"], 2)
|
||||
self.assertEqual(
|
||||
[r.full_url for r in opener_calls], ["https://civitai.com/a.png"]
|
||||
)
|
||||
self.assertEqual(
|
||||
opener_calls[0].get_header("Authorization"), f"Bearer {SECRET_KEY}"
|
||||
)
|
||||
imported = self.api.db.search_prompts(category="lora-manager")
|
||||
self.assertEqual(len(imported), 1)
|
||||
self.assertEqual(imported[0]["text"], "neon street")
|
||||
self.assertIn("lora:neon", imported[0]["tags"])
|
||||
self.assertIn("glow", imported[0]["tags"])
|
||||
self.assertEqual(len(self.api.db.get_prompt_images(imported[0]["id"])), 2)
|
||||
self.assertEqual(len(self.api.db.get_prompt_images(existing_id)), 0)
|
||||
|
||||
async def test_save_failure_is_counted_as_skipped(self):
|
||||
self._enable()
|
||||
loras = self.tmp_root / "loras"
|
||||
loras.mkdir()
|
||||
_write_metadata(loras, "neon", words=["glow"])
|
||||
|
||||
with patch.object(self.api.db, "save_prompt", side_effect=RuntimeError("db")):
|
||||
resp, events, _ = await self._scan(loras)
|
||||
|
||||
self.assertEqual(events[-1]["imported"], 0)
|
||||
self.assertEqual(events[-1]["skipped"], 1)
|
||||
|
||||
|
||||
class TestLoraTriggerWordRoutes(LoraAPITestCase):
|
||||
|
||||
async def test_trigger_words_not_enabled_is_400(self):
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/lora/trigger-words?name=x"
|
||||
)
|
||||
self.assertEqual(resp.status, 400)
|
||||
|
||||
async def test_trigger_words_missing_name_is_400(self):
|
||||
IntegrationConfig.LORA_MANAGER_ENABLED = True
|
||||
resp = await self.client.request("GET", "/prompt_manager/lora/trigger-words")
|
||||
self.assertEqual(resp.status, 400)
|
||||
|
||||
async def test_trigger_words_loads_cache_on_demand(self):
|
||||
IntegrationConfig.LORA_MANAGER_ENABLED = True
|
||||
IntegrationConfig.LORA_MANAGER_PATH = str(self.tmp_root)
|
||||
loras = self.tmp_root / "loras"
|
||||
loras.mkdir()
|
||||
_write_metadata(loras, "neon", words=["glow", "bright"])
|
||||
|
||||
with patch.object(
|
||||
lora_utils, "find_lora_directories", return_value=[str(loras)]
|
||||
):
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/lora/trigger-words?name=NEON"
|
||||
)
|
||||
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["trigger_words"], ["glow", "bright"])
|
||||
self.assertTrue(get_trigger_cache().is_loaded)
|
||||
|
||||
async def test_trigger_words_error_is_500(self):
|
||||
IntegrationConfig.LORA_MANAGER_ENABLED = True
|
||||
with patch("py.lora_utils.get_trigger_cache", side_effect=RuntimeError("x")):
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/lora/trigger-words?name=x"
|
||||
)
|
||||
self.assertEqual(resp.status, 500)
|
||||
|
||||
async def test_refresh_not_enabled_is_400(self):
|
||||
resp = await self.client.request("POST", "/prompt_manager/lora/refresh-cache")
|
||||
self.assertEqual(resp.status, 400)
|
||||
|
||||
async def test_refresh_without_path_is_400(self):
|
||||
IntegrationConfig.LORA_MANAGER_ENABLED = True
|
||||
resp = await self.client.request("POST", "/prompt_manager/lora/refresh-cache")
|
||||
self.assertEqual(resp.status, 400)
|
||||
|
||||
async def test_refresh_reloads_cache(self):
|
||||
IntegrationConfig.LORA_MANAGER_ENABLED = True
|
||||
IntegrationConfig.LORA_MANAGER_PATH = str(self.tmp_root)
|
||||
loras = self.tmp_root / "loras"
|
||||
loras.mkdir()
|
||||
_write_metadata(loras, "neon", words=["glow"])
|
||||
|
||||
with patch.object(
|
||||
lora_utils, "find_lora_directories", return_value=[str(loras)]
|
||||
):
|
||||
resp = await self.client.request(
|
||||
"POST", "/prompt_manager/lora/refresh-cache"
|
||||
)
|
||||
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
# keyed by both the file_name stem and the metadata-file stem
|
||||
self.assertEqual(data["loras_with_trigger_words"], 2)
|
||||
|
||||
async def test_refresh_error_is_500(self):
|
||||
IntegrationConfig.LORA_MANAGER_ENABLED = True
|
||||
IntegrationConfig.LORA_MANAGER_PATH = str(self.tmp_root)
|
||||
with patch("py.lora_utils.get_trigger_cache", side_effect=RuntimeError("x")):
|
||||
resp = await self.client.request(
|
||||
"POST", "/prompt_manager/lora/refresh-cache"
|
||||
)
|
||||
self.assertEqual(resp.status, 500)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,942 @@
|
||||
"""
|
||||
Route tests for the prompt API (py/api/prompts.py).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
sys.modules.setdefault("folder_paths", MagicMock())
|
||||
sys.modules.setdefault("server", MagicMock())
|
||||
|
||||
from aiohttp import web # noqa: E402
|
||||
from aiohttp.test_utils import AioHTTPTestCase # noqa: E402
|
||||
|
||||
from database.operations import PromptDatabase # noqa: E402
|
||||
from py.api import PromptManagerAPI # noqa: E402
|
||||
import py.api.prompts as prompts_module # noqa: E402
|
||||
from py.api.prompts import ( # noqa: E402
|
||||
MAX_PAGE_LIMIT,
|
||||
MAX_PAGE_OFFSET,
|
||||
safe_error_message,
|
||||
)
|
||||
from utils.hashing import generate_prompt_hash # noqa: E402
|
||||
|
||||
|
||||
class PromptAPITestCase(AioHTTPTestCase):
|
||||
"""App with PromptManager routes and a temporary SQLite database."""
|
||||
|
||||
async def get_application(self):
|
||||
self._temp_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
|
||||
self._temp_db.close()
|
||||
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(self._temp_db.name)
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
return app
|
||||
|
||||
async def tearDownAsync(self):
|
||||
await super().tearDownAsync() # closes the aiohttp test client
|
||||
# Windows refuses to unlink a database that still has open handles.
|
||||
self.api.db.close_all()
|
||||
for path in (
|
||||
self._temp_db.name,
|
||||
self._temp_db.name + "-wal",
|
||||
self._temp_db.name + "-shm",
|
||||
):
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
def _save_prompt(self, text="Test prompt", **kwargs):
|
||||
return self.api.db.save_prompt(
|
||||
text=text, prompt_hash=generate_prompt_hash(text), **kwargs
|
||||
)
|
||||
|
||||
|
||||
class TestSafeErrorMessage(unittest.TestCase):
|
||||
"""Error strings returned to clients never contain absolute server paths."""
|
||||
|
||||
def test_oserror_uses_strerror_and_basename(self):
|
||||
secret = os.path.join(os.sep, "srv", "comfy", "output", "hidden.png")
|
||||
exc = FileNotFoundError(2, "No such file or directory", secret)
|
||||
|
||||
message = safe_error_message(exc)
|
||||
|
||||
self.assertNotIn(os.path.dirname(secret), message)
|
||||
self.assertIn("No such file or directory", message)
|
||||
self.assertIn("hidden.png", message)
|
||||
|
||||
def test_oserror_without_filename(self):
|
||||
exc = PermissionError(13, "Permission denied")
|
||||
|
||||
self.assertEqual(safe_error_message(exc), "Permission denied")
|
||||
|
||||
def test_plain_exception_uses_str(self):
|
||||
self.assertEqual(safe_error_message(ValueError("bad value")), "bad value")
|
||||
|
||||
def test_empty_message_falls_back_to_type_name(self):
|
||||
self.assertEqual(safe_error_message(RuntimeError()), "RuntimeError")
|
||||
|
||||
|
||||
class TestSearchBounds(PromptAPITestCase):
|
||||
"""GET /prompt_manager/search clamps limit/offset and rejects junk."""
|
||||
|
||||
async def test_limit_is_clamped_to_max_page_limit(self):
|
||||
resp = await self.client.request("GET", "/prompt_manager/search?limit=999999")
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
self.assertLessEqual(len(data["results"]), MAX_PAGE_LIMIT)
|
||||
self.assertEqual(data["pagination"]["limit"], MAX_PAGE_LIMIT)
|
||||
|
||||
async def test_zero_limit_is_raised_to_one(self):
|
||||
self._save_prompt("only one")
|
||||
resp = await self.client.request("GET", "/prompt_manager/search?limit=0")
|
||||
|
||||
data = await resp.json()
|
||||
self.assertEqual(data["pagination"]["limit"], 1)
|
||||
self.assertEqual(len(data["results"]), 1)
|
||||
|
||||
async def test_non_integer_limit_is_400(self):
|
||||
resp = await self.client.request("GET", "/prompt_manager/search?limit=abc")
|
||||
|
||||
self.assertEqual(resp.status, 400)
|
||||
data = await resp.json()
|
||||
self.assertFalse(data["success"])
|
||||
self.assertIn("error", data)
|
||||
|
||||
async def test_non_integer_offset_is_400(self):
|
||||
resp = await self.client.request("GET", "/prompt_manager/search?offset=1.5")
|
||||
|
||||
self.assertEqual(resp.status, 400)
|
||||
|
||||
async def test_negative_offset_is_clamped_to_zero(self):
|
||||
# Clamped, not rejected: paging backwards past page one yields page one.
|
||||
resp = await self.client.request("GET", "/prompt_manager/search?offset=-5")
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertEqual(data["pagination"]["offset"], 0)
|
||||
|
||||
async def test_offset_beyond_int64_is_clamped_not_500(self):
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/search?offset=99999999999999999999999"
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertEqual(data["pagination"]["offset"], MAX_PAGE_OFFSET)
|
||||
|
||||
async def test_offset_skips_results(self):
|
||||
for i in range(3):
|
||||
self._save_prompt(f"searchable {i}")
|
||||
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/search?text=searchable&limit=2&offset=2"
|
||||
)
|
||||
|
||||
data = await resp.json()
|
||||
self.assertEqual(len(data["results"]), 1)
|
||||
|
||||
|
||||
class TestRecentBounds(PromptAPITestCase):
|
||||
"""GET /prompt_manager/recent clamps limit/offset and rejects junk."""
|
||||
|
||||
async def test_limit_is_clamped_to_max_page_limit(self):
|
||||
resp = await self.client.request("GET", "/prompt_manager/recent?limit=999999")
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertLessEqual(len(data["results"]), MAX_PAGE_LIMIT)
|
||||
self.assertEqual(data["pagination"]["limit"], MAX_PAGE_LIMIT)
|
||||
|
||||
async def test_non_integer_limit_is_400(self):
|
||||
resp = await self.client.request("GET", "/prompt_manager/recent?limit=abc")
|
||||
|
||||
self.assertEqual(resp.status, 400)
|
||||
data = await resp.json()
|
||||
self.assertFalse(data["success"])
|
||||
|
||||
async def test_non_integer_page_is_400(self):
|
||||
resp = await self.client.request("GET", "/prompt_manager/recent?page=two")
|
||||
|
||||
self.assertEqual(resp.status, 400)
|
||||
|
||||
async def test_negative_offset_is_clamped_to_zero(self):
|
||||
# Clamped, not rejected: paging backwards past page one yields page one.
|
||||
resp = await self.client.request("GET", "/prompt_manager/recent?offset=-5")
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertEqual(data["pagination"]["offset"], 0)
|
||||
|
||||
async def test_page_param_derives_offset(self):
|
||||
for i in range(5):
|
||||
self._save_prompt(f"paged {i}")
|
||||
|
||||
resp = await self.client.request("GET", "/prompt_manager/recent?limit=2&page=3")
|
||||
|
||||
data = await resp.json()
|
||||
self.assertEqual(data["pagination"]["offset"], 4)
|
||||
self.assertEqual(len(data["results"]), 1)
|
||||
|
||||
async def test_huge_page_number_is_clamped_not_500(self):
|
||||
resp = await self.client.request(
|
||||
"GET", f"/prompt_manager/recent?limit={MAX_PAGE_LIMIT}&page={10**30}"
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertEqual(data["pagination"]["offset"], MAX_PAGE_OFFSET)
|
||||
self.assertEqual(data["results"], [])
|
||||
|
||||
|
||||
def _raise(*_args, **_kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
class RouteCoverageCase(PromptAPITestCase):
|
||||
"""Helpers for driving success and failure branches of each route."""
|
||||
|
||||
def _break(self, db_method):
|
||||
"""Make a DB method raise so the route's 500 branch runs."""
|
||||
setattr(self.api.db, db_method, _raise)
|
||||
|
||||
def _stub(self, db_method, value):
|
||||
setattr(self.api.db, db_method, lambda *a, **k: value)
|
||||
|
||||
async def _json(self, method, path, **kwargs):
|
||||
resp = await self.client.request(method, path, **kwargs)
|
||||
return resp.status, await resp.json()
|
||||
|
||||
|
||||
class TestListRoutesErrorPaths(RouteCoverageCase):
|
||||
|
||||
async def test_search_db_failure_is_500(self):
|
||||
self._break("search_prompts")
|
||||
status, data = await self._json("GET", "/prompt_manager/search")
|
||||
self.assertEqual(status, 500)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertEqual(data["results"], [])
|
||||
|
||||
async def test_search_ignores_non_integer_min_rating(self):
|
||||
self._save_prompt("rated", rating=3)
|
||||
status, data = await self._json("GET", "/prompt_manager/search?min_rating=x")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(len(data["results"]), 1)
|
||||
|
||||
async def test_recent_db_failure_is_500(self):
|
||||
self._break("get_recent_prompts")
|
||||
status, data = await self._json("GET", "/prompt_manager/recent")
|
||||
self.assertEqual(status, 500)
|
||||
self.assertEqual(data["pagination"]["total"], 0)
|
||||
|
||||
async def test_categories_and_tags_db_failures_are_500(self):
|
||||
self._break("get_all_categories")
|
||||
self._break("get_all_tags")
|
||||
c_status, c_data = await self._json("GET", "/prompt_manager/categories")
|
||||
t_status, t_data = await self._json("GET", "/prompt_manager/tags")
|
||||
self.assertEqual((c_status, t_status), (500, 500))
|
||||
self.assertEqual(c_data["categories"], [])
|
||||
self.assertEqual(t_data["tags"], [])
|
||||
|
||||
async def test_subfolders_success_and_failure(self):
|
||||
ok_status, ok_data = await self._json(
|
||||
"GET", "/prompt_manager/subfolders?include_ancestors=true"
|
||||
)
|
||||
self._break("get_prompt_subfolders")
|
||||
bad_status, _ = await self._json("GET", "/prompt_manager/subfolders")
|
||||
self.assertEqual(ok_status, 200)
|
||||
self.assertEqual(ok_data["subfolders"], [])
|
||||
self.assertEqual(bad_status, 500)
|
||||
|
||||
async def test_export_db_failure_is_500(self):
|
||||
self._break("search_prompts")
|
||||
status, data = await self._json("GET", "/prompt_manager/export")
|
||||
self.assertEqual(status, 500)
|
||||
self.assertFalse(data["success"])
|
||||
|
||||
|
||||
class TestTagStatsAndFilters(RouteCoverageCase):
|
||||
|
||||
async def test_tags_stats_rejects_bad_limit(self):
|
||||
status, _ = await self._json("GET", "/prompt_manager/tags/stats?limit=x")
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
async def test_tags_stats_db_failure_is_500(self):
|
||||
self._break("get_tags_with_counts")
|
||||
status, _ = await self._json("GET", "/prompt_manager/tags/stats")
|
||||
self.assertEqual(status, 500)
|
||||
|
||||
async def test_tag_prompts_success(self):
|
||||
self._save_prompt("P1", tags=["sky"])
|
||||
status, data = await self._json("GET", "/prompt_manager/tags/sky/prompts")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data["tag"], "sky")
|
||||
self.assertEqual(len(data["prompts"]), 1)
|
||||
self.assertIn("pagination", data)
|
||||
|
||||
async def test_tag_prompts_bad_limit_and_db_failure(self):
|
||||
bad_status, _ = await self._json(
|
||||
"GET", "/prompt_manager/tags/sky/prompts?offset=z"
|
||||
)
|
||||
self._break("get_prompts_by_tags")
|
||||
err_status, _ = await self._json("GET", "/prompt_manager/tags/sky/prompts")
|
||||
self.assertEqual((bad_status, err_status), (400, 500))
|
||||
|
||||
async def test_tags_filter_untagged_mode(self):
|
||||
self._save_prompt("no tags")
|
||||
self._save_prompt("tagged", tags=["x"])
|
||||
status, data = await self._json(
|
||||
"GET", "/prompt_manager/tags/filter?untagged=true"
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data["mode"], "untagged")
|
||||
self.assertEqual(len(data["prompts"]), 1)
|
||||
|
||||
async def test_tags_filter_untagged_bad_limit(self):
|
||||
status, _ = await self._json(
|
||||
"GET", "/prompt_manager/tags/filter?untagged=true&limit=x"
|
||||
)
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
async def test_tags_filter_requires_tags(self):
|
||||
status, _ = await self._json("GET", "/prompt_manager/tags/filter")
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
async def test_tags_filter_and_or_modes(self):
|
||||
self._save_prompt("both", tags=["a", "b"])
|
||||
self._save_prompt("only a", tags=["a"])
|
||||
_, and_data = await self._json(
|
||||
"GET", "/prompt_manager/tags/filter?tags=a,b&mode=and"
|
||||
)
|
||||
_, or_data = await self._json(
|
||||
"GET", "/prompt_manager/tags/filter?tags=a,b&mode=weird"
|
||||
)
|
||||
self.assertEqual(len(and_data["prompts"]), 1)
|
||||
self.assertEqual(or_data["mode"], "and")
|
||||
self.assertEqual(len(or_data["prompts"]), 1)
|
||||
|
||||
async def test_tags_filter_bad_limit_and_db_failure(self):
|
||||
bad_status, _ = await self._json(
|
||||
"GET", "/prompt_manager/tags/filter?tags=a&limit=x"
|
||||
)
|
||||
self._break("get_prompts_by_tags")
|
||||
err_status, _ = await self._json("GET", "/prompt_manager/tags/filter?tags=a")
|
||||
self.assertEqual((bad_status, err_status), (400, 500))
|
||||
|
||||
|
||||
class TestTagSuggestRoute(RouteCoverageCase):
|
||||
"""GET /prompt_manager/tags/suggest?q=&with=&limit="""
|
||||
|
||||
URL = "/prompt_manager/tags/suggest"
|
||||
|
||||
def _seed(self):
|
||||
for text, tags in (
|
||||
("P1", ["asian", "portrait", "smile"]),
|
||||
("P2", ["asian", "portrait"]),
|
||||
("P3", ["portrait", "dog"]),
|
||||
):
|
||||
self.api.db.save_prompt(text, "test", tags)
|
||||
|
||||
async def test_suggestions_narrow_by_context_tags(self):
|
||||
self._seed()
|
||||
|
||||
status, data = await self._json("GET", f"{self.URL}?q=&with=asian")
|
||||
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(
|
||||
[(t["name"], t["count"]) for t in data["suggestions"]],
|
||||
[("portrait", 2), ("smile", 1)],
|
||||
)
|
||||
|
||||
async def test_prefix_and_messy_context_parsing(self):
|
||||
self._seed()
|
||||
|
||||
status, data = await self._json(
|
||||
"GET", f"{self.URL}?q=S&with=%20asian%2C%2Cportrait%2Casian%20"
|
||||
)
|
||||
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual([t["name"] for t in data["suggestions"]], ["smile"])
|
||||
|
||||
async def test_limit_is_clamped_and_route_is_not_a_tag_name(self):
|
||||
self._seed()
|
||||
|
||||
status, data = await self._json("GET", f"{self.URL}?limit=1")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(len(data["suggestions"]), 1)
|
||||
|
||||
status, data = await self._json("GET", f"{self.URL}?limit=9999")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertLessEqual(len(data["suggestions"]), 50)
|
||||
|
||||
status, data = await self._json("GET", f"{self.URL}?limit=abc")
|
||||
self.assertEqual(status, 200)
|
||||
|
||||
async def test_db_failure_is_500(self):
|
||||
self._break("suggest_tags")
|
||||
status, data = await self._json("GET", self.URL)
|
||||
self.assertEqual(status, 500)
|
||||
self.assertFalse(data["success"])
|
||||
|
||||
|
||||
class TestTagRoutePagingBounds(RouteCoverageCase):
|
||||
"""Tag listings clamp limit/offset like every other list endpoint."""
|
||||
|
||||
ROUTES = (
|
||||
"/prompt_manager/tags/stats",
|
||||
"/prompt_manager/tags/sky/prompts",
|
||||
"/prompt_manager/tags/filter?tags=sky",
|
||||
"/prompt_manager/tags/filter?untagged=true",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _with(route, query):
|
||||
return f"{route}{'&' if '?' in route else '?'}{query}"
|
||||
|
||||
async def test_limit_is_clamped_to_max_page_limit(self):
|
||||
for route in self.ROUTES:
|
||||
status, data = await self._json("GET", self._with(route, "limit=999999"))
|
||||
self.assertEqual(status, 200, route)
|
||||
self.assertEqual(data["pagination"]["limit"], MAX_PAGE_LIMIT, route)
|
||||
|
||||
async def test_zero_limit_becomes_one(self):
|
||||
for route in self.ROUTES:
|
||||
_, data = await self._json("GET", self._with(route, "limit=0"))
|
||||
self.assertEqual(data["pagination"]["limit"], 1, route)
|
||||
|
||||
async def test_negative_offset_is_clamped_to_zero(self):
|
||||
for route in self.ROUTES:
|
||||
_, data = await self._json("GET", self._with(route, "offset=-7"))
|
||||
self.assertEqual(data["pagination"]["offset"], 0, route)
|
||||
|
||||
async def test_huge_offset_is_clamped_not_500(self):
|
||||
for route in self.ROUTES:
|
||||
status, data = await self._json(
|
||||
"GET", self._with(route, f"offset={10**30}")
|
||||
)
|
||||
self.assertEqual(status, 200, route)
|
||||
self.assertEqual(data["pagination"]["offset"], MAX_PAGE_OFFSET, route)
|
||||
|
||||
async def test_non_integer_values_are_400(self):
|
||||
for route in self.ROUTES:
|
||||
status, data = await self._json("GET", self._with(route, "limit=1.5"))
|
||||
self.assertEqual(status, 400, route)
|
||||
self.assertFalse(data["success"], route)
|
||||
|
||||
|
||||
class TestPublicPathsInPromptResponses(RouteCoverageCase):
|
||||
"""Preview images on prompt listings never expose the server's directories."""
|
||||
|
||||
SECRET_DIR = os.path.join(os.sep, "srv", "secret")
|
||||
|
||||
async def setUpAsync(self):
|
||||
await super().setUpAsync()
|
||||
self.pid = self._save_prompt("has preview", tags=["sky"])
|
||||
image_path = os.path.join(self.SECRET_DIR, "gen.png")
|
||||
self.assertGreater(self.api.db.link_image_to_prompt(self.pid, image_path), 0)
|
||||
|
||||
async def _body(self, method, path, **kwargs):
|
||||
resp = await self.client.request(method, path, **kwargs)
|
||||
text = await resp.text()
|
||||
self.assertNotIn(self.SECRET_DIR, text, path)
|
||||
return resp.status, json.loads(text)
|
||||
|
||||
async def test_listing_routes_publish_preview_image_paths(self):
|
||||
routes = {
|
||||
"/prompt_manager/recent": "results",
|
||||
"/prompt_manager/search?q=preview": "results",
|
||||
"/prompt_manager/tags/sky/prompts": "prompts",
|
||||
"/prompt_manager/tags/filter?tags=sky": "prompts",
|
||||
}
|
||||
for route, key in routes.items():
|
||||
status, data = await self._body("GET", route)
|
||||
|
||||
self.assertEqual(status, 200, route)
|
||||
image = data[key][0]["images"][0]
|
||||
# No ComfyUI anchor is configured here, so only the name survives.
|
||||
self.assertEqual(image["image_path"], "gen.png", route)
|
||||
self.assertNotIn("file_path", image, route)
|
||||
|
||||
async def test_oserror_bodies_keep_only_the_basename(self):
|
||||
def boom(*_args, **_kwargs):
|
||||
raise FileNotFoundError(
|
||||
2, "No such file", os.path.join(self.SECRET_DIR, "prompts.db")
|
||||
)
|
||||
|
||||
for db_method, route in (
|
||||
("get_tags_with_counts", "/prompt_manager/tags/stats"),
|
||||
("search_prompts", "/prompt_manager/search"),
|
||||
("get_recent_prompts", "/prompt_manager/recent"),
|
||||
("get_prompts_by_tags", "/prompt_manager/tags/sky/prompts"),
|
||||
):
|
||||
setattr(self.api.db, db_method, boom)
|
||||
status, data = await self._body("GET", route)
|
||||
|
||||
self.assertEqual(status, 500, route)
|
||||
self.assertIn("prompts.db", data["error"], route)
|
||||
|
||||
|
||||
class TestTagNamesAreDecodedOnce(RouteCoverageCase):
|
||||
"""aiohttp already decodes match_info; a tag literally named '%41' stays '%41'."""
|
||||
|
||||
async def setUpAsync(self):
|
||||
await super().setUpAsync()
|
||||
self.pid = self._save_prompt("percent tag", tags=["%41"])
|
||||
|
||||
async def test_tag_prompts_lookup(self):
|
||||
status, data = await self._json("GET", "/prompt_manager/tags/%2541/prompts")
|
||||
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data["tag"], "%41")
|
||||
self.assertEqual(len(data["prompts"]), 1)
|
||||
|
||||
async def test_rename_tag(self):
|
||||
status, data = await self._json(
|
||||
"PUT", "/prompt_manager/tags/%2541", json={"new_name": "renamed"}
|
||||
)
|
||||
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data["old_name"], "%41")
|
||||
self.assertEqual(data["affected_count"], 1)
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(self.pid)["tags"], ["renamed"])
|
||||
|
||||
async def test_delete_tag(self):
|
||||
status, data = await self._json("DELETE", "/prompt_manager/tags/%2541")
|
||||
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data["tag_name"], "%41")
|
||||
self.assertEqual(data["affected_count"], 1)
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(self.pid)["tags"], [])
|
||||
|
||||
|
||||
class TestTagInputValidation(RouteCoverageCase):
|
||||
"""Tag mutations run validate_tags: bad input is a 400, never a traceback."""
|
||||
|
||||
def _requests(self, tag):
|
||||
pid = self._save_prompt("validate me", tags=["old"])
|
||||
return (
|
||||
("POST", f"/prompt_manager/prompts/{pid}/tags", {"tag": tag}),
|
||||
("POST", "/prompt_manager/prompts/tags", {"prompt_id": pid, "tags": [tag]}),
|
||||
("POST", "/prompt_manager/bulk/tags", {"prompt_ids": [pid], "tags": [tag]}),
|
||||
(
|
||||
"POST",
|
||||
"/prompt_manager/tags/merge",
|
||||
{"source_tags": ["old"], "target_tag": tag},
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"/prompt_manager/tags/merge",
|
||||
{"source_tags": [tag], "target_tag": "t"},
|
||||
),
|
||||
("PUT", "/prompt_manager/tags/old", {"new_name": tag}),
|
||||
)
|
||||
|
||||
async def _assert_all_400(self, tag):
|
||||
for method, route, body in self._requests(tag):
|
||||
status, data = await self._json(method, route, json=body)
|
||||
|
||||
self.assertEqual(status, 400, (route, body))
|
||||
self.assertFalse(data["success"], route)
|
||||
self.assertIn("error", data, route)
|
||||
|
||||
async def test_non_string_tags_are_400(self):
|
||||
await self._assert_all_400(123)
|
||||
|
||||
async def test_overlong_tags_are_400(self):
|
||||
await self._assert_all_400("x" * 51)
|
||||
|
||||
async def test_control_characters_are_400(self):
|
||||
await self._assert_all_400("bad\x00tag")
|
||||
|
||||
|
||||
class TestExportPagesThroughAllPrompts(RouteCoverageCase):
|
||||
"""GET /prompt_manager/export never truncates: it pages until exhausted."""
|
||||
|
||||
async def test_export_is_complete_beyond_one_page(self):
|
||||
ids = {self._save_prompt(f"export {i}") for i in range(5)}
|
||||
real_search = self.api.db.search_prompts
|
||||
calls = []
|
||||
|
||||
def spy(*args, **kwargs):
|
||||
calls.append((kwargs.get("limit"), kwargs.get("offset")))
|
||||
return real_search(*args, **kwargs)
|
||||
|
||||
self.api.db.search_prompts = spy
|
||||
|
||||
with patch.object(prompts_module, "EXPORT_PAGE_SIZE", 2):
|
||||
resp = await self.client.request("GET", "/prompt_manager/export")
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = json.loads(await resp.text())
|
||||
self.assertEqual(data["total_prompts"], 5)
|
||||
self.assertEqual({p["id"] for p in data["prompts"]}, ids)
|
||||
self.assertEqual(calls, [(2, 0), (2, 2), (2, 4)])
|
||||
|
||||
|
||||
class TestTagMutationRoutes(RouteCoverageCase):
|
||||
|
||||
async def test_rename_rejects_bad_json_and_empty_name(self):
|
||||
bad_json, _ = await self._json(
|
||||
"PUT",
|
||||
"/prompt_manager/tags/old",
|
||||
data=b"{nope",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
empty, _ = await self._json(
|
||||
"PUT", "/prompt_manager/tags/old", json={"new_name": " "}
|
||||
)
|
||||
self.assertEqual((bad_json, empty), (400, 400))
|
||||
|
||||
async def test_rename_reports_skipped_rows_and_db_failure(self):
|
||||
self._stub("rename_tag_all_prompts", {"affected_count": 1, "skipped_count": 2})
|
||||
status, data = await self._json(
|
||||
"PUT", "/prompt_manager/tags/old", json={"new_name": "new"}
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data["skipped_count"], 2)
|
||||
self.assertIn("warning", data)
|
||||
self._break("rename_tag_all_prompts")
|
||||
err_status, _ = await self._json(
|
||||
"PUT", "/prompt_manager/tags/old", json={"new_name": "new"}
|
||||
)
|
||||
self.assertEqual(err_status, 500)
|
||||
|
||||
async def test_delete_tag_reports_skipped_rows_and_db_failure(self):
|
||||
self._stub("delete_tag_all_prompts", {"affected_count": 3, "skipped_count": 1})
|
||||
status, data = await self._json("DELETE", "/prompt_manager/tags/gone")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data["skipped_count"], 1)
|
||||
self._break("delete_tag_all_prompts")
|
||||
err_status, _ = await self._json("DELETE", "/prompt_manager/tags/gone")
|
||||
self.assertEqual(err_status, 500)
|
||||
|
||||
async def test_merge_validation_errors(self):
|
||||
bad_json, _ = await self._json(
|
||||
"POST",
|
||||
"/prompt_manager/tags/merge",
|
||||
data=b"{nope",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
no_source, _ = await self._json(
|
||||
"POST", "/prompt_manager/tags/merge", json={"target_tag": "t"}
|
||||
)
|
||||
no_target, _ = await self._json(
|
||||
"POST", "/prompt_manager/tags/merge", json={"source_tags": ["s"]}
|
||||
)
|
||||
self.assertEqual((bad_json, no_source, no_target), (400, 400, 400))
|
||||
|
||||
async def test_merge_reports_skipped_rows_and_db_failure(self):
|
||||
self._stub(
|
||||
"merge_tags",
|
||||
{"affected_count": 2, "tags_merged": ["s"], "skipped_count": 1},
|
||||
)
|
||||
status, data = await self._json(
|
||||
"POST",
|
||||
"/prompt_manager/tags/merge",
|
||||
json={"source_tags": ["s"], "target_tag": "t"},
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data["skipped_count"], 1)
|
||||
self._break("merge_tags")
|
||||
err_status, _ = await self._json(
|
||||
"POST",
|
||||
"/prompt_manager/tags/merge",
|
||||
json={"source_tags": ["s"], "target_tag": "t"},
|
||||
)
|
||||
self.assertEqual(err_status, 500)
|
||||
|
||||
|
||||
class TestSaveRoute(RouteCoverageCase):
|
||||
|
||||
async def test_invalid_rating_is_400(self):
|
||||
status, data = await self._json(
|
||||
"POST", "/prompt_manager/save", json={"text": "ok", "rating": 99}
|
||||
)
|
||||
self.assertEqual(status, 400)
|
||||
self.assertIn("Rating", data["error"])
|
||||
|
||||
async def test_duplicate_updates_metadata(self):
|
||||
first = self._save_prompt("same text")
|
||||
status, data = await self._json(
|
||||
"POST",
|
||||
"/prompt_manager/save",
|
||||
json={"text": "same text", "category": "cat", "tags": ["t"], "rating": 4},
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(data["is_duplicate"])
|
||||
self.assertEqual(data["prompt_id"], first)
|
||||
stored = self.api.db.get_prompt_by_id(first)
|
||||
self.assertEqual(stored["category"], "cat")
|
||||
self.assertEqual(stored["rating"], 4)
|
||||
|
||||
async def test_duplicate_without_metadata_leaves_row_alone(self):
|
||||
first = self._save_prompt("plain dup")
|
||||
status, data = await self._json(
|
||||
"POST", "/prompt_manager/save", json={"text": "plain dup"}
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data["prompt_id"], first)
|
||||
|
||||
async def test_db_failure_is_500(self):
|
||||
self._break("get_prompt_by_hash")
|
||||
status, data = await self._json(
|
||||
"POST", "/prompt_manager/save", json={"text": "boom"}
|
||||
)
|
||||
self.assertEqual(status, 500)
|
||||
self.assertFalse(data["success"])
|
||||
|
||||
|
||||
class TestDeleteUpdateRatingRoutes(RouteCoverageCase):
|
||||
|
||||
async def test_delete_invalid_id_and_db_failure(self):
|
||||
bad, _ = await self._json("DELETE", "/prompt_manager/delete/abc")
|
||||
self._break("delete_prompt")
|
||||
err, _ = await self._json("DELETE", "/prompt_manager/delete/1")
|
||||
self.assertEqual((bad, err), (400, 500))
|
||||
|
||||
async def test_update_text_validation_and_not_found(self):
|
||||
pid = self._save_prompt("orig")
|
||||
empty, _ = await self._json(
|
||||
"PUT", f"/prompt_manager/prompts/{pid}", json={"text": " "}
|
||||
)
|
||||
too_long, _ = await self._json(
|
||||
"PUT", f"/prompt_manager/prompts/{pid}", json={"text": "x" * 10_001}
|
||||
)
|
||||
missing, _ = await self._json(
|
||||
"PUT", "/prompt_manager/prompts/999999", json={"text": "new"}
|
||||
)
|
||||
bad_id, _ = await self._json(
|
||||
"PUT", "/prompt_manager/prompts/abc", json={"text": "new"}
|
||||
)
|
||||
self.assertEqual((empty, too_long, missing, bad_id), (400, 400, 404, 400))
|
||||
|
||||
async def test_update_text_db_failure(self):
|
||||
pid = self._save_prompt("orig")
|
||||
self._break("update_prompt_text")
|
||||
status, _ = await self._json(
|
||||
"PUT", f"/prompt_manager/prompts/{pid}", json={"text": "new"}
|
||||
)
|
||||
self.assertEqual(status, 500)
|
||||
|
||||
async def test_rating_validation_not_found_and_failure(self):
|
||||
pid = self._save_prompt("rate me")
|
||||
invalid, _ = await self._json(
|
||||
"PUT", f"/prompt_manager/prompts/{pid}/rating", json={"rating": 0}
|
||||
)
|
||||
missing, _ = await self._json(
|
||||
"PUT", "/prompt_manager/prompts/999999/rating", json={"rating": 3}
|
||||
)
|
||||
bad_id, _ = await self._json(
|
||||
"PUT", "/prompt_manager/prompts/abc/rating", json={"rating": 3}
|
||||
)
|
||||
self._break("update_prompt_rating")
|
||||
err, _ = await self._json(
|
||||
"PUT", f"/prompt_manager/prompts/{pid}/rating", json={"rating": 3}
|
||||
)
|
||||
self.assertEqual((invalid, missing, bad_id, err), (400, 404, 400, 500))
|
||||
|
||||
|
||||
class TestPromptTagRoutes(RouteCoverageCase):
|
||||
|
||||
async def test_add_tag_validation_paths(self):
|
||||
pid = self._save_prompt("tagme", tags=["have"])
|
||||
empty, _ = await self._json(
|
||||
"POST", f"/prompt_manager/prompts/{pid}/tags", json={"tag": ""}
|
||||
)
|
||||
missing, _ = await self._json(
|
||||
"POST", "/prompt_manager/prompts/999999/tags", json={"tag": "x"}
|
||||
)
|
||||
bad_id, _ = await self._json(
|
||||
"POST", "/prompt_manager/prompts/abc/tags", json={"tag": "x"}
|
||||
)
|
||||
dup, dup_data = await self._json(
|
||||
"POST", f"/prompt_manager/prompts/{pid}/tags", json={"tag": "have"}
|
||||
)
|
||||
self.assertEqual((empty, missing, bad_id, dup), (400, 404, 400, 200))
|
||||
self.assertTrue(dup_data["success"])
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(pid)["tags"], ["have"])
|
||||
|
||||
async def test_add_tag_db_failure(self):
|
||||
pid = self._save_prompt("tagme")
|
||||
self._break("set_prompt_tags")
|
||||
status, _ = await self._json(
|
||||
"POST", f"/prompt_manager/prompts/{pid}/tags", json={"tag": "x"}
|
||||
)
|
||||
self.assertEqual(status, 500)
|
||||
|
||||
async def test_add_tags_bulk_to_single_prompt(self):
|
||||
pid = self._save_prompt("multi", tags=["a"])
|
||||
status, data = await self._json(
|
||||
"POST",
|
||||
"/prompt_manager/prompts/tags",
|
||||
json={"prompt_id": pid, "tags": ["a", " b ", "", "c"]},
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data["tags_added"], 2)
|
||||
again, again_data = await self._json(
|
||||
"POST",
|
||||
"/prompt_manager/prompts/tags",
|
||||
json={"prompt_id": pid, "tags": ["a"]},
|
||||
)
|
||||
self.assertEqual(again, 200)
|
||||
self.assertEqual(again_data["tags_added"], 0)
|
||||
self.assertIn("No new tags", again_data["message"])
|
||||
|
||||
async def test_add_tags_validation_and_failures(self):
|
||||
no_id, _ = await self._json(
|
||||
"POST", "/prompt_manager/prompts/tags", json={"tags": ["a"]}
|
||||
)
|
||||
no_tags, _ = await self._json(
|
||||
"POST", "/prompt_manager/prompts/tags", json={"prompt_id": 1, "tags": "a"}
|
||||
)
|
||||
missing, _ = await self._json(
|
||||
"POST",
|
||||
"/prompt_manager/prompts/tags",
|
||||
json={"prompt_id": 999999, "tags": ["a"]},
|
||||
)
|
||||
self._break("get_prompt_by_id")
|
||||
err, _ = await self._json(
|
||||
"POST", "/prompt_manager/prompts/tags", json={"prompt_id": 1, "tags": ["a"]}
|
||||
)
|
||||
self.assertEqual((no_id, no_tags, missing, err), (400, 400, 404, 500))
|
||||
|
||||
async def test_remove_tag_paths(self):
|
||||
pid = self._save_prompt("untag", tags=["a", "b"])
|
||||
removed, _ = await self._json(
|
||||
"DELETE", f"/prompt_manager/prompts/{pid}/tags", json={"tag": "a"}
|
||||
)
|
||||
absent, _ = await self._json(
|
||||
"DELETE", f"/prompt_manager/prompts/{pid}/tags", json={"tag": "zzz"}
|
||||
)
|
||||
missing, _ = await self._json(
|
||||
"DELETE", "/prompt_manager/prompts/999999/tags", json={"tag": "a"}
|
||||
)
|
||||
bad_id, _ = await self._json(
|
||||
"DELETE", "/prompt_manager/prompts/abc/tags", json={"tag": "a"}
|
||||
)
|
||||
self.assertEqual((removed, absent, missing, bad_id), (200, 200, 404, 400))
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(pid)["tags"], ["b"])
|
||||
|
||||
async def test_remove_tag_db_failure(self):
|
||||
pid = self._save_prompt("untag", tags=["a"])
|
||||
self._break("set_prompt_tags")
|
||||
status, _ = await self._json(
|
||||
"DELETE", f"/prompt_manager/prompts/{pid}/tags", json={"tag": "a"}
|
||||
)
|
||||
self.assertEqual(status, 500)
|
||||
|
||||
|
||||
class TestBulkRoutes(RouteCoverageCase):
|
||||
|
||||
async def test_bulk_delete_paths(self):
|
||||
ids = [self._save_prompt(f"bulk {i}") for i in range(3)]
|
||||
empty, _ = await self._json(
|
||||
"POST", "/prompt_manager/bulk/delete", json={"prompt_ids": []}
|
||||
)
|
||||
ok, ok_data = await self._json(
|
||||
"POST", "/prompt_manager/bulk/delete", json={"prompt_ids": ids[:2]}
|
||||
)
|
||||
self._break("bulk_delete_prompts")
|
||||
err, _ = await self._json(
|
||||
"POST", "/prompt_manager/bulk/delete", json={"prompt_ids": ids}
|
||||
)
|
||||
self.assertEqual((empty, ok, err), (400, 200, 500))
|
||||
self.assertEqual(ok_data["deleted_count"], 2)
|
||||
|
||||
async def test_bulk_add_tags_paths(self):
|
||||
ids = [self._save_prompt(f"bulk tag {i}") for i in range(2)]
|
||||
empty, _ = await self._json(
|
||||
"POST", "/prompt_manager/bulk/tags", json={"prompt_ids": ids, "tags": []}
|
||||
)
|
||||
ok, ok_data = await self._json(
|
||||
"POST", "/prompt_manager/bulk/tags", json={"prompt_ids": ids, "tags": ["z"]}
|
||||
)
|
||||
self._break("bulk_add_tags")
|
||||
err, _ = await self._json(
|
||||
"POST", "/prompt_manager/bulk/tags", json={"prompt_ids": ids, "tags": ["z"]}
|
||||
)
|
||||
self.assertEqual((empty, ok, err), (400, 200, 500))
|
||||
self.assertEqual(ok_data["updated_count"], 2)
|
||||
|
||||
async def test_bulk_set_category_paths(self):
|
||||
ids = [self._save_prompt(f"bulk cat {i}") for i in range(2)]
|
||||
empty, _ = await self._json(
|
||||
"POST", "/prompt_manager/bulk/category", json={"category": "c"}
|
||||
)
|
||||
ok, ok_data = await self._json(
|
||||
"POST",
|
||||
"/prompt_manager/bulk/category",
|
||||
json={"prompt_ids": ids, "category": "c"},
|
||||
)
|
||||
self._break("bulk_set_category")
|
||||
err, _ = await self._json(
|
||||
"POST",
|
||||
"/prompt_manager/bulk/category",
|
||||
json={"prompt_ids": ids, "category": "c"},
|
||||
)
|
||||
self.assertEqual((empty, ok, err), (400, 200, 500))
|
||||
self.assertEqual(ok_data["updated_count"], 2)
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(ids[0])["category"], "c")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestPromptEditHash(PromptAPITestCase):
|
||||
async def test_edit_moves_the_hash_and_original_text_can_be_saved_again(self):
|
||||
original_id = self._save_prompt("Original landscape")
|
||||
response = await self.client.put(
|
||||
f"/prompt_manager/prompts/{original_id}", json={"text": "Edited portrait"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
row = self.api.db.get_prompt_by_id(original_id)
|
||||
self.assertEqual(row["hash"], generate_prompt_hash("Edited portrait"))
|
||||
self.assertIsNone(
|
||||
self.api.db.get_prompt_by_hash(generate_prompt_hash("Original landscape"))
|
||||
)
|
||||
response = await self.client.post(
|
||||
"/prompt_manager/save", json={"text": "Original landscape"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertNotEqual((await response.json())["prompt_id"], original_id)
|
||||
response = await self.client.post(
|
||||
"/prompt_manager/save", json={"text": "Edited portrait"}
|
||||
)
|
||||
self.assertEqual((await response.json())["prompt_id"], original_id)
|
||||
|
||||
async def test_duplicate_edit_returns_conflict_without_changing_either_prompt(self):
|
||||
first = self._save_prompt("Original landscape", tags=["landscape"])
|
||||
second = self._save_prompt("Existing portrait", tags=["portrait"])
|
||||
before = [self.api.db.get_prompt_by_id(pid) for pid in (first, second)]
|
||||
response = await self.client.put(
|
||||
f"/prompt_manager/prompts/{first}", json={"text": " EXISTING PORTRAIT "}
|
||||
)
|
||||
self.assertEqual(response.status, 409)
|
||||
self.assertFalse((await response.json())["success"])
|
||||
self.assertEqual(
|
||||
[self.api.db.get_prompt_by_id(pid) for pid in (first, second)], before
|
||||
)
|
||||
|
||||
async def test_edit_with_same_normalized_hash_keeps_its_identity(self):
|
||||
pid = self._save_prompt("Original landscape")
|
||||
response = await self.client.put(
|
||||
f"/prompt_manager/prompts/{pid}", json={"text": "ORIGINAL LANDSCAPE"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(
|
||||
self.api.db.get_prompt_by_id(pid)["text"], "ORIGINAL LANDSCAPE"
|
||||
)
|
||||
@@ -0,0 +1,716 @@
|
||||
"""Tests for the AutoTagService tagging pipeline with an injected fake model.
|
||||
|
||||
No real ML model is loaded; ``model_loader`` returns small fakes that mimic
|
||||
the onnxruntime session (WD14) and the llama.cpp chat model (GGUF).
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import csv
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
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__))))
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from py import autotag
|
||||
from py.autotag import MODELS, AutoTagService
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _patch_modules(mapping):
|
||||
"""Temporarily set entries of sys.modules, restoring only those keys.
|
||||
|
||||
unittest.mock.patch.dict restores by clearing the whole dict, which evicts
|
||||
any heavy module first imported inside the window (torch cannot be
|
||||
re-imported in-process), so this patcher touches only the named keys.
|
||||
"""
|
||||
missing = object()
|
||||
saved = {name: sys.modules.get(name, missing) for name in mapping}
|
||||
try:
|
||||
for name, module in mapping.items():
|
||||
sys.modules[name] = module
|
||||
yield
|
||||
finally:
|
||||
for name, previous in saved.items():
|
||||
if previous is missing:
|
||||
sys.modules.pop(name, None)
|
||||
else:
|
||||
sys.modules[name] = previous
|
||||
|
||||
|
||||
class FakeInput:
|
||||
def __init__(self, name="input", shape=(1, 8, 8, 3)):
|
||||
self.name = name
|
||||
self.shape = list(shape)
|
||||
|
||||
|
||||
class FakeOnnxSession:
|
||||
"""Mimics onnxruntime.InferenceSession for a WD14 classifier."""
|
||||
|
||||
def __init__(self, probabilities):
|
||||
self.probabilities = probabilities
|
||||
self.run_calls = []
|
||||
|
||||
def get_inputs(self):
|
||||
return [FakeInput()]
|
||||
|
||||
def get_providers(self):
|
||||
return ["CPUExecutionProvider"]
|
||||
|
||||
def run(self, outputs, feeds):
|
||||
self.run_calls.append(feeds)
|
||||
return [[list(self.probabilities)]]
|
||||
|
||||
|
||||
class FakeGgufModel:
|
||||
"""Mimics llama_cpp.Llama.create_chat_completion."""
|
||||
|
||||
def __init__(self, text):
|
||||
self.text = text
|
||||
self.calls = []
|
||||
|
||||
def create_chat_completion(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
return {"choices": [{"message": {"content": self.text}}]}
|
||||
|
||||
|
||||
WD14_TAGS = [
|
||||
{"name": "1girl", "category": "0"},
|
||||
{"name": "smile", "category": "0"},
|
||||
{"name": "hatsune_miku", "category": "4"},
|
||||
{"name": "rating:safe", "category": "9"},
|
||||
{"name": "", "category": "0"},
|
||||
{"name": "long_hair", "category": "0"},
|
||||
]
|
||||
|
||||
|
||||
def _touch_model_files(models_dir, model_type):
|
||||
config = MODELS[model_type]
|
||||
subdir = Path(models_dir) / config["subdir"]
|
||||
subdir.mkdir(parents=True, exist_ok=True)
|
||||
names = config.get("files") or [config["filename"], config["mmproj_filename"]]
|
||||
for name in names:
|
||||
(subdir / name).write_bytes(b"stub")
|
||||
|
||||
|
||||
class EngineTestCase(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.tmp, True)
|
||||
self.models_dir = self.tmp / "models"
|
||||
self.image_path = self.tmp / "img.png"
|
||||
Image.new("RGB", (4, 6), (200, 100, 50)).save(self.image_path, "PNG")
|
||||
self.loader_calls = []
|
||||
|
||||
def _service(self, loaded):
|
||||
def loader(model_type, use_gpu):
|
||||
self.loader_calls.append((model_type, use_gpu))
|
||||
return loaded
|
||||
|
||||
return AutoTagService(models_dir=self.models_dir, model_loader=loader)
|
||||
|
||||
|
||||
class TestModelInjection(EngineTestCase):
|
||||
|
||||
def test_injected_loader_is_used_and_state_tracked(self):
|
||||
_touch_model_files(self.models_dir, "wd14-vit")
|
||||
service = self._service(("wd14", (FakeOnnxSession([0.0] * 6), WD14_TAGS)))
|
||||
|
||||
self.assertTrue(service.load_model("wd14-vit", use_gpu=False))
|
||||
|
||||
self.assertEqual(self.loader_calls, [("wd14-vit", False)])
|
||||
self.assertTrue(service.is_model_loaded())
|
||||
self.assertEqual(service.get_loaded_model_type(), "wd14-vit")
|
||||
|
||||
def test_default_loader_is_the_lazy_dispatcher(self):
|
||||
service = AutoTagService(models_dir=self.models_dir)
|
||||
self.assertEqual(service._model_loader, service._load_model)
|
||||
|
||||
def test_importing_autotag_does_not_import_ml_backends(self):
|
||||
for heavy in ("onnxruntime", "llama_cpp"):
|
||||
self.assertNotIn(heavy, sys.modules)
|
||||
|
||||
def test_default_wd14_loader_reports_missing_onnxruntime(self):
|
||||
_touch_model_files(self.models_dir, "wd14-vit")
|
||||
service = AutoTagService(models_dir=self.models_dir)
|
||||
if "onnxruntime" in sys.modules:
|
||||
self.skipTest("onnxruntime is installed in this environment")
|
||||
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
service.load_model("wd14-vit")
|
||||
|
||||
self.assertIn("onnxruntime", str(ctx.exception))
|
||||
self.assertFalse(service.is_model_loaded())
|
||||
|
||||
def test_invalid_model_type_raises_value_error(self):
|
||||
service = self._service(("gguf", FakeGgufModel("x")))
|
||||
with self.assertRaises(ValueError):
|
||||
service.load_model("nope")
|
||||
|
||||
def test_not_downloaded_raises_runtime_error_before_loading(self):
|
||||
service = self._service(("gguf", FakeGgufModel("x")))
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
service.load_model("gguf")
|
||||
|
||||
self.assertEqual(self.loader_calls, [])
|
||||
|
||||
def test_loader_failure_clears_state(self):
|
||||
_touch_model_files(self.models_dir, "gguf")
|
||||
|
||||
def failing_loader(model_type, use_gpu):
|
||||
raise OSError("cuda exploded")
|
||||
|
||||
service = AutoTagService(
|
||||
models_dir=self.models_dir, model_loader=failing_loader
|
||||
)
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
service.load_model("gguf")
|
||||
|
||||
self.assertFalse(service.is_model_loaded())
|
||||
self.assertIsNone(service.get_loaded_model_type())
|
||||
|
||||
def test_loading_again_unloads_first(self):
|
||||
_touch_model_files(self.models_dir, "gguf")
|
||||
_touch_model_files(self.models_dir, "wd14-swinv2")
|
||||
service = self._service(("gguf", FakeGgufModel("x")))
|
||||
|
||||
service.load_model("gguf")
|
||||
service.load_model("wd14-swinv2")
|
||||
|
||||
self.assertEqual([c[0] for c in self.loader_calls], ["gguf", "wd14-swinv2"])
|
||||
self.assertEqual(service.get_loaded_model_type(), "wd14-swinv2")
|
||||
|
||||
def test_unload_clears_state_and_is_idempotent(self):
|
||||
_touch_model_files(self.models_dir, "gguf")
|
||||
service = self._service(("gguf", FakeGgufModel("x")))
|
||||
service.load_model("gguf")
|
||||
|
||||
service.unload_model()
|
||||
service.unload_model()
|
||||
|
||||
self.assertFalse(service.is_model_loaded())
|
||||
self.assertIsNone(service.get_loaded_model_type())
|
||||
|
||||
|
||||
class TestWd14Thresholding(EngineTestCase):
|
||||
|
||||
def _loaded(self, probabilities):
|
||||
_touch_model_files(self.models_dir, "wd14-vit")
|
||||
self.session = FakeOnnxSession(probabilities)
|
||||
service = self._service(("wd14", (self.session, WD14_TAGS)))
|
||||
service.load_model("wd14-vit")
|
||||
return service
|
||||
|
||||
def test_default_thresholds_apply_per_category(self):
|
||||
# 1girl 0.9 (general, keep), smile 0.3 (general, drop at 0.35),
|
||||
# miku 0.8 (character, drop at 0.85), rating 0.99 (ignored),
|
||||
# empty name 0.99 (ignored), long_hair 0.5 (keep)
|
||||
service = self._loaded([0.9, 0.3, 0.8, 0.99, 0.99, 0.5])
|
||||
|
||||
tags = service.generate_tags(str(self.image_path))
|
||||
|
||||
self.assertEqual(tags, ["1girl", "long_hair"])
|
||||
|
||||
def test_explicit_thresholds_override_defaults(self):
|
||||
service = self._loaded([0.9, 0.3, 0.8, 0.99, 0.99, 0.5])
|
||||
|
||||
tags = service.generate_tags(
|
||||
str(self.image_path), general_threshold=0.25, character_threshold=0.75
|
||||
)
|
||||
|
||||
self.assertEqual(tags, ["1girl", "hatsune_miku", "long_hair", "smile"])
|
||||
|
||||
def test_results_sorted_by_confidence(self):
|
||||
service = self._loaded([0.4, 0.95, 0.0, 0.0, 0.0, 0.6])
|
||||
|
||||
self.assertEqual(
|
||||
service.generate_tags(str(self.image_path)),
|
||||
["smile", "long_hair", "1girl"],
|
||||
)
|
||||
|
||||
def test_fewer_probabilities_than_tags_is_safe(self):
|
||||
service = self._loaded([0.9, 0.9])
|
||||
|
||||
self.assertEqual(
|
||||
service.generate_tags(str(self.image_path)), ["1girl", "smile"]
|
||||
)
|
||||
|
||||
def test_image_is_fed_as_square_batch(self):
|
||||
service = self._loaded([0.0] * 6)
|
||||
|
||||
service.generate_tags(str(self.image_path))
|
||||
|
||||
feeds = self.session.run_calls[0]
|
||||
self.assertEqual(list(feeds.keys()), ["input"])
|
||||
self.assertEqual(tuple(feeds["input"].shape), (1, 8, 8, 3))
|
||||
|
||||
def test_service_threshold_setters_clamp(self):
|
||||
service = self._loaded([0.0] * 6)
|
||||
service.wd14_general_threshold = 1.7
|
||||
service.wd14_character_threshold = -2
|
||||
self.assertEqual(service.wd14_general_threshold, 1.0)
|
||||
self.assertEqual(service.wd14_character_threshold, 0.0)
|
||||
|
||||
|
||||
class TestGgufNormalisation(EngineTestCase):
|
||||
|
||||
def _loaded(self, text):
|
||||
_touch_model_files(self.models_dir, "gguf")
|
||||
self.model = FakeGgufModel(text)
|
||||
service = self._service(("gguf", self.model))
|
||||
service.load_model("gguf")
|
||||
return service
|
||||
|
||||
def test_tags_are_lowercased_underscored_deduped_and_filtered(self):
|
||||
service = self._loaded(
|
||||
"1Girl, Smile, smile, 'quoted tag', photo_realistic, meta:x, "
|
||||
"copyright:y, prepend:z, append:w, a, , Long Hair "
|
||||
)
|
||||
|
||||
tags = service.generate_tags(str(self.image_path))
|
||||
|
||||
self.assertEqual(tags, ["1girl", "smile", "quoted_tag", "long_hair"])
|
||||
|
||||
def test_custom_prompt_is_sent_to_the_model(self):
|
||||
service = self._loaded("tag")
|
||||
service.custom_prompt = "describe tersely"
|
||||
|
||||
service.generate_tags(str(self.image_path))
|
||||
|
||||
user_turn = self.model.calls[0]["messages"][1]["content"]
|
||||
self.assertEqual(user_turn[0]["text"], "describe tersely")
|
||||
self.assertTrue(user_turn[1]["image_url"]["url"].startswith("data:image/png"))
|
||||
|
||||
def test_explicit_prompt_argument_wins(self):
|
||||
service = self._loaded("tag")
|
||||
|
||||
service.generate_tags(str(self.image_path), prompt="one-off")
|
||||
|
||||
self.assertEqual(
|
||||
self.model.calls[0]["messages"][1]["content"][0]["text"], "one-off"
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateTagsErrors(EngineTestCase):
|
||||
|
||||
def test_no_model_loaded(self):
|
||||
service = self._service(("gguf", FakeGgufModel("x")))
|
||||
with self.assertRaises(RuntimeError):
|
||||
service.generate_tags(str(self.image_path))
|
||||
|
||||
def test_missing_image_error_does_not_embed_the_full_path(self):
|
||||
_touch_model_files(self.models_dir, "gguf")
|
||||
service = self._service(("gguf", FakeGgufModel("x")))
|
||||
service.load_model("gguf")
|
||||
missing = self.tmp / "nope.png"
|
||||
|
||||
with self.assertRaises(FileNotFoundError) as ctx:
|
||||
service.generate_tags(str(missing))
|
||||
|
||||
self.assertNotIn(str(self.tmp), ctx.exception.strerror)
|
||||
self.assertEqual(ctx.exception.filename, str(missing))
|
||||
|
||||
|
||||
class TestModelsStatus(EngineTestCase):
|
||||
|
||||
def test_status_reflects_files_on_disk(self):
|
||||
_touch_model_files(self.models_dir, "wd14-vit")
|
||||
gguf_dir = self.models_dir / MODELS["gguf"]["subdir"]
|
||||
gguf_dir.mkdir(parents=True)
|
||||
(gguf_dir / MODELS["gguf"]["filename"]).write_bytes(b"stub")
|
||||
service = AutoTagService(models_dir=self.models_dir)
|
||||
|
||||
status = service.get_models_status()
|
||||
|
||||
self.assertTrue(status["wd14-vit"]["downloaded"])
|
||||
self.assertTrue(status["wd14-vit"]["model_path"].endswith("model.onnx"))
|
||||
self.assertFalse(status["wd14-swinv2"]["downloaded"])
|
||||
self.assertTrue(status["gguf"]["model_exists"])
|
||||
self.assertFalse(status["gguf"]["mmproj_exists"])
|
||||
self.assertFalse(status["gguf"]["downloaded"])
|
||||
self.assertFalse(status["hf"]["downloaded"])
|
||||
|
||||
def test_hf_local_directory_counts_as_downloaded(self):
|
||||
hf_dir = self.models_dir / MODELS["hf"]["subdir"]
|
||||
hf_dir.mkdir(parents=True)
|
||||
(hf_dir / "config.json").write_text("{}")
|
||||
service = AutoTagService(models_dir=self.models_dir)
|
||||
|
||||
status = service.get_models_status()
|
||||
|
||||
self.assertTrue(status["hf"]["downloaded"])
|
||||
self.assertEqual(
|
||||
os.path.realpath(status["hf"]["model_path"]), os.path.realpath(str(hf_dir))
|
||||
)
|
||||
|
||||
|
||||
# ── Model directory resolution and singleton ──────────────────────────
|
||||
|
||||
|
||||
class TestModelsDirResolution(unittest.TestCase):
|
||||
|
||||
def test_uses_folder_paths_models_dir(self):
|
||||
fake = types.ModuleType("folder_paths")
|
||||
fake.models_dir = os.path.join(tempfile.gettempdir(), "fp_models")
|
||||
with _patch_modules({"folder_paths": fake}):
|
||||
service = AutoTagService()
|
||||
self.assertEqual(service.models_dir, Path(fake.models_dir) / "LLM")
|
||||
|
||||
def test_falls_back_without_folder_paths(self):
|
||||
with _patch_modules({"folder_paths": None}):
|
||||
service = AutoTagService()
|
||||
self.assertEqual(service.models_dir.name, "LLM")
|
||||
|
||||
def test_singleton_is_created_once(self):
|
||||
with patch.object(autotag, "_service_instance", None):
|
||||
first = autotag.get_autotag_service()
|
||||
second = autotag.get_autotag_service()
|
||||
self.assertIs(first, second)
|
||||
self.assertIsInstance(first, AutoTagService)
|
||||
|
||||
|
||||
# ── HuggingFace cache lookup ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _fake_hub(snapshot_dir=None, repo_id=None, raise_scan=False):
|
||||
hub = types.ModuleType("huggingface_hub")
|
||||
hub.HFCacheInfo = object
|
||||
|
||||
def scan_cache_dir():
|
||||
if raise_scan:
|
||||
raise OSError("no cache")
|
||||
revision = types.SimpleNamespace(snapshot_path=str(snapshot_dir))
|
||||
repo = types.SimpleNamespace(
|
||||
repo_id=repo_id, repo_type="model", revisions=[revision]
|
||||
)
|
||||
other = types.SimpleNamespace(
|
||||
repo_id="someone/else", repo_type="dataset", revisions=[]
|
||||
)
|
||||
return types.SimpleNamespace(repos=[other, repo])
|
||||
|
||||
hub.scan_cache_dir = scan_cache_dir
|
||||
return hub
|
||||
|
||||
|
||||
class TestHfCacheLookup(EngineTestCase):
|
||||
|
||||
def test_cache_snapshot_with_config_is_found(self):
|
||||
snapshot = self.tmp / "snap"
|
||||
snapshot.mkdir()
|
||||
(snapshot / "config.json").write_text("{}")
|
||||
hub = _fake_hub(snapshot, MODELS["hf"]["repo"])
|
||||
service = AutoTagService(models_dir=self.models_dir)
|
||||
|
||||
with _patch_modules({"huggingface_hub": hub}):
|
||||
self.assertTrue(service._check_hf_model())
|
||||
status = service.get_models_status()
|
||||
|
||||
self.assertTrue(status["hf"]["downloaded"])
|
||||
self.assertEqual(Path(status["hf"]["model_path"]), snapshot)
|
||||
|
||||
def test_snapshot_without_config_is_ignored(self):
|
||||
snapshot = self.tmp / "snap"
|
||||
snapshot.mkdir()
|
||||
hub = _fake_hub(snapshot, MODELS["hf"]["repo"])
|
||||
service = AutoTagService(models_dir=self.models_dir)
|
||||
with _patch_modules({"huggingface_hub": hub}):
|
||||
self.assertIsNone(service._get_hf_model_path())
|
||||
|
||||
def test_scan_errors_and_missing_hub_give_none(self):
|
||||
service = AutoTagService(models_dir=self.models_dir)
|
||||
with _patch_modules({"huggingface_hub": _fake_hub(raise_scan=True)}):
|
||||
self.assertIsNone(service._get_hf_cache_path("x/y"))
|
||||
with _patch_modules({"huggingface_hub": None}):
|
||||
self.assertIsNone(service._get_hf_cache_path("x/y"))
|
||||
|
||||
|
||||
# ── Downloads (hub patched, no network) ───────────────────────────────
|
||||
|
||||
|
||||
class _FakeDownloadHub:
|
||||
"""huggingface_hub stand-in that materialises files instead of fetching."""
|
||||
|
||||
def __init__(self):
|
||||
self.module = types.ModuleType("huggingface_hub")
|
||||
self.module.hf_hub_download = self.hf_hub_download
|
||||
self.module.snapshot_download = self.snapshot_download
|
||||
self.calls = []
|
||||
|
||||
def hf_hub_download(self, repo_id, filename, local_dir, **kwargs):
|
||||
self.calls.append(("file", repo_id, filename))
|
||||
Path(local_dir).mkdir(parents=True, exist_ok=True)
|
||||
(Path(local_dir) / filename).write_bytes(b"model")
|
||||
return str(Path(local_dir) / filename)
|
||||
|
||||
def snapshot_download(self, repo_id, local_dir, **kwargs):
|
||||
self.calls.append(("snapshot", repo_id))
|
||||
Path(local_dir).mkdir(parents=True, exist_ok=True)
|
||||
(Path(local_dir) / "config.json").write_text("{}")
|
||||
return local_dir
|
||||
|
||||
|
||||
class TestDownloadModel(EngineTestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.hub = _FakeDownloadHub()
|
||||
self.service = AutoTagService(models_dir=self.models_dir)
|
||||
self.progress = []
|
||||
|
||||
def _cb(self, status, progress):
|
||||
self.progress.append((status, progress))
|
||||
|
||||
def test_invalid_type_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.service.download_model("nope")
|
||||
|
||||
def test_missing_hub_reports_failure(self):
|
||||
with _patch_modules({"huggingface_hub": None}):
|
||||
self.assertFalse(self.service.download_model("gguf", self._cb))
|
||||
self.assertEqual(self.progress[-1][1], 0)
|
||||
self.assertIn("huggingface_hub", self.progress[-1][0])
|
||||
|
||||
def test_wd14_downloads_each_missing_file(self):
|
||||
with _patch_modules({"huggingface_hub": self.hub.module}):
|
||||
self.assertTrue(self.service.download_model("wd14-vit", self._cb))
|
||||
self.assertTrue(self.service.download_model("wd14-vit", self._cb))
|
||||
|
||||
self.assertEqual(
|
||||
[c[2] for c in self.hub.calls], ["model.onnx", "selected_tags.csv"]
|
||||
)
|
||||
self.assertTrue(self.service.get_models_status()["wd14-vit"]["downloaded"])
|
||||
self.assertEqual(self.progress[-1], ("Download complete", 100))
|
||||
|
||||
def test_gguf_downloads_model_then_mmproj(self):
|
||||
with _patch_modules({"huggingface_hub": self.hub.module}):
|
||||
self.assertTrue(self.service.download_model("gguf", self._cb))
|
||||
|
||||
self.assertEqual(
|
||||
[c[1] for c in self.hub.calls],
|
||||
[MODELS["gguf"]["repo"], MODELS["gguf"]["mmproj_repo"]],
|
||||
)
|
||||
self.assertTrue(self.service.get_models_status()["gguf"]["downloaded"])
|
||||
self.assertIn(("Main model ready", 50), self.progress)
|
||||
|
||||
def test_gguf_skips_files_already_present(self):
|
||||
_touch_model_files(self.models_dir, "gguf")
|
||||
with _patch_modules({"huggingface_hub": self.hub.module}):
|
||||
self.assertTrue(self.service.download_model("gguf"))
|
||||
self.assertEqual(self.hub.calls, [])
|
||||
|
||||
def test_hf_snapshot_download(self):
|
||||
with _patch_modules({"huggingface_hub": self.hub.module}):
|
||||
self.assertTrue(self.service.download_model("hf", self._cb))
|
||||
self.assertTrue(self.service.download_model("hf"))
|
||||
|
||||
self.assertEqual(self.hub.calls, [("snapshot", MODELS["hf"]["repo"])])
|
||||
self.assertTrue(self.service.get_models_status()["hf"]["downloaded"])
|
||||
|
||||
def test_download_errors_are_reported_not_raised(self):
|
||||
def boom(**kwargs):
|
||||
raise OSError("disk full")
|
||||
|
||||
self.hub.module.hf_hub_download = boom
|
||||
with _patch_modules({"huggingface_hub": self.hub.module}):
|
||||
self.assertFalse(self.service.download_model("wd14-vit", self._cb))
|
||||
self.assertTrue(self.progress[-1][0].startswith("Error:"))
|
||||
|
||||
|
||||
# ── Default backend loaders with the ML libraries faked ───────────────
|
||||
|
||||
|
||||
class _FakeOrt:
|
||||
def __init__(self):
|
||||
self.module = types.ModuleType("onnxruntime")
|
||||
self.module.InferenceSession = self.InferenceSession
|
||||
self.sessions = []
|
||||
|
||||
def InferenceSession(self, path, providers):
|
||||
session = FakeOnnxSession([0.9, 0.1])
|
||||
session.path = path
|
||||
session.providers = providers
|
||||
self.sessions.append(session)
|
||||
return session
|
||||
|
||||
|
||||
class TestDefaultLoaders(EngineTestCase):
|
||||
|
||||
def test_wd14_loader_builds_session_and_reads_tags(self):
|
||||
_touch_model_files(self.models_dir, "wd14-vit")
|
||||
csv_path = self.models_dir / MODELS["wd14-vit"]["subdir"] / "selected_tags.csv"
|
||||
with open(csv_path, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=["tag_id", "name", "category"])
|
||||
writer.writeheader()
|
||||
writer.writerow({"tag_id": 1, "name": "1girl", "category": "0"})
|
||||
writer.writerow({"tag_id": 2, "name": "smile", "category": "0"})
|
||||
ort = _FakeOrt()
|
||||
service = AutoTagService(models_dir=self.models_dir)
|
||||
|
||||
with _patch_modules({"onnxruntime": ort.module}):
|
||||
service.load_model("wd14-vit", use_gpu=False)
|
||||
cpu_providers = ort.sessions[-1].providers
|
||||
service.load_model("wd14-vit", use_gpu=True)
|
||||
gpu_providers = ort.sessions[-1].providers
|
||||
|
||||
self.assertEqual(cpu_providers, ["CPUExecutionProvider"])
|
||||
self.assertEqual(gpu_providers[0], "CUDAExecutionProvider")
|
||||
self.assertEqual(service.generate_tags(str(self.image_path)), ["1girl"])
|
||||
|
||||
def test_gguf_loader_wires_llama_with_mmproj(self):
|
||||
_touch_model_files(self.models_dir, "gguf")
|
||||
llama_cpp = types.ModuleType("llama_cpp")
|
||||
chat_format = types.ModuleType("llama_cpp.llama_chat_format")
|
||||
created = {}
|
||||
|
||||
class Llama:
|
||||
def __init__(self, **kwargs):
|
||||
created.update(kwargs)
|
||||
|
||||
def create_chat_completion(self, **kwargs):
|
||||
return {"choices": [{"message": {"content": "Aa, Bb"}}]}
|
||||
|
||||
class Llava15ChatHandler:
|
||||
def __init__(self, clip_model_path):
|
||||
created["clip"] = clip_model_path
|
||||
|
||||
llama_cpp.Llama = Llama
|
||||
chat_format.Llava15ChatHandler = Llava15ChatHandler
|
||||
service = AutoTagService(models_dir=self.models_dir)
|
||||
|
||||
with _patch_modules(
|
||||
{"llama_cpp": llama_cpp, "llama_cpp.llama_chat_format": chat_format},
|
||||
):
|
||||
service.load_model("gguf", use_gpu=False)
|
||||
|
||||
self.assertEqual(created["n_gpu_layers"], 0)
|
||||
self.assertTrue(created["clip"].endswith(MODELS["gguf"]["mmproj_filename"]))
|
||||
self.assertEqual(service.generate_tags(str(self.image_path)), ["aa", "bb"])
|
||||
|
||||
def _fake_torch(self, cuda=False):
|
||||
torch = MagicMock(name="torch")
|
||||
torch.cuda.is_available.return_value = cuda
|
||||
torch.float16 = "f16"
|
||||
torch.bfloat16 = "bf16"
|
||||
torch.inference_mode.return_value.__enter__ = lambda s: None
|
||||
torch.inference_mode.return_value.__exit__ = lambda s, *a: False
|
||||
torch.cuda.amp.autocast.return_value.__enter__ = lambda s: None
|
||||
torch.cuda.amp.autocast.return_value.__exit__ = lambda s, *a: False
|
||||
return torch
|
||||
|
||||
def _fake_transformers(self):
|
||||
transformers = MagicMock(name="transformers")
|
||||
processor = MagicMock(name="processor")
|
||||
processor.apply_chat_template.return_value = "<convo>"
|
||||
inputs = {"input_ids": MagicMock(shape=(1, 3)), "pixel_values": MagicMock()}
|
||||
processed = MagicMock(name="inputs")
|
||||
processed.to.return_value = inputs
|
||||
processor.return_value = processed
|
||||
processor.tokenizer.decode.return_value = "Red Hat, blue sky"
|
||||
transformers.AutoProcessor.from_pretrained.return_value = processor
|
||||
model = MagicMock(name="model")
|
||||
model.generate.return_value = [list(range(10))]
|
||||
transformers.LlavaForConditionalGeneration.from_pretrained.return_value = model
|
||||
return transformers, model, processor, inputs
|
||||
|
||||
def test_hf_loader_8bit_on_cpu_and_generation(self):
|
||||
hf_dir = self.models_dir / MODELS["hf"]["subdir"]
|
||||
hf_dir.mkdir(parents=True)
|
||||
(hf_dir / "config.json").write_text("{}")
|
||||
torch = self._fake_torch(cuda=False)
|
||||
transformers, model, processor, inputs = self._fake_transformers()
|
||||
pixel = inputs["pixel_values"]
|
||||
service = AutoTagService(models_dir=self.models_dir)
|
||||
|
||||
with _patch_modules({"torch": torch, "transformers": transformers}):
|
||||
service.load_model("hf")
|
||||
tags = service.generate_tags(str(self.image_path))
|
||||
|
||||
self.assertEqual(tags, ["red_hat", "blue_sky"])
|
||||
kwargs = (
|
||||
transformers.LlavaForConditionalGeneration.from_pretrained.call_args.kwargs
|
||||
)
|
||||
self.assertEqual(kwargs["device_map"], "cpu")
|
||||
self.assertIn("quantization_config", kwargs)
|
||||
model.eval.assert_called_once()
|
||||
pixel.to.assert_called_with("f16")
|
||||
processor.tokenizer.decode.assert_called_once()
|
||||
decoded = processor.tokenizer.decode.call_args.args[0]
|
||||
self.assertEqual(decoded, list(range(3, 10)))
|
||||
|
||||
def test_hf_loader_without_quantization_uses_bf16_and_cuda(self):
|
||||
hf_dir = self.models_dir / MODELS["hf"]["subdir"]
|
||||
hf_dir.mkdir(parents=True)
|
||||
(hf_dir / "config.json").write_text("{}")
|
||||
torch = self._fake_torch(cuda=True)
|
||||
transformers, model, processor, inputs = self._fake_transformers()
|
||||
pixel = inputs["pixel_values"]
|
||||
service = AutoTagService(models_dir=self.models_dir)
|
||||
|
||||
with _patch_modules({"torch": torch, "transformers": transformers}):
|
||||
kind, (loaded, _, device, dtype) = service._load_hf_tagger(
|
||||
quantization="none"
|
||||
)
|
||||
service._tagger = (kind, (loaded, processor, device, dtype))
|
||||
service._current_model_type = "hf"
|
||||
service.generate_tags(str(self.image_path))
|
||||
service.unload_model()
|
||||
|
||||
self.assertEqual((kind, device, dtype), ("hf", "cuda", "bf16"))
|
||||
kwargs = (
|
||||
transformers.LlavaForConditionalGeneration.from_pretrained.call_args.kwargs
|
||||
)
|
||||
self.assertNotIn("quantization_config", kwargs)
|
||||
pixel.to.assert_called_with("bf16")
|
||||
torch.cuda.empty_cache.assert_called_once()
|
||||
|
||||
def test_hf_loader_without_model_raises(self):
|
||||
service = AutoTagService(models_dir=self.models_dir)
|
||||
with _patch_modules(
|
||||
{"torch": self._fake_torch(), "transformers": self._fake_transformers()[0]},
|
||||
):
|
||||
with self.assertRaises(RuntimeError):
|
||||
service._load_hf_tagger()
|
||||
|
||||
|
||||
class TestUnloadDoesNotImportTorch(EngineTestCase):
|
||||
|
||||
def test_unload_skips_torch_when_not_imported(self):
|
||||
_touch_model_files(self.models_dir, "gguf")
|
||||
service = self._service(("gguf", FakeGgufModel("x")))
|
||||
service.load_model("gguf")
|
||||
|
||||
with _patch_modules({"torch": None}):
|
||||
with patch("builtins.__import__", side_effect=ImportError("no torch")):
|
||||
service.unload_model()
|
||||
|
||||
self.assertFalse(service.is_model_loaded())
|
||||
|
||||
|
||||
class TestImageConversion(EngineTestCase):
|
||||
|
||||
def test_grayscale_images_are_converted_to_rgb(self):
|
||||
_touch_model_files(self.models_dir, "wd14-vit")
|
||||
gray = self.tmp / "gray.png"
|
||||
Image.new("L", (3, 3), 128).save(gray, "PNG")
|
||||
session = FakeOnnxSession([0.9] * 6)
|
||||
service = self._service(("wd14", (session, WD14_TAGS)))
|
||||
service.load_model("wd14-vit")
|
||||
|
||||
tags = service.generate_tags(str(gray))
|
||||
|
||||
self.assertIn("1girl", tags)
|
||||
self.assertEqual(tuple(session.run_calls[0]["input"].shape), (1, 8, 8, 3))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+89
-79
@@ -9,8 +9,14 @@ from unittest.mock import Mock, patch
|
||||
|
||||
# Add the parent directory to the path for imports
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed # noqa: E402
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed # noqa: E402
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.validators import validate_prompt_text, validate_rating, validate_tags
|
||||
@@ -18,61 +24,65 @@ from utils.validators import validate_prompt_text, validate_rating, validate_tag
|
||||
|
||||
class TestBasicFunctionality(unittest.TestCase):
|
||||
"""Test basic functionality of PromptManager components."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
# Use a temporary database for testing
|
||||
self.temp_db = tempfile.NamedTemporaryFile(delete=False, suffix='.db')
|
||||
self.temp_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
|
||||
self.temp_db.close()
|
||||
self.db = PromptDatabase(self.temp_db.name)
|
||||
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test fixtures."""
|
||||
# Remove temporary database
|
||||
if os.path.exists(self.temp_db.name):
|
||||
os.unlink(self.temp_db.name)
|
||||
|
||||
if getattr(self, "db", None) is not None:
|
||||
self.db.close_all()
|
||||
assert_closed(self, self.temp_db.name)
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
if os.path.exists(self.temp_db.name + suffix):
|
||||
os.unlink(self.temp_db.name + suffix)
|
||||
|
||||
def test_prompt_hash_generation(self):
|
||||
"""Test prompt hash generation."""
|
||||
text1 = "A beautiful landscape with mountains"
|
||||
text2 = "A BEAUTIFUL LANDSCAPE WITH MOUNTAINS" # Different case
|
||||
text3 = " A beautiful landscape with mountains " # Extra whitespace
|
||||
text4 = "A different prompt"
|
||||
|
||||
|
||||
hash1 = generate_prompt_hash(text1)
|
||||
hash2 = generate_prompt_hash(text2)
|
||||
hash3 = generate_prompt_hash(text3)
|
||||
hash4 = generate_prompt_hash(text4)
|
||||
|
||||
|
||||
# Same content should produce same hash (case and whitespace insensitive)
|
||||
self.assertEqual(hash1, hash2)
|
||||
self.assertEqual(hash1, hash3)
|
||||
|
||||
|
||||
# Different content should produce different hash
|
||||
self.assertNotEqual(hash1, hash4)
|
||||
|
||||
|
||||
# Hash should be 64 characters (SHA256 hex)
|
||||
self.assertEqual(len(hash1), 64)
|
||||
|
||||
|
||||
def test_prompt_validation(self):
|
||||
"""Test prompt text validation."""
|
||||
# Valid prompts
|
||||
self.assertTrue(validate_prompt_text("Valid prompt text"))
|
||||
self.assertTrue(validate_prompt_text("Another valid prompt"))
|
||||
|
||||
|
||||
# Invalid prompts
|
||||
with self.assertRaises(ValueError):
|
||||
validate_prompt_text("") # Empty
|
||||
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
validate_prompt_text(" ") # Whitespace only
|
||||
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
validate_prompt_text("x" * 10001) # Too long
|
||||
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
validate_prompt_text(123) # Not a string
|
||||
|
||||
|
||||
def test_rating_validation(self):
|
||||
"""Test rating validation."""
|
||||
# Valid ratings
|
||||
@@ -80,17 +90,17 @@ class TestBasicFunctionality(unittest.TestCase):
|
||||
self.assertTrue(validate_rating(1))
|
||||
self.assertTrue(validate_rating(3))
|
||||
self.assertTrue(validate_rating(5))
|
||||
|
||||
|
||||
# Invalid ratings
|
||||
with self.assertRaises(ValueError):
|
||||
validate_rating(0) # Too low
|
||||
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
validate_rating(6) # Too high
|
||||
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
validate_rating("3") # Not an integer
|
||||
|
||||
|
||||
def test_tags_validation(self):
|
||||
"""Test tags validation."""
|
||||
# Valid tags
|
||||
@@ -98,17 +108,17 @@ class TestBasicFunctionality(unittest.TestCase):
|
||||
self.assertTrue(validate_tags([]))
|
||||
self.assertTrue(validate_tags(["tag1", "tag2"]))
|
||||
self.assertTrue(validate_tags("tag1, tag2, tag3"))
|
||||
|
||||
|
||||
# Invalid tags
|
||||
with self.assertRaises(ValueError):
|
||||
validate_tags([""]) # Empty tag
|
||||
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
validate_tags(["x" * 51]) # Tag too long
|
||||
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
validate_tags(["tag"] * 21) # Too many tags
|
||||
|
||||
|
||||
def test_database_save_and_retrieve(self):
|
||||
"""Test basic database save and retrieve operations."""
|
||||
# Save a prompt
|
||||
@@ -118,38 +128,36 @@ class TestBasicFunctionality(unittest.TestCase):
|
||||
tags=["test", "database"],
|
||||
rating=4,
|
||||
notes="Test notes",
|
||||
prompt_hash=generate_prompt_hash("Test prompt for database")
|
||||
prompt_hash=generate_prompt_hash("Test prompt for database"),
|
||||
)
|
||||
|
||||
|
||||
self.assertIsInstance(prompt_id, int)
|
||||
self.assertGreater(prompt_id, 0)
|
||||
|
||||
|
||||
# Retrieve the prompt
|
||||
retrieved = self.db.get_prompt_by_id(prompt_id)
|
||||
self.assertIsNotNone(retrieved)
|
||||
self.assertEqual(retrieved['text'], "Test prompt for database")
|
||||
self.assertEqual(retrieved['category'], "test")
|
||||
self.assertEqual(retrieved['tags'], ["test", "database"])
|
||||
self.assertEqual(retrieved['rating'], 4)
|
||||
self.assertEqual(retrieved['notes'], "Test notes")
|
||||
|
||||
self.assertEqual(retrieved["text"], "Test prompt for database")
|
||||
self.assertEqual(retrieved["category"], "test")
|
||||
self.assertEqual(retrieved["tags"], ["test", "database"])
|
||||
self.assertEqual(retrieved["rating"], 4)
|
||||
self.assertEqual(retrieved["notes"], "Test notes")
|
||||
|
||||
def test_duplicate_detection(self):
|
||||
"""Test duplicate prompt detection."""
|
||||
text = "Duplicate test prompt"
|
||||
hash_val = generate_prompt_hash(text)
|
||||
|
||||
|
||||
# Save original prompt
|
||||
prompt_id1 = self.db.save_prompt(
|
||||
text=text,
|
||||
category="test",
|
||||
prompt_hash=hash_val
|
||||
text=text, category="test", prompt_hash=hash_val
|
||||
)
|
||||
|
||||
|
||||
# Try to save the same prompt again
|
||||
existing = self.db.get_prompt_by_hash(hash_val)
|
||||
self.assertIsNotNone(existing)
|
||||
self.assertEqual(existing['id'], prompt_id1)
|
||||
|
||||
self.assertEqual(existing["id"], prompt_id1)
|
||||
|
||||
def test_search_functionality(self):
|
||||
"""Test prompt search functionality."""
|
||||
# Save multiple prompts
|
||||
@@ -158,110 +166,112 @@ class TestBasicFunctionality(unittest.TestCase):
|
||||
"text": "Beautiful landscape with mountains",
|
||||
"category": "landscape",
|
||||
"tags": ["nature", "mountains"],
|
||||
"rating": 5
|
||||
"rating": 5,
|
||||
},
|
||||
{
|
||||
"text": "Portrait of a woman",
|
||||
"category": "portrait",
|
||||
"tags": ["people", "woman"],
|
||||
"rating": 4
|
||||
"rating": 4,
|
||||
},
|
||||
{
|
||||
"text": "Abstract art piece",
|
||||
"category": "abstract",
|
||||
"tags": ["art", "abstract"],
|
||||
"rating": 3
|
||||
}
|
||||
"rating": 3,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
for prompt_data in prompts:
|
||||
self.db.save_prompt(
|
||||
text=prompt_data["text"],
|
||||
category=prompt_data["category"],
|
||||
tags=prompt_data["tags"],
|
||||
rating=prompt_data["rating"],
|
||||
prompt_hash=generate_prompt_hash(prompt_data["text"])
|
||||
prompt_hash=generate_prompt_hash(prompt_data["text"]),
|
||||
)
|
||||
|
||||
|
||||
# Test text search
|
||||
results = self.db.search_prompts(text="landscape")
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertIn("landscape", results[0]['text'])
|
||||
|
||||
self.assertIn("landscape", results[0]["text"])
|
||||
|
||||
# Test category search
|
||||
results = self.db.search_prompts(category="portrait")
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0]['category'], "portrait")
|
||||
|
||||
self.assertEqual(results[0]["category"], "portrait")
|
||||
|
||||
# Test rating search
|
||||
results = self.db.search_prompts(rating_min=4)
|
||||
self.assertEqual(len(results), 2) # Rating 4 and 5
|
||||
|
||||
|
||||
# Test tag search
|
||||
results = self.db.search_prompts(tags=["nature"])
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertIn("nature", results[0]['tags'])
|
||||
self.assertIn("nature", results[0]["tags"])
|
||||
|
||||
|
||||
class TestNodeIntegration(unittest.TestCase):
|
||||
"""Test the actual node integration (mocked)."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
# Use a temporary database for testing
|
||||
self.temp_db = tempfile.NamedTemporaryFile(delete=False, suffix='.db')
|
||||
self.temp_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
|
||||
self.temp_db.close()
|
||||
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test fixtures."""
|
||||
# Remove temporary database
|
||||
if os.path.exists(self.temp_db.name):
|
||||
os.unlink(self.temp_db.name)
|
||||
|
||||
@patch('prompt_manager.PromptDatabase')
|
||||
if getattr(self, "db", None) is not None:
|
||||
self.db.close_all()
|
||||
assert_closed(self, self.temp_db.name)
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
if os.path.exists(self.temp_db.name + suffix):
|
||||
os.unlink(self.temp_db.name + suffix)
|
||||
|
||||
@patch("prompt_manager_base.PromptDatabase")
|
||||
def test_node_encode_function(self, mock_db_class):
|
||||
"""Test the node's encode function with mocked dependencies."""
|
||||
# Mock the database
|
||||
mock_db = Mock()
|
||||
mock_db_class.return_value = mock_db
|
||||
|
||||
mock_db.save_prompt.return_value = 1
|
||||
mock_db.get_prompt_by_hash.return_value = None
|
||||
|
||||
# Mock CLIP model
|
||||
mock_clip = Mock()
|
||||
mock_clip.tokenize.return_value = "mock_tokens"
|
||||
mock_clip.encode_from_tokens_scheduled.return_value = "mock_conditioning"
|
||||
|
||||
|
||||
# Import and test the node
|
||||
from prompt_manager import PromptManager
|
||||
|
||||
|
||||
node = PromptManager()
|
||||
|
||||
|
||||
# Test encoding
|
||||
result = node.encode(
|
||||
clip=mock_clip,
|
||||
text="Test prompt",
|
||||
search_text=""
|
||||
)
|
||||
|
||||
result = node.encode_prompt(clip=mock_clip, text="Test prompt", search_text="")
|
||||
|
||||
# Verify CLIP was called correctly
|
||||
mock_clip.tokenize.assert_called_once_with("Test prompt")
|
||||
mock_clip.encode_from_tokens_scheduled.assert_called_once_with("mock_tokens")
|
||||
|
||||
# Verify result - PromptManager returns both conditioning and prompt text
|
||||
self.assertEqual(result, ("mock_conditioning", "Test prompt"))
|
||||
|
||||
|
||||
# Verify result tuple (conditioning, prompt_text)
|
||||
self.assertEqual(result[1], "Test prompt")
|
||||
|
||||
def test_node_input_types(self):
|
||||
"""Test the node's input type definitions."""
|
||||
from prompt_manager import PromptManager
|
||||
|
||||
|
||||
input_types = PromptManager.INPUT_TYPES()
|
||||
|
||||
|
||||
# Check required inputs
|
||||
self.assertIn("text", input_types["required"])
|
||||
self.assertIn("clip", input_types["required"])
|
||||
|
||||
|
||||
# Check optional inputs
|
||||
self.assertIn("search_text", input_types["optional"])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Tests for utils.comfyui_integration."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.comfyui_integration import ComfyUIMetadataIntegration
|
||||
|
||||
|
||||
class TestSaveImageNotPatched(unittest.TestCase):
|
||||
"""Regression for #75: SaveImage must save each node's own prompt inputs.
|
||||
|
||||
The old SaveImage patch wrote the last-run PromptManager prompt into every
|
||||
PromptManager node, so negative prompts were saved as copies of the positive.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
def save_images(
|
||||
self_node,
|
||||
images,
|
||||
filename_prefix="ComfyUI",
|
||||
prompt=None,
|
||||
extra_pnginfo=None,
|
||||
):
|
||||
return {"prompt": prompt}
|
||||
|
||||
self.original_save_images = save_images
|
||||
fake_nodes = types.ModuleType("nodes")
|
||||
fake_nodes.SaveImage = type("SaveImage", (), {"save_images": save_images})
|
||||
self._saved_nodes = sys.modules.get("nodes")
|
||||
sys.modules["nodes"] = fake_nodes
|
||||
self.fake_nodes = fake_nodes
|
||||
ComfyUIMetadataIntegration._instance = None
|
||||
|
||||
def tearDown(self):
|
||||
ComfyUIMetadataIntegration._instance = None
|
||||
if self._saved_nodes is None:
|
||||
sys.modules.pop("nodes", None)
|
||||
else:
|
||||
sys.modules["nodes"] = self._saved_nodes
|
||||
|
||||
def test_save_images_is_left_untouched(self):
|
||||
ComfyUIMetadataIntegration()
|
||||
self.assertIs(self.fake_nodes.SaveImage.save_images, self.original_save_images)
|
||||
|
||||
def test_registered_prompt_does_not_rewrite_saved_metadata(self):
|
||||
integration = ComfyUIMetadataIntegration()
|
||||
integration.register_prompt("pm_1", "positive final text", {})
|
||||
prompt = {
|
||||
"39": {"class_type": "PromptManager", "inputs": {"text": "a cat"}},
|
||||
"40": {"class_type": "PromptManager", "inputs": {"text": "blurry"}},
|
||||
}
|
||||
|
||||
result = self.fake_nodes.SaveImage().save_images([], prompt=prompt)
|
||||
|
||||
self.assertEqual(result["prompt"]["39"]["inputs"]["text"], "a cat")
|
||||
self.assertEqual(result["prompt"]["40"]["inputs"]["text"], "blurry")
|
||||
|
||||
def test_prompt_registry_still_available_to_nodes(self):
|
||||
integration = ComfyUIMetadataIntegration()
|
||||
integration.register_prompt("pm_1", "hello", {})
|
||||
self.assertEqual(integration.get_current_prompt_text("pm_1"), "hello")
|
||||
|
||||
|
||||
import threading # noqa: E402
|
||||
import unittest.mock as mock # noqa: E402
|
||||
|
||||
from utils import comfyui_integration # noqa: E402
|
||||
from utils.comfyui_integration import get_comfyui_integration # noqa: E402
|
||||
|
||||
|
||||
class IntegrationTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
ComfyUIMetadataIntegration._instance = None
|
||||
comfyui_integration._integration_instance = None
|
||||
# Patch the module's own ``time`` name rather than ``time.time`` globally:
|
||||
# background threads from other tests (image monitor, watchdog) would
|
||||
# otherwise tick this clock and make fresh registrations look stale.
|
||||
self.clock = mock.patch.object(
|
||||
comfyui_integration, "time", types.SimpleNamespace(time=self._now)
|
||||
)
|
||||
self.clock.start()
|
||||
self.addCleanup(self.clock.stop)
|
||||
self._ticks = 1000.0
|
||||
self.integration = get_comfyui_integration()
|
||||
|
||||
def tearDown(self):
|
||||
ComfyUIMetadataIntegration._instance = None
|
||||
comfyui_integration._integration_instance = None
|
||||
|
||||
def _now(self):
|
||||
self._ticks += 1.0
|
||||
return self._ticks
|
||||
|
||||
|
||||
class TestSingletonAndRegistry(IntegrationTestCase):
|
||||
def test_singleton_keeps_registrations_across_constructions(self):
|
||||
self.assertIs(get_comfyui_integration(), self.integration)
|
||||
self.assertIs(ComfyUIMetadataIntegration(), self.integration)
|
||||
self.integration.register_prompt("n1", "hello", {"category": "x"})
|
||||
ComfyUIMetadataIntegration() # __init__ runs again: must not reset state
|
||||
self.assertEqual(self.integration.get_current_prompt_text("n1"), "hello")
|
||||
|
||||
def test_latest_prompt_of_this_thread_wins_without_a_node_id(self):
|
||||
self.integration.register_prompt("n1", "first", {})
|
||||
self.integration.register_prompt("n2", "second", {})
|
||||
self.assertEqual(self.integration.get_current_prompt_text(), "second")
|
||||
self.assertEqual(self.integration.get_current_prompt_text("missing"), "second")
|
||||
self.assertEqual(self.integration.get_current_prompt_text("n1"), "first")
|
||||
|
||||
def test_nothing_registered_returns_none(self):
|
||||
self.assertIsNone(self.integration.get_current_prompt_text())
|
||||
self.assertIsNone(self.integration.get_current_prompt_text("n1"))
|
||||
|
||||
|
||||
class TestCrossThreadLookup(IntegrationTestCase):
|
||||
def _register_in_thread(self, text):
|
||||
worker = threading.Thread(
|
||||
target=self.integration.register_prompt, args=("w1", text, {})
|
||||
)
|
||||
worker.start()
|
||||
worker.join()
|
||||
|
||||
def test_recent_prompt_from_another_thread_is_used(self):
|
||||
self._register_in_thread("from worker")
|
||||
self.assertEqual(self.integration.get_current_prompt_text(), "from worker")
|
||||
|
||||
def test_stale_prompt_from_another_thread_is_ignored(self):
|
||||
self._register_in_thread("from worker")
|
||||
self._ticks += 400.0
|
||||
self.assertIsNone(self.integration.get_current_prompt_text())
|
||||
|
||||
def test_global_entry_of_this_thread_is_found_without_thread_local(self):
|
||||
self.integration.register_prompt("n1", "mine", {})
|
||||
self.integration._thread_local = threading.local()
|
||||
self.assertEqual(self.integration.get_current_prompt_text(), "mine")
|
||||
|
||||
def test_cleanup_removes_old_registrations_only(self):
|
||||
self._register_in_thread("old")
|
||||
self._ticks += 700.0
|
||||
self.integration.register_prompt("n2", "new", {})
|
||||
self.integration.cleanup_old_prompts(max_age_seconds=600)
|
||||
texts = sorted(v["text"] for v in self.integration._current_prompts.values())
|
||||
self.assertEqual(texts, ["new"])
|
||||
self.integration.cleanup_old_prompts(max_age_seconds=600) # nothing to do
|
||||
self.assertEqual(len(self.integration._current_prompts), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,408 @@
|
||||
"""
|
||||
Tests for GalleryConfig and PromptManagerConfig.
|
||||
|
||||
The config module imports `from server import PromptServer` at module level,
|
||||
which is ComfyUI's server. We mock it before import to run tests standalone.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Mock ComfyUI's server module before importing config
|
||||
_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.config import GalleryConfig, IntegrationConfig, PromptManagerConfig
|
||||
|
||||
|
||||
class TestGalleryConfig(unittest.TestCase):
|
||||
"""Test GalleryConfig class."""
|
||||
|
||||
def setUp(self):
|
||||
"""Save original values to restore after each test."""
|
||||
self._orig = GalleryConfig.get_config()
|
||||
|
||||
def tearDown(self):
|
||||
"""Restore original config values."""
|
||||
GalleryConfig.update_config(self._orig)
|
||||
|
||||
def test_get_config_structure(self):
|
||||
config = GalleryConfig.get_config()
|
||||
self.assertIn("monitoring", config)
|
||||
self.assertIn("tracking", config)
|
||||
self.assertIn("database", config)
|
||||
self.assertIn("web_interface", config)
|
||||
self.assertIn("performance", config)
|
||||
|
||||
def test_default_values(self):
|
||||
config = GalleryConfig.get_config()
|
||||
self.assertTrue(config["monitoring"]["enabled"])
|
||||
self.assertEqual(
|
||||
config["monitoring"]["extensions"],
|
||||
[".png", ".jpg", ".jpeg", ".webp", ".gif"],
|
||||
)
|
||||
self.assertEqual(config["tracking"]["prompt_timeout"], 600)
|
||||
self.assertEqual(config["web_interface"]["images_per_page"], 20)
|
||||
|
||||
def test_update_monitoring(self):
|
||||
GalleryConfig.update_config(
|
||||
{"monitoring": {"enabled": False, "processing_delay": 5.0}}
|
||||
)
|
||||
self.assertFalse(GalleryConfig.MONITORING_ENABLED)
|
||||
self.assertEqual(GalleryConfig.PROCESSING_DELAY, 5.0)
|
||||
|
||||
def test_update_tracking(self):
|
||||
GalleryConfig.update_config({"tracking": {"prompt_timeout": 300}})
|
||||
self.assertEqual(GalleryConfig.PROMPT_TIMEOUT, 300)
|
||||
|
||||
def test_update_database(self):
|
||||
GalleryConfig.update_config(
|
||||
{"database": {"auto_cleanup": False, "max_image_age_days": 30}}
|
||||
)
|
||||
self.assertFalse(GalleryConfig.AUTO_CLEANUP_MISSING_FILES)
|
||||
self.assertEqual(GalleryConfig.MAX_IMAGE_AGE_DAYS, 30)
|
||||
|
||||
def test_update_web_interface(self):
|
||||
GalleryConfig.update_config(
|
||||
{"web_interface": {"images_per_page": 50, "thumbnail_size": 512}}
|
||||
)
|
||||
self.assertEqual(GalleryConfig.IMAGES_PER_PAGE, 50)
|
||||
self.assertEqual(GalleryConfig.THUMBNAIL_SIZE, 512)
|
||||
|
||||
def test_update_performance(self):
|
||||
GalleryConfig.update_config({"performance": {"max_concurrent_processing": 5}})
|
||||
self.assertEqual(GalleryConfig.MAX_CONCURRENT_PROCESSING, 5)
|
||||
|
||||
def test_partial_update_preserves_other_values(self):
|
||||
original_timeout = GalleryConfig.PROMPT_TIMEOUT
|
||||
GalleryConfig.update_config({"monitoring": {"enabled": False}})
|
||||
self.assertEqual(GalleryConfig.PROMPT_TIMEOUT, original_timeout)
|
||||
|
||||
def test_empty_update_changes_nothing(self):
|
||||
before = GalleryConfig.get_config()
|
||||
GalleryConfig.update_config({})
|
||||
after = GalleryConfig.get_config()
|
||||
self.assertEqual(before, after)
|
||||
|
||||
|
||||
class TestPromptManagerConfig(unittest.TestCase):
|
||||
"""Test PromptManagerConfig class."""
|
||||
|
||||
def setUp(self):
|
||||
self._orig = PromptManagerConfig.get_config()
|
||||
|
||||
def tearDown(self):
|
||||
PromptManagerConfig.update_config(self._orig)
|
||||
|
||||
def test_get_config_structure(self):
|
||||
config = PromptManagerConfig.get_config()
|
||||
self.assertIn("database", config)
|
||||
self.assertIn("web_ui", config)
|
||||
self.assertIn("performance", config)
|
||||
self.assertIn("gallery", config)
|
||||
|
||||
def test_gallery_config_nested(self):
|
||||
config = PromptManagerConfig.get_config()
|
||||
# Gallery section should match GalleryConfig output
|
||||
self.assertIn("monitoring", config["gallery"])
|
||||
self.assertIn("tracking", config["gallery"])
|
||||
|
||||
def test_default_database_values(self):
|
||||
config = PromptManagerConfig.get_config()
|
||||
self.assertEqual(config["database"]["default_path"], "prompts.db")
|
||||
self.assertTrue(config["database"]["enable_duplicate_detection"])
|
||||
self.assertTrue(config["database"]["enable_auto_save"])
|
||||
|
||||
def test_default_web_ui_values(self):
|
||||
config = PromptManagerConfig.get_config()
|
||||
self.assertEqual(config["web_ui"]["webui_display_mode"], "newtab")
|
||||
|
||||
def test_update_database(self):
|
||||
PromptManagerConfig.update_config({"database": {"default_path": "custom.db"}})
|
||||
self.assertEqual(PromptManagerConfig.DEFAULT_DB_PATH, "custom.db")
|
||||
|
||||
def test_update_web_ui(self):
|
||||
PromptManagerConfig.update_config({"web_ui": {"webui_display_mode": "popup"}})
|
||||
self.assertEqual(PromptManagerConfig.WEBUI_DISPLAY_MODE, "popup")
|
||||
|
||||
def test_update_performance(self):
|
||||
PromptManagerConfig.update_config({"performance": {"max_search_results": 50}})
|
||||
self.assertEqual(PromptManagerConfig.MAX_SEARCH_RESULTS, 50)
|
||||
|
||||
def test_infinite_scroll_default_off_and_only_booleans_apply(self):
|
||||
self.assertIn("infinite_scroll", PromptManagerConfig.get_config()["web_ui"])
|
||||
PromptManagerConfig.update_config({"web_ui": {"infinite_scroll": True}})
|
||||
self.assertTrue(PromptManagerConfig.INFINITE_SCROLL)
|
||||
PromptManagerConfig.update_config({"web_ui": {"infinite_scroll": "yes"}})
|
||||
self.assertTrue(PromptManagerConfig.INFINITE_SCROLL)
|
||||
PromptManagerConfig.update_config({"web_ui": {"infinite_scroll": False}})
|
||||
self.assertFalse(PromptManagerConfig.INFINITE_SCROLL)
|
||||
|
||||
def test_worker_threads_default_is_half_the_cores_and_current_is_in_range(self):
|
||||
# The current value may come from config.json (loaded at import), so the
|
||||
# rule is checked on the constant and only the range on the live value.
|
||||
cores = PromptManagerConfig.max_worker_threads()
|
||||
self.assertGreaterEqual(cores, 1)
|
||||
self.assertEqual(PromptManagerConfig.DEFAULT_WORKER_THREADS, max(1, cores // 2))
|
||||
current = PromptManagerConfig.get_config()["performance"]["worker_threads"]
|
||||
self.assertGreaterEqual(current, 1)
|
||||
self.assertLessEqual(current, cores)
|
||||
|
||||
def test_update_worker_threads_is_clamped_to_the_machine(self):
|
||||
cores = PromptManagerConfig.max_worker_threads()
|
||||
PromptManagerConfig.update_config({"performance": {"worker_threads": 1}})
|
||||
self.assertEqual(PromptManagerConfig.WORKER_THREADS, 1)
|
||||
PromptManagerConfig.update_config(
|
||||
{"performance": {"worker_threads": cores + 50}}
|
||||
)
|
||||
self.assertEqual(PromptManagerConfig.WORKER_THREADS, cores)
|
||||
PromptManagerConfig.update_config({"performance": {"worker_threads": 0}})
|
||||
self.assertEqual(PromptManagerConfig.WORKER_THREADS, 1)
|
||||
|
||||
def test_update_worker_threads_ignores_junk_from_a_hand_edited_file(self):
|
||||
PromptManagerConfig.update_config({"performance": {"worker_threads": 1}})
|
||||
PromptManagerConfig.update_config({"performance": {"worker_threads": "lots"}})
|
||||
self.assertEqual(PromptManagerConfig.WORKER_THREADS, 1)
|
||||
PromptManagerConfig.update_config({"performance": {"worker_threads": True}})
|
||||
self.assertEqual(PromptManagerConfig.WORKER_THREADS, 1)
|
||||
|
||||
def test_update_propagates_to_gallery(self):
|
||||
PromptManagerConfig.update_config(
|
||||
{"gallery": {"monitoring": {"enabled": False}}}
|
||||
)
|
||||
self.assertFalse(GalleryConfig.MONITORING_ENABLED)
|
||||
# Restore
|
||||
GalleryConfig.MONITORING_ENABLED = True
|
||||
|
||||
def test_save_and_load_file(self):
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=".json", dir=tempfile.gettempdir()
|
||||
)
|
||||
tmp.close()
|
||||
try:
|
||||
# Modify a value
|
||||
PromptManagerConfig.MAX_SEARCH_RESULTS = 42
|
||||
PromptManagerConfig.save_to_file(tmp.name)
|
||||
|
||||
# Reset and reload
|
||||
PromptManagerConfig.MAX_SEARCH_RESULTS = 100
|
||||
PromptManagerConfig.load_from_file(tmp.name)
|
||||
self.assertEqual(PromptManagerConfig.MAX_SEARCH_RESULTS, 42)
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
def test_load_nonexistent_file_uses_defaults(self):
|
||||
# Should not raise, just log and continue
|
||||
original = PromptManagerConfig.MAX_SEARCH_RESULTS
|
||||
PromptManagerConfig.load_from_file("/nonexistent/path/config.json")
|
||||
self.assertEqual(PromptManagerConfig.MAX_SEARCH_RESULTS, original)
|
||||
|
||||
def test_load_invalid_json_file(self):
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".json", mode="w")
|
||||
tmp.write("not valid json {{")
|
||||
tmp.close()
|
||||
try:
|
||||
original = PromptManagerConfig.MAX_SEARCH_RESULTS
|
||||
PromptManagerConfig.load_from_file(tmp.name)
|
||||
# Should not crash, keeps existing values
|
||||
self.assertEqual(PromptManagerConfig.MAX_SEARCH_RESULTS, original)
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
def test_save_creates_directory(self):
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
nested_path = os.path.join(tmp_dir, "subdir", "config.json")
|
||||
try:
|
||||
PromptManagerConfig.save_to_file(nested_path)
|
||||
self.assertTrue(os.path.exists(nested_path))
|
||||
with open(nested_path) as f:
|
||||
data = json.load(f)
|
||||
self.assertIn("database", data)
|
||||
finally:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(tmp_dir)
|
||||
|
||||
def test_saved_file_is_private_to_the_user(self):
|
||||
import stat
|
||||
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
path = os.path.join(tmp_dir, "config.json")
|
||||
PromptManagerConfig.save_to_file(path)
|
||||
mode = stat.S_IMODE(os.stat(path).st_mode)
|
||||
if os.name == "posix":
|
||||
self.assertEqual(mode, 0o600)
|
||||
with open(path) as f:
|
||||
self.assertIn("database", json.load(f))
|
||||
# Overwriting an existing, more open file tightens it as well
|
||||
os.chmod(path, 0o644)
|
||||
PromptManagerConfig.save_to_file(path)
|
||||
if os.name == "posix":
|
||||
self.assertEqual(stat.S_IMODE(os.stat(path).st_mode), 0o600)
|
||||
finally:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
def test_saved_file_is_valid_json(self):
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=".json", dir=tempfile.gettempdir()
|
||||
)
|
||||
tmp.close()
|
||||
try:
|
||||
PromptManagerConfig.save_to_file(tmp.name)
|
||||
with open(tmp.name) as f:
|
||||
data = json.load(f)
|
||||
self.assertIsInstance(data, dict)
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
|
||||
class TestIntegrationConfig(unittest.TestCase):
|
||||
"""Test IntegrationConfig for LoRA Manager settings."""
|
||||
|
||||
def setUp(self):
|
||||
"""Save original values to restore after each test."""
|
||||
self._orig = IntegrationConfig.get_config()
|
||||
|
||||
def tearDown(self):
|
||||
"""Restore original config values."""
|
||||
IntegrationConfig.update_config(self._orig)
|
||||
|
||||
def test_reset_to_disabled(self):
|
||||
"""Integrations can be fully disabled via update_config."""
|
||||
IntegrationConfig.update_config(
|
||||
{
|
||||
"lora_manager": {
|
||||
"enabled": False,
|
||||
"path": "",
|
||||
"trigger_words_enabled": False,
|
||||
"civitai_api_key": "",
|
||||
}
|
||||
}
|
||||
)
|
||||
config = IntegrationConfig.get_config()
|
||||
lora = config["lora_manager"]
|
||||
self.assertFalse(lora["enabled"])
|
||||
self.assertEqual(lora["path"], "")
|
||||
self.assertFalse(lora["trigger_words_enabled"])
|
||||
self.assertEqual(lora["civitai_api_key"], "")
|
||||
|
||||
def test_get_config_structure(self):
|
||||
config = IntegrationConfig.get_config()
|
||||
self.assertIn("lora_manager", config)
|
||||
lora = config["lora_manager"]
|
||||
self.assertIn("enabled", lora)
|
||||
self.assertIn("path", lora)
|
||||
self.assertIn("trigger_words_enabled", lora)
|
||||
self.assertIn("civitai_api_key", lora)
|
||||
|
||||
def test_update_config_enables(self):
|
||||
IntegrationConfig.update_config(
|
||||
{
|
||||
"lora_manager": {
|
||||
"enabled": True,
|
||||
"path": "/some/path",
|
||||
"trigger_words_enabled": True,
|
||||
"civitai_api_key": "test-key-123",
|
||||
}
|
||||
}
|
||||
)
|
||||
self.assertTrue(IntegrationConfig.LORA_MANAGER_ENABLED)
|
||||
self.assertEqual(IntegrationConfig.LORA_MANAGER_PATH, "/some/path")
|
||||
self.assertTrue(IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED)
|
||||
self.assertEqual(IntegrationConfig.CIVITAI_API_KEY, "test-key-123")
|
||||
|
||||
def test_update_partial(self):
|
||||
"""Updating one field shouldn't affect others."""
|
||||
# Reset to known state first
|
||||
IntegrationConfig.update_config(
|
||||
{"lora_manager": {"enabled": False, "path": "/known"}}
|
||||
)
|
||||
# Now update only enabled
|
||||
IntegrationConfig.update_config({"lora_manager": {"enabled": True}})
|
||||
self.assertTrue(IntegrationConfig.LORA_MANAGER_ENABLED)
|
||||
self.assertEqual(IntegrationConfig.LORA_MANAGER_PATH, "/known")
|
||||
|
||||
def test_update_empty_dict_noop(self):
|
||||
"""Updating with empty dict preserves current state."""
|
||||
before = IntegrationConfig.get_config()
|
||||
IntegrationConfig.update_config({})
|
||||
after = IntegrationConfig.get_config()
|
||||
self.assertEqual(before, after)
|
||||
|
||||
def test_roundtrip(self):
|
||||
"""get_config → update_config → get_config should be stable."""
|
||||
IntegrationConfig.update_config(
|
||||
{"lora_manager": {"enabled": True, "path": "/test"}}
|
||||
)
|
||||
config1 = IntegrationConfig.get_config()
|
||||
IntegrationConfig.update_config(config1)
|
||||
config2 = IntegrationConfig.get_config()
|
||||
self.assertEqual(config1, config2)
|
||||
|
||||
|
||||
class TestConfigPathOverride(unittest.TestCase):
|
||||
"""PROMPT_MANAGER_CONFIG_PATH relocates the persisted config.json."""
|
||||
|
||||
ENV = "PROMPT_MANAGER_CONFIG_PATH"
|
||||
|
||||
def setUp(self):
|
||||
self._orig_env = os.environ.pop(self.ENV, None)
|
||||
self._orig = PromptManagerConfig.get_config()
|
||||
|
||||
def tearDown(self):
|
||||
if self._orig_env is None:
|
||||
os.environ.pop(self.ENV, None)
|
||||
else:
|
||||
os.environ[self.ENV] = self._orig_env
|
||||
PromptManagerConfig.update_config(self._orig)
|
||||
|
||||
def test_default_config_path_is_repo_config_json(self):
|
||||
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
expected = os.path.join(repo_root, "config.json")
|
||||
self.assertEqual(
|
||||
os.path.normcase(PromptManagerConfig.get_config_path()),
|
||||
os.path.normcase(expected),
|
||||
)
|
||||
|
||||
def test_env_override_is_used(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
custom = os.path.join(tmpdir, "custom.json")
|
||||
os.environ[self.ENV] = custom
|
||||
self.assertEqual(PromptManagerConfig.get_config_path(), custom)
|
||||
|
||||
def test_save_without_path_uses_env_override(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
custom = os.path.join(tmpdir, "nested", "custom.json")
|
||||
os.environ[self.ENV] = custom
|
||||
PromptManagerConfig.RESULT_TIMEOUT = 42
|
||||
|
||||
PromptManagerConfig.save_to_file()
|
||||
|
||||
with open(custom) as f:
|
||||
saved = json.load(f)
|
||||
self.assertEqual(saved["web_ui"]["result_timeout"], 42)
|
||||
|
||||
def test_load_without_path_uses_env_override(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
custom = os.path.join(tmpdir, "custom.json")
|
||||
with open(custom, "w") as f:
|
||||
json.dump({"web_ui": {"result_timeout": 77}}, f)
|
||||
os.environ[self.ENV] = custom
|
||||
|
||||
PromptManagerConfig.load_from_file()
|
||||
|
||||
self.assertEqual(PromptManagerConfig.RESULT_TIMEOUT, 77)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,301 @@
|
||||
"""Backup, verification and restore of the prompts database.
|
||||
|
||||
The database runs in WAL mode, so committed rows can sit in prompts.db-wal
|
||||
for a long time. A backup must include them, a restore must reject files
|
||||
that are not healthy SQLite databases, and the live database must stay
|
||||
usable afterwards.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database.models import PromptModel
|
||||
from database.operations import PromptDatabase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
def _count_prompts(path):
|
||||
"""Row count read through a brand-new connection (closed before return)."""
|
||||
conn = sqlite3.connect(path)
|
||||
try:
|
||||
return conn.execute("SELECT COUNT(*) FROM prompts").fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _texts(path):
|
||||
conn = sqlite3.connect(path)
|
||||
try:
|
||||
return sorted(r[0] for r in conn.execute("SELECT text FROM prompts"))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class BackupTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.path = os.path.join(self.tmpdir, "prompts.db")
|
||||
self.db = PromptDatabase(self.path)
|
||||
self.addCleanup(self._cleanup)
|
||||
|
||||
def _cleanup(self):
|
||||
self.db.close_all()
|
||||
for name in os.listdir(self.tmpdir):
|
||||
os.unlink(os.path.join(self.tmpdir, name))
|
||||
os.rmdir(self.tmpdir)
|
||||
|
||||
def _save(self, db, text):
|
||||
return db.save_prompt(text=text, prompt_hash=generate_prompt_hash(text))
|
||||
|
||||
def _make_source_db(self, texts):
|
||||
"""A second database file holding the given prompts, fully closed."""
|
||||
src_path = os.path.join(self.tmpdir, "source.db")
|
||||
src = PromptDatabase(src_path)
|
||||
for text in texts:
|
||||
self._save(src, text)
|
||||
src.close_all()
|
||||
return src_path
|
||||
|
||||
|
||||
class TestBackupDatabase(BackupTestCase):
|
||||
def test_backup_contains_rows_that_only_exist_in_the_wal(self):
|
||||
for n in range(5):
|
||||
self._save(self.db, f"wal prompt {n}")
|
||||
wal = self.path + "-wal"
|
||||
self.assertTrue(os.path.exists(wal) and os.path.getsize(wal) > 0)
|
||||
|
||||
dst = os.path.join(self.tmpdir, "backup.db")
|
||||
self.assertTrue(self.db.model.backup_database(dst))
|
||||
|
||||
self.assertEqual(_count_prompts(dst), 5)
|
||||
self.assertFalse(os.path.exists(dst + "-wal"))
|
||||
# The live database is untouched and still usable
|
||||
self.assertEqual(len(self.db.search_prompts(text="wal prompt")), 5)
|
||||
|
||||
def test_backup_overwrites_a_stale_destination(self):
|
||||
self._save(self.db, "one")
|
||||
dst = os.path.join(self.tmpdir, "backup.db")
|
||||
with open(dst, "wb") as fh:
|
||||
fh.write(b"not a database at all")
|
||||
self.assertTrue(self.db.model.backup_database(dst))
|
||||
self.assertEqual(_count_prompts(dst), 1)
|
||||
|
||||
def test_backup_onto_the_live_file_is_refused(self):
|
||||
self._save(self.db, "one")
|
||||
self.assertFalse(self.db.model.backup_database(self.path))
|
||||
self.assertEqual(len(self.db.search_prompts(text="one")), 1)
|
||||
|
||||
def test_backup_to_an_unwritable_location_returns_false(self):
|
||||
dst = os.path.join(self.tmpdir, "missing", "dir", "backup.db")
|
||||
self.assertFalse(self.db.model.backup_database(dst))
|
||||
|
||||
|
||||
class TestVerifyDatabaseFile(BackupTestCase):
|
||||
def test_text_file_is_rejected_without_raising(self):
|
||||
path = os.path.join(self.tmpdir, "notes.txt")
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write("just some text, definitely not sqlite")
|
||||
ok, reason = PromptModel.verify_database_file(path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("SQLite", reason)
|
||||
|
||||
def test_missing_file_is_rejected(self):
|
||||
ok, reason = PromptModel.verify_database_file(
|
||||
os.path.join(self.tmpdir, "nope.db")
|
||||
)
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(reason)
|
||||
|
||||
def test_database_without_prompts_table_is_rejected(self):
|
||||
path = os.path.join(self.tmpdir, "other.db")
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute("CREATE TABLE something (id INTEGER)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
ok, reason = PromptModel.verify_database_file(path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("prompts", reason)
|
||||
|
||||
def test_prompts_table_missing_required_columns_is_rejected(self):
|
||||
path = os.path.join(self.tmpdir, "partial.db")
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute("CREATE TABLE prompts (id INTEGER PRIMARY KEY, text TEXT)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
ok, reason = PromptModel.verify_database_file(path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("created_at", reason)
|
||||
|
||||
def test_corrupted_database_fails_integrity_check(self):
|
||||
path = self._make_source_db([f"prompt {n}" for n in range(40)])
|
||||
with open(path, "r+b") as fh:
|
||||
page_size = int.from_bytes(fh.read(18)[16:18], "big") or 4096
|
||||
fh.seek(page_size * 2) # scribble over pages 3 and 4 entirely
|
||||
fh.write(b"\xff" * (page_size * 2))
|
||||
ok, reason = PromptModel.verify_database_file(path)
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(reason)
|
||||
|
||||
def test_valid_database_passes(self):
|
||||
path = self._make_source_db(["a", "b"])
|
||||
ok, reason = PromptModel.verify_database_file(path)
|
||||
self.assertTrue(ok, reason)
|
||||
|
||||
def test_verification_does_not_lock_or_modify_the_file(self):
|
||||
path = self._make_source_db(["a"])
|
||||
before = os.path.getsize(path)
|
||||
PromptModel.verify_database_file(path)
|
||||
os.unlink(path) # would fail on Windows if a connection were left open
|
||||
self.assertEqual(before, before)
|
||||
|
||||
|
||||
class TestRestoreFromFile(BackupTestCase):
|
||||
def test_restore_replaces_content_and_backs_up_the_old_database(self):
|
||||
self._save(self.db, "old prompt")
|
||||
src = self._make_source_db(["restored one", "restored two"])
|
||||
|
||||
backup_path = self.db.model.restore_from_file(src)
|
||||
|
||||
self.assertTrue(backup_path.startswith(self.path + ".backup_"))
|
||||
self.assertTrue(os.path.exists(backup_path))
|
||||
self.assertEqual(_texts(backup_path), ["old prompt"])
|
||||
# The restore goes through SQLite's backup API into the live file, so
|
||||
# the WAL stays valid (and shared with other instances) rather than
|
||||
# being deleted under them; the content is what matters.
|
||||
self.assertEqual(_texts(self.path), ["restored one", "restored two"])
|
||||
|
||||
def test_database_is_usable_from_every_thread_after_restore(self):
|
||||
self._save(self.db, "old prompt")
|
||||
src = self._make_source_db(["restored"])
|
||||
|
||||
ready = threading.Event()
|
||||
proceed = threading.Event()
|
||||
results = {}
|
||||
|
||||
def worker():
|
||||
self.db.search_prompts(text="old") # opens this thread's connection
|
||||
ready.set()
|
||||
proceed.wait(5)
|
||||
try:
|
||||
results["texts"] = [p["text"] for p in self.db.search_prompts()]
|
||||
except Exception as exc: # noqa: BLE001 - reported through results
|
||||
results["error"] = exc
|
||||
finally:
|
||||
self.db.close()
|
||||
|
||||
thread = threading.Thread(target=worker)
|
||||
thread.start()
|
||||
ready.wait(5)
|
||||
|
||||
self.db.model.restore_from_file(src)
|
||||
proceed.set()
|
||||
thread.join(5)
|
||||
|
||||
self.assertNotIn("error", results)
|
||||
self.assertEqual(results["texts"], ["restored"])
|
||||
self.assertEqual([p["text"] for p in self.db.search_prompts()], ["restored"])
|
||||
self.assertEqual(self._save(self.db, "after restore"), 2)
|
||||
|
||||
def test_restore_applies_migrations_to_an_old_schema(self):
|
||||
src = os.path.join(self.tmpdir, "legacy.db")
|
||||
conn = sqlite3.connect(src)
|
||||
# The 3.0-era schema: workflow_name still present, no usage columns
|
||||
conn.execute(
|
||||
"CREATE TABLE prompts (id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" text TEXT NOT NULL, created_at TIMESTAMP, updated_at TIMESTAMP,"
|
||||
" category TEXT, tags TEXT, rating INTEGER, notes TEXT,"
|
||||
" hash TEXT UNIQUE, workflow_name TEXT)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO prompts (text, created_at, tags, workflow_name)"
|
||||
" VALUES ('legacy', '2024-01-01T00:00:00', '[\"old\"]', 'wf')"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
self.db.model.restore_from_file(src)
|
||||
|
||||
prompt = self.db.get_prompt_by_id(1)
|
||||
self.assertEqual(prompt["text"], "legacy")
|
||||
self.assertEqual(prompt["tags"], ["old"])
|
||||
self.assertEqual(prompt["run_count"], 1)
|
||||
self.assertNotIn("workflow_name", prompt)
|
||||
self.assertEqual(self.db.get_prompt_images(1), [])
|
||||
|
||||
def test_restore_without_a_live_database_returns_empty_backup_path(self):
|
||||
src = self._make_source_db(["fresh"])
|
||||
self.db.close_all()
|
||||
os.unlink(self.path)
|
||||
for suffix in ("-wal", "-shm"):
|
||||
if os.path.exists(self.path + suffix):
|
||||
os.unlink(self.path + suffix)
|
||||
|
||||
self.assertEqual(self.db.model.restore_from_file(src), "")
|
||||
self.assertEqual(_texts(self.path), ["fresh"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestConnectionsAcrossInstances(BackupTestCase):
|
||||
"""Several PromptModel instances share one file (nodes, API, monitor)."""
|
||||
|
||||
def test_close_all_closes_connections_opened_on_other_threads(self):
|
||||
opened = []
|
||||
|
||||
def open_one():
|
||||
opened.append(self.db.model.get_connection())
|
||||
|
||||
threads = [threading.Thread(target=open_one) for _ in range(3)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
self.assertEqual(len(opened), 3)
|
||||
|
||||
self.db.close_all()
|
||||
|
||||
for conn in opened:
|
||||
with self.assertRaises(sqlite3.ProgrammingError):
|
||||
conn.execute("SELECT 1")
|
||||
|
||||
def test_restore_is_seen_by_another_instance_holding_an_open_connection(self):
|
||||
self.db.save_prompt(text="before", prompt_hash=generate_prompt_hash("before"))
|
||||
snapshot = os.path.join(self.tmpdir, "snapshot.db")
|
||||
other_db = PromptDatabase(snapshot)
|
||||
for i in range(3):
|
||||
other_db.save_prompt(
|
||||
text=f"after {i}", prompt_hash=generate_prompt_hash(f"after {i}")
|
||||
)
|
||||
other_db.close_all()
|
||||
|
||||
# A second instance on the live file, with a connection kept open on
|
||||
# a worker thread for the whole restore (like the image monitor).
|
||||
bystander = PromptDatabase(self.path)
|
||||
ready, go, seen = threading.Event(), threading.Event(), []
|
||||
|
||||
def hold_and_read():
|
||||
bystander.model.get_connection().execute("SELECT COUNT(*) FROM prompts")
|
||||
ready.set()
|
||||
go.wait(10)
|
||||
seen.append(len(bystander.search_prompts(limit=50)))
|
||||
|
||||
worker = threading.Thread(target=hold_and_read)
|
||||
worker.start()
|
||||
self.assertTrue(ready.wait(10))
|
||||
try:
|
||||
self.db.model.restore_from_file(snapshot)
|
||||
finally:
|
||||
go.set()
|
||||
worker.join(10)
|
||||
bystander.close_all()
|
||||
|
||||
self.assertEqual(seen, [3])
|
||||
self.assertEqual(sorted(_texts(self.path)), ["after 0", "after 1", "after 2"])
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Concurrent access to PromptDatabase from several threads.
|
||||
|
||||
ComfyUI touches the database from the server loop, executor threads, the
|
||||
watchdog image monitor and the queue hook at the same time. Every thread must
|
||||
get its own connection so one thread's commit or rollback never lands on
|
||||
another thread's half-done write.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
THREADS = 8
|
||||
CALLS_PER_THREAD = 50
|
||||
|
||||
|
||||
class ConcurrencyTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
fd, self.path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
self.db = PromptDatabase(self.path)
|
||||
self.addCleanup(self._cleanup)
|
||||
|
||||
def _cleanup(self):
|
||||
self.db.close_all()
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
if os.path.exists(self.path + suffix):
|
||||
os.unlink(self.path + suffix)
|
||||
|
||||
def _run_threads(self, target):
|
||||
errors = []
|
||||
|
||||
def wrapped(index):
|
||||
try:
|
||||
target(index)
|
||||
except Exception as exc: # noqa: BLE001 - collected for the assertion
|
||||
errors.append(exc)
|
||||
finally:
|
||||
self.db.close()
|
||||
|
||||
threads = [threading.Thread(target=wrapped, args=(i,)) for i in range(THREADS)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
return errors
|
||||
|
||||
|
||||
class TestPerThreadConnections(ConcurrencyTestCase):
|
||||
def test_each_thread_gets_its_own_connection(self):
|
||||
main_conn = self.db.model.get_connection()
|
||||
self.assertIs(self.db.model.get_connection(), main_conn)
|
||||
|
||||
seen = []
|
||||
|
||||
def grab(_index):
|
||||
first = self.db.model.get_connection()
|
||||
seen.append((first, self.db.model.get_connection()))
|
||||
|
||||
self.assertEqual(self._run_threads(grab), [])
|
||||
self.assertEqual(len(seen), THREADS)
|
||||
for first, second in seen:
|
||||
self.assertIs(first, second)
|
||||
self.assertIsNot(first, main_conn)
|
||||
self.assertEqual(len({id(first) for first, _ in seen}), THREADS)
|
||||
|
||||
def test_close_releases_only_the_calling_threads_connection(self):
|
||||
main_conn = self.db.model.get_connection()
|
||||
other = []
|
||||
|
||||
def grab(_index):
|
||||
other.append(self.db.model.get_connection())
|
||||
self.db.close()
|
||||
|
||||
self.assertEqual(self._run_threads(grab), [])
|
||||
# The main thread's connection survives closes made by other threads
|
||||
self.assertIs(self.db.model.get_connection(), main_conn)
|
||||
main_conn.execute("SELECT 1").fetchone()
|
||||
self.db.close()
|
||||
self.assertIsNot(self.db.model.get_connection(), main_conn)
|
||||
|
||||
|
||||
class TestConcurrentWrites(ConcurrencyTestCase):
|
||||
def test_distinct_prompts_saved_and_counted_from_many_threads(self):
|
||||
def work(index):
|
||||
for n in range(CALLS_PER_THREAD):
|
||||
text = f"thread {index} prompt {n}"
|
||||
pid = self.db.save_prompt(
|
||||
text=text, prompt_hash=generate_prompt_hash(text)
|
||||
)
|
||||
self.assertTrue(self.db.record_prompt_use(pid))
|
||||
|
||||
self.assertEqual(self._run_threads(work), [])
|
||||
|
||||
info = self.db.model.get_database_info()
|
||||
self.assertEqual(info["total_prompts"], THREADS * CALLS_PER_THREAD)
|
||||
prompts = self.db.search_prompts(limit=THREADS * CALLS_PER_THREAD + 1)
|
||||
self.assertEqual(len(prompts), THREADS * CALLS_PER_THREAD)
|
||||
self.assertTrue(all(p["run_count"] == 1 for p in prompts))
|
||||
|
||||
def test_saving_the_same_prompt_from_many_threads_keeps_one_row(self):
|
||||
text = "the same prompt from every thread"
|
||||
prompt_hash = generate_prompt_hash(text)
|
||||
ids = []
|
||||
|
||||
def work(_index):
|
||||
for _ in range(CALLS_PER_THREAD):
|
||||
pid = self.db.save_prompt(text=text, prompt_hash=prompt_hash)
|
||||
ids.append(pid)
|
||||
self.assertTrue(self.db.record_prompt_use(pid))
|
||||
|
||||
self.assertEqual(self._run_threads(work), [])
|
||||
|
||||
self.assertEqual(len(ids), THREADS * CALLS_PER_THREAD)
|
||||
self.assertEqual(len(set(ids)), 1)
|
||||
self.assertEqual(self.db.model.get_database_info()["total_prompts"], 1)
|
||||
prompt = self.db.get_prompt_by_hash(prompt_hash)
|
||||
self.assertEqual(prompt["id"], ids[0])
|
||||
self.assertEqual(prompt["run_count"], THREADS * CALLS_PER_THREAD)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Schema initialisation and migration behaviour of PromptModel.
|
||||
|
||||
Every PromptDatabase() construction used to re-run table creation and all
|
||||
migrations, migrations swallowed their errors, and a rebuild that failed
|
||||
half-way left a prompts_new table behind that made every later start fail.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database.models import PromptModel
|
||||
from database.operations import PromptDatabase
|
||||
from database.models import normalize_image_path # noqa: E402
|
||||
|
||||
LEGACY_PROMPTS_TABLE = (
|
||||
"CREATE TABLE prompts (id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" text TEXT NOT NULL, created_at TIMESTAMP, updated_at TIMESTAMP,"
|
||||
" category TEXT, tags TEXT, rating INTEGER, notes TEXT,"
|
||||
" hash TEXT UNIQUE, workflow_name TEXT)"
|
||||
)
|
||||
LEGACY_IMAGES_TABLE = (
|
||||
"CREATE TABLE generated_images (id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" prompt_id INTEGER NOT NULL, image_path TEXT NOT NULL,"
|
||||
" filename TEXT NOT NULL, generation_time TIMESTAMP, file_size INTEGER,"
|
||||
" width INTEGER, height INTEGER, format TEXT, workflow_data TEXT,"
|
||||
" prompt_metadata TEXT, parameters TEXT,"
|
||||
" FOREIGN KEY (prompt_id) REFERENCES prompts(id) ON DELETE CASCADE)"
|
||||
)
|
||||
|
||||
|
||||
class MigrationTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.path = os.path.join(self.tmpdir, "prompts.db")
|
||||
self.dbs = []
|
||||
self.addCleanup(self._cleanup)
|
||||
|
||||
def _cleanup(self):
|
||||
for db in self.dbs:
|
||||
db.close_all()
|
||||
PromptModel.reset_schema_cache()
|
||||
for name in os.listdir(self.tmpdir):
|
||||
os.unlink(os.path.join(self.tmpdir, name))
|
||||
os.rmdir(self.tmpdir)
|
||||
|
||||
def _open(self):
|
||||
db = PromptDatabase(self.path)
|
||||
self.dbs.append(db)
|
||||
return db
|
||||
|
||||
def _legacy_db(self, statements):
|
||||
"""A 3.0-era database file (workflow_name column, no usage columns)."""
|
||||
conn = sqlite3.connect(self.path)
|
||||
try:
|
||||
conn.execute(LEGACY_PROMPTS_TABLE)
|
||||
conn.execute(LEGACY_IMAGES_TABLE)
|
||||
for sql in statements:
|
||||
conn.execute(sql)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _columns(self, table):
|
||||
conn = sqlite3.connect(self.path)
|
||||
try:
|
||||
return [r[1] for r in conn.execute(f"PRAGMA table_info({table})")]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestSchemaInitOncePerProcess(MigrationTestCase):
|
||||
def _spy(self):
|
||||
original = PromptModel._create_tables
|
||||
calls = []
|
||||
|
||||
def spy(model, conn):
|
||||
calls.append(model.db_path)
|
||||
return original(model, conn)
|
||||
|
||||
return patch.object(PromptModel, "_create_tables", spy), calls
|
||||
|
||||
def test_second_construction_on_same_path_skips_schema_init(self):
|
||||
patcher, calls = self._spy()
|
||||
with patcher:
|
||||
self._open()
|
||||
self._open()
|
||||
# Same file spelled differently still counts as the same path
|
||||
self.dbs.append(
|
||||
PromptDatabase(os.path.join(self.tmpdir, ".", "prompts.db"))
|
||||
)
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
def test_reset_schema_cache_forces_init_again(self):
|
||||
patcher, calls = self._spy()
|
||||
with patcher:
|
||||
self._open()
|
||||
PromptModel.reset_schema_cache()
|
||||
self._open()
|
||||
self.assertEqual(len(calls), 2)
|
||||
|
||||
def test_deleted_database_file_is_recreated_on_next_construction(self):
|
||||
db = self._open()
|
||||
db.close_all()
|
||||
os.unlink(self.path)
|
||||
for suffix in ("-wal", "-shm"):
|
||||
if os.path.exists(self.path + suffix):
|
||||
os.unlink(self.path + suffix)
|
||||
|
||||
reopened = self._open()
|
||||
self.assertEqual(reopened.model.get_database_info()["total_prompts"], 0)
|
||||
self.assertIn("prompts", self._tables())
|
||||
|
||||
def test_close_then_unlink_succeeds(self):
|
||||
db = self._open()
|
||||
db.save_prompt(text="x", prompt_hash="x")
|
||||
db.close_all()
|
||||
os.unlink(self.path)
|
||||
self.assertFalse(os.path.exists(self.path))
|
||||
|
||||
def _tables(self):
|
||||
conn = sqlite3.connect(self.path)
|
||||
try:
|
||||
return [
|
||||
r[0]
|
||||
for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
)
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestIdempotentMigrations(MigrationTestCase):
|
||||
def test_stale_prompts_new_from_a_failed_rebuild_does_not_block_startup(self):
|
||||
self._legacy_db(
|
||||
[
|
||||
"INSERT INTO prompts (text, hash, workflow_name)"
|
||||
" VALUES ('a', 'h1', 'wf')",
|
||||
"CREATE TABLE prompts_new (id INTEGER PRIMARY KEY, junk TEXT)",
|
||||
"INSERT INTO prompts_new (junk) VALUES ('leftover')",
|
||||
]
|
||||
)
|
||||
|
||||
db = self._open()
|
||||
|
||||
self.assertNotIn("workflow_name", self._columns("prompts"))
|
||||
self.assertEqual(db.get_prompt_by_id(1)["text"], "a")
|
||||
self.assertNotIn("prompts_new", self._table_names())
|
||||
|
||||
def test_rebuild_keeps_linked_images(self):
|
||||
# DROP TABLE prompts with foreign keys ON cascades into generated_images
|
||||
self._legacy_db(
|
||||
[
|
||||
"INSERT INTO prompts (text, hash, workflow_name)"
|
||||
" VALUES ('a', 'h1', 'wf')",
|
||||
"INSERT INTO generated_images (prompt_id, image_path, filename)"
|
||||
" VALUES (1, '/out/a.png', 'a.png')",
|
||||
]
|
||||
)
|
||||
|
||||
db = self._open()
|
||||
|
||||
self.assertNotIn("workflow_name", self._columns("prompts"))
|
||||
self.assertEqual(len(db.get_prompt_images(1)), 1)
|
||||
|
||||
def test_migration_error_is_logged_and_raised(self):
|
||||
# The legacy table has no CHECK on rating; the rebuilt one does, so the
|
||||
# copy into prompts_new fails inside the migration.
|
||||
self._legacy_db(
|
||||
[
|
||||
"INSERT INTO prompts (text, hash, rating, workflow_name)"
|
||||
" VALUES ('a', 'h1', 9, 'wf')",
|
||||
]
|
||||
)
|
||||
|
||||
with self.assertLogs("prompt_manager.database.models", level="ERROR") as logs:
|
||||
with self.assertRaises(sqlite3.Error):
|
||||
self._open()
|
||||
|
||||
self.assertTrue(any("igration" in line for line in logs.output))
|
||||
# Nothing was half-applied: the old table is intact and usable
|
||||
self.assertIn("workflow_name", self._columns("prompts"))
|
||||
self.assertNotIn("prompts_new", self._table_names())
|
||||
|
||||
def test_text_prompt_ids_in_images_become_integers(self):
|
||||
conn = sqlite3.connect(self.path)
|
||||
try:
|
||||
conn.execute(LEGACY_PROMPTS_TABLE)
|
||||
conn.execute(
|
||||
LEGACY_IMAGES_TABLE.replace("prompt_id INTEGER", "prompt_id TEXT")
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO prompts (text, hash, workflow_name)"
|
||||
" VALUES ('a', 'h1', 'wf')"
|
||||
)
|
||||
for prompt_id, name in (
|
||||
("1", "ok.png"),
|
||||
("", "blank.png"),
|
||||
("7", "gone.png"),
|
||||
):
|
||||
conn.execute(
|
||||
"INSERT INTO generated_images (prompt_id, image_path, filename)"
|
||||
" VALUES (?, ?, ?)",
|
||||
(prompt_id, "/out/" + name, name),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
db = self._open()
|
||||
|
||||
conn = sqlite3.connect(self.path)
|
||||
try:
|
||||
types_ = {
|
||||
r[1]: r[2] for r in conn.execute("PRAGMA table_info(generated_images)")
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
self.assertEqual(types_["prompt_id"], "INTEGER")
|
||||
self.assertEqual([i["filename"] for i in db.get_prompt_images(1)], ["ok.png"])
|
||||
self.assertEqual(db.get_statistics()["total_images"], 1)
|
||||
|
||||
def test_migrated_database_opens_cleanly_a_second_time(self):
|
||||
self._legacy_db(
|
||||
["INSERT INTO prompts (text, hash, workflow_name) VALUES ('a', 'h1', 'wf')"]
|
||||
)
|
||||
self._open().close()
|
||||
PromptModel.reset_schema_cache()
|
||||
db = self._open()
|
||||
self.assertEqual(db.get_prompt_by_id(1)["text"], "a")
|
||||
|
||||
def test_image_table_is_rebuilt_with_uniqueness_by_full_path(self):
|
||||
self._legacy_db(
|
||||
[
|
||||
"INSERT INTO prompts (text, hash, workflow_name)"
|
||||
" VALUES ('a', 'h1', 'wf')",
|
||||
# same basename in two folders: both must survive
|
||||
"INSERT INTO generated_images (prompt_id, image_path, filename)"
|
||||
" VALUES (1, '/out/2026-01-01/a.png', 'a.png')",
|
||||
"INSERT INTO generated_images (prompt_id, image_path, filename)"
|
||||
" VALUES (1, '/out/2026-01-02/a.png', 'a.png')",
|
||||
# two spellings of one file: only the newest row survives
|
||||
"INSERT INTO generated_images (prompt_id, image_path, filename)"
|
||||
" VALUES (1, '/out/x/../b.png', 'b.png')",
|
||||
"INSERT INTO generated_images (prompt_id, image_path, filename)"
|
||||
" VALUES (1, '/out/b.png', 'b.png')",
|
||||
]
|
||||
)
|
||||
|
||||
db = self._open()
|
||||
|
||||
images = db.get_prompt_images(1)
|
||||
self.assertEqual(len(images), 3)
|
||||
self.assertIn("file_path", self._columns("generated_images"))
|
||||
self.assertEqual(
|
||||
sorted(i["file_path"] for i in images),
|
||||
sorted(
|
||||
normalize_image_path(p)
|
||||
for p in (
|
||||
"/out/2026-01-01/a.png",
|
||||
"/out/2026-01-02/a.png",
|
||||
"/out/b.png",
|
||||
)
|
||||
),
|
||||
)
|
||||
self.assertEqual([i["id"] for i in images if i["filename"] == "b.png"], [4])
|
||||
self.assertEqual(
|
||||
self._unique_columns("generated_images"), ["file_path", "prompt_id"]
|
||||
)
|
||||
|
||||
def test_image_uniqueness_migration_is_not_undone_on_the_next_start(self):
|
||||
self._legacy_db(
|
||||
["INSERT INTO prompts (text, hash, workflow_name) VALUES ('a', 'h1', 'wf')"]
|
||||
)
|
||||
self._open().close()
|
||||
PromptModel.reset_schema_cache()
|
||||
db = self._open()
|
||||
self.assertEqual(
|
||||
self._unique_columns("generated_images"), ["file_path", "prompt_id"]
|
||||
)
|
||||
self.assertTrue(db.link_image_to_prompt(1, "/out/1/a.png"))
|
||||
self.assertTrue(db.link_image_to_prompt(1, "/out/2/a.png"))
|
||||
self.assertEqual(len(db.get_prompt_images(1)), 2)
|
||||
|
||||
def _unique_columns(self, table):
|
||||
conn = sqlite3.connect(self.path)
|
||||
try:
|
||||
columns = []
|
||||
for idx in conn.execute(f"PRAGMA index_list({table})"):
|
||||
if idx[2] == 1:
|
||||
columns.extend(
|
||||
c[2] for c in conn.execute(f"PRAGMA index_info({idx[1]})")
|
||||
)
|
||||
return sorted(columns)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _table_names(self):
|
||||
conn = sqlite3.connect(self.path)
|
||||
try:
|
||||
return [
|
||||
r[0]
|
||||
for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
)
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Tests for issue #119: Database path configuration.
|
||||
|
||||
Verifies that _resolve_db_path() correctly reads from config, resolves
|
||||
relative paths against the extension root, and handles absolute paths.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database.operations import _resolve_db_path
|
||||
|
||||
EXTENSION_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
class TestResolveDbPath(unittest.TestCase):
|
||||
"""Test _resolve_db_path() database path resolution."""
|
||||
|
||||
def test_explicit_absolute_path_used_as_is(self):
|
||||
"""An explicit absolute path should be returned unchanged."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = os.path.join(tmpdir, "custom.db")
|
||||
result = _resolve_db_path(path)
|
||||
self.assertEqual(result, path)
|
||||
|
||||
def test_explicit_relative_path_resolved_to_extension_root(self):
|
||||
"""An explicit relative path should resolve against the extension root."""
|
||||
result = _resolve_db_path("data/my.db")
|
||||
expected = os.path.join(EXTENSION_ROOT, "data", "my.db")
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
def test_none_falls_back_to_default_without_config(self):
|
||||
"""Without config (outside ComfyUI), should default to prompts.db."""
|
||||
# py.config requires ComfyUI's server module, so import fails naturally
|
||||
result = _resolve_db_path(None)
|
||||
expected = os.path.join(EXTENSION_ROOT, "prompts.db")
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
def test_none_reads_config_when_available(self):
|
||||
"""None should read DEFAULT_DB_PATH from config when importable."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
custom_path = os.path.join(tmpdir, "prompts.db")
|
||||
fake_config_mod = types.ModuleType("py.config")
|
||||
fake_pm_config = type(
|
||||
"PromptManagerConfig", (), {"DEFAULT_DB_PATH": custom_path}
|
||||
)
|
||||
fake_config_mod.PromptManagerConfig = fake_pm_config
|
||||
|
||||
with patch.dict(sys.modules, {"py.config": fake_config_mod}):
|
||||
result = _resolve_db_path(None)
|
||||
|
||||
self.assertEqual(result, custom_path)
|
||||
|
||||
def test_config_relative_path_resolved_to_extension_root(self):
|
||||
"""A relative path from config should resolve against extension root."""
|
||||
fake_config_mod = types.ModuleType("py.config")
|
||||
fake_pm_config = type(
|
||||
"PromptManagerConfig", (), {"DEFAULT_DB_PATH": "custom_data/prompts.db"}
|
||||
)
|
||||
fake_config_mod.PromptManagerConfig = fake_pm_config
|
||||
|
||||
with patch.dict(sys.modules, {"py.config": fake_config_mod}):
|
||||
result = _resolve_db_path(None)
|
||||
|
||||
expected = os.path.join(EXTENSION_ROOT, "custom_data", "prompts.db")
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
def test_result_is_always_absolute(self):
|
||||
"""Result should always be an absolute path."""
|
||||
result = _resolve_db_path("prompts.db")
|
||||
self.assertTrue(os.path.isabs(result))
|
||||
|
||||
def test_parent_directory_created(self):
|
||||
"""Parent directories should be created for custom paths."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
nested = os.path.join(tmpdir, "deep", "nested", "prompts.db")
|
||||
result = _resolve_db_path(nested)
|
||||
self.assertEqual(result, nested)
|
||||
self.assertTrue(os.path.isdir(os.path.join(tmpdir, "deep", "nested")))
|
||||
|
||||
|
||||
class TestPromptDatabaseUsesConfig(unittest.TestCase):
|
||||
"""Test that PromptDatabase() picks up the resolved path."""
|
||||
|
||||
@patch("database.operations._resolve_db_path")
|
||||
def test_constructor_calls_resolve_with_none(self, mock_resolve):
|
||||
"""PromptDatabase() should call _resolve_db_path with None."""
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as f:
|
||||
mock_resolve.return_value = f.name
|
||||
|
||||
try:
|
||||
from database.operations import PromptDatabase
|
||||
|
||||
PromptDatabase()
|
||||
mock_resolve.assert_called_once_with(None)
|
||||
finally:
|
||||
os.unlink(f.name)
|
||||
|
||||
@patch("database.operations._resolve_db_path")
|
||||
def test_constructor_passes_explicit_path(self, mock_resolve):
|
||||
"""PromptDatabase(path) should call _resolve_db_path with that path."""
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as f:
|
||||
mock_resolve.return_value = f.name
|
||||
|
||||
try:
|
||||
from database.operations import PromptDatabase
|
||||
|
||||
PromptDatabase("/explicit/path.db")
|
||||
mock_resolve.assert_called_once_with("/explicit/path.db")
|
||||
finally:
|
||||
os.unlink(f.name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
Tests for issue #132: Diagnostics, backup, and restore must respect config.json.
|
||||
|
||||
Verifies that:
|
||||
- GalleryDiagnostics resolves db_path from config (not hardcoded "prompts.db")
|
||||
- The admin API diagnostics/backup/restore endpoints use the configured db_path
|
||||
- Output directory checks consult GalleryConfig before falling back to heuristics
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from contextlib import closing
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
EXTENSION_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GalleryDiagnostics path resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestGalleryDiagnosticsDbPath(unittest.TestCase):
|
||||
"""GalleryDiagnostics should resolve db_path from config, not hardcode it."""
|
||||
|
||||
def test_default_resolves_from_config(self):
|
||||
"""GalleryDiagnostics() with no args should read config like PromptDatabase."""
|
||||
from utils.diagnostics import GalleryDiagnostics
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
custom_path = os.path.join(tmpdir, "custom.db")
|
||||
fake_config_mod = types.ModuleType("py.config")
|
||||
fake_pm_config = type(
|
||||
"PromptManagerConfig", (), {"DEFAULT_DB_PATH": custom_path}
|
||||
)
|
||||
fake_config_mod.PromptManagerConfig = fake_pm_config
|
||||
|
||||
with patch.dict(sys.modules, {"py.config": fake_config_mod}):
|
||||
diag = GalleryDiagnostics()
|
||||
|
||||
self.assertEqual(diag.db_path, custom_path)
|
||||
|
||||
def test_default_without_config_falls_back_to_extension_root(self):
|
||||
"""Without config, should default to prompts.db in extension root."""
|
||||
from utils.diagnostics import GalleryDiagnostics
|
||||
|
||||
diag = GalleryDiagnostics()
|
||||
expected = os.path.join(EXTENSION_ROOT, "prompts.db")
|
||||
self.assertEqual(diag.db_path, expected)
|
||||
|
||||
def test_explicit_path_overrides_config(self):
|
||||
"""An explicit db_path argument should be used as-is."""
|
||||
from utils.diagnostics import GalleryDiagnostics
|
||||
|
||||
diag = GalleryDiagnostics("/explicit/path.db")
|
||||
self.assertEqual(diag.db_path, "/explicit/path.db")
|
||||
|
||||
def test_config_relative_path_resolved_to_extension_root(self):
|
||||
"""A relative path from config should resolve against extension root."""
|
||||
from utils.diagnostics import GalleryDiagnostics
|
||||
|
||||
fake_config_mod = types.ModuleType("py.config")
|
||||
fake_pm_config = type(
|
||||
"PromptManagerConfig", (), {"DEFAULT_DB_PATH": "data/prompts.db"}
|
||||
)
|
||||
fake_config_mod.PromptManagerConfig = fake_pm_config
|
||||
|
||||
with patch.dict(sys.modules, {"py.config": fake_config_mod}):
|
||||
diag = GalleryDiagnostics()
|
||||
|
||||
expected = os.path.join(EXTENSION_ROOT, "data", "prompts.db")
|
||||
self.assertEqual(diag.db_path, expected)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin API endpoints use self.db.model.db_path
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAdminEndpointsUseConfigPath(unittest.TestCase):
|
||||
"""Admin diagnostics, backup, and restore must use the configured db_path."""
|
||||
|
||||
def _make_api_stub(self, db_path):
|
||||
"""Create a minimal object that mimics the admin mixin's self."""
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
stub = MagicMock()
|
||||
stub.db.model.db_path = db_path
|
||||
stub.logger = MagicMock()
|
||||
stub._public_path = PromptManagerAPI._public_path
|
||||
stub._public_error = PromptManagerAPI._public_error
|
||||
|
||||
async def run_in_executor(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
stub._run_in_executor = run_in_executor
|
||||
return stub
|
||||
|
||||
def _run_async(self, coro):
|
||||
"""Run a coroutine on a fresh event loop to avoid aiohttp pollution."""
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
def test_run_diagnostics_uses_model_db_path(self):
|
||||
"""run_diagnostics should check self.db.model.db_path, not 'prompts.db'."""
|
||||
if "server" not in sys.modules:
|
||||
sys.modules["server"] = MagicMock()
|
||||
|
||||
from py.api.admin import AdminRoutesMixin
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as f:
|
||||
db_file = f.name
|
||||
|
||||
try:
|
||||
import sqlite3
|
||||
|
||||
with closing(sqlite3.connect(db_file)) as conn, conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE prompts "
|
||||
"(id INTEGER PRIMARY KEY, text TEXT, created_at TEXT)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO prompts (text, created_at) "
|
||||
"VALUES ('test', '2024-01-01')"
|
||||
)
|
||||
|
||||
stub = self._make_api_stub(db_file)
|
||||
result = self._run_async(
|
||||
AdminRoutesMixin.run_diagnostics(stub, MagicMock())
|
||||
)
|
||||
|
||||
body = json.loads(result.body)
|
||||
self.assertTrue(body["success"])
|
||||
self.assertEqual(body["diagnostics"]["database"]["status"], "ok")
|
||||
self.assertEqual(body["diagnostics"]["database"]["prompt_count"], 1)
|
||||
finally:
|
||||
os.unlink(db_file)
|
||||
|
||||
def test_run_diagnostics_reports_missing_custom_path(self):
|
||||
"""If the configured db_path doesn't exist, diagnostics should report error."""
|
||||
if "server" not in sys.modules:
|
||||
sys.modules["server"] = MagicMock()
|
||||
|
||||
from py.api.admin import AdminRoutesMixin
|
||||
|
||||
stub = self._make_api_stub("/nonexistent/custom/prompts.db")
|
||||
|
||||
result = self._run_async(AdminRoutesMixin.run_diagnostics(stub, MagicMock()))
|
||||
body = json.loads(result.body)
|
||||
self.assertTrue(body["success"])
|
||||
self.assertEqual(body["diagnostics"]["database"]["status"], "error")
|
||||
# The message names the file but never the absolute server path.
|
||||
message = body["diagnostics"]["database"]["message"]
|
||||
self.assertIn("prompts.db", message)
|
||||
self.assertNotIn("/nonexistent/custom", message)
|
||||
|
||||
def test_backup_uses_model_db_path(self):
|
||||
"""backup_database should read from self.db.model.db_path."""
|
||||
if "server" not in sys.modules:
|
||||
sys.modules["server"] = MagicMock()
|
||||
|
||||
from py.api.admin import AdminRoutesMixin
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as f:
|
||||
db_file = f.name
|
||||
|
||||
# A WAL-safe backup copies pages through sqlite itself, so the
|
||||
# configured path has to hold a real database, not arbitrary bytes.
|
||||
real_db = PromptDatabase(db_file)
|
||||
try:
|
||||
stub = self._make_api_stub(db_file)
|
||||
stub.db.model = real_db.model
|
||||
result = self._run_async(
|
||||
AdminRoutesMixin.backup_database(stub, MagicMock())
|
||||
)
|
||||
|
||||
self.assertEqual(result.content_type, "application/octet-stream")
|
||||
self.assertGreater(len(result.body), 0)
|
||||
self.assertTrue(result.body.startswith(b"SQLite format 3\x00"))
|
||||
finally:
|
||||
real_db.close_all()
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
if os.path.exists(db_file + suffix):
|
||||
os.unlink(db_file + suffix)
|
||||
|
||||
def test_backup_reports_missing_custom_path(self):
|
||||
"""backup_database should return 404 when configured path doesn't exist."""
|
||||
if "server" not in sys.modules:
|
||||
sys.modules["server"] = MagicMock()
|
||||
|
||||
from py.api.admin import AdminRoutesMixin
|
||||
|
||||
stub = self._make_api_stub("/nonexistent/custom/prompts.db")
|
||||
|
||||
result = self._run_async(AdminRoutesMixin.backup_database(stub, MagicMock()))
|
||||
body = json.loads(result.body)
|
||||
self.assertFalse(body["success"])
|
||||
self.assertEqual(result.status, 404)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
Tests for issue #123: UnboundLocalError when Text input is empty.
|
||||
|
||||
Verifies that PromptManagerText.process_text() and PromptManager.encode_prompt()
|
||||
handle empty/blank text inputs without raising UnboundLocalError on extended_tags.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
@patch("prompt_manager_base.get_image_monitor")
|
||||
@patch("prompt_manager_base.get_prompt_tracker")
|
||||
@patch("prompt_manager_base.get_comfyui_integration")
|
||||
@patch("prompt_manager_base.PromptDatabase")
|
||||
class TestPromptManagerTextEmptyInput(unittest.TestCase):
|
||||
"""Test PromptManagerText with empty/blank text inputs (issue #123)."""
|
||||
|
||||
def _make_node(self, mock_db_class, mock_integration, mock_tracker, mock_monitor):
|
||||
mock_db = Mock()
|
||||
mock_db_class.return_value = mock_db
|
||||
mock_db.save_prompt.return_value = 1
|
||||
mock_db.get_prompt_by_hash.return_value = None
|
||||
|
||||
from prompt_manager_text import PromptManagerText
|
||||
|
||||
return PromptManagerText()
|
||||
|
||||
def test_empty_string(self, mock_db, mock_integ, mock_tracker, mock_monitor):
|
||||
"""Empty string should not raise UnboundLocalError."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
result = node.process_text(text="")
|
||||
self.assertIsInstance(result, tuple)
|
||||
self.assertEqual(result[0], "")
|
||||
|
||||
def test_whitespace_only(self, mock_db, mock_integ, mock_tracker, mock_monitor):
|
||||
"""Whitespace-only string should not raise UnboundLocalError."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
result = node.process_text(text=" ")
|
||||
self.assertIsInstance(result, tuple)
|
||||
|
||||
def test_none_coerced_empty(self, mock_db, mock_integ, mock_tracker, mock_monitor):
|
||||
"""Falsy text value should not raise UnboundLocalError."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
# ComfyUI may pass empty string for unconnected inputs
|
||||
result = node.process_text(text="", category="", tags="")
|
||||
self.assertIsInstance(result, tuple)
|
||||
|
||||
def test_empty_text_with_prepend_append(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
"""Empty main text with prepend/append should produce single space, not
|
||||
double."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
result = node.process_text(text="", prepend_text="before", append_text="after")
|
||||
self.assertIsInstance(result, tuple)
|
||||
self.assertEqual(result[0], "before after")
|
||||
self.assertNotIn(" ", result[0])
|
||||
|
||||
def test_valid_text_still_works(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
"""Normal text input should continue to work correctly."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
result = node.process_text(text="a beautiful sunset")
|
||||
self.assertIsInstance(result, tuple)
|
||||
self.assertEqual(result[0], "a beautiful sunset")
|
||||
|
||||
def test_prepend_text_append_all_present(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
"""All three parts should combine with single spaces."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
result = node.process_text(
|
||||
text="main prompt", prepend_text="before", append_text="after"
|
||||
)
|
||||
self.assertEqual(result[0], "before main prompt after")
|
||||
|
||||
def test_only_prepend(self, mock_db, mock_integ, mock_tracker, mock_monitor):
|
||||
"""Prepend with empty text should output just prepend."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
result = node.process_text(text="", prepend_text="before")
|
||||
self.assertEqual(result[0], "before")
|
||||
|
||||
|
||||
@patch("prompt_manager_base.get_image_monitor")
|
||||
@patch("prompt_manager_base.get_prompt_tracker")
|
||||
@patch("prompt_manager_base.get_comfyui_integration")
|
||||
@patch("prompt_manager_base.PromptDatabase")
|
||||
class TestPromptManagerEmptyInput(unittest.TestCase):
|
||||
"""Test PromptManager (CLIP variant) with empty/blank text inputs (issue #123)."""
|
||||
|
||||
def _make_node_and_clip(
|
||||
self, mock_db_class, mock_integration, mock_tracker, mock_monitor
|
||||
):
|
||||
mock_db = Mock()
|
||||
mock_db_class.return_value = mock_db
|
||||
mock_db.save_prompt.return_value = 1
|
||||
mock_db.get_prompt_by_hash.return_value = None
|
||||
|
||||
mock_clip = Mock()
|
||||
mock_clip.tokenize.return_value = "mock_tokens"
|
||||
mock_clip.encode_from_tokens_scheduled.return_value = "mock_cond"
|
||||
|
||||
from prompt_manager import PromptManager
|
||||
|
||||
return PromptManager(), mock_clip
|
||||
|
||||
def test_empty_string(self, mock_db, mock_integ, mock_tracker, mock_monitor):
|
||||
"""Empty string should not raise UnboundLocalError."""
|
||||
node, clip = self._make_node_and_clip(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
result = node.encode_prompt(clip=clip, text="")
|
||||
self.assertIsInstance(result, tuple)
|
||||
|
||||
def test_whitespace_only(self, mock_db, mock_integ, mock_tracker, mock_monitor):
|
||||
"""Whitespace-only string should not raise UnboundLocalError."""
|
||||
node, clip = self._make_node_and_clip(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
result = node.encode_prompt(clip=clip, text=" ")
|
||||
self.assertIsInstance(result, tuple)
|
||||
|
||||
def test_empty_text_with_prepend_append(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
"""Empty main text with prepend/append should produce single space, not
|
||||
double."""
|
||||
node, clip = self._make_node_and_clip(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
result = node.encode_prompt(
|
||||
clip=clip, text="", prepend_text="before", append_text="after"
|
||||
)
|
||||
self.assertIsInstance(result, tuple)
|
||||
self.assertEqual(result[1], "before after")
|
||||
self.assertNotIn(" ", result[1])
|
||||
|
||||
def test_valid_text_still_works(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
"""Normal text input should continue to work correctly."""
|
||||
node, clip = self._make_node_and_clip(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
result = node.encode_prompt(clip=clip, text="a beautiful sunset")
|
||||
self.assertIsInstance(result, tuple)
|
||||
self.assertEqual(result[1], "a beautiful sunset")
|
||||
|
||||
def test_prepend_text_append_all_present(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
"""All three parts should combine with single spaces."""
|
||||
node, clip = self._make_node_and_clip(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
result = node.encode_prompt(
|
||||
clip=clip, text="main prompt", prepend_text="before", append_text="after"
|
||||
)
|
||||
self.assertEqual(result[1], "before main prompt after")
|
||||
|
||||
|
||||
@patch("prompt_manager_base.get_image_monitor")
|
||||
@patch("prompt_manager_base.get_prompt_tracker")
|
||||
@patch("prompt_manager_base.get_comfyui_integration")
|
||||
@patch("prompt_manager_base.PromptDatabase")
|
||||
class TestConnectedTextInput(unittest.TestCase):
|
||||
"""Test that externally connected text is preserved in output (issue #123).
|
||||
|
||||
When text comes from a connected node (not the widget), the Python side
|
||||
must include it in the final output. The JS fix ensures the connected
|
||||
value reaches Python; these tests verify Python handles it correctly.
|
||||
"""
|
||||
|
||||
def _make_text_node(self, mock_db_class, mock_integ, mock_tracker, mock_monitor):
|
||||
mock_db = Mock()
|
||||
mock_db_class.return_value = mock_db
|
||||
mock_db.save_prompt.return_value = 1
|
||||
mock_db.get_prompt_by_hash.return_value = None
|
||||
from prompt_manager_text import PromptManagerText
|
||||
|
||||
return PromptManagerText()
|
||||
|
||||
def _make_clip_node(self, mock_db_class, mock_integ, mock_tracker, mock_monitor):
|
||||
mock_db = Mock()
|
||||
mock_db_class.return_value = mock_db
|
||||
mock_db.save_prompt.return_value = 1
|
||||
mock_db.get_prompt_by_hash.return_value = None
|
||||
mock_clip = Mock()
|
||||
mock_clip.tokenize.return_value = "mock_tokens"
|
||||
mock_clip.encode_from_tokens_scheduled.return_value = "mock_cond"
|
||||
from prompt_manager import PromptManager
|
||||
|
||||
return PromptManager(), mock_clip
|
||||
|
||||
def test_text_node_connected_text_with_prepend_append(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
"""Simulates screenshot: connected text + prepend + append must all appear."""
|
||||
node = self._make_text_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
result = node.process_text(
|
||||
text="This is the prompt",
|
||||
prepend_text="This is prepend text",
|
||||
append_text="This is append text",
|
||||
)
|
||||
self.assertEqual(
|
||||
result[0],
|
||||
"This is prepend text This is the prompt This is append text",
|
||||
)
|
||||
|
||||
def test_text_node_connected_text_only(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
"""Connected text without prepend/append passes through unchanged."""
|
||||
node = self._make_text_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
result = node.process_text(text="This is the prompt")
|
||||
self.assertEqual(result[0], "This is the prompt")
|
||||
|
||||
def test_clip_node_connected_text_with_prepend_append(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
"""Same scenario for PromptManager (CLIP variant)."""
|
||||
node, clip = self._make_clip_node(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
result = node.encode_prompt(
|
||||
clip=clip,
|
||||
text="This is the prompt",
|
||||
prepend_text="This is prepend text",
|
||||
append_text="This is append text",
|
||||
)
|
||||
self.assertEqual(
|
||||
result[1],
|
||||
"This is prepend text This is the prompt This is append text",
|
||||
)
|
||||
|
||||
def test_clip_node_connected_text_only(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
"""Connected text without prepend/append passes through unchanged."""
|
||||
node, clip = self._make_clip_node(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
result = node.encode_prompt(clip=clip, text="This is the prompt")
|
||||
self.assertEqual(result[1], "This is the prompt")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,65 @@
|
||||
"""ComfyUI loads only the canvas extension, not the admin or gallery bundles.
|
||||
|
||||
ComfyUI serves every .js file under WEB_DIRECTORY into the canvas page, so the
|
||||
directory must hold nothing but the node extension.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(TESTS_DIR)
|
||||
sys.path.insert(0, ROOT)
|
||||
sys.path.insert(0, TESTS_DIR)
|
||||
|
||||
from test_init_package import close_every_database, load_package # noqa: E402
|
||||
|
||||
|
||||
class TestExtensionWebDir(unittest.TestCase):
|
||||
package_name = "prompt_manager_pkg_web"
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.tmp = tempfile.TemporaryDirectory()
|
||||
output_dir = os.path.join(cls.tmp.name, "output")
|
||||
os.makedirs(output_dir)
|
||||
cls.package, _ = load_package(
|
||||
cls.package_name, os.path.join(cls.tmp.name, "prompts.db"), output_dir
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
monitor = getattr(cls.package, "_global_image_monitor", None)
|
||||
if monitor is not None:
|
||||
monitor.stop_monitoring()
|
||||
sys.modules.pop(cls.package_name, None)
|
||||
close_every_database() # the package's database must not hold the temp dir open
|
||||
cls.tmp.cleanup()
|
||||
|
||||
def test_web_directory_is_the_canvas_extension_folder(self):
|
||||
self.assertEqual(self.package.WEB_DIRECTORY, "web/comfy")
|
||||
self.assertIn("WEB_DIRECTORY", self.package.__all__)
|
||||
|
||||
def test_comfyui_resolves_the_folder_next_to_the_package(self):
|
||||
# ComfyUI joins the module directory with WEB_DIRECTORY and checks isdir
|
||||
folder = os.path.join(ROOT, self.package.WEB_DIRECTORY)
|
||||
self.assertTrue(os.path.isdir(folder))
|
||||
self.assertEqual(sorted(os.listdir(folder)), ["prompt_manager.js"])
|
||||
|
||||
def test_extension_was_moved_not_copied(self):
|
||||
self.assertFalse(os.path.exists(os.path.join(ROOT, "web", "prompt_manager.js")))
|
||||
|
||||
def test_extension_registers_with_the_canvas_app(self):
|
||||
path = os.path.join(ROOT, "web", "comfy", "prompt_manager.js")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
source = f.read()
|
||||
self.assertIn("app.registerExtension(", source)
|
||||
# Served at /extensions/<name>/prompt_manager.js, so ComfyUI's app module
|
||||
# is two levels up regardless of where the file lives on disk.
|
||||
self.assertIn('from "../../scripts/app.js"', source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Tests for gallery root validation (GalleryConfig.validate_gallery_root).
|
||||
|
||||
A gallery root may only point inside one of ComfyUI's own directories
|
||||
(output/input/temp/user, as reported by ``folder_paths``) or inside a
|
||||
directory listed in ``PROMPT_MANAGER_EXTRA_GALLERY_ROOTS``. Anything else,
|
||||
including filesystem roots and the home directory, is rejected so the
|
||||
image-serving routes can never be pointed at arbitrary files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
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.config import GalleryConfig, PromptManagerConfig # noqa: E402
|
||||
|
||||
EXTRA_ROOTS_ENV = "PROMPT_MANAGER_EXTRA_GALLERY_ROOTS"
|
||||
|
||||
|
||||
class TestUpdateConfigValidatesRoots(unittest.TestCase):
|
||||
"""Roots hand-edited into config.json go through validate_gallery_root."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.tmpdir, True)
|
||||
self.comfy_dir = Path(self.tmpdir) / "ComfyUI"
|
||||
self.output_dir = self.comfy_dir / "output"
|
||||
self.output_dir.mkdir(parents=True)
|
||||
self.outside = Path(self.tmpdir) / "outside"
|
||||
self.outside.mkdir()
|
||||
self._orig_folder_paths = sys.modules.get("folder_paths")
|
||||
sys.modules["folder_paths"] = types.SimpleNamespace(
|
||||
base_path=str(self.comfy_dir),
|
||||
get_output_directory=lambda: str(self.output_dir),
|
||||
)
|
||||
self.addCleanup(self._restore_folder_paths)
|
||||
self._orig_dirs = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
self.addCleanup(
|
||||
setattr, GalleryConfig, "MONITORING_DIRECTORIES", self._orig_dirs
|
||||
)
|
||||
self._orig_extra = os.environ.pop(EXTRA_ROOTS_ENV, None)
|
||||
self.addCleanup(self._restore_extra_env)
|
||||
|
||||
def _restore_folder_paths(self):
|
||||
if self._orig_folder_paths is None:
|
||||
sys.modules.pop("folder_paths", None)
|
||||
else:
|
||||
sys.modules["folder_paths"] = self._orig_folder_paths
|
||||
|
||||
def _restore_extra_env(self):
|
||||
if self._orig_extra is None:
|
||||
os.environ.pop(EXTRA_ROOTS_ENV, None)
|
||||
else:
|
||||
os.environ[EXTRA_ROOTS_ENV] = self._orig_extra
|
||||
|
||||
def test_invalid_roots_are_dropped_with_a_warning(self):
|
||||
good = self.output_dir / "renders"
|
||||
good.mkdir()
|
||||
with self.assertLogs("prompt_manager.config", level="WARNING") as logs:
|
||||
GalleryConfig.update_config(
|
||||
{
|
||||
"monitoring": {
|
||||
"directories": [
|
||||
str(self.outside),
|
||||
str(good),
|
||||
os.path.abspath(os.sep),
|
||||
42,
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
self.assertEqual(GalleryConfig.MONITORING_DIRECTORIES, [str(good)])
|
||||
self.assertEqual(len(logs.output), 3)
|
||||
self.assertNotIn(str(self.outside), "".join(logs.output))
|
||||
|
||||
def test_non_list_directories_are_ignored(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [str(self.output_dir)]
|
||||
GalleryConfig.update_config({"monitoring": {"directories": "/etc"}})
|
||||
self.assertEqual(GalleryConfig.MONITORING_DIRECTORIES, [str(self.output_dir)])
|
||||
|
||||
def test_load_from_file_drops_invalid_roots(self):
|
||||
config_path = os.path.join(self.tmpdir, "config.json")
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"gallery": {
|
||||
"monitoring": {
|
||||
"directories": [str(self.outside), str(self.output_dir)]
|
||||
}
|
||||
}
|
||||
},
|
||||
f,
|
||||
)
|
||||
PromptManagerConfig.load_from_file(config_path)
|
||||
self.assertEqual(GalleryConfig.MONITORING_DIRECTORIES, [str(self.output_dir)])
|
||||
|
||||
|
||||
class GalleryRootTestCase(unittest.TestCase):
|
||||
"""Shared fixture: a fake ComfyUI tree exposed through a stub folder_paths."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.addCleanup(self._rmtree, self.tmpdir)
|
||||
|
||||
self.comfy_dir = Path(self.tmpdir) / "ComfyUI"
|
||||
self.output_dir = self.comfy_dir / "output"
|
||||
self.input_dir = self.comfy_dir / "input"
|
||||
self.output_dir.mkdir(parents=True)
|
||||
self.input_dir.mkdir(parents=True)
|
||||
|
||||
self._orig_folder_paths = sys.modules.get("folder_paths")
|
||||
sys.modules["folder_paths"] = types.SimpleNamespace(
|
||||
base_path=str(self.comfy_dir),
|
||||
get_output_directory=lambda: str(self.output_dir),
|
||||
get_input_directory=lambda: str(self.input_dir),
|
||||
get_temp_directory=self._raise_runtime_error,
|
||||
# get_user_directory intentionally absent
|
||||
)
|
||||
self.addCleanup(self._restore_folder_paths)
|
||||
|
||||
self._orig_extra = os.environ.pop(EXTRA_ROOTS_ENV, None)
|
||||
self.addCleanup(self._restore_extra_env)
|
||||
|
||||
@staticmethod
|
||||
def _raise_runtime_error():
|
||||
raise RuntimeError("temp directory unavailable")
|
||||
|
||||
@staticmethod
|
||||
def _rmtree(path):
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
def _restore_folder_paths(self):
|
||||
if self._orig_folder_paths is None:
|
||||
sys.modules.pop("folder_paths", None)
|
||||
else:
|
||||
sys.modules["folder_paths"] = self._orig_folder_paths
|
||||
|
||||
def _restore_extra_env(self):
|
||||
if self._orig_extra is None:
|
||||
os.environ.pop(EXTRA_ROOTS_ENV, None)
|
||||
else:
|
||||
os.environ[EXTRA_ROOTS_ENV] = self._orig_extra
|
||||
|
||||
|
||||
class TestValidateGalleryRootRejects(GalleryRootTestCase):
|
||||
|
||||
def test_filesystem_root_rejected(self):
|
||||
ok, reason = GalleryConfig.validate_gallery_root(os.path.abspath(os.sep))
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("root", reason.lower())
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "drive roots only exist on Windows")
|
||||
def test_windows_drive_root_rejected(self):
|
||||
ok, _ = GalleryConfig.validate_gallery_root("C:\\")
|
||||
self.assertFalse(ok)
|
||||
|
||||
def test_home_directory_rejected(self):
|
||||
ok, reason = GalleryConfig.validate_gallery_root(os.path.expanduser("~"))
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("home", reason.lower())
|
||||
|
||||
def test_directory_outside_comfyui_rejected(self):
|
||||
outside = Path(self.tmpdir) / "elsewhere"
|
||||
outside.mkdir()
|
||||
ok, _ = GalleryConfig.validate_gallery_root(str(outside))
|
||||
self.assertFalse(ok)
|
||||
|
||||
def test_nonexistent_path_rejected(self):
|
||||
missing = self.output_dir / "does_not_exist"
|
||||
ok, reason = GalleryConfig.validate_gallery_root(str(missing))
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("exist", reason.lower())
|
||||
|
||||
def test_file_rejected(self):
|
||||
file_path = self.output_dir / "image.png"
|
||||
file_path.write_bytes(b"PNG")
|
||||
ok, reason = GalleryConfig.validate_gallery_root(str(file_path))
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("directory", reason.lower())
|
||||
|
||||
def test_empty_and_non_string_rejected(self):
|
||||
self.assertFalse(GalleryConfig.validate_gallery_root("")[0])
|
||||
self.assertFalse(GalleryConfig.validate_gallery_root(" ")[0])
|
||||
self.assertFalse(GalleryConfig.validate_gallery_root(None)[0])
|
||||
self.assertFalse(GalleryConfig.validate_gallery_root(42)[0])
|
||||
|
||||
def test_sibling_with_shared_prefix_rejected(self):
|
||||
"""'output2' must not pass because it string-starts with 'output'."""
|
||||
sibling = self.comfy_dir / "output2"
|
||||
sibling.mkdir()
|
||||
ok, _ = GalleryConfig.validate_gallery_root(str(sibling))
|
||||
self.assertFalse(ok)
|
||||
|
||||
def test_traversal_out_of_output_rejected(self):
|
||||
outside = Path(self.tmpdir) / "elsewhere"
|
||||
outside.mkdir()
|
||||
sneaky = os.path.join(str(self.output_dir), "..", "..", "elsewhere")
|
||||
ok, _ = GalleryConfig.validate_gallery_root(sneaky)
|
||||
self.assertFalse(ok)
|
||||
|
||||
def test_symlink_escaping_output_rejected(self):
|
||||
outside = Path(self.tmpdir) / "elsewhere"
|
||||
outside.mkdir()
|
||||
link = self.output_dir / "escape"
|
||||
try:
|
||||
os.symlink(str(outside), str(link), target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
self.skipTest("symlinks not available on this platform")
|
||||
ok, _ = GalleryConfig.validate_gallery_root(str(link))
|
||||
self.assertFalse(ok)
|
||||
|
||||
def test_env_root_that_is_filesystem_root_is_ignored(self):
|
||||
"""Listing '/' in the env var must not re-open the whole disk."""
|
||||
os.environ[EXTRA_ROOTS_ENV] = os.path.abspath(os.sep)
|
||||
outside = Path(self.tmpdir) / "elsewhere"
|
||||
outside.mkdir()
|
||||
ok, _ = GalleryConfig.validate_gallery_root(str(outside))
|
||||
self.assertFalse(ok)
|
||||
|
||||
|
||||
class TestValidateGalleryRootAccepts(GalleryRootTestCase):
|
||||
|
||||
def test_output_directory_itself_accepted(self):
|
||||
ok, reason = GalleryConfig.validate_gallery_root(str(self.output_dir))
|
||||
self.assertTrue(ok, reason)
|
||||
|
||||
def test_subdirectory_of_output_accepted(self):
|
||||
sub = self.output_dir / "renders" / "2026"
|
||||
sub.mkdir(parents=True)
|
||||
ok, reason = GalleryConfig.validate_gallery_root(str(sub))
|
||||
self.assertTrue(ok, reason)
|
||||
|
||||
def test_subdirectory_of_input_accepted(self):
|
||||
sub = self.input_dir / "refs"
|
||||
sub.mkdir()
|
||||
ok, reason = GalleryConfig.validate_gallery_root(str(sub))
|
||||
self.assertTrue(ok, reason)
|
||||
|
||||
def test_directory_listed_in_env_accepted(self):
|
||||
extra_a = Path(self.tmpdir) / "extra_a"
|
||||
extra_b = Path(self.tmpdir) / "extra_b"
|
||||
extra_a.mkdir()
|
||||
extra_b.mkdir()
|
||||
os.environ[EXTRA_ROOTS_ENV] = os.pathsep.join([str(extra_a), str(extra_b)])
|
||||
|
||||
ok_a, reason_a = GalleryConfig.validate_gallery_root(str(extra_a))
|
||||
ok_b, reason_b = GalleryConfig.validate_gallery_root(str(extra_b / "."))
|
||||
self.assertTrue(ok_a, reason_a)
|
||||
self.assertTrue(ok_b, reason_b)
|
||||
|
||||
def test_relative_path_resolves_against_comfyui_base_not_cwd(self):
|
||||
sub = self.output_dir / "rel"
|
||||
sub.mkdir()
|
||||
orig_cwd = os.getcwd()
|
||||
os.chdir(self.tmpdir)
|
||||
self.addCleanup(os.chdir, orig_cwd)
|
||||
|
||||
ok, reason = GalleryConfig.validate_gallery_root("output/rel")
|
||||
self.assertTrue(ok, reason)
|
||||
self.assertEqual(
|
||||
GalleryConfig.resolve_gallery_root("output/rel"),
|
||||
os.path.normcase(os.path.realpath(sub)),
|
||||
)
|
||||
# A path that only exists relative to the CWD is not accepted.
|
||||
(Path(self.tmpdir) / "rel").mkdir()
|
||||
self.assertFalse(GalleryConfig.validate_gallery_root("rel")[0])
|
||||
|
||||
def test_accepts_without_folder_paths_when_env_lists_dir(self):
|
||||
sys.modules.pop("folder_paths", None)
|
||||
extra = Path(self.tmpdir) / "extra"
|
||||
extra.mkdir()
|
||||
os.environ[EXTRA_ROOTS_ENV] = str(extra)
|
||||
ok, reason = GalleryConfig.validate_gallery_root(str(extra))
|
||||
self.assertTrue(ok, reason)
|
||||
|
||||
def test_rejects_everything_without_folder_paths_and_env(self):
|
||||
sys.modules.pop("folder_paths", None)
|
||||
ok, _ = GalleryConfig.validate_gallery_root(str(self.output_dir))
|
||||
self.assertFalse(ok)
|
||||
|
||||
|
||||
class TestGetAllOutputDirsFiltersRoots(GalleryRootTestCase):
|
||||
"""PromptManagerAPI._get_all_output_dirs() only returns validated roots."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
self.api = PromptManagerAPI()
|
||||
self.api._cached_output_dir = None
|
||||
self._orig_dirs = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
self.addCleanup(self._restore_dirs)
|
||||
|
||||
def _restore_dirs(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = self._orig_dirs
|
||||
|
||||
def _dirs(self):
|
||||
return [os.path.normcase(str(p)) for p in self.api._get_all_output_dirs()]
|
||||
|
||||
def test_filesystem_root_is_never_served(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [os.path.abspath(os.sep)]
|
||||
dirs = self._dirs()
|
||||
self.assertNotIn(os.path.normcase(os.path.abspath(os.sep)), dirs)
|
||||
self.assertEqual(dirs, [os.path.normcase(os.path.realpath(self.output_dir))])
|
||||
|
||||
def test_outside_directory_is_dropped_and_valid_one_kept(self):
|
||||
outside = Path(self.tmpdir) / "elsewhere"
|
||||
outside.mkdir()
|
||||
good = self.output_dir / "good"
|
||||
good.mkdir()
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [str(outside), str(good)]
|
||||
self.assertEqual(self._dirs(), [os.path.normcase(os.path.realpath(good))])
|
||||
|
||||
def test_all_rejected_falls_back_to_comfyui_output_only(self):
|
||||
outside = Path(self.tmpdir) / "elsewhere"
|
||||
outside.mkdir()
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [str(outside)]
|
||||
self.assertEqual(
|
||||
self._dirs(), [os.path.normcase(os.path.realpath(self.output_dir))]
|
||||
)
|
||||
|
||||
def test_no_configured_dirs_uses_auto_detection(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = []
|
||||
self.api._find_comfyui_output_dir = lambda: str(self.output_dir)
|
||||
self.assertEqual(self._dirs(), [os.path.normcase(str(self.output_dir))])
|
||||
|
||||
def test_nothing_available_returns_empty_list(self):
|
||||
sys.modules.pop("folder_paths", None)
|
||||
outside = Path(self.tmpdir) / "elsewhere"
|
||||
outside.mkdir()
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [str(outside)]
|
||||
self.assertEqual(self._dirs(), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Tests for hashing utilities.
|
||||
|
||||
Tests is_duplicate_prompt and generate_prompt_hash, which are
|
||||
currently untested. generate_prompt_hash is covered in test_basic.py
|
||||
but we add edge cases here.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.hashing import (
|
||||
generate_prompt_hash,
|
||||
is_duplicate_prompt,
|
||||
)
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
class TestSingleHashImplementation(unittest.TestCase):
|
||||
"""generate_prompt_hash is the only prompt hash; nothing re-implements it."""
|
||||
|
||||
def test_node_base_delegates_to_generate_prompt_hash(self):
|
||||
from prompt_manager_base import PromptManagerBase
|
||||
|
||||
node = PromptManagerBase.__new__(PromptManagerBase)
|
||||
self.assertEqual(node._generate_hash(" A "), generate_prompt_hash(" A "))
|
||||
self.assertEqual(node._generate_hash(" A "), generate_prompt_hash("a"))
|
||||
|
||||
def test_tracker_and_monitor_use_generate_prompt_hash(self):
|
||||
for relative in (
|
||||
os.path.join("utils", "prompt_tracker.py"),
|
||||
os.path.join("utils", "image_monitor.py"),
|
||||
"prompt_manager_base.py",
|
||||
):
|
||||
with open(os.path.join(ROOT, relative), encoding="utf-8") as f:
|
||||
source = f.read()
|
||||
self.assertFalse("hashlib.sha256" in source, f"inline hash in {relative}")
|
||||
self.assertTrue("generate_prompt_hash" in source, relative)
|
||||
|
||||
def test_tracker_finds_an_existing_prompt_by_the_shared_hash(self):
|
||||
import unittest.mock as mock
|
||||
|
||||
from utils.prompt_tracker import PromptTracker
|
||||
|
||||
db = mock.Mock()
|
||||
db.get_prompt_by_hash.return_value = {"id": 42}
|
||||
tracker = PromptTracker(db)
|
||||
tracker.set_current_prompt(" Hello World ")
|
||||
db.get_prompt_by_hash.assert_called_once_with(
|
||||
generate_prompt_hash(" Hello World ")
|
||||
)
|
||||
self.assertEqual(tracker.get_current_prompt()["id"], 42)
|
||||
|
||||
def test_node_base_has_no_browser_opener(self):
|
||||
from prompt_manager_base import PromptManagerBase
|
||||
|
||||
self.assertFalse(hasattr(PromptManagerBase, "_open_web_interface"))
|
||||
self.assertFalse(os.path.exists(os.path.join(ROOT, "restart_gallery.py")))
|
||||
|
||||
|
||||
class TestIsDuplicatePrompt(unittest.TestCase):
|
||||
"""Test is_duplicate_prompt function."""
|
||||
|
||||
def test_identical_texts_are_duplicates(self):
|
||||
self.assertTrue(is_duplicate_prompt("hello", "hello"))
|
||||
|
||||
def test_case_insensitive(self):
|
||||
self.assertTrue(is_duplicate_prompt("Hello World", "hello world"))
|
||||
|
||||
def test_whitespace_insensitive(self):
|
||||
self.assertTrue(is_duplicate_prompt(" hello ", "hello"))
|
||||
|
||||
def test_different_texts_not_duplicates(self):
|
||||
self.assertFalse(is_duplicate_prompt("text A", "text B"))
|
||||
|
||||
def test_threshold_parameter_accepted(self):
|
||||
# threshold is unused but should not cause errors
|
||||
self.assertTrue(is_duplicate_prompt("same", "same", threshold=0.5))
|
||||
|
||||
def test_empty_strings_are_duplicates(self):
|
||||
self.assertTrue(is_duplicate_prompt("", ""))
|
||||
|
||||
def test_unicode_comparison(self):
|
||||
self.assertTrue(is_duplicate_prompt("日本語", "日本語"))
|
||||
self.assertFalse(is_duplicate_prompt("日本語", "中文"))
|
||||
|
||||
|
||||
class TestGeneratePromptHashEdgeCases(unittest.TestCase):
|
||||
"""Additional edge cases for generate_prompt_hash."""
|
||||
|
||||
def test_non_string_raises_type_error(self):
|
||||
with self.assertRaises(TypeError):
|
||||
generate_prompt_hash(123)
|
||||
|
||||
def test_none_raises_type_error(self):
|
||||
with self.assertRaises(TypeError):
|
||||
generate_prompt_hash(None)
|
||||
|
||||
def test_empty_string(self):
|
||||
h = generate_prompt_hash("")
|
||||
self.assertEqual(len(h), 64)
|
||||
|
||||
def test_unicode_hash(self):
|
||||
h = generate_prompt_hash("émojis 🎨 日本語")
|
||||
self.assertEqual(len(h), 64)
|
||||
|
||||
def test_very_long_text(self):
|
||||
h = generate_prompt_hash("x" * 100000)
|
||||
self.assertEqual(len(h), 64)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Image linking picks the positive prompt, preferring the image's own metadata."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import unittest.mock as mock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.image_monitor import ImageGenerationHandler
|
||||
from utils.prompt_tracker import PromptTracker
|
||||
|
||||
LOADER = {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": "m.safetensors"},
|
||||
}
|
||||
|
||||
|
||||
def graph(positive_text="a cat", negative_text="blurry"):
|
||||
# Negative listed first: dict order must not decide which prompt an image gets
|
||||
return {
|
||||
"176": {"class_type": "PromptManager", "inputs": {"text": negative_text}},
|
||||
"134": {"class_type": "PromptManager", "inputs": {"text": positive_text}},
|
||||
"5": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": ["134", 0],
|
||||
"negative": ["176", 0],
|
||||
"model": ["20", 0],
|
||||
},
|
||||
},
|
||||
"20": LOADER,
|
||||
}
|
||||
|
||||
|
||||
class LinkingTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
fd, self.db_path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
fd, self.image = tempfile.mkstemp(suffix=".png")
|
||||
os.close(fd)
|
||||
self.db = PromptDatabase(self.db_path)
|
||||
self.tracker = PromptTracker(self.db)
|
||||
self.handler = ImageGenerationHandler(self.db, self.tracker)
|
||||
self.linked = []
|
||||
self.handler.link_image_to_prompt = (
|
||||
lambda path, prompt, meta: self.linked.append(prompt["id"])
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close_all() # Windows cannot unlink an open database file
|
||||
for path in (
|
||||
self.image,
|
||||
self.db_path,
|
||||
self.db_path + "-wal",
|
||||
self.db_path + "-shm",
|
||||
):
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
def _save(self, text):
|
||||
return self.db.save_prompt(text=text, prompt_hash=generate_prompt_hash(text))
|
||||
|
||||
def _process(self, metadata):
|
||||
with mock.patch.object(
|
||||
self.handler.metadata_extractor, "extract_metadata", return_value=metadata
|
||||
):
|
||||
self.handler.process_new_image(self.image)
|
||||
return self.linked[-1] if self.linked else None
|
||||
|
||||
|
||||
class TestMetadataLinking(LinkingTestCase):
|
||||
def test_links_the_positive_prompt_not_the_first_prompt_manager_node(self):
|
||||
positive = self._save("a cat")
|
||||
self._save("blurry")
|
||||
self.assertEqual(self._process({"prompt": graph()}), positive)
|
||||
|
||||
def test_image_metadata_wins_over_a_stale_queue_entry(self):
|
||||
positive = self._save("a cat")
|
||||
stale = self._save("previous run")
|
||||
self.tracker.set_current_prompt("previous run", {"prompt_id": stale})
|
||||
|
||||
self.assertEqual(self._process({"prompt": graph()}), positive)
|
||||
self.assertEqual(
|
||||
self.tracker.pop_next_prompt()["id"], stale, "queue left intact"
|
||||
)
|
||||
|
||||
def test_text_from_a_known_string_node_links_from_metadata(self):
|
||||
positive = self._save("a cat")
|
||||
linked_graph = graph()
|
||||
linked_graph["134"]["inputs"]["text"] = ["8", 0]
|
||||
linked_graph["8"] = {
|
||||
"class_type": "PrimitiveString",
|
||||
"inputs": {"value": "a cat"},
|
||||
}
|
||||
self.assertEqual(self._process({"prompt": linked_graph}), positive)
|
||||
|
||||
def test_batch_items_with_linked_text_use_the_queue(self):
|
||||
first, second = self._save("item one"), self._save("item two")
|
||||
batch_graph = graph()
|
||||
batch_graph["134"]["inputs"]["text"] = ["7", 0]
|
||||
batch_graph["7"] = {"class_type": "PromptSearchList", "inputs": {}}
|
||||
self.tracker.set_current_prompt("item one", {"prompt_id": first})
|
||||
self.tracker.set_current_prompt("item two", {"prompt_id": second})
|
||||
|
||||
self.assertEqual(self._process({"prompt": batch_graph}), first)
|
||||
self.assertEqual(self._process({"prompt": batch_graph}), second)
|
||||
|
||||
def test_api_graph_present_skips_the_role_unaware_workflow_fallback(self):
|
||||
negative = self._save("blurry")
|
||||
batch_graph = graph()
|
||||
batch_graph["134"]["inputs"]["text"] = ["7", 0]
|
||||
metadata = {
|
||||
"prompt": batch_graph,
|
||||
"text_encoder_nodes": [
|
||||
{"type": "PromptManager", "inputs": [], "widgets_values": ["blurry"]}
|
||||
],
|
||||
}
|
||||
self.assertEqual(
|
||||
self.handler._find_prompt_from_metadata(metadata), ("unknown", None)
|
||||
)
|
||||
latest = self._save("most recent prompt") # recency fallback picks this
|
||||
self.assertEqual(self._process(metadata), latest)
|
||||
self.assertNotEqual(latest, negative)
|
||||
|
||||
def test_workflow_fallback_still_used_without_an_api_graph(self):
|
||||
positive = self._save("from workflow only")
|
||||
metadata = {
|
||||
"text_encoder_nodes": [
|
||||
{
|
||||
"type": "PromptManager",
|
||||
"inputs": [],
|
||||
"widgets_values": ["from workflow only"],
|
||||
}
|
||||
]
|
||||
}
|
||||
state, prompt = self.handler._find_prompt_from_metadata(metadata)
|
||||
self.assertEqual(state, "linked")
|
||||
self.assertEqual(prompt["id"], positive)
|
||||
|
||||
|
||||
def foreign_graph():
|
||||
"""A workflow with no PromptManager node at all."""
|
||||
return {
|
||||
"9": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "a cat", "clip": ["20", 1]},
|
||||
},
|
||||
"5": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["9", 0], "negative": ["9", 0], "model": ["20", 0]},
|
||||
},
|
||||
"20": LOADER,
|
||||
}
|
||||
|
||||
|
||||
class TestForeignWorkflows(LinkingTestCase):
|
||||
"""Images from workflows without a positive PromptManager node are not ours."""
|
||||
|
||||
def _queue_batch_item(self, text):
|
||||
prompt_id = self._save(text)
|
||||
self.tracker.set_current_prompt(text, {"prompt_id": prompt_id})
|
||||
return prompt_id
|
||||
|
||||
def test_foreign_image_leaves_the_batch_queue_aligned(self):
|
||||
first = self._queue_batch_item("item one")
|
||||
self.assertIsNone(self._process({"prompt": foreign_graph()}))
|
||||
self.assertEqual(self.linked, [])
|
||||
self.assertEqual(self.tracker.pop_next_prompt()["id"], first)
|
||||
self.assertIsNone(self.tracker.pop_next_prompt())
|
||||
|
||||
def test_foreign_image_is_not_linked_by_the_later_fallbacks_either(self):
|
||||
self._queue_batch_item("item one") # live tracker and recent prompt exist
|
||||
self.assertIsNone(self._process({"prompt": foreign_graph()}))
|
||||
self.assertEqual(self.linked, [])
|
||||
|
||||
def test_negative_only_workflow_is_foreign(self):
|
||||
self._save("blurry")
|
||||
negative_only = foreign_graph()
|
||||
negative_only["176"] = {
|
||||
"class_type": "PromptManager",
|
||||
"inputs": {"text": "blurry"},
|
||||
}
|
||||
negative_only["5"]["inputs"]["negative"] = ["176", 0]
|
||||
self.assertEqual(
|
||||
self.handler._find_prompt_from_metadata({"prompt": negative_only}),
|
||||
("foreign", None),
|
||||
)
|
||||
|
||||
def test_workflow_only_metadata_without_prompt_manager_is_foreign(self):
|
||||
metadata = {
|
||||
"text_encoder_nodes": [
|
||||
{"type": "CLIPTextEncode", "inputs": [], "widgets_values": ["a cat"]}
|
||||
]
|
||||
}
|
||||
self.assertEqual(
|
||||
self.handler._find_prompt_from_metadata(metadata), ("foreign", None)
|
||||
)
|
||||
|
||||
def test_missing_metadata_is_unknown_and_uses_the_queue(self):
|
||||
first = self._queue_batch_item("item one")
|
||||
self.assertEqual(
|
||||
self.handler._find_prompt_from_metadata(None), ("unknown", None)
|
||||
)
|
||||
self.assertEqual(self._process(None), first)
|
||||
|
||||
def test_tri_state_for_linked_unknown_and_foreign(self):
|
||||
positive = self._save("a cat")
|
||||
state, prompt = self.handler._find_prompt_from_metadata({"prompt": graph()})
|
||||
self.assertEqual((state, prompt["id"]), ("linked", positive))
|
||||
self.assertEqual(
|
||||
self.handler._find_prompt_from_metadata({"prompt": foreign_graph()}),
|
||||
("foreign", None),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.handler._find_prompt_from_metadata({"workflow": {}}),
|
||||
("unknown", None),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.handler._find_prompt_from_metadata({"prompt": graph("not saved")}),
|
||||
("unknown", None),
|
||||
)
|
||||
|
||||
def test_unknown_image_without_any_context_falls_back_to_the_latest_prompt(self):
|
||||
self._save("older")
|
||||
latest = self._save("latest")
|
||||
self.assertEqual(self._process(None), latest)
|
||||
|
||||
def test_recent_prompt_fallback_handles_an_empty_database(self):
|
||||
self.assertIsNone(self.handler._get_fallback_prompt())
|
||||
self.assertIsNone(self._process(None))
|
||||
|
||||
def test_metadata_lookup_errors_are_unknown(self):
|
||||
self._save("a cat")
|
||||
with mock.patch.object(
|
||||
self.db, "get_prompt_by_hash", side_effect=RuntimeError("locked")
|
||||
):
|
||||
self.assertEqual(
|
||||
self.handler._find_prompt_from_metadata({"prompt": graph()}),
|
||||
("unknown", None),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,485 @@
|
||||
"""
|
||||
Tests for image monitor singleton lifecycle.
|
||||
|
||||
Verifies that:
|
||||
- The singleton image monitor survives node garbage collection
|
||||
- Node __del__ / cleanup_gallery_system does NOT stop the shared monitor
|
||||
- Multiple node instances share the same monitor
|
||||
- The monitor observer stays alive across node lifecycles
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# Mock ComfyUI server before importing anything that touches config. Only the
|
||||
# first test module to run installs the stub; nothing else in sys.modules is
|
||||
# replaced, so later modules (test_comfyui_integration) see the real package.
|
||||
_mock_server = MagicMock()
|
||||
_mock_server.PromptServer.instance.routes = MagicMock()
|
||||
sys.modules.setdefault("server", _mock_server)
|
||||
|
||||
from utils.image_monitor import get_image_monitor
|
||||
import utils.image_monitor as im_mod
|
||||
|
||||
|
||||
class TestMonitorSingletonLifecycle(unittest.TestCase):
|
||||
"""The singleton monitor must survive node instance garbage collection."""
|
||||
|
||||
def setUp(self):
|
||||
# Reset singleton for each test
|
||||
im_mod._monitor_instance = None
|
||||
|
||||
def tearDown(self):
|
||||
im_mod._monitor_instance = None
|
||||
|
||||
def test_get_image_monitor_returns_singleton(self):
|
||||
"""Multiple calls should return the exact same instance."""
|
||||
db = MagicMock()
|
||||
tracker = MagicMock()
|
||||
|
||||
m1 = get_image_monitor(db, tracker)
|
||||
m2 = get_image_monitor(db, tracker)
|
||||
|
||||
self.assertIs(m1, m2)
|
||||
|
||||
def test_singleton_survives_across_different_callers(self):
|
||||
"""Different db/tracker args on subsequent calls still return the same
|
||||
instance."""
|
||||
db1, tracker1 = MagicMock(), MagicMock()
|
||||
db2, tracker2 = MagicMock(), MagicMock()
|
||||
|
||||
m1 = get_image_monitor(db1, tracker1)
|
||||
m2 = get_image_monitor(db2, tracker2)
|
||||
|
||||
self.assertIs(m1, m2)
|
||||
|
||||
def test_cleanup_does_not_stop_monitor(self):
|
||||
"""PromptManagerBase.cleanup_gallery_system must NOT stop the monitor."""
|
||||
from prompt_manager_base import PromptManagerBase
|
||||
|
||||
with patch.object(PromptManagerBase, "__init__", lambda self, **kw: None):
|
||||
node = PromptManagerBase()
|
||||
node.logger = MagicMock()
|
||||
|
||||
mock_monitor = MagicMock()
|
||||
node.image_monitor = mock_monitor
|
||||
|
||||
node.cleanup_gallery_system()
|
||||
|
||||
mock_monitor.stop_monitoring.assert_not_called()
|
||||
|
||||
def test_del_does_not_stop_monitor(self):
|
||||
"""Node __del__ must NOT stop the singleton monitor."""
|
||||
from prompt_manager_base import PromptManagerBase
|
||||
|
||||
with patch.object(PromptManagerBase, "__init__", lambda self, **kw: None):
|
||||
node = PromptManagerBase()
|
||||
node.logger = MagicMock()
|
||||
|
||||
mock_monitor = MagicMock()
|
||||
node.image_monitor = mock_monitor
|
||||
|
||||
# Simulate garbage collection
|
||||
del node
|
||||
|
||||
mock_monitor.stop_monitoring.assert_not_called()
|
||||
|
||||
def test_observer_stays_alive_after_node_cleanup(self):
|
||||
"""A running observer must remain alive after node cleanup."""
|
||||
db = MagicMock()
|
||||
tracker = MagicMock()
|
||||
monitor = get_image_monitor(db, tracker)
|
||||
|
||||
# Simulate a running observer
|
||||
mock_observer = MagicMock()
|
||||
mock_observer.is_alive.return_value = True
|
||||
monitor.observer = mock_observer
|
||||
monitor.handler = MagicMock()
|
||||
monitor.monitored_directories = ["/fake/output"]
|
||||
|
||||
from prompt_manager_base import PromptManagerBase
|
||||
|
||||
with patch.object(PromptManagerBase, "__init__", lambda self, **kw: None):
|
||||
node = PromptManagerBase()
|
||||
node.logger = MagicMock()
|
||||
node.image_monitor = monitor
|
||||
|
||||
node.cleanup_gallery_system()
|
||||
|
||||
# Observer must still be alive
|
||||
self.assertIsNotNone(monitor.observer)
|
||||
self.assertTrue(monitor.observer.is_alive())
|
||||
self.assertEqual(monitor.monitored_directories, ["/fake/output"])
|
||||
|
||||
def test_monitor_start_not_called_when_already_running(self):
|
||||
"""start_monitoring should be a no-op if observer is already active."""
|
||||
db = MagicMock()
|
||||
tracker = MagicMock()
|
||||
monitor = get_image_monitor(db, tracker)
|
||||
|
||||
# Set up as if already running
|
||||
mock_observer = MagicMock()
|
||||
monitor.observer = mock_observer
|
||||
|
||||
monitor.start_monitoring()
|
||||
|
||||
# Should not create a new observer
|
||||
self.assertIs(monitor.observer, mock_observer)
|
||||
|
||||
|
||||
class FakeObserver:
|
||||
"""Records what the monitor does with the watchdog observer."""
|
||||
|
||||
def __init__(self):
|
||||
self.scheduled = []
|
||||
self.started = False
|
||||
self.stopped = False
|
||||
self.joined = False
|
||||
|
||||
def schedule(self, handler, path, recursive=False):
|
||||
self.scheduled.append((handler, path, recursive))
|
||||
|
||||
def start(self):
|
||||
self.started = True
|
||||
|
||||
def stop(self):
|
||||
self.stopped = True
|
||||
|
||||
def join(self, timeout=None):
|
||||
self.joined = True
|
||||
|
||||
def is_alive(self):
|
||||
return self.started and not self.stopped
|
||||
|
||||
|
||||
class TestStartStop(unittest.TestCase):
|
||||
"""start_monitoring validates directories before it creates an observer."""
|
||||
|
||||
def setUp(self):
|
||||
im_mod._monitor_instance = None
|
||||
self.monitor = im_mod.ImageMonitor(MagicMock(), MagicMock())
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.missing = os.path.join(self.tmp.name, "does-not-exist")
|
||||
|
||||
def tearDown(self):
|
||||
im_mod._monitor_instance = None
|
||||
|
||||
def test_only_nonexistent_directories_leaves_monitoring_off(self):
|
||||
with patch.object(im_mod, "Observer") as observer_cls:
|
||||
self.monitor.start_monitoring([self.missing])
|
||||
observer_cls.assert_not_called()
|
||||
self.assertFalse(self.monitor.is_monitoring)
|
||||
self.assertIsNone(self.monitor.observer)
|
||||
self.assertEqual(self.monitor.monitored_directories, [])
|
||||
self.monitor.stop_monitoring() # must not raise
|
||||
|
||||
def test_stop_without_start_does_not_raise(self):
|
||||
self.monitor.stop_monitoring()
|
||||
self.assertFalse(self.monitor.is_monitoring)
|
||||
|
||||
def test_start_twice_is_a_noop(self):
|
||||
fake = FakeObserver()
|
||||
with patch.object(im_mod, "Observer", return_value=fake) as observer_cls:
|
||||
self.monitor.start_monitoring([self.tmp.name])
|
||||
self.monitor.start_monitoring([self.tmp.name])
|
||||
self.assertEqual(observer_cls.call_count, 1)
|
||||
self.assertEqual(len(fake.scheduled), 1)
|
||||
self.assertTrue(self.monitor.is_monitoring)
|
||||
|
||||
def test_stop_joins_the_observer_and_clears_state(self):
|
||||
fake = FakeObserver()
|
||||
with patch.object(im_mod, "Observer", return_value=fake):
|
||||
self.monitor.start_monitoring([self.tmp.name, self.missing])
|
||||
self.assertTrue(fake.started)
|
||||
self.assertEqual([p for _, p, _ in fake.scheduled], [self.tmp.name])
|
||||
self.assertEqual(self.monitor.monitored_directories, [self.tmp.name])
|
||||
self.assertTrue(self.monitor.get_status()["observer_alive"])
|
||||
|
||||
self.monitor.stop_monitoring()
|
||||
self.assertTrue(fake.stopped)
|
||||
self.assertTrue(fake.joined)
|
||||
self.assertFalse(self.monitor.is_monitoring)
|
||||
self.assertIsNone(self.monitor.handler)
|
||||
self.assertEqual(self.monitor.monitored_directories, [])
|
||||
self.assertFalse(self.monitor.get_status()["running"])
|
||||
|
||||
def test_stop_shuts_the_handler_worker_down(self):
|
||||
fake = FakeObserver()
|
||||
with patch.object(im_mod, "Observer", return_value=fake):
|
||||
self.monitor.start_monitoring([self.tmp.name])
|
||||
handler = self.monitor.handler
|
||||
handler.processing_delay = 10
|
||||
image = os.path.join(self.tmp.name, "w.png")
|
||||
with open(image, "wb") as f:
|
||||
f.write(b"x" * 16)
|
||||
handler.schedule(image)
|
||||
worker = handler._worker
|
||||
self.assertTrue(worker.is_alive())
|
||||
|
||||
self.monitor.stop_monitoring()
|
||||
|
||||
worker.join(timeout=2)
|
||||
self.assertFalse(worker.is_alive())
|
||||
self.assertEqual(handler.pending_count(), 0)
|
||||
# A restart gets a fresh handler and observer
|
||||
fake2 = FakeObserver()
|
||||
with patch.object(im_mod, "Observer", return_value=fake2):
|
||||
self.monitor.start_monitoring([self.tmp.name])
|
||||
self.assertTrue(self.monitor.is_monitoring)
|
||||
self.assertIsNot(self.monitor.handler, handler)
|
||||
self.monitor.stop_monitoring()
|
||||
|
||||
def test_observer_failing_to_start_leaves_monitoring_off(self):
|
||||
fake = FakeObserver()
|
||||
fake.start = MagicMock(side_effect=OSError("inotify limit reached"))
|
||||
with patch.object(im_mod, "Observer", return_value=fake):
|
||||
self.monitor.start_monitoring([self.tmp.name])
|
||||
self.assertFalse(self.monitor.is_monitoring)
|
||||
self.monitor.stop_monitoring()
|
||||
|
||||
def test_disabled_in_config_leaves_monitoring_off(self):
|
||||
config = types.SimpleNamespace(
|
||||
MONITORING_ENABLED=False, MONITORING_DIRECTORIES=[]
|
||||
)
|
||||
with patch.object(im_mod, "Observer") as observer_cls:
|
||||
with patch.object(
|
||||
im_mod.ImageMonitor, "_gallery_config", return_value=config
|
||||
):
|
||||
self.monitor.start_monitoring([self.tmp.name])
|
||||
observer_cls.assert_not_called()
|
||||
self.assertFalse(self.monitor.is_monitoring)
|
||||
|
||||
def test_directories_failing_validation_are_not_watched(self):
|
||||
allowed = os.path.join(self.tmp.name, "allowed")
|
||||
os.mkdir(allowed)
|
||||
config = types.SimpleNamespace(
|
||||
MONITORING_ENABLED=True,
|
||||
MONITORING_DIRECTORIES=[self.tmp.name, allowed],
|
||||
validate_gallery_root=lambda p: (p == allowed, "outside ComfyUI"),
|
||||
)
|
||||
fake = FakeObserver()
|
||||
with patch.object(im_mod, "Observer", return_value=fake):
|
||||
with patch.object(
|
||||
im_mod.ImageMonitor, "_gallery_config", return_value=config
|
||||
):
|
||||
self.monitor.start_monitoring()
|
||||
self.assertEqual([p for _, p, _ in fake.scheduled], [allowed])
|
||||
self.assertEqual(self.monitor.monitored_directories, [allowed])
|
||||
self.monitor.stop_monitoring()
|
||||
|
||||
def test_explicit_directories_are_validated_too(self):
|
||||
config = types.SimpleNamespace(
|
||||
MONITORING_ENABLED=True,
|
||||
MONITORING_DIRECTORIES=[],
|
||||
validate_gallery_root=lambda p: (False, "outside ComfyUI"),
|
||||
)
|
||||
with patch.object(im_mod, "Observer") as observer_cls:
|
||||
with patch.object(
|
||||
im_mod.ImageMonitor, "_gallery_config", return_value=config
|
||||
):
|
||||
self.monitor.start_monitoring([self.tmp.name])
|
||||
observer_cls.assert_not_called()
|
||||
self.assertFalse(self.monitor.is_monitoring)
|
||||
|
||||
def test_configured_directories_are_used_when_none_are_given(self):
|
||||
config = types.SimpleNamespace(
|
||||
MONITORING_ENABLED=True, MONITORING_DIRECTORIES=[self.tmp.name]
|
||||
)
|
||||
fake = FakeObserver()
|
||||
with patch.object(im_mod, "Observer", return_value=fake):
|
||||
with patch.object(
|
||||
im_mod.ImageMonitor, "_gallery_config", return_value=config
|
||||
):
|
||||
self.monitor.start_monitoring()
|
||||
self.assertEqual(self.monitor.monitored_directories, [self.tmp.name])
|
||||
self.monitor.stop_monitoring()
|
||||
|
||||
|
||||
class FakeEvent:
|
||||
def __init__(self, path, is_directory=False):
|
||||
self.src_path = path
|
||||
self.is_directory = is_directory
|
||||
|
||||
|
||||
def wait_for(predicate, timeout=5.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return predicate()
|
||||
|
||||
|
||||
class TestHandlerScheduling(unittest.TestCase):
|
||||
"""New images are processed once each, in order, after the file settles."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.tracker = MagicMock()
|
||||
self.tracker.get_current_prompt.return_value = {"id": 7}
|
||||
self.handler = im_mod.ImageGenerationHandler(MagicMock(), self.tracker)
|
||||
self.handler.processing_delay = 0
|
||||
self.handler.settle_interval = 0.01
|
||||
self.processed = []
|
||||
self.handler.process_new_image = lambda path, prompt_snapshot=None: (
|
||||
self.processed.append((path, prompt_snapshot))
|
||||
)
|
||||
|
||||
def _image(self, name, content=b"x" * 16):
|
||||
path = os.path.join(self.tmp.name, name)
|
||||
with open(path, "wb") as f:
|
||||
f.write(content)
|
||||
return path
|
||||
|
||||
def test_same_path_is_scheduled_once(self):
|
||||
path = self._image("a.png")
|
||||
self.assertTrue(self.handler.schedule(path, {"id": 1}))
|
||||
self.assertFalse(self.handler.schedule(path, {"id": 2}))
|
||||
self.assertTrue(wait_for(lambda: self.handler.pending_count() == 0))
|
||||
self.assertEqual(self.processed, [(path, {"id": 1})])
|
||||
|
||||
def test_on_created_snapshots_the_prompt_and_skips_non_images(self):
|
||||
path = self._image("b.png")
|
||||
self.handler.on_created(FakeEvent(path))
|
||||
self.handler.on_created(FakeEvent(os.path.join(self.tmp.name, "c.txt")))
|
||||
self.handler.on_created(FakeEvent(self.tmp.name, is_directory=True))
|
||||
self.assertTrue(wait_for(lambda: self.handler.pending_count() == 0))
|
||||
self.assertEqual(self.processed, [(path, {"id": 7})])
|
||||
|
||||
def test_images_are_processed_in_creation_order(self):
|
||||
paths = [self._image(f"{i}.png") for i in range(5)]
|
||||
for path in paths:
|
||||
self.handler.schedule(path)
|
||||
self.assertTrue(wait_for(lambda: self.handler.pending_count() == 0))
|
||||
self.assertEqual([p for p, _ in self.processed], paths)
|
||||
|
||||
def test_pending_set_is_bounded(self):
|
||||
self.handler.processing_delay = 10 # keep the worker busy
|
||||
first = self._image("first.png")
|
||||
self.handler.schedule(first)
|
||||
with patch.object(im_mod, "MAX_PENDING_IMAGES", 2):
|
||||
self.assertTrue(self.handler.schedule(self._image("second.png")))
|
||||
self.assertFalse(self.handler.schedule(self._image("third.png")))
|
||||
self.assertEqual(self.handler.pending_count(), 2)
|
||||
|
||||
def test_file_that_never_settles_is_skipped_and_cleared(self):
|
||||
path = self._image("d.png")
|
||||
sizes = iter(range(1, 100))
|
||||
with patch.object(im_mod.os.path, "getsize", side_effect=lambda p: next(sizes)):
|
||||
self.handler._process_pending(path)
|
||||
self.assertEqual(self.processed, [])
|
||||
self.assertEqual(self.handler.pending_count(), 0)
|
||||
|
||||
def test_vanished_file_is_skipped(self):
|
||||
path = os.path.join(self.tmp.name, "gone.png")
|
||||
self.assertFalse(self.handler.wait_until_settled(path))
|
||||
self.handler._process_pending(path)
|
||||
self.assertEqual(self.processed, [])
|
||||
|
||||
def test_settled_file_is_detected_after_two_stable_reads(self):
|
||||
path = self._image("e.png")
|
||||
self.assertTrue(self.handler.wait_until_settled(path))
|
||||
empty = self._image("empty.png", content=b"")
|
||||
self.assertFalse(self.handler.wait_until_settled(empty))
|
||||
|
||||
def test_shutdown_stops_the_worker_and_clears_pending(self):
|
||||
self.handler.processing_delay = 10 # keep the first image waiting
|
||||
self.handler.schedule(self._image("s1.png"))
|
||||
self.handler.schedule(self._image("s2.png"))
|
||||
worker = self.handler._worker
|
||||
self.assertTrue(worker.is_alive())
|
||||
|
||||
self.handler.shutdown()
|
||||
|
||||
worker.join(timeout=2)
|
||||
self.assertFalse(worker.is_alive())
|
||||
self.assertEqual(self.handler.pending_count(), 0)
|
||||
|
||||
def test_shutdown_is_idempotent_and_safe_before_start(self):
|
||||
fresh = im_mod.ImageGenerationHandler(MagicMock(), self.tracker)
|
||||
fresh.shutdown()
|
||||
fresh.shutdown()
|
||||
self.handler.schedule(self._image("s3.png"))
|
||||
self.assertTrue(wait_for(lambda: self.handler.pending_count() == 0))
|
||||
self.handler.shutdown()
|
||||
self.handler.shutdown()
|
||||
|
||||
def test_scheduling_after_shutdown_restarts_the_worker(self):
|
||||
self.handler.schedule(self._image("r1.png"))
|
||||
self.assertTrue(wait_for(lambda: self.handler.pending_count() == 0))
|
||||
old_worker = self.handler._worker
|
||||
self.handler.shutdown()
|
||||
old_worker.join(timeout=2)
|
||||
self.assertFalse(old_worker.is_alive())
|
||||
|
||||
path = self._image("r2.png")
|
||||
self.assertTrue(self.handler.schedule(path))
|
||||
self.assertTrue(wait_for(lambda: self.handler.pending_count() == 0))
|
||||
self.assertIn(path, [p for p, _ in self.processed])
|
||||
self.assertIsNot(self.handler._worker, old_worker)
|
||||
self.handler.shutdown()
|
||||
|
||||
def test_processing_errors_do_not_kill_the_worker(self):
|
||||
def explode(path, prompt_snapshot=None):
|
||||
self.processed.append(path)
|
||||
raise RuntimeError("boom")
|
||||
|
||||
self.handler.process_new_image = explode
|
||||
first, second = self._image("f1.png"), self._image("f2.png")
|
||||
self.handler.schedule(first)
|
||||
self.handler.schedule(second)
|
||||
self.assertTrue(wait_for(lambda: self.handler.pending_count() == 0))
|
||||
self.assertEqual(self.processed, [first, second])
|
||||
|
||||
|
||||
class TestModuleIsolation(unittest.TestCase):
|
||||
"""This module must not leave mocks behind for later test modules."""
|
||||
|
||||
def test_comfyui_integration_is_not_replaced_by_a_mock(self):
|
||||
module = sys.modules.get("utils.comfyui_integration")
|
||||
self.assertFalse(
|
||||
isinstance(module, MagicMock),
|
||||
"utils.comfyui_integration was replaced by a MagicMock in sys.modules",
|
||||
)
|
||||
|
||||
|
||||
class TestMonitorThreadSafety(unittest.TestCase):
|
||||
"""Singleton creation must be thread-safe."""
|
||||
|
||||
def setUp(self):
|
||||
im_mod._monitor_instance = None
|
||||
|
||||
def tearDown(self):
|
||||
im_mod._monitor_instance = None
|
||||
|
||||
def test_concurrent_get_image_monitor_returns_same_instance(self):
|
||||
"""Multiple threads calling get_image_monitor must get the same instance."""
|
||||
results = []
|
||||
barrier = threading.Barrier(5)
|
||||
|
||||
def get_monitor():
|
||||
barrier.wait()
|
||||
m = get_image_monitor(MagicMock(), MagicMock())
|
||||
results.append(id(m))
|
||||
|
||||
threads = [threading.Thread(target=get_monitor) for _ in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
self.assertEqual(len(set(results)), 1, "All threads must get the same instance")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Importing the extension package wires the queue hook to the shared database.
|
||||
|
||||
The package is loaded the way ComfyUI loads it (``__init__`` with relative
|
||||
imports) with ComfyUI's ``server`` and ``folder_paths`` modules stubbed.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
import unittest.mock as mock
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
try:
|
||||
from tests.open_handles import open_handles_under # noqa: E402
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import open_handles_under # noqa: E402
|
||||
|
||||
|
||||
class FakePromptQueue:
|
||||
def __init__(self):
|
||||
self.results = []
|
||||
|
||||
def get(self, timeout=None):
|
||||
return self.results.pop(0) if self.results else None
|
||||
|
||||
|
||||
def fake_server_module():
|
||||
"""A ``server`` module whose PromptServer.instance accepts routes and hooks."""
|
||||
instance = types.SimpleNamespace(
|
||||
routes=web.RouteTableDef(),
|
||||
prompt_queue=FakePromptQueue(),
|
||||
handlers=[],
|
||||
)
|
||||
instance.add_on_prompt_handler = instance.handlers.append
|
||||
module = types.ModuleType("server")
|
||||
module.PromptServer = type("PromptServer", (), {"instance": instance})
|
||||
return module
|
||||
|
||||
|
||||
def fake_folder_paths_module(output_dir):
|
||||
module = types.ModuleType("folder_paths")
|
||||
module.base_path = output_dir
|
||||
module.models_dir = os.path.join(output_dir, "models")
|
||||
module.get_output_directory = lambda: output_dir
|
||||
module.get_folder_paths = lambda name: []
|
||||
return module
|
||||
|
||||
|
||||
def load_package(name, db_path, output_dir, server=None):
|
||||
"""Import ROOT/__init__.py as package ``name`` with ComfyUI stubbed.
|
||||
|
||||
Returns (package, server instance). The caller stops the image monitor.
|
||||
"""
|
||||
saved = {key: sys.modules.get(key) for key in ("server", "folder_paths")}
|
||||
server = server or fake_server_module()
|
||||
sys.modules["server"] = server
|
||||
sys.modules["folder_paths"] = fake_folder_paths_module(output_dir)
|
||||
try:
|
||||
from py.config import PromptManagerConfig
|
||||
|
||||
with mock.patch.object(PromptManagerConfig, "DEFAULT_DB_PATH", db_path):
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
name,
|
||||
os.path.join(ROOT, "__init__.py"),
|
||||
submodule_search_locations=[ROOT],
|
||||
)
|
||||
package = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = package
|
||||
try:
|
||||
spec.loader.exec_module(package)
|
||||
except BaseException:
|
||||
sys.modules.pop(name, None)
|
||||
raise
|
||||
finally:
|
||||
for key, module in saved.items():
|
||||
if module is None:
|
||||
sys.modules.pop(key, None)
|
||||
else:
|
||||
sys.modules[key] = module
|
||||
return package, server.PromptServer.instance
|
||||
|
||||
|
||||
def close_every_database():
|
||||
"""Close connections of every PromptModel registry in the process.
|
||||
|
||||
A package loaded under its own name imports its own copy of
|
||||
database.models, whose PromptModel class keeps its own registry.
|
||||
"""
|
||||
for name, module in list(sys.modules.items()):
|
||||
if name.endswith("database.models") and hasattr(module, "PromptModel"):
|
||||
module.PromptModel.close_all_instances()
|
||||
|
||||
|
||||
class PackageTestCase(unittest.TestCase):
|
||||
package_name = "prompt_manager_pkg"
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.tmp = tempfile.TemporaryDirectory()
|
||||
cls.output_dir = os.path.join(cls.tmp.name, "output")
|
||||
os.makedirs(cls.output_dir)
|
||||
cls.db_path = cls.make_db_path(cls.tmp.name)
|
||||
cls.package, cls.server = load_package(
|
||||
cls.package_name, cls.db_path, cls.output_dir, server=cls.make_server()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def make_db_path(cls, tmp):
|
||||
return os.path.join(tmp, "prompts.db")
|
||||
|
||||
@classmethod
|
||||
def make_server(cls):
|
||||
return fake_server_module()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
monitor = getattr(cls.package, "_global_image_monitor", None)
|
||||
if monitor is not None:
|
||||
monitor.stop_monitoring()
|
||||
sys.modules.pop(cls.package_name, None)
|
||||
close_every_database()
|
||||
hits = open_handles_under(cls.tmp.name)
|
||||
cls.tmp.cleanup()
|
||||
assert not hits, f"files still open before temp dir cleanup: {hits}"
|
||||
|
||||
|
||||
class TestPackageWiring(PackageTestCase):
|
||||
def test_nodes_are_registered(self):
|
||||
self.assertEqual(
|
||||
sorted(self.package.NODE_CLASS_MAPPINGS),
|
||||
["PromptManager", "PromptManagerText", "PromptSearchList"],
|
||||
)
|
||||
|
||||
def test_db_factory_returns_the_shared_database(self):
|
||||
self.assertIsNotNone(self.package._global_db)
|
||||
self.assertIs(self.package._db_factory(), self.package._global_db)
|
||||
self.assertEqual(
|
||||
os.path.normcase(os.path.realpath(self.package._global_db.model.db_path)),
|
||||
os.path.normcase(os.path.realpath(self.db_path)),
|
||||
)
|
||||
|
||||
def test_db_factory_raises_while_the_database_is_unavailable(self):
|
||||
with mock.patch.object(self.package, "_global_db", None):
|
||||
with self.assertRaisesRegex(RuntimeError, "database unavailable"):
|
||||
self.package._db_factory()
|
||||
|
||||
def test_queue_hook_is_registered_and_the_queue_get_is_wrapped(self):
|
||||
self.assertEqual(len(self.server.handlers), 1)
|
||||
self.assertTrue(
|
||||
getattr(self.server.prompt_queue.get, "_prompt_manager_dequeue_hook", False)
|
||||
)
|
||||
|
||||
def test_image_monitor_watches_the_stubbed_output_directory(self):
|
||||
status = self.package._global_image_monitor.get_status()
|
||||
self.assertTrue(status["running"])
|
||||
self.assertEqual(
|
||||
[
|
||||
os.path.normcase(os.path.realpath(d))
|
||||
for d in status["monitored_directories"]
|
||||
],
|
||||
[os.path.normcase(os.path.realpath(self.output_dir))],
|
||||
)
|
||||
|
||||
|
||||
class TestPackageWithoutDatabase(PackageTestCase):
|
||||
"""The database failing to initialise must not break the queue hook."""
|
||||
|
||||
package_name = "prompt_manager_pkg_nodb"
|
||||
|
||||
@classmethod
|
||||
def make_db_path(cls, tmp):
|
||||
blocker = os.path.join(tmp, "blocker")
|
||||
with open(blocker, "w", encoding="utf-8") as f:
|
||||
f.write("not a directory")
|
||||
return os.path.join(blocker, "prompts.db") # parent is a file: init fails
|
||||
|
||||
def test_global_db_is_defined_but_unset(self):
|
||||
self.assertIsNone(self.package._global_db)
|
||||
with self.assertRaisesRegex(RuntimeError, "database unavailable"):
|
||||
self.package._db_factory()
|
||||
|
||||
def test_queue_hook_still_registers_and_survives_a_dequeue(self):
|
||||
self.assertEqual(len(self.server.handlers), 1)
|
||||
request = self.server.handlers[0](
|
||||
{"prompt": {"1": {"class_type": "PromptManager", "inputs": {"text": "x"}}}}
|
||||
)
|
||||
self.server.prompt_queue.results.append(
|
||||
((0, request["prompt_id"], request["prompt"], {}, [], False), 1)
|
||||
)
|
||||
self.assertIsNotNone(self.server.prompt_queue.get())
|
||||
|
||||
|
||||
class TestVersion(PackageTestCase):
|
||||
def test_version_comes_from_pyproject(self):
|
||||
import re
|
||||
|
||||
with open(os.path.join(ROOT, "pyproject.toml"), encoding="utf-8") as f:
|
||||
expected = re.search(r'version\s*=\s*["\']([^"\']+)["\']', f.read())
|
||||
self.assertEqual(self.package.get_version(), expected.group(1))
|
||||
|
||||
def test_unreadable_pyproject_gives_unknown(self):
|
||||
with mock.patch.object(self.package, "Path", side_effect=OSError("denied")):
|
||||
self.assertEqual(self.package.get_version(), "unknown")
|
||||
missing = mock.Mock()
|
||||
missing.parent.__truediv__ = lambda self_, name: mock.Mock(exists=lambda: False)
|
||||
with mock.patch.object(self.package, "Path", return_value=missing):
|
||||
self.assertEqual(self.package.get_version(), "unknown")
|
||||
|
||||
|
||||
class TestPackageWhenHookRegistrationFails(PackageTestCase):
|
||||
"""A server that rejects the hook must not break the rest of the package."""
|
||||
|
||||
package_name = "prompt_manager_pkg_nohook"
|
||||
|
||||
@classmethod
|
||||
def make_server(cls):
|
||||
module = fake_server_module()
|
||||
instance = module.PromptServer.instance
|
||||
|
||||
def reject(handler):
|
||||
raise RuntimeError("hooks unsupported")
|
||||
|
||||
instance.add_on_prompt_handler = reject
|
||||
return module
|
||||
|
||||
def test_nodes_and_monitoring_still_come_up(self):
|
||||
self.assertIn("PromptManager", self.package.NODE_CLASS_MAPPINGS)
|
||||
self.assertIsNotNone(self.package._global_db)
|
||||
self.assertTrue(self.package._global_image_monitor.is_monitoring)
|
||||
self.assertEqual(self.server.handlers, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestConsoleBanner(unittest.TestCase):
|
||||
"""Loading the node must not depend on the console being able to print emoji."""
|
||||
|
||||
def test_package_loads_with_a_cp1252_console(self):
|
||||
import contextlib
|
||||
import io
|
||||
|
||||
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_cp1252"
|
||||
stream = io.TextIOWrapper(io.BytesIO(), encoding="cp1252", errors="strict")
|
||||
|
||||
with contextlib.redirect_stdout(stream):
|
||||
package, _ = load_package(name, os.path.join(tmp.name, "p.db"), output_dir)
|
||||
self.addCleanup(sys.modules.pop, name, None)
|
||||
monitor = getattr(package, "_global_image_monitor", None)
|
||||
if monitor is not None:
|
||||
self.addCleanup(monitor.stop_monitoring)
|
||||
|
||||
stream.flush()
|
||||
banner = stream.buffer.getvalue().decode("cp1252")
|
||||
self.assertIn("Loaded:", banner)
|
||||
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
Database tests for LoRA Manager integration and folder filter features.
|
||||
|
||||
Tests delete_prompts_by_category, search_prompts folder filter,
|
||||
get_prompt_subfolders, and LoRA-specific prompt workflows using
|
||||
an in-memory SQLite database.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
class LoraDBTestCase(unittest.TestCase):
|
||||
"""Base class with temp database setup/teardown."""
|
||||
|
||||
def setUp(self):
|
||||
self.temp_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
|
||||
self.temp_db.close()
|
||||
self.db = PromptDatabase(self.temp_db.name)
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close()
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
path = self.temp_db.name + suffix
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
def _save(self, text, category=None, tags=None):
|
||||
"""Save a prompt and return its ID."""
|
||||
return self.db.save_prompt(
|
||||
text=text,
|
||||
category=category,
|
||||
tags=tags or [],
|
||||
prompt_hash=generate_prompt_hash(text),
|
||||
)
|
||||
|
||||
def _link_image(self, prompt_id, image_path):
|
||||
"""Link a fake image to a prompt."""
|
||||
return self.db.link_image_to_prompt(
|
||||
prompt_id=str(prompt_id), image_path=image_path
|
||||
)
|
||||
|
||||
|
||||
# ── delete_prompts_by_category ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDeleteByCategory(LoraDBTestCase):
|
||||
"""Test delete_prompts_by_category for LoRA reimport cleanup."""
|
||||
|
||||
def test_deletes_matching_category(self):
|
||||
self._save("lora prompt 1", category="lora-manager")
|
||||
self._save("lora prompt 2", category="lora-manager")
|
||||
self._save("keep this", category="general")
|
||||
|
||||
deleted = self.db.delete_prompts_by_category("lora-manager")
|
||||
|
||||
self.assertEqual(deleted, 2)
|
||||
results = self.db.search_prompts(category="lora-manager")
|
||||
self.assertEqual(len(results), 0)
|
||||
|
||||
def test_preserves_other_categories(self):
|
||||
self._save("keep this", category="general")
|
||||
self._save("and this", category="portraits")
|
||||
self.db.delete_prompts_by_category("lora-manager")
|
||||
|
||||
results = self.db.search_prompts()
|
||||
self.assertEqual(len(results), 2)
|
||||
|
||||
def test_returns_zero_when_none_match(self):
|
||||
self._save("no match", category="general")
|
||||
deleted = self.db.delete_prompts_by_category("lora-manager")
|
||||
self.assertEqual(deleted, 0)
|
||||
|
||||
def test_cascades_to_images(self):
|
||||
pid = self._save("lora with image", category="lora-manager")
|
||||
self._link_image(pid, "/fake/path/image.jpg")
|
||||
|
||||
# Verify image is linked
|
||||
images = self.db.get_prompt_images(pid)
|
||||
self.assertEqual(len(images), 1)
|
||||
|
||||
self.db.delete_prompts_by_category("lora-manager")
|
||||
|
||||
# Prompt gone
|
||||
results = self.db.search_prompts(category="lora-manager")
|
||||
self.assertEqual(len(results), 0)
|
||||
|
||||
def test_empty_category_string(self):
|
||||
self._save("test", category="general")
|
||||
deleted = self.db.delete_prompts_by_category("")
|
||||
self.assertEqual(deleted, 0)
|
||||
|
||||
|
||||
# ── Folder filter (search_prompts with folder param) ──────────────────
|
||||
|
||||
|
||||
class TestFolderFilter(LoraDBTestCase):
|
||||
"""Test search_prompts folder parameter for subfolder filtering."""
|
||||
|
||||
def _setup_prompts_with_images(self):
|
||||
"""Create prompts linked to images in different directories."""
|
||||
pid1 = self._save("landscape prompt", category="nature")
|
||||
self._link_image(pid1, "/output/landscapes/sunset.png")
|
||||
|
||||
pid2 = self._save("portrait prompt", category="portraits")
|
||||
self._link_image(pid2, "/output/portraits/face.png")
|
||||
|
||||
pid3 = self._save("another landscape", category="nature")
|
||||
self._link_image(pid3, "/output/landscapes/mountain.png")
|
||||
|
||||
return pid1, pid2, pid3
|
||||
|
||||
def test_filter_by_folder(self):
|
||||
self._setup_prompts_with_images()
|
||||
results = self.db.search_prompts(folder="landscapes")
|
||||
self.assertEqual(len(results), 2)
|
||||
texts = {r["text"] for r in results}
|
||||
self.assertEqual(texts, {"landscape prompt", "another landscape"})
|
||||
|
||||
def test_filter_different_folder(self):
|
||||
self._setup_prompts_with_images()
|
||||
results = self.db.search_prompts(folder="portraits")
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0]["text"], "portrait prompt")
|
||||
|
||||
def test_no_match_returns_empty(self):
|
||||
self._setup_prompts_with_images()
|
||||
results = self.db.search_prompts(folder="nonexistent")
|
||||
self.assertEqual(len(results), 0)
|
||||
|
||||
def test_no_folder_returns_all(self):
|
||||
self._setup_prompts_with_images()
|
||||
results = self.db.search_prompts()
|
||||
self.assertGreaterEqual(len(results), 3)
|
||||
|
||||
def test_folder_with_category_filter(self):
|
||||
self._setup_prompts_with_images()
|
||||
results = self.db.search_prompts(folder="landscapes", category="nature")
|
||||
self.assertEqual(len(results), 2)
|
||||
|
||||
|
||||
# ── get_prompt_subfolders ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetPromptSubfolders(LoraDBTestCase):
|
||||
"""Test get_prompt_subfolders — extracts unique folder names from images."""
|
||||
|
||||
def test_extracts_subfolders(self):
|
||||
pid1 = self._save("prompt 1")
|
||||
self._link_image(pid1, "/output/folder_a/img1.png")
|
||||
|
||||
pid2 = self._save("prompt 2")
|
||||
self._link_image(pid2, "/output/folder_b/img2.png")
|
||||
|
||||
folders = self.db.get_prompt_subfolders()
|
||||
self.assertIsInstance(folders, list)
|
||||
self.assertGreaterEqual(len(folders), 2)
|
||||
|
||||
def test_deduplicates(self):
|
||||
pid1 = self._save("prompt 1")
|
||||
self._link_image(pid1, "/output/same_folder/img1.png")
|
||||
|
||||
pid2 = self._save("prompt 2")
|
||||
self._link_image(pid2, "/output/same_folder/img2.png")
|
||||
|
||||
folders = self.db.get_prompt_subfolders()
|
||||
# Count occurrences of the folder — should appear once
|
||||
matches = [f for f in folders if "same_folder" in f]
|
||||
self.assertEqual(len(matches), 1)
|
||||
|
||||
def test_empty_database(self):
|
||||
folders = self.db.get_prompt_subfolders()
|
||||
self.assertEqual(folders, [])
|
||||
|
||||
def test_returns_sorted(self):
|
||||
for i, name in enumerate(["charlie", "alpha", "bravo"]):
|
||||
pid = self._save(f"prompt {i}")
|
||||
self._link_image(pid, f"/output/{name}/img.png")
|
||||
|
||||
folders = self.db.get_prompt_subfolders()
|
||||
self.assertEqual(folders, sorted(folders))
|
||||
|
||||
def test_with_root_dirs(self):
|
||||
pid = self._save("prompt")
|
||||
self._link_image(pid, "/output/sub/deep/img.png")
|
||||
|
||||
folders = self.db.get_prompt_subfolders(root_dirs=["/output"])
|
||||
self.assertIsInstance(folders, list)
|
||||
self.assertGreater(len(folders), 0)
|
||||
|
||||
def test_subfolders_use_forward_slashes_on_every_platform(self):
|
||||
"""Clients split folders on "/" whatever separator the OS produced."""
|
||||
from unittest import mock
|
||||
|
||||
import database.operations as operations
|
||||
|
||||
pid = self._save("prompt")
|
||||
self._link_image(pid, "/output/2026/08-Aug/06/img.png")
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
operations.os.path, "relpath", return_value="2026\\08-Aug\\06"
|
||||
),
|
||||
mock.patch.object(operations.os, "sep", "\\"),
|
||||
):
|
||||
folders = self.db.get_prompt_subfolders(root_dirs=["/output"])
|
||||
|
||||
self.assertEqual(folders, ["2026/08-Aug/06"])
|
||||
|
||||
def test_include_ancestors_adds_intermediate_paths(self):
|
||||
pid = self._save("prompt")
|
||||
self._link_image(pid, "/output/2026/08-Aug/2026-08-06/img.png")
|
||||
|
||||
folders = self.db.get_prompt_subfolders(
|
||||
root_dirs=["/output"], include_ancestors=True
|
||||
)
|
||||
self.assertIn("2026", folders)
|
||||
self.assertIn("2026/08-Aug", folders)
|
||||
self.assertIn("2026/08-Aug/2026-08-06", folders)
|
||||
|
||||
def test_include_ancestors_false_no_intermediates(self):
|
||||
pid = self._save("prompt")
|
||||
self._link_image(pid, "/output/2026/08-Aug/2026-08-06/img.png")
|
||||
|
||||
folders = self.db.get_prompt_subfolders(
|
||||
root_dirs=["/output"], include_ancestors=False
|
||||
)
|
||||
self.assertIn("2026/08-Aug/2026-08-06", folders)
|
||||
self.assertNotIn("2026", folders)
|
||||
self.assertNotIn("2026/08-Aug", folders)
|
||||
|
||||
def test_include_ancestors_sorted(self):
|
||||
pid = self._save("prompt")
|
||||
self._link_image(pid, "/output/a/b/c/img.png")
|
||||
|
||||
folders = self.db.get_prompt_subfolders(
|
||||
root_dirs=["/output"], include_ancestors=True
|
||||
)
|
||||
self.assertEqual(folders, sorted(folders))
|
||||
|
||||
|
||||
# ── LoRA prompt workflow ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLoraPromptWorkflow(LoraDBTestCase):
|
||||
"""Test the full LoRA import workflow at the database layer."""
|
||||
|
||||
def test_save_lora_prompt_with_tags(self):
|
||||
"""Simulate what lora_scan does: save prompt with lora-manager tags."""
|
||||
pid = self._save(
|
||||
text="1girl, detailed face, anime style",
|
||||
category="lora-manager",
|
||||
tags=["lora-manager", "lora:my_lora", "trigger1"],
|
||||
)
|
||||
prompt = self.db.get_prompt_by_id(pid)
|
||||
self.assertEqual(prompt["category"], "lora-manager")
|
||||
self.assertIn("lora-manager", prompt["tags"])
|
||||
|
||||
def test_reimport_clears_and_recreates(self):
|
||||
"""Simulate reimport: delete old, create new."""
|
||||
# First import
|
||||
pid1 = self._save("old lora prompt", category="lora-manager")
|
||||
self._link_image(pid1, "/cache/old.jpg")
|
||||
|
||||
# Reimport
|
||||
self.db.delete_prompts_by_category("lora-manager")
|
||||
|
||||
# Second import
|
||||
pid2 = self._save("new lora prompt", category="lora-manager")
|
||||
self._link_image(pid2, "/cache/new.jpg")
|
||||
|
||||
results = self.db.search_prompts(category="lora-manager")
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0]["text"], "new lora prompt")
|
||||
|
||||
def test_hash_dedup_prevents_duplicates(self):
|
||||
"""Verify hash-based dedup works for LoRA prompts."""
|
||||
text = "duplicate lora prompt"
|
||||
h = generate_prompt_hash(text)
|
||||
|
||||
self._save(text, category="lora-manager")
|
||||
existing = self.db.get_prompt_by_hash(h)
|
||||
self.assertIsNotNone(existing)
|
||||
|
||||
def test_link_multiple_images_to_lora_prompt(self):
|
||||
"""LoRA prompts can have multiple preview images."""
|
||||
pid = self._save("multi-image lora", category="lora-manager")
|
||||
self._link_image(pid, "/cache/lora/img1.jpg")
|
||||
self._link_image(pid, "/cache/lora/img2.jpg")
|
||||
self._link_image(pid, "/cache/lora/img3.jpg")
|
||||
|
||||
images = self.db.get_prompt_images(pid)
|
||||
self.assertEqual(len(images), 3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user