Files
ComfyAssets-ComfyUI_PromptM…/utils/trash_macos.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

491 lines
18 KiB
Python

"""The macOS Trash backend (#499): NSFileManager through ctypes, or refuse.
``-[NSFileManager trashItemAtURL:resultingItemURL:error:]`` moves a file into
its volume's trash folder (``~/.Trash`` on the startup volume,
``<volume>/.Trashes/<uid>`` elsewhere) or fails with an ``NSError``; it renames
name clashes itself and never replaces a file. Before calling it,
:class:`MacTrashBackend` refuses what it cannot show to be recoverable:
network volumes, read-only volumes, iCloud Drive and File Provider sync
folders, a trash folder inside an output root, and files macOS would refuse
for permission. Nothing here unlinks, copies or overwrites a file.
The :class:`Bridge` is the only code that touches the Objective-C runtime. It
sends ten fixed messages, each through ``objc_msgSend`` cast to its exact
prototype (required on arm64), checks every object for nil before a send (a
nil can raise an Objective-C exception, which would abort the process), and
runs each call inside its own autorelease pool. It is loaded lazily, on the
first ``available``/``move``; when it cannot load, every file answers
``no-trash`` as before. The facts behind these rules were measured on macOS
14, 15 (x86_64) and 26 (docs/egm/499-macos-trash/design.md §4).
"""
from __future__ import annotations
import ctypes
import logging
import os
import stat
import sys
import threading
from dataclasses import dataclass
from . import trash
logger = logging.getLogger("prompt_manager.trash")
COCOA = "NSCocoaErrorDomain"
POSIX = "NSPOSIXErrorDomain"
FEATURE_UNSUPPORTED = 3328
_ENOENT = 2
# Top-level Cocoa codes of a refused move (design §7.5); anything else is "failed".
_MOVE_CODES = {
FEATURE_UNSUPPORTED: trash.NO_TRASH,
642: trash.NO_TRASH, # NSFileWriteVolumeReadOnlyError
513: trash.NO_PERMISSION, # NSFileWriteNoPermissionError
257: trash.NO_PERMISSION, # NSFileReadNoPermissionError
}
_LOCK_FLAGS = stat.UF_IMMUTABLE | stat.SF_IMMUTABLE | stat.UF_APPEND | stat.SF_APPEND
_CLOUD_FOLDERS = (("Library", "Mobile Documents"), ("Library", "CloudStorage"))
@dataclass(frozen=True)
class NSErr:
domain: str
code: int
under_domain: str | None = None
under_code: int | None = None
@dataclass(frozen=True)
class Facts:
"""Resource values of a file's URL; ``None`` means the lookup failed or was nil."""
local: bool | None
read_only: bool | None
root_fs: bool | None
volume: str | None
ubiquitous: bool | None
def error_detail(error: NSErr | None) -> str:
"""``"<domain> <code> (<under_domain> <under_code>)"`` for the log."""
if error is None:
return "no NSError"
text = f"{error.domain} {error.code}"
if error.under_domain is not None:
text += f" ({error.under_domain} {error.under_code})"
return text
def refusal_code(error: NSErr | None) -> str:
if error is None or error.domain != COCOA:
return trash.FAILED
return _MOVE_CODES.get(error.code, trash.FAILED)
def _within(path: str, folder: str) -> bool:
path = os.path.normcase(path)
folder = os.path.normcase(folder)
trimmed = folder.rstrip("\\/") or folder
return path == trimmed or path.startswith(trimmed.rstrip(os.sep) + os.sep)
def _identity(path: str):
info = os.stat(path)
return info.st_dev, info.st_ino
def inside_any_root(path: str, roots) -> bool:
"""Whether *path* (existing or not) lies in an output root, by file identity.
Every existing folder from *path* up to the top is compared with the
roots' ``(st_dev, st_ino)``, so case, NFC/NFD and symlinked spellings of a
root all match.
"""
identities = set()
for root in roots:
try:
identities.add(_identity(str(root)))
except OSError:
continue
current = os.path.abspath(path)
while True:
try:
if _identity(current) in identities:
return True
except OSError:
# an unreadable folder cannot be a trash; try its parent
pass
parent = os.path.dirname(current)
if parent == current:
return False
current = parent
def _same_folder(left: str, right: str) -> bool:
try:
return _identity(left) == _identity(right)
except OSError:
return os.path.realpath(left) == os.path.realpath(right)
class MacTrashBackend:
"""``available``/``move`` for macOS; the bridge and file checks are seams."""
def __init__(
self, bridge=None, uid=None, home=None, lstat=os.lstat, access=os.access
):
self._bridge = bridge
self._load_failed = False
self._lock = threading.Lock()
if uid is None:
uid = os.getuid() if hasattr(os, "getuid") else -1
self._uid = uid
self._home = home
self._lstat = lstat
self._access = access
def _get_bridge(self):
if self._bridge is None and not self._load_failed:
with self._lock:
if self._bridge is None and not self._load_failed:
try:
self._bridge = load_bridge()
except (OSError, AttributeError, ValueError) as error:
self._load_failed = True
logger.warning("The macOS trash is unavailable: %s", error)
if self._bridge is None:
raise trash.TrashRefused(trash.NO_TRASH, "Foundation bridge not loaded")
return self._bridge
def _home_dir(self) -> str:
return os.path.realpath(self._home or os.path.expanduser("~"))
def _plan(self, path: str, roots):
"""``(trash folder, facts)`` for *path*; raises TrashRefused (design §7.2)."""
bridge = self._get_bridge()
real = os.path.realpath(path)
facts = bridge.facts(real)
if facts.local is not True:
raise trash.TrashRefused(trash.NETWORK)
if facts.read_only is not False:
raise trash.TrashRefused(trash.NO_TRASH, "read-only volume")
home = self._home_dir()
if facts.ubiquitous is True or any(
_within(real, os.path.join(home, *parts)) for parts in _CLOUD_FOLDERS
):
raise trash.TrashRefused(trash.CLOUD_SYNC)
folder, error = bridge.trash_dir(real)
if folder is None:
folder = self._predict(facts, error)
if inside_any_root(folder, roots):
raise trash.TrashRefused(trash.TRASH_IN_ROOT)
self._check_permission(real)
return folder, facts
def _predict(self, facts: Facts, error: NSErr | None) -> str:
"""``<volume>/.Trashes/<uid>`` for a volume whose trash is not made yet."""
missing = (
error is not None
and error.domain == COCOA
and error.code == FEATURE_UNSUPPORTED
and error.under_domain == POSIX
and error.under_code == _ENOENT
)
if not missing or facts.root_fs is not False or not facts.volume:
raise trash.TrashRefused(trash.NO_TRASH, error_detail(error))
trashes = os.path.join(facts.volume, ".Trashes")
try:
info = self._lstat(trashes)
except FileNotFoundError:
info = None
except OSError as failure:
raise trash.TrashRefused(trash.NO_TRASH, str(failure)) from failure
if info is not None and not stat.S_ISDIR(info.st_mode):
raise trash.TrashRefused(trash.NO_TRASH, ".Trashes is not a folder")
return os.path.join(trashes, str(self._uid))
def _check_permission(self, real: str) -> None:
parent = os.path.dirname(real)
if not self._access(parent, os.W_OK | os.X_OK):
raise trash.TrashRefused(trash.NO_PERMISSION, "folder not writable")
try:
file_info = self._lstat(real)
parent_info = self._lstat(parent)
except OSError as failure:
raise trash.TrashRefused(trash.FAILED, str(failure)) from failure
for info in (file_info, parent_info):
if getattr(info, "st_flags", 0) & _LOCK_FLAGS:
raise trash.TrashRefused(trash.NO_PERMISSION, "immutable flag")
if parent_info.st_mode & stat.S_ISVTX and file_info.st_uid != self._uid:
raise trash.TrashRefused(trash.NO_PERMISSION, "sticky folder, foreign file")
def _label(self, folder: str, facts: Facts) -> str:
if _same_folder(folder, os.path.join(self._home_dir(), ".Trash")):
return trash.HOME_TRASH_LABEL
real = os.path.realpath(folder)
volume = os.path.realpath(facts.volume) if facts.volume else None
if volume and volume != os.path.realpath(os.sep) and _within(real, volume):
relative = os.path.relpath(real, volume).replace(os.sep, "/")
return f"{relative} on its drive"
return real
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)
except trash.TrashRefused as refused:
return refused.code
except OSError:
return trash.NO_TRASH
return None
def move(self, path: str, roots) -> str:
"""Move *path* into its trash folder and return the label (design §7.4)."""
try:
planned, facts = self._plan(path, roots)
except OSError as failure:
raise trash.TrashRefused(trash.NO_TRASH, str(failure)) from failure
real = os.path.realpath(path)
ok, resulting, error = self._get_bridge().trash(real)
if not ok:
raise trash.TrashRefused(refusal_code(error), error_detail(error))
if os.path.lexists(real):
raise trash.TrashRefused(trash.FAILED, "reported moved, still in place")
if not resulting or inside_any_root(os.path.dirname(resulting), roots):
raise trash.TrashUnconfirmed(f"{real} -> {resulting!r}")
folder = os.path.dirname(resulting)
if not _same_folder(folder, planned):
logger.warning(
"macOS trashed %s to %s, not the planned %s", real, resulting, planned
)
return self._label(folder, facts)
# ── the Foundation bridge ────────────────────────────────────────────────
_NS_TRASH_DIRECTORY = 102
_NS_USER_DOMAIN_MASK = 1
_LIBOBJC = "/usr/lib/libobjc.A.dylib"
_FOUNDATION = "/System/Library/Frameworks/Foundation.framework/Foundation"
_KEYS = (
"NSURLVolumeIsLocalKey",
"NSURLVolumeIsReadOnlyKey",
"NSURLVolumeIsRootFileSystemKey",
"NSURLVolumeURLKey",
"NSURLIsUbiquitousItemKey",
"NSUnderlyingErrorKey",
)
_REQUIRED_SELECTORS = (
("NSURL", "fileURLWithFileSystemRepresentation:isDirectory:relativeToURL:"),
("NSURL", "getResourceValue:forKey:error:"),
("NSFileManager", "URLForDirectory:inDomain:appropriateForURL:create:error:"),
("NSFileManager", "trashItemAtURL:resultingItemURL:error:"),
)
class _Nil(Exception):
"""An Objective-C object was nil where a send needs one."""
class Bridge: # pragma: no cover - macOS only; the native-trash job runs it
"""Foundation through ctypes: ``facts``, ``trash_dir`` and ``trash`` (design §6)."""
def __init__(self):
if sys.platform != "darwin":
raise OSError("the Foundation bridge exists only on macOS")
objc = ctypes.CDLL(_LIBOBJC)
foundation = ctypes.CDLL(_FOUNDATION)
void_p, char_p, byte = ctypes.c_void_p, ctypes.c_char_p, ctypes.c_byte
objc.objc_getClass.argtypes = [char_p]
objc.objc_getClass.restype = void_p
objc.sel_registerName.argtypes = [char_p]
objc.sel_registerName.restype = void_p
objc.objc_autoreleasePoolPush.argtypes = []
objc.objc_autoreleasePoolPush.restype = void_p
objc.objc_autoreleasePoolPop.argtypes = [void_p]
objc.objc_autoreleasePoolPop.restype = None
self._objc = objc
address = ctypes.cast(objc.objc_msgSend, void_p).value
def send(restype, *argtypes):
return ctypes.CFUNCTYPE(restype, void_p, void_p, *argtypes)(address)
out = ctypes.POINTER(void_p)
self._send_id = send(void_p)
self._send_responds = send(byte, void_p)
self._send_url = send(void_p, char_p, byte, void_p)
self._send_resource = send(byte, out, void_p, out)
self._send_bool = send(byte)
self._send_dir = send(void_p, ctypes.c_ulong, ctypes.c_ulong, void_p, byte, out)
self._send_trash = send(byte, void_p, out, out)
self._send_cstr = send(char_p)
self._send_long = send(ctypes.c_long)
self._send_object = send(void_p, void_p)
self._keys = {}
for name in _KEYS:
value = void_p.in_dll(foundation, name).value
if not value:
raise ValueError(f"Foundation constant {name} is nil")
self._keys[name] = value
for class_name, selector in _REQUIRED_SELECTORS:
if not self._send_responds(
self._class(class_name),
self._sel("respondsToSelector:"),
self._sel(selector),
) and not self._instances_respond(class_name, selector):
raise ValueError(f"{class_name} does not respond to {selector}")
# helpers (call inside a pool) ------------------------------------------------
def _class(self, name: str):
value = self._objc.objc_getClass(name.encode())
if not value:
raise ValueError(f"no Objective-C class {name}")
return value
def _sel(self, name: str):
return self._objc.sel_registerName(name.encode())
def _instances_respond(self, class_name: str, selector: str) -> bool:
return (
self._send_responds(
self._class(class_name),
self._sel("instancesRespondToSelector:"),
self._sel(selector),
)
!= 0
)
@staticmethod
def _need(value):
if not value:
raise _Nil()
return value
def _manager(self):
return self._need(
self._send_id(self._class("NSFileManager"), self._sel("defaultManager"))
)
def _url(self, path: str):
return self._need(
self._send_url(
self._class("NSURL"),
self._sel(
"fileURLWithFileSystemRepresentation:isDirectory:relativeToURL:"
),
os.fsencode(path),
0,
None,
)
)
def _path(self, url) -> str | None:
if not url:
return None
raw = self._send_cstr(url, self._sel("fileSystemRepresentation"))
return os.fsdecode(raw) if raw else None
def _text(self, obj) -> str | None:
if not obj:
return None
raw = self._send_cstr(obj, self._sel("UTF8String"))
return raw.decode("utf-8", "replace") if raw else None
def _error(self, error) -> NSErr | None:
if not error:
return None
domain = self._text(self._send_id(error, self._sel("domain"))) or "?"
code = self._send_long(error, self._sel("code"))
under_domain = under_code = None
info = self._send_id(error, self._sel("userInfo"))
if info:
under = self._send_object(
info, self._sel("objectForKey:"), self._keys["NSUnderlyingErrorKey"]
)
if under:
under_domain = self._text(self._send_id(under, self._sel("domain")))
under_code = self._send_long(under, self._sel("code"))
return NSErr(domain, code, under_domain, under_code)
def _resource(self, url, key_name: str):
value, error = ctypes.c_void_p(), ctypes.c_void_p()
ok = self._send_resource(
url,
self._sel("getResourceValue:forKey:error:"),
ctypes.byref(value),
self._keys[key_name],
ctypes.byref(error),
)
return value.value if ok != 0 and value.value else None
def _flag(self, url, key_name: str) -> bool | None:
value = self._resource(url, key_name)
if value is None:
return None
return self._send_bool(value, self._sel("boolValue")) != 0
def _pooled(self, work):
pool = self._objc.objc_autoreleasePoolPush()
try:
return work()
except _Nil as nil:
raise trash.TrashRefused(trash.NO_TRASH, "nil Objective-C object") from nil
finally:
self._objc.objc_autoreleasePoolPop(pool)
# the bridge API (design §6.3) ----------------------------------------------
def facts(self, path: str) -> Facts:
def work():
url = self._url(path)
return Facts(
local=self._flag(url, "NSURLVolumeIsLocalKey"),
read_only=self._flag(url, "NSURLVolumeIsReadOnlyKey"),
root_fs=self._flag(url, "NSURLVolumeIsRootFileSystemKey"),
volume=self._path(self._resource(url, "NSURLVolumeURLKey")),
ubiquitous=self._flag(url, "NSURLIsUbiquitousItemKey"),
)
return self._pooled(work)
def trash_dir(self, path: str):
def work():
error = ctypes.c_void_p()
folder = self._send_dir(
self._manager(),
self._sel("URLForDirectory:inDomain:appropriateForURL:create:error:"),
_NS_TRASH_DIRECTORY,
_NS_USER_DOMAIN_MASK,
self._url(path),
0,
ctypes.byref(error),
)
if folder:
return self._path(folder), None
return None, self._error(error.value)
return self._pooled(work)
def trash(self, path: str):
def work():
resulting, error = ctypes.c_void_p(), ctypes.c_void_p()
ok = self._send_trash(
self._manager(),
self._sel("trashItemAtURL:resultingItemURL:error:"),
self._url(path),
ctypes.byref(resulting),
ctypes.byref(error),
)
if ok != 0:
return True, self._path(resulting.value), None
return False, None, self._error(error.value)
return self._pooled(work)
def load_bridge() -> Bridge:
"""The real Foundation bridge; raises OSError/ValueError where it cannot load."""
return Bridge()