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 ADDED
@@ -0,0 +1,6 @@
1
+ """safekeep — motore di backup event-driven per macOS (vedi SPEC.md)."""
2
+ import logging
3
+
4
+ logging.getLogger('safekeep').addHandler(logging.NullHandler())
5
+
6
+ __version__ = '0.2.0'
safekeep/cli.py ADDED
@@ -0,0 +1,272 @@
1
+ """CLI di safekeep: `run`, `sync-once`, `status` e `doctor` (SPEC.md §9).
2
+
3
+ Entry point del package (`safekeep.cli:main`): `bin/safekeep.py` è solo lo
4
+ shim che usano launchd e i test.
5
+ """
6
+ import argparse
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+
12
+ from safekeep.config import ConfigError, discover_projects, parse_sync
13
+ from safekeep.copier import TMP_INFIX
14
+ from safekeep.daemon import Daemon, setup_logging
15
+ from safekeep.volumes import dest_state
16
+
17
+ # Radice del repo quando si gira da checkout; in un install pip punta altrove
18
+ # (site-packages) e i riferimenti a file del repo degradano (vedi cmd_doctor).
19
+ REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
20
+ EXAMPLE = os.path.join(REPO, 'examples', 'safekeep.example')
21
+ REPO_URL = 'https://gitlab.com/foxhound87/safekeep'
22
+
23
+
24
+ def build_parser():
25
+ common = argparse.ArgumentParser(add_help=False)
26
+ common.add_argument('--config', metavar='PATH',
27
+ help='config globale (default: ~/.safekeep)')
28
+ common.add_argument('-v', action='store_true', help='log verboso (debug)')
29
+
30
+ parser = argparse.ArgumentParser(
31
+ prog='safekeep',
32
+ description='safekeep — backup event-driven per macOS (vedi SPEC.md)')
33
+ sub = parser.add_subparsers(dest='comando', required=True)
34
+ sub.add_parser('run', parents=[common],
35
+ help='daemon: reconcile iniziale + watch fswatch + dispatch eventi + timer 24h')
36
+ once = sub.add_parser('sync-once', parents=[common],
37
+ help='un singolo passaggio: walk sorgente, copia ciò che differisce, esce')
38
+ once.add_argument('--project', metavar='PATH',
39
+ help='solo questo progetto (path della root o name)')
40
+ once.add_argument('--dry-run', action='store_true',
41
+ help='stampa cosa copierebbe, senza copiare')
42
+ once.add_argument('--prune', action='store_true',
43
+ help='rimuove dalla dest i file il cui sorgente esiste ancora ma '
44
+ 'non è più incluso dal matcher (default: mai cancellare)')
45
+ sub.add_parser('status', parents=[common],
46
+ help='config, progetti e stato delle dest — sola lettura')
47
+ sub.add_parser('doctor', parents=[common],
48
+ help='diagnostica: config, fswatch, TCC, plist — exit ≠ 0 se un check fatale fallisce')
49
+ return parser
50
+
51
+
52
+ def cmd_sync_once(args):
53
+ daemon = Daemon(args.config)
54
+ daemon.load_config()
55
+ setup_logging('debug' if args.v else daemon.cfg.log_level)
56
+ daemon.validate_dests() # CR-02: dest dentro source/$HOME → exit ≠ 0
57
+ daemon.load_projects()
58
+ rows = daemon.sync_once(dry_run=args.dry_run, only=args.project,
59
+ prune=args.prune)
60
+ if args.project and not rows:
61
+ print(f'progetto non trovato: {args.project}', file=sys.stderr)
62
+ return 1
63
+ if not rows:
64
+ print('nessun progetto trovato (nessun .sync scoperto)', file=sys.stderr)
65
+ for project, stats, absent in rows:
66
+ if args.dry_run:
67
+ for src, dst in stats.get('pianificati', []):
68
+ print(f' {src} -> {dst}')
69
+ verb = 'da copiare' if args.dry_run else 'copie'
70
+ line = (f'{project.name}: {stats.get("copiati", 0)} {verb}, '
71
+ f'{stats.get("skippati", 0)} già aggiornati, '
72
+ f'{stats.get("esclusi", 0)} esclusi, '
73
+ f'{stats.get("errati", 0)} errori, dest assenti: {absent}')
74
+ if args.prune:
75
+ verb = 'da rimuovere' if args.dry_run else 'rimossi'
76
+ line += f', {verb}: {stats.get("rimossi", 0)}'
77
+ print(line)
78
+ return 0
79
+
80
+
81
+ def cmd_status(args):
82
+ """Sola lettura: config, modalità, source/progetti con N regole, dest con dest_state."""
83
+ daemon = Daemon(args.config)
84
+ daemon.load_config()
85
+ print(f'config {daemon.config_path}')
86
+ print(f'layout {daemon.cfg.layout}')
87
+ if daemon.cfg.sources:
88
+ print('modalità source')
89
+ for src in daemon.cfg.sources:
90
+ print(f'source {src}')
91
+ else:
92
+ print(f'modalità auto-discovery da {os.path.expanduser("~")}')
93
+ daemon.load_projects()
94
+ for project in sorted(daemon.projects, key=lambda p: p.root):
95
+ print(f'progetto {project.name} — {len(project.matcher.rules)} regole '
96
+ f'— {project.root}')
97
+ if not daemon.projects:
98
+ print('progetto nessuno (.sync non trovato)')
99
+ for dest in daemon.cfg.dests:
100
+ print(f'dest {dest} [{dest_state(dest)}]')
101
+ return 0
102
+
103
+
104
+ def check_sync_files(daemon):
105
+ """SPEC.md §9.2 (CR-03): ogni `.sync` scoperto deve parsificare e avere
106
+ ≥1 regola valida. Non fatale: il conteggio delle righe scartate diventa
107
+ un warning di `doctor`.
108
+
109
+ → (progetti totali, `.sync` rotti con motivo, righe scartate totali)."""
110
+ roots, auto = daemon.discovery_roots()
111
+ total, broken, discarded = 0, [], 0
112
+ for root in discover_projects(roots, auto=auto):
113
+ total += 1
114
+ path = os.path.join(root, '.sync')
115
+ try:
116
+ with open(path, encoding='utf-8') as fh:
117
+ text = fh.read()
118
+ except (OSError, UnicodeError) as e: # OSError + UnicodeDecodeError
119
+ broken.append(f'{path}: {e}')
120
+ continue
121
+ # log_level fisso a info: il parse di doctor NON deve mai essere fatale
122
+ sync = parse_sync(text, log_level='info', origin=path)
123
+ discarded += sync.discarded
124
+ if not (sync.rules or sync.defaults):
125
+ broken.append(f'{path}: nessuna regola valida')
126
+ return total, broken, discarded
127
+
128
+
129
+ def cmd_doctor(args):
130
+ """Un check per riga con esito ✔/✗; exit 1 se almeno un check fatale fallisce."""
131
+ fatal = False
132
+
133
+ def out(ok, msg, is_fatal=False):
134
+ nonlocal fatal
135
+ print(('✔ ' if ok else '✗ ') + msg)
136
+ if not ok and is_fatal:
137
+ fatal = True
138
+
139
+ cfg_path = os.path.expanduser(args.config or '~/.safekeep')
140
+ cfg = None
141
+ daemon = None
142
+ if not os.path.exists(cfg_path):
143
+ if os.path.exists(EXAMPLE):
144
+ hint = f'creala: cp {EXAMPLE} {cfg_path}'
145
+ else:
146
+ # install pip: niente repo, niente esempi → hint degradato
147
+ hint = (f'clone {REPO_URL} and copy examples/safekeep.example to '
148
+ f'{cfg_path}')
149
+ out(False, f'config assente: {cfg_path} ({hint})',
150
+ is_fatal=bool(args.config))
151
+ else:
152
+ try:
153
+ daemon = Daemon(cfg_path)
154
+ cfg = daemon.load_config()
155
+ except ConfigError as e:
156
+ daemon = None
157
+ out(False, f'config non valida: {e}', is_fatal=True)
158
+ else:
159
+ # nessuna `source` → auto-discovery da $HOME: info, non warning (SPEC §6)
160
+ mode = (f'{len(cfg.sources)} source' if cfg.sources
161
+ else f'auto-discovery da {os.path.expanduser("~")}')
162
+ out(True, f'config valida: {cfg_path} ({mode}, {len(cfg.dests)} dest)')
163
+
164
+ if daemon is not None:
165
+ # base = source, oppure $HOME in auto-mode: copre anche "dest dentro $HOME"
166
+ try:
167
+ daemon.validate_dests()
168
+ except ConfigError as e:
169
+ out(False, str(e), is_fatal=True)
170
+ else:
171
+ out(True, 'dest non annidate nelle source/$HOME (nessun loop di copia)')
172
+
173
+ if daemon is not None:
174
+ total, broken, discarded = check_sync_files(daemon)
175
+ if broken:
176
+ detail = f' ({discarded} righe scartate)' if discarded else ''
177
+ out(False, '.sync non validi: ' + '; '.join(broken) + detail
178
+ + ' — warning non fatale')
179
+ elif discarded:
180
+ out(False, f'.sync: {discarded} righe scartate in {total} progetto/i '
181
+ '— warning non fatale')
182
+ elif total:
183
+ out(True, f'.sync: {total} progetto/i parsificati con regole valide')
184
+ else:
185
+ out(True, '.sync: nessun progetto scoperto (niente da validare)')
186
+
187
+ exe = shutil.which('fswatch')
188
+ if exe is None:
189
+ out(False, 'fswatch non trovato — installa con: brew install fswatch',
190
+ is_fatal=True)
191
+ else:
192
+ version = None
193
+ try:
194
+ proc = subprocess.run([exe, '--version'], capture_output=True,
195
+ text=True, timeout=15)
196
+ lines = (proc.stdout + proc.stderr).strip().splitlines()
197
+ version = lines[0] if lines else None
198
+ except (OSError, subprocess.SubprocessError):
199
+ pass
200
+ out(version is not None,
201
+ f'fswatch: {version}' if version else 'fswatch --version senza output',
202
+ is_fatal=True)
203
+ try:
204
+ monitors = subprocess.run([exe, '-M'], capture_output=True,
205
+ text=True, timeout=15)
206
+ has_fsevents = 'fsevents_monitor' in monitors.stdout
207
+ except (OSError, subprocess.SubprocessError):
208
+ has_fsevents = False
209
+ out(has_fsevents,
210
+ 'fswatch monitor: fsevents_monitor presente' if has_fsevents else
211
+ 'fswatch monitor: fsevents_monitor ASSENTE in `fswatch -M`',
212
+ is_fatal=True)
213
+
214
+ out(sys.version_info >= (3, 9),
215
+ f'python {sys.version.split()[0]} (≥ 3.9)', is_fatal=True)
216
+
217
+ docs = os.path.expanduser('~/Documents')
218
+ if not os.path.isdir(docs):
219
+ out(True, 'TCC (Transparency, Consent and Control): ~/Documents assente — probe saltato')
220
+ else:
221
+ try:
222
+ os.listdir(docs)
223
+ except PermissionError:
224
+ out(False, 'TCC: PermissionError su ~/Documents — serve FDA '
225
+ '(Full Disk Access) per i progetti in cartelle protette')
226
+ else:
227
+ out(True, 'TCC: ~/Documents leggibile')
228
+
229
+ if cfg is not None:
230
+ residui = [os.path.join(dirpath, name)
231
+ for dest in cfg.dests
232
+ for dirpath, _, names in os.walk(dest)
233
+ for name in names
234
+ if TMP_INFIX in name]
235
+ out(not residui,
236
+ 'residui *.safekeep.tmp.*: nessuno' if not residui else
237
+ 'residui *.safekeep.tmp.*: ' + ', '.join(residui))
238
+
239
+ plist = os.path.expanduser('~/Library/LaunchAgents/com.safekeep.agent.plist')
240
+ if not os.path.exists(plist):
241
+ out(True, 'plist non installato (nessun agent da validare)')
242
+ elif shutil.which('plutil') is None:
243
+ out(True, 'plutil assente — lint del plist saltato')
244
+ else:
245
+ try:
246
+ lint = subprocess.run(['plutil', '-lint', plist],
247
+ capture_output=True, text=True, timeout=15)
248
+ detail = (lint.stdout + lint.stderr).strip() or plist
249
+ out(lint.returncode == 0, f'plist lint: {detail}', is_fatal=True)
250
+ except (OSError, subprocess.SubprocessError) as e:
251
+ out(False, f'plist lint non eseguibile: {e}', is_fatal=True)
252
+
253
+ return 1 if fatal else 0
254
+
255
+
256
+ def main(argv=None):
257
+ args = build_parser().parse_args(argv)
258
+ try:
259
+ if args.comando == 'run':
260
+ return Daemon(args.config).run(verbose=args.v)
261
+ if args.comando == 'status':
262
+ return cmd_status(args)
263
+ if args.comando == 'doctor':
264
+ return cmd_doctor(args)
265
+ return cmd_sync_once(args)
266
+ except ConfigError as e:
267
+ print(f'errore di config: {e}', file=sys.stderr)
268
+ return 1
269
+
270
+
271
+ if __name__ == '__main__':
272
+ sys.exit(main())
safekeep/config.py ADDED
@@ -0,0 +1,266 @@
1
+ """Config globale `~/.safekeep` e file `.sync` di progetto (SPEC.md §3 e §4).
2
+
3
+ Le destinazioni vivono SOLO nel config globale: un `.sync` contiene solo
4
+ regole — le chiavi `name:`/`defaults:`/`include:`/`exclude:` e le righe nude
5
+ stile gitignore (una riga nuda = `include:`, `!riga` = `exclude:`, SPEC.md §4.2).
6
+ Una riga a forma di chiave non nota (`dest:`, `-dest:`, …) resta invalida: con
7
+ `log_level: info` genera solo un warning e viene saltata, con `log_level: debug`
8
+ è fatale (SPEC.md §4.2). Il config globale è invece sempre fail-fast.
9
+ """
10
+ import logging
11
+ import os
12
+ import re
13
+
14
+ from .matcher import BUILTIN_RULES, Matcher, parse_rule, strip_comment
15
+
16
+ log = logging.getLogger('safekeep')
17
+
18
+
19
+ class ConfigError(ValueError):
20
+ """Errore di config: il messaggio contiene sempre l'origine e la riga."""
21
+
22
+
23
+ class GlobalConfig:
24
+ def __init__(self):
25
+ self.sources = [] # radici osservate (ripetibili, opzionali)
26
+ self.dests = [] # destinazioni globali (ripetibili) — le UNICHE
27
+ self.layout = 'relative' # relative | full
28
+ self.defaults = [] # regole a bassa priorità (list[Rule])
29
+ self.log_level = 'info' # debug | info | warn | error
30
+
31
+
32
+ class SyncConfig:
33
+ def __init__(self):
34
+ self.name = None
35
+ self.defaults = [] # defaults: <regola> (list[Rule])
36
+ self.rules = [] # include:/exclude: in ordine (list[Rule])
37
+ self.discarded = 0 # righe scartate come invalide (per doctor)
38
+
39
+
40
+ _SYNC_KEY = re.compile(r'(name|defaults|include|exclude)\s*:\s*(.*)')
41
+ _CONF_KEY = re.compile(r'([A-Za-z_]+)\s*:\s*(.*)')
42
+ # Riga a forma di chiave (`dest:`, `-dest:`, `foo:`): NON è una riga nuda.
43
+ _KEY_SHAPE = re.compile(r'[A-Za-z_-][A-Za-z0-9_-]*\s*:')
44
+
45
+
46
+ def bare_rule(line):
47
+ """Riga nuda stile gitignore → regola (SPEC.md §4.2): `pattern` include,
48
+ `!pattern` exclude. Attenzione: è l'**inverso di gitignore** — qui la riga
49
+ elenca i file da COPIARE. None se il pattern è vuoto."""
50
+ neg = line.startswith('!')
51
+ pat = line[1:].strip() if neg else line
52
+ if not pat:
53
+ return None
54
+ return parse_rule(f'{"exclude" if neg else "include"}: {pat}')
55
+
56
+
57
+ def parse_sync(text, *, log_level='info', origin='<.sync>'):
58
+ """Parse di un file `.sync` → SyncConfig. Nessuna dest: le destinazioni
59
+ vivono solo in `~/.safekeep`, quindi `dest:`/`-dest:` restano righe invalide
60
+ (SPEC.md §4.2)."""
61
+ cfg = SyncConfig()
62
+
63
+ def invalid(lineno, detail):
64
+ cfg.discarded += 1
65
+ msg = f'{origin}:{lineno}: {detail}'
66
+ if log_level == 'debug':
67
+ raise ConfigError(msg)
68
+ log.warning('%s', msg)
69
+
70
+ for lineno, raw in enumerate(text.splitlines(), 1):
71
+ line = strip_comment(raw).strip()
72
+ if not line:
73
+ continue
74
+ m = _SYNC_KEY.match(line)
75
+ if not m:
76
+ if _KEY_SHAPE.match(line):
77
+ # chiave ignota / dest: → riga invalida (SPEC.md §4.2)
78
+ invalid(lineno, f'riga non valida: {raw.strip()}')
79
+ continue
80
+ try:
81
+ rule = bare_rule(line) # riga nuda stile gitignore
82
+ except ValueError as e:
83
+ # pattern da `.sync` non attendibile (regex invalida, NUL, …):
84
+ # warning + skip con info, ConfigError con debug (CR-03)
85
+ invalid(lineno, str(e))
86
+ continue
87
+ if rule is None:
88
+ invalid(lineno, 'pattern vuoto')
89
+ continue
90
+ cfg.rules.append(rule) # stessa lista, stesso ordine
91
+ continue
92
+ key, val = m.group(1), m.group(2).strip()
93
+ if key in ('include', 'exclude', 'defaults'):
94
+ try:
95
+ rule = parse_rule(val if key == 'defaults' else line)
96
+ except ValueError as e:
97
+ invalid(lineno, str(e))
98
+ continue
99
+ if rule is None:
100
+ invalid(lineno, 'regola vuota')
101
+ continue
102
+ (cfg.defaults if key == 'defaults' else cfg.rules).append(rule)
103
+ elif key == 'name':
104
+ if val:
105
+ cfg.name = val
106
+ else:
107
+ invalid(lineno, 'name vuoto')
108
+ elif not val:
109
+ invalid(lineno, f'{key} senza valore')
110
+ return cfg
111
+
112
+
113
+ def parse_config(text, origin='config'):
114
+ """Parse del config globale → GlobalConfig. Errori fatali sempre (fail-fast).
115
+
116
+ `source` è opzionale (nessuna riga → modalità auto-discovery da `$HOME`,
117
+ SPEC.md §6); `dest` è obbligatoria: è l'unico posto dove vivono le
118
+ destinazioni (SPEC.md §3)."""
119
+ cfg = GlobalConfig()
120
+ for lineno, raw in enumerate(text.splitlines(), 1):
121
+ line = strip_comment(raw).strip()
122
+ if not line:
123
+ continue
124
+ m = _CONF_KEY.match(line)
125
+ if not m:
126
+ raise ConfigError(f'{origin}:{lineno}: riga non valida: {raw.strip()}')
127
+ key, val = m.group(1), m.group(2).strip()
128
+ if '\0' in val:
129
+ # un valore con NUL dal config globale esploderebbe più avanti in
130
+ # modo opaco (os.path/Popen): fail-fast qui, con numero di riga (CR-03)
131
+ raise ConfigError(f'{origin}:{lineno}: valore con carattere NUL: {key}')
132
+ if key == 'source':
133
+ if not val:
134
+ raise ConfigError(f'{origin}:{lineno}: source vuota')
135
+ cfg.sources.append(os.path.expanduser(val))
136
+ elif key == 'dest':
137
+ if not val:
138
+ raise ConfigError(f'{origin}:{lineno}: dest vuota')
139
+ cfg.dests.append(os.path.expanduser(val))
140
+ elif key == 'layout':
141
+ if val not in ('relative', 'full'):
142
+ raise ConfigError(f'{origin}:{lineno}: layout non valido: {val!r} (relative|full)')
143
+ cfg.layout = val
144
+ elif key == 'defaults':
145
+ try:
146
+ rule = parse_rule(val)
147
+ except ValueError as e:
148
+ raise ConfigError(f'{origin}:{lineno}: {e}') from None
149
+ if rule is None:
150
+ raise ConfigError(f'{origin}:{lineno}: defaults senza regola')
151
+ cfg.defaults.append(rule)
152
+ elif key == 'log_level':
153
+ if val not in ('debug', 'info', 'warn', 'error'):
154
+ raise ConfigError(f'{origin}:{lineno}: log_level non valido: {val!r}')
155
+ cfg.log_level = val
156
+ else:
157
+ raise ConfigError(f'{origin}:{lineno}: chiave sconosciuta: {key}')
158
+ if not cfg.dests:
159
+ raise ConfigError(f'{origin}: nessuna dest: serve almeno una riga '
160
+ f'`dest: <path>` (esempio: examples/safekeep.example)')
161
+ return cfg
162
+
163
+
164
+ def source_root_for(sources, path):
165
+ """La `source` configurata più profonda che contiene `path`; `path` se nessuna.
166
+
167
+ È la radice da cui si calcola il path dest in layout `relative` (SPEC.md §4.3):
168
+ il path dest include così il segmento del progetto (`backup/myapp/docs/a.md`)
169
+ e due progetti sotto la stessa sorgente non si sovrappongono.
170
+ """
171
+ p = os.path.realpath(os.path.abspath(os.path.expanduser(path)))
172
+ best, best_len = p, -1
173
+ for src in sources:
174
+ s = os.path.realpath(os.path.abspath(os.path.expanduser(src)))
175
+ if (p == s or p.startswith(s + os.sep)) and len(s) > best_len:
176
+ best, best_len = s, len(s)
177
+ return best
178
+
179
+
180
+ def dest_path(dest_root, source_root, relpath, layout):
181
+ """Path di destinazione (SPEC.md §4.3): relative → dest/<relativo alla
182
+ radice `source>`, full → dest/<path assoluto senza / iniziale>.
183
+
184
+ `source_root` è la radice `source` del config che contiene il progetto
185
+ (`source_root_for`), NON la root del progetto: con una dest condivisa,
186
+ `~/Code/myapp/docs/a.md` → `backup/myapp/docs/a.md` e due progetti
187
+ (`projA`, `projB`) con lo stesso file relativo non si sovrappongono.
188
+ """
189
+ if layout not in ('relative', 'full'):
190
+ raise ValueError(f'layout non valido: {layout!r} (relative|full)')
191
+ src = os.path.abspath(os.path.expanduser(source_root))
192
+ p = relpath if os.path.isabs(relpath) else os.path.join(src, relpath)
193
+ p = os.path.abspath(os.path.expanduser(p))
194
+ rel = os.path.relpath(p, os.sep) if layout == 'full' else os.path.relpath(p, src)
195
+ return os.path.normpath(os.path.join(os.path.expanduser(dest_root), rel))
196
+
197
+
198
+ def validate_dests(sources, dests):
199
+ """Errore se una dest è dentro una sorgente → loop di copia infinito (SPEC.md §11)."""
200
+ def real(p):
201
+ return os.path.realpath(os.path.abspath(os.path.expanduser(p)))
202
+ for dest in dests:
203
+ d = real(dest)
204
+ for src in sources:
205
+ s = real(src)
206
+ if d == s or d.startswith(s + os.sep):
207
+ raise ConfigError(f'dest {d} è dentro la sorgente {s}: loop di copia infinito')
208
+
209
+
210
+ # Modalità auto-discovery (SPEC.md §6): dir da potare durante lo scan di $HOME.
211
+ # Le nascoste (iniziano con `.`) sono potate a monte, qui elencate per chiarezza
212
+ # `.Trash` e `.cache` (e per `venv`, non coperto dai builtin).
213
+ # Ultime sette: cartelle protette dalla privacy di macOS (TCC = Transparency,
214
+ # Consent and Control). Senza "Accesso completo al disco" l'`opendir()` su una
215
+ # di queste resta bloccato nel kernel — niente errore, niente prompt per un
216
+ # agent launchd — e il discovery non finirebbe mai (SPEC.md §6).
217
+ HOME_PRUNE_DIRS = frozenset({
218
+ 'Library', '.Trash', '.cache', 'node_modules', '.git', '.venv',
219
+ '__pycache__', 'venv', 'Applications',
220
+ 'Desktop', 'Documents', 'Downloads', 'Movies', 'Music', 'Pictures',
221
+ 'Public',
222
+ })
223
+
224
+
225
+ def home_prune(rel):
226
+ """Pruning dell'auto-discovery: True = non scendere in questa directory.
227
+
228
+ `rel` è il path relativo alla radice con `/` come separatore; la radice
229
+ stessa (rel '.') non viene mai potata.
230
+ """
231
+ name = rel.rsplit('/', 1)[-1]
232
+ return name.startswith('.') or name in HOME_PRUNE_DIRS
233
+
234
+
235
+ def discover_projects(sources, auto=False):
236
+ """Directory che contengono `.sync` sotto ogni radice (scan con pruning).
237
+
238
+ `auto=True` (modalità auto-discovery, scan della `$HOME`): si pota su
239
+ `home_prune` — nessuna directory nascosta e niente `Library`/`.Trash`/…,
240
+ così `.sync` dentro una di queste NON diventa progetto (SPEC.md §6).
241
+ `auto=False` (modalità source): si pota sul verdict *esclusa* delle regole
242
+ builtin. Scan ordinato (root prima dei sotto-progetti): i `.sync` annidati
243
+ restano progetti a sé (SPEC.md §4.1).
244
+ Il pruning è applicato ai *figli* prima di scendere: `os.walk` apre la
245
+ directory (`opendir`) solo quando ci si entra, così una dir potata — in
246
+ particolare una cartella protetta da TCC — non viene mai nemmeno aperta.
247
+ """
248
+ builtin = Matcher(BUILTIN_RULES)
249
+ prune = home_prune if auto else (lambda rel: not builtin.evaluate(rel, True))
250
+ found = []
251
+ for src in sources:
252
+ root = os.path.abspath(os.path.expanduser(src))
253
+ if not os.path.isdir(root):
254
+ log.warning('source inesistente o non è una directory: %s', src)
255
+ continue
256
+ for dirpath, dirnames, filenames in os.walk(root):
257
+ rel = os.path.relpath(dirpath, root).replace(os.sep, '/')
258
+ if '.sync' in filenames:
259
+ found.append(dirpath) # dir senza .sync non è progetto
260
+ # pota i figli PRIMA di scendere: os.walk apre (opendir) una dir
261
+ # solo quando ci si entra, così una dir esclusa — in particolare
262
+ # una cartella protetta da TCC — non viene mai nemmeno aperta
263
+ dirnames[:] = sorted(
264
+ name for name in dirnames
265
+ if not prune(name if rel == '.' else rel + '/' + name))
266
+ return found