safekeep 0.2.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.
- safekeep/__init__.py +6 -0
- safekeep/cli.py +272 -0
- safekeep/config.py +266 -0
- safekeep/copier.py +333 -0
- safekeep/daemon.py +692 -0
- safekeep/matcher.py +161 -0
- safekeep/volumes.py +28 -0
- safekeep-0.2.0.dist-info/METADATA +180 -0
- safekeep-0.2.0.dist-info/RECORD +13 -0
- safekeep-0.2.0.dist-info/WHEEL +5 -0
- safekeep-0.2.0.dist-info/entry_points.txt +2 -0
- safekeep-0.2.0.dist-info/licenses/LICENSE +21 -0
- safekeep-0.2.0.dist-info/top_level.txt +1 -0
safekeep/daemon.py
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
"""Daemon fswatch: spawn, dispatch eventi, timer, anti-flood e pending
|
|
2
|
+
(SPEC.md §6 flusso evento→sync, §8.3 pending/backoff, §10 anti-flood).
|
|
3
|
+
|
|
4
|
+
Un solo processo, nessun thread: `step()` fa `select` su stdout di fswatch con
|
|
5
|
+
timeout pari alla scadenza più vicina (reconcile giornaliero, retry dest
|
|
6
|
+
absent, respawn fswatch). Le regex `-e` sono un pre-filtro grezzo: la verità
|
|
7
|
+
su cosa copiare resta al matcher Python (SPEC.md §6.4).
|
|
8
|
+
"""
|
|
9
|
+
import logging
|
|
10
|
+
import logging.handlers
|
|
11
|
+
import os
|
|
12
|
+
import select
|
|
13
|
+
import signal
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
from .config import (
|
|
19
|
+
ConfigError,
|
|
20
|
+
dest_path,
|
|
21
|
+
discover_projects,
|
|
22
|
+
parse_config,
|
|
23
|
+
parse_sync,
|
|
24
|
+
source_root_for,
|
|
25
|
+
validate_dests,
|
|
26
|
+
)
|
|
27
|
+
from .copier import MOUNT_ERRNOS, copy_one, needs_copy, prune_project, reconcile_project
|
|
28
|
+
from .matcher import BUILTIN_RULES, Matcher, _glob, parse_rule
|
|
29
|
+
from .volumes import backoff_schedule, dest_state, partition_dests
|
|
30
|
+
|
|
31
|
+
log = logging.getLogger('safekeep')
|
|
32
|
+
|
|
33
|
+
FLOOD_LIMIT = 5000 # path per progetto in un batch → collapse a reconcile (§10.2)
|
|
34
|
+
DEDUP_WINDOW = 2.0 # secondi: stessa path entro la finestra → un solo evento
|
|
35
|
+
RECONCILE_INTERVAL = 24 * 3600
|
|
36
|
+
DEATH_WINDOW = 60 # finestra di conteggio delle morti di fswatch
|
|
37
|
+
MAX_DEATHS = 5 # 5 morti in 60s → esco con codice ≠ 0 (launchd ci riprova)
|
|
38
|
+
RESPAWN_CAP = 60 # backoff respawn 1 → 60s
|
|
39
|
+
|
|
40
|
+
SKIP = 'skip'
|
|
41
|
+
SYNC_FILE = 'sync-file'
|
|
42
|
+
WALK_DIR = 'walk-dir'
|
|
43
|
+
RELOAD_SYNC = 'reload-sync'
|
|
44
|
+
|
|
45
|
+
# SPEC.md §4.1: il file `.sync` viene SEMPRE copiato nel backup (serve per
|
|
46
|
+
# ricostruire la config sul destino) — layer più basso, esplicito, così non
|
|
47
|
+
# dipende dalle regole del progetto (semantica allow-list, §4.3).
|
|
48
|
+
SYNC_RULES = [parse_rule('include: .sync')]
|
|
49
|
+
|
|
50
|
+
# Modalità auto-discovery (SPEC.md §6): pre-filtro `-e` per la `$HOME` — non
|
|
51
|
+
# osserviamo le directory rumorose né i nostri stessi log.
|
|
52
|
+
HOME_EXCLUDES = ('Library', '.Trash', '.cache', '.local/state/safekeep',
|
|
53
|
+
'Desktop', 'Documents', 'Downloads', 'Movies', 'Music',
|
|
54
|
+
'Pictures', 'Public') # TCC: anche gli eventi arrivano filtrati
|
|
55
|
+
HOME_EXCLUDE_REGEXES = ['(^|/)' + _glob(p) + r'(/|$)' for p in HOME_EXCLUDES]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------- funzioni pure
|
|
59
|
+
|
|
60
|
+
def normalize(path):
|
|
61
|
+
"""Path canonico SENZA risolvere il symlink finale: un link sorgente va
|
|
62
|
+
copiato come link (SPEC.md §7.3). Risolve solo i genitori, così il path
|
|
63
|
+
degli eventi FSEvents (`/private/var/...`) e la config (`/var/...`)
|
|
64
|
+
finiscono nello stesso namespace della root del progetto."""
|
|
65
|
+
parent = os.path.realpath(os.path.dirname(path))
|
|
66
|
+
return os.path.join(parent, os.path.basename(path))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Project:
|
|
70
|
+
"""Un progetto seguito: directory con `.sync` (SPEC.md §4.1)."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, root, name, matcher, excludes=None, source_root=None):
|
|
73
|
+
self.root = os.path.realpath(root)
|
|
74
|
+
# radice `source` del config che contiene il progetto: da qui si calcola
|
|
75
|
+
# il path dest in layout `relative` (SPEC.md §4.3), così i progetti non
|
|
76
|
+
# si sovrappongono; senza source nota vale la root del progetto
|
|
77
|
+
self.source_root = os.path.realpath(source_root) if source_root else self.root
|
|
78
|
+
self.name = name
|
|
79
|
+
self.matcher = matcher
|
|
80
|
+
self.excludes = excludes or [] # regole exclude statiche (per fswatch -e)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def sync_path(self):
|
|
84
|
+
return os.path.join(self.root, '.sync')
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_project(root, cfg):
|
|
88
|
+
"""Progetto dalla sua directory con `.sync`; None se il `.sync` manca o è
|
|
89
|
+
illeggibile (un `.sync` è "file altrui": con `log_level: info` un file
|
|
90
|
+
non validi/saltabile avvisa e salta, con `debug` è fatale, CR-03)."""
|
|
91
|
+
sync_path = os.path.join(root, '.sync')
|
|
92
|
+
try:
|
|
93
|
+
with open(sync_path, encoding='utf-8') as fh:
|
|
94
|
+
text = fh.read()
|
|
95
|
+
except OSError:
|
|
96
|
+
return None
|
|
97
|
+
except UnicodeDecodeError as e:
|
|
98
|
+
if cfg.log_level == 'debug':
|
|
99
|
+
raise ConfigError(f'{sync_path}: .sync non UTF-8 valido: {e}') from None
|
|
100
|
+
log.warning('%s: .sync non UTF-8 valido, progetto saltato: %s', sync_path, e)
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
sync = parse_sync(text, log_level=cfg.log_level, origin=sync_path)
|
|
104
|
+
except ConfigError:
|
|
105
|
+
raise # log_level: debug → fatale (SPEC.md §4.2)
|
|
106
|
+
except ValueError as e: # difesa: eccezione di parse non prevista
|
|
107
|
+
if cfg.log_level == 'debug':
|
|
108
|
+
raise ConfigError(f'{sync_path}: {e}') from None
|
|
109
|
+
log.warning('%s: progetto saltato: %s', sync_path, e)
|
|
110
|
+
return None
|
|
111
|
+
layers = [SYNC_RULES, BUILTIN_RULES, cfg.defaults, sync.defaults, sync.rules]
|
|
112
|
+
excludes = [r for layer in layers for r in layer if not r.include]
|
|
113
|
+
name = sync.name or os.path.basename(os.path.normpath(root))
|
|
114
|
+
# auto-discovery (nessuna `source`): la radice da cui calcolare il path
|
|
115
|
+
# dest è $HOME, così due progetti sotto la home non si sovrappongono
|
|
116
|
+
# (SPEC.md §3 layout `relative`)
|
|
117
|
+
roots = cfg.sources or [os.path.expanduser('~')]
|
|
118
|
+
return Project(root, name, Matcher(*layers), excludes,
|
|
119
|
+
source_root=source_root_for(roots, root))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def parse_events(data, residual=b''):
|
|
123
|
+
"""Path NUL-delimitati (fswatch -0): (paths, residuo).
|
|
124
|
+
|
|
125
|
+
Un chunk che si ferma a metà di un path lascia i byte residui per il
|
|
126
|
+
prossimo wake del loop — path con spazi/newline/unicode sicuri.
|
|
127
|
+
"""
|
|
128
|
+
*complete, residuo = (residual + data).split(b'\0')
|
|
129
|
+
return [os.fsdecode(p) for p in complete if p], residuo
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def dedup(state, paths, now=None, window=DEDUP_WINDOW):
|
|
133
|
+
"""Finestra di dedup: `state` è un dict path → (ultimo arrivo, scadenza)
|
|
134
|
+
aggiornato in place. Ritorna i path non visti negli ultimi `window` secondi.
|
|
135
|
+
|
|
136
|
+
Una soppressione NON perde l'evento (CR-04): la prima soppressione dopo un
|
|
137
|
+
emissione programma una scadenza fissa `emissione + window` che gli arrivi
|
|
138
|
+
successivi NON estendono — alla scadenza `dedup_flush` emette il path
|
|
139
|
+
comunque (trailing edge). Le entry scadute e senza scadenza pendente
|
|
140
|
+
vengono potate.
|
|
141
|
+
"""
|
|
142
|
+
now = time.monotonic() if now is None else now
|
|
143
|
+
out = []
|
|
144
|
+
for path in paths:
|
|
145
|
+
entry = state.get(path)
|
|
146
|
+
last = entry[0] if entry else None
|
|
147
|
+
if last is None or now - last >= window:
|
|
148
|
+
out.append(path)
|
|
149
|
+
state[path] = (now, None) # emesso all'arrivo: niente scadenza
|
|
150
|
+
else:
|
|
151
|
+
# soppresso: la scadenza è quella dell'emissione precedente (ferma)
|
|
152
|
+
deadline = entry[1] if entry[1] is not None else last + window
|
|
153
|
+
state[path] = (now, deadline)
|
|
154
|
+
for path, (last, deadline) in list(state.items()):
|
|
155
|
+
# con una scadenza pendente l'entry resta: l'emissione va ancora fatta
|
|
156
|
+
if now - last >= window and deadline is None:
|
|
157
|
+
del state[path]
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def dedup_flush(state, now=None):
|
|
162
|
+
"""Path con scadenza passata: vengono emessi comunque (CR-04).
|
|
163
|
+
|
|
164
|
+
Chiamato a ogni wake del loop: la scadenza entra nel `timeout` del `select`
|
|
165
|
+
(vedi `Daemon.next_deadline`), quindi senza nuovi batch l'ultima modifica di
|
|
166
|
+
un path viene comunque processata entro `window` secondi — niente thread.
|
|
167
|
+
"""
|
|
168
|
+
now = time.monotonic() if now is None else now
|
|
169
|
+
due = [p for p, (_, deadline) in state.items()
|
|
170
|
+
if deadline is not None and now >= deadline]
|
|
171
|
+
for path in due:
|
|
172
|
+
state[path] = (state[path][0], None) # emesso: niente nuova scadenza
|
|
173
|
+
return due
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def find_project(path, projects):
|
|
177
|
+
"""Il progetto la cui root è la più profonda che contiene `path`; None se fuori."""
|
|
178
|
+
path = normalize(path)
|
|
179
|
+
best, best_len = None, -1
|
|
180
|
+
for project in projects:
|
|
181
|
+
root = project.root
|
|
182
|
+
if (path == root or path.startswith(root + os.sep)) and len(root) > best_len:
|
|
183
|
+
best, best_len = project, len(root)
|
|
184
|
+
return best
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def dedup_roots(roots):
|
|
188
|
+
"""Radici watch: quelle annidate sotto un'altra già coperta vengono saltate.
|
|
189
|
+
|
|
190
|
+
`fswatch -r` copre già il sottoalbero → niente doppioni in argv e niente
|
|
191
|
+
progetti scoperti due volte da radici sovrapposte. Ritorna i path come
|
|
192
|
+
sono scritti nel config (l'ordinamento è per profondità, il più corto
|
|
193
|
+
prima).
|
|
194
|
+
"""
|
|
195
|
+
pairs = sorted(((os.path.abspath(os.path.expanduser(r)), r) for r in roots),
|
|
196
|
+
key=lambda p: len(p[0]))
|
|
197
|
+
out, covered = [], []
|
|
198
|
+
for norm, orig in pairs:
|
|
199
|
+
if any(norm == c or norm.startswith(c + os.sep) for c in covered):
|
|
200
|
+
continue
|
|
201
|
+
out.append(orig)
|
|
202
|
+
covered.append(norm)
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def exclude_regexes(rules):
|
|
207
|
+
"""Pre-filtro per `fswatch -e` (SPEC.md §6.4): una regex per regola
|
|
208
|
+
`exclude:` non ancorata, che matcha un segmento intero del path.
|
|
209
|
+
|
|
210
|
+
Solo i casi traducibili senza la root: pattern ancorati (`/foo`, `docs/a`)
|
|
211
|
+
e con `**` vengono omessi. Sovrafiltrare perderebbe eventi, sotto-filtrare
|
|
212
|
+
no (la verità resta il matcher Python). Compatibile POSIX ERE.
|
|
213
|
+
"""
|
|
214
|
+
out, seen = [], set()
|
|
215
|
+
for rule in rules:
|
|
216
|
+
if rule.include or rule.anchored or '**' in rule.pattern or rule.pattern in seen:
|
|
217
|
+
continue
|
|
218
|
+
seen.add(rule.pattern)
|
|
219
|
+
body = _glob(rule.pattern[:-1] if rule.dir_only else rule.pattern)
|
|
220
|
+
# foo/ → il segmento deve essere seguito da / (un FILE chiamato foo
|
|
221
|
+
# resta visibile al matcher); altro → segmento finale del path
|
|
222
|
+
out.append('(^|/)' + body + ('/' if rule.dir_only else '(/|$)'))
|
|
223
|
+
return out
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def classify(path, project):
|
|
227
|
+
"""Azione per un path arrivato da fswatch (SPEC.md §6.5)."""
|
|
228
|
+
if project is None:
|
|
229
|
+
return SKIP
|
|
230
|
+
if os.path.basename(path) == '.sync':
|
|
231
|
+
return RELOAD_SYNC # regole cambiate o rimosse → reload + reconcile
|
|
232
|
+
if not os.path.lexists(path):
|
|
233
|
+
return SKIP # sparito → policy mai-cancellare
|
|
234
|
+
rel = os.path.relpath(normalize(path), project.root)
|
|
235
|
+
if rel == os.pardir or rel.startswith(os.pardir + os.sep):
|
|
236
|
+
return SKIP # fuori dal progetto (path via symlink)
|
|
237
|
+
if os.path.isdir(path) and not os.path.islink(path):
|
|
238
|
+
return WALK_DIR if project.matcher.evaluate(rel, True) else SKIP
|
|
239
|
+
return SYNC_FILE if project.matcher.evaluate(rel, False) else SKIP
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def plan_batch(paths, projects):
|
|
243
|
+
"""→ (reload_paths, {progetto: 'reconcile' | [(path, azione)]}).
|
|
244
|
+
|
|
245
|
+
Più di FLOOD_LIMIT path distinti per progetto in un batch → la coda
|
|
246
|
+
incrementale viene buttata a favore di un reconcile completo (§10.2).
|
|
247
|
+
"""
|
|
248
|
+
reloads, per = [], {}
|
|
249
|
+
for path in paths:
|
|
250
|
+
path = normalize(path)
|
|
251
|
+
project = find_project(path, projects)
|
|
252
|
+
if project is not None:
|
|
253
|
+
per.setdefault(project, []).append(path)
|
|
254
|
+
elif os.path.basename(path) == '.sync':
|
|
255
|
+
reloads.append(path) # `.sync` di un progetto non ancora scoperto
|
|
256
|
+
plan = {}
|
|
257
|
+
for project, plist in per.items():
|
|
258
|
+
plan[project] = ('reconcile' if len(plist) > FLOOD_LIMIT
|
|
259
|
+
else [(p, classify(p, project)) for p in plist])
|
|
260
|
+
return reloads, plan
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def setup_logging(level='info', logfile=None):
|
|
264
|
+
"""Log su file rotante (5 × 5 MB) + stderr (SPEC.md §3). Idempotente."""
|
|
265
|
+
logger = logging.getLogger('safekeep')
|
|
266
|
+
logger.setLevel(getattr(logging, (level or 'info').upper(), logging.INFO))
|
|
267
|
+
if getattr(logger, '_safekeep_handlers', False):
|
|
268
|
+
return
|
|
269
|
+
logger._safekeep_handlers = True
|
|
270
|
+
fmt = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
|
|
271
|
+
if logfile is None:
|
|
272
|
+
logfile = os.path.expanduser('~/.local/state/safekeep/safekeep.log')
|
|
273
|
+
try:
|
|
274
|
+
os.makedirs(os.path.dirname(logfile), exist_ok=True)
|
|
275
|
+
handler = logging.handlers.RotatingFileHandler(
|
|
276
|
+
logfile, maxBytes=5 * 1024 * 1024, backupCount=5)
|
|
277
|
+
except OSError as e:
|
|
278
|
+
log.warning('log su file non disponibile (%s): %s', logfile, e)
|
|
279
|
+
else:
|
|
280
|
+
handler.setFormatter(fmt)
|
|
281
|
+
logger.addHandler(handler)
|
|
282
|
+
stream = logging.StreamHandler(sys.stderr)
|
|
283
|
+
stream.setFormatter(fmt)
|
|
284
|
+
logger.addHandler(stream)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# --------------------------------------------------------------------- daemon
|
|
288
|
+
|
|
289
|
+
class Daemon:
|
|
290
|
+
def __init__(self, config_path=None, argv=None, clock=time.monotonic):
|
|
291
|
+
self.config_path = os.path.expanduser(config_path or '~/.safekeep')
|
|
292
|
+
self.argv = argv # override per i test (fswatch finto)
|
|
293
|
+
self.clock = clock
|
|
294
|
+
self.cfg = None
|
|
295
|
+
self.projects = []
|
|
296
|
+
self.proc = None
|
|
297
|
+
self.buf = b'' # residuo del parser a metà path
|
|
298
|
+
self.seen = {} # dedup: path → ultimo visto
|
|
299
|
+
self.pending = {} # dest → next/sched/projects (backoff)
|
|
300
|
+
self.deaths = [] # timestamp delle morti di fswatch
|
|
301
|
+
self.fatal = False
|
|
302
|
+
self.spawn_at = 0.0
|
|
303
|
+
self.spawn_delay = 1
|
|
304
|
+
self.last_spawn = 0.0
|
|
305
|
+
self.next_reconcile = clock() + RECONCILE_INTERVAL
|
|
306
|
+
|
|
307
|
+
# --- setup -----------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
def load_config(self):
|
|
310
|
+
try:
|
|
311
|
+
with open(self.config_path, encoding='utf-8') as fh:
|
|
312
|
+
text = fh.read()
|
|
313
|
+
except OSError as e:
|
|
314
|
+
raise ConfigError(f'{self.config_path}: {e.strerror or e}') from None
|
|
315
|
+
except UnicodeDecodeError as e:
|
|
316
|
+
# il config globale è comunque un file letto da disco: fallisce pulito (CR-03)
|
|
317
|
+
raise ConfigError(f'{self.config_path}: non è UTF-8 valido: {e}') from None
|
|
318
|
+
self.cfg = parse_config(text, origin=self.config_path)
|
|
319
|
+
return self.cfg
|
|
320
|
+
|
|
321
|
+
def validate_dests(self):
|
|
322
|
+
"""CR-02: dest dentro la sorgente → ConfigError, subito (SPEC.md §11/§12).
|
|
323
|
+
|
|
324
|
+
Base = `source` se presenti, altrimenti `$HOME` (modalità
|
|
325
|
+
auto-discovery): così viene bloccata anche una dest dentro la `$HOME`,
|
|
326
|
+
dove finirebbero comunque le copie dei progetti scoperti lì.
|
|
327
|
+
"""
|
|
328
|
+
validate_dests(self.cfg.sources or [os.path.expanduser('~')], self.cfg.dests)
|
|
329
|
+
|
|
330
|
+
def discovery_roots(self):
|
|
331
|
+
"""`(roots, auto)`: modalità source → le `source` (deduplicate);
|
|
332
|
+
auto-discovery → `$HOME` con pruning dedicato (SPEC.md §6)."""
|
|
333
|
+
if self.cfg.sources:
|
|
334
|
+
return dedup_roots(self.cfg.sources), False
|
|
335
|
+
return [os.path.expanduser('~')], True
|
|
336
|
+
|
|
337
|
+
def load_projects(self):
|
|
338
|
+
self.projects = []
|
|
339
|
+
roots, auto = self.discovery_roots()
|
|
340
|
+
for root in discover_projects(roots, auto=auto):
|
|
341
|
+
project = load_project(root, self.cfg)
|
|
342
|
+
if project is not None:
|
|
343
|
+
self.projects.append(project)
|
|
344
|
+
return self.projects
|
|
345
|
+
|
|
346
|
+
def reconcile_all(self):
|
|
347
|
+
total = sum(self.reconcile(p) for p in self.projects)
|
|
348
|
+
log.info('reconcile: %d copie su %d progetto/i', total, len(self.projects))
|
|
349
|
+
return total
|
|
350
|
+
|
|
351
|
+
def reconcile(self, project):
|
|
352
|
+
"""Walk + copie su ogni dest pronta (§10.1); dest assente o con errore
|
|
353
|
+
di mount → pending con backoff (§8.3, CR-05)."""
|
|
354
|
+
ready, absent = partition_dests(self.cfg.dests)
|
|
355
|
+
for dest in absent:
|
|
356
|
+
self.mark_pending(project, dest)
|
|
357
|
+
copied = 0
|
|
358
|
+
for dest in ready:
|
|
359
|
+
stats = {}
|
|
360
|
+
try:
|
|
361
|
+
copied += reconcile_project(project.root, dest, project.matcher,
|
|
362
|
+
self.cfg.layout, dest_path,
|
|
363
|
+
stats=stats,
|
|
364
|
+
dest_base=project.source_root)
|
|
365
|
+
except OSError as e:
|
|
366
|
+
log.warning('reconcile %s → %s fallito: %s', project.name, dest, e)
|
|
367
|
+
self.mark_pending(project, dest)
|
|
368
|
+
continue
|
|
369
|
+
if stats.get('dest_pendente'):
|
|
370
|
+
# ENODEV/EBUSY/ENOSPC: questa dest va in pending, le altre
|
|
371
|
+
# proseguono (il detail è già stato loggato dal copier)
|
|
372
|
+
self.mark_pending(project, dest)
|
|
373
|
+
return copied
|
|
374
|
+
|
|
375
|
+
def sync_once(self, dry_run=False, only=None, prune=False):
|
|
376
|
+
"""Passaggio singolo per la CLI: [(project, stats, dest_assenti)].
|
|
377
|
+
`prune=True` rimuove dalla dest i file non più inclusi con sorgente
|
|
378
|
+
ancora presente (`--prune`, SPEC.md §9)."""
|
|
379
|
+
only_abs = os.path.realpath(os.path.expanduser(only)) if only else None
|
|
380
|
+
rows = []
|
|
381
|
+
for project in self.projects:
|
|
382
|
+
if only and project.root != only_abs and project.name != only:
|
|
383
|
+
continue
|
|
384
|
+
ready, absent = partition_dests(self.cfg.dests)
|
|
385
|
+
stats = {}
|
|
386
|
+
for dest in ready:
|
|
387
|
+
try:
|
|
388
|
+
reconcile_project(project.root, dest, project.matcher,
|
|
389
|
+
self.cfg.layout, dest_path,
|
|
390
|
+
stats=stats, dry_run=dry_run,
|
|
391
|
+
dest_base=project.source_root)
|
|
392
|
+
except OSError as e:
|
|
393
|
+
log.warning('reconcile %s → %s fallito: %s', project.name, dest, e)
|
|
394
|
+
if prune:
|
|
395
|
+
stats['rimossi'] = stats.get('rimossi', 0) + prune_project(
|
|
396
|
+
project.root, dest, project.matcher, self.cfg.layout,
|
|
397
|
+
dest_path, dest_base=project.source_root, dry_run=dry_run)
|
|
398
|
+
rows.append((project, stats, len(absent)))
|
|
399
|
+
return rows
|
|
400
|
+
|
|
401
|
+
def mark_pending(self, project, dest):
|
|
402
|
+
entry = self.pending.get(dest)
|
|
403
|
+
if entry is None:
|
|
404
|
+
sched = backoff_schedule()
|
|
405
|
+
entry = {'next': self.clock() + next(sched), 'sched': sched, 'projects': set()}
|
|
406
|
+
self.pending[dest] = entry
|
|
407
|
+
entry['projects'].add(project)
|
|
408
|
+
|
|
409
|
+
# --- fswatch ---------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
def fswatch_argv(self):
|
|
412
|
+
"""`fswatch -0 -m fsevents_monitor -r -l 1.0 -e <regex> -- <roots>` (§6.4)."""
|
|
413
|
+
argv = ['fswatch', '-0', '-m', 'fsevents_monitor', '-r', '-l', '1.0']
|
|
414
|
+
for regex in self.exclude_regexes():
|
|
415
|
+
argv += ['-e', regex]
|
|
416
|
+
return argv + ['--'] + self.discovery_roots()[0]
|
|
417
|
+
|
|
418
|
+
def exclude_regexes(self):
|
|
419
|
+
rules = [r for p in self.projects for r in p.excludes]
|
|
420
|
+
regexes = exclude_regexes(rules)
|
|
421
|
+
if not self.cfg.sources:
|
|
422
|
+
regexes += HOME_EXCLUDE_REGEXES # auto-discovery: si guarda la $HOME
|
|
423
|
+
return regexes
|
|
424
|
+
|
|
425
|
+
def spawn(self):
|
|
426
|
+
argv = self.argv or self.fswatch_argv()
|
|
427
|
+
log.info('avvio: %s', ' '.join(argv))
|
|
428
|
+
try:
|
|
429
|
+
self.proc = subprocess.Popen(argv, stdout=subprocess.PIPE)
|
|
430
|
+
except OSError as e:
|
|
431
|
+
log.error('impossibile avviare fswatch: %s', e)
|
|
432
|
+
self.fatal = True
|
|
433
|
+
return False
|
|
434
|
+
os.set_blocking(self.proc.stdout.fileno(), False)
|
|
435
|
+
self.buf = b''
|
|
436
|
+
self.last_spawn = self.clock()
|
|
437
|
+
return True
|
|
438
|
+
|
|
439
|
+
def close_proc(self):
|
|
440
|
+
proc, self.proc = self.proc, None
|
|
441
|
+
if proc is None:
|
|
442
|
+
return
|
|
443
|
+
try:
|
|
444
|
+
proc.terminate()
|
|
445
|
+
proc.wait(timeout=3)
|
|
446
|
+
except Exception:
|
|
447
|
+
try:
|
|
448
|
+
proc.kill()
|
|
449
|
+
proc.wait(timeout=3)
|
|
450
|
+
except Exception:
|
|
451
|
+
pass
|
|
452
|
+
try:
|
|
453
|
+
proc.stdout.close()
|
|
454
|
+
except OSError:
|
|
455
|
+
pass
|
|
456
|
+
|
|
457
|
+
def on_death(self):
|
|
458
|
+
"""EOF/uscita di fswatch: respawn con backoff 1→60s; 5 morti in 60s → fatal."""
|
|
459
|
+
now = self.clock()
|
|
460
|
+
self.close_proc()
|
|
461
|
+
self.deaths = [t for t in self.deaths if now - t < DEATH_WINDOW]
|
|
462
|
+
self.deaths.append(now)
|
|
463
|
+
if len(self.deaths) >= MAX_DEATHS:
|
|
464
|
+
log.error('fswatch morto %d volte in %ds: esco con codice != 0 '
|
|
465
|
+
'(launchd con KeepAlive ci riproverà)',
|
|
466
|
+
len(self.deaths), DEATH_WINDOW)
|
|
467
|
+
self.fatal = True
|
|
468
|
+
return
|
|
469
|
+
if now - self.last_spawn >= DEATH_WINDOW:
|
|
470
|
+
self.spawn_delay = 1 # era stabile: ricomincia da 1s
|
|
471
|
+
self.spawn_at = now + self.spawn_delay
|
|
472
|
+
log.warning('fswatch è uscito: riavvio tra %ds', self.spawn_delay)
|
|
473
|
+
self.spawn_delay = min(self.spawn_delay * 2, RESPAWN_CAP)
|
|
474
|
+
|
|
475
|
+
def _read_available(self):
|
|
476
|
+
"""Tutto ciò che è disponibile sullo stdout, senza bloccare: (data, eof)."""
|
|
477
|
+
data = b''
|
|
478
|
+
while True:
|
|
479
|
+
try:
|
|
480
|
+
chunk = os.read(self.proc.stdout.fileno(), 65536)
|
|
481
|
+
except (BlockingIOError, InterruptedError):
|
|
482
|
+
break
|
|
483
|
+
if not chunk:
|
|
484
|
+
return data, True # fswatch morto
|
|
485
|
+
data += chunk
|
|
486
|
+
return data, False
|
|
487
|
+
|
|
488
|
+
# --- loop ------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
def next_deadline(self):
|
|
491
|
+
"""Secondi fino alla scadenza più vicina (reconcile 24h, retry dest,
|
|
492
|
+
respawn, scadenza dedup — CR-04: è ciò che fa svegliare il `select`
|
|
493
|
+
per emettere un path soppresso)."""
|
|
494
|
+
now = self.clock()
|
|
495
|
+
deadlines = [self.next_reconcile] + [e['next'] for e in self.pending.values()]
|
|
496
|
+
deadlines += [d for _, d in self.seen.values() if d is not None]
|
|
497
|
+
if self.proc is None:
|
|
498
|
+
deadlines.append(self.spawn_at)
|
|
499
|
+
return max(0.0, min(deadlines) - now)
|
|
500
|
+
|
|
501
|
+
def check_timers(self):
|
|
502
|
+
"""Reconcile giornaliero + retry delle dest absent con backoff (§8.3).
|
|
503
|
+
|
|
504
|
+
Il ciclo 24h fa anche il **rescan di discovery**: un `.sync` creato
|
|
505
|
+
mentre il daemon era spento (evento perso) viene agganciato qui (§6).
|
|
506
|
+
"""
|
|
507
|
+
now = self.clock()
|
|
508
|
+
if now >= self.next_reconcile:
|
|
509
|
+
self.next_reconcile = now + RECONCILE_INTERVAL
|
|
510
|
+
self.load_projects()
|
|
511
|
+
self.reconcile_all()
|
|
512
|
+
for dest in [d for d, e in self.pending.items() if now >= e['next']]:
|
|
513
|
+
entry = self.pending[dest]
|
|
514
|
+
if dest_state(dest) != 'ok':
|
|
515
|
+
entry['next'] = now + next(entry['sched'])
|
|
516
|
+
continue
|
|
517
|
+
log.info('dest %s di nuovo montata: reconcile di %d progetto/i',
|
|
518
|
+
dest, len(entry['projects']))
|
|
519
|
+
del self.pending[dest]
|
|
520
|
+
for project in entry['projects']:
|
|
521
|
+
self.reconcile(project)
|
|
522
|
+
if dest in self.pending:
|
|
523
|
+
# il mount c'è ma la copia è fallita di nuovo (ENODEV/EBUSY/ENOSPC):
|
|
524
|
+
# il backoff riprende da dove era — niente retry-loop a 1Hz (CR-05)
|
|
525
|
+
failed = self.pending[dest]
|
|
526
|
+
failed['sched'] = entry['sched']
|
|
527
|
+
failed['next'] = self.clock() + next(entry['sched'])
|
|
528
|
+
|
|
529
|
+
def step(self, timeout=None):
|
|
530
|
+
"""Un passo del loop: timer/pending, scadenze dedup, respawn, select
|
|
531
|
+
su fswatch, batch."""
|
|
532
|
+
self.check_timers()
|
|
533
|
+
self.flush_dedup() # CR-04: emissione "di coda" alla scadenza
|
|
534
|
+
if self.proc is None:
|
|
535
|
+
wait = self.next_deadline() # include spawn_at + timer
|
|
536
|
+
if wait > 0:
|
|
537
|
+
time.sleep(wait if timeout is None else min(wait, timeout))
|
|
538
|
+
if self.clock() < self.spawn_at:
|
|
539
|
+
return # backoff respawn non scaduto
|
|
540
|
+
if not self.spawn():
|
|
541
|
+
return # fswatch mancante → fatal
|
|
542
|
+
if timeout is None:
|
|
543
|
+
timeout = self.next_deadline()
|
|
544
|
+
ready, _, _ = select.select(
|
|
545
|
+
[self.proc.stdout.fileno()], [], [], max(0.0, timeout))
|
|
546
|
+
if not ready:
|
|
547
|
+
return
|
|
548
|
+
data, eof = self._read_available()
|
|
549
|
+
if data:
|
|
550
|
+
paths, self.buf = parse_events(data, self.buf)
|
|
551
|
+
if paths:
|
|
552
|
+
self.run_batch(paths)
|
|
553
|
+
if eof:
|
|
554
|
+
self.on_death()
|
|
555
|
+
|
|
556
|
+
def flush_dedup(self):
|
|
557
|
+
"""Emissione alla scadenza del dedup (CR-04): i path soppressi con
|
|
558
|
+
`deadline` passata vengono processati anche senza nuovi batch."""
|
|
559
|
+
due = dedup_flush(self.seen, now=self.clock())
|
|
560
|
+
if due:
|
|
561
|
+
log.debug('%d path emessi alla scadenza della finestra di dedup', len(due))
|
|
562
|
+
self.dispatch(due)
|
|
563
|
+
|
|
564
|
+
def run_batch(self, paths):
|
|
565
|
+
paths = dedup(self.seen, paths, now=self.clock())
|
|
566
|
+
if paths:
|
|
567
|
+
self.dispatch(paths)
|
|
568
|
+
|
|
569
|
+
def dispatch(self, paths):
|
|
570
|
+
reloads, plan = plan_batch(paths, self.projects)
|
|
571
|
+
for path in reloads:
|
|
572
|
+
self.reload(path)
|
|
573
|
+
for project, actions in plan.items():
|
|
574
|
+
if actions == 'reconcile':
|
|
575
|
+
log.warning('%s: oltre %d path nel batch: collapse a reconcile',
|
|
576
|
+
project.name, FLOOD_LIMIT)
|
|
577
|
+
self.reconcile(project)
|
|
578
|
+
continue
|
|
579
|
+
for path, action in actions:
|
|
580
|
+
if action == SYNC_FILE:
|
|
581
|
+
self._copy(path, project, is_link_dir=os.path.islink(path))
|
|
582
|
+
elif action == WALK_DIR:
|
|
583
|
+
self.walk_dir(path, project)
|
|
584
|
+
elif action == RELOAD_SYNC:
|
|
585
|
+
self.reload(path)
|
|
586
|
+
|
|
587
|
+
def reload(self, sync_path):
|
|
588
|
+
"""Un `.sync` è cambiato: ricarica i progetti e reconcilia quello
|
|
589
|
+
interessato (le regole sono cambiate). Gli eventi di questo batch
|
|
590
|
+
potrebbero usare matcher vecchi: il reconcile subito dopo convergere.
|
|
591
|
+
|
|
592
|
+
Il rescan completo copre anche il `.sync` di un progetto **nuovo** (un
|
|
593
|
+
evento su `<dir>/.sync` fuori da ogni progetto noto → discovery di quel
|
|
594
|
+
progetto + reconcile): così l'auto-discovery aggancia un `.sync` creato
|
|
595
|
+
dopo l'avvio istantaneamente.
|
|
596
|
+
"""
|
|
597
|
+
self.load_projects()
|
|
598
|
+
root = os.path.dirname(sync_path)
|
|
599
|
+
for project in self.projects:
|
|
600
|
+
if project.root == root:
|
|
601
|
+
log.info('.sync cambiato in %s: reconcile del progetto', root)
|
|
602
|
+
self.reconcile(project)
|
|
603
|
+
return
|
|
604
|
+
log.info('nessun progetto seguito in %s (.sync rimosso o nuovo)', root)
|
|
605
|
+
|
|
606
|
+
def _copy(self, src, project, is_link_dir=False):
|
|
607
|
+
"""Copia su ogni dest pronta; dest assente o errore di mount → pending
|
|
608
|
+
(§8.3); altri errori I/O → solo log (l'evento non deve mettere la dest
|
|
609
|
+
in pending: al prossimo reconcile il file viene ritentato, CR-05)."""
|
|
610
|
+
# rel rispetto alla radice `source` (non del progetto): il path dest
|
|
611
|
+
# include il segmento del progetto, come in reconcile (SPEC.md §4.3)
|
|
612
|
+
rel = os.path.relpath(normalize(src), project.source_root)
|
|
613
|
+
for dest in self.cfg.dests:
|
|
614
|
+
if dest_state(dest) != 'ok':
|
|
615
|
+
self.mark_pending(project, dest)
|
|
616
|
+
continue
|
|
617
|
+
dst = dest_path(dest, project.source_root, rel, self.cfg.layout)
|
|
618
|
+
try:
|
|
619
|
+
if is_link_dir:
|
|
620
|
+
copy_one(src, dst, dest) # symlink: sempre ricreato come link
|
|
621
|
+
elif needs_copy(src, dst) and copy_one(src, dst, dest):
|
|
622
|
+
log.debug('copiato %s → %s', src, dst)
|
|
623
|
+
except OSError as e:
|
|
624
|
+
if e.errno in MOUNT_ERRNOS:
|
|
625
|
+
log.warning('errore di mount %s → %s: %s: dest in pending',
|
|
626
|
+
src, dst, e)
|
|
627
|
+
self.mark_pending(project, dest)
|
|
628
|
+
else:
|
|
629
|
+
log.error('copia fallita %s → %s: %s', src, dst, e)
|
|
630
|
+
|
|
631
|
+
def walk_dir(self, dirpath, project):
|
|
632
|
+
"""Mini-walk: i figli di una directory appena creata potrebbero non
|
|
633
|
+
avere eventi propri; pruning delle dir con verdict escluso + filtro
|
|
634
|
+
del matcher sui file (allow-list, SPEC.md §4.3)."""
|
|
635
|
+
dirpath = normalize(dirpath)
|
|
636
|
+
for dirpath2, dirnames, filenames in os.walk(dirpath):
|
|
637
|
+
base = os.path.relpath(dirpath2, project.root)
|
|
638
|
+
base = '' if base == os.curdir else base.replace(os.sep, '/') + '/'
|
|
639
|
+
keep = []
|
|
640
|
+
for name in sorted(dirnames):
|
|
641
|
+
full = os.path.join(dirpath2, name)
|
|
642
|
+
if os.path.islink(full):
|
|
643
|
+
# foglia (come in classify): valutata come FILE, non come
|
|
644
|
+
# directory — vedi reconcile_project
|
|
645
|
+
if project.matcher.evaluate(base + name, False):
|
|
646
|
+
self._copy(full, project, is_link_dir=True)
|
|
647
|
+
continue
|
|
648
|
+
if project.matcher.evaluate(base + name, True):
|
|
649
|
+
keep.append(name)
|
|
650
|
+
dirnames[:] = keep # dir esclusa → sottoalbero potato
|
|
651
|
+
for name in filenames:
|
|
652
|
+
if project.matcher.evaluate(base + name, False): # allow-list (§4.3)
|
|
653
|
+
self._copy(os.path.join(dirpath2, name), project)
|
|
654
|
+
|
|
655
|
+
# --- run -------------------------------------------------------------
|
|
656
|
+
|
|
657
|
+
def _on_signal(self, signum, frame):
|
|
658
|
+
# solleva per interrompere subito il select: senza eccezione PEP 475
|
|
659
|
+
# lo ritenterebbe e il shutdown aspetterebbe tutta la scadenza
|
|
660
|
+
raise KeyboardInterrupt
|
|
661
|
+
|
|
662
|
+
def run(self, verbose=False):
|
|
663
|
+
"""Setup → reconcile iniziale → watch → dispatch (SPEC.md §6).
|
|
664
|
+
|
|
665
|
+
Exit 0 su SIGTERM/SIGINT pulito, 1 se la config è invalida, se una dest
|
|
666
|
+
è dentro la sorgente (CR-02) o se fswatch muore 5 volte in 60s.
|
|
667
|
+
"""
|
|
668
|
+
signal.signal(signal.SIGTERM, self._on_signal)
|
|
669
|
+
signal.signal(signal.SIGINT, self._on_signal)
|
|
670
|
+
try:
|
|
671
|
+
self.load_config()
|
|
672
|
+
setup_logging('debug' if verbose else self.cfg.log_level)
|
|
673
|
+
self.validate_dests() # CR-02: dest dentro source/$HOME → blocca
|
|
674
|
+
roots, auto = self.discovery_roots()
|
|
675
|
+
if auto:
|
|
676
|
+
log.info('modalità auto-discovery: scan di %s alla ricerca di .sync',
|
|
677
|
+
roots[0])
|
|
678
|
+
else:
|
|
679
|
+
log.info('modalità source: %d radice/i da osservare', len(roots))
|
|
680
|
+
self.load_projects()
|
|
681
|
+
log.info('%d progetto/i: %s', len(self.projects),
|
|
682
|
+
', '.join(p.root for p in self.projects) or '-')
|
|
683
|
+
self.next_reconcile = self.clock() + RECONCILE_INTERVAL
|
|
684
|
+
self.reconcile_all()
|
|
685
|
+
while not self.fatal:
|
|
686
|
+
self.step()
|
|
687
|
+
except KeyboardInterrupt:
|
|
688
|
+
log.info('shutdown su segnale')
|
|
689
|
+
finally:
|
|
690
|
+
self.close_proc()
|
|
691
|
+
logging.shutdown()
|
|
692
|
+
return 1 if self.fatal else 0
|