dupefind 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dupefind/__init__.py +52 -0
- dupefind/__main__.py +7 -0
- dupefind/_act.py +108 -0
- dupefind/_actions.py +99 -0
- dupefind/_apply.py +75 -0
- dupefind/_cli.py +133 -0
- dupefind/_cli_output.py +50 -0
- dupefind/_find.py +117 -0
- dupefind/_hash.py +47 -0
- dupefind/_json.py +72 -0
- dupefind/_options.py +81 -0
- dupefind/_scan.py +169 -0
- dupefind/_stages.py +151 -0
- dupefind/_streams.py +62 -0
- dupefind/_types.py +129 -0
- dupefind/py.typed +0 -0
- dupefind-0.2.0.dist-info/METADATA +154 -0
- dupefind-0.2.0.dist-info/RECORD +21 -0
- dupefind-0.2.0.dist-info/WHEEL +4 -0
- dupefind-0.2.0.dist-info/entry_points.txt +2 -0
- dupefind-0.2.0.dist-info/licenses/LICENSE +21 -0
dupefind/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Find duplicate files by staged hashing: group by size, then partial hash, then full hash.
|
|
2
|
+
|
|
3
|
+
Scanning only ever reads files. Clean-up is a separate, explicit step:
|
|
4
|
+
``plan_actions`` decides, ``apply_actions`` acts, and only with ``dry_run=False``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ._actions import apply_actions, choose_keeper, plan_actions
|
|
10
|
+
from ._find import find_duplicates, iter_duplicates, iter_groups, scan
|
|
11
|
+
from ._json import SCHEMA_VERSION, report_to_dict, report_to_json
|
|
12
|
+
from ._options import ScanOptions
|
|
13
|
+
from ._types import (
|
|
14
|
+
Action,
|
|
15
|
+
ActionResult,
|
|
16
|
+
DuplicateFile,
|
|
17
|
+
DuplicateGroup,
|
|
18
|
+
IssueKind,
|
|
19
|
+
KeepPolicy,
|
|
20
|
+
PlannedAction,
|
|
21
|
+
Progress,
|
|
22
|
+
ScanIssue,
|
|
23
|
+
ScanReport,
|
|
24
|
+
ScanStats,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__version__ = "0.2.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"SCHEMA_VERSION",
|
|
31
|
+
"Action",
|
|
32
|
+
"ActionResult",
|
|
33
|
+
"DuplicateFile",
|
|
34
|
+
"DuplicateGroup",
|
|
35
|
+
"IssueKind",
|
|
36
|
+
"KeepPolicy",
|
|
37
|
+
"PlannedAction",
|
|
38
|
+
"Progress",
|
|
39
|
+
"ScanIssue",
|
|
40
|
+
"ScanOptions",
|
|
41
|
+
"ScanReport",
|
|
42
|
+
"ScanStats",
|
|
43
|
+
"apply_actions",
|
|
44
|
+
"choose_keeper",
|
|
45
|
+
"find_duplicates",
|
|
46
|
+
"iter_duplicates",
|
|
47
|
+
"iter_groups",
|
|
48
|
+
"plan_actions",
|
|
49
|
+
"report_to_dict",
|
|
50
|
+
"report_to_json",
|
|
51
|
+
"scan",
|
|
52
|
+
]
|
dupefind/__main__.py
ADDED
dupefind/_act.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Carry out one verified action so that the data always exists under some name.
|
|
2
|
+
|
|
3
|
+
The target is first renamed aside to a random name next to it and checked to be
|
|
4
|
+
the file that was compared. Only then is it removed (delete), or a hard link to
|
|
5
|
+
the kept file is created at the freed name (hardlink) before the aside copy is
|
|
6
|
+
removed. While this happens the kept file is held open, which on Windows stops
|
|
7
|
+
anyone deleting or renaming it. Anything unexpected puts the target back.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import secrets
|
|
14
|
+
import stat
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ._apply import pin, unchanged
|
|
18
|
+
from ._types import ActionResult, PlannedAction
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run(item: PlannedAction, kept: os.stat_result, target: os.stat_result) -> ActionResult:
|
|
22
|
+
"""Act on a checked item after last identity checks. Never raises ``OSError``."""
|
|
23
|
+
try:
|
|
24
|
+
hold = open(item.keep, "rb") # noqa: SIM115 - held across the whole act
|
|
25
|
+
except OSError as exc:
|
|
26
|
+
return ActionResult(item, "skipped", f"cannot open the kept copy: {exc}")
|
|
27
|
+
with hold:
|
|
28
|
+
reason = unchanged(item, kept, target)
|
|
29
|
+
if reason:
|
|
30
|
+
return ActionResult(item, "skipped", reason)
|
|
31
|
+
aside = _aside_name(item.target)
|
|
32
|
+
try:
|
|
33
|
+
os.rename(item.target, aside)
|
|
34
|
+
except OSError as exc:
|
|
35
|
+
return ActionResult(item, "failed", str(exc))
|
|
36
|
+
reason = _still_the_same(item, aside, kept, target)
|
|
37
|
+
if reason:
|
|
38
|
+
return ActionResult(item, "skipped", reason + _restore(aside, item.target))
|
|
39
|
+
try:
|
|
40
|
+
if item.action == "hardlink":
|
|
41
|
+
_link_in_place(item.keep, item.target, kept)
|
|
42
|
+
except OSError as exc:
|
|
43
|
+
return ActionResult(item, "failed", str(exc) + _restore(aside, item.target))
|
|
44
|
+
try:
|
|
45
|
+
os.remove(aside)
|
|
46
|
+
except OSError as exc:
|
|
47
|
+
return ActionResult(item, "failed", f"{exc}; the old copy is still at {aside}")
|
|
48
|
+
return ActionResult(item, "done")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _aside_name(target: Path) -> Path:
|
|
52
|
+
return target.with_name(f".{target.name}.{secrets.token_hex(6)}.dupefind")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _still_the_same(
|
|
56
|
+
item: PlannedAction, aside: Path, kept: os.stat_result, target: os.stat_result
|
|
57
|
+
) -> str:
|
|
58
|
+
try:
|
|
59
|
+
moved = os.lstat(aside)
|
|
60
|
+
now_kept = os.stat(item.keep)
|
|
61
|
+
except OSError as exc:
|
|
62
|
+
return f"changed while acting: {exc}"
|
|
63
|
+
if pin(moved) != pin(target):
|
|
64
|
+
return "target was replaced or modified before acting"
|
|
65
|
+
if pin(now_kept) != pin(kept):
|
|
66
|
+
return "the kept copy was replaced or modified before acting"
|
|
67
|
+
return ""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _link_in_place(keep: Path, target: Path, kept: os.stat_result) -> None:
|
|
71
|
+
"""Create a hard link to the real kept file at the (now free) target name."""
|
|
72
|
+
os.link(os.path.realpath(keep), target) # fails if anything appeared there
|
|
73
|
+
made = os.lstat(target)
|
|
74
|
+
if not stat.S_ISREG(made.st_mode) or not os.path.samestat(made, kept):
|
|
75
|
+
_drop_link(target, keep)
|
|
76
|
+
raise OSError(f"the new link at {target} is not the kept file")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _restore(aside: Path, target: Path) -> str:
|
|
80
|
+
"""Put the aside copy back without overwriting anything; describe a failure."""
|
|
81
|
+
try:
|
|
82
|
+
if os.name == "nt":
|
|
83
|
+
os.rename(aside, target) # never replaces an existing file on Windows
|
|
84
|
+
else:
|
|
85
|
+
os.link(aside, target) # never replaces an existing file
|
|
86
|
+
os.remove(aside)
|
|
87
|
+
except OSError as exc:
|
|
88
|
+
return f"; could not put it back ({exc}), it is at {aside}"
|
|
89
|
+
return ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _drop_link(link: Path, keep: Path) -> None:
|
|
93
|
+
"""Remove a link to the kept file, even when the kept file is read-only.
|
|
94
|
+
|
|
95
|
+
The read-only flag belongs to the file, so it is cleared just long enough to
|
|
96
|
+
remove the extra name and then put back on the kept file.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
os.remove(link)
|
|
100
|
+
except PermissionError:
|
|
101
|
+
mode = os.lstat(link).st_mode
|
|
102
|
+
if mode & stat.S_IWUSR:
|
|
103
|
+
raise
|
|
104
|
+
os.chmod(link, mode | stat.S_IWUSR)
|
|
105
|
+
try:
|
|
106
|
+
os.remove(link)
|
|
107
|
+
finally:
|
|
108
|
+
os.chmod(keep, mode)
|
dupefind/_actions.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Plan and apply clean-up actions on duplicate groups. Dry run unless told otherwise."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ._act import run
|
|
10
|
+
from ._apply import check
|
|
11
|
+
from ._types import (
|
|
12
|
+
Action,
|
|
13
|
+
ActionResult,
|
|
14
|
+
DuplicateFile,
|
|
15
|
+
DuplicateGroup,
|
|
16
|
+
KeepPolicy,
|
|
17
|
+
PlannedAction,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
ACTIONS: tuple[Action, ...] = ("delete", "hardlink")
|
|
21
|
+
KEEP_POLICIES: tuple[KeepPolicy, ...] = ("first", "oldest", "newest", "shortest")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def choose_keeper(group: DuplicateGroup, keep: KeepPolicy = "oldest") -> DuplicateFile:
|
|
25
|
+
"""Return the file of ``group`` that ``keep`` says should survive.
|
|
26
|
+
|
|
27
|
+
A regular file is always preferred over a symlink (seen with
|
|
28
|
+
``follow_symlinks``), so the real data is what survives. Ties are broken by
|
|
29
|
+
path, so the choice is deterministic.
|
|
30
|
+
"""
|
|
31
|
+
real = [f for f in group.files if not os.path.islink(f.path)]
|
|
32
|
+
files = real or list(group.files)
|
|
33
|
+
if keep == "first":
|
|
34
|
+
return min(files, key=lambda f: str(f.path))
|
|
35
|
+
if keep == "oldest":
|
|
36
|
+
return min(files, key=lambda f: (f.mtime_ns, str(f.path)))
|
|
37
|
+
if keep == "newest":
|
|
38
|
+
return min(files, key=lambda f: (-f.mtime_ns, str(f.path)))
|
|
39
|
+
if keep == "shortest":
|
|
40
|
+
return min(files, key=lambda f: (len(str(f.path)), str(f.path)))
|
|
41
|
+
raise ValueError(f"unknown keep policy {keep!r}; choose one of {', '.join(KEEP_POLICIES)}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def plan_actions(
|
|
45
|
+
groups: Iterable[DuplicateGroup],
|
|
46
|
+
*,
|
|
47
|
+
action: Action = "delete",
|
|
48
|
+
keep: KeepPolicy = "oldest",
|
|
49
|
+
) -> list[PlannedAction]:
|
|
50
|
+
"""Plan ``action`` for every copy of every group except the one ``keep`` selects.
|
|
51
|
+
|
|
52
|
+
Exactly one file per group is kept, together with its hard-link aliases. Every
|
|
53
|
+
other file, and every alias of it, gets one planned action, so a delete really
|
|
54
|
+
frees the space. Planning touches nothing on disk.
|
|
55
|
+
"""
|
|
56
|
+
if action not in ACTIONS:
|
|
57
|
+
raise ValueError(f"unknown action {action!r}; choose one of {', '.join(ACTIONS)}")
|
|
58
|
+
plan: list[PlannedAction] = []
|
|
59
|
+
for group in groups:
|
|
60
|
+
keeper = choose_keeper(group, keep)
|
|
61
|
+
for item in group.files:
|
|
62
|
+
if item is keeper:
|
|
63
|
+
continue
|
|
64
|
+
for target in (item.path, *item.aliases):
|
|
65
|
+
plan.append(PlannedAction(action, target, keeper.path, group.size, group.digest))
|
|
66
|
+
return plan
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def apply_actions(plan: Iterable[PlannedAction], *, dry_run: bool = True) -> list[ActionResult]:
|
|
70
|
+
"""Carry out a plan. With the default ``dry_run=True`` nothing is changed.
|
|
71
|
+
|
|
72
|
+
Each action is verified right before it runs (and in a dry run). The target
|
|
73
|
+
must be a regular, writable file of its own (not a link, junction or read-only
|
|
74
|
+
file); the kept copy must exist; the two must not be the same file, must still
|
|
75
|
+
have the planned size and must be equal byte for byte. Just before the delete or
|
|
76
|
+
the replace, both are stat'ed once more and must still be the very files that
|
|
77
|
+
were compared. A target that is also a kept file in the plan is never touched.
|
|
78
|
+
Anything that fails a check is ``skipped`` with a reason; an OS error while
|
|
79
|
+
acting is ``failed``. Nothing is raised and the next action still runs.
|
|
80
|
+
"""
|
|
81
|
+
actions = list(plan)
|
|
82
|
+
keepers = {_norm(item.keep) for item in actions}
|
|
83
|
+
results: list[ActionResult] = []
|
|
84
|
+
for item in actions:
|
|
85
|
+
if _norm(item.target) in keepers:
|
|
86
|
+
results.append(ActionResult(item, "skipped", "target is a kept file"))
|
|
87
|
+
continue
|
|
88
|
+
reason, pinned = check(item)
|
|
89
|
+
if pinned is None:
|
|
90
|
+
results.append(ActionResult(item, "skipped", reason))
|
|
91
|
+
elif dry_run:
|
|
92
|
+
results.append(ActionResult(item, "dry-run"))
|
|
93
|
+
else:
|
|
94
|
+
results.append(run(item, *pinned))
|
|
95
|
+
return results
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _norm(path: Path) -> str:
|
|
99
|
+
return os.path.normcase(os.path.abspath(path))
|
dupefind/_apply.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Verify one planned action against the disk and carry it out. Refuse on any doubt."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
|
|
8
|
+
from ._hash import same_content
|
|
9
|
+
from ._streams import extra_streams
|
|
10
|
+
from ._types import PlannedAction
|
|
11
|
+
|
|
12
|
+
_MOUNT_POINT = 0xA0000003 # IO_REPARSE_TAG_MOUNT_POINT: a Windows junction
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def pin(info: os.stat_result) -> tuple[int, int, int, int, int]:
|
|
16
|
+
"""The facts that must not change between the check and the act."""
|
|
17
|
+
return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_mode)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def check(item: PlannedAction) -> tuple[str, tuple[os.stat_result, os.stat_result] | None]:
|
|
21
|
+
"""Return ``("", (kept, target))`` when the action is safe, else a refusal reason.
|
|
22
|
+
|
|
23
|
+
``target`` is stat'ed without following links: it must be a regular, writable
|
|
24
|
+
file of its own, so a link, a junction, a directory or a read-only file is never
|
|
25
|
+
removed or replaced.
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
target = os.lstat(item.target)
|
|
29
|
+
except OSError as exc:
|
|
30
|
+
return f"cannot stat target: {exc}", None
|
|
31
|
+
if stat.S_ISLNK(target.st_mode) or getattr(target, "st_reparse_tag", 0) == _MOUNT_POINT:
|
|
32
|
+
return "target is a link; only regular files are acted on", None
|
|
33
|
+
if not stat.S_ISREG(target.st_mode):
|
|
34
|
+
return "target is not a regular file", None
|
|
35
|
+
if not target.st_mode & stat.S_IWUSR or not os.access(item.target, os.W_OK):
|
|
36
|
+
return "target is read-only", None
|
|
37
|
+
try:
|
|
38
|
+
kept = os.stat(item.keep)
|
|
39
|
+
except OSError as exc:
|
|
40
|
+
return f"cannot stat the kept copy: {exc}", None
|
|
41
|
+
if not stat.S_ISREG(kept.st_mode):
|
|
42
|
+
return "the kept copy is not a regular file", None
|
|
43
|
+
if os.path.samestat(kept, target):
|
|
44
|
+
return "already the same file as the kept copy", None
|
|
45
|
+
if kept.st_size != item.size or target.st_size != item.size:
|
|
46
|
+
return "size changed since the scan", None
|
|
47
|
+
if item.action == "hardlink" and kept.st_dev != target.st_dev:
|
|
48
|
+
return "on a different device than the kept copy", None
|
|
49
|
+
try:
|
|
50
|
+
equal = same_content(item.keep, item.target)
|
|
51
|
+
except OSError as exc:
|
|
52
|
+
return f"cannot compare: {exc}", None
|
|
53
|
+
if not equal:
|
|
54
|
+
return "content differs from the kept copy", None
|
|
55
|
+
try:
|
|
56
|
+
streams = extra_streams(item.target)
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
return f"cannot list data streams: {exc}", None
|
|
59
|
+
if streams:
|
|
60
|
+
return f"target has extra data streams: {', '.join(streams)}", None
|
|
61
|
+
return "", (kept, target)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def unchanged(item: PlannedAction, kept: os.stat_result, target: os.stat_result) -> str:
|
|
65
|
+
"""Re-stat both files right before acting; return a reason if either moved on."""
|
|
66
|
+
try:
|
|
67
|
+
now_target = os.lstat(item.target)
|
|
68
|
+
now_kept = os.stat(item.keep)
|
|
69
|
+
except OSError as exc:
|
|
70
|
+
return f"changed before acting: {exc}"
|
|
71
|
+
if pin(now_target) != pin(target):
|
|
72
|
+
return "target was replaced or modified before acting"
|
|
73
|
+
if pin(now_kept) != pin(kept):
|
|
74
|
+
return "the kept copy was replaced or modified before acting"
|
|
75
|
+
return ""
|
dupefind/_cli.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Command line entry point: ``dupefind PATH...``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from typing import NoReturn, TextIO
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from ._actions import KEEP_POLICIES, apply_actions, plan_actions
|
|
13
|
+
from ._cli_output import print_actions, print_groups, print_summary
|
|
14
|
+
from ._find import scan
|
|
15
|
+
from ._json import report_to_json
|
|
16
|
+
from ._options import ScanOptions
|
|
17
|
+
from ._types import Action, KeepPolicy
|
|
18
|
+
|
|
19
|
+
_EXAMPLES = """\
|
|
20
|
+
examples:
|
|
21
|
+
dupefind ~/Pictures list duplicate groups
|
|
22
|
+
dupefind a b --min-size 1M --json scan two trees, files of 1 MiB or more, JSON out
|
|
23
|
+
dupefind . --exclude .git --exclude "*.tmp"
|
|
24
|
+
dupefind . --delete --keep oldest show what would be deleted (dry run)
|
|
25
|
+
dupefind . --delete --keep oldest --apply really delete the newer copies
|
|
26
|
+
dupefind . --hardlink --apply replace copies with hard links to one file
|
|
27
|
+
|
|
28
|
+
Nothing is changed unless --apply is given with --delete or --hardlink.
|
|
29
|
+
With --delete or --hardlink, empty files are left alone unless --empty is given.
|
|
30
|
+
Exit status: 0 on success, 1 on an error or a failed action.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Parser(argparse.ArgumentParser):
|
|
35
|
+
def error(self, message: str) -> NoReturn:
|
|
36
|
+
self.print_usage(sys.stderr)
|
|
37
|
+
self.exit(1, f"{self.prog}: error: {message}\n")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_size(text: str) -> int:
|
|
41
|
+
"""Parse a byte count with an optional K, M, G or T suffix (powers of 1024)."""
|
|
42
|
+
units = {"": 1, "K": 1 << 10, "M": 1 << 20, "G": 1 << 30, "T": 1 << 40}
|
|
43
|
+
raw = text.strip().upper().removesuffix("B").removesuffix("I")
|
|
44
|
+
suffix = raw[-1:] if raw[-1:] in units else ""
|
|
45
|
+
number = raw[: len(raw) - len(suffix)]
|
|
46
|
+
if not number.isdigit():
|
|
47
|
+
raise argparse.ArgumentTypeError(f"invalid size: {text!r}")
|
|
48
|
+
return int(number) * units[suffix]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
52
|
+
"""Return the argument parser used by ``main``."""
|
|
53
|
+
parser = _Parser(
|
|
54
|
+
prog="dupefind",
|
|
55
|
+
description="Find duplicate files by size, then partial hash, then full hash.",
|
|
56
|
+
epilog=_EXAMPLES,
|
|
57
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
58
|
+
)
|
|
59
|
+
add = parser.add_argument
|
|
60
|
+
add("paths", nargs="+", metavar="PATH", help="directories or files to scan")
|
|
61
|
+
add("--version", action="version", version=f"%(prog)s {__version__}")
|
|
62
|
+
add("--json", action="store_true", help="print one JSON document instead of text")
|
|
63
|
+
add("--algorithm", default="sha256", help="hashlib algorithm (default: sha256)")
|
|
64
|
+
add("--partial-size", type=parse_size, default=4096, help="prefix bytes hashed first")
|
|
65
|
+
add("--min-size", type=parse_size, default=0, help="ignore smaller files (e.g. 10K)")
|
|
66
|
+
add("--max-size", type=parse_size, default=None, help="ignore larger files (e.g. 2G)")
|
|
67
|
+
add("--no-empty", action="store_true", help="ignore zero-length files")
|
|
68
|
+
add("--empty", action="store_true", help="let --delete/--hardlink act on empty files")
|
|
69
|
+
add("--include", action="append", default=[], metavar="GLOB", help="only these files")
|
|
70
|
+
add("--exclude", action="append", default=[], metavar="GLOB", help="skip files and dirs")
|
|
71
|
+
add("--follow-symlinks", action="store_true", help="follow symlinks and junctions")
|
|
72
|
+
add("--hardlinks", action="store_true", help="report hard links to one file as copies")
|
|
73
|
+
act = parser.add_mutually_exclusive_group()
|
|
74
|
+
act.add_argument("--delete", action="store_true", help="plan deleting all but one copy")
|
|
75
|
+
act.add_argument("--hardlink", action="store_true", help="plan linking copies to one")
|
|
76
|
+
add("--keep", choices=KEEP_POLICIES, default="oldest", help="copy to keep (default: oldest)")
|
|
77
|
+
add("--apply", action="store_true", help="carry out --delete or --hardlink")
|
|
78
|
+
add("-q", "--quiet", action="store_true", help="no summary or issues on stderr")
|
|
79
|
+
return parser
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main(argv: Sequence[str] | None = None, *, stdout: TextIO | None = None) -> int:
|
|
83
|
+
"""Run the command line tool and return its exit status."""
|
|
84
|
+
_tolerate_unencodable(sys.stderr)
|
|
85
|
+
out = stdout if stdout is not None else _tolerate_unencodable(sys.stdout)
|
|
86
|
+
parser = build_parser()
|
|
87
|
+
args = parser.parse_args(argv)
|
|
88
|
+
action: Action | None = "delete" if args.delete else "hardlink" if args.hardlink else None
|
|
89
|
+
if args.apply and action is None:
|
|
90
|
+
parser.error("--apply needs --delete or --hardlink")
|
|
91
|
+
if args.empty and (action is None or args.no_empty):
|
|
92
|
+
parser.error("--empty needs --delete or --hardlink and no --no-empty")
|
|
93
|
+
missing = [path for path in args.paths if not os.path.exists(path)]
|
|
94
|
+
if missing:
|
|
95
|
+
parser.error(f"no such file or directory: {missing[0]}")
|
|
96
|
+
try:
|
|
97
|
+
options = ScanOptions(
|
|
98
|
+
algorithm=args.algorithm,
|
|
99
|
+
partial_size=args.partial_size,
|
|
100
|
+
min_size=args.min_size,
|
|
101
|
+
max_size=args.max_size,
|
|
102
|
+
include_empty=not args.no_empty and (action is None or args.empty),
|
|
103
|
+
include=tuple(args.include),
|
|
104
|
+
exclude=tuple(args.exclude),
|
|
105
|
+
follow_symlinks=args.follow_symlinks,
|
|
106
|
+
hardlinks=args.hardlinks,
|
|
107
|
+
)
|
|
108
|
+
except ValueError as exc:
|
|
109
|
+
parser.error(str(exc))
|
|
110
|
+
report = scan(*args.paths, options=options)
|
|
111
|
+
results = None
|
|
112
|
+
if action is not None:
|
|
113
|
+
keep: KeepPolicy = args.keep
|
|
114
|
+
plan = plan_actions(report.groups, action=action, keep=keep)
|
|
115
|
+
results = apply_actions(plan, dry_run=not args.apply)
|
|
116
|
+
if args.json:
|
|
117
|
+
out.write(report_to_json(report, results) + "\n")
|
|
118
|
+
else:
|
|
119
|
+
print_groups(report, out)
|
|
120
|
+
if results is not None:
|
|
121
|
+
print_actions(results, out)
|
|
122
|
+
if not args.quiet:
|
|
123
|
+
print_summary(report, results, sys.stderr)
|
|
124
|
+
failed = results is not None and any(r.status == "failed" for r in results)
|
|
125
|
+
return 1 if failed else 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _tolerate_unencodable(stream: TextIO) -> TextIO:
|
|
129
|
+
"""Write paths the console encoding cannot show as escapes instead of failing."""
|
|
130
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
131
|
+
if reconfigure is not None:
|
|
132
|
+
reconfigure(errors="backslashreplace")
|
|
133
|
+
return stream
|
dupefind/_cli_output.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Plain-text rendering for the command line tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import TextIO
|
|
7
|
+
|
|
8
|
+
from ._types import ActionResult, ScanReport
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def print_groups(report: ScanReport, out: TextIO) -> None:
|
|
12
|
+
"""Print each group as a header line, its paths, then a blank line."""
|
|
13
|
+
for group in report.groups:
|
|
14
|
+
count = len(group.files)
|
|
15
|
+
header = f"# {group.size} bytes x {count} files, {group.algorithm}:{group.digest}"
|
|
16
|
+
out.write(header + "\n")
|
|
17
|
+
for item in group.files:
|
|
18
|
+
out.write(f"{item.path}\n")
|
|
19
|
+
for alias in item.aliases:
|
|
20
|
+
out.write(f" = {alias}\n")
|
|
21
|
+
out.write("\n")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def print_actions(results: Sequence[ActionResult], out: TextIO) -> None:
|
|
25
|
+
"""Print one line per action: status, action, target and the kept copy."""
|
|
26
|
+
for result in results:
|
|
27
|
+
item = result.planned
|
|
28
|
+
line = f"[{result.status}] {item.action} {item.target} (keep {item.keep})"
|
|
29
|
+
if result.reason:
|
|
30
|
+
line += f": {result.reason}"
|
|
31
|
+
out.write(line + "\n")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def print_summary(report: ScanReport, results: Sequence[ActionResult] | None, err: TextIO) -> None:
|
|
35
|
+
"""Print skipped paths and a one-line summary to the error stream."""
|
|
36
|
+
for issue in report.issues:
|
|
37
|
+
err.write(f"dupefind: skipped {issue.path} ({issue.kind}): {issue.message}\n")
|
|
38
|
+
stats = report.stats
|
|
39
|
+
line = (
|
|
40
|
+
f"dupefind: {stats.groups} groups, {stats.duplicate_files} files, "
|
|
41
|
+
f"{stats.wasted_bytes} bytes reclaimable, {len(report.issues)} issues"
|
|
42
|
+
)
|
|
43
|
+
if results is not None:
|
|
44
|
+
counts: dict[str, int] = {}
|
|
45
|
+
for result in results:
|
|
46
|
+
counts[result.status] = counts.get(result.status, 0) + 1
|
|
47
|
+
line += "; actions: " + ", ".join(f"{n} {s}" for s, n in sorted(counts.items()))
|
|
48
|
+
if not results:
|
|
49
|
+
line += "none"
|
|
50
|
+
err.write(line + "\n")
|
dupefind/_find.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Staged duplicate detection: size, then a partial hash, then a full hash."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Callable, Iterator
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ._options import ScanOptions
|
|
11
|
+
from ._scan import Walker, group_by_size
|
|
12
|
+
from ._stages import Stages
|
|
13
|
+
from ._types import DuplicateGroup, Progress, ScanIssue, ScanReport, ScanStats
|
|
14
|
+
|
|
15
|
+
PathLike = str | os.PathLike[str]
|
|
16
|
+
ProgressCallback = Callable[[Progress], None]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def iter_groups(
|
|
20
|
+
*roots: PathLike,
|
|
21
|
+
options: ScanOptions | None = None,
|
|
22
|
+
on_progress: ProgressCallback | None = None,
|
|
23
|
+
issues: list[ScanIssue] | None = None,
|
|
24
|
+
stats: ScanStats | None = None,
|
|
25
|
+
) -> Iterator[DuplicateGroup]:
|
|
26
|
+
"""Stream ``DuplicateGroup`` objects for files under ``roots`` as they are confirmed.
|
|
27
|
+
|
|
28
|
+
Each root may be a directory (walked recursively) or a single file. Roots that
|
|
29
|
+
are links are followed; links found inside them are not, unless
|
|
30
|
+
``options.follow_symlinks`` is true. A file reached twice (overlapping roots, a
|
|
31
|
+
loop) is compared once. Files are only read, never changed.
|
|
32
|
+
|
|
33
|
+
The work is staged so that most files are never read in full:
|
|
34
|
+
|
|
35
|
+
1. walk and group by size; a unique size cannot have a duplicate;
|
|
36
|
+
2. merge names of the same file (hard links) unless ``options.hardlinks``;
|
|
37
|
+
3. hash the first ``options.partial_size`` bytes of same-size files;
|
|
38
|
+
4. hash in full only files that still match, and re-check that each file's size
|
|
39
|
+
and modification time did not change while it was being read.
|
|
40
|
+
|
|
41
|
+
Paths that cannot be used are appended to ``issues`` (when given) and left out.
|
|
42
|
+
``stats`` (when given) is filled in while the generator runs. Groups of larger
|
|
43
|
+
files come first.
|
|
44
|
+
"""
|
|
45
|
+
opts = options if options is not None else ScanOptions()
|
|
46
|
+
sink: list[ScanIssue] = issues if issues is not None else []
|
|
47
|
+
counters = stats if stats is not None else ScanStats()
|
|
48
|
+
walker = Walker(opts, sink, on_progress)
|
|
49
|
+
entries = walker.walk(roots)
|
|
50
|
+
counters.files_seen = len(entries)
|
|
51
|
+
stages = Stages(walker, counters)
|
|
52
|
+
by_size = group_by_size(entries)
|
|
53
|
+
candidates = [stages.identify(by_size[size]) for size in sorted(by_size, reverse=True)]
|
|
54
|
+
yield from stages.run([group for group in candidates if group])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def scan(
|
|
58
|
+
*roots: PathLike,
|
|
59
|
+
options: ScanOptions | None = None,
|
|
60
|
+
on_progress: ProgressCallback | None = None,
|
|
61
|
+
) -> ScanReport:
|
|
62
|
+
"""Scan ``roots`` and return a ``ScanReport`` with groups, issues and counters.
|
|
63
|
+
|
|
64
|
+
This is ``iter_groups`` collected into one object. See it for the staging and
|
|
65
|
+
the link policies. Nothing on disk is modified.
|
|
66
|
+
"""
|
|
67
|
+
report = ScanReport()
|
|
68
|
+
report.groups = list(
|
|
69
|
+
iter_groups(
|
|
70
|
+
*roots,
|
|
71
|
+
options=options,
|
|
72
|
+
on_progress=on_progress,
|
|
73
|
+
issues=report.issues,
|
|
74
|
+
stats=report.stats,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
return report
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def iter_duplicates(
|
|
81
|
+
root: PathLike,
|
|
82
|
+
*,
|
|
83
|
+
follow_symlinks: bool = False,
|
|
84
|
+
options: ScanOptions | None = None,
|
|
85
|
+
) -> Iterator[tuple[str, list[Path]]]:
|
|
86
|
+
"""Yield ``(full hash, paths)`` for every set of two or more identical files.
|
|
87
|
+
|
|
88
|
+
The original ``0.1.0`` interface, kept for compatibility. ``options`` adds every
|
|
89
|
+
scan setting; ``follow_symlinks=True`` turns link following on in either case.
|
|
90
|
+
Paths are the first name reached for each file; hard links to the same file are
|
|
91
|
+
one file here (see ``ScanOptions.hardlinks``). Use ``iter_groups`` for typed
|
|
92
|
+
results and ``scan`` for the issues met.
|
|
93
|
+
"""
|
|
94
|
+
opts = _with_follow(options, follow_symlinks)
|
|
95
|
+
for group in iter_groups(root, options=opts):
|
|
96
|
+
yield group.digest, group.paths
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def find_duplicates(
|
|
100
|
+
root: PathLike,
|
|
101
|
+
*,
|
|
102
|
+
follow_symlinks: bool = False,
|
|
103
|
+
options: ScanOptions | None = None,
|
|
104
|
+
) -> dict[str, list[Path]]:
|
|
105
|
+
"""Return duplicate files under ``root`` grouped by their full content hash.
|
|
106
|
+
|
|
107
|
+
Each value holds two or more paths with identical content; files with no
|
|
108
|
+
duplicate are left out. Nothing on disk is modified. See ``iter_duplicates``.
|
|
109
|
+
"""
|
|
110
|
+
return dict(iter_duplicates(root, follow_symlinks=follow_symlinks, options=options))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _with_follow(options: ScanOptions | None, follow_symlinks: bool) -> ScanOptions:
|
|
114
|
+
opts = options if options is not None else ScanOptions()
|
|
115
|
+
if follow_symlinks and not opts.follow_symlinks:
|
|
116
|
+
opts = dataclasses.replace(opts, follow_symlinks=True)
|
|
117
|
+
return opts
|