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/__init__.py
ADDED
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
|