dupefind 0.2.0__tar.gz

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.
Files changed (45) hide show
  1. dupefind-0.2.0/.gitattributes +1 -0
  2. dupefind-0.2.0/.github/FUNDING.yml +1 -0
  3. dupefind-0.2.0/.gitignore +12 -0
  4. dupefind-0.2.0/CHANGELOG.md +52 -0
  5. dupefind-0.2.0/CONTRIBUTING.md +19 -0
  6. dupefind-0.2.0/GUIDE.md +181 -0
  7. dupefind-0.2.0/IMPROVEMENTS-round.md +95 -0
  8. dupefind-0.2.0/LICENSE +21 -0
  9. dupefind-0.2.0/PKG-INFO +154 -0
  10. dupefind-0.2.0/README.md +122 -0
  11. dupefind-0.2.0/pyproject.toml +59 -0
  12. dupefind-0.2.0/requirements-dev.txt +3 -0
  13. dupefind-0.2.0/scripts/check.ps1 +7 -0
  14. dupefind-0.2.0/scripts/release.ps1 +28 -0
  15. dupefind-0.2.0/src/dupefind/__init__.py +52 -0
  16. dupefind-0.2.0/src/dupefind/__main__.py +7 -0
  17. dupefind-0.2.0/src/dupefind/_act.py +108 -0
  18. dupefind-0.2.0/src/dupefind/_actions.py +99 -0
  19. dupefind-0.2.0/src/dupefind/_apply.py +75 -0
  20. dupefind-0.2.0/src/dupefind/_cli.py +133 -0
  21. dupefind-0.2.0/src/dupefind/_cli_output.py +50 -0
  22. dupefind-0.2.0/src/dupefind/_find.py +117 -0
  23. dupefind-0.2.0/src/dupefind/_hash.py +47 -0
  24. dupefind-0.2.0/src/dupefind/_json.py +72 -0
  25. dupefind-0.2.0/src/dupefind/_options.py +81 -0
  26. dupefind-0.2.0/src/dupefind/_scan.py +169 -0
  27. dupefind-0.2.0/src/dupefind/_stages.py +151 -0
  28. dupefind-0.2.0/src/dupefind/_streams.py +62 -0
  29. dupefind-0.2.0/src/dupefind/_types.py +129 -0
  30. dupefind-0.2.0/src/dupefind/py.typed +0 -0
  31. dupefind-0.2.0/tests/helpers.py +106 -0
  32. dupefind-0.2.0/tests/test_actions.py +172 -0
  33. dupefind-0.2.0/tests/test_cli.py +141 -0
  34. dupefind-0.2.0/tests/test_contract.py +49 -0
  35. dupefind-0.2.0/tests/test_e2e_wheel.py +74 -0
  36. dupefind-0.2.0/tests/test_find.py +108 -0
  37. dupefind-0.2.0/tests/test_links.py +135 -0
  38. dupefind-0.2.0/tests/test_options.py +139 -0
  39. dupefind-0.2.0/tests/test_oracle.py +66 -0
  40. dupefind-0.2.0/tests/test_promise.py +74 -0
  41. dupefind-0.2.0/tests/test_readonly.py +109 -0
  42. dupefind-0.2.0/tests/test_review2_fixes.py +149 -0
  43. dupefind-0.2.0/tests/test_review_fixes.py +111 -0
  44. dupefind-0.2.0/tests/test_scan_issues.py +132 -0
  45. dupefind-0.2.0/tests/test_toctou.py +167 -0
