duplicate-cleaner 0.1.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.
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.3
2
+ Name: duplicate-cleaner
3
+ Version: 0.1.0
4
+ Summary: Cleans specified directories from duplicates based on hash comparison
5
+ Author: Konstantin Kovalenko
6
+ Author-email: Konstantin Kovalenko <konstantin.l.kovalenko@gmail.com>
7
+ License: MIT
8
+ Classifier: Programming Language :: Python :: 3.12
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Environment :: Console
12
+ Requires-Python: >=3.12
13
+ Project-URL: Homepage, https://github.com/despy49/duplicate-cleaner
14
+ Project-URL: Repository, https://github.com/despy49/duplicate-cleaner
15
+ Description-Content-Type: text/markdown
16
+
17
+ # ๐Ÿš€ Duplicate File Cleaner (with 100% Test Coverage)
18
+
19
+ A fast and secure Python command-line utility (CLI) for finding and removing duplicate files. This project is built with strict type annotations, system error handling, and full test coverage.
20
+
21
+ ## ๐ŸŒŸ Features
22
+
23
+ * **100% Code Coverage:** Every part of the application, including empty directory cleanup, interactive choices, and edge cases, is tested.
24
+ * **Isolated Testing:** Robust handling of system exceptions such as symlink loops (`ELOOP`) and permission errors (`EACCES`) via mocking.
25
+ * **Scan workflow:** Identifies duplicate files efficiently by grouping them by size first, then calculating SHA-256 chunked hashes for verification.
26
+
27
+ ---
28
+
29
+ ## ๐Ÿ›  Stack & Tools
30
+
31
+ * **Language:** Python 3.12+ (with strict `typing` annotations)
32
+ * **Environment & Package Manager:** `uv`
33
+ * **Testing Framework:** `pytest`
34
+ * **Coverage Tool:** `pytest-cov`
35
+ * **Reporting:** `allure-pytest`
36
+
37
+ ---
38
+
39
+ ## ๐Ÿ“ฆ Code Coverage Metrics
40
+
41
+ Run the coverage tool directly using the customized default test runner. The project has zero coverage gaps:
42
+
43
+ | File | Stmts | Miss | Branch | BrPart | Cover |
44
+ | :--- | :---: | :---: | :---: | :---: | :---: |
45
+ | `src/duplicate_cleaner/__init__.py` | 8 | 0 | 0 | 0 | **100.0%** |
46
+ | `src/duplicate_cleaner/duplicate_cleaner.py` | 151 | 0 | 60 | 0 | **100.0%** |
47
+ | **TOTAL** | **159** | **0** | **60** | **0** | **100.0%** |
48
+
49
+ ### Verified Edge Cases:
50
+ 1. **Circular Symlinks (`ELOOP`):** Validated protection loops against recursive symlinks during depth detection and link flattening.
51
+ 2. **Relative Symlink Targets:** Correct resolution of target paths when symlinks point to relative locations (`../`).
52
+ 3. **Permission Errors (`EACCES`):** Ensures locked or system files are safely skipped and logged without crashing the tool.
53
+ 4. **Interactive Prompts and Flags:** Complete parameter mapping for CLI combinations like `--no-confirm`, `--remove-empty`, and user inputs (`y/N`).
54
+
55
+ ---
56
+
57
+ ## ๐Ÿš€ Quick Start
58
+
59
+ ### 1. Installation and Setup
60
+ Project dependencies are managed via `uv`. Clone the repository and run the setup command in the project root:
61
+ ```bash
62
+ uv sync
63
+ ```
64
+ *This command creates an isolated virtual environment and automatically installs all dependencies.*
65
+
66
+ ### 2. Running the Utility
67
+ Execute the application entry point using `uv run`:
68
+ ```bash
69
+ # Basic run (scans user home directory by default)
70
+ uv run duplicate-cleaner
71
+
72
+ # Scan a specific directory recursively with auto-confirmation and empty directory cleanup
73
+ uv run duplicate-cleaner --target . --recurse --no-confirm --remove-empty
74
+ ```
75
+
76
+ ---
77
+
78
+ ## ๐Ÿงช Testing & Reporting
79
+
80
+ Thanks to pre-configured `addopts` project settings, short logs, test execution coverage metrics collection, and Allure report indexing run automatically.
81
+
82
+ ### Run Tests and Collect Coverage
83
+ ```bash
84
+ uv run pytest
85
+ ```
86
+ *This instantly executes tests, renders an HTML report in `htmlcov/`, and updates text metrics.*
87
+
88
+ ### Generate Allure Reports
89
+ Build and view the dashboard in your default browser (requires a local installation of `allure-cli`):
90
+ ```bash
91
+ allure serve allure-results
92
+ ```
93
+
94
+ ---
95
+
96
+ ## โš™๏ธ Code Quality & Linters
97
+
98
+ The codebase complies with modern standard formatting guidelines. Code styles are validated with **Ruff**:
99
+ ```bash
100
+ # Check code style, formatting, and imports
101
+ uv run ruff check
102
+
103
+ # Automatically format the code to PEP 8 standards
104
+ uv run ruff format
105
+ ```
106
+
107
+ Run static type checking using **mypy**:
108
+ ```bash
109
+ uv run mypy src/ tests/
110
+ ```
@@ -0,0 +1,94 @@
1
+ # ๐Ÿš€ Duplicate File Cleaner (with 100% Test Coverage)
2
+
3
+ A fast and secure Python command-line utility (CLI) for finding and removing duplicate files. This project is built with strict type annotations, system error handling, and full test coverage.
4
+
5
+ ## ๐ŸŒŸ Features
6
+
7
+ * **100% Code Coverage:** Every part of the application, including empty directory cleanup, interactive choices, and edge cases, is tested.
8
+ * **Isolated Testing:** Robust handling of system exceptions such as symlink loops (`ELOOP`) and permission errors (`EACCES`) via mocking.
9
+ * **Scan workflow:** Identifies duplicate files efficiently by grouping them by size first, then calculating SHA-256 chunked hashes for verification.
10
+
11
+ ---
12
+
13
+ ## ๐Ÿ›  Stack & Tools
14
+
15
+ * **Language:** Python 3.12+ (with strict `typing` annotations)
16
+ * **Environment & Package Manager:** `uv`
17
+ * **Testing Framework:** `pytest`
18
+ * **Coverage Tool:** `pytest-cov`
19
+ * **Reporting:** `allure-pytest`
20
+
21
+ ---
22
+
23
+ ## ๐Ÿ“ฆ Code Coverage Metrics
24
+
25
+ Run the coverage tool directly using the customized default test runner. The project has zero coverage gaps:
26
+
27
+ | File | Stmts | Miss | Branch | BrPart | Cover |
28
+ | :--- | :---: | :---: | :---: | :---: | :---: |
29
+ | `src/duplicate_cleaner/__init__.py` | 8 | 0 | 0 | 0 | **100.0%** |
30
+ | `src/duplicate_cleaner/duplicate_cleaner.py` | 151 | 0 | 60 | 0 | **100.0%** |
31
+ | **TOTAL** | **159** | **0** | **60** | **0** | **100.0%** |
32
+
33
+ ### Verified Edge Cases:
34
+ 1. **Circular Symlinks (`ELOOP`):** Validated protection loops against recursive symlinks during depth detection and link flattening.
35
+ 2. **Relative Symlink Targets:** Correct resolution of target paths when symlinks point to relative locations (`../`).
36
+ 3. **Permission Errors (`EACCES`):** Ensures locked or system files are safely skipped and logged without crashing the tool.
37
+ 4. **Interactive Prompts and Flags:** Complete parameter mapping for CLI combinations like `--no-confirm`, `--remove-empty`, and user inputs (`y/N`).
38
+
39
+ ---
40
+
41
+ ## ๐Ÿš€ Quick Start
42
+
43
+ ### 1. Installation and Setup
44
+ Project dependencies are managed via `uv`. Clone the repository and run the setup command in the project root:
45
+ ```bash
46
+ uv sync
47
+ ```
48
+ *This command creates an isolated virtual environment and automatically installs all dependencies.*
49
+
50
+ ### 2. Running the Utility
51
+ Execute the application entry point using `uv run`:
52
+ ```bash
53
+ # Basic run (scans user home directory by default)
54
+ uv run duplicate-cleaner
55
+
56
+ # Scan a specific directory recursively with auto-confirmation and empty directory cleanup
57
+ uv run duplicate-cleaner --target . --recurse --no-confirm --remove-empty
58
+ ```
59
+
60
+ ---
61
+
62
+ ## ๐Ÿงช Testing & Reporting
63
+
64
+ Thanks to pre-configured `addopts` project settings, short logs, test execution coverage metrics collection, and Allure report indexing run automatically.
65
+
66
+ ### Run Tests and Collect Coverage
67
+ ```bash
68
+ uv run pytest
69
+ ```
70
+ *This instantly executes tests, renders an HTML report in `htmlcov/`, and updates text metrics.*
71
+
72
+ ### Generate Allure Reports
73
+ Build and view the dashboard in your default browser (requires a local installation of `allure-cli`):
74
+ ```bash
75
+ allure serve allure-results
76
+ ```
77
+
78
+ ---
79
+
80
+ ## โš™๏ธ Code Quality & Linters
81
+
82
+ The codebase complies with modern standard formatting guidelines. Code styles are validated with **Ruff**:
83
+ ```bash
84
+ # Check code style, formatting, and imports
85
+ uv run ruff check
86
+
87
+ # Automatically format the code to PEP 8 standards
88
+ uv run ruff format
89
+ ```
90
+
91
+ Run static type checking using **mypy**:
92
+ ```bash
93
+ uv run mypy src/ tests/
94
+ ```
@@ -0,0 +1,75 @@
1
+ [project]
2
+ name = "duplicate-cleaner"
3
+ version = "0.1.0"
4
+ description = "Cleans specified directories from duplicates based on hash comparison"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ classifiers = [
8
+ "Programming Language :: Python :: 3.12",
9
+ "License :: OSI Approved :: MIT License",
10
+ "Operating System :: OS Independent",
11
+ "Environment :: Console",
12
+ ]
13
+
14
+ [project.license]
15
+ text = "MIT"
16
+
17
+ [[project.authors]]
18
+ name = "Konstantin Kovalenko"
19
+ email = "konstantin.l.kovalenko@gmail.com"
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/despy49/duplicate-cleaner"
23
+ Repository = "https://github.com/despy49/duplicate-cleaner"
24
+
25
+ [project.scripts]
26
+ duplicate-cleaner = "duplicate_cleaner.duplicate_cleaner:main"
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.12.2,<0.13.0"]
30
+ build-backend = "uv_build"
31
+
32
+ [dependency-groups]
33
+ dev = [
34
+ "allure-pytest>=2.16.2",
35
+ "mypy>=2.4.1",
36
+ "pytest>=9.2.0",
37
+ "pytest-cov>=7.1.0",
38
+ ]
39
+
40
+ [tool.pytest.ini_options]
41
+ minversion = "8.0"
42
+ testpaths = ["tests"]
43
+ disable_test_id_escaping_and_forfeit_all_rights_to_community_support = true
44
+ markers = [
45
+ "unit: Isolated tests using mocks or parsing logic without disk I/O",
46
+ "functional: Core logic tests interacting with temporary file trees",
47
+ "integration: Component integration tests invoking the main execution flow",
48
+ ]
49
+ addopts = "-v --tb=short --cov --cov-report=term --cov-report=html --alluredir=allure-results"
50
+ norecursedirs = [
51
+ ".git",
52
+ ".venv",
53
+ "build",
54
+ "dist",
55
+ ]
56
+ python_files = "test_*.py"
57
+ python_functions = "test_*"
58
+
59
+ [tool.coverage.run]
60
+ source = ["src"]
61
+ branch = true
62
+ parallel = true
63
+
64
+ [tool.coverage.report]
65
+ show_missing = true
66
+ skip_empty = true
67
+ sort = "Cover"
68
+ exclude_also = [
69
+ "pragma: no cover",
70
+ "if __name__ == .__main__.:",
71
+ "raise NotImplementedError",
72
+ ]
73
+
74
+ [tool.coverage.html]
75
+ directory = "htmlcov"
@@ -0,0 +1,75 @@
1
+ [project]
2
+ name = "duplicate-cleaner"
3
+ version = "0.1.0"
4
+ description = "Cleans specified directories from duplicates based on hash comparison"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "MIT" }
8
+ authors = [
9
+ { name = "Konstantin Kovalenko", email = "konstantin.l.kovalenko@gmail.com" }
10
+ ]
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3.12",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Operating System :: OS Independent",
15
+ "Environment :: Console",
16
+ ]
17
+ [project.urls]
18
+ Homepage = "https://github.com/despy49/duplicate-cleaner"
19
+ Repository = "https://github.com/despy49/duplicate-cleaner"
20
+
21
+
22
+ [project.scripts]
23
+ duplicate-cleaner = "duplicate_cleaner.duplicate_cleaner:main"
24
+
25
+ [build-system]
26
+ requires = ["uv_build>=0.12.2,<0.13.0"]
27
+ build-backend = "uv_build"
28
+
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ "allure-pytest>=2.16.2",
33
+ "mypy>=2.4.1",
34
+ "pytest>=9.2.0",
35
+ "pytest-cov>=7.1.0",
36
+ ]
37
+
38
+
39
+ [tool.pytest.ini_options]
40
+ minversion = "8.0"
41
+ testpaths = ["tests"]
42
+ disable_test_id_escaping_and_forfeit_all_rights_to_community_support = true
43
+ markers = [
44
+ "unit: Isolated tests using mocks or parsing logic without disk I/O",
45
+ "functional: Core logic tests interacting with temporary file trees",
46
+ "integration: Component integration tests invoking the main execution flow"
47
+ ]
48
+
49
+ addopts = "-v --tb=short --cov --cov-report=term --cov-report=html --alluredir=allure-results"
50
+ norecursedirs = [
51
+ ".git",
52
+ ".venv",
53
+ "build",
54
+ "dist"
55
+ ]
56
+ python_files = "test_*.py"
57
+ python_functions = "test_*"
58
+
59
+ [tool.coverage.run]
60
+ source = ["src"]
61
+ branch = true
62
+ parallel = true
63
+
64
+ [tool.coverage.report]
65
+ show_missing = true
66
+ skip_empty = true
67
+ sort = "Cover"
68
+ exclude_also = [
69
+ "pragma: no cover",
70
+ "if __name__ == .__main__.:",
71
+ "raise NotImplementedError"
72
+ ]
73
+
74
+ [tool.coverage.html]
75
+ directory = "htmlcov"
@@ -0,0 +1,8 @@
1
+ from .duplicate_cleaner import calculate_file_hash # noqa: F401, I001
2
+ from .duplicate_cleaner import find_duplicate_files # noqa: F401
3
+ from .duplicate_cleaner import flatten_symlink_chain # noqa: F401
4
+ from .duplicate_cleaner import parse_args # noqa: F401
5
+ from .duplicate_cleaner import remove_duplicate_files # noqa: F401
6
+ from .duplicate_cleaner import remove_empty_directories # noqa: F401
7
+ from .duplicate_cleaner import symlink_depth # noqa: F401
8
+ from .duplicate_cleaner import B_RED, GREEN, RST, color_print # noqa: F401
@@ -0,0 +1,231 @@
1
+ import argparse
2
+ import errno
3
+ import hashlib
4
+ import os
5
+ import sys
6
+ from collections import defaultdict
7
+ from pathlib import Path
8
+
9
+ B_RED = "\033[1;31m"
10
+ GREEN = "\033[0;32m"
11
+ RST = "\033[0m"
12
+
13
+
14
+ def color_print(color: str, msg: str) -> None:
15
+ if color not in [B_RED, GREEN]:
16
+ raise RuntimeError(f"ERROR: color '{color!r}' undefined")
17
+ print(f"{color}{msg}{RST}")
18
+
19
+
20
+ def calculate_file_hash(file_path: Path) -> str | None:
21
+ hasher = hashlib.sha256()
22
+ try:
23
+ with open(file_path, "rb") as file:
24
+ for chunk in iter(lambda: file.read(65536), b""):
25
+ hasher.update(chunk)
26
+ return hasher.hexdigest()
27
+ except (PermissionError, FileNotFoundError, OSError) as e:
28
+ color_print(B_RED, f"Failed reading '{file_path}': {e}")
29
+ return None
30
+
31
+
32
+ def find_duplicate_files(root_dir: Path, recurse: bool) -> dict[str, list]:
33
+ size_groups = defaultdict(list)
34
+ duplicates = defaultdict(list)
35
+
36
+ color_print(GREEN, "Scanning directories structures for file sizes...")
37
+
38
+ search_pattern = "**/*" if recurse else "*"
39
+ for fpath in root_dir.glob(search_pattern):
40
+ try:
41
+ if fpath.is_dir() and not fpath.is_symlink():
42
+ continue
43
+
44
+ fsize = fpath.stat().st_size
45
+ size_groups[fsize].append(fpath)
46
+ except OSError as e:
47
+ if os.path.islink(fpath):
48
+ if e.errno == errno.ENOENT:
49
+ color_print(B_RED, f"[Broken Symlink] '{fpath}' is broken.")
50
+ elif e.errno == errno.ELOOP:
51
+ color_print(B_RED, f"[Symlink Loop] '{fpath}' forms a loop")
52
+ else:
53
+ color_print(
54
+ B_RED, f"[OSError Symlink] Failed to access link '{fpath}': {e}"
55
+ )
56
+ else:
57
+ color_print(
58
+ B_RED, f"[OSError File] Failed to get size for '{fpath}': {e}"
59
+ )
60
+ continue
61
+
62
+ color_print(GREEN, "Calculating file hashes...")
63
+ for paths in size_groups.values():
64
+ if len(paths) < 2:
65
+ continue
66
+
67
+ for file_path in paths:
68
+ file_hash = calculate_file_hash(file_path)
69
+ if file_hash:
70
+ duplicates[file_hash].append(file_path)
71
+ result = {h: paths for h, paths in duplicates.items() if len(paths) > 1}
72
+ for file_paths in result.values():
73
+ file_paths.sort(key=lambda p: len(str(p)))
74
+ return result
75
+
76
+
77
+ def symlink_depth(path: Path) -> int:
78
+ path = path.absolute()
79
+ depth = 0
80
+ seen = set()
81
+
82
+ while path.is_symlink():
83
+ if path in seen:
84
+ raise RuntimeError("Symlink loop detected")
85
+ seen.add(path)
86
+
87
+ target = Path(path.readlink())
88
+ if not target.is_absolute():
89
+ target = path.parent / target
90
+
91
+ path = target.absolute()
92
+ depth += 1
93
+ return depth
94
+
95
+
96
+ def flatten_symlink_chain(first: Path) -> None:
97
+ first = first.absolute()
98
+ current = first
99
+ links = []
100
+ seen = set()
101
+
102
+ while current.is_symlink():
103
+ current = current.absolute()
104
+ if current in seen:
105
+ raise RuntimeError("Symlink loop detected")
106
+ seen.add(current)
107
+ links.append(current)
108
+
109
+ target = Path(current.readlink())
110
+ current = target if target.is_absolute() else current.parent / target
111
+
112
+ final_target = current.resolve(strict=True) # existence guaranteed
113
+
114
+ for link in links[1:]:
115
+ link.unlink()
116
+
117
+ first.unlink()
118
+ first.symlink_to(final_target)
119
+
120
+
121
+ def remove_empty_directories(root_folder: Path):
122
+ color_print(GREEN, "Cleaning up empty directories...")
123
+
124
+ all_dirs = [p for p in root_folder.rglob("*") if p.is_dir() and not p.is_symlink()]
125
+ all_dirs.sort(key=lambda p: len(p.parts), reverse=True)
126
+
127
+ for dir_path in all_dirs:
128
+ if not any(dir_path.iterdir()):
129
+ try:
130
+ dir_path.rmdir()
131
+ color_print(GREEN, "[Removed Empty Dir] -> {dir_path}")
132
+ except OSError as e:
133
+ color_print(B_RED, f"ERROR: failed removing a dir:{e}")
134
+
135
+
136
+ def remove_duplicate_files(duplicates: dict) -> None:
137
+ for file_paths in duplicates.values():
138
+ color_print(GREEN, f"Duplicates found (keeping: {file_paths[0]}):")
139
+ for to_delete in file_paths[1:]:
140
+ color_print(GREEN, f"[Deleting] -> {to_delete}")
141
+ try:
142
+ if to_delete.is_symlink():
143
+ flatten_symlink_chain(to_delete)
144
+ to_delete.unlink(missing_ok=True)
145
+ else:
146
+ to_delete.unlink(missing_ok=True)
147
+ except OSError as e:
148
+ color_print(B_RED, f"[Error] Failed deleting {to_delete}: {e}")
149
+
150
+
151
+ def parse_args():
152
+ parser = argparse.ArgumentParser("Duplicate cleaner script")
153
+ parser.add_argument(
154
+ "-t",
155
+ "--target",
156
+ dest="root_dir",
157
+ type=Path,
158
+ default=Path.home(),
159
+ help="Path to target directory",
160
+ )
161
+
162
+ parser.add_argument(
163
+ "-r",
164
+ "--recurse",
165
+ dest="recursive",
166
+ action="store_true",
167
+ help="Scan subdirectories recursively",
168
+ )
169
+
170
+ parser.add_argument(
171
+ "-nc",
172
+ "--no-confirm",
173
+ dest="no_confirm",
174
+ action="store_true",
175
+ help="Delete duplicates without confirmation prompt",
176
+ )
177
+
178
+ parser.add_argument(
179
+ "-re",
180
+ "--remove-empty",
181
+ dest="remove_empty_dirs",
182
+ action="store_true",
183
+ help="Remove empty directories after cleanup",
184
+ )
185
+
186
+ def check_root_dir(rd: Path) -> None:
187
+ if not rd.exists():
188
+ color_print(B_RED, f"Error: path '{rd}' not found")
189
+ parser.print_help()
190
+ sys.exit(-1)
191
+
192
+ if not rd.is_dir():
193
+ color_print(B_RED, f"Error: '{rd.__str__()}' is not a directory. Aborted")
194
+ parser.print_help()
195
+ sys.exit(-1)
196
+
197
+ try:
198
+ args = parser.parse_args()
199
+ args.root_dir = args.root_dir.resolve()
200
+ check_root_dir(args.root_dir)
201
+ return args
202
+ except argparse.ArgumentError as e:
203
+ color_print(B_RED, f"Error parsing args: {e}")
204
+ parser.print_help()
205
+ sys.exit(-1)
206
+
207
+
208
+ def main() -> None:
209
+ args = parse_args()
210
+
211
+ duplicates: dict = find_duplicate_files(args.root_dir, args.recursive)
212
+
213
+ if not duplicates:
214
+ color_print(GREEN, "Duplicates not found")
215
+ else:
216
+ if args.no_confirm:
217
+ remove_duplicate_files(duplicates)
218
+ if args.remove_empty_dirs:
219
+ remove_empty_directories(args.root_dir)
220
+ else:
221
+ duplicate_count = len(duplicates)
222
+ decision = input(
223
+ f"{GREEN}\nFound {duplicate_count} duplicate groups. "
224
+ f"Remove them?(y/N): "
225
+ )
226
+ if decision.lower() == "y":
227
+ remove_duplicate_files(duplicates)
228
+ if args.remove_empty_dirs:
229
+ remove_empty_directories(args.root_dir)
230
+ else:
231
+ color_print(B_RED, "File deletion aborted")