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
@@ -0,0 +1,662 @@
1
+ """Watch service: filesystem monitoring daemon, event debounce, settle, and move execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import errno
7
+ import logging
8
+ import os
9
+ import queue
10
+ import signal
11
+ import sys
12
+ import threading
13
+ import time
14
+ from collections.abc import Callable
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from watchdog.observers import Observer
20
+ from watchdog.observers.api import BaseObserver
21
+ from watchdog.observers.polling import PollingObserver
22
+
23
+ from ..config import loader
24
+ from ..config.runtime import RuntimeConfig
25
+ from ..config.schema import WatchEntry
26
+ from ..interfaces import Organizer
27
+ from ..locking import watch_lock
28
+ from ..models import (
29
+ ClutterError,
30
+ ErrorCode,
31
+ FileInfo,
32
+ PlanStatus,
33
+ SafetyRefusal,
34
+ )
35
+ from ..paths import watch_lock_path
36
+ from .debounce import Debouncer
37
+ from .events import (
38
+ SelfEventSuppression,
39
+ WatcherEvent,
40
+ WatchEventHandler,
41
+ WatchEventKind,
42
+ )
43
+ from .ignore import BUILTIN_IGNORE, IgnoreMatcher
44
+ from .settle import Settler, SettleStatus
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ class WatchService:
50
+ """Watch service managing observer threads, event queue, debouncer, settle checks,
51
+
52
+ and executing moves via the Organizer.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ config_path: Path,
58
+ organizer: Organizer,
59
+ *,
60
+ poll: bool = False,
61
+ dry_run: bool = False,
62
+ on_event: Callable[[dict[str, Any]], None] | None = None,
63
+ clock: Callable[[], float] | None = None,
64
+ stat_fn: Callable[[Path | str], Any] | None = None,
65
+ proc_dir: Path | None = None,
66
+ watch_lock_file: Path | None = None,
67
+ sample_interval: float | None = None,
68
+ ) -> None:
69
+ self.config_path = config_path
70
+ self.organizer = organizer
71
+ self.poll = poll
72
+ self.dry_run = dry_run
73
+ self.on_event = on_event
74
+
75
+ self._clock = clock if clock is not None else time.monotonic
76
+ self._stat_fn = stat_fn if stat_fn is not None else os.lstat
77
+ self._proc_dir = proc_dir if proc_dir is not None else Path("/proc")
78
+ lock_file = watch_lock_file if watch_lock_file is not None else watch_lock_path()
79
+ self._watch_lock_file = lock_file
80
+
81
+ self._debouncer = Debouncer(self._clock)
82
+ settler_interval = sample_interval if sample_interval is not None else 0.5
83
+ self._settler = Settler(
84
+ self._clock,
85
+ self._stat_fn,
86
+ self._proc_dir,
87
+ sample_interval=settler_interval,
88
+ )
89
+ self._suppression = SelfEventSuppression(self._clock)
90
+ self._event_queue: queue.Queue[WatcherEvent] = queue.Queue()
91
+
92
+ self._stop_event = threading.Event()
93
+ self._reload_requested = threading.Event()
94
+ self._worker_thread: threading.Thread | None = None
95
+ self._lock_cm: Any = None
96
+
97
+ self._observers: list[BaseObserver] = []
98
+ self._watch_entries: dict[Path, WatchEntry] = {}
99
+ self._ignore_matchers: dict[Path, IgnoreMatcher] = {}
100
+ self._path_to_folder: dict[Path, Path] = {}
101
+ self._ambiguous_reported: dict[Path, tuple[int, int]] = {}
102
+
103
+ self._missing_folders: dict[Path, float] = {}
104
+ self._retry_backoff: dict[Path, float] = {}
105
+
106
+ self._config_mtime_ns: int = 0
107
+ self._last_config_check: float = 0.0
108
+
109
+ self.runtime_config: RuntimeConfig = loader.load(self.config_path)
110
+ if not self.runtime_config.is_valid:
111
+ raise SafetyRefusal(
112
+ f"Invalid configuration in {config_path}: {'; '.join(self.runtime_config.errors)}",
113
+ code=ErrorCode.DEST_INVALID,
114
+ )
115
+ self._config_mtime_ns = self.runtime_config.mtime_ns
116
+
117
+ def _create_observer(self) -> BaseObserver:
118
+ """Create a new observer instance (PollingObserver if poll=True else Observer)."""
119
+ if self.poll:
120
+ return PollingObserver()
121
+ obs: BaseObserver = Observer()
122
+ return obs
123
+
124
+ def start(self) -> None:
125
+ """Start watcher service (acquires lock, sets up observers, starts worker thread)."""
126
+ # S14: Acquire single-instance watch lock
127
+ try:
128
+ self._lock_cm = watch_lock(self._watch_lock_file)
129
+ self._lock_cm.__enter__()
130
+ except ClutterError as e:
131
+ raise SafetyRefusal(
132
+ f"Another watcher is already running: {e}",
133
+ code=ErrorCode.LOCK_TIMEOUT,
134
+ ) from e
135
+
136
+ self._stop_event.clear()
137
+ self._reload_requested.clear()
138
+
139
+ # Setup observers and entries
140
+ self._setup_watchers()
141
+
142
+ # Initial scan if configured
143
+ self._run_initial_scans()
144
+
145
+ # Start worker thread
146
+ self._worker_thread = threading.Thread(
147
+ target=self._worker_loop,
148
+ name="clutterxp-watch-worker",
149
+ daemon=True,
150
+ )
151
+ self._worker_thread.start()
152
+
153
+ def stop(self) -> None:
154
+ """Gracefully stop watcher service.
155
+
156
+ Stops observers, completes in-flight moves, and releases the watch lock.
157
+ """
158
+ self._stop_event.set()
159
+
160
+ for obs in self._observers:
161
+ with contextlib.suppress(Exception):
162
+ obs.stop()
163
+
164
+ for obs in self._observers:
165
+ with contextlib.suppress(Exception):
166
+ obs.join(timeout=2.0)
167
+ self._observers.clear()
168
+
169
+ if self._worker_thread is not None and self._worker_thread.is_alive():
170
+ self._worker_thread.join(timeout=5.0)
171
+ self._worker_thread = None
172
+
173
+ if self._lock_cm is not None:
174
+ with contextlib.suppress(Exception):
175
+ self._lock_cm.__exit__(None, None, None)
176
+ self._lock_cm = None
177
+
178
+ def request_reload(self) -> None:
179
+ """Signal service to reload configuration on next worker cycle."""
180
+ self._reload_requested.set()
181
+
182
+ def run(self) -> None:
183
+ """Run service synchronously blocking until SIGINT / SIGTERM or stop()."""
184
+ orig_sigint = None
185
+ orig_sigterm = None
186
+ orig_sighup = None
187
+
188
+ def _sig_handler(signum: int, _frame: Any) -> None:
189
+ if signum in (signal.SIGINT, signal.SIGTERM):
190
+ self.stop()
191
+ elif hasattr(signal, "SIGHUP") and signum == signal.SIGHUP:
192
+ self.request_reload()
193
+
194
+ if threading.current_thread() is threading.main_thread():
195
+ orig_sigint = signal.signal(signal.SIGINT, _sig_handler)
196
+ orig_sigterm = signal.signal(signal.SIGTERM, _sig_handler)
197
+ if hasattr(signal, "SIGHUP"):
198
+ orig_sighup = signal.signal(signal.SIGHUP, _sig_handler)
199
+
200
+ try:
201
+ self.start()
202
+ while not self._stop_event.is_set():
203
+ self._stop_event.wait(timeout=0.5)
204
+ finally:
205
+ self.stop()
206
+ if threading.current_thread() is threading.main_thread():
207
+ if orig_sigint is not None:
208
+ signal.signal(signal.SIGINT, orig_sigint)
209
+ if orig_sigterm is not None:
210
+ signal.signal(signal.SIGTERM, orig_sigterm)
211
+ if orig_sighup is not None and hasattr(signal, "SIGHUP"):
212
+ signal.signal(signal.SIGHUP, orig_sighup)
213
+
214
+ def reload_config(self) -> bool:
215
+ """Reload configuration live. Returns True on success, False on failure."""
216
+ try:
217
+ new_config = loader.load(self.config_path)
218
+ except Exception as e:
219
+ logger.error(
220
+ "Failed to parse config from %s: %s; keeping existing config",
221
+ self.config_path,
222
+ e,
223
+ )
224
+ return False
225
+
226
+ if not new_config.is_valid:
227
+ logger.error(
228
+ "Invalid config during reload (%s); keeping existing config",
229
+ "; ".join(new_config.errors),
230
+ )
231
+ return False
232
+
233
+ try:
234
+ self.organizer.reload_config()
235
+ except Exception as e:
236
+ logger.error("Organizer reload_config failed: %s; keeping existing config", e)
237
+ return False
238
+
239
+ self.runtime_config = new_config
240
+ self._config_mtime_ns = new_config.mtime_ns
241
+ self._setup_watchers()
242
+ logger.info("Configuration reloaded successfully from %s", self.config_path)
243
+ return True
244
+
245
+ def _setup_watchers(self) -> None:
246
+ """Stop existing observers and configure observers for current watch entries."""
247
+ for obs in self._observers:
248
+ with contextlib.suppress(Exception):
249
+ obs.stop()
250
+ obs.join(timeout=1.0)
251
+ self._observers.clear()
252
+ self._watch_entries.clear()
253
+ self._ignore_matchers.clear()
254
+
255
+ for entry in self.runtime_config.watch_entries:
256
+ folder = Path(entry.path)
257
+ self._watch_entries[folder] = entry
258
+
259
+ matcher = IgnoreMatcher(
260
+ builtin=BUILTIN_IGNORE,
261
+ global_extra=self.runtime_config.settings.ignore,
262
+ folder_extra=entry.ignore,
263
+ skip_hidden=self.runtime_config.settings.skip_hidden,
264
+ )
265
+ self._ignore_matchers[folder] = matcher
266
+
267
+ if not folder.exists() or not folder.is_dir():
268
+ now = self._clock()
269
+ self._missing_folders[folder] = now + 30.0
270
+ self._retry_backoff[folder] = 30.0
271
+ logger.warning("Watched folder %s does not exist; will retry in 30s", folder)
272
+ continue
273
+
274
+ settle_sec = (
275
+ entry.settle_seconds
276
+ if entry.settle_seconds is not None
277
+ else self.runtime_config.settings.settle_seconds
278
+ )
279
+
280
+ handler = WatchEventHandler(
281
+ folder=folder,
282
+ queue=self._event_queue,
283
+ ignore_matcher=matcher,
284
+ suppression=self._suppression,
285
+ settle_seconds=settle_sec,
286
+ is_candidate=lambda p: p in self._debouncer,
287
+ )
288
+
289
+ obs = self._create_observer()
290
+ try:
291
+ obs.schedule(handler, str(folder), recursive=entry.recursive)
292
+ obs.start()
293
+ self._observers.append(obs)
294
+ except OSError as e:
295
+ if getattr(e, "errno", None) == errno.ENOSPC:
296
+ raise ClutterError(
297
+ "inotify watch limit reached. Increase fs.inotify.max_user_watches: "
298
+ "sudo sysctl fs.inotify.max_user_watches=524288",
299
+ code=ErrorCode.PERMISSION,
300
+ ) from e
301
+ logger.warning("Failed to watch folder %s: %s", folder, e)
302
+
303
+ def _schedule_single_folder(self, folder: Path, entry: WatchEntry) -> None:
304
+ """Schedule observer for a single recovered folder."""
305
+ matcher = self._ignore_matchers[folder]
306
+ settle_sec = (
307
+ entry.settle_seconds
308
+ if entry.settle_seconds is not None
309
+ else self.runtime_config.settings.settle_seconds
310
+ )
311
+ handler = WatchEventHandler(
312
+ folder=folder,
313
+ queue=self._event_queue,
314
+ ignore_matcher=matcher,
315
+ suppression=self._suppression,
316
+ settle_seconds=settle_sec,
317
+ is_candidate=lambda p: p in self._debouncer,
318
+ )
319
+ obs = self._create_observer()
320
+ try:
321
+ obs.schedule(handler, str(folder), recursive=entry.recursive)
322
+ obs.start()
323
+ self._observers.append(obs)
324
+ except OSError as e:
325
+ if getattr(e, "errno", None) == errno.ENOSPC:
326
+ raise ClutterError(
327
+ "inotify watch limit reached. Increase fs.inotify.max_user_watches: "
328
+ "sudo sysctl fs.inotify.max_user_watches=524288",
329
+ code=ErrorCode.PERMISSION,
330
+ ) from e
331
+ logger.warning("Failed to watch folder %s: %s", folder, e)
332
+
333
+ def _run_initial_scans(self) -> None:
334
+ """Run organizer.plan/apply at startup on folders with initial_scan: true."""
335
+ for entry in self.runtime_config.watch_entries:
336
+ if not entry.initial_scan:
337
+ continue
338
+ folder = Path(entry.path)
339
+ if not folder.exists() or not folder.is_dir():
340
+ continue
341
+
342
+ rules_filter = entry.rules if isinstance(entry.rules, list) else None
343
+ try:
344
+ plan = self.organizer.plan(
345
+ [folder],
346
+ recursive=entry.recursive,
347
+ only_rules=rules_filter,
348
+ )
349
+ if self.dry_run:
350
+ for action in plan.actions:
351
+ if action.status in (PlanStatus.WILL_MOVE, PlanStatus.WILL_MOVE_RENAMED):
352
+ rule_name = action.decision.hit.rule_name if action.decision.hit else ""
353
+ self._emit_event(
354
+ event_type="dry_run",
355
+ src=action.file.path,
356
+ dst=action.dest_path,
357
+ rule=rule_name,
358
+ )
359
+ else:
360
+ batch_res = self.organizer.apply(plan, command="watch")
361
+ for mr in batch_res.results:
362
+ if mr.ok and mr.dst:
363
+ self._suppression.suppress(mr.src, 5.0)
364
+ self._suppression.suppress(mr.dst, 5.0)
365
+ rule_name = mr.message or ""
366
+ self._emit_event(
367
+ event_type="moved",
368
+ src=mr.src,
369
+ dst=mr.dst,
370
+ rule=rule_name,
371
+ )
372
+ except Exception as e:
373
+ logger.error("Initial scan failed on %s: %s", folder, e)
374
+
375
+ def _worker_loop(self) -> None:
376
+ """Main worker thread loop: drain queue, settle due items, invoke organizer."""
377
+ while not self._stop_event.is_set():
378
+ # 1. Drain incoming event queue
379
+ while True:
380
+ try:
381
+ event = self._event_queue.get_nowait()
382
+ except queue.Empty:
383
+ break
384
+ self._handle_event(event)
385
+
386
+ # 2. Check debouncer overflow
387
+ if self._debouncer.overflow:
388
+ self._debouncer.overflow = False
389
+ logger.warning("Debouncer queue overflowed; reconciling watched folders")
390
+ for folder in list(self._watch_entries.keys()):
391
+ self._reconcile_folder(folder)
392
+
393
+ # 3. Process due candidates
394
+ now = self._clock()
395
+ due_paths = self._debouncer.due(now)
396
+ for path in due_paths:
397
+ if self._stop_event.is_set():
398
+ break
399
+ self._process_candidate(path)
400
+
401
+ # 4. Check config mtime or SIGHUP reload
402
+ if self._reload_requested.is_set() or (now - self._last_config_check >= 5.0):
403
+ self._last_config_check = now
404
+ self._reload_requested.clear()
405
+ self._check_and_reload_config()
406
+
407
+ # 5. Check missing folders retry
408
+ self._check_missing_folders(now)
409
+
410
+ # 6. Sleep / wait for next event or deadline
411
+ next_dl = self._debouncer.next_deadline()
412
+ now = self._clock()
413
+ timeout = max(0.01, min(next_dl - now, 0.5)) if next_dl is not None else 0.5
414
+
415
+ try:
416
+ ev = self._event_queue.get(timeout=timeout)
417
+ self._handle_event(ev)
418
+ except queue.Empty:
419
+ pass
420
+
421
+ def _handle_event(self, event: WatcherEvent) -> None:
422
+ """Handle normalized event from observer queue."""
423
+ if event.kind == WatchEventKind.ARM:
424
+ self._path_to_folder[event.path] = event.folder
425
+ self._debouncer.arm(event.path, event.settle_seconds)
426
+ elif event.kind == WatchEventKind.REARM:
427
+ if event.path in self._debouncer:
428
+ self._path_to_folder[event.path] = event.folder
429
+ self._debouncer.arm(event.path, event.settle_seconds)
430
+ elif event.kind == WatchEventKind.SHORTEN:
431
+ if event.path in self._debouncer:
432
+ current_dl = self._debouncer.get_deadline(event.path)
433
+ shortened_delay = min(event.settle_seconds, 1.0)
434
+ shortened_dl = self._clock() + shortened_delay
435
+ if current_dl is None or shortened_dl < current_dl:
436
+ self._debouncer.arm(event.path, shortened_delay)
437
+ elif event.kind == WatchEventKind.CANCEL:
438
+ self._debouncer.cancel(event.path)
439
+ self._settler.clear(event.path)
440
+ self._path_to_folder.pop(event.path, None)
441
+ elif event.kind == WatchEventKind.SCAN:
442
+ self._reconcile_folder(event.folder)
443
+
444
+ def _process_candidate(self, path: Path) -> None:
445
+ """Run settle checks and process file if ready."""
446
+ folder = self._find_folder_for_path(path)
447
+ settle_sec = self._get_settle_seconds_for_folder(folder)
448
+
449
+ status = self._settler.check(path, settle_sec)
450
+
451
+ if status == SettleStatus.NOT_YET:
452
+ # Re-arm with sample_interval delay to check stability
453
+ delay = min(settle_sec, self._settler.sample_interval)
454
+ self._debouncer.arm(path, delay)
455
+ return
456
+
457
+ if status in (SettleStatus.GONE, SettleStatus.DROP):
458
+ self._debouncer.cancel(path)
459
+ self._path_to_folder.pop(path, None)
460
+ return
461
+
462
+ # SettleStatus.READY:
463
+ self._debouncer.cancel(path)
464
+ self._path_to_folder.pop(path, None)
465
+
466
+ if self.dry_run:
467
+ try:
468
+ plan = self.organizer.plan([path], recursive=False)
469
+ for action in plan.actions:
470
+ if action.file.path == path:
471
+ if action.status in (PlanStatus.WILL_MOVE, PlanStatus.WILL_MOVE_RENAMED):
472
+ rule_name = action.decision.hit.rule_name if action.decision.hit else ""
473
+ self._emit_event(
474
+ event_type="dry_run",
475
+ src=path,
476
+ dst=action.dest_path,
477
+ rule=rule_name,
478
+ )
479
+ elif action.status == PlanStatus.AMBIGUOUS:
480
+ self._handle_ambiguous(action.file)
481
+ elif action.status == PlanStatus.BLOCKED:
482
+ self._emit_event(
483
+ event_type="blocked",
484
+ src=path,
485
+ error=action.message or action.code.value,
486
+ )
487
+ break
488
+ except Exception as e:
489
+ logger.error("Dry run plan failed on %s: %s", path, e)
490
+ return
491
+
492
+ # Normal run: invoke organizer.process_file
493
+ try:
494
+ batch_res = self.organizer.process_file(path, folder=folder, command="watch")
495
+ except Exception as e:
496
+ logger.error("process_file failed on %s: %s", path, e)
497
+ self._emit_event(
498
+ event_type="error",
499
+ src=path,
500
+ error=str(e),
501
+ )
502
+ return
503
+
504
+ for mr in batch_res.results:
505
+ if mr.ok and mr.dst:
506
+ self._suppression.suppress(mr.src, 5.0)
507
+ self._suppression.suppress(mr.dst, 5.0)
508
+ rule_name = mr.message or ""
509
+ self._emit_event(
510
+ event_type="moved",
511
+ src=mr.src,
512
+ dst=mr.dst,
513
+ rule=rule_name,
514
+ )
515
+ elif not mr.ok:
516
+ self._emit_event(
517
+ event_type="blocked",
518
+ src=mr.src,
519
+ error=mr.message or mr.code.value,
520
+ )
521
+
522
+ if batch_res.moved == 0 and batch_res.skipped > 0:
523
+ # File was skipped: check if ambiguous
524
+ with contextlib.suppress(OSError, Exception):
525
+ fi = FileInfo.from_path(path)
526
+ plan = self.organizer.plan([path], recursive=False)
527
+ if plan.actions and plan.actions[0].status == PlanStatus.AMBIGUOUS:
528
+ self._handle_ambiguous(fi)
529
+
530
+ def _handle_ambiguous(self, file: FileInfo) -> None:
531
+ """Report ambiguous file once de-duplicated until file changes."""
532
+ key = (file.size, file.mtime_ns)
533
+ if self._ambiguous_reported.get(file.path) == key:
534
+ return
535
+ self._ambiguous_reported[file.path] = key
536
+ logger.warning("Ambiguous match for %s; file untouched", file.path)
537
+ self._emit_event(
538
+ event_type="ambiguous",
539
+ src=file.path,
540
+ error="ambiguous rules match",
541
+ )
542
+
543
+ def _find_folder_for_path(self, path: Path) -> Path | None:
544
+ """Find the configured watched folder containing path."""
545
+ folder = self._path_to_folder.get(path)
546
+ if folder is not None:
547
+ return folder
548
+ for f in self._watch_entries:
549
+ try:
550
+ path.relative_to(f)
551
+ return f
552
+ except ValueError:
553
+ pass
554
+ return None
555
+
556
+ def _get_settle_seconds_for_folder(self, folder: Path | None) -> float:
557
+ """Get effective settle_seconds for folder."""
558
+ if folder is not None and folder in self._watch_entries:
559
+ entry = self._watch_entries[folder]
560
+ if entry.settle_seconds is not None:
561
+ return entry.settle_seconds
562
+ return self.runtime_config.settings.settle_seconds
563
+
564
+ def _reconcile_folder(self, folder: Path) -> None:
565
+ """Trigger reconciliation scan for folder."""
566
+ if not folder.exists() or not folder.is_dir():
567
+ return
568
+ entry = self._watch_entries.get(folder)
569
+ recursive = entry.recursive if entry else False
570
+ rules_filter = entry.rules if (entry and isinstance(entry.rules, list)) else None
571
+ try:
572
+ plan = self.organizer.plan([folder], recursive=recursive, only_rules=rules_filter)
573
+ if not self.dry_run:
574
+ batch_res = self.organizer.apply(plan, command="watch")
575
+ for mr in batch_res.results:
576
+ if mr.ok and mr.dst:
577
+ self._suppression.suppress(mr.src, 5.0)
578
+ self._suppression.suppress(mr.dst, 5.0)
579
+ self._emit_event(
580
+ event_type="moved",
581
+ src=mr.src,
582
+ dst=mr.dst,
583
+ rule=mr.message or "",
584
+ )
585
+ except Exception as e:
586
+ logger.error("Reconciliation scan failed on %s: %s", folder, e)
587
+
588
+ def _check_and_reload_config(self) -> None:
589
+ """Check config file mtime and trigger reload if changed."""
590
+ try:
591
+ current_mtime = self.config_path.stat().st_mtime_ns
592
+ except OSError:
593
+ current_mtime = 0
594
+
595
+ if current_mtime != self._config_mtime_ns:
596
+ self.reload_config()
597
+
598
+ def _check_missing_folders(self, now: float) -> None:
599
+ """Check previously missing folders with exponential backoff up to 5 min."""
600
+ for folder, next_retry in list(self._missing_folders.items()):
601
+ if now < next_retry:
602
+ continue
603
+ if folder.exists() and folder.is_dir():
604
+ logger.info("Watched folder %s has reappeared; starting watcher", folder)
605
+ entry = self._watch_entries.get(folder)
606
+ if entry is not None:
607
+ self._schedule_single_folder(folder, entry)
608
+ del self._missing_folders[folder]
609
+ self._retry_backoff.pop(folder, None)
610
+ else:
611
+ backoff = min(300.0, self._retry_backoff.get(folder, 30.0) * 1.5)
612
+ self._retry_backoff[folder] = backoff
613
+ self._missing_folders[folder] = now + backoff
614
+
615
+ def _emit_event(
616
+ self,
617
+ event_type: str,
618
+ src: Path,
619
+ dst: Path | None = None,
620
+ rule: str = "",
621
+ error: str = "",
622
+ ) -> None:
623
+ """Format human-readable log and deliver structured event to on_event callback."""
624
+ now = datetime.now(timezone.utc)
625
+ time_str = now.strftime("%H:%M:%S")
626
+
627
+ rule_display = f" [{rule}]" if rule else ""
628
+ if event_type == "moved":
629
+ line = f"{time_str} moved {src.name} -> {dst}{rule_display}"
630
+ sys.stdout.write(f"{line}\n")
631
+ sys.stdout.flush()
632
+ elif event_type == "dry_run":
633
+ line = f"{time_str} [dry-run] would move {src.name} -> {dst}{rule_display}"
634
+ sys.stdout.write(f"{line}\n")
635
+ sys.stdout.flush()
636
+ elif event_type == "ambiguous":
637
+ line = f"{time_str} warning: ambiguous rules for {src.name}; file untouched"
638
+ sys.stderr.write(f"{line}\n")
639
+ sys.stderr.flush()
640
+ elif event_type == "blocked":
641
+ line = f"{time_str} warning: blocked {src.name}: {error}"
642
+ sys.stderr.write(f"{line}\n")
643
+ sys.stderr.flush()
644
+
645
+ if self.on_event is not None:
646
+ payload: dict[str, Any] = {
647
+ "schema_version": 1,
648
+ "timestamp": now.isoformat(),
649
+ "event": event_type,
650
+ "src": str(src),
651
+ "dst": str(dst) if dst is not None else None,
652
+ "rule": rule if rule else None,
653
+ }
654
+ if error:
655
+ payload["error"] = error
656
+ try:
657
+ self.on_event(payload)
658
+ except Exception as e:
659
+ logger.error("on_event callback raised error: %s", e)
660
+
661
+
662
+ __all__ = ["WatchService"]