clutterxp 1.1.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.
Files changed (84) hide show
  1. clutterxp/__init__.py +4 -0
  2. clutterxp/cli/__init__.py +7 -0
  3. clutterxp/cli/app.py +251 -0
  4. clutterxp/cli/cmd_config.py +250 -0
  5. clutterxp/cli/cmd_doctor.py +449 -0
  6. clutterxp/cli/cmd_duplicates.py +253 -0
  7. clutterxp/cli/cmd_gui.py +22 -0
  8. clutterxp/cli/cmd_history.py +233 -0
  9. clutterxp/cli/cmd_init.py +76 -0
  10. clutterxp/cli/cmd_organize.py +226 -0
  11. clutterxp/cli/cmd_rules.py +540 -0
  12. clutterxp/cli/cmd_scan.py +109 -0
  13. clutterxp/cli/cmd_service.py +130 -0
  14. clutterxp/cli/cmd_undo.py +180 -0
  15. clutterxp/cli/cmd_watch.py +147 -0
  16. clutterxp/cli/errors.py +166 -0
  17. clutterxp/cli/render.py +390 -0
  18. clutterxp/config/__init__.py +40 -0
  19. clutterxp/config/jsonschema.py +12 -0
  20. clutterxp/config/loader.py +212 -0
  21. clutterxp/config/placeholders.py +79 -0
  22. clutterxp/config/runtime.py +30 -0
  23. clutterxp/config/schema.py +166 -0
  24. clutterxp/config/validate.py +226 -0
  25. clutterxp/config/writer.py +248 -0
  26. clutterxp/config.example.yaml +76 -0
  27. clutterxp/duplicates/__init__.py +22 -0
  28. clutterxp/duplicates/detector.py +438 -0
  29. clutterxp/duplicates/hashcache.py +149 -0
  30. clutterxp/duplicates/namenorm.py +45 -0
  31. clutterxp/duplicates/quarantine.py +196 -0
  32. clutterxp/duplicates/report.py +110 -0
  33. clutterxp/gui/__init__.py +1 -0
  34. clutterxp/gui/api.py +494 -0
  35. clutterxp/gui/app.py +205 -0
  36. clutterxp/gui/models_qt.py +544 -0
  37. clutterxp/gui/views/__init__.py +21 -0
  38. clutterxp/gui/views/dashboard.py +253 -0
  39. clutterxp/gui/views/duplicates.py +404 -0
  40. clutterxp/gui/views/folders.py +354 -0
  41. clutterxp/gui/views/history.py +281 -0
  42. clutterxp/gui/views/preview.py +277 -0
  43. clutterxp/gui/views/rules.py +527 -0
  44. clutterxp/gui/views/settings.py +172 -0
  45. clutterxp/gui/workers.py +147 -0
  46. clutterxp/history/__init__.py +23 -0
  47. clutterxp/history/db.py +174 -0
  48. clutterxp/history/pathcodec.py +42 -0
  49. clutterxp/history/recovery.py +318 -0
  50. clutterxp/history/schema.sql +43 -0
  51. clutterxp/history/service.py +493 -0
  52. clutterxp/history/undo.py +373 -0
  53. clutterxp/interfaces.py +164 -0
  54. clutterxp/locking.py +113 -0
  55. clutterxp/logging_setup.py +73 -0
  56. clutterxp/models.py +266 -0
  57. clutterxp/mover/__init__.py +26 -0
  58. clutterxp/mover/collision.py +46 -0
  59. clutterxp/mover/fsops.py +191 -0
  60. clutterxp/mover/safe_move.py +741 -0
  61. clutterxp/organizer.py +388 -0
  62. clutterxp/packaging/clutterxp.service +15 -0
  63. clutterxp/paths.py +112 -0
  64. clutterxp/planner.py +339 -0
  65. clutterxp/py.typed +0 -0
  66. clutterxp/rules/__init__.py +16 -0
  67. clutterxp/rules/engine.py +374 -0
  68. clutterxp/rules/matcher.py +222 -0
  69. clutterxp/rules/template.py +171 -0
  70. clutterxp/rules/tokenizer.py +118 -0
  71. clutterxp/safety.py +111 -0
  72. clutterxp/scanner.py +196 -0
  73. clutterxp/service_install.py +208 -0
  74. clutterxp/units.py +59 -0
  75. clutterxp/watch/__init__.py +37 -0
  76. clutterxp/watch/debounce.py +84 -0
  77. clutterxp/watch/events.py +259 -0
  78. clutterxp/watch/ignore.py +77 -0
  79. clutterxp/watch/service.py +662 -0
  80. clutterxp/watch/settle.py +170 -0
  81. clutterxp-1.1.0.dist-info/METADATA +149 -0
  82. clutterxp-1.1.0.dist-info/RECORD +84 -0
  83. clutterxp-1.1.0.dist-info/WHEEL +4 -0
  84. clutterxp-1.1.0.dist-info/entry_points.txt +6 -0
