mimedy 1.0.0__py3-none-any.whl
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.
- mimedy/__init__.py +0 -0
- mimedy/config.example.yaml +69 -0
- mimedy/config.py +308 -0
- mimedy/errors.py +13 -0
- mimedy/main.py +172 -0
- mimedy/organizer.py +202 -0
- mimedy-1.0.0.dist-info/METADATA +383 -0
- mimedy-1.0.0.dist-info/RECORD +11 -0
- mimedy-1.0.0.dist-info/WHEEL +4 -0
- mimedy-1.0.0.dist-info/entry_points.txt +2 -0
- mimedy-1.0.0.dist-info/licenses/LICENSE +21 -0
mimedy/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# mimedy — example configuration
|
|
2
|
+
#
|
|
3
|
+
# Copy this file to config.yaml and adapt it to your needs:
|
|
4
|
+
# cp config.example.yaml config.yaml
|
|
5
|
+
#
|
|
6
|
+
# Every key is optional: missing keys fall back to the built-in defaults.
|
|
7
|
+
#
|
|
8
|
+
# Rules are applied in this order (first match wins):
|
|
9
|
+
# 1. hidden -> files whose name starts with '.'
|
|
10
|
+
# 2. large_files -> files bigger than threshold_mb
|
|
11
|
+
# 3. extensions -> exact extension match (cheap, no content analysis)
|
|
12
|
+
# 4. mimetypes -> MIME type detected by Magika from the file content
|
|
13
|
+
# 5. fallback -> Magika content group (Image, Video, Code, Document, ...)
|
|
14
|
+
|
|
15
|
+
# Destination folder for hidden files (.env, .bashrc, ...)
|
|
16
|
+
hidden: "Hidden"
|
|
17
|
+
|
|
18
|
+
# Large files are moved aside before any content analysis.
|
|
19
|
+
# Sizes are in decimal MB (1 MB = 1000 * 1000 bytes), like Finder/Explorer.
|
|
20
|
+
large_files:
|
|
21
|
+
threshold_mb: 500
|
|
22
|
+
target_dir: "Large"
|
|
23
|
+
|
|
24
|
+
# Extension rules: use them for formats Magika cannot know about,
|
|
25
|
+
# or when the extension carries more meaning than the content
|
|
26
|
+
# (a .blend is "Blender", not just "unknown binary").
|
|
27
|
+
extensions:
|
|
28
|
+
# Creative tools
|
|
29
|
+
.blend: "Blender"
|
|
30
|
+
.psd: "Photoshop"
|
|
31
|
+
.kra: "Krita"
|
|
32
|
+
.gd: "Godot"
|
|
33
|
+
.tscn: "Godot"
|
|
34
|
+
# Data files (Magika would otherwise group them under "code" or "text")
|
|
35
|
+
.csv: "Data"
|
|
36
|
+
.json: "Data"
|
|
37
|
+
.yaml: "Data"
|
|
38
|
+
.yml: "Data"
|
|
39
|
+
.toml: "Data"
|
|
40
|
+
# Disk images and installers
|
|
41
|
+
.dmg: "Installers"
|
|
42
|
+
.pkg: "Installers"
|
|
43
|
+
.msi: "Installers"
|
|
44
|
+
.iso: "Disk Images"
|
|
45
|
+
|
|
46
|
+
# MIME type rules: matched against Magika's detection, so they work
|
|
47
|
+
# even when the extension is wrong or missing.
|
|
48
|
+
mimetypes:
|
|
49
|
+
# Documents
|
|
50
|
+
application/pdf: "PDF"
|
|
51
|
+
application/epub+zip: "Books"
|
|
52
|
+
application/msword: "Documents"
|
|
53
|
+
application/vnd.openxmlformats-officedocument.wordprocessingml.document: "Documents"
|
|
54
|
+
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet: "Spreadsheets"
|
|
55
|
+
application/vnd.openxmlformats-officedocument.presentationml.presentation: "Presentations"
|
|
56
|
+
# Images
|
|
57
|
+
image/jpeg: "Photos"
|
|
58
|
+
image/heic: "Photos"
|
|
59
|
+
image/png: "Images"
|
|
60
|
+
image/svg+xml: "Vector"
|
|
61
|
+
# Archives
|
|
62
|
+
application/zip: "Archives"
|
|
63
|
+
application/x-tar: "Archives"
|
|
64
|
+
application/gzip: "Archives"
|
|
65
|
+
application/x-7z-compressed: "Archives"
|
|
66
|
+
# Code
|
|
67
|
+
text/x-python: "Python"
|
|
68
|
+
text/x-c: "C"
|
|
69
|
+
text/x-c++: "C++"
|
mimedy/config.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Configuration loading and validation.
|
|
2
|
+
|
|
3
|
+
Dataclasses do not check types at runtime: building a Config directly from
|
|
4
|
+
unchecked data would accept anything. Always go through load_config(), which
|
|
5
|
+
validates every value before building the dataclasses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass, field, fields
|
|
13
|
+
from difflib import get_close_matches
|
|
14
|
+
from pathlib import Path, PurePath
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from mimedy.errors import ConfigError
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("mimedy.config")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, kw_only=True)
|
|
24
|
+
class LargeFilesConfig:
|
|
25
|
+
"""Where files bigger than threshold_mb (decimal MB) are moved."""
|
|
26
|
+
|
|
27
|
+
threshold_mb: float = 100
|
|
28
|
+
target_dir: str = "Large"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, kw_only=True)
|
|
32
|
+
class Config:
|
|
33
|
+
"""The validated configuration, as returned by load_config().
|
|
34
|
+
|
|
35
|
+
The rest of the program can rely on these guarantees:
|
|
36
|
+
|
|
37
|
+
- every destination folder is a non-empty relative path that stays
|
|
38
|
+
inside the organized folder, normalized ("Code/Python", never
|
|
39
|
+
"./Code//Python/", "/tmp" or "../Data")
|
|
40
|
+
- extension keys are lowercase and start with a dot (".jpg"), so
|
|
41
|
+
they must be compared with file.suffix.lower()
|
|
42
|
+
- MIME type keys are lowercase ("application/pdf")
|
|
43
|
+
- no two rules conflict once normalized
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
hidden: str = "Hidden"
|
|
47
|
+
large_files: LargeFilesConfig = field(default_factory=LargeFilesConfig)
|
|
48
|
+
extensions: dict[str, str] = field(default_factory=dict)
|
|
49
|
+
mimetypes: dict[str, str] = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def default_config_path() -> Path:
|
|
53
|
+
"""Return the per-user config file: XDG on Linux and macOS, APPDATA on Windows."""
|
|
54
|
+
if sys.platform == "win32":
|
|
55
|
+
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
56
|
+
else:
|
|
57
|
+
xdg = os.environ.get("XDG_CONFIG_HOME", "")
|
|
58
|
+
# The XDG spec says to ignore an empty or relative value
|
|
59
|
+
base = Path(xdg) if xdg and Path(xdg).is_absolute() else Path.home() / ".config"
|
|
60
|
+
return base / "mimedy" / "config.yaml"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check_unknown_keys(data: dict, config_class: type, errors: list[str]) -> None:
|
|
64
|
+
"""Add an error for each key of data that is not a field of config_class.
|
|
65
|
+
|
|
66
|
+
A likely typo gets a suggestion: "did you mean 'extensions'?".
|
|
67
|
+
"""
|
|
68
|
+
authorized_keys = {f.name for f in fields(config_class)}
|
|
69
|
+
unknown_keys = data.keys() - authorized_keys
|
|
70
|
+
for key in sorted(unknown_keys):
|
|
71
|
+
close_match = get_close_matches(key, authorized_keys, n=1)
|
|
72
|
+
err_msg = f"Unknown key '{key}'"
|
|
73
|
+
if close_match:
|
|
74
|
+
err_msg += f" (did you mean '{close_match[0]}'?)"
|
|
75
|
+
errors.append(err_msg)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read_dir(value: object, key: str, errors: list[str]) -> str | None:
|
|
79
|
+
"""Read a destination folder, relative to the organized folder.
|
|
80
|
+
|
|
81
|
+
It must be a non-empty relative path that stays inside the organized
|
|
82
|
+
folder. Surrounding spaces are stripped and the path is normalized
|
|
83
|
+
("./Code//Python/" becomes "Code/Python").
|
|
84
|
+
"""
|
|
85
|
+
if not isinstance(value, str):
|
|
86
|
+
errors.append(f"'{key}' must be a string, got {type(value).__name__}")
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
# PurePath only parses the text: destinations usually don't exist yet,
|
|
90
|
+
# and validating the config must not depend on the disk
|
|
91
|
+
path = PurePath(value.strip())
|
|
92
|
+
|
|
93
|
+
# "" and "." both have no parts: they would mean the organized folder itself
|
|
94
|
+
if not path.parts:
|
|
95
|
+
errors.append(f"'{key}' must not be empty")
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
# Path("/organized") / "/tmp" gives "/tmp", and ".." climbs out of it:
|
|
99
|
+
# a destination must never lead outside the organized folder
|
|
100
|
+
if path.is_absolute():
|
|
101
|
+
errors.append(f"'{key}' must be a relative folder name, got '{path}'")
|
|
102
|
+
return None
|
|
103
|
+
if ".." in path.parts:
|
|
104
|
+
errors.append(f"'{key}' must stay inside the organized folder, got '{path}'")
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
# Only the shell expands "~" (and "~user") at the start of a path: here it
|
|
108
|
+
# would create a folder literally named "~", not use the home directory
|
|
109
|
+
if path.parts[0].startswith("~"):
|
|
110
|
+
errors.append(
|
|
111
|
+
f"'{key}' must stay inside the organized folder "
|
|
112
|
+
f"('~' is not expanded), got '{path}'"
|
|
113
|
+
)
|
|
114
|
+
return None
|
|
115
|
+
return str(path)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def read_threshold(value: object, key: str, errors: list[str]) -> float | None:
|
|
119
|
+
"""Read a strictly positive number, booleans excluded."""
|
|
120
|
+
# YAML reads "yes" as True, and True is an int in Python: reject it first
|
|
121
|
+
if isinstance(value, bool):
|
|
122
|
+
errors.append(f"'{key}' must be a number, got bool")
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
if not isinstance(value, (int, float)):
|
|
126
|
+
errors.append(f"'{key}' must be a positive number, got {type(value).__name__}")
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
threshold = float(value)
|
|
130
|
+
if threshold <= 0:
|
|
131
|
+
errors.append(f"'{key}' must be a positive number")
|
|
132
|
+
return None
|
|
133
|
+
return threshold
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def add_section_errors(
|
|
137
|
+
section: str, section_errors: list[str], errors: list[str]
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Add the errors of a config section, grouped under its name."""
|
|
140
|
+
details = "\n".join(f" - {error}" for error in section_errors)
|
|
141
|
+
errors.append(f"In '{section}':\n{details}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def normalize_extension(extension: str) -> str:
|
|
145
|
+
"""Normalize an extension: '.JPG' -> '.jpg', 'csv' -> '.csv'.
|
|
146
|
+
|
|
147
|
+
Raise ValueError for an empty extension ("" or ".").
|
|
148
|
+
"""
|
|
149
|
+
name = extension.lower().removeprefix(".")
|
|
150
|
+
if not name:
|
|
151
|
+
msg = "is not a valid extension"
|
|
152
|
+
raise ValueError(msg)
|
|
153
|
+
return f".{name}"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def read_mapping(
|
|
157
|
+
value: object, key: str, errors: list[str], normalize: Callable[[str], str]
|
|
158
|
+
) -> dict[str, str] | None:
|
|
159
|
+
"""Read a mapping of rules to destination folders (extensions, mimetypes).
|
|
160
|
+
|
|
161
|
+
Keys go through normalize. Two keys that become the same rule are
|
|
162
|
+
accepted when they point to the same folder, and reported otherwise.
|
|
163
|
+
"""
|
|
164
|
+
if not isinstance(value, dict):
|
|
165
|
+
errors.append(f"'{key}' must be a mapping, got {type(value).__name__}")
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
# Errors are grouped under the mapping's name, e.g. "In 'extensions':"
|
|
169
|
+
mapping_errors: list[str] = []
|
|
170
|
+
mapping: dict[str, str] = {}
|
|
171
|
+
# Normalized rule -> rule as written, to name both sides of a conflict
|
|
172
|
+
origins: dict[str, str] = {}
|
|
173
|
+
for rule, dest in value.items():
|
|
174
|
+
if not isinstance(rule, str):
|
|
175
|
+
mapping_errors.append(
|
|
176
|
+
f"{rule!r} must be a string, got {type(rule).__name__}"
|
|
177
|
+
)
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
normalized_rule = normalize(rule)
|
|
182
|
+
except ValueError as e:
|
|
183
|
+
mapping_errors.append(f"'{rule}' {e}")
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
checked_dest = read_dir(dest, rule, mapping_errors)
|
|
187
|
+
if checked_dest is None:
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
if normalized_rule not in mapping:
|
|
191
|
+
mapping[normalized_rule] = checked_dest
|
|
192
|
+
origins[normalized_rule] = rule
|
|
193
|
+
elif mapping[normalized_rule] != checked_dest:
|
|
194
|
+
mapping_errors.append(
|
|
195
|
+
f"'{origins[normalized_rule]}' and '{rule}' "
|
|
196
|
+
"are the same rule but go to different folders"
|
|
197
|
+
)
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
if mapping_errors:
|
|
201
|
+
add_section_errors(key, mapping_errors, errors)
|
|
202
|
+
return None
|
|
203
|
+
return mapping
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def read_large_files(value: object, errors: list[str]) -> LargeFilesConfig | None:
|
|
207
|
+
"""Read the large_files section: threshold_mb and target_dir."""
|
|
208
|
+
if not isinstance(value, dict):
|
|
209
|
+
errors.append(f"'large_files' must be a mapping, got {type(value).__name__}")
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
# Errors are grouped under "In 'large_files':", unknown keys included
|
|
213
|
+
large_files_errors: list[str] = []
|
|
214
|
+
check_unknown_keys(value, LargeFilesConfig, large_files_errors)
|
|
215
|
+
|
|
216
|
+
kwargs = {}
|
|
217
|
+
if "threshold_mb" in value:
|
|
218
|
+
threshold_mb = read_threshold(
|
|
219
|
+
value["threshold_mb"], "threshold_mb", large_files_errors
|
|
220
|
+
)
|
|
221
|
+
if threshold_mb is not None:
|
|
222
|
+
kwargs["threshold_mb"] = threshold_mb
|
|
223
|
+
if "target_dir" in value:
|
|
224
|
+
target_dir = read_dir(value["target_dir"], "target_dir", large_files_errors)
|
|
225
|
+
if target_dir is not None:
|
|
226
|
+
kwargs["target_dir"] = target_dir
|
|
227
|
+
|
|
228
|
+
if large_files_errors:
|
|
229
|
+
add_section_errors("large_files", large_files_errors, errors)
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
return LargeFilesConfig(**kwargs)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def read_yaml(config_path: Path) -> dict:
|
|
236
|
+
"""Read a YAML config file as a mapping of settings.
|
|
237
|
+
|
|
238
|
+
Raise ConfigError if the file is missing, unreadable, not valid YAML, or
|
|
239
|
+
not a mapping.
|
|
240
|
+
"""
|
|
241
|
+
try:
|
|
242
|
+
with config_path.open() as f:
|
|
243
|
+
data = yaml.safe_load(f) or {}
|
|
244
|
+
except FileNotFoundError as e:
|
|
245
|
+
msg = f"Config file not found: {config_path}"
|
|
246
|
+
raise ConfigError(msg) from e
|
|
247
|
+
except OSError as e:
|
|
248
|
+
msg = f"Cannot read {config_path}: {e.strerror}"
|
|
249
|
+
raise ConfigError(msg) from e
|
|
250
|
+
except yaml.YAMLError as e:
|
|
251
|
+
msg = f"Invalid YAML in {config_path}: {e}"
|
|
252
|
+
raise ConfigError(msg) from e
|
|
253
|
+
|
|
254
|
+
if not isinstance(data, dict):
|
|
255
|
+
msg = f"Invalid config in {config_path}: expected a mapping of settings"
|
|
256
|
+
raise ConfigError(msg)
|
|
257
|
+
return data
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def load_config(config_path: Path | None = None) -> Config:
|
|
261
|
+
"""Load and validate the configuration.
|
|
262
|
+
|
|
263
|
+
config_path is the file given with --config: it must exist. Without it,
|
|
264
|
+
the per-user file from default_config_path() is used when it exists, and
|
|
265
|
+
the default configuration otherwise.
|
|
266
|
+
|
|
267
|
+
Raise ConfigError listing every problem found in an invalid file.
|
|
268
|
+
"""
|
|
269
|
+
if config_path is None:
|
|
270
|
+
config_path = default_config_path()
|
|
271
|
+
if not config_path.exists():
|
|
272
|
+
logger.debug("No config file at %s, using defaults", config_path)
|
|
273
|
+
return Config()
|
|
274
|
+
|
|
275
|
+
config_to_load = read_yaml(config_path)
|
|
276
|
+
|
|
277
|
+
# Collect every error before reporting, so they can all be fixed at once
|
|
278
|
+
errors: list[str] = []
|
|
279
|
+
check_unknown_keys(config_to_load, Config, errors)
|
|
280
|
+
|
|
281
|
+
config_kwargs = {}
|
|
282
|
+
if "hidden" in config_to_load:
|
|
283
|
+
hidden = read_dir(config_to_load["hidden"], "hidden", errors)
|
|
284
|
+
if hidden is not None:
|
|
285
|
+
config_kwargs["hidden"] = hidden
|
|
286
|
+
if "extensions" in config_to_load:
|
|
287
|
+
extensions = read_mapping(
|
|
288
|
+
config_to_load["extensions"], "extensions", errors, normalize_extension
|
|
289
|
+
)
|
|
290
|
+
if extensions is not None:
|
|
291
|
+
config_kwargs["extensions"] = extensions
|
|
292
|
+
if "mimetypes" in config_to_load:
|
|
293
|
+
mimetypes = read_mapping(
|
|
294
|
+
config_to_load["mimetypes"], "mimetypes", errors, str.lower
|
|
295
|
+
)
|
|
296
|
+
if mimetypes is not None:
|
|
297
|
+
config_kwargs["mimetypes"] = mimetypes
|
|
298
|
+
|
|
299
|
+
large_files_config = config_to_load.get("large_files", {})
|
|
300
|
+
large_files = read_large_files(large_files_config, errors)
|
|
301
|
+
|
|
302
|
+
if errors:
|
|
303
|
+
details = "\n".join(f" - {error}" for error in errors)
|
|
304
|
+
msg = f"Invalid config in {config_path}:\n{details}"
|
|
305
|
+
raise ConfigError(msg)
|
|
306
|
+
|
|
307
|
+
logger.info("Loaded config from %s", config_path)
|
|
308
|
+
return Config(**config_kwargs, large_files=large_files)
|
mimedy/errors.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Exceptions raised by mimedy."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MimedyError(Exception):
|
|
5
|
+
"""Base class for all mimedy errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigError(MimedyError):
|
|
9
|
+
"""The configuration file cannot be loaded."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ClassificationError(MimedyError):
|
|
13
|
+
"""A file cannot be classified."""
|
mimedy/main.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Command-line interface of mimedy."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from importlib.metadata import version
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from magika import Magika
|
|
11
|
+
|
|
12
|
+
from mimedy.config import default_config_path, load_config
|
|
13
|
+
from mimedy.errors import ConfigError
|
|
14
|
+
from mimedy.organizer import execute, log_plan, plan_moves
|
|
15
|
+
|
|
16
|
+
# Exit codes
|
|
17
|
+
EXIT_OK = 0
|
|
18
|
+
EXIT_FAILURES = 1 # the run completed, but at least one file failed
|
|
19
|
+
# Invalid arguments or configuration exit with 2, handled by Typer
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("mimedy.main")
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
add_completion=True,
|
|
26
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def init_logging(*, verbose: bool = False) -> None:
|
|
31
|
+
"""Configure log output: short messages, or detailed ones with --verbose."""
|
|
32
|
+
if verbose:
|
|
33
|
+
log_format = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
34
|
+
else:
|
|
35
|
+
log_format = "[%(levelname)s] %(message)s"
|
|
36
|
+
logging.basicConfig(format=log_format)
|
|
37
|
+
|
|
38
|
+
# Only our own loggers go down to DEBUG, not third-party libraries
|
|
39
|
+
logging.getLogger("mimedy").setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def show_version(value: bool) -> None:
|
|
43
|
+
"""Print the version and exit, before any other argument is checked."""
|
|
44
|
+
if value:
|
|
45
|
+
typer.echo(f"mimedy {version('mimedy')}")
|
|
46
|
+
raise typer.Exit(EXIT_OK)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def init_config(value: bool) -> None:
|
|
50
|
+
"""Copy the example config to the default location, then exit.
|
|
51
|
+
|
|
52
|
+
An existing config is never overwritten.
|
|
53
|
+
"""
|
|
54
|
+
if not value:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
target_config_path = default_config_path()
|
|
58
|
+
|
|
59
|
+
if target_config_path.exists():
|
|
60
|
+
typer.echo(f"Config file already exists: {target_config_path}", err=True)
|
|
61
|
+
raise typer.Exit(EXIT_FAILURES)
|
|
62
|
+
|
|
63
|
+
# Create the folder that holds the file (~/.config/mimedy), not the file itself
|
|
64
|
+
target_config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
target_config_path.write_text((files("mimedy") / "config.example.yaml").read_text())
|
|
66
|
+
|
|
67
|
+
typer.echo(f"Created config file: {target_config_path}")
|
|
68
|
+
raise typer.Exit(EXIT_OK)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command()
|
|
72
|
+
def main( # noqa: PLR0913, PLR0917 (one parameter per CLI option)
|
|
73
|
+
directory: Annotated[
|
|
74
|
+
Path,
|
|
75
|
+
typer.Argument(
|
|
76
|
+
help="Folder to organize.",
|
|
77
|
+
exists=True,
|
|
78
|
+
file_okay=False,
|
|
79
|
+
readable=True,
|
|
80
|
+
resolve_path=True,
|
|
81
|
+
),
|
|
82
|
+
],
|
|
83
|
+
config_path: Annotated[
|
|
84
|
+
Path | None,
|
|
85
|
+
typer.Option(
|
|
86
|
+
"--config",
|
|
87
|
+
"-c",
|
|
88
|
+
help=f"YAML configuration file (default: {default_config_path()}).",
|
|
89
|
+
),
|
|
90
|
+
] = None,
|
|
91
|
+
dry_run: Annotated[
|
|
92
|
+
bool, typer.Option("--dry-run", "-n", help="Show planned moves without moving.")
|
|
93
|
+
] = False,
|
|
94
|
+
yes: Annotated[
|
|
95
|
+
bool, typer.Option("--yes", "-y", help="Move without asking for confirmation.")
|
|
96
|
+
] = False,
|
|
97
|
+
lowercase: Annotated[
|
|
98
|
+
bool,
|
|
99
|
+
typer.Option("--lowercase", "-l", help="Keep Magika folder names lowercase."),
|
|
100
|
+
] = False,
|
|
101
|
+
verbose: Annotated[
|
|
102
|
+
bool,
|
|
103
|
+
typer.Option("--verbose", "-v", help="Show the rule applied to each file."),
|
|
104
|
+
] = False,
|
|
105
|
+
show_version_flag: Annotated[
|
|
106
|
+
bool,
|
|
107
|
+
typer.Option(
|
|
108
|
+
"--version",
|
|
109
|
+
"-V",
|
|
110
|
+
callback=show_version,
|
|
111
|
+
is_eager=True,
|
|
112
|
+
help="Show the version and exit.",
|
|
113
|
+
),
|
|
114
|
+
] = False,
|
|
115
|
+
init_config_flag: Annotated[
|
|
116
|
+
bool,
|
|
117
|
+
typer.Option(
|
|
118
|
+
"--init-config",
|
|
119
|
+
callback=init_config,
|
|
120
|
+
is_eager=True,
|
|
121
|
+
help="Create a commented example config at the default location.",
|
|
122
|
+
),
|
|
123
|
+
] = False,
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Organize a folder by real file type, detected from content with Magika."""
|
|
126
|
+
init_logging(verbose=verbose)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
config = load_config(config_path)
|
|
130
|
+
except ConfigError as e:
|
|
131
|
+
# Reported by Typer as a usage error, with exit code 2
|
|
132
|
+
raise typer.BadParameter(str(e), param_hint="--config") from e
|
|
133
|
+
|
|
134
|
+
# Loading the Magika model is slow: do it once for all files
|
|
135
|
+
magika = Magika()
|
|
136
|
+
|
|
137
|
+
# Phase 1: decide where every file goes, without touching anything
|
|
138
|
+
logger.info("Organizing %s", directory)
|
|
139
|
+
plan = plan_moves(directory, magika, config, lowercase=lowercase)
|
|
140
|
+
log_plan(plan)
|
|
141
|
+
planning_code = EXIT_FAILURES if plan.failures else EXIT_OK
|
|
142
|
+
|
|
143
|
+
if not plan.moves:
|
|
144
|
+
logger.info("Nothing to move")
|
|
145
|
+
raise typer.Exit(planning_code)
|
|
146
|
+
|
|
147
|
+
logger.info(
|
|
148
|
+
"%d files to move into %d folders, %d skipped",
|
|
149
|
+
len(plan.moves),
|
|
150
|
+
len(plan.folders),
|
|
151
|
+
len(plan.failures),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if dry_run:
|
|
155
|
+
logger.info("Dry run: nothing was moved")
|
|
156
|
+
raise typer.Exit(planning_code)
|
|
157
|
+
|
|
158
|
+
if not yes:
|
|
159
|
+
typer.confirm(f"Move {len(plan.moves)} files?", abort=True)
|
|
160
|
+
|
|
161
|
+
# Phase 2: perform the plan
|
|
162
|
+
failures = execute(plan)
|
|
163
|
+
logger.info(
|
|
164
|
+
"Done: %d moved, %d failed", len(plan.moves) - len(failures), len(failures)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if failures or plan.failures:
|
|
168
|
+
raise typer.Exit(EXIT_FAILURES)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
app()
|
mimedy/organizer.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Plan where each file goes, then move the files."""
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import logging
|
|
5
|
+
import shutil
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from magika import Magika
|
|
10
|
+
|
|
11
|
+
from mimedy.config import Config
|
|
12
|
+
from mimedy.errors import ClassificationError
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("mimedy.organizer")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Move:
|
|
19
|
+
"""A planned move of one file."""
|
|
20
|
+
|
|
21
|
+
source: Path
|
|
22
|
+
target: Path
|
|
23
|
+
rule: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Failure:
|
|
28
|
+
"""A file that could not be planned or moved."""
|
|
29
|
+
|
|
30
|
+
source: Path
|
|
31
|
+
reason: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Plan:
|
|
36
|
+
"""Every move to perform, computed without touching the disk."""
|
|
37
|
+
|
|
38
|
+
directory: Path
|
|
39
|
+
moves: list[Move] = field(default_factory=list)
|
|
40
|
+
failures: list[Failure] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def folders(self) -> set[Path]:
|
|
44
|
+
"""Return the destination folders used by the plan."""
|
|
45
|
+
return {move.target.parent for move in self.moves}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_hidden(path: Path) -> bool:
|
|
49
|
+
"""Return whether the file is hidden (its name starts with '.')."""
|
|
50
|
+
return path.name.startswith(".")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_large(path: Path, threshold_mb: float) -> bool:
|
|
54
|
+
"""Return whether the file size reaches threshold_mb."""
|
|
55
|
+
# Decimal MB (1000, not 1024), like Finder and Explorer display sizes
|
|
56
|
+
filesize_mb = path.stat().st_size / 1000 / 1000
|
|
57
|
+
|
|
58
|
+
return filesize_mb >= threshold_mb
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_file_mimetype_and_group(m: Magika, file: Path) -> tuple[str, str]:
|
|
62
|
+
"""Return the MIME type and content group detected by Magika."""
|
|
63
|
+
# The magic happens here !
|
|
64
|
+
res = m.identify_path(file)
|
|
65
|
+
|
|
66
|
+
# Magika reports read errors in the result instead of raising
|
|
67
|
+
if not res.ok:
|
|
68
|
+
msg = f"Magika could not read the file ({res.status})"
|
|
69
|
+
raise ClassificationError(msg)
|
|
70
|
+
|
|
71
|
+
return (res.output.mime_type, res.output.group)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def determine_target_directory(
|
|
75
|
+
file: Path, magika: Magika, config: Config, *, lowercase: bool
|
|
76
|
+
) -> tuple[str, str]:
|
|
77
|
+
"""Return the target directory and the rule that selected it."""
|
|
78
|
+
extension = file.suffix.lower()
|
|
79
|
+
|
|
80
|
+
if is_hidden(file):
|
|
81
|
+
return config.hidden, "hidden file"
|
|
82
|
+
threshold_mb = config.large_files.threshold_mb
|
|
83
|
+
if is_large(file, threshold_mb):
|
|
84
|
+
return config.large_files.target_dir, f"larger than {threshold_mb} MB"
|
|
85
|
+
if extension in config.extensions:
|
|
86
|
+
return config.extensions[extension], f"extension {extension}"
|
|
87
|
+
|
|
88
|
+
mimetype, group = get_file_mimetype_and_group(magika, file)
|
|
89
|
+
if mimetype in config.mimetypes:
|
|
90
|
+
return config.mimetypes[mimetype], f"mimetype {mimetype}"
|
|
91
|
+
|
|
92
|
+
target_dir = group if lowercase else group.capitalize()
|
|
93
|
+
return target_dir, f"magika group {group} ({mimetype})"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def unique_path(path: Path, reserved: set[Path]) -> Path:
|
|
97
|
+
"""Return a free target: "photo.jpg", then "photo (1).jpg", "photo (2).jpg"...
|
|
98
|
+
|
|
99
|
+
A target is taken if it exists on disk or was already given to another
|
|
100
|
+
file of the plan.
|
|
101
|
+
"""
|
|
102
|
+
candidate = path
|
|
103
|
+
counter = 1
|
|
104
|
+
while candidate.exists() or candidate in reserved:
|
|
105
|
+
candidate = path.with_name(f"{path.stem} ({counter}){path.suffix}")
|
|
106
|
+
counter += 1
|
|
107
|
+
return candidate
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def describe_error(e: Exception) -> str:
|
|
111
|
+
"""Return a short reason, e.g. "Permission denied".
|
|
112
|
+
|
|
113
|
+
OSError messages otherwise look like "[Errno 13] Permission denied: '/path'".
|
|
114
|
+
"""
|
|
115
|
+
if isinstance(e, OSError) and e.strerror:
|
|
116
|
+
return e.strerror
|
|
117
|
+
return str(e)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def plan_moves(
|
|
121
|
+
directory: Path, magika: Magika, config: Config, *, lowercase: bool = False
|
|
122
|
+
) -> Plan:
|
|
123
|
+
"""Decide where every file goes. Nothing is moved or created."""
|
|
124
|
+
plan = Plan(directory)
|
|
125
|
+
reserved: set[Path] = set()
|
|
126
|
+
|
|
127
|
+
for file in sorted(directory.iterdir()):
|
|
128
|
+
if not file.is_file():
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
target_dir, rule = determine_target_directory(
|
|
133
|
+
file, magika, config, lowercase=lowercase
|
|
134
|
+
)
|
|
135
|
+
except (OSError, ClassificationError) as e:
|
|
136
|
+
plan.failures.append(Failure(file, describe_error(e)))
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
target = unique_path(directory / target_dir / file.name, reserved)
|
|
140
|
+
reserved.add(target)
|
|
141
|
+
plan.moves.append(Move(file, target, rule))
|
|
142
|
+
|
|
143
|
+
return plan
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def display_target(move: Move, directory: Path) -> str:
|
|
147
|
+
"""Return "Photos/" when the name is kept, "Photos/photo (1).jpg" if renamed."""
|
|
148
|
+
relative = move.target.relative_to(directory)
|
|
149
|
+
if move.target.name == move.source.name:
|
|
150
|
+
return f"{relative.parent}/"
|
|
151
|
+
return str(relative)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def log_plan(plan: Plan) -> None:
|
|
155
|
+
"""Log every planned move, then every file skipped while planning."""
|
|
156
|
+
for move in plan.moves:
|
|
157
|
+
logger.debug("'%s': rule %s", move.source.name, move.rule)
|
|
158
|
+
logger.info("'%s' → %s", move.source.name, display_target(move, plan.directory))
|
|
159
|
+
for failure in plan.failures:
|
|
160
|
+
logger.warning("Skipped '%s': %s", failure.source.name, failure.reason)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def apply_move(move: Move) -> None:
|
|
164
|
+
"""Move one file to its planned target, creating its folder if needed."""
|
|
165
|
+
move.target.parent.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
|
|
167
|
+
# The plan promised this exact target: never rename it again behind the
|
|
168
|
+
# user's back if something appeared there since planning
|
|
169
|
+
if move.target.exists():
|
|
170
|
+
raise FileExistsError(
|
|
171
|
+
errno.EEXIST, "Target appeared since planning", str(move.target)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
shutil.move(move.source, move.target)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def try_move(move: Move) -> Failure | None:
|
|
178
|
+
"""Apply a move, returning a Failure instead of raising."""
|
|
179
|
+
try:
|
|
180
|
+
apply_move(move)
|
|
181
|
+
except OSError as e:
|
|
182
|
+
return Failure(move.source, describe_error(e))
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def execute(plan: Plan) -> list[Failure]:
|
|
187
|
+
"""Perform the planned moves. A failed move doesn't stop the others."""
|
|
188
|
+
failures: list[Failure] = []
|
|
189
|
+
|
|
190
|
+
for move in plan.moves:
|
|
191
|
+
failure = try_move(move)
|
|
192
|
+
if failure:
|
|
193
|
+
failures.append(failure)
|
|
194
|
+
logger.error("Failed to move '%s': %s", move.source.name, failure.reason)
|
|
195
|
+
else:
|
|
196
|
+
logger.debug(
|
|
197
|
+
"Moved '%s' → %s",
|
|
198
|
+
move.source.name,
|
|
199
|
+
display_target(move, plan.directory),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
return failures
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mimedy
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Organize messy folders by real file type, detected from content with Google Magika
|
|
5
|
+
Project-URL: Homepage, https://github.com/ochapeau/mimedy
|
|
6
|
+
Project-URL: Repository, https://github.com/ochapeau/mimedy
|
|
7
|
+
Project-URL: Issues, https://github.com/ochapeau/mimedy/issues
|
|
8
|
+
Project-URL: Releases, https://github.com/ochapeau/mimedy/releases
|
|
9
|
+
Author: Olivier Chapeau
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: cli,downloads,files,magika,mime,mimetype,organizer
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: System :: Filesystems
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: magika>=0.6.2
|
|
27
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
28
|
+
Requires-Dist: typer>=0.16
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# 🗂️ mimedy
|
|
32
|
+
|
|
33
|
+
[](https://github.com/ochapeau/mimedy/actions/workflows/ci.yml)
|
|
34
|
+
|
|
35
|
+
**Range un dossier en désordre selon le vrai type de chaque fichier, pas selon son extension.**\
|
|
36
|
+
**Tidies up a messy folder by each file's real type, not by its extension.**
|
|
37
|
+
|
|
38
|
+
*mimedy = **MIME** + **tidy** (ranger) : le type MIME de chaque fichier décide de sa place.*\
|
|
39
|
+
*mimedy = **MIME** + **tidy**: each file's MIME type decides where it belongs.*
|
|
40
|
+
|
|
41
|
+
[Français](#français) · [English](#english)
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
<a id="français"></a>
|
|
46
|
+
|
|
47
|
+
## 🇫🇷 Français
|
|
48
|
+
|
|
49
|
+
### Pourquoi ?
|
|
50
|
+
|
|
51
|
+
Un dossier `Téléchargements` finit toujours par ressembler à ça : des PDF, des captures d'écran, des archives, un `.json` exporté un jour, un fichier sans extension dont personne ne se souvient…
|
|
52
|
+
|
|
53
|
+
La plupart des outils de rangement se fient à l'extension. Or une extension peut mentir, manquer ou être fausse. **mimedy** utilise [Magika](https://github.com/google/magika), le modèle de deep learning de Google qui identifie un fichier **à partir de son contenu**, pour décider où il doit aller.
|
|
54
|
+
|
|
55
|
+
```text
|
|
56
|
+
$ mimedy ~/Downloads
|
|
57
|
+
[INFO] Loaded config from /Users/me/.config/mimedy/config.yaml
|
|
58
|
+
[INFO] Organizing /Users/me/Downloads
|
|
59
|
+
[INFO] 'export.csv' → Data/
|
|
60
|
+
[INFO] 'holidays.jpg' → Photos/
|
|
61
|
+
[INFO] 'invoice' → PDF/
|
|
62
|
+
[INFO] 'report.pdf' → PDF/report (1).pdf
|
|
63
|
+
[INFO] 'script.py' → Python/
|
|
64
|
+
[WARNING] Skipped 'locked.txt': Magika could not read the file (permission_error)
|
|
65
|
+
[INFO] 5 files to move into 4 folders, 1 skipped
|
|
66
|
+
Move 5 files? [y/N]: y
|
|
67
|
+
[INFO] Done: 5 moved, 0 failed
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Fonctionnalités
|
|
71
|
+
|
|
72
|
+
- 🧠 **Détection par le contenu** : Magika reconnaît plus de 200 types de fichiers, même renommés ou sans extension.
|
|
73
|
+
- 🪜 **Règles en cascade** : fichiers cachés, fichiers volumineux, extensions, types MIME, puis le groupe Magika en dernier recours.
|
|
74
|
+
- 📋 **Plan puis confirmation** : tous les déplacements sont affichés avant d'être effectués, et rien ne bouge sans votre accord. `--dry-run` s'arrête au plan.
|
|
75
|
+
- 🧯 **Robuste** : un fichier illisible ou verrouillé est signalé et ignoré, sans interrompre le rangement.
|
|
76
|
+
- 🛡️ **Aucun écrasement** : si `photo.jpg` existe déjà, le nouveau fichier devient `photo (1).jpg`.
|
|
77
|
+
- 🐛 **Mode `--verbose`** : indique pour chaque fichier la règle qui a décidé de sa destination.
|
|
78
|
+
- ⚙️ **Configuration YAML** simple, entièrement facultative.
|
|
79
|
+
|
|
80
|
+
### Installation
|
|
81
|
+
|
|
82
|
+
Prérequis : Python 3.10+ et [uv](https://docs.astral.sh/uv/).
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
git clone https://github.com/ochapeau/mimedy.git
|
|
86
|
+
cd mimedy
|
|
87
|
+
uv sync
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Utilisation
|
|
91
|
+
|
|
92
|
+
> 💡 mimedy affiche toujours le plan complet et demande confirmation avant de déplacer quoi que ce soit.
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# Afficher le plan, puis confirmer
|
|
96
|
+
uv run mimedy ~/Downloads
|
|
97
|
+
|
|
98
|
+
# Afficher le plan seulement
|
|
99
|
+
uv run mimedy ~/Downloads --dry-run
|
|
100
|
+
|
|
101
|
+
# Sans confirmation, par exemple dans un script
|
|
102
|
+
uv run mimedy ~/Downloads --yes --config my-config.yaml
|
|
103
|
+
|
|
104
|
+
# Comprendre pourquoi un fichier va à tel endroit
|
|
105
|
+
uv run mimedy ~/Downloads --dry-run --verbose
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
| Option | Description |
|
|
109
|
+
| :--- | :--- |
|
|
110
|
+
| `DIRECTORY` | Dossier à ranger *(obligatoire)*. |
|
|
111
|
+
| `--config`, `-c PATH` | Fichier de configuration YAML *(par défaut : voir [Configuration](#configuration))*. |
|
|
112
|
+
| `--dry-run`, `-n` | Affiche les déplacements prévus sans les effectuer. |
|
|
113
|
+
| `--yes`, `-y` | Déplace sans demander de confirmation. |
|
|
114
|
+
| `--verbose`, `-v` | Affiche la règle appliquée à chaque fichier. |
|
|
115
|
+
| `--lowercase`, `-l` | Garde en minuscules les dossiers nommés d'après Magika (`video/` au lieu de `Video/`). |
|
|
116
|
+
| `--version`, `-V` | Affiche la version. |
|
|
117
|
+
| `--help`, `-h` | Affiche l'aide. |
|
|
118
|
+
| `--init-config` | Crée une configuration d'exemple commentée à l'emplacement par défaut (sans jamais écraser une configuration existante). |
|
|
119
|
+
| `--install-completion` | Active l'autocomplétion des options avec Tab dans votre shell (une seule fois suffit). |
|
|
120
|
+
|
|
121
|
+
Seuls les fichiers situés directement dans le dossier sont traités. Les sous-dossiers existants ne sont pas touchés, ce qui permet de relancer l'outil sans risque.
|
|
122
|
+
|
|
123
|
+
| Code de sortie | Signification |
|
|
124
|
+
| :-: | :--- |
|
|
125
|
+
| `0` | Tout s'est bien passé, ou il n'y avait rien à ranger. |
|
|
126
|
+
| `1` | Au moins un fichier n'a pas pu être traité, ou la confirmation a été refusée. |
|
|
127
|
+
| `2` | Arguments ou configuration invalides. |
|
|
128
|
+
|
|
129
|
+
### Configuration
|
|
130
|
+
|
|
131
|
+
Sans `--config`, mimedy lit le fichier de configuration personnel, s'il existe :
|
|
132
|
+
|
|
133
|
+
| Système | Emplacement |
|
|
134
|
+
|---|---|
|
|
135
|
+
| Linux, macOS | `~/.config/mimedy/config.yaml` (ou `$XDG_CONFIG_HOME/mimedy/config.yaml`) |
|
|
136
|
+
| Windows | `%APPDATA%\mimedy\config.yaml` |
|
|
137
|
+
|
|
138
|
+
S'il n'existe pas, les valeurs par défaut sont utilisées. Un fichier passé avec `--config` doit en revanche exister. `mimedy --help` affiche l'emplacement exact sur votre machine.
|
|
139
|
+
|
|
140
|
+
Pour démarrer, créez une configuration d'exemple commentée, puis adaptez-la :
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
mimedy --init-config
|
|
144
|
+
# Created config file: /Users/me/.config/mimedy/config.yaml
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
```yaml
|
|
148
|
+
hidden: "Hidden" # fichiers commençant par '.'
|
|
149
|
+
|
|
150
|
+
large_files:
|
|
151
|
+
threshold_mb: 500 # en Mo décimaux, comme le Finder ou l'Explorateur
|
|
152
|
+
target_dir: "Large"
|
|
153
|
+
|
|
154
|
+
extensions: # correspondance exacte sur l'extension
|
|
155
|
+
.blend: "Blender"
|
|
156
|
+
.csv: "Data"
|
|
157
|
+
|
|
158
|
+
mimetypes: # type MIME détecté par Magika
|
|
159
|
+
application/pdf: "PDF"
|
|
160
|
+
image/jpeg: "Photos"
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Toutes les clés sont facultatives : celles qui manquent reprennent leur valeur par défaut. Les destinations peuvent contenir des sous-dossiers (`"Code/Python"`). [`config.example.yaml`](src/mimedy/config.example.yaml), le fichier copié par `--init-config`, contient un exemple complet et commenté.
|
|
164
|
+
|
|
165
|
+
### Comment un fichier est-il classé ?
|
|
166
|
+
|
|
167
|
+
Les règles sont évaluées dans cet ordre ; **la première qui correspond l'emporte** :
|
|
168
|
+
|
|
169
|
+
| # | Règle | Exemple |
|
|
170
|
+
|:-:|:---|:---|
|
|
171
|
+
| 1 | Fichier caché | `.env` → `Hidden/` |
|
|
172
|
+
| 2 | Fichier volumineux | `film.mkv` (2 Go) → `Large/` |
|
|
173
|
+
| 3 | Extension | `scene.blend` → `Blender/` |
|
|
174
|
+
| 4 | Type MIME (Magika) | `facture` *(PDF sans extension)* → `PDF/` |
|
|
175
|
+
| 5 | Groupe Magika | `script.py` → `Code/` |
|
|
176
|
+
|
|
177
|
+
Les règles 1 à 3 ne lisent pas le contenu des fichiers : elles sont instantanées. Magika n'est appelé que si elles ne suffisent pas.
|
|
178
|
+
|
|
179
|
+
### Structure du projet
|
|
180
|
+
|
|
181
|
+
```text
|
|
182
|
+
mimedy/
|
|
183
|
+
├── pyproject.toml
|
|
184
|
+
├── src/mimedy/
|
|
185
|
+
│ ├── main.py # Point d'entrée de la CLI (Typer)
|
|
186
|
+
│ ├── config.py # Chargement et validation de la configuration
|
|
187
|
+
│ ├── config.example.yaml # Configuration d'exemple commentée
|
|
188
|
+
│ ├── organizer.py # Règles de classement, planification et déplacements
|
|
189
|
+
│ └── errors.py # Exceptions du projet
|
|
190
|
+
└── tests/ # Tests pytest (configuration, règles, plan, CLI)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Développement
|
|
194
|
+
|
|
195
|
+
Le code est vérifié par [Ruff](https://docs.astral.sh/ruff/) (lint et formatage) à chaque commit, et testé avec [pytest](https://docs.pytest.org/). L'intégration continue lance les deux à chaque push, sur Python 3.10 à 3.13.
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
uv run pre-commit install # vérifications automatiques à chaque commit
|
|
199
|
+
uv run pytest # lancer les tests
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Feuille de route
|
|
203
|
+
|
|
204
|
+
- [x] Configuration typée et validée
|
|
205
|
+
- [x] Configuration globale dans `~/.config/mimedy/`
|
|
206
|
+
- [x] Tests automatisés et intégration continue
|
|
207
|
+
- [ ] Publication sur PyPI (`uv tool install mimedy`)
|
|
208
|
+
- [ ] Interface en terminal (TUI), en option
|
|
209
|
+
|
|
210
|
+
### Licence
|
|
211
|
+
|
|
212
|
+
Distribué sous licence MIT. Voir [LICENSE](LICENSE).
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
<a id="english"></a>
|
|
217
|
+
|
|
218
|
+
## 🇬🇧 English
|
|
219
|
+
|
|
220
|
+
### Why?
|
|
221
|
+
|
|
222
|
+
A `Downloads` folder always ends up looking the same: PDFs, screenshots, archives, a `.json` exported at some point, a file with no extension that nobody remembers…
|
|
223
|
+
|
|
224
|
+
Most organizing tools trust file extensions. But an extension can lie, be missing, or simply be wrong. **mimedy** uses [Magika](https://github.com/google/magika), Google's deep learning model that identifies a file **from its content**, to decide where it belongs.
|
|
225
|
+
|
|
226
|
+
```text
|
|
227
|
+
$ mimedy ~/Downloads
|
|
228
|
+
[INFO] Loaded config from /Users/me/.config/mimedy/config.yaml
|
|
229
|
+
[INFO] Organizing /Users/me/Downloads
|
|
230
|
+
[INFO] 'export.csv' → Data/
|
|
231
|
+
[INFO] 'holidays.jpg' → Photos/
|
|
232
|
+
[INFO] 'invoice' → PDF/
|
|
233
|
+
[INFO] 'report.pdf' → PDF/report (1).pdf
|
|
234
|
+
[INFO] 'script.py' → Python/
|
|
235
|
+
[WARNING] Skipped 'locked.txt': Magika could not read the file (permission_error)
|
|
236
|
+
[INFO] 5 files to move into 4 folders, 1 skipped
|
|
237
|
+
Move 5 files? [y/N]: y
|
|
238
|
+
[INFO] Done: 5 moved, 0 failed
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Features
|
|
242
|
+
|
|
243
|
+
- 🧠 **Content-based detection**: Magika recognizes over 200 file types, even when renamed or missing an extension.
|
|
244
|
+
- 🪜 **Cascading rules**: hidden files, large files, extensions, MIME types, then the Magika group as a fallback.
|
|
245
|
+
- 📋 **Plan, then confirm**: every move is shown before it happens, and nothing moves without your approval. `--dry-run` stops at the plan.
|
|
246
|
+
- 🧯 **Robust**: an unreadable or locked file is reported and skipped, without stopping the run.
|
|
247
|
+
- 🛡️ **Never overwrites**: if `photo.jpg` already exists, the new file becomes `photo (1).jpg`.
|
|
248
|
+
- 🐛 **`--verbose` mode**: shows which rule decided each file's destination.
|
|
249
|
+
- ⚙️ **Simple YAML configuration**, entirely optional.
|
|
250
|
+
|
|
251
|
+
### Installation
|
|
252
|
+
|
|
253
|
+
Requirements: Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
git clone https://github.com/ochapeau/mimedy.git
|
|
257
|
+
cd mimedy
|
|
258
|
+
uv sync
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Usage
|
|
262
|
+
|
|
263
|
+
> 💡 mimedy always shows the full plan and asks for confirmation before moving anything.
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
# Show the plan, then confirm
|
|
267
|
+
uv run mimedy ~/Downloads
|
|
268
|
+
|
|
269
|
+
# Only show the plan
|
|
270
|
+
uv run mimedy ~/Downloads --dry-run
|
|
271
|
+
|
|
272
|
+
# No confirmation, e.g. in a script
|
|
273
|
+
uv run mimedy ~/Downloads --yes --config my-config.yaml
|
|
274
|
+
|
|
275
|
+
# Understand why a file goes where it goes
|
|
276
|
+
uv run mimedy ~/Downloads --dry-run --verbose
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
| Option | Description |
|
|
280
|
+
| :--- | :--- |
|
|
281
|
+
| `DIRECTORY` | Folder to organize *(required)*. |
|
|
282
|
+
| `--config`, `-c PATH` | YAML configuration file *(default: see [Configuration](#configuration-1))*. |
|
|
283
|
+
| `--dry-run`, `-n` | Show planned moves without performing them. |
|
|
284
|
+
| `--yes`, `-y` | Move without asking for confirmation. |
|
|
285
|
+
| `--verbose`, `-v` | Show which rule applied to each file. |
|
|
286
|
+
| `--lowercase`, `-l` | Keep folders named after Magika groups lowercase (`video/` instead of `Video/`). |
|
|
287
|
+
| `--version`, `-V` | Show the version. |
|
|
288
|
+
| `--help`, `-h` | Show the help. |
|
|
289
|
+
| `--init-config` | Create a commented example config at the default location (never overwrites an existing one). |
|
|
290
|
+
| `--install-completion` | Enable Tab completion of the options in your shell (once is enough). |
|
|
291
|
+
|
|
292
|
+
Only files directly inside the folder are processed. Existing subfolders are left untouched, so the tool can safely be run again.
|
|
293
|
+
|
|
294
|
+
| Exit code | Meaning |
|
|
295
|
+
| :-: | :--- |
|
|
296
|
+
| `0` | Everything went fine, or there was nothing to organize. |
|
|
297
|
+
| `1` | At least one file could not be processed, or the confirmation was declined. |
|
|
298
|
+
| `2` | Invalid arguments or configuration. |
|
|
299
|
+
|
|
300
|
+
### Configuration
|
|
301
|
+
|
|
302
|
+
Without `--config`, mimedy reads your personal configuration file, if it exists:
|
|
303
|
+
|
|
304
|
+
| System | Location |
|
|
305
|
+
|---|---|
|
|
306
|
+
| Linux, macOS | `~/.config/mimedy/config.yaml` (or `$XDG_CONFIG_HOME/mimedy/config.yaml`) |
|
|
307
|
+
| Windows | `%APPDATA%\mimedy\config.yaml` |
|
|
308
|
+
|
|
309
|
+
If it doesn't exist, the defaults are used. A file passed with `--config`, however, must exist. `mimedy --help` shows the exact location on your machine.
|
|
310
|
+
|
|
311
|
+
To get started, create a commented example config, then adapt it:
|
|
312
|
+
|
|
313
|
+
```bash
|
|
314
|
+
mimedy --init-config
|
|
315
|
+
# Created config file: /Users/me/.config/mimedy/config.yaml
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
```yaml
|
|
319
|
+
hidden: "Hidden" # files starting with '.'
|
|
320
|
+
|
|
321
|
+
large_files:
|
|
322
|
+
threshold_mb: 500 # decimal MB, like Finder or Explorer
|
|
323
|
+
target_dir: "Large"
|
|
324
|
+
|
|
325
|
+
extensions: # exact extension match
|
|
326
|
+
.blend: "Blender"
|
|
327
|
+
.csv: "Data"
|
|
328
|
+
|
|
329
|
+
mimetypes: # MIME type detected by Magika
|
|
330
|
+
application/pdf: "PDF"
|
|
331
|
+
image/jpeg: "Photos"
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Every key is optional: missing ones fall back to their default value. Destinations may include subfolders (`"Code/Python"`). See [`config.example.yaml`](src/mimedy/config.example.yaml), the file copied by `--init-config`, for a complete, commented example.
|
|
335
|
+
|
|
336
|
+
### How is a file classified?
|
|
337
|
+
|
|
338
|
+
Rules are evaluated in this order; **the first match wins**:
|
|
339
|
+
|
|
340
|
+
| # | Rule | Example |
|
|
341
|
+
|:-:|:---|:---|
|
|
342
|
+
| 1 | Hidden file | `.env` → `Hidden/` |
|
|
343
|
+
| 2 | Large file | `movie.mkv` (2 GB) → `Large/` |
|
|
344
|
+
| 3 | Extension | `scene.blend` → `Blender/` |
|
|
345
|
+
| 4 | MIME type (Magika) | `invoice` *(PDF with no extension)* → `PDF/` |
|
|
346
|
+
| 5 | Magika group | `script.py` → `Code/` |
|
|
347
|
+
|
|
348
|
+
Rules 1 to 3 don't read file contents, so they are instant. Magika only runs when they aren't enough.
|
|
349
|
+
|
|
350
|
+
### Project structure
|
|
351
|
+
|
|
352
|
+
```text
|
|
353
|
+
mimedy/
|
|
354
|
+
├── pyproject.toml
|
|
355
|
+
├── src/mimedy/
|
|
356
|
+
│ ├── main.py # CLI entry point (Typer)
|
|
357
|
+
│ ├── config.py # Configuration loading and validation
|
|
358
|
+
│ ├── config.example.yaml # Commented example configuration
|
|
359
|
+
│ ├── organizer.py # Classification rules, planning and moves
|
|
360
|
+
│ └── errors.py # Project exceptions
|
|
361
|
+
└── tests/ # pytest tests (configuration, rules, plan, CLI)
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Development
|
|
365
|
+
|
|
366
|
+
Code is checked by [Ruff](https://docs.astral.sh/ruff/) (linting and formatting) on every commit, and tested with [pytest](https://docs.pytest.org/). Continuous integration runs both on every push, on Python 3.10 to 3.13.
|
|
367
|
+
|
|
368
|
+
```bash
|
|
369
|
+
uv run pre-commit install # automatic checks on every commit
|
|
370
|
+
uv run pytest # run the tests
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Roadmap
|
|
374
|
+
|
|
375
|
+
- [x] Typed, validated configuration
|
|
376
|
+
- [x] Global configuration in `~/.config/mimedy/`
|
|
377
|
+
- [x] Automated tests and continuous integration
|
|
378
|
+
- [ ] Publish on PyPI (`uv tool install mimedy`)
|
|
379
|
+
- [ ] Optional terminal interface (TUI)
|
|
380
|
+
|
|
381
|
+
### License
|
|
382
|
+
|
|
383
|
+
Released under the MIT License. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
mimedy/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
mimedy/config.example.yaml,sha256=4S9MH5uwcIGKh4-AmguPx4BPhOACvms_x0dMvNAtdeI,2264
|
|
3
|
+
mimedy/config.py,sha256=K9fqe1w6KDGY9ZizQuLAab9DbXLAV3X2R0KgvgOWlIs,11128
|
|
4
|
+
mimedy/errors.py,sha256=RX7eagHZhzReLOw-h5z2pPMxejtdmgSerinBH1C5kok,277
|
|
5
|
+
mimedy/main.py,sha256=eQZ2zREfVYkbxqiOfTfVYoYHaNLRhr5DncNYP-VnoL4,5173
|
|
6
|
+
mimedy/organizer.py,sha256=SxzS5GSIq_WQB59Y9-uEUikofNLkEiZj5kOP8s5cm1U,6254
|
|
7
|
+
mimedy-1.0.0.dist-info/METADATA,sha256=kmkX43YUs0emaYU6K2EAxUCPtau5k18cNEAHOff1Wpw,15181
|
|
8
|
+
mimedy-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
mimedy-1.0.0.dist-info/entry_points.txt,sha256=k6teYDmGJK1UKT7f1rORFFvoGyTfk1rlVCkZpHDwE30,43
|
|
10
|
+
mimedy-1.0.0.dist-info/licenses/LICENSE,sha256=BmU3jrD8wlFYuTPdl454P0HwEEyRqWWraEJaKllS4Uo,1072
|
|
11
|
+
mimedy-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Olivier Chapeau
|
|
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.
|