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/copier.py ADDED
@@ -0,0 +1,333 @@
1
+ """Copia atomica, stability check e reconcile (SPEC.md §7 e §10).
2
+
3
+ Semantica: solo copia/aggiorna, mai cancellare. `copy_one` restituisce False
4
+ quando la copia non è fattibile in questo momento (sorgente instabile o
5
+ sparita, oppure scrittura fuori dest) mentre `OSError` di I/O
6
+ (ENOSPC/EIO/EBUSY/ENODEV) viene rilanciato com'è: a decidere il retry è il
7
+ caller. Nel reconcile ogni errore resta confinato al proprio file (CR-05).
8
+ """
9
+ import errno
10
+ import logging
11
+ import os
12
+ import shutil
13
+ import time
14
+
15
+ log = logging.getLogger('safekeep')
16
+
17
+ STABILITY_PAUSE = 0.2 # secondi fra i due stat (SPEC.md §7.1)
18
+ STABILITY_RETRIES = 2 # ritentativi extra se il file cambia fra i due stat
19
+ MTIME_TOLERANCE = 1.0 # secondi: tolleranza mtime per volumi a risoluzione limitata
20
+ TMP_INFIX = '.safekeep.tmp.'
21
+ # errori che dicono "questa dest non è usabile adesso" → pending + backoff (§8.3)
22
+ MOUNT_ERRNOS = (errno.ENODEV, errno.EBUSY, errno.ENOSPC)
23
+
24
+
25
+ def _tmp_path(dst):
26
+ """Tmp sempre nella cartella di dst (stesso filesystem ⇒ os.replace atomico)."""
27
+ return dst + TMP_INFIX + str(os.getpid())
28
+
29
+
30
+ def _remove(path):
31
+ try:
32
+ os.unlink(path)
33
+ except OSError:
34
+ pass
35
+
36
+
37
+ def _fsync_best_effort(path):
38
+ try:
39
+ fd = os.open(path, os.O_RDONLY)
40
+ try:
41
+ os.fsync(fd)
42
+ finally:
43
+ os.close(fd)
44
+ except OSError as e:
45
+ log.debug('fsync best-effort non riuscito su %s: %s', path, e)
46
+
47
+
48
+ def _stable(src):
49
+ """Due `os.stat` separati con (size, mtime_ns) identici ⇒ file fermo."""
50
+ for _ in range(1 + STABILITY_RETRIES):
51
+ try:
52
+ s1 = os.stat(src)
53
+ except FileNotFoundError:
54
+ return False
55
+ time.sleep(STABILITY_PAUSE)
56
+ try:
57
+ s2 = os.stat(src)
58
+ except FileNotFoundError:
59
+ return False
60
+ if (s1.st_size, s1.st_mtime_ns) == (s2.st_size, s2.st_mtime_ns):
61
+ return True
62
+ log.debug('stability check fallito su %s: il file è in scrittura', src)
63
+ return False
64
+
65
+
66
+ def _inside(path, root):
67
+ return path == root or path.startswith(root + os.sep)
68
+
69
+
70
+ def _make_contained(dst, dest_root):
71
+ """True se la directory di `dst` sta dentro `dest_root` (CR-01, SPEC.md §7.2).
72
+
73
+ Un componente-symlink della dest che punta fuori è un artefatto di copie
74
+ precedenti (§7.3 ricrea i link sorgente): è roba della dest → viene rimosso
75
+ e ricreato come directory reale. Se dopo il ripristino la directory risolve
76
+ ancora fuori → False: mai scrivere fuori dest.
77
+ """
78
+ base = os.path.abspath(os.path.expanduser(dest_root))
79
+ target = os.path.dirname(os.path.abspath(dst))
80
+ rel = os.path.relpath(target, base)
81
+ if rel == os.pardir or rel.startswith(os.pardir + os.sep):
82
+ return False # dst fuori dalla dest per costruzione
83
+ root = os.path.realpath(base)
84
+ cur = base
85
+ for part in rel.split(os.sep):
86
+ if part in ('', os.curdir):
87
+ continue
88
+ cur = os.path.join(cur, part)
89
+ if os.path.islink(cur) and not _inside(os.path.realpath(cur), root):
90
+ log.warning('symlink di dest fuori da %s: lo rimuovo (roba della dest): %s',
91
+ root, cur)
92
+ try:
93
+ os.unlink(cur) # unlink NON segue il link: safe
94
+ except OSError as e:
95
+ log.error('impossibile rimuovere il symlink %s: %s', cur, e)
96
+ return _inside(os.path.realpath(target), root)
97
+
98
+
99
+ def _copy_symlink(src, dst, dest_root=None):
100
+ """Ricrea il symlink senza seguirlo (SPEC.md §7.3), anche se rotto."""
101
+ try:
102
+ target = os.readlink(src)
103
+ except OSError:
104
+ return False # symlink sparito fra islink e readlink
105
+ if dest_root is not None and not _make_contained(dst, dest_root):
106
+ log.error('scrittura fuori da dest bloccata: %s', dst)
107
+ return False
108
+ dirname = os.path.dirname(dst)
109
+ if dirname:
110
+ os.makedirs(dirname, exist_ok=True)
111
+ tmp = _tmp_path(dst)
112
+ _remove(tmp) # residuo di un crash precedente
113
+ try:
114
+ os.symlink(target, tmp)
115
+ os.replace(tmp, dst)
116
+ except OSError:
117
+ _remove(tmp)
118
+ raise
119
+ log.debug('symlink %s → %s (target %r)', src, dst, target)
120
+ return True
121
+
122
+
123
+ def copy_one(src: str, dst: str, dest_root=None) -> bool:
124
+ """Copia atomica src → dst. True se copiato, False se da ritentare più tardi.
125
+
126
+ - src sparito (FileNotFoundError) → False silenzioso: la policy "mai
127
+ cancellare" conserva già il backup esistente;
128
+ - `dest_root` passato ⇒ containment check prima di ogni scrittura: mai
129
+ scrivere fuori dalla dest via symlink (CR-01);
130
+ - OSError di I/O → rilanciato così com'è (il caller decide il retry);
131
+ - chmod/utime non riusciti (volumi exFAT) → non fatali (log debug).
132
+ """
133
+ if os.path.islink(src):
134
+ return _copy_symlink(src, dst, dest_root)
135
+ if not _stable(src):
136
+ return False # instabile o sparito
137
+ if dest_root is not None and not _make_contained(dst, dest_root):
138
+ log.error('scrittura fuori da dest bloccata: %s', dst)
139
+ return False
140
+ dirname = os.path.dirname(dst)
141
+ if dirname:
142
+ os.makedirs(dirname, exist_ok=True)
143
+ tmp = _tmp_path(dst)
144
+ _remove(tmp) # residuo di un crash precedente
145
+ try:
146
+ shutil.copyfile(src, tmp) # solo bytes: ENOSPC/EIO salgono
147
+ try:
148
+ shutil.copystat(src, tmp) # mtime + permessi
149
+ except OSError as e:
150
+ log.debug('copystat non riuscito su %s: %s', tmp, e)
151
+ _fsync_best_effort(tmp)
152
+ os.replace(tmp, dst)
153
+ except FileNotFoundError:
154
+ _remove(tmp)
155
+ log.debug('sorgente sparita durante la copia: %s', src)
156
+ return False
157
+ except OSError:
158
+ _remove(tmp)
159
+ raise
160
+ return True
161
+
162
+
163
+ def needs_copy(src: str, dst: str) -> bool:
164
+ """True se dst mancante o (size, mtime) diversi — tolleranza mtime 1s."""
165
+ try:
166
+ s = os.stat(src)
167
+ except FileNotFoundError:
168
+ return False # niente da copiare
169
+ try:
170
+ d = os.stat(dst)
171
+ except FileNotFoundError:
172
+ return True
173
+ if s.st_size != d.st_size:
174
+ return True
175
+ return abs(s.st_mtime - d.st_mtime) > MTIME_TOLERANCE
176
+
177
+
178
+ def reconcile_project(source_root, dest_root, matcher, layout, dest_path_fn,
179
+ stats=None, dry_run=False, dest_base=None) -> int:
180
+ """Walk della sorgente con pruning delle dir con verdict escluso; copia ogni
181
+ file con verdict incluso che differisce (un file non matchato da nessuna
182
+ regola NON viene copiato: allow-list, SPEC.md §4.3). Ritorna il numero di copie.
183
+
184
+ `dest_base`: radice da cui calcolare il path dest (default: la radice del
185
+ walk). Con layout `relative` passa la `source` del config che contiene il
186
+ progetto (SPEC.md §4.3): il path dest include il segmento del progetto e
187
+ due progetti diversi non si sovrappongono. `stats` (opzionale, dict
188
+ aggiornato in place): `copiati` si somma su più dest (passa lo stesso dict
189
+ per ogni dest), `skippati` = file già aggiornati, `errati` = copie fallite,
190
+ `esclusi` = file con verdict escluso incontrati nel walk (indipendente
191
+ dalla dest), `pianificati` = [(src, dst)] con `dry_run=True`, e
192
+ `dest_pendente` = True se un errore di mount (ENODEV/EBUSY/ENOSPC) ha
193
+ fermato la passata: il caller metta quella dest in pending (CR-05).
194
+ `dry_run`: conta ed elenca senza copiare.
195
+
196
+ Nessun errore I/O di un singolo file abortisce il walk: viene loggato e
197
+ contato, e gli altri file vengono processati comunque.
198
+ """
199
+ root = os.path.abspath(os.path.expanduser(source_root))
200
+ base_root = os.path.abspath(os.path.expanduser(dest_base)) if dest_base else root
201
+ prefix = os.path.relpath(root, base_root) # radice del walk rispetto a base_root
202
+ copied = 0
203
+ excluded = 0
204
+ skipped = 0
205
+ errors = 0
206
+ mount_error = None
207
+ plan = stats.setdefault('pianificati', []) if (stats is not None and dry_run) else None
208
+
209
+ def failed(e, src, dst):
210
+ """Conteggia l'errore; True ⇒ errore di mount: la dest va in pending."""
211
+ nonlocal errors, mount_error
212
+ errors += 1
213
+ if e.errno in MOUNT_ERRNOS:
214
+ mount_error = e
215
+ log.error('errore di mount su %s → %s: %s: la dest va in pending',
216
+ src, dst, e)
217
+ return True
218
+ log.error('copia fallita %s → %s: %s', src, dst, e)
219
+ return False
220
+
221
+ for dirpath, dirnames, filenames in os.walk(root):
222
+ if mount_error is not None:
223
+ break # dest in pending: il resto verrà copiato al remount
224
+ rel = os.path.relpath(dirpath, root)
225
+ base = '' if rel == os.curdir else rel.replace(os.sep, '/') + '/'
226
+ keep = []
227
+ for name in sorted(dirnames):
228
+ full = os.path.join(dirpath, name)
229
+ r = base + name
230
+ if os.path.islink(full):
231
+ # dir symlink: nessun descend, ricreata come symlink (SPEC.md §7.3).
232
+ # È una FOGLIA: va valutata come file, altrimenti il default
233
+ # allow-list "nessun match → True" (che serve a far attraversare
234
+ # le directory) la copierebbe ignorando ogni include (§4.3).
235
+ if matcher.evaluate(r, False):
236
+ dst = dest_path_fn(dest_root, base_root,
237
+ os.path.join(prefix, r), layout)
238
+ try:
239
+ if dry_run or copy_one(full, dst, dest_root):
240
+ copied += 1
241
+ if plan is not None:
242
+ plan.append((full, dst))
243
+ except OSError as e:
244
+ if failed(e, full, dst):
245
+ break
246
+ continue
247
+ if matcher.evaluate(r, True): # False → pota il sottoalbero
248
+ keep.append(name)
249
+ dirnames[:] = keep
250
+ if mount_error is not None:
251
+ break # errore di mount nella dir-symlink
252
+ for name in filenames:
253
+ r = base + name
254
+ if not matcher.evaluate(r, False):
255
+ excluded += 1 # non incluso → nessuna copia
256
+ continue
257
+ src = os.path.join(dirpath, name)
258
+ dst = dest_path_fn(dest_root, base_root,
259
+ os.path.join(prefix, r), layout)
260
+ try:
261
+ if needs_copy(src, dst):
262
+ if dry_run or copy_one(src, dst, dest_root):
263
+ copied += 1
264
+ if plan is not None:
265
+ plan.append((src, dst))
266
+ else:
267
+ skipped += 1 # già aggiornato su questa dest
268
+ except OSError as e:
269
+ if failed(e, src, dst):
270
+ break
271
+ if stats is not None:
272
+ stats['copiati'] = stats.get('copiati', 0) + copied
273
+ stats['skippati'] = stats.get('skippati', 0) + skipped
274
+ stats['errati'] = stats.get('errati', 0) + errors
275
+ stats['esclusi'] = excluded
276
+ if mount_error is not None:
277
+ stats['dest_pendente'] = True
278
+ return copied
279
+
280
+
281
+ def prune_project(source_root, dest_root, matcher, layout, dest_path_fn,
282
+ dest_base=None, dry_run=False) -> int:
283
+ """Rimuove dalla dest i file il cui sorgente esiste ancora ma che il
284
+ matcher ora esclude (`sync-once --prune`, SPEC.md §9).
285
+
286
+ Solo sotto il prefisso del progetto (niente tocco fuori) e solo dove la
287
+ corrispondenza è certa: sorgente sparita o diventata directory → file
288
+ intatto (policy "mai cancellare"). Ritorna il numero di rimozioni
289
+ (anche con `dry_run=True`, dove conta quelle che verrebbe rimosso).
290
+ """
291
+ root = os.path.abspath(os.path.expanduser(source_root))
292
+ base_root = os.path.abspath(os.path.expanduser(dest_base)) if dest_base else root
293
+ prefix = os.path.relpath(root, base_root)
294
+ proj_dst = dest_path_fn(dest_root, base_root, prefix, layout)
295
+ # difesa: mai camminare (e tantomeno cancellare) fuori dalla dest (CR-01)
296
+ if not _inside(os.path.realpath(proj_dst),
297
+ os.path.realpath(os.path.abspath(os.path.expanduser(dest_root)))):
298
+ log.warning('prefisso progetto fuori dalla dest: prune saltato: %s', proj_dst)
299
+ return 0
300
+
301
+ def pruneable(rel):
302
+ src = os.path.join(root, rel.replace('/', os.sep))
303
+ if not (os.path.isfile(src) or os.path.islink(src)):
304
+ return False # sorgente sparita o diventata dir → intatto
305
+ return not matcher.evaluate(rel, False)
306
+
307
+ removed = 0
308
+ for dirpath, dirnames, filenames in os.walk(proj_dst):
309
+ rel_dir = os.path.relpath(dirpath, proj_dst)
310
+ base = '' if rel_dir == os.curdir else rel_dir.replace(os.sep, '/') + '/'
311
+ # le dir-symlink della dest sono foglie come lo erano alla sorgente
312
+ for name in list(dirnames):
313
+ full = os.path.join(dirpath, name)
314
+ if os.path.islink(full) and pruneable(base + name):
315
+ removed += _drop(full, dry_run)
316
+ for name in filenames:
317
+ full = os.path.join(dirpath, name)
318
+ if pruneable(base + name):
319
+ removed += _drop(full, dry_run)
320
+ return removed
321
+
322
+
323
+ def _drop(path, dry_run):
324
+ if dry_run:
325
+ log.info('prune (dry-run): rimuoverei %s', path)
326
+ return 1
327
+ try:
328
+ os.unlink(path)
329
+ except OSError as e:
330
+ log.warning('prune: impossibile rimuovere %s: %s', path, e)
331
+ return 0
332
+ log.info('prune: rimosso %s', path)
333
+ return 1