Coverage for src/recycle_bin.py: 100%
57 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-08 21:05 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-08 21:05 +0000
1"""Recycle bin for obsolete-file cleanup.
3When ``app.recycle_bin.enabled`` is set, files and folders that obsolete-file
4cleanup would delete are moved to ``<app.root>/Recently Deleted/<service>/
5<YYYY-MM-DD>/<path relative to the service destination>`` instead (with the
6library's folder first when Photos libraries have their own destinations), and kept
7for ``app.recycle_bin.retention_days`` (or forever when that is unset).
9The bin only changes *how* cleanup removes something, never *what* it
10removes: with ``remove_obsolete`` off, nothing is cleaned up and nothing
11reaches the bin.
12"""
14import copy
15import datetime
16import os
17import shutil
18from pathlib import Path
20from src import get_logger
22LOGGER = get_logger()
24BIN_DIRECTORY_NAME = "Recently Deleted"
27class RecycleBin:
28 """Where one service's cleanup puts what it removes."""
30 def __init__(
31 self,
32 root: str,
33 service: str,
34 retention_days: int | None = None,
35 today: datetime.date | None = None,
36 ):
37 """Create the bin for ``service`` under ``root``.
39 Args:
40 root: ``app.root``, the parent of every service destination
41 service: ``drive`` or ``photos``; each gets its own folder
42 retention_days: Days to keep a day's folder; None keeps forever
43 today: Override for tests
44 """
45 self.path = os.path.abspath(os.path.join(root, BIN_DIRECTORY_NAME, service))
46 self.retention_days = retention_days
47 self.today = today or datetime.date.today()
48 self.subfolder = ""
50 def within(self, subfolder: str) -> "RecycleBin":
51 """The same bin, filing what it receives under ``subfolder`` in each day.
53 Several destinations can share one service's bin -- each Photos
54 library in ``library_destinations`` has its own -- and the same
55 relative path in two of them must land in two places, or nothing
56 says where either belongs.
57 """
58 scoped = copy.copy(self)
59 scoped.subfolder = subfolder
60 return scoped
62 def contains(self, path: str) -> bool:
63 """True if ``path`` is the bin or inside it.
65 A destination can be ``app.root`` itself, so cleanup walks would
66 otherwise find the bin and treat everything in it as obsolete.
67 """
68 absolute = os.path.abspath(path)
69 top = os.path.dirname(self.path)
70 return absolute == top or absolute.startswith(top + os.sep)
72 def discard(self, path: str, destination_path: str) -> str:
73 """Move ``path`` into today's folder, keeping its place in the tree.
75 Args:
76 path: File or folder that cleanup would delete
77 destination_path: The service destination ``path`` lives under
79 Returns:
80 Where it was moved to
81 """
82 relative = os.path.relpath(
83 os.path.abspath(path), os.path.abspath(destination_path),
84 )
85 day = os.path.join(self.path, self.today.isoformat())
86 target = os.path.normpath(os.path.join(day, self.subfolder, relative))
87 if not target.startswith(day + os.sep):
88 msg = f"Recycle bin: {path} would be filed outside the bin, at {target}"
89 raise ValueError(msg)
90 os.makedirs(os.path.dirname(target), exist_ok=True)
91 # The same path can be removed twice in a day (re-downloaded, then
92 # dropped again); keep both rather than overwrite the first. The
93 # number goes before a file's extension, so renaming it back to the
94 # original name is all a restore takes.
95 stem, extension = (target, "") if os.path.isdir(path) else os.path.splitext(target)
96 candidate, n = target, 2
97 while os.path.lexists(candidate):
98 candidate = f"{stem} ({n}){extension}"
99 n += 1
100 shutil.move(path, candidate)
101 return candidate
103 def purge(self) -> int:
104 """Delete day folders older than the retention period.
106 Returns:
107 Number of day folders deleted
108 """
109 if self.retention_days is None or not os.path.isdir(self.path):
110 return 0
111 cutoff = self.today - datetime.timedelta(days=self.retention_days)
112 purged = 0
113 for day in sorted(Path(self.path).iterdir()):
114 try:
115 day_date = datetime.date.fromisoformat(day.name)
116 except ValueError:
117 continue # not a folder the bin made; leave it alone
118 if day.is_dir() and day_date < cutoff:
119 LOGGER.info(
120 f"Recycle bin: deleting {day}, older than {self.retention_days} days ...",
121 )
122 shutil.rmtree(day)
123 purged += 1
124 return purged
127def from_config(config: dict | None, service: str) -> RecycleBin | None:
128 """The bin for ``service`` if ``app.recycle_bin.enabled``, else None."""
129 from src import config_parser
131 if not config or not config_parser.get_recycle_bin_enabled(config=config):
132 return None
133 return RecycleBin(
134 root=config_parser.get_root_destination_path(config=config),
135 service=service,
136 retention_days=config_parser.get_recycle_bin_retention_days(config=config),
137 )