Files
ComfyAssets-ComfyUI_PromptM…/utils/trash.py
T
Vito d88cf9e7c6 fix(quality): sweep the Code Quality standard findings (#558)
* fix(quality): preserve cursor iteration and option validation

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

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

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

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

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

* fix(quality): drop the extra saveCoverage argument

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

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

* refactor(quality): drop imports repeated inside functions

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

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

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

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

* refactor(quality): use the precise unittest asserts

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

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

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

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

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

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

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

* refactor(quality): use the precise assert in the model status test
2026-10-31 05:43:48 +00:00

427 lines
15 KiB
Python

"""Move one media file into the system trash, or refuse (#480).
Two rules hold for every backend:
- **Rename or fail.** The only primitive is a no-replace rename within one
filesystem. Nothing is ever copied, unlinked or overwritten: a file that
cannot be moved recoverably stays where it is, and ``TrashRefused`` says why.
- **Never into a root.** A trash folder inside an output root would be listed
as images, so it is refused (``trash-in-root``). The one exception (#543): for
an output root that is exactly a mount root, that mount's own ``.Trash-$uid``
and ``.Trash/$uid``, which every walker and media route ignores
(``utils.media_folders``).
Linux and BSD use :class:`FreedesktopBackend`, the freedesktop.org Trash
specification 1.0: the home trash (``$XDG_DATA_HOME/Trash``) for a file on the
same mount, else the file's mount's ``.Trash/$uid`` (only under a real, sticky
``.Trash``) or ``.Trash-$uid``. Names are reserved by creating the
``.trashinfo`` with ``O_EXCL`` and the file is moved with
``renameat2(RENAME_NOREPLACE)``; without that call the move is refused.
macOS uses :class:`utils.trash_macos.MacTrashBackend` (#499): NSFileManager's
``trashItemAtURL`` through ctypes, after refusing what it cannot show to be
recoverable. Windows never uses the Recycle Bin (#500: files over its capacity
are deleted permanently, undetectably), so it answers ``windows-recycle``
through :class:`UnavailableBackend`.
``BACKEND`` is the one replaceable seam; tests install a fake.
"""
from __future__ import annotations
import ctypes
import datetime
import errno
import os
import stat
import sys
import urllib.parse
NO_TRASH = "no-trash"
NETWORK = "network"
TRASH_IN_ROOT = "trash-in-root"
PATH_TOO_LONG = "path-too-long"
LOCKED = "locked"
FAILED = "failed"
NO_PERMISSION = "no-permission"
CLOUD_SYNC = "cloud-sync"
# Windows never uses the Recycle Bin (#500): it can delete permanently.
WINDOWS_RECYCLE = "windows-recycle"
CODES = frozenset(
{
NO_TRASH,
NETWORK,
TRASH_IN_ROOT,
PATH_TOO_LONG,
LOCKED,
FAILED,
NO_PERMISSION,
CLOUD_SYNC,
WINDOWS_RECYCLE,
}
)
HOME_TRASH_LABEL = "home trash"
RENAME_NOREPLACE = 1
MAX_NAME_ATTEMPTS = 1000
_DIR_FLAGS = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
# rename() errors that mean "this trash cannot take it", never a copy.
_NO_TRASH_ERRNOS = {
errno.ENOSYS,
errno.EINVAL,
errno.EXDEV,
errno.EACCES,
errno.EPERM,
errno.EROFS,
getattr(errno, "ENOTSUP", errno.EOPNOTSUPP),
errno.EOPNOTSUPP,
}
class TrashRefused(Exception):
"""The file was not moved; ``code`` is one of ``CODES``."""
def __init__(self, code: str, detail: str = ""):
super().__init__(detail or code)
self.code = code
self.detail = detail
class TrashUnconfirmed(Exception):
"""The system reported the file moved, but not to a place shown to be a trash.
The file is no longer where it was, so this is not a refusal: the caller
keeps the file's records and stops (#499 design §7.4).
"""
def __init__(self, detail: str):
super().__init__(detail)
self.detail = detail
def parse_mountinfo(text: str) -> list[str]:
"""Mount points of ``/proc/self/mountinfo`` text (octal escapes decoded)."""
points = []
for line in text.splitlines():
fields = line.split(" ")
if len(fields) > 4:
raw = fields[4]
points.append(
bytes(raw, "utf-8")
.decode("unicode_escape")
.encode("latin-1")
.decode("utf-8", "surrogateescape")
)
return points
def _proc_mounts() -> list[str]:
try:
with open(
"/proc/self/mountinfo", encoding="utf-8", errors="surrogateescape"
) as f:
return parse_mountinfo(f.read())
except OSError:
return []
def _inside(path: str, folder: str) -> bool:
folder = folder.rstrip("/") or "/"
return path == folder or path.startswith(folder if folder == "/" else folder + "/")
def mount_of(path: str, mounts: list[str]) -> str:
"""The longest mount point holding *path* (both real paths)."""
best = "/"
for point in mounts:
if _inside(path, point) and len(point) > len(best):
best = point
return best
def _ismount_walk(path: str) -> str:
"""Fallback without mountinfo (BSD): walk up until ``os.path.ismount``."""
current = path
while not os.path.ismount(current):
parent = os.path.dirname(current)
if parent == current:
break
current = parent
return current
def _libc_renameat2():
if os.name != "posix":
return None # Windows: no libc, and no freedesktop backend
try:
libc = ctypes.CDLL(None, use_errno=True)
function = libc.renameat2
except (OSError, AttributeError, TypeError):
return None
function.argtypes = [
ctypes.c_int,
ctypes.c_char_p,
ctypes.c_int,
ctypes.c_char_p,
ctypes.c_uint,
]
function.restype = ctypes.c_int
return function
_RENAMEAT2 = _libc_renameat2()
def rename_noreplace(src_dir_fd: int, src: str, dst_dir_fd: int, dst: str) -> None:
"""``renameat2(..., RENAME_NOREPLACE)``; raises OSError (ENOSYS when missing)."""
if _RENAMEAT2 is None:
raise OSError(errno.ENOSYS, "renameat2 is not available")
result = _RENAMEAT2(
src_dir_fd, os.fsencode(src), dst_dir_fd, os.fsencode(dst), RENAME_NOREPLACE
)
if result != 0:
code = ctypes.get_errno()
raise OSError(code, os.strerror(code))
def _canonical(path: str) -> str:
return os.path.normcase(os.path.realpath(path))
def _check_roots(trash_dir: str, roots, top: str | None = None) -> None:
"""Refuse a trash that a root would list, or that holds a root.
*top* is the mount a per-drive *trash_dir* was built from. A root that is
exactly that mount may hold it, unless a link makes the trash really live
somewhere else (#543). Any other root holding it refuses, also beside one.
"""
trash_key = _canonical(trash_dir)
top_key = None if top is None else _canonical(top)
unlinked = trash_key == os.path.normcase(trash_dir)
for root in roots:
root_key = _canonical(str(root))
if _inside(root_key, trash_key):
raise TrashRefused(TRASH_IN_ROOT)
if _inside(trash_key, root_key) and not (unlinked and root_key == top_key):
raise TrashRefused(TRASH_IN_ROOT)
def _owned_folder(path: str, uid: int, *, create: bool) -> bool:
"""Whether *path* is (or, with *create*, now is) a real folder we own."""
try:
info = os.lstat(path)
except FileNotFoundError:
if not create:
return False
try:
os.mkdir(path, 0o700)
except OSError:
return False
info = os.lstat(path)
except OSError:
return False
return stat.S_ISDIR(info.st_mode) and info.st_uid == uid
class UnavailableBackend:
"""No trash on this system: every move is refused with *code*, untouched."""
def __init__(self, code: str = NO_TRASH):
self.code = code
def available(self, path: str, roots) -> str | None:
return self.code
def move(self, path: str, roots) -> str:
raise TrashRefused(self.code)
class FreedesktopBackend:
"""The freedesktop.org Trash specification 1.0 (Linux, BSD)."""
def __init__(self, data_home=None, mounts=None, rename=None, uid=None, now=None):
self._data_home = data_home
self._mounts = mounts or _proc_mounts
self._rename = rename or rename_noreplace
if uid is None:
uid = os.getuid() if hasattr(os, "getuid") else -1
self._uid = uid
self._now = now or datetime.datetime.now
def _home_data(self) -> str:
if self._data_home:
return self._data_home
configured = os.environ.get("XDG_DATA_HOME")
if configured and os.path.isabs(configured):
return configured
return os.path.join(os.path.expanduser("~"), ".local", "share")
def _mount(self, real: str, mounts) -> str:
return mount_of(real, mounts) if mounts else _ismount_walk(real)
def _plan(self, path: str, roots, *, create: bool):
"""``(trash_dir, topdir_or_None, label)`` for *path*; raises TrashRefused."""
real = os.path.realpath(path)
mounts = [os.path.realpath(point) for point in self._mounts()]
home_data = os.path.realpath(self._home_data())
if self._mount(real, mounts) == self._mount(home_data, mounts):
trash_dir = os.path.join(home_data, "Trash")
_check_roots(trash_dir, roots)
if create:
os.makedirs(trash_dir, mode=0o700, exist_ok=True)
return trash_dir, None, HOME_TRASH_LABEL
top = self._mount(real, mounts)
shared = os.path.join(top, ".Trash")
try:
info = os.lstat(shared)
sticky = stat.S_ISDIR(info.st_mode) and bool(info.st_mode & stat.S_ISVTX)
except OSError:
sticky = False
candidates = []
if sticky:
candidates.append(os.path.join(shared, str(self._uid)))
candidates.append(os.path.join(top, f".Trash-{self._uid}"))
for trash_dir in candidates:
_check_roots(trash_dir, roots, top)
if self._usable(trash_dir, create=create):
label = f"{os.path.relpath(trash_dir, top)} on its drive"
return trash_dir, top, label
raise TrashRefused(NO_TRASH)
def _usable(self, trash_dir: str, *, create: bool) -> bool:
if os.path.lexists(trash_dir):
return _owned_folder(trash_dir, self._uid, create=False)
parent = os.path.dirname(trash_dir)
if not create:
return os.access(parent, os.W_OK | os.X_OK)
return _owned_folder(trash_dir, self._uid, create=True)
def available(self, path: str, roots) -> str | None:
"""None when *path* can be trashed, else the refusal code (no writes)."""
try:
self._plan(path, roots, create=False)
except TrashRefused as refused:
return refused.code
except OSError:
return NO_TRASH
return None
def move(self, path: str, roots) -> str:
"""Move *path* into its trash and return the trash's label."""
try:
trash_dir, top, label = self._plan(path, roots, create=True)
except OSError as error:
raise TrashRefused(NO_TRASH, str(error)) from error
real = os.path.realpath(path)
recorded = os.path.relpath(real, top) if top else real
opened = []
try:
trash_fd = self._open_dir(trash_dir, opened)
files_fd = self._subfolder(trash_fd, "files", opened)
info_fd = self._subfolder(trash_fd, "info", opened)
source_fd = self._open_dir(os.path.dirname(real), opened)
self._move_named(
source_fd, os.path.basename(real), files_fd, info_fd, recorded
)
finally:
for fd in opened:
os.close(fd)
return label
def _open_dir(self, path: str, opened: list) -> int:
try:
fd = os.open(path, _DIR_FLAGS)
except OSError as error:
raise TrashRefused(NO_TRASH, str(error)) from error
opened.append(fd)
return fd
def _subfolder(self, parent_fd: int, name: str, opened: list) -> int:
try:
os.mkdir(name, 0o700, dir_fd=parent_fd)
except FileExistsError:
# the folder already exists; the checks below still vet it
pass
except OSError as error:
raise TrashRefused(NO_TRASH, str(error)) from error
try:
info = os.lstat(name, dir_fd=parent_fd)
except OSError as error:
raise TrashRefused(NO_TRASH, str(error)) from error
if not stat.S_ISDIR(info.st_mode) or info.st_uid != self._uid:
raise TrashRefused(NO_TRASH, f"{name} is not our folder")
try:
fd = os.open(name, _DIR_FLAGS, dir_fd=parent_fd)
except OSError as error:
raise TrashRefused(NO_TRASH, str(error)) from error
opened.append(fd)
return fd
def _info_text(self, recorded: str) -> bytes:
encoded = urllib.parse.quote(os.fsencode(recorded), safe="/")
stamp = self._now().strftime("%Y-%m-%dT%H:%M:%S")
return f"[Trash Info]\nPath={encoded}\nDeletionDate={stamp}\n".encode("utf-8")
def _move_named(self, source_fd, name, files_fd, info_fd, recorded) -> None:
stem, extension = os.path.splitext(name)
for attempt in range(MAX_NAME_ATTEMPTS):
candidate = name if attempt == 0 else f"{stem}.{attempt}{extension}"
info_name = candidate + ".trashinfo"
try:
fd = os.open(
info_name,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
0o600,
dir_fd=info_fd,
)
except FileExistsError:
continue
except OSError as error:
raise TrashRefused(NO_TRASH, str(error)) from error
try:
os.write(fd, self._info_text(recorded))
finally:
os.close(fd)
try:
self._rename(source_fd, name, files_fd, candidate)
return
except OSError as error:
os.unlink(info_name, dir_fd=info_fd)
if error.errno == errno.EEXIST:
continue # a stray trashed file has that name: never replace it
if error.errno in _NO_TRASH_ERRNOS:
raise TrashRefused(NO_TRASH, str(error)) from error
raise TrashRefused(FAILED, str(error)) from error
raise TrashRefused(FAILED, "no free name in the trash")
def default_backend(platform: str = sys.platform):
"""The backend for *platform* (#480, #499, #500).
macOS gets :class:`utils.trash_macos.MacTrashBackend`, which loads its
Foundation bridge only on first use (#499). Windows never uses the Recycle
Bin (#500).
"""
if platform == "darwin":
from . import trash_macos
return trash_macos.MacTrashBackend()
if platform == "win32" or platform.startswith("cygwin"):
# #500, ruling (a): Windows deletes files larger than the Recycle Bin's
# capacity permanently, and IFileOperation's PreDeleteItem guard cannot
# tell (native probe runs 40951037575, 40953347904).
return UnavailableBackend(WINDOWS_RECYCLE)
return FreedesktopBackend()
BACKEND = default_backend()
def trash_available(path: str, roots) -> str | None:
"""None when *path* can be trashed, else the refusal code (blocking, no writes)."""
return BACKEND.available(path, roots)
def move_to_trash(path: str, roots) -> str:
"""Move one regular file into the trash; returns where. Raises TrashRefused."""
return BACKEND.move(path, roots)