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.
- dupefind-0.2.0/.gitattributes +1 -0
- dupefind-0.2.0/.github/FUNDING.yml +1 -0
- dupefind-0.2.0/.gitignore +12 -0
- dupefind-0.2.0/CHANGELOG.md +52 -0
- dupefind-0.2.0/CONTRIBUTING.md +19 -0
- dupefind-0.2.0/GUIDE.md +181 -0
- dupefind-0.2.0/IMPROVEMENTS-round.md +95 -0
- dupefind-0.2.0/LICENSE +21 -0
- dupefind-0.2.0/PKG-INFO +154 -0
- dupefind-0.2.0/README.md +122 -0
- dupefind-0.2.0/pyproject.toml +59 -0
- dupefind-0.2.0/requirements-dev.txt +3 -0
- dupefind-0.2.0/scripts/check.ps1 +7 -0
- dupefind-0.2.0/scripts/release.ps1 +28 -0
- dupefind-0.2.0/src/dupefind/__init__.py +52 -0
- dupefind-0.2.0/src/dupefind/__main__.py +7 -0
- dupefind-0.2.0/src/dupefind/_act.py +108 -0
- dupefind-0.2.0/src/dupefind/_actions.py +99 -0
- dupefind-0.2.0/src/dupefind/_apply.py +75 -0
- dupefind-0.2.0/src/dupefind/_cli.py +133 -0
- dupefind-0.2.0/src/dupefind/_cli_output.py +50 -0
- dupefind-0.2.0/src/dupefind/_find.py +117 -0
- dupefind-0.2.0/src/dupefind/_hash.py +47 -0
- dupefind-0.2.0/src/dupefind/_json.py +72 -0
- dupefind-0.2.0/src/dupefind/_options.py +81 -0
- dupefind-0.2.0/src/dupefind/_scan.py +169 -0
- dupefind-0.2.0/src/dupefind/_stages.py +151 -0
- dupefind-0.2.0/src/dupefind/_streams.py +62 -0
- dupefind-0.2.0/src/dupefind/_types.py +129 -0
- dupefind-0.2.0/src/dupefind/py.typed +0 -0
- dupefind-0.2.0/tests/helpers.py +106 -0
- dupefind-0.2.0/tests/test_actions.py +172 -0
- dupefind-0.2.0/tests/test_cli.py +141 -0
- dupefind-0.2.0/tests/test_contract.py +49 -0
- dupefind-0.2.0/tests/test_e2e_wheel.py +74 -0
- dupefind-0.2.0/tests/test_find.py +108 -0
- dupefind-0.2.0/tests/test_links.py +135 -0
- dupefind-0.2.0/tests/test_options.py +139 -0
- dupefind-0.2.0/tests/test_oracle.py +66 -0
- dupefind-0.2.0/tests/test_promise.py +74 -0
- dupefind-0.2.0/tests/test_readonly.py +109 -0
- dupefind-0.2.0/tests/test_review2_fixes.py +149 -0
- dupefind-0.2.0/tests/test_review_fixes.py +111 -0
- dupefind-0.2.0/tests/test_scan_issues.py +132 -0
- 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,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.
|
dupefind-0.2.0/GUIDE.md
ADDED
|
@@ -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.
|
dupefind-0.2.0/PKG-INFO
ADDED
|
@@ -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/
|
dupefind-0.2.0/README.md
ADDED
|
@@ -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/
|