@@ -0,0 +1 @@
1
+ * text=auto eol=lf
@@ -0,0 +1 @@
1
+ buy_me_a_coffee: jlianesglrs
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.0]
10
+
11
+ ### Added
12
+
13
+ - `scan` and `iter_groups` take several roots (directories or files) and return
14
+ typed results: `ScanReport`, `DuplicateGroup`, `DuplicateFile`, `ScanIssue`,
15
+ `ScanStats`, `Progress`.
16
+ - `ScanOptions`: hash algorithm, partial hash size, size bounds, empty files,
17
+ include and exclude globs, link following and hard-link reporting.
18
+ - Progress callback for the walk, partial and full stages.
19
+ - Files that change while being read, unreadable files and missing paths are
20
+ reported as issues instead of being dropped silently.
21
+ - `plan_actions`, `apply_actions` and `choose_keeper`: delete or hard link extra
22
+ copies with a keep policy (first, oldest, newest, shortest). Dry run by
23
+ default; every action re-checks size, identity and byte-for-byte equality,
24
+ stats both files again just before acting, moves the target aside and checks it
25
+ once more before removing it, and refuses links, junctions, read-only targets
26
+ and targets with extra NTFS data streams.
27
+ - `report_to_json` and `report_to_dict` with a versioned schema.
28
+ - The `dupefind` command line tool and `python -m dupefind`.
29
+ - GUIDE.md.
30
+
31
+ ### Changed
32
+
33
+ - Hard links to one file are no longer reported as duplicates of each other;
34
+ they are merged into one file and listed as `aliases`. Pass
35
+ `ScanOptions(hardlinks=True)` for the previous view.
36
+ - Files no larger than the partial hash size are read once instead of twice.
37
+
38
+ ### Fixed
39
+
40
+ - Windows junctions were walked even with `follow_symlinks=False`, so a junction
41
+ into the tree listed files as duplicates of themselves.
42
+ - Overlapping or repeated roots no longer list a file twice.
43
+
44
+ ## [0.1.0]
45
+
46
+ - First release.
47
+ - `find_duplicates` returns duplicate files grouped by their full content hash.
48
+ - `iter_duplicates` streams the same groups one at a time.
49
+ - Staged matching compares files by size, then a partial hash, then a full hash,
50
+ so unique files are never read in full.
51
+ - Read-only by design: the search reads file contents and never modifies, moves
52
+ or deletes anything on disk.
@@ -0,0 +1,19 @@
1
+ # Contributing
2
+
3
+ Thanks for taking the time. This project keeps a small, predictable shape.
4
+
5
+ ## Setup
6
+
7
+ ```
8
+ pip install -r requirements-dev.txt
9
+ ```
10
+
11
+ ## Before you open a pull request
12
+
13
+ - Keep public API in `src/dupefind/__init__.py`; internals live in `_`-prefixed modules.
14
+ - Add a test for the behaviour you change. Tests inject time, disk and network; they
15
+ never touch the real thing.
16
+ - Run `scripts/check.ps1` (format, lint, types, tests) and make sure it passes.
17
+ - One logical change per commit, imperative subject line.
18
+
19
+ Open an issue first if the change is large or shifts the public API.
@@ -0,0 +1,181 @@
1
+ # dupefind guide
2
+
3
+ ## How a scan works
4
+
5
+ `scan(*roots, options=None, on_progress=None)` returns a `ScanReport`.
6
+ `iter_groups` takes the same arguments and streams `DuplicateGroup` objects
7
+ instead. Both read files and never change them.
8
+
9
+ 1. **Walk.** Every root is used as given, even when it is a link. A root may be a
10
+ directory (walked depth first, entries in name order) or a single file. Links
11
+ met during the walk are skipped unless `follow_symlinks=True`. Directories are
12
+ recognised by their real location, so nothing is walked twice.
13
+ 2. **Size.** Files are grouped by byte size. A unique size cannot have a duplicate,
14
+ so such a file is never opened.
15
+ 3. **Identity.** Each same-size file is stat'ed again. Two paths that resolve to
16
+ the same location are one entry. Two names with the same device and file id are
17
+ hard links to one file: they are merged into one `DuplicateFile` whose
18
+ `aliases` lists the other names, unless `hardlinks=True`. If the size or
19
+ modification time differs from the walk, the file is reported as `changed`.
20
+ 4. **Partial hash.** The first `partial_size` bytes are hashed. A file no larger
21
+ than that is now fully hashed and is never read again.
22
+ 5. **Full hash.** Files still sharing a partial digest are hashed in full. A file
23
+ whose byte count differs from its size while it is read, or whose size or
24
+ modification time changed by the time its group is confirmed, is dropped and
25
+ reported as `changed`.
26
+
27
+ Groups of larger files are yielded first.
28
+
29
+ ## Options
30
+
31
+ | Field | Default | Meaning |
32
+ | --- | --- | --- |
33
+ | `algorithm` | `"sha256"` | Any fixed-size `hashlib` algorithm. The digest is of the whole file. |
34
+ | `partial_size` | `4096` | Bytes hashed before any full read. Must be at least 1. |
35
+ | `min_size` | `0` | Ignore files smaller than this. |
36
+ | `max_size` | `None` | Ignore files larger than this. |
37
+ | `include_empty` | `True` | Compare zero-length files (they all form one group). |
38
+ | `include` | `()` | Globs; when given, only matching files are compared. |
39
+ | `exclude` | `()` | Globs for files and directories to skip. |
40
+ | `follow_symlinks` | `False` | Walk into symlinked directories and junctions. |
41
+ | `hardlinks` | `False` | List every hard-link name as a separate copy. |
42
+
43
+ Invalid values raise `ValueError` when the options are built (`TypeError` for a
44
+ bare string given as a pattern list).
45
+
46
+ Glob rules: a pattern without `/` matches the base name; a pattern with `/`
47
+ matches the path relative to the root it was found under, written with `/`.
48
+ Matching follows the platform case rules, so it is case-insensitive on Windows.
49
+
50
+ Empty files: fdupes compares them by default, jdupes needs `-z`, and rmlint leaves
51
+ them out. `dupefind` keeps its `0.1.0` behaviour and compares them; pass
52
+ `include_empty=False` or `min_size=1` (`--no-empty` or `--min-size 1`) before any
53
+ clean-up.
54
+
55
+ ## Results
56
+
57
+ - `DuplicateGroup`: `digest`, `algorithm`, `size`, `files` (sorted by path, at
58
+ least two), `paths`, `wasted_bytes` (size times copies minus one).
59
+ - `DuplicateFile`: `path`, `size`, `mtime_ns`, `aliases`.
60
+ - `ScanIssue`: `path`, `kind` (`permission`, `missing`, `changed`, `error`),
61
+ `message`. The path is not part of any group.
62
+ - `ScanStats`: `files_seen`, `candidates`, `partial_hashed`, `full_hashed`,
63
+ `bytes_read`, `groups`, `duplicate_files`, `wasted_bytes`.
64
+ - `Progress`: `stage` (`walk`, `partial`, `full`), `done`, `total`, `bytes_read`.
65
+
66
+ ## Clean-up actions
67
+
68
+ `plan_actions(groups, action="delete", keep="oldest")` returns `PlannedAction`
69
+ items. One file per group survives with its aliases; every other file and every
70
+ alias of it gets an action, so a delete frees the space.
71
+
72
+ A regular file is always preferred as the survivor over a symlink (which can only
73
+ appear with `follow_symlinks`), so the real data is what is kept.
74
+
75
+ | `keep` | Survivor (ties broken by path) |
76
+ | --- | --- |
77
+ | `first` | first path in sort order |
78
+ | `oldest` | oldest modification time |
79
+ | `newest` | newest modification time |
80
+ | `shortest` | shortest path |
81
+
82
+ `apply_actions(plan, dry_run=True)` returns one `ActionResult` per item with
83
+ `status` `dry-run`, `done`, `skipped` or `failed`. Every item, in a dry run too, is
84
+ checked right before it would run:
85
+
86
+ - the target, looked at without following links, is a regular file of its own:
87
+ a symlink, a junction or a directory found at the target path is refused, so
88
+ with `follow_symlinks` the symlinks in a group are reported but never acted on;
89
+ - the target is writable (see read-only files below);
90
+ - the kept file exists and is a regular file;
91
+ - they are not the same file (same device and file id), so a hard link, junction
92
+ path, symlink or other spelling of the kept file is never removed;
93
+ - both still have the planned size;
94
+ - for `hardlink`, both are on the same device;
95
+ - the two files are equal byte for byte;
96
+ - the target is not a kept file anywhere in the plan.
97
+
98
+ - on Windows, the target has no extra NTFS data streams (the download marker
99
+ `Zone.Identifier` is allowed), since only the main stream is compared and the
100
+ others would be lost.
101
+
102
+ Then the act itself is guarded so the data always exists under some name:
103
+
104
+ 1. The kept file is opened and held open until the act is over. On Windows this
105
+ stops any other process deleting or renaming it in the meantime.
106
+ 2. Both paths are stat'ed again and must still show the same device, file id,
107
+ size, modification time and mode as the files that were just compared.
108
+ 3. The target is renamed aside to a random name next to it
109
+ (`.NAME.RANDOM.dupefind`) and that aside file is checked once more to be the
110
+ compared file, and the kept file to be unchanged. A target swapped for another
111
+ file or a link, or a kept file removed or rewritten, is refused and the aside
112
+ file is put back under its name without overwriting anything.
113
+ 4. `delete` then removes the aside file. `hardlink` creates a hard link to the real
114
+ file behind the kept path at the freed name (which fails if anything appeared
115
+ there), checks it is the kept file, and only then removes the aside file.
116
+
117
+ A refusal never removes either file, so a group always keeps at least the kept
118
+ copy. During step 4 the target name is briefly missing, but its data is still on
119
+ disk under the aside name; if the process is killed there, that file remains. If
120
+ putting a file back fails, the result says where it is. What remains is the few
121
+ instructions between the last check of the kept file and the removal of the aside
122
+ copy, during which, on POSIX only (no open-file lock there), another process could
123
+ delete the kept file.
124
+
125
+ Read-only files: a target that is read-only (the Windows read-only attribute, or
126
+ no owner write bit or no write access on POSIX, such as mode 0444) is `skipped`
127
+ with the reason `target is read-only`, for both actions, in a dry run too, and is
128
+ left exactly as it was. Clear the flag first if you want it handled. A read-only
129
+ kept file is fine: it is only read, and a hard link to it shares its attributes.
130
+ If a hard link to a read-only kept file has to be undone, the flag is cleared just
131
+ long enough to remove that extra name and is then put back. If the operating
132
+ system refuses the act itself (for example a busy target, or a directory you
133
+ cannot write to), the result is `failed` and the target is left in place.
134
+
135
+ Windows allows at most 1023 extra hard links per file; beyond that the action
136
+ fails and is reported.
137
+
138
+ Only file contents are compared. Metadata that is not part of the content, such as
139
+ permissions, owners and extended attributes, is not compared and goes with a
140
+ removed or replaced copy.
141
+
142
+ ## JSON
143
+
144
+ `report_to_json(report, actions=None, indent=2)` and `report_to_dict` produce:
145
+
146
+ ```json
147
+ {
148
+ "schema": 1,
149
+ "groups": [
150
+ {"digest": "...", "algorithm": "sha256", "size": 6000, "wasted_bytes": 6000,
151
+ "files": [{"path": "a.jpg", "mtime_ns": 0, "aliases": []}]}
152
+ ],
153
+ "issues": [{"path": "...", "kind": "permission", "message": "..."}],
154
+ "stats": {"files_seen": 0, "candidates": 0, "partial_hashed": 0, "full_hashed": 0,
155
+ "bytes_read": 0, "groups": 0, "duplicate_files": 0, "wasted_bytes": 0},
156
+ "actions": null
157
+ }
158
+ ```
159
+
160
+ `actions` is a list of `{action, target, keep, size, status, reason}` when
161
+ actions were planned. `schema` changes only if a field is removed or changes
162
+ meaning.
163
+
164
+ ## Command line
165
+
166
+ ```
167
+ dupefind PATH [PATH ...] [--json] [--algorithm NAME] [--partial-size N]
168
+ [--min-size N] [--max-size N] [--no-empty] [--include GLOB]...
169
+ [--exclude GLOB]... [--follow-symlinks] [--hardlinks]
170
+ [--delete | --hardlink] [--keep first|oldest|newest|shortest]
171
+ [--apply] [--empty] [-q]
172
+ ```
173
+
174
+ With `--delete` or `--hardlink`, empty files are left out unless `--empty` is given
175
+ (a plain report still lists them, unless `--no-empty`).
176
+
177
+ Sizes accept `K`, `M`, `G` and `T` suffixes (powers of 1024). Text output lists
178
+ each group as `# SIZE bytes x N files, ALGORITHM:DIGEST`, its paths (aliases
179
+ indented with `=`), and a blank line. With `--delete` or `--hardlink` one line per
180
+ action follows. Skipped paths and a summary go to stderr unless `-q`. Errors exit
181
+ with status 1, as does any failed action. `python -m dupefind` works too.
@@ -0,0 +1,95 @@
1
+ # dupefind improvement round
2
+
3
+ Progress:
4
+ - [x] Brainstorm ranked improvements (this file)
5
+ - [x] Typed results, options and scan issues (`ScanOptions`, `DuplicateGroup`, `ScanReport`)
6
+ - [x] Walker: hard-link identity, junctions and symlinks not followed, globs, size limits
7
+ - [x] Staging: configurable algorithm and partial size, change detection, progress callback
8
+ - [x] Safe actions: plan and apply delete or hardlink, dry run by default, keep policies
9
+ - [x] JSON output and CLI (`dupefind PATH...`)
10
+ - [x] Docs: README, GUIDE, CHANGELOG, docstrings
11
+ - [x] Gates: ruff format, ruff check, mypy --strict, pytest
12
+ - [x] Contracts, brute-force oracle on seeded trees, real wheel e2e
13
+ - [x] Pull request opened (#2)
14
+ - [x] Independent review findings fixed, guard clean
15
+ - [x] Read-only targets refused, documented and tested (Windows attribute, POSIX 0444 in WSL)
16
+ - [x] Swaps between scan and apply (TOCTOU) refused, with a last re-stat before acting, tested
17
+ - [x] Second independent review (junction loops and roots, repeated, cased and nested roots) fixed
18
+ - [x] Gates, guard and PR body refreshed
19
+
20
+ Every item is additive. `find_duplicates` and `iter_duplicates` keep their names,
21
+ their positional `root` argument, `follow_symlinks`, and their return types.
22
+
23
+ ## Starting point
24
+
25
+ `0.1.0` grouped files by size, a 4 KiB SHA-256 prefix and a full SHA-256. It had no
26
+ options beyond `follow_symlinks`, no way to see what was skipped, and two safety
27
+ gaps found while reading the walker:
28
+
29
+ - Hard links to one file were reported as duplicates of each other. Any tool that
30
+ deleted "all but one" of such a group would free no space at best.
31
+ - On Windows a directory junction is not a symlink to `os.scandir`, so junctions
32
+ were walked even with `follow_symlinks=False`. A junction pointing back into the
33
+ tree made every file under it look like its own duplicate (the "same directory
34
+ given twice" data-loss caveat both fdupes and jdupes document).
35
+
36
+ ## Sources (fetched 2026-10-12)
37
+
38
+ - fdupes man page: https://raw.githubusercontent.com/adrianlopezroche/fdupes/master/fdupes.1
39
+ (size, MD5, then byte-by-byte; `-H` "files that point to the same disk area are
40
+ treated as non-duplicates" by default; `-s` follows symlinked directories only on
41
+ request; `-G`/`-L` size bounds; caveat on listing a file as its own duplicate).
42
+ - jdupes README: https://codeberg.org/jbruchon/jdupes/raw/branch/master/README.md
43
+ (linked files are non-duplicates "for safety" unless `-H`; zero-length files only
44
+ match with `-z`; the change check before acting on a file guards against TOCTTOU;
45
+ Windows allows at most 1023 hard links per file).
46
+ - rmlint man page: https://raw.githubusercontent.com/sahib/rmlint/master/docs/rmlint.1.rst
47
+ (`--no-hardlinked` keeps one file of a hard-linked set; `-F` ignores symlinks;
48
+ `--size` excludes empty files by default; rank criteria such as oldest mtime and
49
+ path for the original to keep).
50
+
51
+ ## Ranked improvements
52
+
53
+ 1. **Hard-link identity.** Files sharing a device and file id (`st_dev`, `st_ino`,
54
+ which Python fills from the NTFS file id on Windows) are one file. By default a
55
+ hard-linked set is reported once, with the other names kept as `aliases`; a set
56
+ made only of links to one file is never a duplicate group. `hardlinks=True`
57
+ restores the old view (fdupes and jdupes `-H`).
58
+ 2. **Links are not followed by default, junctions included.** Symlinks and Windows
59
+ junctions (reparse tag `IO_REPARSE_TAG_MOUNT_POINT`) are skipped unless
60
+ `follow_symlinks=True`. Every path is also de-duplicated by its normalised real
61
+ path, so overlapping roots or a root given twice (also in another letter case on
62
+ a case-insensitive filesystem) never list a file as its own duplicate.
63
+ 3. **Typed results.** `DuplicateGroup` (digest, algorithm, size, `files`, `paths`,
64
+ `wasted_bytes`), `DuplicateFile` (path, size, mtime, aliases) and `ScanReport`
65
+ (groups, issues, stats). Frozen dataclasses, fully typed.
66
+ 4. **Issues instead of silent drops.** Permission errors, vanished files and files
67
+ that changed while being read are recorded as `ScanIssue(path, kind, message)`
68
+ and left out of the results. Change detection re-stats size and mtime after the
69
+ full hash, as jdupes does before acting.
70
+ 5. **`ScanOptions`.** `algorithm` (any fixed-size `hashlib` algorithm, default
71
+ `sha256` so keys are unchanged), `partial_size`, `min_size`, `max_size`,
72
+ `include_empty`, `include`/`exclude` globs, `follow_symlinks`, `hardlinks`.
73
+ Validated at construction with typed `ValueError`s.
74
+ 6. **Staging that reads less.** A file no larger than the partial size is hashed
75
+ once and its partial digest is its full digest, so it is never read twice.
76
+ 7. **Multiple roots, streaming and progress.** `scan(*roots)` and
77
+ `iter_groups(*roots)` take several paths (directories or files). Groups stream
78
+ as they are confirmed. `on_progress` receives a `Progress(stage, done, total)`.
79
+ 8. **Safe actions.** `plan_actions(groups, action="delete"|"hardlink", keep=...)`
80
+ with keep policies `first`, `oldest`, `newest`, `shortest`; `apply_actions(plan,
81
+ dry_run=True)` acts only with `dry_run=False`. Before each action it re-checks
82
+ that both files exist, are not the same file, still have the scanned size, and
83
+ are byte-for-byte equal. Hard links are created next to the target and moved
84
+ over it atomically. Nothing is ever deleted without the explicit flag.
85
+ 9. **JSON output.** `report_to_json(report)` with a versioned, documented schema.
86
+ 10. **CLI.** `dupefind PATH...` prints fdupes-style groups, `--json`, the scan
87
+ options, and `--delete`/`--hardlink` that only print a plan unless `--apply`.
88
+ 11. **Docs.** Docstrings on every public name, a GUIDE covering policies, actions,
89
+ JSON schema and the CLI, and a CHANGELOG entry.
90
+
91
+ ## Out of scope
92
+
93
+ Byte-for-byte confirmation during the scan itself (hash equality is the scan
94
+ verdict; actions do the byte comparison), a hash cache database, and fuzzy
95
+ matching of media files.
dupefind-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Lianes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.5
2
+ Name: dupefind
3
+ Version: 0.2.0
4
+ Summary: Find duplicate files by staged hashing: group by size, then partial hash, then full hash
5
+ Project-URL: Homepage, https://lianes8studio.com
6
+ Project-URL: Repository, https://github.com/JaviLianes8/dupefind
7
+ Project-URL: Issues, https://github.com/JaviLianes8/dupefind/issues
8
+ Project-URL: Changelog, https://github.com/JaviLianes8/dupefind/blob/main/CHANGELOG.md
9
+ Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
10
+ Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
11
+ Author-email: Javier Lianes <javier@lianes8studio.com>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: cli,dedupe,duplicates,fdupes,files,filesystem,hardlink,hash
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Environment :: Console
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: Intended Audience :: System Administrators
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Topic :: System :: Filesystems
22
+ Classifier: Topic :: Utilities
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Provides-Extra: dev
26
+ Requires-Dist: build; extra == 'dev'
27
+ Requires-Dist: mypy; extra == 'dev'
28
+ Requires-Dist: pytest; extra == 'dev'
29
+ Requires-Dist: ruff; extra == 'dev'
30
+ Requires-Dist: twine; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # dupefind
34
+
35
+ Find duplicate files by staged hashing: group by size, then partial hash, then full hash.
36
+
37
+ A small, fully typed library with no dependencies (standard `hashlib` only) and a
38
+ command line tool. Scanning only reads files. Clean-up is a separate, explicit step
39
+ that is a dry run unless you say otherwise.
40
+
41
+ ## Install
42
+
43
+ ```
44
+ pip install dupefind
45
+ ```
46
+
47
+ ## Quick start
48
+
49
+ ```python
50
+ from dupefind import scan
51
+
52
+ report = scan("photos/import", "photos/library")
53
+ for group in report.groups:
54
+ print(group.size, group.digest)
55
+ for path in group.paths:
56
+ print(" ", path)
57
+ for issue in report.issues: # unreadable, vanished or changed while reading
58
+ print("skipped", issue.path, issue.kind)
59
+ ```
60
+
61
+ The original interface still works and returns `dict[str, list[Path]]` keyed by
62
+ the SHA-256 of the content:
63
+
64
+ ```python
65
+ from dupefind import find_duplicates, iter_duplicates
66
+
67
+ groups = find_duplicates("path/to/tree")
68
+ for digest, paths in iter_duplicates("path/to/tree"):
69
+ ...
70
+ ```
71
+
72
+ ## Options
73
+
74
+ ```python
75
+ from dupefind import ScanOptions, iter_groups
76
+
77
+ options = ScanOptions(
78
+ algorithm="blake2b", # any fixed-size hashlib algorithm, default sha256
79
+ partial_size=64 * 1024, # bytes hashed before any full read
80
+ min_size=1, # leave empty files alone
81
+ exclude=(".git", "*.tmp"),
82
+ )
83
+ for group in iter_groups("data", options=options, on_progress=print):
84
+ ...
85
+ ```
86
+
87
+ Safe defaults, in line with fdupes, jdupes and rmlint:
88
+
89
+ - **Hard links are one file.** Several names of the same file are merged and kept
90
+ in `aliases`; a set made only of hard links is never a duplicate group. Set
91
+ `hardlinks=True` to list every name.
92
+ - **Links are not followed.** Symlinks and Windows junctions found while walking
93
+ are skipped unless `follow_symlinks=True`. Paths you pass in are always used.
94
+ - **A file is never its own duplicate.** Overlapping roots, a root given twice, a
95
+ root in another letter case, or a link loop never list one file twice.
96
+ - **Changes are caught.** A file whose size or modification time changes while it
97
+ is read is left out and reported.
98
+ - **Errors are reported, not raised.** Permission errors and vanished files become
99
+ `ScanIssue` entries.
100
+
101
+ ## Cleaning up
102
+
103
+ ```python
104
+ from dupefind import apply_actions, plan_actions, scan
105
+
106
+ plan = plan_actions(scan("photos").groups, action="delete", keep="oldest")
107
+ for result in apply_actions(plan): # dry run: nothing changes
108
+ print(result.status, result.planned.target)
109
+ apply_actions(plan, dry_run=False) # now it happens
110
+ ```
111
+
112
+ `keep` is `first`, `oldest`, `newest` or `shortest`. `action="hardlink"` replaces
113
+ each copy with a hard link to the kept file instead. Right before acting, every
114
+ pair is checked again: both files exist, are not the same file, have the scanned
115
+ size, and are equal byte for byte; then both are stat'ed once more just before the
116
+ act, the target is moved aside and checked again, and the kept file is held open.
117
+ Links, junctions, read-only files and (on Windows) files with extra data streams
118
+ are never removed or replaced. Anything else is skipped and reported.
119
+
120
+ ## Command line
121
+
122
+ ```
123
+ dupefind ~/Pictures # list duplicate groups
124
+ dupefind a b --min-size 1M --json # two trees, JSON report
125
+ dupefind . --exclude .git --exclude "*.tmp"
126
+ dupefind . --delete --keep oldest # show the plan (dry run)
127
+ dupefind . --delete --keep oldest --apply # really delete the other copies
128
+ dupefind . --hardlink --apply # link copies to one file
129
+ ```
130
+
131
+ Nothing changes unless `--apply` is given with `--delete` or `--hardlink`. Those
132
+ two leave empty files alone unless `--empty` is added. A summary and any skipped paths go to stderr. Exit status is 0 on success and 1 on
133
+ an error or a failed action.
134
+
135
+ See [GUIDE.md](GUIDE.md) for the staging, every option, the JSON schema and the
136
+ safety checks in detail.
137
+
138
+ ## Why
139
+
140
+ Finding duplicate files is a common chore, but the Python packages for it are
141
+ mostly stale: `fastdupes` last shipped in 2015, `dupfinder` in 2010, and `dupe` in
142
+ 2021. The few that are maintained are command-line tools, not a typed library you
143
+ can import. `dupefind` is both: a typed API with safe link handling and explicit,
144
+ verified clean-up, and a CLI for the shell.
145
+
146
+ ## License
147
+
148
+ MIT. See [LICENSE](LICENSE).
149
+
150
+ ## Links
151
+
152
+ - Website: https://lianes8studio.com
153
+ - GitHub: https://github.com/JaviLianes8
154
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,122 @@
1
+ # dupefind
2
+
3
+ Find duplicate files by staged hashing: group by size, then partial hash, then full hash.
4
+
5
+ A small, fully typed library with no dependencies (standard `hashlib` only) and a
6
+ command line tool. Scanning only reads files. Clean-up is a separate, explicit step
7
+ that is a dry run unless you say otherwise.
8
+
9
+ ## Install
10
+
11
+ ```
12
+ pip install dupefind
13
+ ```
14
+
15
+ ## Quick start
16
+
17
+ ```python
18
+ from dupefind import scan
19
+
20
+ report = scan("photos/import", "photos/library")
21
+ for group in report.groups:
22
+ print(group.size, group.digest)
23
+ for path in group.paths:
24
+ print(" ", path)
25
+ for issue in report.issues: # unreadable, vanished or changed while reading
26
+ print("skipped", issue.path, issue.kind)
27
+ ```
28
+
29
+ The original interface still works and returns `dict[str, list[Path]]` keyed by
30
+ the SHA-256 of the content:
31
+
32
+ ```python
33
+ from dupefind import find_duplicates, iter_duplicates
34
+
35
+ groups = find_duplicates("path/to/tree")
36
+ for digest, paths in iter_duplicates("path/to/tree"):
37
+ ...
38
+ ```
39
+
40
+ ## Options
41
+
42
+ ```python
43
+ from dupefind import ScanOptions, iter_groups
44
+
45
+ options = ScanOptions(
46
+ algorithm="blake2b", # any fixed-size hashlib algorithm, default sha256
47
+ partial_size=64 * 1024, # bytes hashed before any full read
48
+ min_size=1, # leave empty files alone
49
+ exclude=(".git", "*.tmp"),
50
+ )
51
+ for group in iter_groups("data", options=options, on_progress=print):
52
+ ...
53
+ ```
54
+
55
+ Safe defaults, in line with fdupes, jdupes and rmlint:
56
+
57
+ - **Hard links are one file.** Several names of the same file are merged and kept
58
+ in `aliases`; a set made only of hard links is never a duplicate group. Set
59
+ `hardlinks=True` to list every name.
60
+ - **Links are not followed.** Symlinks and Windows junctions found while walking
61
+ are skipped unless `follow_symlinks=True`. Paths you pass in are always used.
62
+ - **A file is never its own duplicate.** Overlapping roots, a root given twice, a
63
+ root in another letter case, or a link loop never list one file twice.
64
+ - **Changes are caught.** A file whose size or modification time changes while it
65
+ is read is left out and reported.
66
+ - **Errors are reported, not raised.** Permission errors and vanished files become
67
+ `ScanIssue` entries.
68
+
69
+ ## Cleaning up
70
+
71
+ ```python
72
+ from dupefind import apply_actions, plan_actions, scan
73
+
74
+ plan = plan_actions(scan("photos").groups, action="delete", keep="oldest")
75
+ for result in apply_actions(plan): # dry run: nothing changes
76
+ print(result.status, result.planned.target)
77
+ apply_actions(plan, dry_run=False) # now it happens
78
+ ```
79
+
80
+ `keep` is `first`, `oldest`, `newest` or `shortest`. `action="hardlink"` replaces
81
+ each copy with a hard link to the kept file instead. Right before acting, every
82
+ pair is checked again: both files exist, are not the same file, have the scanned
83
+ size, and are equal byte for byte; then both are stat'ed once more just before the
84
+ act, the target is moved aside and checked again, and the kept file is held open.
85
+ Links, junctions, read-only files and (on Windows) files with extra data streams
86
+ are never removed or replaced. Anything else is skipped and reported.
87
+
88
+ ## Command line
89
+
90
+ ```
91
+ dupefind ~/Pictures # list duplicate groups
92
+ dupefind a b --min-size 1M --json # two trees, JSON report
93
+ dupefind . --exclude .git --exclude "*.tmp"
94
+ dupefind . --delete --keep oldest # show the plan (dry run)
95
+ dupefind . --delete --keep oldest --apply # really delete the other copies
96
+ dupefind . --hardlink --apply # link copies to one file
97
+ ```
98
+
99
+ Nothing changes unless `--apply` is given with `--delete` or `--hardlink`. Those
100
+ two leave empty files alone unless `--empty` is added. A summary and any skipped paths go to stderr. Exit status is 0 on success and 1 on
101
+ an error or a failed action.
102
+
103
+ See [GUIDE.md](GUIDE.md) for the staging, every option, the JSON schema and the
104
+ safety checks in detail.
105
+
106
+ ## Why
107
+
108
+ Finding duplicate files is a common chore, but the Python packages for it are
109
+ mostly stale: `fastdupes` last shipped in 2015, `dupfinder` in 2010, and `dupe` in
110
+ 2021. The few that are maintained are command-line tools, not a typed library you
111
+ can import. `dupefind` is both: a typed API with safe link handling and explicit,
112
+ verified clean-up, and a CLI for the shell.
113
+
114
+ ## License
115
+
116
+ MIT. See [LICENSE](LICENSE).
117
+
118
+ ## Links
119
+
120
+ - Website: https://lianes8studio.com
121
+ - GitHub: https://github.com/JaviLianes8
122
+ - LinkedIn: https://www.linkedin.com/in/jlianes/