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 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
@@ -0,0 +1,7 @@
1
+ """Allow ``python -m dupefind``."""
2
+
3
+ import sys
4
+
5
+ from ._cli import main
6
+
7
+ sys.exit(main())
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
@@ -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