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/matcher.py ADDED
@@ -0,0 +1,161 @@
1
+ """Matcher gitignore-like con semantica last-match-wins (SPEC.md §4.3 e §5).
2
+
3
+ Ordine di priorità dei layer (dal basso all'alto): builtin → globali →
4
+ defaults del progetto → regole del `.sync`. L'ultima regola che matcha il
5
+ path (o un suo genitore directory) decide il verdict; nessun match →
6
+ semantica allow-list: file NON copiato, directory attraversata.
7
+ """
8
+ import re
9
+
10
+
11
+ def strip_comment(line):
12
+ """Rimuove i commenti: `# ...` a inizio riga (escape `\\#`) o ` # ...` inline."""
13
+ if line.lstrip().startswith('#'):
14
+ return ''
15
+ return re.split(r'\s#', line, maxsplit=1)[0]
16
+
17
+
18
+ def _charclass(pat, i):
19
+ """`[abc]`/`[!abc]` a pat[i] → (regex, indice dopo) o None se malformed."""
20
+ j = i + 1
21
+ neg = False
22
+ if j < len(pat) and pat[j] in '!^':
23
+ neg = True
24
+ j += 1
25
+ if j < len(pat) and pat[j] == ']': # ']' come primo carattere è letterale
26
+ j += 1
27
+ while j < len(pat) and pat[j] != ']':
28
+ j += 1
29
+ if j >= len(pat):
30
+ return None
31
+ body = re.sub(r'([\\\[\]])', r'\\\1', pat[i + 1:j])
32
+ return ('[^' + body + ']' if neg else '[' + body + ']'), j + 1
33
+
34
+
35
+ def _glob(pat):
36
+ """Pattern gitignore → corpo di regex (ancorata dal chiamante)."""
37
+ out = []
38
+ i, n = 0, len(pat)
39
+ while i < n:
40
+ if pat[i] == '\\' and i + 1 < n: # \x escape generico
41
+ out.append(re.escape(pat[i + 1]))
42
+ i += 2
43
+ elif pat[i:i + 2] == '**':
44
+ if pat[i:i + 3] == '**/': # a/**/b → segmenti multipli opzionali
45
+ out.append('(?:[^/]+/)*')
46
+ i += 3
47
+ else:
48
+ out.append('.*') # ** → qualsiasi segmenti (include /)
49
+ i += 2
50
+ elif pat[i] == '*': # * entro un singolo segmento
51
+ out.append('[^/]*')
52
+ i += 1
53
+ elif pat[i] == '?':
54
+ out.append('[^/]')
55
+ i += 1
56
+ elif pat[i] == '[':
57
+ cls = _charclass(pat, i)
58
+ if cls is None:
59
+ out.append('\\[')
60
+ i += 1
61
+ else:
62
+ out.append(cls[0])
63
+ i = cls[1]
64
+ else:
65
+ out.append(re.escape(pat[i]))
66
+ i += 1
67
+ return ''.join(out)
68
+
69
+
70
+ class Rule:
71
+ """Singola regola `include:`/`exclude:` con pattern gitignore-like."""
72
+
73
+ def __init__(self, pattern, include):
74
+ if '\0' in pattern:
75
+ # un pattern con NUL arriva da un `.sync` non attendibile e finirebbe
76
+ # in argv di fswatch → ValueError "embedded null byte" (CR-03)
77
+ raise ValueError(f'pattern contiene un carattere NUL: {pattern!r}')
78
+ self.pattern = pattern
79
+ self.include = include
80
+ self.dir_only = pattern.endswith('/') # foo/ → solo directory
81
+ p = pattern[:-1] if self.dir_only else pattern
82
+ self.anchored = '/' in p # /foo o slash interna → alla root
83
+ if p.startswith('/'):
84
+ p = p[1:]
85
+ # dir/** matcha anche dir stessa, così il pruning la esclude per intero
86
+ body = _glob(p[:-3]) + '(?:/.*)?' if p.endswith('/**') else _glob(p)
87
+ try:
88
+ self._re = re.compile('(?:' + body + r')\Z')
89
+ except re.error as e:
90
+ # `re.error` NON è un ValueError: un pattern malevolo/esotico
91
+ # (es. `[z-a]*`) va convertito così il parser lo gestisce come
92
+ # riga invalida invece di far crashare il daemon (CR-03)
93
+ raise ValueError(f'pattern regex non valido: {pattern!r} ({e})') from None
94
+
95
+ def matches(self, path, is_dir=False):
96
+ path = path.strip('/')
97
+ if not path:
98
+ return False
99
+ parts = path.split('/')
100
+ last = len(parts) - 1
101
+ for i, part in enumerate(parts):
102
+ if i == last and self.dir_only and not is_dir:
103
+ continue # foo/ non matcha un file chiamato foo
104
+ candidate = '/'.join(parts[:i + 1]) if self.anchored else part
105
+ if self._re.match(candidate):
106
+ return True # path o genitore directory
107
+ return False
108
+
109
+
110
+ def parse_rule(line):
111
+ """Rule da `include: <pattern>`/`exclude: <pattern>`; None per riga vuota/commento."""
112
+ s = strip_comment(line).strip()
113
+ if not s:
114
+ return None
115
+ m = re.match(r'(include|exclude)\s*:\s*(.*)\Z', s)
116
+ if not m:
117
+ raise ValueError(f'riga non valida (atteso include:/exclude:): {line.strip()!r}')
118
+ pattern = m.group(2).strip()
119
+ if not pattern:
120
+ raise ValueError(f'pattern vuoto: {line.strip()!r}')
121
+ return Rule(pattern, m.group(1) == 'include')
122
+
123
+
124
+ def parse_rules(lines, origin='<rules>'):
125
+ """Layer di regole da una lista di righe; riga invalida → errore con numero di riga."""
126
+ rules = []
127
+ for lineno, raw in enumerate(lines, 1):
128
+ try:
129
+ rule = parse_rule(raw)
130
+ except ValueError as e:
131
+ raise ValueError(f'{origin}:{lineno}: {e}') from None
132
+ if rule is not None:
133
+ rules.append(rule)
134
+ return rules
135
+
136
+
137
+ class Matcher:
138
+ """Layer di regole in ordine di priorità (bassa → alta), last-match-wins."""
139
+
140
+ def __init__(self, *layers):
141
+ self.rules = [rule for layer in layers for rule in layer]
142
+
143
+ def evaluate(self, relpath: str, is_dir: bool = False) -> bool:
144
+ """True = copiare. Ultima regola che matcha decide; nessun match →
145
+ allow-list (SPEC.md §4.3): file → False (non copiato), directory →
146
+ True (attraversata: si pota solo su un'esclusione esplicita)."""
147
+ for rule in reversed(self.rules):
148
+ if rule.matches(relpath, is_dir):
149
+ return rule.include
150
+ return is_dir
151
+
152
+
153
+ # Regole builtin (bassa priorità, sempre presenti — SPEC.md §5)
154
+ BUILTIN_RULES = parse_rules([
155
+ 'exclude: .git/',
156
+ 'exclude: node_modules/',
157
+ 'exclude: .venv/',
158
+ 'exclude: __pycache__/',
159
+ 'exclude: .DS_Store',
160
+ 'exclude: *.swp',
161
+ ], origin='builtin')
safekeep/volumes.py ADDED
@@ -0,0 +1,28 @@
1
+ """Destinazioni non montate: stato, backoff e split (SPEC.md §8.3)."""
2
+ import os
3
+
4
+ BACKOFF_CAP = 60 # secondi massimi fra due retry (niente busy-loop)
5
+
6
+
7
+ def dest_state(path) -> str:
8
+ """`ok` se il mountpoint esiste (volume montato), `absent` altrimenti.
9
+
10
+ Solo `os.path.exists`: nessun processo figlio, nessun `mount` da scrapare.
11
+ """
12
+ return 'ok' if os.path.exists(path) else 'absent'
13
+
14
+
15
+ def backoff_schedule():
16
+ """1, 2, 4, 8, 16, 32, 60, 60, … — iteratore infinito con cap a 60s."""
17
+ delay = 1
18
+ while True:
19
+ yield min(delay, BACKOFF_CAP)
20
+ delay *= 2
21
+
22
+
23
+ def partition_dests(dests):
24
+ """Split delle dest in (ready, absent) sullo stato corrente del mountpoint."""
25
+ ready, absent = [], []
26
+ for d in dests:
27
+ (ready if dest_state(d) == 'ok' else absent).append(d)
28
+ return ready, absent
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.4
2
+ Name: safekeep
3
+ Version: 0.2.0
4
+ Summary: Selective always-on backups for macOS: fswatch-driven copy of allow-listed files, never deletes
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://gitlab.com/foxhound87/safekeep
7
+ Project-URL: Repository, https://gitlab.com/foxhound87/safekeep
8
+ Keywords: backup,macos,fswatch,sync,allow-list,launchd
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: Operating System :: MacOS
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: System :: Archiving :: Backup
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Dynamic: license-file
19
+
20
+ # safekeep
21
+
22
+ **Selective always-on backups for macOS**: watches source folders with
23
+ [fswatch](https://github.com/emcrisostomo/fswatch) and copies only the files you
24
+ chose to their destinations — an **allow-list** model that **never deletes**
25
+ anything in the backup (a file removed or renamed at the source stays in the
26
+ backup).
27
+
28
+ ## Features
29
+
30
+ - **Allow-list**: a file is copied only if it matches at least one `include:`;
31
+ no match → not copied. Unmatched directories are still traversed, while
32
+ directories with an *excluded* verdict are pruned together with their subtree.
33
+ - **Atomic copy, never a half-written file**: the temp file lives in the
34
+ destination's own directory, followed by `fsync` + `os.replace` → a reader
35
+ sees either the old file or the new one, never a partial copy.
36
+ - **Reconcile** at startup, every 24h, on remount, and on `sync-once`: copies
37
+ whatever differs by size/mtime, idempotently — lost events, crashes and reboots
38
+ don't matter, the next reconcile brings everything back in sync.
39
+ - **Unmounted volumes**: missing destination → `pending` state with exponential
40
+ backoff (1s → 60s cap), then a reconcile once the mount is back.
41
+ - **launchd at boot**: an agent with `RunAtLoad` + `KeepAlive` keeps the process
42
+ alive and restarts it if it dies.
43
+ - **`.sync` carries rules only, destinations live only in `~/.safekeep`**: a
44
+ `.sync` file can neither add nor remove destinations (a `dest:` line is an
45
+ invalid line), so a repo cloned from a third party can't redirect the backup
46
+ somewhere else.
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ pipx install safekeep # recommended for a CLI
52
+ # or
53
+ pip install safekeep
54
+ ```
55
+
56
+ **Requirements**: macOS, Python >= 3.9 and
57
+ [fswatch](https://github.com/emcrisostomo/fswatch):
58
+
59
+ ```bash
60
+ brew install fswatch
61
+ ```
62
+
63
+ The **launchd agent** (daemon at boot) is installed from a checkout of this
64
+ repository with `./install.sh` — not from the wheel — see
65
+ [Agent (launchd)](#agent-launchd) below.
66
+
67
+ ## Agent (launchd)
68
+
69
+ ```bash
70
+ git clone https://gitlab.com/foxhound87/safekeep.git
71
+ cd safekeep
72
+
73
+ # only external dependency, if not installed yet
74
+ brew install fswatch
75
+
76
+ # copies the example into ~/.safekeep, renders the plist, runs preflight checks
77
+ bash install.sh
78
+ ```
79
+
80
+ Then:
81
+
82
+ 1. Fill `~/.safekeep` with your real `dest:` entries — and, optionally, `source:`
83
+ (the example ships with placeholders — see
84
+ [`examples/safekeep.example`](examples/safekeep.example));
85
+ 2. drop a `.sync` file in the root of every project you want to follow (see
86
+ [`examples/sync.example`](examples/sync.example)); a directory **without** a
87
+ `.sync` is not tracked;
88
+ 3. load the agent:
89
+
90
+ ```bash
91
+ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.safekeep.agent.plist
92
+ # unload with: launchctl bootout gui/$(id -u)/com.safekeep.agent
93
+ ```
94
+
95
+ Once you have granted TCC/FDA (Full Disk Access) permissions to the Python
96
+ interpreter and to `fswatch`, run `safekeep doctor` for diagnostics.
97
+
98
+ ## Quickstart
99
+
100
+ `~/.safekeep` (the only place where destinations live):
101
+
102
+ ```bash
103
+ # observed roots: only directories containing a .sync are followed
104
+ source: ~/Code
105
+ source: ~/Projects
106
+
107
+ # destination for ALL projects
108
+ dest: ~/Backup/safekeep
109
+
110
+ # relative (default): <dest>/<path relative to the source root>
111
+ # ~/Code/myapp/docs/a.md → ~/Backup/safekeep/myapp/docs/a.md
112
+ layout: relative
113
+
114
+ log_level: info
115
+ ```
116
+
117
+ `source:` is **optional**. With it present (source mode) the watched roots are
118
+ exactly those entries, as always. Without it safekeep switches to
119
+ **auto-discovery**: it scans `$HOME` for `.sync` files (skipping hidden
120
+ directories, `Library`, `.Trash`, `.cache`, `node_modules`, `.git`, `.venv`,
121
+ `__pycache__`, `venv`), watches `$HOME`, and picks up any `.sync` created after
122
+ startup. `dest:` stays mandatory in both modes.
123
+
124
+ `~/Code/myapp/.sync` (rules only, no destinations):
125
+
126
+ ```bash
127
+ name: myapp
128
+
129
+ # allow-list semantics: without these lines NOT A SINGLE file would be copied
130
+ *.md
131
+ .env
132
+ !secrets/old.env # ! = exclude
133
+ ```
134
+
135
+ Rules can be written as bare gitignore-style lines — **the inverse of gitignore**:
136
+ a line lists what to **copy**, not what to ignore (`*.md` includes markdown here,
137
+ excludes it in a `.gitignore`), and a leading `!` turns it into an `exclude:`.
138
+ Bare lines share the same ordered list as the explicit `include:`/`exclude:`
139
+ keys, so last-match-wins works the same way (see
140
+ [`examples/sync.example`](examples/sync.example)).
141
+
142
+ Dry run first, then start the daemon:
143
+
144
+ ```bash
145
+ safekeep sync-once --dry-run # prints what would be copied, copies nothing
146
+ safekeep run # daemon: watch + copy (foreground)
147
+ ```
148
+
149
+ ## CLI commands
150
+
151
+ ```
152
+ safekeep <command> [--config PATH] [-v]
153
+ ```
154
+
155
+ | Command | What it does |
156
+ |---|---|
157
+ | `run` | daemon: initial reconcile, fswatch loop, event dispatch, 24h timer |
158
+ | `sync-once [--dry-run] [--project PATH] [--prune]` | a single pass: walks the source and copies whatever differs (`--dry-run` only prints what it would copy; `--prune` also removes dest files whose source still exists but is no longer included) |
159
+ | `status` | read-only: config, sources, discovered projects with N rules, destination states |
160
+ | `doctor` | diagnostics: config, fswatch, TCC, launchd plist — exits non-zero if a fatal check fails |
161
+
162
+ ## Tests
163
+
164
+ ```bash
165
+ python3 -m unittest discover -s tests
166
+ ```
167
+
168
+ Stdlib (`unittest`) suite, zero dependencies: 156 tests covering the matcher,
169
+ config, atomic copy, volumes, daemon, auto-discovery and CLI. `fswatch` is not
170
+ needed to run the tests.
171
+
172
+ ## Documentation
173
+
174
+ Everything in detail (config format, pattern semantics, event → sync flow,
175
+ atomic copy, launchd, edge cases) lives in [`SPEC.md`](SPEC.md); commented
176
+ examples in [`examples/`](examples/).
177
+
178
+ ## License
179
+
180
+ MIT — see [LICENSE](LICENSE)
@@ -0,0 +1,13 @@
1
+ safekeep/__init__.py,sha256=JYMz2afeGarcNRe9G1XF17PKF70cYS75cIh3EAt8ndU,178
2
+ safekeep/cli.py,sha256=b4JuHhuuKFukCl1wgXxYppF2UCcKvGhmBcnOPWipUY0,11392
3
+ safekeep/config.py,sha256=oAwUw5KITp8oyYQx8X9yF_ftRTh65CMRR4BmxjFrgwc,11668
4
+ safekeep/copier.py,sha256=KdGZe3lwKWepA_3qVX52BjFHfsmbQUsls_CVBKU9s4g,13750
5
+ safekeep/daemon.py,sha256=dXjJox00KXcjB6Hmsmy8BvLwSjbkASapFWg9W4USjRo,30394
6
+ safekeep/matcher.py,sha256=EwkE378Wo9-n56hw_wnD3un5UdRy0aG1Q-KM5nETvdQ,6008
7
+ safekeep/volumes.py,sha256=v3fBGp7Ir_IVBR2PQSU44_Oxk6DE3griBntPbTOwicQ,849
8
+ safekeep-0.2.0.dist-info/licenses/LICENSE,sha256=1WM2NMXTKpFlFQ2JItrUnblhj4Hybdo9U6gzipCh-Fs,1067
9
+ safekeep-0.2.0.dist-info/METADATA,sha256=T4oxx1D_mtY35n1df0egZJEKBv8yft_lQKyn4ZOHbfk,6457
10
+ safekeep-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ safekeep-0.2.0.dist-info/entry_points.txt,sha256=5XT-DVH6stDI99sx0dzDGsqP3zHTd92GBo8XhczThHg,47
12
+ safekeep-0.2.0.dist-info/top_level.txt,sha256=ZNrpaPE43W6IK3vPTA-U_R_RYt8f0T2J5-DS_NOKN6M,9
13
+ safekeep-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ safekeep = safekeep.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 foxhound87
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ safekeep