filedust 0.2.0__tar.gz → 0.3.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.
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: filedust
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Opinionated junk cleaner for dev machines (caches, build artifacts, editor backups).
5
- Home-page: https://github.com/mdaleo404/filedust
5
+ Home-page: https://github.com/guardutils/filedust
6
6
  License: GPL-3.0-or-later
7
7
  Author: Marco D'Aleo
8
8
  Author-email: marco@marcodaleo.com
@@ -15,14 +15,13 @@ Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
16
  Requires-Dist: argcomplete (>=3.3.0,<4.0.0)
17
17
  Requires-Dist: rich (>=13.0.0,<14.0.0)
18
- Project-URL: Repository, https://github.com/mdaleo404/filedust
18
+ Project-URL: Repository, https://github.com/guardutils/filedust
19
19
  Description-Content-Type: text/markdown
20
20
 
21
- [![License](https://img.shields.io/github/license/mdaleo404/filedust?style=flat)](LICENCE)
22
- [![Language](https://img.shields.io/github/languages/top/mdaleo404/filedust.svg)](https://github.com/mdaleo404/filedust/)
23
- ![GitHub Release](https://img.shields.io/github/v/release/mdaleo404/filedust?display_name=release&logo=github)
21
+ [![License](https://img.shields.io/github/license/guardutils/filedust?style=flat)](LICENCE)
22
+ [![Language](https://img.shields.io/github/languages/top/guardutils/filedust.svg)](https://github.com/guardutils/filedust/)
23
+ ![GitHub Release](https://img.shields.io/github/v/release/guardutils/filedust?display_name=release&logo=github)
24
24
  ![PyPI - Version](https://img.shields.io/pypi/v/filedust?logo=pypi)
25
- [![Build Status](https://img.shields.io/github/actions/workflow/status/mdaleo404/filedust/.github/workflows/lint-and-security.yml)](https://github.com/mdaleo404/filedust/actions)
26
25
  [![PyPI downloads](https://img.shields.io/pypi/dm/filedust.svg)](https://pypi.org/project/filedust/)
27
26
 
28
27
  # filedust
@@ -55,7 +54,11 @@ One interactive prompt at the end of the run (unless -y is used).
55
54
  Shows how much disk space can be freed.
56
55
 
57
56
  ### Safe by design
58
- Never touches dotfiles, configs, project files, or anything important.
57
+ * It ONLY runs within user's `$HOME`
58
+
59
+ * Put user in control by reading `~/.filedust.conf`
60
+
61
+ * Never touches dotfiles, configs, project files, or anything important unless you want.
59
62
 
60
63
  ## Installation
61
64
 
@@ -66,11 +69,17 @@ pip install filedust
66
69
 
67
70
  ### From this repository
68
71
  ```
69
- git clone https://github.com/mdaleo404/filedust.git
72
+ git clone https://github.com/guardutils/filedust.git
70
73
  cd filedust/
71
74
  poetry install
72
75
  ```
73
76
 
77
+ ### Custom config
78
+ You can download the example and add your custom rule
79
+ ```
80
+ wget -O ~/.filedust.conf https://raw.githubusercontent.com/guardutils/filedust/main/.filedust.conf.example
81
+ ```
82
+
74
83
  ### TAB completion
75
84
  Add this to your `.bashrc`
76
85
  ```
@@ -1,8 +1,7 @@
1
- [![License](https://img.shields.io/github/license/mdaleo404/filedust?style=flat)](LICENCE)
2
- [![Language](https://img.shields.io/github/languages/top/mdaleo404/filedust.svg)](https://github.com/mdaleo404/filedust/)
3
- ![GitHub Release](https://img.shields.io/github/v/release/mdaleo404/filedust?display_name=release&logo=github)
1
+ [![License](https://img.shields.io/github/license/guardutils/filedust?style=flat)](LICENCE)
2
+ [![Language](https://img.shields.io/github/languages/top/guardutils/filedust.svg)](https://github.com/guardutils/filedust/)
3
+ ![GitHub Release](https://img.shields.io/github/v/release/guardutils/filedust?display_name=release&logo=github)
4
4
  ![PyPI - Version](https://img.shields.io/pypi/v/filedust?logo=pypi)
5
- [![Build Status](https://img.shields.io/github/actions/workflow/status/mdaleo404/filedust/.github/workflows/lint-and-security.yml)](https://github.com/mdaleo404/filedust/actions)
6
5
  [![PyPI downloads](https://img.shields.io/pypi/dm/filedust.svg)](https://pypi.org/project/filedust/)
7
6
 
8
7
  # filedust
@@ -35,7 +34,11 @@ One interactive prompt at the end of the run (unless -y is used).
35
34
  Shows how much disk space can be freed.
36
35
 
37
36
  ### Safe by design
38
- Never touches dotfiles, configs, project files, or anything important.
37
+ * It ONLY runs within user's `$HOME`
38
+
39
+ * Put user in control by reading `~/.filedust.conf`
40
+
41
+ * Never touches dotfiles, configs, project files, or anything important unless you want.
39
42
 
40
43
  ## Installation
41
44
 
@@ -46,11 +49,17 @@ pip install filedust
46
49
 
47
50
  ### From this repository
48
51
  ```
49
- git clone https://github.com/mdaleo404/filedust.git
52
+ git clone https://github.com/guardutils/filedust.git
50
53
  cd filedust/
51
54
  poetry install
52
55
  ```
53
56
 
57
+ ### Custom config
58
+ You can download the example and add your custom rule
59
+ ```
60
+ wget -O ~/.filedust.conf https://raw.githubusercontent.com/guardutils/filedust/main/.filedust.conf.example
61
+ ```
62
+
54
63
  ### TAB completion
55
64
  Add this to your `.bashrc`
56
65
  ```
@@ -1,12 +1,12 @@
1
1
  [tool.poetry]
2
2
  name = "filedust"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Opinionated junk cleaner for dev machines (caches, build artifacts, editor backups)."
5
5
  authors = ["Marco D'Aleo <marco@marcodaleo.com>"]
6
6
  license = "GPL-3.0-or-later"
7
7
  readme = "README.md"
8
- homepage = "https://github.com/mdaleo404/filedust"
9
- repository = "https://github.com/mdaleo404/filedust"
8
+ homepage = "https://github.com/guardutils/filedust"
9
+ repository = "https://github.com/guardutils/filedust"
10
10
  packages = [{ include = "filedust", from = "src" }]
11
11
 
12
12
  [tool.poetry.dependencies]
@@ -12,7 +12,7 @@ from rich.table import Table
12
12
  from rich.prompt import Confirm
13
13
  from rich import box
14
14
 
15
- from .junk import Finding, iter_junk
15
+ from .junk import Finding, iter_junk, load_user_rules
16
16
 
17
17
 
18
18
  console = Console()
@@ -168,10 +168,24 @@ def delete_all(findings: List[Finding]) -> int:
168
168
 
169
169
 
170
170
  def main(argv: list[str] | None = None) -> int:
171
+ print("Looking for junk ...")
171
172
  parser = build_parser()
172
173
  args = parser.parse_args(argv)
173
174
 
174
175
  root = Path(args.path).expanduser()
176
+ home = Path.home().resolve()
177
+ root_resolved = root.resolve()
178
+
179
+ # Ensure root is inside the user's home directory
180
+ try:
181
+ root_resolved.relative_to(home)
182
+ except ValueError:
183
+ console.print(
184
+ f"[red]Error:[/] Refusing to operate outside the user's home directory.\n"
185
+ f"Requested: {root_resolved}\n"
186
+ f"Allowed: {home}"
187
+ )
188
+ return 1
175
189
 
176
190
  if not root.exists():
177
191
  console.print(f"[red]Error:[/] Path not found: {root}")
@@ -183,7 +197,8 @@ def main(argv: list[str] | None = None) -> int:
183
197
  "This may take a while and may require sudo for deletions.[/]"
184
198
  )
185
199
 
186
- findings = list(iter_junk(root))
200
+ rules = load_user_rules()
201
+ findings = list(iter_junk(root, rules=rules))
187
202
  total_size = compute_total_size(findings)
188
203
 
189
204
  if not findings:
@@ -0,0 +1,267 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import configparser
5
+ from dataclasses import dataclass
6
+ from fnmatch import fnmatch
7
+ from pathlib import Path
8
+ from typing import Iterable, List
9
+
10
+
11
+ class UserRules:
12
+ def __init__(self):
13
+ self.include: list[str] = []
14
+ self.exclude: list[str] = []
15
+
16
+
17
+ def load_user_rules() -> UserRules:
18
+ rules = UserRules()
19
+ cfg_path = Path.home() / ".filedust.conf"
20
+
21
+ if cfg_path.exists():
22
+ parser = configparser.ConfigParser(allow_no_value=True)
23
+ parser.read(cfg_path)
24
+
25
+ if parser.has_section("include"):
26
+ rules.include = list(parser["include"].keys())
27
+
28
+ if parser.has_section("exclude"):
29
+ rules.exclude = list(parser["exclude"].keys())
30
+
31
+ return rules
32
+
33
+
34
+ def matches_any(patterns: list[str], relpath: Path) -> bool:
35
+ posix = relpath.as_posix()
36
+ return any(fnmatch(posix, p) for p in patterns)
37
+
38
+
39
+ @dataclass
40
+ class Finding:
41
+ path: Path
42
+ kind: str # "file" or "dir"
43
+ reason: str
44
+
45
+
46
+ # Directories that are *typically* safe to delete completely.
47
+ JUNK_DIR_NAMES = {
48
+ "__pycache__",
49
+ ".pytest_cache",
50
+ ".mypy_cache",
51
+ ".ruff_cache",
52
+ ".nox",
53
+ ".tox",
54
+ ".hypothesis",
55
+ ".gradle",
56
+ ".parcel-cache",
57
+ ".turbo",
58
+ ".next",
59
+ ".vite",
60
+ ".sass-cache",
61
+ ".sass-cache",
62
+ "dist",
63
+ }
64
+
65
+ # File name patterns that are almost always junk / temporary.
66
+ JUNK_FILE_PATTERNS = [
67
+ "*~",
68
+ "*.swp",
69
+ "*.swo",
70
+ "*.swpx",
71
+ "*.tmp",
72
+ "*.temp",
73
+ "*.bak",
74
+ "*.orig",
75
+ "*.rej",
76
+ ".DS_Store",
77
+ "Thumbs.db",
78
+ "desktop.ini",
79
+ ]
80
+
81
+ # VCS / system dirs
82
+ SKIP_DIR_NAMES = {
83
+ ".cache",
84
+ "build",
85
+ ".gnupg",
86
+ ".git",
87
+ ".hg",
88
+ ".svn",
89
+ ".bzr",
90
+ ".idea",
91
+ ".vscode",
92
+ }
93
+
94
+
95
+ HOME = Path.home().resolve()
96
+
97
+
98
+ def safe_exists(path: Path) -> bool | None:
99
+ """Return True/False if the path exists, or None if permission denied."""
100
+ try:
101
+ return path.exists()
102
+ except Exception:
103
+ return None
104
+
105
+
106
+ def safe_resolve(path: Path, root: Path) -> Path | None:
107
+ """
108
+ Resolve symlinks only if safe.
109
+ Return resolved path if it stays within root.
110
+ Return None if:
111
+ - resolution escapes the root
112
+ - resolution fails
113
+ - permission denied
114
+ """
115
+ try:
116
+ resolved = path.resolve(strict=False) # NEVER strict
117
+ resolved.relative_to(root) # ensure containment
118
+ return resolved
119
+ except Exception:
120
+ return None
121
+
122
+
123
+ def is_junk_dir_name(name: str) -> bool:
124
+ return name in JUNK_DIR_NAMES
125
+
126
+
127
+ def is_junk_file_name(name: str) -> bool:
128
+ return any(fnmatch(name, pattern) for pattern in JUNK_FILE_PATTERNS)
129
+
130
+
131
+ def iter_junk(root: Path, rules: UserRules | None = None) -> Iterable[Finding]:
132
+ """
133
+ Safe, fast junk scanner:
134
+ - Never follows symlinks.
135
+ - Broken symlinks are not automatically junk — they follow normal rules.
136
+ - User include/exclude overrides all.
137
+ - Built-in junk rules applied only when safe.
138
+ - SKIP_DIR_NAMES protected unless user includes.
139
+ - Fully contained in $HOME.
140
+ - No crashes from PermissionError or unreadable paths.
141
+ """
142
+ if rules is None:
143
+ rules = UserRules()
144
+
145
+ root = root.resolve()
146
+ root_str = str(root)
147
+
148
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
149
+ dirpath_p = Path(dirpath)
150
+
151
+ # Fast relative path computation
152
+ if dirpath == root_str:
153
+ rel_dir = Path(".")
154
+ else:
155
+ rel_dir = Path(dirpath[len(root_str) :].lstrip("/"))
156
+
157
+ # USER EXCLUDE → skip entire subtree
158
+ if matches_any(rules.exclude, rel_dir):
159
+ dirnames[:] = []
160
+ continue
161
+
162
+ pruned = []
163
+
164
+ # Handling dirs
165
+ for d in dirnames:
166
+ child = dirpath_p / d
167
+
168
+ try:
169
+ st = child.lstat()
170
+ except Exception:
171
+ continue # unreadable
172
+
173
+ is_symlink = (st.st_mode & 0o170000) == 0o120000
174
+
175
+ if is_symlink:
176
+ # If broken symlink dir treat as file later via filenames (skip descent)
177
+ continue
178
+
179
+ rel_child = rel_dir / d
180
+
181
+ # User exclude wins
182
+ if matches_any(rules.exclude, rel_child):
183
+ continue
184
+
185
+ # SKIP_DIR_NAMES unless user includes
186
+ if d in SKIP_DIR_NAMES and not matches_any(
187
+ rules.include, rel_child
188
+ ):
189
+ continue
190
+
191
+ pruned.append(d)
192
+
193
+ dirnames[:] = pruned
194
+
195
+ # Detect JUNK dirs
196
+ i = 0
197
+ while i < len(dirnames):
198
+ name = dirnames[i]
199
+ rel_child = rel_dir / name
200
+
201
+ # User include directory
202
+ if matches_any(rules.include, rel_child):
203
+ yield Finding(dirpath_p / name, "dir", "user_include")
204
+ del dirnames[i]
205
+ continue
206
+
207
+ # Built-in safe junk dirs
208
+ if is_junk_dir_name(name):
209
+ yield Finding(dirpath_p / name, "dir", "junk_dir")
210
+ del dirnames[i]
211
+ continue
212
+
213
+ i += 1
214
+
215
+ # Handling files (including symlinks)
216
+ for fname in filenames:
217
+ fpath = dirpath_p / fname
218
+ rel_file = rel_dir / fname
219
+
220
+ try:
221
+ st = fpath.lstat()
222
+ except Exception:
223
+ continue
224
+
225
+ is_symlink = (st.st_mode & 0o170000) == 0o120000
226
+
227
+ # Handling broken symlinks
228
+ if is_symlink:
229
+ exists = safe_exists(fpath)
230
+
231
+ # Permission denied → skip
232
+ if exists is None:
233
+ continue
234
+
235
+ # User exclude wins
236
+ if matches_any(rules.exclude, rel_file):
237
+ continue
238
+
239
+ # User include wins
240
+ if matches_any(rules.include, rel_file):
241
+ yield Finding(fpath, "file", "user_include")
242
+ continue
243
+
244
+ # Broken symlink?
245
+ if exists is False:
246
+ # DO NOT auto-delete — classify like regular file
247
+ # Only built-in junk patterns apply
248
+ if is_junk_file_name(fname):
249
+ yield Finding(fpath, "file", "broken_symlink")
250
+ continue
251
+
252
+ # Valid symlink — NEVER follow; only user-include counts
253
+ continue
254
+
255
+ # Regular files
256
+ # User exclude wins
257
+ if matches_any(rules.exclude, rel_file):
258
+ continue
259
+
260
+ # User include wins
261
+ if matches_any(rules.include, rel_file):
262
+ yield Finding(fpath, "file", "user_include")
263
+ continue
264
+
265
+ # Built-in junk patterns (safe ones)
266
+ if is_junk_file_name(fname):
267
+ yield Finding(fpath, "file", "junk_file")
@@ -1,106 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- from dataclasses import dataclass
5
- from fnmatch import fnmatch
6
- from pathlib import Path
7
- from typing import Iterable, List
8
-
9
-
10
- @dataclass
11
- class Finding:
12
- path: Path
13
- kind: str # "file" or "dir"
14
- reason: str
15
-
16
-
17
- # Directories that are *typically* safe to delete completely.
18
- JUNK_DIR_NAMES = {
19
- "__pycache__",
20
- ".pytest_cache",
21
- ".mypy_cache",
22
- ".ruff_cache",
23
- ".nox",
24
- ".tox",
25
- ".hypothesis",
26
- ".cache",
27
- ".gradle",
28
- ".parcel-cache",
29
- ".turbo",
30
- ".next",
31
- ".vite",
32
- ".sass-cache",
33
- ".sass-cache",
34
- "build",
35
- "dist",
36
- }
37
-
38
- # File name patterns that are almost always junk / temporary.
39
- JUNK_FILE_PATTERNS = [
40
- "*~",
41
- "*.swp",
42
- "*.swo",
43
- "*.swpx",
44
- "*.tmp",
45
- "*.temp",
46
- "*.bak",
47
- "*.orig",
48
- "*.rej",
49
- ".DS_Store",
50
- "Thumbs.db",
51
- "desktop.ini",
52
- ]
53
-
54
- # VCS / system dirs
55
- SKIP_DIR_NAMES = {
56
- ".git",
57
- ".hg",
58
- ".svn",
59
- ".bzr",
60
- ".idea",
61
- ".vscode",
62
- }
63
-
64
-
65
- def is_junk_dir_name(name: str) -> bool:
66
- return name in JUNK_DIR_NAMES
67
-
68
-
69
- def is_junk_file_name(name: str) -> bool:
70
- return any(fnmatch(name, pattern) for pattern in JUNK_FILE_PATTERNS)
71
-
72
-
73
- def iter_junk(root: Path) -> Iterable[Finding]:
74
- """
75
- Walk the tree under `root` and yield junk candidates.
76
-
77
- filedust:
78
- - Skips known critical / config directories (SKIP_DIR_NAMES).
79
- - Treats known "junk" directory names as removable as a whole.
80
- - Treats known junk file patterns as removable.
81
- """
82
- root = root.resolve()
83
-
84
- for dirpath, dirnames, filenames in os.walk(root):
85
- dirpath_p = Path(dirpath)
86
-
87
- # Prune dirs we never touch at all.
88
- dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES]
89
-
90
- # Detect junk directories (and skip walking inside them).
91
- i = 0
92
- while i < len(dirnames):
93
- name = dirnames[i]
94
- if is_junk_dir_name(name):
95
- junk_dir = dirpath_p / name
96
- yield Finding(path=junk_dir, kind="dir", reason="junk_dir")
97
- # Remove from walk so we don't descend into it.
98
- del dirnames[i]
99
- continue
100
- i += 1
101
-
102
- # Now process files.
103
- for fname in filenames:
104
- if is_junk_file_name(fname):
105
- fpath = dirpath_p / fname
106
- yield Finding(path=fpath, kind="file", reason="junk_file")
File without changes