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.
- duplicate_cleaner-0.1.0/PKG-INFO +110 -0
- duplicate_cleaner-0.1.0/README.md +94 -0
- duplicate_cleaner-0.1.0/pyproject.toml +75 -0
- duplicate_cleaner-0.1.0/pyproject.toml.orig +75 -0
- duplicate_cleaner-0.1.0/src/duplicate_cleaner/__init__.py +8 -0
- duplicate_cleaner-0.1.0/src/duplicate_cleaner/duplicate_cleaner.py +231 -0
|
@@ -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")
|