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/__init__.py +9 -0
- consync/backup.py +188 -0
- consync/cli.py +372 -0
- consync/config.py +200 -0
- consync/hooks.py +81 -0
- consync/lock.py +118 -0
- consync/logging_config.py +273 -0
- consync/models.py +104 -0
- consync/parsers/__init__.py +40 -0
- consync/parsers/c_header.py +96 -0
- consync/parsers/csv_parser.py +133 -0
- consync/parsers/json_parser.py +138 -0
- consync/parsers/toml_parser.py +74 -0
- consync/parsers/xlsx.py +116 -0
- consync/precision.py +148 -0
- consync/renderers/__init__.py +49 -0
- consync/renderers/c_header.py +222 -0
- consync/renderers/csharp.py +174 -0
- consync/renderers/csv_renderer.py +46 -0
- consync/renderers/json_renderer.py +71 -0
- consync/renderers/python_const.py +84 -0
- consync/renderers/rust_const.py +90 -0
- consync/renderers/verilog.py +89 -0
- consync/renderers/vhdl.py +94 -0
- consync/state.py +76 -0
- consync/sync.py +458 -0
- consync/validators.py +233 -0
- consync/watcher.py +176 -0
- consync-0.1.0.dist-info/METADATA +590 -0
- consync-0.1.0.dist-info/RECORD +33 -0
- consync-0.1.0.dist-info/WHEEL +4 -0
- consync-0.1.0.dist-info/entry_points.txt +2 -0
- consync-0.1.0.dist-info/licenses/LICENSE +21 -0
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()
|