feat(system): report configured, effective and watched sources
GET /prompt_manager/settings adds settings.monitoring_enabled, a sources
block (configured roots with valid/code/reason, effective roots with their
listing index and watch state, auto_detected) and the monitor state
(present, observer_alive, scheduled). Every path is a public path.
A source is "watching" only while a live observer scheduled it, "manual"
when monitoring is off or the process has no monitor, and otherwise
"restart_pending".
POST accepts monitoring_enabled as a strict bool, saved under
gallery.monitoring.enabled; restart_required_fields names it when the
running observer does not already match. A save that changes the sources
runs as a "Source change" records hold, so it is refused with 409 busy
while a scan runs. Case variants of one folder are saved once.
POST /prompt_manager/settings/validate-source {path} checks a path
lexically (no "..", no control characters, native path forms only) and
then against the gallery-root rules, answering a code and reason, and
writes nothing.
This commit is contained in:
+170
-17
@@ -4,8 +4,15 @@ A save validates the payload, commits it to config.json through the config
|
||||
store and publishes it at runtime inside that commit; a save that cannot be
|
||||
written changes nothing at runtime and says so. ``_config_commit_failure``
|
||||
is shared with the LoRA integration's save.
|
||||
|
||||
GET also reports the image sources and the monitor (``sources.py``). A save
|
||||
that changes the sources runs as a ``source_change`` registry hold, so it is
|
||||
refused while a record-changing job (a scan) runs. ``monitoring_enabled``
|
||||
takes effect at the next restart. ``POST /prompt_manager/settings/validate-source``
|
||||
checks one path and writes nothing.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
|
||||
from aiohttp import web
|
||||
@@ -15,7 +22,10 @@ from ..config_store import (
|
||||
RevisionConflict,
|
||||
get_config_store,
|
||||
)
|
||||
from . import sources
|
||||
from .admin import _current_image_monitor
|
||||
from .job_routes import busy_response
|
||||
from .jobs import JobBusy
|
||||
|
||||
try:
|
||||
from ...utils.validators import validate_result_timeout, validate_worker_threads
|
||||
@@ -23,13 +33,36 @@ except ImportError:
|
||||
from utils.validators import validate_result_timeout, validate_worker_threads
|
||||
|
||||
|
||||
# Settings owned by POST /settings: config attribute -> (section, key).
|
||||
# Settings owned by POST /settings: config attribute -> its keys in config.json.
|
||||
_SETTINGS_FILE_KEYS = {
|
||||
"RESULT_TIMEOUT": ("web_ui", "result_timeout"),
|
||||
"WEBUI_DISPLAY_MODE": ("web_ui", "webui_display_mode"),
|
||||
"INFINITE_SCROLL": ("web_ui", "infinite_scroll"),
|
||||
"WORKER_THREADS": ("performance", "worker_threads"),
|
||||
"MONITORING_ENABLED": ("gallery", "monitoring", "enabled"),
|
||||
}
|
||||
# Attributes that live on GalleryConfig; the others on PromptManagerConfig.
|
||||
_GALLERY_SETTINGS = frozenset({"MONITORING_ENABLED"})
|
||||
SOURCE_CHANGE_JOB_KIND = "source_change"
|
||||
SOURCE_CHANGE_JOB_TITLE = "Source change"
|
||||
|
||||
|
||||
def _source_identity(path):
|
||||
"""The case-folded spelling two roots are the same directory by (Windows)."""
|
||||
return os.path.normcase(path)
|
||||
|
||||
|
||||
def _stat_identity(path):
|
||||
"""``(st_dev, st_ino)`` of an existing directory, or None.
|
||||
|
||||
Catches case variants on a case-insensitive filesystem whose spelling
|
||||
normcase keeps (macOS).
|
||||
"""
|
||||
try:
|
||||
stat = os.stat(path)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return (stat.st_dev, stat.st_ino) if stat.st_ino else None
|
||||
|
||||
|
||||
def _section(config, name):
|
||||
@@ -47,10 +80,8 @@ def _settings_mutation(updates, new_roots):
|
||||
|
||||
def mutate(config):
|
||||
for name, value in updates.items():
|
||||
section, key = _SETTINGS_FILE_KEYS[name]
|
||||
merged = _section(config, section)
|
||||
merged[key] = value
|
||||
config[section] = merged
|
||||
*sections, key = _SETTINGS_FILE_KEYS[name]
|
||||
_set_nested(config, sections, key, value)
|
||||
if new_roots is not None:
|
||||
gallery = _section(config, "gallery")
|
||||
monitoring = _section(gallery, "monitoring")
|
||||
@@ -62,6 +93,16 @@ def _settings_mutation(updates, new_roots):
|
||||
return mutate
|
||||
|
||||
|
||||
def _set_nested(config, sections, key, value):
|
||||
"""``config[s1][s2]...[key] = value``, each section a fresh dict."""
|
||||
if not sections:
|
||||
config[key] = value
|
||||
return
|
||||
merged = _section(config, sections[0])
|
||||
_set_nested(merged, sections[1:], key, value)
|
||||
config[sections[0]] = merged
|
||||
|
||||
|
||||
async def _read_json(request):
|
||||
"""Parse a JSON object body through the package's capped reader."""
|
||||
from . import _read_json_body
|
||||
@@ -81,6 +122,10 @@ class SettingsRoutesMixin:
|
||||
async def save_settings_route(request):
|
||||
return await self.save_settings(request)
|
||||
|
||||
@routes.post("/prompt_manager/settings/validate-source")
|
||||
async def validate_source_route(request):
|
||||
return await self.validate_source(request)
|
||||
|
||||
async def get_settings(self, request):
|
||||
"""Get current settings."""
|
||||
try:
|
||||
@@ -90,11 +135,15 @@ class SettingsRoutesMixin:
|
||||
self._public_path(d) for d in GalleryConfig.MONITORING_DIRECTORIES
|
||||
]
|
||||
revision = await self._run_in_executor(self._config_revision)
|
||||
source_status, monitor = await self._run_in_executor(self._source_status)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
**revision,
|
||||
"sources": source_status,
|
||||
"monitor": monitor,
|
||||
"settings": {
|
||||
"monitoring_enabled": GalleryConfig.MONITORING_ENABLED,
|
||||
"result_timeout": PromptManagerConfig.RESULT_TIMEOUT,
|
||||
"webui_display_mode": PromptManagerConfig.WEBUI_DISPLAY_MODE,
|
||||
"infinite_scroll": PromptManagerConfig.INFINITE_SCROLL,
|
||||
@@ -115,6 +164,61 @@ class SettingsRoutesMixin:
|
||||
status=500,
|
||||
)
|
||||
|
||||
def _source_status(self):
|
||||
"""``(sources, monitor)`` of GET /settings (blocking)."""
|
||||
from ..config import GalleryConfig
|
||||
|
||||
output_dirs, auto_detected = self._output_dirs_with_origin()
|
||||
return sources.describe_sources(
|
||||
configured=list(GalleryConfig.MONITORING_DIRECTORIES),
|
||||
output_dirs=output_dirs,
|
||||
auto_detected=auto_detected,
|
||||
monitor=_current_image_monitor(),
|
||||
enabled=GalleryConfig.MONITORING_ENABLED,
|
||||
)
|
||||
|
||||
async def validate_source(self, request):
|
||||
"""Check one source path ``{path}``; writes nothing.
|
||||
|
||||
200 ``{success, valid, label, path, code, reason}``: a valid source
|
||||
answers its label and public path, an invalid one its code and reason
|
||||
only. A body without a string ``path`` is a 400 ``invalid-request``.
|
||||
"""
|
||||
data, error_response = await _read_json(request)
|
||||
if error_response is not None:
|
||||
too_large = error_response.status == 413
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "too-large" if too_large else "invalid-request",
|
||||
"error": (
|
||||
"Request body too large"
|
||||
if too_large
|
||||
else "Request body must be a JSON object"
|
||||
),
|
||||
},
|
||||
status=error_response.status,
|
||||
)
|
||||
path = data.get("path")
|
||||
if not isinstance(path, str):
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "invalid-request",
|
||||
"error": "path must be a string",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
try:
|
||||
result = await self._run_in_executor(sources.validate_source, path)
|
||||
except Exception:
|
||||
self.logger.exception("Source validation failed")
|
||||
return web.json_response(
|
||||
{"success": False, "code": None, "error": "Failed to check the source"},
|
||||
status=500,
|
||||
)
|
||||
return web.json_response({"success": True, **result})
|
||||
|
||||
def _monitored_directories(self):
|
||||
"""Directories the running image monitor watches, else the configured roots."""
|
||||
from ..config import GalleryConfig
|
||||
@@ -162,15 +266,30 @@ class SettingsRoutesMixin:
|
||||
# they were written, so the runtime ends on the last one.
|
||||
published.update(self._publish_settings(updates, new_roots))
|
||||
|
||||
try:
|
||||
_saved, revision = await self._run_in_executor(
|
||||
get_config_store().commit,
|
||||
_settings_mutation(updates, new_roots),
|
||||
expected_revision=expected_revision,
|
||||
publish=publish,
|
||||
)
|
||||
except (ConfigUnreadable, RevisionConflict, OSError) as exc:
|
||||
return self._config_commit_failure(exc)
|
||||
async with contextlib.AsyncExitStack() as stack:
|
||||
hold = None
|
||||
if self._roots_change(new_roots):
|
||||
try:
|
||||
hold = await stack.enter_async_context(
|
||||
self._jobs.hold(
|
||||
SOURCE_CHANGE_JOB_KIND,
|
||||
SOURCE_CHANGE_JOB_TITLE,
|
||||
admission="records",
|
||||
)
|
||||
)
|
||||
except JobBusy as busy:
|
||||
return busy_response(busy)
|
||||
try:
|
||||
_saved, revision = await self._run_in_executor(
|
||||
get_config_store().commit,
|
||||
_settings_mutation(updates, new_roots),
|
||||
expected_revision=expected_revision,
|
||||
publish=publish,
|
||||
)
|
||||
except (ConfigUnreadable, RevisionConflict, OSError) as exc:
|
||||
if hold is not None:
|
||||
hold.finish("failed")
|
||||
return self._config_commit_failure(exc)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -196,10 +315,11 @@ class SettingsRoutesMixin:
|
||||
as ``runtime_applied: false`` with the fields that need a restart;
|
||||
nothing is retried or rolled back. Never raises.
|
||||
"""
|
||||
from ..config import PromptManagerConfig
|
||||
from ..config import GalleryConfig, PromptManagerConfig
|
||||
|
||||
for name, value in updates.items():
|
||||
setattr(PromptManagerConfig, name, value)
|
||||
owner = GalleryConfig if name in _GALLERY_SETTINGS else PromptManagerConfig
|
||||
setattr(owner, name, value)
|
||||
restart_fields = []
|
||||
runtime_applied = True
|
||||
if new_roots is not None:
|
||||
@@ -212,11 +332,33 @@ class SettingsRoutesMixin:
|
||||
)
|
||||
runtime_applied = False
|
||||
restart_fields.append("gallery_root_paths")
|
||||
if "MONITORING_ENABLED" in updates:
|
||||
# The monitor starts with the server: a restart applies the setting
|
||||
# unless the observer already matches it.
|
||||
if updates["MONITORING_ENABLED"] != self._observer_alive():
|
||||
restart_fields.append("monitoring_enabled")
|
||||
return {
|
||||
"runtime_applied": runtime_applied,
|
||||
"restart_required_fields": restart_fields,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _roots_change(new_roots):
|
||||
"""Whether a save changes the sources (the legacy page resends them)."""
|
||||
from ..config import GalleryConfig
|
||||
|
||||
return new_roots is not None and new_roots != list(
|
||||
GalleryConfig.MONITORING_DIRECTORIES
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _observer_alive():
|
||||
"""Whether the image monitor's observer is running now. Never raises."""
|
||||
try:
|
||||
return sources.monitor_state(_current_image_monitor())[1]
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _config_commit_failure(self, exc):
|
||||
"""The response for a config commit that wrote nothing."""
|
||||
not_saved = {"success": False, "persisted": False, "runtime_applied": False}
|
||||
@@ -283,6 +425,10 @@ class SettingsRoutesMixin:
|
||||
if not isinstance(data["infinite_scroll"], bool):
|
||||
raise ValueError("infinite_scroll must be true or false")
|
||||
updates["INFINITE_SCROLL"] = data["infinite_scroll"]
|
||||
if "monitoring_enabled" in data:
|
||||
if not isinstance(data["monitoring_enabled"], bool):
|
||||
raise ValueError("monitoring_enabled must be true or false")
|
||||
updates["MONITORING_ENABLED"] = data["monitoring_enabled"]
|
||||
if "worker_threads" in data:
|
||||
updates["WORKER_THREADS"] = validate_worker_threads(
|
||||
data["worker_threads"], PromptManagerConfig.max_worker_threads()
|
||||
@@ -312,6 +458,7 @@ class SettingsRoutesMixin:
|
||||
from ..config import GalleryConfig
|
||||
|
||||
roots = []
|
||||
seen = set()
|
||||
for entry in raw:
|
||||
if not isinstance(entry, str):
|
||||
return None, "Gallery root paths must be strings"
|
||||
@@ -324,7 +471,13 @@ class SettingsRoutesMixin:
|
||||
None,
|
||||
f"Invalid gallery root '{os.path.basename(entry)}': {reason}",
|
||||
)
|
||||
roots.append(GalleryConfig.resolve_gallery_root(entry))
|
||||
root = GalleryConfig.resolve_gallery_root(entry)
|
||||
keys = {("path", _source_identity(root)), ("file", _stat_identity(root))}
|
||||
keys.discard(("file", None))
|
||||
if keys & seen:
|
||||
continue # another spelling of a root already listed
|
||||
seen |= keys
|
||||
roots.append(root)
|
||||
return roots, None
|
||||
|
||||
def _apply_gallery_roots(self, roots):
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Image sources as System › Sources & scans shows them (#249 S4).
|
||||
|
||||
- ``source_form_error`` is the lexical check a submitted source path passes
|
||||
before any filesystem access: no control characters, no ``..`` part, and
|
||||
only the native path forms (on Windows a drive or a UNC share, never the
|
||||
``\\\\?\\`` and ``\\\\.\\`` namespaces, a drive-relative path, an alternate
|
||||
data stream, a device name or a part Windows would silently trim; on POSIX
|
||||
no Windows drive or backslash form).
|
||||
- ``describe_sources`` (blocking: it resolves paths) reports the configured
|
||||
sources with their validity, the effective sources with their listing
|
||||
index and watch state, and the image monitor's state. Every path in it is
|
||||
a public path (``_public_path``), never an absolute one.
|
||||
|
||||
A source's watch state is ``watching`` when the monitor's observer is alive
|
||||
and scheduled that directory; ``manual`` when monitoring is off or this
|
||||
process has no monitor; else ``restart_pending`` (a source added since the
|
||||
monitor started, monitoring switched on, or an observer that stopped).
|
||||
"""
|
||||
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
|
||||
MAX_SOURCE_PATH = 4096 # characters
|
||||
FORM_REASONS = {
|
||||
"empty": "Gallery root must be a non-empty path",
|
||||
"invalid-form": "Gallery root is not a folder path this server can use",
|
||||
"traversal": "Gallery root must not contain '..'",
|
||||
}
|
||||
_DRIVE = re.compile(r"[A-Za-z]:")
|
||||
_POSIX_FOREIGN = re.compile(r"^[A-Za-z]:")
|
||||
# Reserved device names: Windows opens the device for any folder of that name.
|
||||
_DEVICE_NAME = re.compile(r"^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³])(\..*)?$", re.I)
|
||||
# folder_paths getters and the label of the directory each one reports.
|
||||
_COMFYUI_LABELS = (
|
||||
("get_output_directory", "ComfyUI output"),
|
||||
("get_input_directory", "ComfyUI input"),
|
||||
("get_temp_directory", "ComfyUI temp"),
|
||||
("get_user_directory", "ComfyUI user"),
|
||||
)
|
||||
WATCH_STATES = ("watching", "manual", "restart_pending")
|
||||
|
||||
|
||||
def source_form_error(text, *, windows=None):
|
||||
"""The form code that refuses ``text`` as a source path, or None.
|
||||
|
||||
``windows`` selects the path rules (default: this OS). No filesystem
|
||||
access happens here.
|
||||
"""
|
||||
if windows is None:
|
||||
windows = os.name == "nt"
|
||||
if not isinstance(text, str) or len(text) > MAX_SOURCE_PATH:
|
||||
return "invalid-form"
|
||||
if any(ord(char) < 32 or ord(char) == 127 for char in text):
|
||||
return "invalid-form"
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return "empty"
|
||||
return _windows_form_error(text) if windows else _posix_form_error(text)
|
||||
|
||||
|
||||
def _posix_form_error(text):
|
||||
if "\\" in text or _POSIX_FOREIGN.match(text):
|
||||
return "invalid-form" # a Windows path names nothing on this server
|
||||
return "traversal" if ".." in text.split("/") else None
|
||||
|
||||
|
||||
def _windows_form_error(text):
|
||||
portable = text.replace("/", "\\")
|
||||
if portable.startswith(("\\\\?\\", "\\\\.\\")):
|
||||
return "invalid-form" # verbatim and device namespaces
|
||||
if portable.startswith("\\\\"):
|
||||
drive, rest = ntpath.splitdrive(portable)
|
||||
share = drive[2:].split("\\")
|
||||
if len(share) != 2 or any(part in ("", ".", "..") for part in share):
|
||||
return "invalid-form"
|
||||
else:
|
||||
drive, rest = ntpath.splitdrive(portable)
|
||||
if drive and (not _DRIVE.fullmatch(drive) or not rest.startswith("\\")):
|
||||
return "invalid-form" # "C:folder" is relative to that drive's cwd
|
||||
if ":" in rest:
|
||||
return "invalid-form" # an alternate data stream
|
||||
parts = rest.split("\\")
|
||||
if ".." in parts:
|
||||
return "traversal"
|
||||
for part in parts:
|
||||
if part in ("", "."):
|
||||
continue
|
||||
if part[-1] in ". " or _DEVICE_NAME.match(part):
|
||||
return "invalid-form"
|
||||
return None
|
||||
|
||||
|
||||
def identity(path):
|
||||
"""The comparison key of a directory: resolved and case-folded (Windows)."""
|
||||
return os.path.normcase(os.path.realpath(str(path)))
|
||||
|
||||
|
||||
def _comfyui_labels():
|
||||
"""``{identity: label}`` of the directories ComfyUI reports."""
|
||||
try:
|
||||
import folder_paths
|
||||
except ImportError:
|
||||
return {}
|
||||
labels = {}
|
||||
for getter_name, label in _COMFYUI_LABELS:
|
||||
getter = getattr(folder_paths, getter_name, None)
|
||||
if getter is None:
|
||||
continue
|
||||
try:
|
||||
directory = getter()
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(directory, str) and directory:
|
||||
labels.setdefault(identity(directory), label)
|
||||
return labels
|
||||
|
||||
|
||||
def source_label(path, labels=None):
|
||||
""" "ComfyUI output" for ComfyUI's own directories, else the folder name."""
|
||||
if labels is None:
|
||||
labels = _comfyui_labels()
|
||||
label = labels.get(identity(path))
|
||||
if label:
|
||||
return label
|
||||
return os.path.basename(os.path.normpath(str(path))) or str(path)
|
||||
|
||||
|
||||
def _configured(entry, labels, public_path):
|
||||
from ..config import GalleryConfig
|
||||
|
||||
code, reason = GalleryConfig.gallery_root_problem(entry)
|
||||
try:
|
||||
resolved = GalleryConfig.resolve_gallery_root(entry)
|
||||
except (OSError, TypeError, ValueError):
|
||||
resolved = str(entry)
|
||||
return {
|
||||
"label": source_label(resolved, labels),
|
||||
"path": public_path(resolved),
|
||||
"valid": code is None,
|
||||
"code": code,
|
||||
"reason": reason or None,
|
||||
}
|
||||
|
||||
|
||||
def monitor_state(monitor):
|
||||
"""``(present, observer_alive, scheduled directories)`` of the image monitor."""
|
||||
if monitor is None:
|
||||
return False, False, []
|
||||
status = monitor.get_status()
|
||||
scheduled = [str(d) for d in status.get("monitored_directories") or []]
|
||||
return True, bool(status.get("observer_alive")), scheduled
|
||||
|
||||
|
||||
def watch_state(path, *, present, alive, scheduled_keys, enabled):
|
||||
"""``watching``, ``manual`` or ``restart_pending`` (see the module docstring)."""
|
||||
if alive and identity(path) in scheduled_keys:
|
||||
return "watching"
|
||||
if not present or not enabled:
|
||||
return "manual"
|
||||
return "restart_pending"
|
||||
|
||||
|
||||
def describe_sources(*, configured, output_dirs, auto_detected, monitor, enabled):
|
||||
"""``(sources, monitor)`` for GET /settings (blocking; public paths only).
|
||||
|
||||
``configured`` is the configured roots, ``output_dirs`` and
|
||||
``auto_detected`` what ``_output_dirs_with_origin`` returned, ``monitor``
|
||||
the running ImageMonitor or None, ``enabled`` the monitoring setting.
|
||||
"""
|
||||
from . import _public_path
|
||||
|
||||
labels = _comfyui_labels()
|
||||
present, alive, scheduled = monitor_state(monitor)
|
||||
scheduled_keys = {identity(path) for path in scheduled}
|
||||
effective = [
|
||||
{
|
||||
"label": source_label(path, labels),
|
||||
"path": _public_path(path),
|
||||
"root_index": index,
|
||||
"watch": watch_state(
|
||||
path,
|
||||
present=present,
|
||||
alive=alive,
|
||||
scheduled_keys=scheduled_keys,
|
||||
enabled=enabled,
|
||||
),
|
||||
}
|
||||
for index, path in enumerate(output_dirs)
|
||||
]
|
||||
return (
|
||||
{
|
||||
"configured": [
|
||||
_configured(entry, labels, _public_path) for entry in configured
|
||||
],
|
||||
"effective": effective,
|
||||
"auto_detected": bool(auto_detected),
|
||||
},
|
||||
{
|
||||
"present": present,
|
||||
"observer_alive": alive,
|
||||
"scheduled": [_public_path(path) for path in scheduled],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def validate_source(text):
|
||||
"""``{valid, label, path, code, reason}`` for one submitted path (blocking).
|
||||
|
||||
Writes nothing. An invalid path answers its code and reason with no
|
||||
label or path, so a response never echoes what was typed.
|
||||
"""
|
||||
from ..config import GalleryConfig
|
||||
from . import _public_path
|
||||
|
||||
code = source_form_error(text)
|
||||
reason = FORM_REASONS.get(code)
|
||||
if code is None:
|
||||
code, reason = GalleryConfig.gallery_root_problem(text)
|
||||
if code is not None:
|
||||
return {
|
||||
"valid": False,
|
||||
"label": None,
|
||||
"path": None,
|
||||
"code": code,
|
||||
"reason": reason,
|
||||
}
|
||||
resolved = GalleryConfig.resolve_gallery_root(text)
|
||||
return {
|
||||
"valid": True,
|
||||
"label": source_label(resolved),
|
||||
"path": _public_path(resolved),
|
||||
"code": None,
|
||||
"reason": None,
|
||||
}
|
||||
+25
-9
@@ -268,7 +268,12 @@ class GalleryConfig:
|
||||
"""
|
||||
monitoring = new_config.get("monitoring", {})
|
||||
if "enabled" in monitoring:
|
||||
cls.MONITORING_ENABLED = monitoring["enabled"]
|
||||
if isinstance(monitoring["enabled"], bool):
|
||||
cls.MONITORING_ENABLED = monitoring["enabled"]
|
||||
else: # a hand-edited "false" would otherwise switch it on
|
||||
config_logger.warning(
|
||||
"Ignoring gallery monitoring enabled: expected true or false"
|
||||
)
|
||||
if "directories" in monitoring:
|
||||
directories = cls._validated_directories(monitoring["directories"])
|
||||
if directories is not None:
|
||||
@@ -399,30 +404,41 @@ class GalleryConfig:
|
||||
(True, "") when the directory lies inside one of
|
||||
:meth:`allowed_gallery_parents`; otherwise (False, reason).
|
||||
"""
|
||||
code, reason = cls.gallery_root_problem(path)
|
||||
return code is None, reason
|
||||
|
||||
@classmethod
|
||||
def gallery_root_problem(cls, path: Any) -> Tuple[Optional[str], str]:
|
||||
"""``(code, reason)`` why ``path`` may not be a gallery root, else (None, "").
|
||||
|
||||
Codes: ``empty``, ``unresolvable``, ``filesystem-root``,
|
||||
``home-directory``, ``not-found``, ``not-directory`` and
|
||||
``outside-allowed``. No reason names the path.
|
||||
"""
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
return False, "Gallery root must be a non-empty path"
|
||||
return "empty", "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"
|
||||
return "unresolvable", "Gallery root could not be resolved"
|
||||
|
||||
if _is_filesystem_root(canonical):
|
||||
return False, "Gallery root cannot be a filesystem root"
|
||||
return "filesystem-root", "Gallery root cannot be a filesystem root"
|
||||
if canonical == _canonical_path(os.path.expanduser("~")):
|
||||
return False, "Gallery root cannot be the home directory"
|
||||
return "home-directory", "Gallery root cannot be the home directory"
|
||||
if not os.path.exists(canonical):
|
||||
return False, "Gallery root does not exist"
|
||||
return "not-found", "Gallery root does not exist"
|
||||
if not os.path.isdir(canonical):
|
||||
return False, "Gallery root is not a directory"
|
||||
return "not-directory", "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 None, ""
|
||||
|
||||
return (
|
||||
False,
|
||||
"outside-allowed",
|
||||
"Gallery root must be inside a ComfyUI directory "
|
||||
f"(output, input, temp, user) or one listed in {EXTRA_GALLERY_ROOTS_ENV}",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
"""System › Sources & scans backend (#249 S4 Task 9).
|
||||
|
||||
``GET /prompt_manager/settings`` reports the configured, effective and
|
||||
watched sources and the monitoring setting; ``POST`` saves
|
||||
``monitoring_enabled`` (a restart applies it) and refuses a source change
|
||||
while a record-changing job runs; ``POST /prompt_manager/settings/validate-source``
|
||||
checks one path and writes nothing. No response carries an absolute path.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import ntpath
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from tests.test_api_admin import AdminAPITestCase
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from test_api_admin import AdminAPITestCase
|
||||
|
||||
from py.api import settings_routes, sources # noqa: E402
|
||||
from py.config import GalleryConfig # noqa: E402
|
||||
|
||||
VALIDATE = "/prompt_manager/settings/validate-source"
|
||||
|
||||
|
||||
class FakeMonitor:
|
||||
"""The image monitor as get_settings sees it: status and scheduled roots."""
|
||||
|
||||
def __init__(self, scheduled, alive=True):
|
||||
self.monitored_directories = [str(path) for path in scheduled]
|
||||
self.alive = alive
|
||||
|
||||
def get_status(self):
|
||||
return {
|
||||
"running": True,
|
||||
"monitored_directories": self.monitored_directories,
|
||||
"handler_active": True,
|
||||
"observer_alive": self.alive,
|
||||
}
|
||||
|
||||
|
||||
class SourcesTestCase(AdminAPITestCase):
|
||||
async def asyncSetUp(self):
|
||||
await super().asyncSetUp()
|
||||
self.a = self.output_dir / "a"
|
||||
self.b = self.output_dir / "b"
|
||||
self.a.mkdir()
|
||||
self.b.mkdir()
|
||||
enabled = GalleryConfig.MONITORING_ENABLED
|
||||
self.addCleanup(setattr, GalleryConfig, "MONITORING_ENABLED", enabled)
|
||||
GalleryConfig.MONITORING_ENABLED = True
|
||||
self._install_monitor(None)
|
||||
|
||||
def _install_monitor(self, monitor):
|
||||
import utils.image_monitor as im_mod
|
||||
|
||||
orig = im_mod._monitor_instance
|
||||
im_mod._monitor_instance = monitor
|
||||
self.addCleanup(setattr, im_mod, "_monitor_instance", orig)
|
||||
|
||||
async def _get(self):
|
||||
resp = await self.client.request("GET", "/prompt_manager/settings")
|
||||
body = await resp.text()
|
||||
self.assertEqual(resp.status, 200, body)
|
||||
self.assertNotIn(self.tmpdir, body)
|
||||
return json.loads(body)
|
||||
|
||||
async def _post(self, url, payload):
|
||||
resp = await self.client.request("POST", url, json=payload)
|
||||
body = await resp.text()
|
||||
self.assertNotIn(self.tmpdir, body)
|
||||
return resp.status, json.loads(body)
|
||||
|
||||
def _config_bytes(self):
|
||||
if not os.path.exists(self.config_path):
|
||||
return None
|
||||
with open(self.config_path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
def _states(self, data):
|
||||
return {row["path"]: row["watch"] for row in data["sources"]["effective"]}
|
||||
|
||||
|
||||
class TestSourceStatus(SourcesTestCase):
|
||||
async def test_sources_list_configured_effective_and_auto_detected(self):
|
||||
missing = self.output_dir / "gone"
|
||||
missing.mkdir()
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [
|
||||
self._root(self.a),
|
||||
self._root(missing),
|
||||
]
|
||||
missing.rmdir() # an unmounted drive: configured, no longer a directory
|
||||
|
||||
data = await self._get()
|
||||
|
||||
self.assertEqual(
|
||||
data["sources"]["configured"],
|
||||
[
|
||||
{
|
||||
"label": "a",
|
||||
"path": "output/a",
|
||||
"valid": True,
|
||||
"code": None,
|
||||
"reason": None,
|
||||
},
|
||||
{
|
||||
"label": "gone",
|
||||
"path": "output/gone",
|
||||
"valid": False,
|
||||
"code": "not-found",
|
||||
"reason": "Gallery root does not exist",
|
||||
},
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
data["sources"]["effective"],
|
||||
[{"label": "a", "path": "output/a", "root_index": 0, "watch": "manual"}],
|
||||
)
|
||||
self.assertFalse(data["sources"]["auto_detected"])
|
||||
# The legacy keys are unchanged.
|
||||
self.assertEqual(
|
||||
data["settings"]["gallery_root_paths"], ["output/a", "output/gone"]
|
||||
)
|
||||
|
||||
async def test_empty_list_means_auto_detect(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.a)]
|
||||
self._write_config({"gallery": {"monitoring": {"directories": ["x"]}}})
|
||||
# Detection (the upward search for a ComfyUI tree) finds the output.
|
||||
self.api._find_comfyui_output_dir = lambda: str(self.output_dir)
|
||||
|
||||
status, saved = await self._post(
|
||||
"/prompt_manager/settings", {"gallery_root_paths": []}
|
||||
)
|
||||
|
||||
self.assertEqual(status, 200, saved)
|
||||
self.assertEqual(
|
||||
self._read_config()["gallery"]["monitoring"]["directories"], []
|
||||
)
|
||||
data = await self._get()
|
||||
self.assertEqual(data["sources"]["configured"], [])
|
||||
self.assertTrue(data["sources"]["auto_detected"])
|
||||
self.assertEqual(
|
||||
data["sources"]["effective"],
|
||||
[
|
||||
{
|
||||
"label": "ComfyUI output",
|
||||
"path": "output",
|
||||
"root_index": 0,
|
||||
"watch": "manual",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
async def test_watching_requires_live_observer_with_root(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.a), self._root(self.b)]
|
||||
|
||||
# No monitor in this process: nothing is watched, a restart cannot help.
|
||||
data = await self._get()
|
||||
self.assertEqual(
|
||||
data["monitor"],
|
||||
{"present": False, "observer_alive": False, "scheduled": []},
|
||||
)
|
||||
self.assertEqual(
|
||||
self._states(data), {"output/a": "manual", "output/b": "manual"}
|
||||
)
|
||||
|
||||
# A dead observer watches nothing, even the roots it scheduled.
|
||||
self._install_monitor(FakeMonitor([self.a], alive=False))
|
||||
data = await self._get()
|
||||
self.assertEqual(
|
||||
data["monitor"],
|
||||
{"present": True, "observer_alive": False, "scheduled": ["output/a"]},
|
||||
)
|
||||
self.assertEqual(
|
||||
self._states(data),
|
||||
{"output/a": "restart_pending", "output/b": "restart_pending"},
|
||||
)
|
||||
|
||||
# A live observer watches its scheduled root; a root configured since
|
||||
# it started waits for a restart.
|
||||
self._install_monitor(FakeMonitor([self.a]))
|
||||
data = await self._get()
|
||||
self.assertTrue(data["monitor"]["observer_alive"])
|
||||
self.assertEqual(
|
||||
self._states(data), {"output/a": "watching", "output/b": "restart_pending"}
|
||||
)
|
||||
|
||||
# Monitoring switched off: the live observer still watches until the
|
||||
# restart, and nothing else is pending.
|
||||
GalleryConfig.MONITORING_ENABLED = False
|
||||
data = await self._get()
|
||||
self.assertFalse(data["settings"]["monitoring_enabled"])
|
||||
self.assertEqual(
|
||||
self._states(data), {"output/a": "watching", "output/b": "manual"}
|
||||
)
|
||||
|
||||
async def test_scheduled_root_matches_through_its_spelling(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.a)]
|
||||
spelled = os.path.join(str(self.output_dir), "b", "..", "a")
|
||||
self._install_monitor(FakeMonitor([spelled]))
|
||||
|
||||
data = await self._get()
|
||||
|
||||
self.assertEqual(self._states(data), {"output/a": "watching"})
|
||||
self.assertEqual(data["monitor"]["scheduled"], ["output/a"])
|
||||
|
||||
async def test_status_failure_is_500_without_paths(self):
|
||||
def boom(*args, **kwargs):
|
||||
raise OSError(2, "No such file", f"{self.tmpdir}/secret")
|
||||
|
||||
with patch.object(sources, "describe_sources", boom):
|
||||
resp = await self.client.request("GET", "/prompt_manager/settings")
|
||||
self.assertEqual(resp.status, 500)
|
||||
self.assertNotIn(self.tmpdir, await resp.text())
|
||||
|
||||
|
||||
class TestMonitoringSetting(SourcesTestCase):
|
||||
async def test_monitoring_enabled_strict_bool_and_restart_flag(self):
|
||||
self._write_config({"custom": {"keep": True}})
|
||||
before = self._config_bytes()
|
||||
for value in ("false", 0, 1, None, [], {"on": True}):
|
||||
with self.subTest(value=value):
|
||||
status, data = await self._post(
|
||||
"/prompt_manager/settings", {"monitoring_enabled": value}
|
||||
)
|
||||
self.assertEqual(status, 400, data)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertEqual(self._config_bytes(), before)
|
||||
self.assertTrue(GalleryConfig.MONITORING_ENABLED)
|
||||
|
||||
self._install_monitor(FakeMonitor([self.output_dir]))
|
||||
status, data = await self._post(
|
||||
"/prompt_manager/settings", {"monitoring_enabled": False}
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertTrue(data["persisted"])
|
||||
self.assertTrue(data["restart_required"])
|
||||
self.assertEqual(data["restart_required_fields"], ["monitoring_enabled"])
|
||||
saved = self._read_config()
|
||||
self.assertIs(saved["gallery"]["monitoring"]["enabled"], False)
|
||||
self.assertEqual(saved["custom"], {"keep": True})
|
||||
self.assertFalse(GalleryConfig.MONITORING_ENABLED)
|
||||
self.assertFalse((await self._get())["settings"]["monitoring_enabled"])
|
||||
|
||||
# Back on while the observer still runs: nothing waits for a restart.
|
||||
status, data = await self._post(
|
||||
"/prompt_manager/settings", {"monitoring_enabled": True}
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(data["restart_required_fields"], [])
|
||||
self.assertIs(self._read_config()["gallery"]["monitoring"]["enabled"], True)
|
||||
|
||||
async def test_enabling_without_a_live_observer_needs_a_restart(self):
|
||||
GalleryConfig.MONITORING_ENABLED = False
|
||||
status, data = await self._post(
|
||||
"/prompt_manager/settings", {"monitoring_enabled": True}
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(data["restart_required_fields"], ["monitoring_enabled"])
|
||||
self.assertTrue(GalleryConfig.MONITORING_ENABLED)
|
||||
|
||||
def test_config_file_enabled_must_be_a_bool(self):
|
||||
GalleryConfig.MONITORING_ENABLED = True
|
||||
with self.assertLogs("prompt_manager.config", level="WARNING"):
|
||||
GalleryConfig.update_config({"monitoring": {"enabled": "false"}})
|
||||
self.assertIs(GalleryConfig.MONITORING_ENABLED, True)
|
||||
GalleryConfig.update_config({"monitoring": {"enabled": False}})
|
||||
self.assertIs(GalleryConfig.MONITORING_ENABLED, False)
|
||||
|
||||
|
||||
class TestSourceChangeHold(SourcesTestCase):
|
||||
async def test_root_save_refused_while_scan_job_runs(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.a)]
|
||||
self._write_config({"gallery": {"monitoring": {"directories": ["kept"]}}})
|
||||
before = self._config_bytes()
|
||||
gate = asyncio.Event()
|
||||
|
||||
async def runner(job):
|
||||
await gate.wait()
|
||||
|
||||
scan = self.api._jobs.start(
|
||||
"scan", "Scan for metadata", record_changing=True, runner=runner
|
||||
)
|
||||
try:
|
||||
status, data = await self._post(
|
||||
"/prompt_manager/settings",
|
||||
{"gallery_root_paths": [str(self.b)]},
|
||||
)
|
||||
self.assertEqual(status, 409, data)
|
||||
self.assertEqual(data["code"], "busy")
|
||||
self.assertEqual(
|
||||
data["running"], {"id": scan.id, "title": "Scan for metadata"}
|
||||
)
|
||||
self.assertEqual(self._config_bytes(), before)
|
||||
self.assertEqual(GalleryConfig.MONITORING_DIRECTORIES, [self._root(self.a)])
|
||||
|
||||
# A save without sources, or one resending the current sources
|
||||
# (the legacy page always does), is not a source change.
|
||||
status, data = await self._post(
|
||||
"/prompt_manager/settings", {"result_timeout": 9}
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
status, data = await self._post(
|
||||
"/prompt_manager/settings",
|
||||
{"result_timeout": 8, "gallery_root_paths": [str(self.a)]},
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
finally:
|
||||
gate.set()
|
||||
await asyncio.wait_for(scan.task, 5)
|
||||
|
||||
async def test_root_save_runs_as_a_source_change_hold(self):
|
||||
seen = []
|
||||
original = settings_routes.get_config_store
|
||||
|
||||
def store():
|
||||
seen.extend(
|
||||
(job.kind, job.title, job.admission, job.cancellable)
|
||||
for job in self.api._jobs.list()
|
||||
if job.running
|
||||
)
|
||||
return original()
|
||||
|
||||
with patch.object(settings_routes, "get_config_store", store):
|
||||
status, data = await self._post(
|
||||
"/prompt_manager/settings", {"gallery_root_paths": [str(self.b)]}
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertIn(("source_change", "Source change", "records", False), seen)
|
||||
self.assertFalse(any(job.running for job in self.api._jobs.list()))
|
||||
|
||||
async def test_failed_root_save_ends_the_hold_failed(self):
|
||||
os.makedirs(self.config_path) # config.json is a directory: unreadable
|
||||
status, data = await self._post(
|
||||
"/prompt_manager/settings", {"gallery_root_paths": [str(self.b)]}
|
||||
)
|
||||
self.assertGreaterEqual(status, 400, data)
|
||||
held = [j for j in self.api._jobs.list() if j.kind == "source_change"]
|
||||
self.assertEqual([job.state for job in held], ["failed"])
|
||||
|
||||
async def test_mixed_case_windows_paths_dedupe(self):
|
||||
"""Two spellings of one Windows folder are saved once (normcase)."""
|
||||
with (
|
||||
patch.object(
|
||||
GalleryConfig, "validate_gallery_root", return_value=(True, "")
|
||||
),
|
||||
patch.object(
|
||||
GalleryConfig, "resolve_gallery_root", side_effect=lambda path: path
|
||||
),
|
||||
patch.object(settings_routes, "_source_identity", ntpath.normcase),
|
||||
patch.object(self.api, "_apply_gallery_roots", return_value=True),
|
||||
):
|
||||
roots, error = self.api._parse_gallery_roots(
|
||||
{
|
||||
"gallery_root_paths": [
|
||||
"C:\\ComfyUI\\Output\\Renders",
|
||||
"c:/comfyui/output/renders",
|
||||
"C:\\ComfyUI\\output\\other",
|
||||
]
|
||||
}
|
||||
)
|
||||
self.assertIsNone(error)
|
||||
self.assertEqual(
|
||||
roots, ["C:\\ComfyUI\\Output\\Renders", "C:\\ComfyUI\\output\\other"]
|
||||
)
|
||||
|
||||
async def test_case_variant_of_one_folder_is_saved_once_natively(self):
|
||||
variant = str(self.a)[:-1] + "A"
|
||||
if not os.path.isdir(variant):
|
||||
self.skipTest("case-sensitive filesystem")
|
||||
status, data = await self._post(
|
||||
"/prompt_manager/settings", {"gallery_root_paths": [str(self.a), variant]}
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(len(GalleryConfig.MONITORING_DIRECTORIES), 1)
|
||||
|
||||
|
||||
class TestValidateSource(SourcesTestCase):
|
||||
async def test_validate_source_writes_nothing(self):
|
||||
self._write_config({"gallery": {"monitoring": {"directories": []}}})
|
||||
before = self._config_bytes()
|
||||
roots = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
|
||||
status, data = await self._post(VALIDATE, {"path": str(self.a)})
|
||||
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(
|
||||
data,
|
||||
{
|
||||
"success": True,
|
||||
"valid": True,
|
||||
"label": "a",
|
||||
"path": "output/a",
|
||||
"code": None,
|
||||
"reason": None,
|
||||
},
|
||||
)
|
||||
self.assertEqual(self._config_bytes(), before)
|
||||
self.assertEqual(GalleryConfig.MONITORING_DIRECTORIES, roots)
|
||||
self.assertEqual(self.api._jobs.list(), [])
|
||||
|
||||
async def test_relative_path_resolves_against_comfyui(self):
|
||||
status, data = await self._post(VALIDATE, {"path": "output/b"})
|
||||
self.assertEqual((status, data["valid"], data["path"]), (200, True, "output/b"))
|
||||
status, data = await self._post(VALIDATE, {"path": "output"})
|
||||
self.assertEqual(data["label"], "ComfyUI output")
|
||||
|
||||
async def test_invalid_sources_answer_a_code(self):
|
||||
a_file = self.output_dir / "file.png"
|
||||
a_file.write_bytes(b"x")
|
||||
cases = {
|
||||
"": "empty",
|
||||
" ": "empty",
|
||||
str(self.output_dir / "missing"): "not-found",
|
||||
str(a_file): "not-directory",
|
||||
str(self.outside_dir): "outside-allowed",
|
||||
os.path.abspath(os.sep): "filesystem-root",
|
||||
os.path.expanduser("~"): "home-directory",
|
||||
os.path.join(str(self.output_dir), "b", "..", "a"): "traversal",
|
||||
"output/../output/a": "traversal",
|
||||
str(self.a) + "\x00": "invalid-form",
|
||||
"\\\\?\\C:\\ComfyUI\\output": "invalid-form",
|
||||
"x" * 5000: "invalid-form",
|
||||
}
|
||||
if os.name != "nt":
|
||||
cases["C:\\ComfyUI\\output"] = "invalid-form"
|
||||
for path, code in cases.items():
|
||||
with self.subTest(code=code, path=path[:40]):
|
||||
status, data = await self._post(VALIDATE, {"path": path})
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertFalse(data["valid"])
|
||||
self.assertEqual(data["code"], code)
|
||||
self.assertIsInstance(data["reason"], str)
|
||||
self.assertIsNone(data["label"])
|
||||
self.assertIsNone(data["path"])
|
||||
self.assertNotIn(os.path.expanduser("~"), json.dumps(data))
|
||||
|
||||
async def test_malformed_request_is_400(self):
|
||||
for body in ({}, {"path": 42}, {"path": None}, ["x"]):
|
||||
with self.subTest(body=body):
|
||||
status, data = await self._post(VALIDATE, body)
|
||||
self.assertEqual(status, 400, data)
|
||||
self.assertEqual(data["code"], "invalid-request")
|
||||
self.assertFalse(data["success"])
|
||||
|
||||
|
||||
class TestSourceEdges(SourcesTestCase):
|
||||
async def test_validation_failure_is_500_without_paths(self):
|
||||
def boom(path):
|
||||
raise OSError(13, "Permission denied", f"{self.tmpdir}/secret")
|
||||
|
||||
with patch.object(sources, "validate_source", boom):
|
||||
status, data = await self._post(VALIDATE, {"path": str(self.a)})
|
||||
self.assertEqual(status, 500)
|
||||
self.assertEqual(data["error"], "Failed to check the source")
|
||||
|
||||
async def test_oversized_body_is_413_too_large(self):
|
||||
resp = await self.client.request(
|
||||
"POST", VALIDATE, data=json.dumps({"path": "x" * (2 << 20)})
|
||||
)
|
||||
self.assertEqual(resp.status, 413)
|
||||
self.assertEqual((await resp.json())["code"], "too-large")
|
||||
|
||||
async def test_unreadable_monitor_counts_as_no_live_observer(self):
|
||||
class Broken:
|
||||
def get_status(self):
|
||||
raise RuntimeError("observer gone")
|
||||
|
||||
self._install_monitor(Broken())
|
||||
status, data = await self._post(
|
||||
"/prompt_manager/settings", {"monitoring_enabled": True}
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(data["restart_required_fields"], ["monitoring_enabled"])
|
||||
|
||||
def test_labels_skip_getters_that_fail_or_are_missing(self):
|
||||
import sys
|
||||
import types
|
||||
|
||||
def fails():
|
||||
raise RuntimeError("no input dir")
|
||||
|
||||
fake = types.SimpleNamespace(
|
||||
get_output_directory=lambda: str(self.output_dir),
|
||||
get_input_directory=fails,
|
||||
get_temp_directory=lambda: None,
|
||||
)
|
||||
with patch.dict(sys.modules, {"folder_paths": fake}):
|
||||
self.assertEqual(sources.source_label(self.output_dir), "ComfyUI output")
|
||||
self.assertEqual(sources.source_label(self.a), "a")
|
||||
with patch.dict(sys.modules, {"folder_paths": None}):
|
||||
self.assertEqual(sources.source_label(self.output_dir), "output")
|
||||
|
||||
|
||||
class TestSourceFormError(unittest.TestCase):
|
||||
"""The lexical check that runs before any filesystem access."""
|
||||
|
||||
def test_windows_forms(self):
|
||||
err = sources.source_form_error
|
||||
for path in (
|
||||
"C:\\ComfyUI\\output",
|
||||
"C:/ComfyUI/output",
|
||||
"\\\\server\\share\\output",
|
||||
"D:\\images",
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
self.assertIsNone(err(path, windows=True))
|
||||
for path, code in {
|
||||
"C:output": "invalid-form", # drive-relative
|
||||
"C:\\out\\a:stream": "invalid-form", # alternate data stream
|
||||
"C:\\out\\CON": "invalid-form",
|
||||
"C:\\out\\nul.txt": "invalid-form",
|
||||
"C:\\out\\folder.": "invalid-form",
|
||||
"C:\\out\\folder \\sub": "invalid-form",
|
||||
"\\\\?\\C:\\out": "invalid-form",
|
||||
"//?/C:/out": "invalid-form",
|
||||
"\\\\.\\pipe\\x": "invalid-form",
|
||||
"\\\\server": "invalid-form",
|
||||
"C:\\out\\..\\in": "traversal",
|
||||
"output\\..\\input": "traversal",
|
||||
}.items():
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(err(path, windows=True), code)
|
||||
|
||||
def test_posix_forms(self):
|
||||
err = sources.source_form_error
|
||||
for path in ("/srv/comfy/output", "output/renders", "out/CON", "a b/c."):
|
||||
with self.subTest(path=path):
|
||||
self.assertIsNone(err(path, windows=False))
|
||||
for path, code in {
|
||||
"C:\\ComfyUI\\output": "invalid-form",
|
||||
"C:/ComfyUI/output": "invalid-form",
|
||||
"\\\\server\\share": "invalid-form",
|
||||
"out\\renders": "invalid-form",
|
||||
"/srv/../etc": "traversal",
|
||||
"../output": "traversal",
|
||||
"/srv/out\x00": "invalid-form",
|
||||
"/srv/out\n": "invalid-form",
|
||||
"": "empty",
|
||||
}.items():
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(err(path, windows=False), code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user