consync 0.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.
consync/watcher.py ADDED
@@ -0,0 +1,176 @@
1
+ """File watcher — auto-syncs when source or target files change.
2
+
3
+ Uses the `watchdog` library for cross-platform file system monitoring.
4
+ No `brew install` required — pure Python.
5
+
6
+ Event handling:
7
+ - Changes during debounce are QUEUED, not dropped
8
+ - After debounce expires, all queued events are coalesced into one sync
9
+ - Lock conflicts trigger automatic retry after a short delay
10
+ - Errors are logged but the watcher continues (resilient)
11
+ - On startup, a full sync is run to recover from any drift
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import threading
18
+ import time
19
+ from pathlib import Path
20
+
21
+ import click
22
+
23
+ from consync.config import load_config
24
+ from consync.sync import sync, SyncResult
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # Max retries when lock is held by another process
29
+ LOCK_RETRY_ATTEMPTS = 3
30
+ LOCK_RETRY_DELAY = 2.0 # seconds between retries
31
+
32
+
33
+ def start_watcher(
34
+ config_path: str | Path | None = None,
35
+ debounce_override: float | None = None,
36
+ ):
37
+ """Start watching all mapped files and auto-sync on changes.
38
+
39
+ Blocks until KeyboardInterrupt.
40
+
41
+ Behaviour:
42
+ - Runs a full sync on startup to catch any drift
43
+ - Queues events during debounce (never drops changes)
44
+ - Retries on lock conflict (up to 3 times)
45
+ - Continues watching after errors (resilient)
46
+ """
47
+ from watchdog.events import FileSystemEventHandler
48
+ from watchdog.observers import Observer
49
+
50
+ cfg = load_config(config_path)
51
+ config_dir = Path(config_path).parent if config_path else Path.cwd()
52
+ debounce = debounce_override if debounce_override is not None else cfg.watch_debounce
53
+
54
+ # Collect all files to watch
55
+ watch_files: set[Path] = set()
56
+ for m in cfg.mappings:
57
+ source_path = (config_dir / m.source).resolve()
58
+ target_path = (config_dir / m.target).resolve()
59
+ watch_files.add(source_path)
60
+ watch_files.add(target_path)
61
+
62
+ # Collect unique parent directories
63
+ watch_dirs: set[Path] = {f.parent for f in watch_files}
64
+
65
+ click.echo(f"👁️ consync watcher started (debounce={debounce}s)")
66
+ click.echo(f" Watching {len(watch_files)} files in {len(watch_dirs)} directory(s)")
67
+ click.echo(f" Press Ctrl+C to stop.\n")
68
+
69
+ for f in sorted(watch_files):
70
+ click.echo(f" • {f.relative_to(config_dir) if f.is_relative_to(config_dir) else f}")
71
+ click.echo("")
72
+
73
+ # ── Startup sync: catch any drift from while watcher was not running ──
74
+ click.echo("[startup] Running full sync to recover any drift...")
75
+ try:
76
+ reports = sync(config_path=config_path)
77
+ for r in reports:
78
+ if r.result in (SyncResult.SYNCED_SOURCE_TO_TARGET, SyncResult.SYNCED_TARGET_TO_SOURCE):
79
+ click.echo(f" ✅ {r.message}")
80
+ elif r.result == SyncResult.ERROR:
81
+ click.echo(f" ❌ {r.message}")
82
+ click.echo("[startup] Done.\n")
83
+ except Exception as e:
84
+ click.echo(f"[startup] ⚠️ Startup sync failed: {e} — continuing in watch mode.\n")
85
+
86
+ # ── Event queue (thread-safe) — changes are QUEUED, never dropped ──
87
+ pending_changes: dict[Path, str] = {} # path → forced direction
88
+ pending_lock = threading.Lock()
89
+ last_sync_time: float = 0
90
+
91
+ class SyncHandler(FileSystemEventHandler):
92
+ def on_modified(self, event):
93
+ if event.is_directory:
94
+ return
95
+
96
+ changed_path = Path(event.src_path).resolve()
97
+ if changed_path not in watch_files:
98
+ return
99
+
100
+ # Determine direction based on which file changed
101
+ force = None
102
+ for m in cfg.mappings:
103
+ source_resolved = (config_dir / m.source).resolve()
104
+ target_resolved = (config_dir / m.target).resolve()
105
+ if changed_path == source_resolved:
106
+ force = "source"
107
+ break
108
+ elif changed_path == target_resolved:
109
+ force = "target"
110
+ break
111
+
112
+ # Queue the event (never drop)
113
+ with pending_lock:
114
+ pending_changes[changed_path] = force
115
+ logger.debug("Queued: %s (direction=%s)", changed_path.name, force)
116
+
117
+ def _process_queue():
118
+ """Process all pending changes in one coalesced sync."""
119
+ nonlocal last_sync_time
120
+
121
+ with pending_lock:
122
+ if not pending_changes:
123
+ return
124
+ # Snapshot and clear
125
+ queued = dict(pending_changes)
126
+ pending_changes.clear()
127
+
128
+ # Determine overall force direction (if all queued point same way)
129
+ directions = set(queued.values())
130
+ if len(directions) == 1:
131
+ force = directions.pop()
132
+ else:
133
+ force = None # mixed — let engine auto-detect
134
+
135
+ rel_names = [p.name for p in queued.keys()]
136
+ timestamp = time.strftime("%H:%M:%S")
137
+ click.echo(f"[{timestamp}] 📝 {', '.join(rel_names)} changed — syncing...")
138
+
139
+ # Retry on lock conflict
140
+ for attempt in range(LOCK_RETRY_ATTEMPTS):
141
+ try:
142
+ reports = sync(config_path=config_path, force_direction=force)
143
+ for r in reports:
144
+ if r.result in (SyncResult.SYNCED_SOURCE_TO_TARGET, SyncResult.SYNCED_TARGET_TO_SOURCE):
145
+ click.echo(f" ✅ {r.message}")
146
+ elif r.result == SyncResult.ALREADY_IN_SYNC:
147
+ pass # silent
148
+ elif r.result == SyncResult.ERROR:
149
+ click.echo(f" ❌ {r.message}")
150
+ last_sync_time = time.time()
151
+ return # success
152
+ except Exception as e:
153
+ if "Another consync process" in str(e) and attempt < LOCK_RETRY_ATTEMPTS - 1:
154
+ click.echo(f" 🔒 Lock conflict — retrying in {LOCK_RETRY_DELAY}s... ({attempt+1}/{LOCK_RETRY_ATTEMPTS})")
155
+ time.sleep(LOCK_RETRY_DELAY)
156
+ else:
157
+ click.echo(f" ❌ Sync failed: {e}")
158
+ logger.error("Watch sync failed: %s", e)
159
+ return
160
+
161
+ observer = Observer()
162
+ handler = SyncHandler()
163
+
164
+ for dir_path in watch_dirs:
165
+ if dir_path.exists():
166
+ observer.schedule(handler, str(dir_path), recursive=False)
167
+
168
+ observer.start()
169
+ try:
170
+ while True:
171
+ time.sleep(debounce)
172
+ # After debounce, process any queued events
173
+ _process_queue()
174
+ finally:
175
+ observer.stop()
176
+ observer.join()