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