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/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