core/tools: send a deleted path to the system trash

`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.
This commit is contained in:
2026-10-21 20:18:35 +08:00
parent 290f3daf60
commit 7b7c90b8ee
6 changed files with 315 additions and 35 deletions
+73 -1
View File
@@ -2,7 +2,9 @@ from __future__ import annotations
from pathlib import Path
from plyngent.tools.file import copy_path, delete_path, move_path, read_file, write_file
import pytest
from plyngent.tools.file import copy_path, delete_path, fs_ops, move_path, read_file, write_file
from tests.test_tools.helpers import call_sync
@@ -75,6 +77,8 @@ def test_delete_nonempty_requires_recursive(workspace: object) -> None:
out = call_sync(delete_path, "box")
assert "not empty" in out
assert (workspace / "box" / "a.txt").is_file()
# The workspace fixture lives under the system temp dir, so it is removed
# for good rather than trashed (see the trash tests below).
out2 = call_sync(delete_path, "box", recursive=True)
assert "recursive" in out2
assert not (workspace / "box").exists()
@@ -97,3 +101,71 @@ def test_missing_source(workspace: object) -> None:
assert "does not exist" in call_sync(copy_path, "nope", "x")
assert "does not exist" in call_sync(move_path, "nope", "x")
assert "does not exist" in call_sync(delete_path, "nope")
def _elsewhere_than_temp(monkeypatch: pytest.MonkeyPatch) -> None:
"""Pretend the workspace is not scratch space, so deletes use the trash."""
monkeypatch.setattr(fs_ops, "under_system_temp", lambda _path: False)
def test_a_temp_path_is_deleted_for_good(workspace: object) -> None:
assert isinstance(workspace, Path)
_ = call_sync(write_file, "t.txt", "x")
out = call_sync(delete_path, "t.txt")
assert "trash" not in out
assert not (workspace / "t.txt").exists()
def test_delete_goes_to_the_trash(workspace: object, monkeypatch: pytest.MonkeyPatch) -> None:
assert isinstance(workspace, Path)
_elsewhere_than_temp(monkeypatch)
trashed: list[Path] = []
monkeypatch.setattr(fs_ops, "move_to_trash", trashed.append)
_ = call_sync(write_file, "t.txt", "x")
out = call_sync(delete_path, "t.txt")
assert out == "deleted file t.txt (moved to system trash)"
assert [path.name for path in trashed] == ["t.txt"]
def test_recursive_delete_goes_to_the_trash(workspace: object, monkeypatch: pytest.MonkeyPatch) -> None:
assert isinstance(workspace, Path)
_elsewhere_than_temp(monkeypatch)
trashed: list[Path] = []
monkeypatch.setattr(fs_ops, "move_to_trash", trashed.append)
_ = call_sync(write_file, "box/a.txt", "a")
out = call_sync(delete_path, "box", recursive=True)
assert out == "deleted directory box (moved to system trash)"
assert [path.name for path in trashed] == ["box"]
def test_a_refused_trash_keeps_the_path(workspace: object, monkeypatch: pytest.MonkeyPatch) -> None:
assert isinstance(workspace, Path)
_elsewhere_than_temp(monkeypatch)
refused = "error: cannot move to trash: nope (use permanent=true)"
monkeypatch.setattr(fs_ops, "move_to_trash", lambda _target: refused)
_ = call_sync(write_file, "t.txt", "x")
out = call_sync(delete_path, "t.txt")
assert out == refused
assert (workspace / "t.txt").is_file()
def test_permanent_delete_skips_the_trash(workspace: object, monkeypatch: pytest.MonkeyPatch) -> None:
assert isinstance(workspace, Path)
def _never(_target: Path) -> str | None:
msg = "a permanent delete must not touch the trash"
raise AssertionError(msg)
monkeypatch.setattr(fs_ops, "move_to_trash", _never)
_ = call_sync(write_file, "t.txt", "x")
out = call_sync(delete_path, "t.txt", permanent=True)
assert out == "deleted file t.txt"
assert not (workspace / "t.txt").exists()
+130
View File
@@ -0,0 +1,130 @@
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"))