clutterxp/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """ClutterXP - configurable, safe, fast file organizer."""
2
+
3
+ __version__ = "1.1.0"
4
+
@@ -0,0 +1,7 @@
1
+ """ClutterXP command-line interface package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .app import app, build_organizer, cxp_main, main
6
+
7
+ __all__ = ["app", "build_organizer", "cxp_main", "main"]
clutterxp/cli/app.py ADDED
@@ -0,0 +1,251 @@
1
+ """Typer root application, global option parsing, dependency wiring, and entry points."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Annotated
11
+
12
+ import typer
13
+
14
+ from clutterxp import logging_setup, paths, safety
15
+ from clutterxp.cli.cmd_config import config_app
16
+ from clutterxp.cli.cmd_doctor import doctor_command
17
+ from clutterxp.cli.cmd_duplicates import duplicates_app
18
+ from clutterxp.cli.cmd_gui import gui_command
19
+ from clutterxp.cli.cmd_history import history_app
20
+ from clutterxp.cli.cmd_init import init_command
21
+ from clutterxp.cli.cmd_organize import organize_command
22
+ from clutterxp.cli.cmd_rules import rules_app
23
+ from clutterxp.cli.cmd_scan import scan_command
24
+ from clutterxp.cli.cmd_service import service_app
25
+ from clutterxp.cli.cmd_undo import undo_command
26
+ from clutterxp.cli.cmd_watch import watch_command
27
+ from clutterxp.cli.errors import handle_errors
28
+ from clutterxp.config import loader
29
+ from clutterxp.history.service import HistoryService
30
+ from clutterxp.history.undo import UndoService
31
+ from clutterxp.models import ClutterError, ConfigError, ErrorCode, SafetyRefusal
32
+ from clutterxp.mover.safe_move import SafeMover
33
+ from clutterxp.organizer import OrganizerImpl
34
+ from clutterxp.rules import build_engine
35
+
36
+ logger = logging.getLogger("clutterxp.cli")
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class GlobalOptions:
41
+ """Parsed global command-line options passed down through Typer context."""
42
+
43
+ config: Path | None
44
+ json_mode: bool
45
+ quiet: bool
46
+ verbose: int
47
+ no_color: bool
48
+ allow_root: bool
49
+
50
+
51
+ def get_globals(ctx: typer.Context) -> GlobalOptions:
52
+ """Retrieve GlobalOptions from Typer context or fallback to defaults."""
53
+ if ctx.obj is not None and isinstance(ctx.obj, GlobalOptions):
54
+ return ctx.obj
55
+ obj = ctx.find_object(GlobalOptions)
56
+ if obj is not None:
57
+ return obj
58
+ return GlobalOptions(
59
+ config=None,
60
+ json_mode=False,
61
+ quiet=False,
62
+ verbose=0,
63
+ no_color=False,
64
+ allow_root=False,
65
+ )
66
+
67
+
68
+ def version_callback(value: bool) -> None:
69
+ """Print version and exit cleanly."""
70
+ if value:
71
+ try:
72
+ from clutterxp import __version__
73
+
74
+ ver = __version__
75
+ except Exception:
76
+ ver = "1.0.0"
77
+ typer.echo(f"clutterxp {ver}")
78
+ raise typer.Exit(code=0)
79
+
80
+
81
+ def build_organizer(config_path: Path | None = None) -> OrganizerImpl:
82
+ """Wire dependencies and build an OrganizerImpl facade."""
83
+ resolved_config_path = config_path if config_path is not None else paths.config_path()
84
+ runtime_cfg = loader.load(resolved_config_path)
85
+
86
+ engine = build_engine(
87
+ list(runtime_cfg.rules),
88
+ runtime_cfg.settings,
89
+ watch=list(runtime_cfg.watch_entries),
90
+ )
91
+ mover = SafeMover(verify_copy=runtime_cfg.settings.verify_copy)
92
+ history = HistoryService(paths.history_db_path())
93
+ undo_svc = UndoService(history, mover, paths.fs_lock_path())
94
+
95
+ return OrganizerImpl(
96
+ runtime_cfg,
97
+ engine,
98
+ mover,
99
+ history,
100
+ undo_service=undo_svc,
101
+ fs_lock_path=paths.fs_lock_path(),
102
+ )
103
+
104
+
105
+ # Root application with completion support
106
+ app = typer.Typer(
107
+ name="clutterxp",
108
+ help="ClutterXP: Configurable, safe, fast file organizer.",
109
+ no_args_is_help=True,
110
+ add_completion=True,
111
+ )
112
+
113
+
114
+ @app.callback(no_args_is_help=True)
115
+ def main_callback(
116
+ ctx: typer.Context,
117
+ config: Annotated[
118
+ Path | None,
119
+ typer.Option(
120
+ "--config",
121
+ "-c",
122
+ envvar="CLUTTERXP_CONFIG",
123
+ help="Path to configuration file",
124
+ ),
125
+ ] = None,
126
+ json_output: Annotated[
127
+ bool,
128
+ typer.Option(
129
+ "--json",
130
+ help="Machine-readable JSON output (schema_version: 1); no colors, no prompts",
131
+ ),
132
+ ] = False,
133
+ quiet: Annotated[
134
+ bool,
135
+ typer.Option(
136
+ "-q",
137
+ "--quiet",
138
+ help="Quiet output (errors and results only)",
139
+ ),
140
+ ] = False,
141
+ verbose: Annotated[
142
+ int,
143
+ typer.Option(
144
+ "-v",
145
+ "--verbose",
146
+ count=True,
147
+ help="Increase logging verbosity (-v for INFO, -vv for DEBUG)",
148
+ ),
149
+ ] = 0,
150
+ no_color: Annotated[
151
+ bool,
152
+ typer.Option(
153
+ "--no-color",
154
+ help="Disable colors (also honors NO_COLOR environment variable)",
155
+ ),
156
+ ] = False,
157
+ allow_root: Annotated[
158
+ bool,
159
+ typer.Option(
160
+ "--allow-root",
161
+ help="Permit running as root user (otherwise refused for safety)",
162
+ ),
163
+ ] = False,
164
+ version: Annotated[
165
+ bool | None,
166
+ typer.Option(
167
+ "--version",
168
+ callback=version_callback,
169
+ is_eager=True,
170
+ help="Show ClutterXP version",
171
+ ),
172
+ ] = None,
173
+ ) -> None:
174
+ """ClutterXP root callback setting up globals, logging, and safety checks."""
175
+ effective_no_color = no_color or bool(os.environ.get("NO_COLOR", "").strip())
176
+ ctx.obj = GlobalOptions(
177
+ config=config,
178
+ json_mode=json_output,
179
+ quiet=quiet,
180
+ verbose=verbose,
181
+ no_color=effective_no_color,
182
+ allow_root=allow_root,
183
+ )
184
+
185
+ # Invariant S10: refuse root execution unless --allow-root
186
+ with handle_errors(json_mode=json_output, command_name="root"):
187
+ safety.refuse_root(allow_root=allow_root)
188
+
189
+ # Setup logging
190
+ log_level = 0
191
+ if quiet:
192
+ log_level = 0
193
+ elif verbose > 0:
194
+ log_level = verbose
195
+ logging_setup.setup_logging(verbosity=log_level)
196
+
197
+
198
+ # Register subcommands and command groups
199
+ app.command("init")(init_command)
200
+ app.add_typer(config_app, name="config")
201
+ app.add_typer(rules_app, name="rules")
202
+ app.command("scan")(scan_command)
203
+ app.command("organize")(organize_command)
204
+ app.command("watch")(watch_command)
205
+ app.add_typer(history_app, name="history")
206
+ app.command("undo")(undo_command)
207
+ app.add_typer(duplicates_app, name="duplicates")
208
+ app.add_typer(service_app, name="service")
209
+ app.command("doctor")(doctor_command)
210
+ app.command("gui")(gui_command)
211
+
212
+
213
+ def main() -> None:
214
+ """CLI entry point for clutterxp binary."""
215
+ try:
216
+ app()
217
+ except SafetyRefusal as e:
218
+ sys.stderr.write(f"error: {e}\n")
219
+ sys.exit(4)
220
+ except ConfigError as e:
221
+ if e.problems:
222
+ for p in e.problems:
223
+ sys.stderr.write(f"error: {p}\n")
224
+ else:
225
+ sys.stderr.write(f"error: {e}\n")
226
+ sys.exit(2)
227
+ except ClutterError as e:
228
+ code = 5 if e.code == ErrorCode.LOCK_TIMEOUT or "lock" in str(e).lower() else 3
229
+ sys.stderr.write(f"error: {e}\n")
230
+ sys.exit(code)
231
+ except SystemExit:
232
+ raise
233
+ except Exception as e:
234
+ logger.exception("Unexpected error")
235
+ sys.stderr.write(f"error: unexpected error: {e}\n")
236
+ sys.exit(1)
237
+
238
+
239
+ def cxp_main() -> None:
240
+ """CLI entry point for cxp binary alias."""
241
+ main()
242
+
243
+
244
+ __all__ = [
245
+ "GlobalOptions",
246
+ "app",
247
+ "build_organizer",
248
+ "cxp_main",
249
+ "get_globals",
250
+ "main",
251
+ ]
@@ -0,0 +1,250 @@
1
+ """Command handlers for `clutterxp config` subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Annotated
13
+
14
+ import typer
15
+
16
+ from clutterxp import paths
17
+ from clutterxp.cli.errors import handle_errors
18
+ from clutterxp.cli.render import get_console, print_json, safe_str
19
+ from clutterxp.config import jsonschema, loader
20
+ from clutterxp.models import ConfigError
21
+
22
+ if TYPE_CHECKING:
23
+ from clutterxp.cli.app import GlobalOptions
24
+
25
+ config_app = typer.Typer(
26
+ help="Manage ClutterXP configuration (path, show, validate, schema, edit, migrate)",
27
+ no_args_is_help=True,
28
+ )
29
+
30
+
31
+ @config_app.command("path")
32
+ def config_path_command(ctx: typer.Context) -> None:
33
+ """Print the resolved path to config.yaml."""
34
+ from clutterxp.cli.app import get_globals
35
+
36
+ opts: GlobalOptions = get_globals(ctx)
37
+ target = opts.config if opts.config is not None else paths.config_path()
38
+
39
+ with handle_errors(json_mode=opts.json_mode, command_name="config_path"):
40
+ if opts.json_mode:
41
+ print_json(
42
+ {
43
+ "schema_version": 1,
44
+ "command": "config_path",
45
+ "path": safe_str(target),
46
+ "exists": target.exists(),
47
+ }
48
+ )
49
+ else:
50
+ console = get_console(no_color=opts.no_color)
51
+ console.print(safe_str(target))
52
+
53
+
54
+ @config_app.command("show")
55
+ def config_show_command(ctx: typer.Context) -> None:
56
+ """Print effective configuration settings with paths expanded."""
57
+ from clutterxp.cli.app import get_globals
58
+
59
+ opts: GlobalOptions = get_globals(ctx)
60
+ target = opts.config if opts.config is not None else paths.config_path()
61
+
62
+ with handle_errors(json_mode=opts.json_mode, command_name="config_show"):
63
+ cfg = loader.load(target)
64
+
65
+ if opts.json_mode:
66
+ print_json(
67
+ {
68
+ "schema_version": 1,
69
+ "command": "config_show",
70
+ "config_path": safe_str(target),
71
+ "version": 1,
72
+ "settings": cfg.settings.model_dump(),
73
+ "watch": [w.model_dump() for w in cfg.watch_entries],
74
+ "rules": [r.model_dump() for r in cfg.all_rules],
75
+ "is_valid": cfg.is_valid,
76
+ "errors": list(cfg.errors),
77
+ "warnings": list(cfg.warnings),
78
+ }
79
+ )
80
+ else:
81
+ console = get_console(no_color=opts.no_color)
82
+ console.print(f"[bold]Configuration file:[/bold] {safe_str(target)}\n")
83
+ console.print(f"[bold]Settings:[/bold] {cfg.settings.model_dump()}\n")
84
+ console.print(f"[bold]Watch Folders ({len(cfg.watch_entries)}):[/bold]")
85
+ for w in cfg.watch_entries:
86
+ console.print(f" - {safe_str(w.path)} (recursive={w.recursive})")
87
+ console.print(f"\n[bold]Rules ({len(cfg.all_rules)}):[/bold]")
88
+ for r in cfg.all_rules:
89
+ status = "enabled" if r.enabled else "disabled"
90
+ dest_str = safe_str(r.destination)
91
+ console.print(
92
+ f" - {safe_str(r.name)} [{status}, priority={r.priority}] -> {dest_str}"
93
+ )
94
+
95
+
96
+ @config_app.command("validate")
97
+ def config_validate_command(
98
+ ctx: typer.Context,
99
+ strict: Annotated[
100
+ bool,
101
+ typer.Option(
102
+ "--strict",
103
+ help="Treat warnings as errors (exit code 3)",
104
+ ),
105
+ ] = False,
106
+ ) -> None:
107
+ """Validate configuration file and print errors/warnings with YAML paths."""
108
+ from clutterxp.cli.app import get_globals
109
+
110
+ opts: GlobalOptions = get_globals(ctx)
111
+ target = opts.config if opts.config is not None else paths.config_path()
112
+
113
+ with handle_errors(json_mode=opts.json_mode, command_name="config_validate"):
114
+ cfg = loader.load(target)
115
+
116
+ is_valid = len(cfg.errors) == 0 and (not strict or len(cfg.warnings) == 0)
117
+
118
+ if opts.json_mode:
119
+ print_json(
120
+ {
121
+ "schema_version": 1,
122
+ "command": "config_validate",
123
+ "config_path": safe_str(target),
124
+ "valid": is_valid,
125
+ "errors": list(cfg.errors),
126
+ "warnings": list(cfg.warnings),
127
+ }
128
+ )
129
+ else:
130
+ console = get_console(no_color=opts.no_color)
131
+ for err in cfg.errors:
132
+ console.print(f"error: {err}")
133
+ for warn in cfg.warnings:
134
+ console.print(f"warning: {warn}")
135
+
136
+ if is_valid and not cfg.errors and not cfg.warnings:
137
+ console.print("Configuration is valid.")
138
+
139
+ if cfg.errors:
140
+ raise typer.Exit(code=2)
141
+ if strict and cfg.warnings:
142
+ raise typer.Exit(code=3)
143
+
144
+
145
+ @config_app.command("schema")
146
+ def config_schema_command(ctx: typer.Context) -> None:
147
+ """Print the JSON Schema for configuration files."""
148
+ from clutterxp.cli.app import get_globals
149
+
150
+ opts: GlobalOptions = get_globals(ctx)
151
+ with handle_errors(json_mode=opts.json_mode, command_name="config_schema"):
152
+ schema = jsonschema.config_json_schema()
153
+ print_json(schema)
154
+
155
+
156
+ @config_app.command("edit")
157
+ def config_edit_command(ctx: typer.Context) -> None:
158
+ """Open $EDITOR on a temp copy, validate on exit, replace atomically if valid."""
159
+ from clutterxp.cli.app import get_globals
160
+
161
+ opts: GlobalOptions = get_globals(ctx)
162
+ target = opts.config if opts.config is not None else paths.config_path()
163
+
164
+ with handle_errors(json_mode=opts.json_mode, command_name="config_edit"):
165
+ if not target.exists():
166
+ sys.stderr.write(f"error: Config file not found at '{target}'. Run `init` first.\n")
167
+ raise typer.Exit(code=2)
168
+
169
+ # Create temporary working copy in same directory
170
+ temp_fd, temp_path_str = tempfile.mkstemp(
171
+ dir=target.parent, prefix=f".{target.name}.", suffix=".edit.tmp"
172
+ )
173
+ temp_path = Path(temp_path_str)
174
+ try:
175
+ with os.fdopen(temp_fd, "wb") as f_out, target.open("rb") as f_in:
176
+ shutil.copyfileobj(f_in, f_out)
177
+
178
+ editor = os.environ.get("EDITOR")
179
+ if not editor:
180
+ editor = "nano" if shutil.which("nano") else "vi"
181
+
182
+ res = subprocess.run([editor, str(temp_path)], check=False) # noqa: S603
183
+ if res.returncode != 0:
184
+ sys.stderr.write(f"error: Editor exited with code {res.returncode}\n")
185
+ raise typer.Exit(code=2)
186
+
187
+ # Validate the edited file
188
+ try:
189
+ cfg = loader.load(temp_path)
190
+ if not cfg.is_valid:
191
+ sys.stderr.write("error: Validation failed for edited configuration:\n")
192
+ for err in cfg.errors:
193
+ sys.stderr.write(f" {err}\n")
194
+ sys.stderr.write("Changes discarded.\n")
195
+ raise typer.Exit(code=2)
196
+ except ConfigError as ce:
197
+ sys.stderr.write(
198
+ f"error: Failed to load edited configuration: {ce}\nChanges discarded.\n"
199
+ )
200
+ raise typer.Exit(code=2) from None
201
+
202
+ # Backup existing and replace atomically
203
+ bak_path = target.with_name(target.name + ".bak")
204
+ shutil.copy2(target, bak_path)
205
+ with contextlib.suppress(OSError):
206
+ bak_path.chmod(0o600)
207
+
208
+ temp_path.chmod(0o600)
209
+ temp_path.replace(target)
210
+
211
+ if opts.json_mode:
212
+ print_json(
213
+ {
214
+ "schema_version": 1,
215
+ "command": "config_edit",
216
+ "status": "updated",
217
+ "path": safe_str(target),
218
+ }
219
+ )
220
+ else:
221
+ console = get_console(no_color=opts.no_color)
222
+ console.print(f"Configuration successfully updated: {safe_str(target)}")
223
+ finally:
224
+ if temp_path.exists():
225
+ with contextlib.suppress(OSError):
226
+ temp_path.unlink()
227
+
228
+
229
+ @config_app.command("migrate")
230
+ def config_migrate_command(ctx: typer.Context) -> None:
231
+ """Migrate configuration file if necessary (v1 is current)."""
232
+ from clutterxp.cli.app import get_globals
233
+
234
+ opts: GlobalOptions = get_globals(ctx)
235
+ with handle_errors(json_mode=opts.json_mode, command_name="config_migrate"):
236
+ if opts.json_mode:
237
+ print_json(
238
+ {
239
+ "schema_version": 1,
240
+ "command": "config_migrate",
241
+ "status": "up_to_date",
242
+ "version": 1,
243
+ }
244
+ )
245
+ else:
246
+ console = get_console(no_color=opts.no_color)
247
+ console.print("Configuration is up to date (version 1).")
248
+
249
+
250
+ __all__ = ["config_app"]