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.
- clutterxp/__init__.py +4 -0
- clutterxp/cli/__init__.py +7 -0
- clutterxp/cli/app.py +251 -0
- clutterxp/cli/cmd_config.py +250 -0
- clutterxp/cli/cmd_doctor.py +449 -0
- clutterxp/cli/cmd_duplicates.py +253 -0
- clutterxp/cli/cmd_gui.py +22 -0
- clutterxp/cli/cmd_history.py +233 -0
- clutterxp/cli/cmd_init.py +76 -0
- clutterxp/cli/cmd_organize.py +226 -0
- clutterxp/cli/cmd_rules.py +540 -0
- clutterxp/cli/cmd_scan.py +109 -0
- clutterxp/cli/cmd_service.py +130 -0
- clutterxp/cli/cmd_undo.py +180 -0
- clutterxp/cli/cmd_watch.py +147 -0
- clutterxp/cli/errors.py +166 -0
- clutterxp/cli/render.py +390 -0
- clutterxp/config/__init__.py +40 -0
- clutterxp/config/jsonschema.py +12 -0
- clutterxp/config/loader.py +212 -0
- clutterxp/config/placeholders.py +79 -0
- clutterxp/config/runtime.py +30 -0
- clutterxp/config/schema.py +166 -0
- clutterxp/config/validate.py +226 -0
- clutterxp/config/writer.py +248 -0
- clutterxp/config.example.yaml +76 -0
- clutterxp/duplicates/__init__.py +22 -0
- clutterxp/duplicates/detector.py +438 -0
- clutterxp/duplicates/hashcache.py +149 -0
- clutterxp/duplicates/namenorm.py +45 -0
- clutterxp/duplicates/quarantine.py +196 -0
- clutterxp/duplicates/report.py +110 -0
- clutterxp/gui/__init__.py +1 -0
- clutterxp/gui/api.py +494 -0
- clutterxp/gui/app.py +205 -0
- clutterxp/gui/models_qt.py +544 -0
- clutterxp/gui/views/__init__.py +21 -0
- clutterxp/gui/views/dashboard.py +253 -0
- clutterxp/gui/views/duplicates.py +404 -0
- clutterxp/gui/views/folders.py +354 -0
- clutterxp/gui/views/history.py +281 -0
- clutterxp/gui/views/preview.py +277 -0
- clutterxp/gui/views/rules.py +527 -0
- clutterxp/gui/views/settings.py +172 -0
- clutterxp/gui/workers.py +147 -0
- clutterxp/history/__init__.py +23 -0
- clutterxp/history/db.py +174 -0
- clutterxp/history/pathcodec.py +42 -0
- clutterxp/history/recovery.py +318 -0
- clutterxp/history/schema.sql +43 -0
- clutterxp/history/service.py +493 -0
- clutterxp/history/undo.py +373 -0
- clutterxp/interfaces.py +164 -0
- clutterxp/locking.py +113 -0
- clutterxp/logging_setup.py +73 -0
- clutterxp/models.py +266 -0
- clutterxp/mover/__init__.py +26 -0
- clutterxp/mover/collision.py +46 -0
- clutterxp/mover/fsops.py +191 -0
- clutterxp/mover/safe_move.py +741 -0
- clutterxp/organizer.py +388 -0
- clutterxp/packaging/clutterxp.service +15 -0
- clutterxp/paths.py +112 -0
- clutterxp/planner.py +339 -0
- clutterxp/py.typed +0 -0
- clutterxp/rules/__init__.py +16 -0
- clutterxp/rules/engine.py +374 -0
- clutterxp/rules/matcher.py +222 -0
- clutterxp/rules/template.py +171 -0
- clutterxp/rules/tokenizer.py +118 -0
- clutterxp/safety.py +111 -0
- clutterxp/scanner.py +196 -0
- clutterxp/service_install.py +208 -0
- clutterxp/units.py +59 -0
- clutterxp/watch/__init__.py +37 -0
- clutterxp/watch/debounce.py +84 -0
- clutterxp/watch/events.py +259 -0
- clutterxp/watch/ignore.py +77 -0
- clutterxp/watch/service.py +662 -0
- clutterxp/watch/settle.py +170 -0
- clutterxp-1.1.0.dist-info/METADATA +149 -0
- clutterxp-1.1.0.dist-info/RECORD +84 -0
- clutterxp-1.1.0.dist-info/WHEEL +4 -0
- 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"]
|