* 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
361 lines
15 KiB
Python
361 lines
15 KiB
Python
"""The macOS trash backend's decisions (#499), with a fake Foundation bridge.
|
|
|
|
No Objective-C is involved: the bridge is faked, ``uid``/``home``/``lstat``/
|
|
``access`` are injected, and the only real files are temp files used for the
|
|
"source still exists" and output-root identity checks. Runs on every OS.
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import stat
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from types import SimpleNamespace
|
|
import unittest.mock as mock
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from utils import trash, trash_macos # noqa: E402
|
|
from utils.trash_macos import Facts, MacTrashBackend, NSErr # noqa: E402
|
|
|
|
LOCAL = Facts(local=True, read_only=False, root_fs=True, volume="/", ubiquitous=False)
|
|
COCOA = "NSCocoaErrorDomain"
|
|
NO_TRASH_DIR = NSErr(COCOA, 3328, "NSPOSIXErrorDomain", 2)
|
|
|
|
|
|
class FakeBridge:
|
|
"""The §6.3 bridge with canned answers; ``trash`` moves nothing by default."""
|
|
|
|
def __init__(self, facts=LOCAL, trash_dir=("/Users/me/.Trash", None), result=None):
|
|
self.facts_value = facts
|
|
self.trash_dir_value = trash_dir
|
|
self.result = result or (True, None, None)
|
|
self.calls = []
|
|
|
|
def facts(self, path):
|
|
self.calls.append(("facts", path))
|
|
return self.facts_value
|
|
|
|
def trash_dir(self, path):
|
|
self.calls.append(("trash_dir", path))
|
|
return self.trash_dir_value
|
|
|
|
def trash(self, path):
|
|
self.calls.append(("trash", path))
|
|
result = self.result
|
|
return result(path) if callable(result) else result
|
|
|
|
|
|
def _stat(mode=stat.S_IFREG | 0o644, uid=501, flags=0):
|
|
return SimpleNamespace(st_mode=mode, st_uid=uid, st_flags=flags)
|
|
|
|
|
|
class MacBackendTestCase(unittest.TestCase):
|
|
def setUp(self):
|
|
# realpath: the backend resolves paths (/var -> /private/var on macOS, 8.3
|
|
# short names on Windows), so the lstat fakes must use the same spelling.
|
|
self.tmp = os.path.realpath(tempfile.mkdtemp(prefix="pm-mac-trash-"))
|
|
self.addCleanup(shutil.rmtree, self.tmp, True)
|
|
self.home = os.path.join(self.tmp, "home")
|
|
self.root = os.path.join(self.tmp, "output")
|
|
os.makedirs(os.path.join(self.home, ".Trash"))
|
|
os.makedirs(self.root)
|
|
self.file = os.path.join(self.root, "a.png")
|
|
with open(self.file, "wb") as handle:
|
|
handle.write(b"png")
|
|
self.home_trash = os.path.join(self.home, ".Trash")
|
|
self.stats = {}
|
|
self.access_ok = True
|
|
|
|
def _lstat(self, path):
|
|
if path in self.stats:
|
|
value = self.stats[path]
|
|
if isinstance(value, Exception):
|
|
raise value
|
|
return value
|
|
return os.lstat(path)
|
|
|
|
def backend(self, **bridge_args):
|
|
bridge_args.setdefault("trash_dir", (self.home_trash, None))
|
|
self.bridge = FakeBridge(**bridge_args)
|
|
return MacTrashBackend(
|
|
bridge=self.bridge,
|
|
uid=501,
|
|
home=self.home,
|
|
lstat=self._lstat,
|
|
access=lambda path, mode: self.access_ok,
|
|
)
|
|
|
|
def moved_to(self, destination):
|
|
"""A ``trash`` result that really renames the file to *destination*."""
|
|
|
|
def run(path):
|
|
os.makedirs(os.path.dirname(destination), exist_ok=True)
|
|
os.rename(path, destination)
|
|
return True, destination, None
|
|
|
|
return run
|
|
|
|
def assert_refused(self, backend, code):
|
|
self.assertEqual(backend.available(self.file, [self.root]), code)
|
|
with self.assertRaises(trash.TrashRefused) as caught:
|
|
backend.move(self.file, [self.root])
|
|
self.assertEqual(caught.exception.code, code)
|
|
self.assertNotIn("trash", [name for name, _ in self.bridge.calls])
|
|
self.assertTrue(os.path.exists(self.file))
|
|
return caught.exception
|
|
|
|
|
|
class AvailableOrderTests(MacBackendTestCase):
|
|
def test_a_writable_local_file_is_available(self):
|
|
self.assertIsNone(self.backend().available(self.file, [self.root]))
|
|
|
|
def test_network_volume_is_refused_without_trying(self):
|
|
facts = Facts(False, False, False, "/Volumes/nas", False)
|
|
self.assert_refused(self.backend(facts=facts), trash.NETWORK)
|
|
|
|
def test_unknown_locality_counts_as_network(self):
|
|
facts = Facts(None, False, True, "/", False)
|
|
self.assert_refused(self.backend(facts=facts), trash.NETWORK)
|
|
|
|
def test_read_only_volume_or_unknown_is_no_trash(self):
|
|
for read_only in (True, None):
|
|
with self.subTest(read_only=read_only):
|
|
facts = Facts(True, read_only, False, "/Volumes/ro", False)
|
|
self.assert_refused(self.backend(facts=facts), trash.NO_TRASH)
|
|
|
|
def test_ubiquitous_item_is_cloud_sync(self):
|
|
facts = Facts(True, False, True, "/", True)
|
|
self.assert_refused(self.backend(facts=facts), trash.CLOUD_SYNC)
|
|
|
|
def test_icloud_and_file_provider_folders_are_cloud_sync(self):
|
|
for parts in (("Library", "Mobile Documents"), ("Library", "CloudStorage")):
|
|
with self.subTest(parts=parts):
|
|
folder = os.path.join(self.home, *parts, "Box")
|
|
os.makedirs(folder)
|
|
path = os.path.join(folder, "b.png")
|
|
with open(path, "wb") as handle:
|
|
handle.write(b"x")
|
|
backend = self.backend()
|
|
self.assertEqual(backend.available(path, [folder]), trash.CLOUD_SYNC)
|
|
with self.assertRaises(trash.TrashRefused):
|
|
backend.move(path, [folder])
|
|
self.assertTrue(os.path.exists(path))
|
|
|
|
def test_found_trash_folder_inside_an_output_root_is_trash_in_root(self):
|
|
self.assertEqual(
|
|
self.backend().available(self.file, [self.home]), trash.TRASH_IN_ROOT
|
|
)
|
|
|
|
def test_trash_dir_error_other_than_missing_is_no_trash(self):
|
|
backend = self.backend(
|
|
trash_dir=(None, NSErr(COCOA, 3328, "NSPOSIXErrorDomain", 30))
|
|
)
|
|
self.assert_refused(backend, trash.NO_TRASH)
|
|
|
|
def test_missing_trash_on_the_startup_volume_is_no_trash(self):
|
|
self.assert_refused(
|
|
self.backend(trash_dir=(None, NO_TRASH_DIR)), trash.NO_TRASH
|
|
)
|
|
|
|
|
|
class FreshVolumeTests(MacBackendTestCase):
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.volume = os.path.join(self.tmp, "vol")
|
|
self.root = os.path.join(self.volume, "output")
|
|
os.makedirs(self.root)
|
|
self.file = os.path.join(self.root, "a.png")
|
|
with open(self.file, "wb") as handle:
|
|
handle.write(b"png")
|
|
self.facts = Facts(True, False, False, self.volume, False)
|
|
|
|
def test_missing_volume_trash_is_predicted(self):
|
|
backend = self.backend(facts=self.facts, trash_dir=(None, NO_TRASH_DIR))
|
|
self.assertIsNone(backend.available(self.file, [self.root]))
|
|
|
|
def test_prediction_refused_when_trashes_is_a_link(self):
|
|
os.symlink(self.tmp, os.path.join(self.volume, ".Trashes"))
|
|
backend = self.backend(facts=self.facts, trash_dir=(None, NO_TRASH_DIR))
|
|
self.assert_refused(backend, trash.NO_TRASH)
|
|
|
|
def test_predicted_trash_inside_the_volume_root_is_trash_in_root(self):
|
|
backend = self.backend(facts=self.facts, trash_dir=(None, NO_TRASH_DIR))
|
|
self.assert_refused_root = backend.available(self.file, [self.volume])
|
|
self.assertEqual(self.assert_refused_root, trash.TRASH_IN_ROOT)
|
|
|
|
def test_move_to_a_predicted_trash_returns_the_volume_label(self):
|
|
destination = os.path.join(self.volume, ".Trashes", "501", "a.png")
|
|
backend = self.backend(
|
|
facts=self.facts,
|
|
trash_dir=(None, NO_TRASH_DIR),
|
|
result=self.moved_to(destination),
|
|
)
|
|
self.assertEqual(
|
|
backend.move(self.file, [self.root]), ".Trashes/501 on its drive"
|
|
)
|
|
self.assertFalse(os.path.exists(self.file))
|
|
|
|
|
|
class PermissionTests(MacBackendTestCase):
|
|
def test_parent_not_writable_is_no_permission(self):
|
|
self.access_ok = False
|
|
self.assert_refused(self.backend(), trash.NO_PERMISSION)
|
|
|
|
def test_immutable_or_append_flags_on_file_or_parent(self):
|
|
for flag in ("UF_IMMUTABLE", "SF_IMMUTABLE", "UF_APPEND", "SF_APPEND"):
|
|
for target in (self.file, self.root):
|
|
with self.subTest(flag=flag, target=target):
|
|
mode = (
|
|
stat.S_IFDIR | 0o755
|
|
if target == self.root
|
|
else stat.S_IFREG | 0o644
|
|
)
|
|
self.stats = {target: _stat(mode=mode, flags=getattr(stat, flag))}
|
|
self.assert_refused(self.backend(), trash.NO_PERMISSION)
|
|
|
|
def test_sticky_parent_and_foreign_owner(self):
|
|
self.stats = {
|
|
self.root: _stat(mode=stat.S_IFDIR | 0o1777),
|
|
self.file: _stat(uid=502),
|
|
}
|
|
self.assert_refused(self.backend(), trash.NO_PERMISSION)
|
|
|
|
def test_sticky_parent_with_own_file_is_allowed(self):
|
|
self.stats = {self.root: _stat(mode=stat.S_IFDIR | 0o1777), self.file: _stat()}
|
|
self.assertIsNone(self.backend().available(self.file, [self.root]))
|
|
|
|
|
|
class MoveOutcomeTests(MacBackendTestCase):
|
|
def test_success_into_the_home_trash(self):
|
|
destination = os.path.join(self.home_trash, "a.png")
|
|
backend = self.backend(result=self.moved_to(destination))
|
|
self.assertEqual(backend.move(self.file, [self.root]), trash.HOME_TRASH_LABEL)
|
|
self.assertTrue(os.path.exists(destination))
|
|
|
|
def test_cocoa_errors_map_to_codes_with_detail(self):
|
|
cases = {
|
|
3328: trash.NO_TRASH,
|
|
642: trash.NO_TRASH,
|
|
513: trash.NO_PERMISSION,
|
|
257: trash.NO_PERMISSION,
|
|
4: trash.FAILED,
|
|
640: trash.FAILED,
|
|
}
|
|
for code, expected in cases.items():
|
|
with self.subTest(code=code):
|
|
error = NSErr(COCOA, code, "NSOSStatusErrorDomain", -5000)
|
|
backend = self.backend(result=(False, None, error))
|
|
with self.assertRaises(trash.TrashRefused) as caught:
|
|
backend.move(self.file, [self.root])
|
|
self.assertEqual(caught.exception.code, expected)
|
|
self.assertEqual(
|
|
caught.exception.detail,
|
|
f"NSCocoaErrorDomain {code} (NSOSStatusErrorDomain -5000)",
|
|
)
|
|
self.assertTrue(os.path.exists(self.file))
|
|
|
|
def test_other_domain_or_missing_error_is_failed(self):
|
|
for error in (NSErr("NSPOSIXErrorDomain", 13), None):
|
|
with self.subTest(error=error):
|
|
backend = self.backend(result=(False, None, error))
|
|
with self.assertRaises(trash.TrashRefused) as caught:
|
|
backend.move(self.file, [self.root])
|
|
self.assertEqual(caught.exception.code, trash.FAILED)
|
|
|
|
def test_yes_with_the_source_still_present_is_failed(self):
|
|
backend = self.backend(
|
|
result=(True, os.path.join(self.home_trash, "a.png"), None)
|
|
)
|
|
with self.assertRaises(trash.TrashRefused) as caught:
|
|
backend.move(self.file, [self.root])
|
|
self.assertEqual(caught.exception.code, trash.FAILED)
|
|
self.assertTrue(os.path.exists(self.file))
|
|
|
|
def test_yes_with_no_result_path_is_unconfirmed(self):
|
|
def vanished(path):
|
|
os.rename(path, os.path.join(self.tmp, "elsewhere.png"))
|
|
return True, None, None
|
|
|
|
backend = self.backend(result=vanished)
|
|
with self.assertRaises(trash.TrashUnconfirmed):
|
|
backend.move(self.file, [self.root])
|
|
|
|
def test_yes_into_an_output_root_is_unconfirmed(self):
|
|
backend = self.backend(
|
|
result=self.moved_to(os.path.join(self.root, "x", "a.png"))
|
|
)
|
|
with self.assertRaises(trash.TrashUnconfirmed):
|
|
backend.move(self.file, [self.root])
|
|
|
|
def test_yes_into_an_unplanned_folder_is_trashed_and_logged(self):
|
|
other = os.path.join(self.tmp, "other-trash", "a.png")
|
|
backend = self.backend(result=self.moved_to(other))
|
|
with self.assertLogs("prompt_manager.trash", level="WARNING") as logs:
|
|
label = backend.move(self.file, [self.root])
|
|
self.assertEqual(label, os.path.realpath(os.path.dirname(other)))
|
|
self.assertIn(other, "\n".join(logs.output))
|
|
|
|
|
|
class IdentityTests(MacBackendTestCase):
|
|
@unittest.skipUnless(hasattr(os, "symlink") and os.name == "posix", "symlinks")
|
|
def test_root_given_through_a_symlink_still_contains_its_trash(self):
|
|
alias = os.path.join(self.tmp, "home-alias")
|
|
os.symlink(self.home, alias)
|
|
self.assertEqual(
|
|
self.backend().available(self.file, [alias]), trash.TRASH_IN_ROOT
|
|
)
|
|
|
|
def test_missing_roots_are_ignored(self):
|
|
missing = os.path.join(self.tmp, "nope")
|
|
self.assertIsNone(self.backend().available(self.file, [missing, self.root]))
|
|
|
|
|
|
class LoadingTests(unittest.TestCase):
|
|
def test_default_backend_for_darwin_does_not_load_the_bridge(self):
|
|
with mock.patch.object(trash_macos, "load_bridge") as loader:
|
|
backend = trash.default_backend("darwin")
|
|
self.assertIsInstance(backend, MacTrashBackend)
|
|
loader.assert_not_called()
|
|
|
|
def test_failed_load_answers_no_trash_and_is_cached_and_logged_once(self):
|
|
backend = MacTrashBackend(uid=501)
|
|
with (
|
|
mock.patch.object(
|
|
trash_macos, "load_bridge", side_effect=OSError("no Foundation")
|
|
) as loader,
|
|
self.assertLogs("prompt_manager.trash", level="WARNING") as logs,
|
|
):
|
|
self.assertEqual(backend.available("/x/a.png", ["/x"]), trash.NO_TRASH)
|
|
with self.assertRaises(trash.TrashRefused) as caught:
|
|
backend.move("/x/a.png", ["/x"])
|
|
self.assertEqual(caught.exception.code, trash.NO_TRASH)
|
|
self.assertEqual(loader.call_count, 1)
|
|
self.assertEqual(len(logs.output), 1)
|
|
|
|
def test_load_bridge_off_macos_raises(self):
|
|
if sys.platform == "darwin":
|
|
self.skipTest("the real bridge loads on macOS (native test covers it)")
|
|
with self.assertRaises(OSError):
|
|
trash_macos.load_bridge()
|
|
|
|
|
|
class ErrorTextTests(unittest.TestCase):
|
|
def test_detail_without_underlying(self):
|
|
self.assertEqual(
|
|
trash_macos.error_detail(NSErr(COCOA, 4)), "NSCocoaErrorDomain 4"
|
|
)
|
|
self.assertEqual(trash_macos.error_detail(None), "no NSError")
|
|
|
|
def test_refusal_keeps_detail(self):
|
|
refused = trash.TrashRefused(trash.FAILED, "why")
|
|
self.assertEqual(
|
|
(refused.code, refused.detail, str(refused)), ("failed", "why", "why")
|
|
)
|
|
self.assertEqual(trash.TrashRefused(trash.FAILED).detail, "")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|