mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
7b7c90b8ee
`delete_path` is the only destructive file entry point the agent has, and it carries `ToolTag.YOLO` — under `--yes` / `/yolo` it runs with no confirmation at all. A delete is now a move into the desktop's own trash (freedesktop trash on Linux, Finder trash on macOS, Recycle Bin on Windows) through `send2trash`, so the human can put the file back with the file manager they already have. The new `permanent=true` argument is the old behaviour, chosen on purpose. A trashing that fails is reported, never downgraded to a deletion: the human was told the path is recoverable, so a volume with no writable trash directory leaves the file where it is and the error names `permanent=true` as the way to delete it anyway. A broken install (the library is a required dependency) is reported the same way. Scratch paths under the OS temporary directory are still deleted for good — nobody restores them, and a trash on the `/tmp` volume would only collect them. The rule now lives in `tools/trash.py` (`under_system_temp`) and the temporary workspace cleanup uses it too, instead of keeping a second copy of the check.
131 lines
4.3 KiB
Python
131 lines
4.3 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import send2trash
|
|
|
|
from plyngent.tools import trash
|
|
from plyngent.tools.trash import is_available, move_to_trash, under_system_temp
|
|
|
|
# The freedesktop implementation is the only one whose trash location can be
|
|
# redirected for a test; macOS and Windows move into their own system trash.
|
|
freedesktop_only = pytest.mark.skipif(sys.platform != "linux", reason="freedesktop trash only")
|
|
|
|
|
|
@pytest.fixture
|
|
def home_trash(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
"""Point the freedesktop implementation at a trash under *tmp_path*.
|
|
|
|
``plat_other`` reads its trash locations from module globals computed at
|
|
import time and picks "home" vs "external volume" by device number, so both
|
|
are pinned here — otherwise a ``/tmp`` on a filesystem of its own would
|
|
create a real trash directory on that volume.
|
|
"""
|
|
plat_other = pytest.importorskip("send2trash.plat_other")
|
|
root = tmp_path / "Trash"
|
|
monkeypatch.setattr(plat_other, "XDG_DATA_HOME", os.fsencode(str(tmp_path)))
|
|
monkeypatch.setattr(plat_other, "HOMETRASH_B", os.fsencode(str(root)))
|
|
monkeypatch.setattr(plat_other, "HOMETRASH", str(root))
|
|
monkeypatch.setattr(plat_other, "get_dev", lambda _path: 1)
|
|
# A GIO-backed install would otherwise bypass every patch above.
|
|
monkeypatch.setattr(send2trash, "send2trash", plat_other.send2trash)
|
|
return root
|
|
|
|
|
|
def test_the_library_is_installed() -> None:
|
|
assert is_available()
|
|
|
|
|
|
@freedesktop_only
|
|
def test_move_to_trash_lands_in_the_trash(home_trash: Path, tmp_path: Path) -> None:
|
|
target = tmp_path / "gone.txt"
|
|
_ = target.write_text("x")
|
|
|
|
assert move_to_trash(target) is None
|
|
|
|
assert not target.exists()
|
|
assert (home_trash / "files" / "gone.txt").read_text() == "x"
|
|
entry = (home_trash / "info" / "gone.txt.trashinfo").read_text()
|
|
assert entry.startswith("[Trash Info]")
|
|
assert "Path=" in entry
|
|
assert "DeletionDate=" in entry
|
|
|
|
|
|
@freedesktop_only
|
|
def test_move_to_trash_moves_a_directory_in_one_piece(home_trash: Path, tmp_path: Path) -> None:
|
|
target = tmp_path / "box"
|
|
target.mkdir()
|
|
_ = (target / "inner.txt").write_text("x")
|
|
|
|
assert move_to_trash(target) is None
|
|
|
|
assert not target.exists()
|
|
assert (home_trash / "files" / "box" / "inner.txt").read_text() == "x"
|
|
|
|
|
|
@freedesktop_only
|
|
def test_trashing_twice_keeps_both_copies(home_trash: Path, tmp_path: Path) -> None:
|
|
first = tmp_path / "dup.txt"
|
|
second = tmp_path / "sub" / "dup.txt"
|
|
second.parent.mkdir()
|
|
_ = first.write_text("one")
|
|
_ = second.write_text("two")
|
|
|
|
assert move_to_trash(first) is None
|
|
assert move_to_trash(second) is None
|
|
|
|
names = sorted(path.name for path in (home_trash / "files").iterdir())
|
|
assert names == ["dup 1.txt", "dup.txt"]
|
|
|
|
|
|
def test_a_refused_volume_names_the_escape_hatch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
target = tmp_path / "gone.txt"
|
|
_ = target.write_text("x")
|
|
|
|
def _refuse(_paths: object) -> None:
|
|
raise send2trash.TrashPermissionError(str(target))
|
|
|
|
monkeypatch.setattr(send2trash, "send2trash", _refuse)
|
|
|
|
error = move_to_trash(target)
|
|
|
|
assert error is not None
|
|
assert error.startswith("error: cannot move to trash:")
|
|
assert "permanent=true" in error
|
|
assert target.is_file()
|
|
|
|
|
|
def test_an_oserror_is_reported(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
target = tmp_path / "gone.txt"
|
|
_ = target.write_text("x")
|
|
|
|
def _fail(_paths: object) -> None:
|
|
raise OSError("disk on fire")
|
|
|
|
monkeypatch.setattr(send2trash, "send2trash", _fail)
|
|
|
|
assert move_to_trash(target) == "error: cannot move to trash: disk on fire"
|
|
assert target.is_file()
|
|
|
|
|
|
def test_a_broken_install_leaves_the_file_alone(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
target = tmp_path / "gone.txt"
|
|
_ = target.write_text("x")
|
|
monkeypatch.setattr(trash, "is_available", lambda: False)
|
|
|
|
error = move_to_trash(target)
|
|
|
|
assert error is not None
|
|
assert "send2trash is not installed" in error
|
|
assert "permanent=true" in error
|
|
assert target.is_file()
|
|
|
|
|
|
def test_under_system_temp(tmp_path: Path) -> None:
|
|
assert under_system_temp(tmp_path)
|
|
assert under_system_temp(tmp_path / "nested" / "file.txt")
|
|
assert not under_system_temp(Path("/plyngent-not-temp/deep/file.txt"))
|