safekeep 0.2.0__tar.gz
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-0.2.0/LICENSE +21 -0
- safekeep-0.2.0/PKG-INFO +180 -0
- safekeep-0.2.0/README.md +161 -0
- safekeep-0.2.0/pyproject.toml +37 -0
- safekeep-0.2.0/safekeep/__init__.py +6 -0
- safekeep-0.2.0/safekeep/cli.py +272 -0
- safekeep-0.2.0/safekeep/config.py +266 -0
- safekeep-0.2.0/safekeep/copier.py +333 -0
- safekeep-0.2.0/safekeep/daemon.py +692 -0
- safekeep-0.2.0/safekeep/matcher.py +161 -0
- safekeep-0.2.0/safekeep/volumes.py +28 -0
- safekeep-0.2.0/safekeep.egg-info/PKG-INFO +180 -0
- safekeep-0.2.0/safekeep.egg-info/SOURCES.txt +21 -0
- safekeep-0.2.0/safekeep.egg-info/dependency_links.txt +1 -0
- safekeep-0.2.0/safekeep.egg-info/entry_points.txt +2 -0
- safekeep-0.2.0/safekeep.egg-info/top_level.txt +1 -0
- safekeep-0.2.0/setup.cfg +4 -0
- safekeep-0.2.0/tests/test_cli.py +221 -0
- safekeep-0.2.0/tests/test_config.py +384 -0
- safekeep-0.2.0/tests/test_copier.py +336 -0
- safekeep-0.2.0/tests/test_daemon.py +788 -0
- safekeep-0.2.0/tests/test_matcher.py +189 -0
- safekeep-0.2.0/tests/test_volumes.py +45 -0
safekeep-0.2.0/LICENSE
ADDED
|
@@ -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.
|
safekeep-0.2.0/PKG-INFO
ADDED
|
@@ -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)
|
safekeep-0.2.0/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# safekeep
|
|
2
|
+
|
|
3
|
+
**Selective always-on backups for macOS**: watches source folders with
|
|
4
|
+
[fswatch](https://github.com/emcrisostomo/fswatch) and copies only the files you
|
|
5
|
+
chose to their destinations — an **allow-list** model that **never deletes**
|
|
6
|
+
anything in the backup (a file removed or renamed at the source stays in the
|
|
7
|
+
backup).
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Allow-list**: a file is copied only if it matches at least one `include:`;
|
|
12
|
+
no match → not copied. Unmatched directories are still traversed, while
|
|
13
|
+
directories with an *excluded* verdict are pruned together with their subtree.
|
|
14
|
+
- **Atomic copy, never a half-written file**: the temp file lives in the
|
|
15
|
+
destination's own directory, followed by `fsync` + `os.replace` → a reader
|
|
16
|
+
sees either the old file or the new one, never a partial copy.
|
|
17
|
+
- **Reconcile** at startup, every 24h, on remount, and on `sync-once`: copies
|
|
18
|
+
whatever differs by size/mtime, idempotently — lost events, crashes and reboots
|
|
19
|
+
don't matter, the next reconcile brings everything back in sync.
|
|
20
|
+
- **Unmounted volumes**: missing destination → `pending` state with exponential
|
|
21
|
+
backoff (1s → 60s cap), then a reconcile once the mount is back.
|
|
22
|
+
- **launchd at boot**: an agent with `RunAtLoad` + `KeepAlive` keeps the process
|
|
23
|
+
alive and restarts it if it dies.
|
|
24
|
+
- **`.sync` carries rules only, destinations live only in `~/.safekeep`**: a
|
|
25
|
+
`.sync` file can neither add nor remove destinations (a `dest:` line is an
|
|
26
|
+
invalid line), so a repo cloned from a third party can't redirect the backup
|
|
27
|
+
somewhere else.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pipx install safekeep # recommended for a CLI
|
|
33
|
+
# or
|
|
34
|
+
pip install safekeep
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**Requirements**: macOS, Python >= 3.9 and
|
|
38
|
+
[fswatch](https://github.com/emcrisostomo/fswatch):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
brew install fswatch
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The **launchd agent** (daemon at boot) is installed from a checkout of this
|
|
45
|
+
repository with `./install.sh` — not from the wheel — see
|
|
46
|
+
[Agent (launchd)](#agent-launchd) below.
|
|
47
|
+
|
|
48
|
+
## Agent (launchd)
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git clone https://gitlab.com/foxhound87/safekeep.git
|
|
52
|
+
cd safekeep
|
|
53
|
+
|
|
54
|
+
# only external dependency, if not installed yet
|
|
55
|
+
brew install fswatch
|
|
56
|
+
|
|
57
|
+
# copies the example into ~/.safekeep, renders the plist, runs preflight checks
|
|
58
|
+
bash install.sh
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then:
|
|
62
|
+
|
|
63
|
+
1. Fill `~/.safekeep` with your real `dest:` entries — and, optionally, `source:`
|
|
64
|
+
(the example ships with placeholders — see
|
|
65
|
+
[`examples/safekeep.example`](examples/safekeep.example));
|
|
66
|
+
2. drop a `.sync` file in the root of every project you want to follow (see
|
|
67
|
+
[`examples/sync.example`](examples/sync.example)); a directory **without** a
|
|
68
|
+
`.sync` is not tracked;
|
|
69
|
+
3. load the agent:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.safekeep.agent.plist
|
|
73
|
+
# unload with: launchctl bootout gui/$(id -u)/com.safekeep.agent
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Once you have granted TCC/FDA (Full Disk Access) permissions to the Python
|
|
77
|
+
interpreter and to `fswatch`, run `safekeep doctor` for diagnostics.
|
|
78
|
+
|
|
79
|
+
## Quickstart
|
|
80
|
+
|
|
81
|
+
`~/.safekeep` (the only place where destinations live):
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# observed roots: only directories containing a .sync are followed
|
|
85
|
+
source: ~/Code
|
|
86
|
+
source: ~/Projects
|
|
87
|
+
|
|
88
|
+
# destination for ALL projects
|
|
89
|
+
dest: ~/Backup/safekeep
|
|
90
|
+
|
|
91
|
+
# relative (default): <dest>/<path relative to the source root>
|
|
92
|
+
# ~/Code/myapp/docs/a.md → ~/Backup/safekeep/myapp/docs/a.md
|
|
93
|
+
layout: relative
|
|
94
|
+
|
|
95
|
+
log_level: info
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`source:` is **optional**. With it present (source mode) the watched roots are
|
|
99
|
+
exactly those entries, as always. Without it safekeep switches to
|
|
100
|
+
**auto-discovery**: it scans `$HOME` for `.sync` files (skipping hidden
|
|
101
|
+
directories, `Library`, `.Trash`, `.cache`, `node_modules`, `.git`, `.venv`,
|
|
102
|
+
`__pycache__`, `venv`), watches `$HOME`, and picks up any `.sync` created after
|
|
103
|
+
startup. `dest:` stays mandatory in both modes.
|
|
104
|
+
|
|
105
|
+
`~/Code/myapp/.sync` (rules only, no destinations):
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
name: myapp
|
|
109
|
+
|
|
110
|
+
# allow-list semantics: without these lines NOT A SINGLE file would be copied
|
|
111
|
+
*.md
|
|
112
|
+
.env
|
|
113
|
+
!secrets/old.env # ! = exclude
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Rules can be written as bare gitignore-style lines — **the inverse of gitignore**:
|
|
117
|
+
a line lists what to **copy**, not what to ignore (`*.md` includes markdown here,
|
|
118
|
+
excludes it in a `.gitignore`), and a leading `!` turns it into an `exclude:`.
|
|
119
|
+
Bare lines share the same ordered list as the explicit `include:`/`exclude:`
|
|
120
|
+
keys, so last-match-wins works the same way (see
|
|
121
|
+
[`examples/sync.example`](examples/sync.example)).
|
|
122
|
+
|
|
123
|
+
Dry run first, then start the daemon:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
safekeep sync-once --dry-run # prints what would be copied, copies nothing
|
|
127
|
+
safekeep run # daemon: watch + copy (foreground)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## CLI commands
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
safekeep <command> [--config PATH] [-v]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
| Command | What it does |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `run` | daemon: initial reconcile, fswatch loop, event dispatch, 24h timer |
|
|
139
|
+
| `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) |
|
|
140
|
+
| `status` | read-only: config, sources, discovered projects with N rules, destination states |
|
|
141
|
+
| `doctor` | diagnostics: config, fswatch, TCC, launchd plist — exits non-zero if a fatal check fails |
|
|
142
|
+
|
|
143
|
+
## Tests
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
python3 -m unittest discover -s tests
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Stdlib (`unittest`) suite, zero dependencies: 156 tests covering the matcher,
|
|
150
|
+
config, atomic copy, volumes, daemon, auto-discovery and CLI. `fswatch` is not
|
|
151
|
+
needed to run the tests.
|
|
152
|
+
|
|
153
|
+
## Documentation
|
|
154
|
+
|
|
155
|
+
Everything in detail (config format, pattern semantics, event → sync flow,
|
|
156
|
+
atomic copy, launchd, edge cases) lives in [`SPEC.md`](SPEC.md); commented
|
|
157
|
+
examples in [`examples/`](examples/).
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
MIT — see [LICENSE](LICENSE)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Packaging per PyPI: package stdlib-only, un solo console script.
|
|
2
|
+
[build-system]
|
|
3
|
+
requires = ["setuptools>=77"]
|
|
4
|
+
build-backend = "setuptools.build_meta"
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "safekeep"
|
|
8
|
+
dynamic = ["version"]
|
|
9
|
+
description = "Selective always-on backups for macOS: fswatch-driven copy of allow-listed files, never deletes"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
keywords = ["backup", "macos", "fswatch", "sync", "allow-list", "launchd"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: System Administrators",
|
|
19
|
+
"Operating System :: MacOS",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: System :: Archiving :: Backup",
|
|
22
|
+
]
|
|
23
|
+
# zero runtime dependencies: solo stdlib (fswatch è un binario esterno)
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://gitlab.com/foxhound87/safekeep"
|
|
27
|
+
Repository = "https://gitlab.com/foxhound87/safekeep"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
safekeep = "safekeep.cli:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools]
|
|
33
|
+
# NO auto-discovery: bin/ e tests/ farebbero errore di build
|
|
34
|
+
packages = ["safekeep"]
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.dynamic]
|
|
37
|
+
version = {attr = "safekeep.__version__"}
|
|
@@ -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())
|