safekeep 0.2.0__tar.gz → 0.3.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 → safekeep-0.3.0}/PKG-INFO +51 -23
- safekeep-0.2.0/safekeep.egg-info/PKG-INFO → safekeep-0.3.0/README.md +40 -37
- {safekeep-0.2.0 → safekeep-0.3.0}/pyproject.toml +10 -4
- safekeep-0.3.0/safekeep/__init__.py +6 -0
- {safekeep-0.2.0 → safekeep-0.3.0}/safekeep/cli.py +79 -32
- {safekeep-0.2.0 → safekeep-0.3.0}/safekeep/daemon.py +9 -6
- safekeep-0.3.0/safekeep/platform.py +57 -0
- safekeep-0.2.0/README.md → safekeep-0.3.0/safekeep.egg-info/PKG-INFO +65 -18
- {safekeep-0.2.0 → safekeep-0.3.0}/safekeep.egg-info/SOURCES.txt +2 -0
- {safekeep-0.2.0 → safekeep-0.3.0}/tests/test_cli.py +56 -2
- {safekeep-0.2.0 → safekeep-0.3.0}/tests/test_daemon.py +18 -3
- safekeep-0.3.0/tests/test_platform.py +150 -0
- safekeep-0.2.0/safekeep/__init__.py +0 -6
- {safekeep-0.2.0 → safekeep-0.3.0}/LICENSE +0 -0
- {safekeep-0.2.0 → safekeep-0.3.0}/safekeep/config.py +0 -0
- {safekeep-0.2.0 → safekeep-0.3.0}/safekeep/copier.py +0 -0
- {safekeep-0.2.0 → safekeep-0.3.0}/safekeep/matcher.py +0 -0
- {safekeep-0.2.0 → safekeep-0.3.0}/safekeep/volumes.py +0 -0
- {safekeep-0.2.0 → safekeep-0.3.0}/safekeep.egg-info/dependency_links.txt +0 -0
- {safekeep-0.2.0 → safekeep-0.3.0}/safekeep.egg-info/entry_points.txt +0 -0
- {safekeep-0.2.0 → safekeep-0.3.0}/safekeep.egg-info/top_level.txt +0 -0
- {safekeep-0.2.0 → safekeep-0.3.0}/setup.cfg +0 -0
- {safekeep-0.2.0 → safekeep-0.3.0}/tests/test_config.py +0 -0
- {safekeep-0.2.0 → safekeep-0.3.0}/tests/test_copier.py +0 -0
- {safekeep-0.2.0 → safekeep-0.3.0}/tests/test_matcher.py +0 -0
- {safekeep-0.2.0 → safekeep-0.3.0}/tests/test_volumes.py +0 -0
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: safekeep
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Selective always-on backups for macOS: fswatch-driven copy of allow-listed files, never deletes
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Selective always-on backups for macOS and Linux: fswatch-driven copy of allow-listed files, never deletes
|
|
5
5
|
License-Expression: MIT
|
|
6
|
-
Project-URL:
|
|
7
|
-
Project-URL:
|
|
8
|
-
|
|
6
|
+
Project-URL: Repository, https://github.com/foxhound87/safekeep
|
|
7
|
+
Project-URL: Homepage, https://foxhound87.github.io/safekeep/
|
|
8
|
+
Project-URL: Documentation, https://foxhound87.github.io/safekeep/
|
|
9
|
+
Keywords: backup,macos,linux,fswatch,sync,allow-list,launchd,systemd
|
|
9
10
|
Classifier: Development Status :: 4 - Beta
|
|
10
11
|
Classifier: Environment :: Console
|
|
11
12
|
Classifier: Intended Audience :: System Administrators
|
|
12
13
|
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: POSIX
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
16
|
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
20
|
Classifier: Topic :: System :: Archiving :: Backup
|
|
15
21
|
Requires-Python: >=3.9
|
|
16
22
|
Description-Content-Type: text/markdown
|
|
@@ -19,12 +25,17 @@ Dynamic: license-file
|
|
|
19
25
|
|
|
20
26
|
# safekeep
|
|
21
27
|
|
|
22
|
-
|
|
28
|
+
[](https://pypi.org/project/safekeep/)
|
|
29
|
+
[](https://pypi.org/project/safekeep/)
|
|
30
|
+
|
|
31
|
+
**Selective always-on backups for macOS and Linux**: watches source folders with
|
|
23
32
|
[fswatch](https://github.com/emcrisostomo/fswatch) and copies only the files you
|
|
24
33
|
chose to their destinations — an **allow-list** model that **never deletes**
|
|
25
34
|
anything in the backup (a file removed or renamed at the source stays in the
|
|
26
35
|
backup).
|
|
27
36
|
|
|
37
|
+
Documentation: https://foxhound87.github.io/safekeep/
|
|
38
|
+
|
|
28
39
|
## Features
|
|
29
40
|
|
|
30
41
|
- **Allow-list**: a file is copied only if it matches at least one `include:`;
|
|
@@ -38,7 +49,8 @@ backup).
|
|
|
38
49
|
don't matter, the next reconcile brings everything back in sync.
|
|
39
50
|
- **Unmounted volumes**: missing destination → `pending` state with exponential
|
|
40
51
|
backoff (1s → 60s cap), then a reconcile once the mount is back.
|
|
41
|
-
- **launchd at boot**: an agent with `RunAtLoad` +
|
|
52
|
+
- **launchd (macOS) or systemd (Linux) at boot**: an agent with `RunAtLoad` +
|
|
53
|
+
`KeepAlive` (or `WantedBy=default.target` + `Restart=always`) keeps the process
|
|
42
54
|
alive and restarts it if it dies.
|
|
43
55
|
- **`.sync` carries rules only, destinations live only in `~/.safekeep`**: a
|
|
44
56
|
`.sync` file can neither add nor remove destinations (a `dest:` line is an
|
|
@@ -53,27 +65,31 @@ pipx install safekeep # recommended for a CLI
|
|
|
53
65
|
pip install safekeep
|
|
54
66
|
```
|
|
55
67
|
|
|
56
|
-
**Requirements**: macOS, Python >= 3.9 and
|
|
68
|
+
**Requirements**: macOS or Linux (POSIX with systemd), Python >= 3.9 and
|
|
57
69
|
[fswatch](https://github.com/emcrisostomo/fswatch):
|
|
58
70
|
|
|
59
71
|
```bash
|
|
60
|
-
brew install fswatch
|
|
72
|
+
brew install fswatch # macOS
|
|
73
|
+
sudo pacman -S fswatch # Arch / Omarchy
|
|
74
|
+
sudo apt install fswatch # Debian / Ubuntu
|
|
75
|
+
sudo dnf install fswatch # Fedora
|
|
61
76
|
```
|
|
62
77
|
|
|
63
|
-
The **
|
|
64
|
-
repository with `./install.sh` — not from
|
|
65
|
-
[Agent
|
|
78
|
+
The **agent** (daemon at boot: launchd on macOS, a systemd *user unit* on Linux)
|
|
79
|
+
is installed from a checkout of this repository with `./install.sh` — not from
|
|
80
|
+
the wheel — see [Agent](#agent) below.
|
|
66
81
|
|
|
67
|
-
## Agent
|
|
82
|
+
## Agent
|
|
68
83
|
|
|
69
84
|
```bash
|
|
70
|
-
git clone https://
|
|
85
|
+
git clone https://github.com/foxhound87/safekeep.git
|
|
71
86
|
cd safekeep
|
|
72
87
|
|
|
73
|
-
# only external dependency, if not installed yet
|
|
74
|
-
brew install fswatch
|
|
88
|
+
# only external dependency, if not installed yet (see the per-OS list above)
|
|
89
|
+
brew install fswatch # macOS — the script suggests the right command on Linux
|
|
75
90
|
|
|
76
|
-
# copies the example into ~/.safekeep, renders the plist
|
|
91
|
+
# copies the example into ~/.safekeep, renders the plist / systemd unit,
|
|
92
|
+
# runs preflight checks
|
|
77
93
|
bash install.sh
|
|
78
94
|
```
|
|
79
95
|
|
|
@@ -88,12 +104,21 @@ Then:
|
|
|
88
104
|
3. load the agent:
|
|
89
105
|
|
|
90
106
|
```bash
|
|
107
|
+
# macOS (launchd)
|
|
91
108
|
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.safekeep.agent.plist
|
|
92
109
|
# unload with: launchctl bootout gui/$(id -u)/com.safekeep.agent
|
|
110
|
+
|
|
111
|
+
# Linux (systemd user unit)
|
|
112
|
+
systemctl --user enable --now safekeep
|
|
113
|
+
# status with: systemctl --user status safekeep
|
|
114
|
+
# logs with: journalctl --user -u safekeep -f
|
|
115
|
+
# unload with: systemctl --user disable --now safekeep
|
|
116
|
+
# start at boot without a login session: loginctl enable-linger $USER
|
|
93
117
|
```
|
|
94
118
|
|
|
95
|
-
|
|
96
|
-
interpreter and to `fswatch`, run `safekeep doctor` for diagnostics.
|
|
119
|
+
On macOS, once you have granted TCC/FDA (Full Disk Access) permissions to the
|
|
120
|
+
Python interpreter and to `fswatch`, run `safekeep doctor` for diagnostics. On
|
|
121
|
+
Linux the same command checks the `inotify` watch limit instead.
|
|
97
122
|
|
|
98
123
|
## Quickstart
|
|
99
124
|
|
|
@@ -157,7 +182,7 @@ safekeep <command> [--config PATH] [-v]
|
|
|
157
182
|
| `run` | daemon: initial reconcile, fswatch loop, event dispatch, 24h timer |
|
|
158
183
|
| `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
184
|
| `status` | read-only: config, sources, discovered projects with N rules, destination states |
|
|
160
|
-
| `doctor` | diagnostics: config, fswatch,
|
|
185
|
+
| `doctor` | diagnostics: config, fswatch + platform monitor, python, TCC/launchd plist (macOS), inotify limit and systemd unit (Linux) — exits non-zero if a fatal check fails |
|
|
161
186
|
|
|
162
187
|
## Tests
|
|
163
188
|
|
|
@@ -165,9 +190,12 @@ safekeep <command> [--config PATH] [-v]
|
|
|
165
190
|
python3 -m unittest discover -s tests
|
|
166
191
|
```
|
|
167
192
|
|
|
168
|
-
Stdlib (`unittest`) suite, zero dependencies:
|
|
169
|
-
config, atomic copy, volumes, daemon, auto-discovery
|
|
170
|
-
|
|
193
|
+
Stdlib (`unittest`) suite, zero dependencies: 175 tests covering the matcher,
|
|
194
|
+
config, atomic copy, volumes, daemon, auto-discovery, CLI and the POSIX
|
|
195
|
+
portability layer (platform helper, per-OS fswatch monitor, systemd template).
|
|
196
|
+
`fswatch` is not needed to run the suite — the few `doctor` checks that talk to
|
|
197
|
+
the real binary are skipped when it's missing — but CI installs it so the
|
|
198
|
+
Linux job exercises the real `inotify` monitor.
|
|
171
199
|
|
|
172
200
|
## Documentation
|
|
173
201
|
|
|
@@ -1,30 +1,16 @@
|
|
|
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
1
|
# safekeep
|
|
21
2
|
|
|
22
|
-
|
|
3
|
+
[](https://pypi.org/project/safekeep/)
|
|
4
|
+
[](https://pypi.org/project/safekeep/)
|
|
5
|
+
|
|
6
|
+
**Selective always-on backups for macOS and Linux**: watches source folders with
|
|
23
7
|
[fswatch](https://github.com/emcrisostomo/fswatch) and copies only the files you
|
|
24
8
|
chose to their destinations — an **allow-list** model that **never deletes**
|
|
25
9
|
anything in the backup (a file removed or renamed at the source stays in the
|
|
26
10
|
backup).
|
|
27
11
|
|
|
12
|
+
Documentation: https://foxhound87.github.io/safekeep/
|
|
13
|
+
|
|
28
14
|
## Features
|
|
29
15
|
|
|
30
16
|
- **Allow-list**: a file is copied only if it matches at least one `include:`;
|
|
@@ -38,7 +24,8 @@ backup).
|
|
|
38
24
|
don't matter, the next reconcile brings everything back in sync.
|
|
39
25
|
- **Unmounted volumes**: missing destination → `pending` state with exponential
|
|
40
26
|
backoff (1s → 60s cap), then a reconcile once the mount is back.
|
|
41
|
-
- **launchd at boot**: an agent with `RunAtLoad` +
|
|
27
|
+
- **launchd (macOS) or systemd (Linux) at boot**: an agent with `RunAtLoad` +
|
|
28
|
+
`KeepAlive` (or `WantedBy=default.target` + `Restart=always`) keeps the process
|
|
42
29
|
alive and restarts it if it dies.
|
|
43
30
|
- **`.sync` carries rules only, destinations live only in `~/.safekeep`**: a
|
|
44
31
|
`.sync` file can neither add nor remove destinations (a `dest:` line is an
|
|
@@ -53,27 +40,31 @@ pipx install safekeep # recommended for a CLI
|
|
|
53
40
|
pip install safekeep
|
|
54
41
|
```
|
|
55
42
|
|
|
56
|
-
**Requirements**: macOS, Python >= 3.9 and
|
|
43
|
+
**Requirements**: macOS or Linux (POSIX with systemd), Python >= 3.9 and
|
|
57
44
|
[fswatch](https://github.com/emcrisostomo/fswatch):
|
|
58
45
|
|
|
59
46
|
```bash
|
|
60
|
-
brew install fswatch
|
|
47
|
+
brew install fswatch # macOS
|
|
48
|
+
sudo pacman -S fswatch # Arch / Omarchy
|
|
49
|
+
sudo apt install fswatch # Debian / Ubuntu
|
|
50
|
+
sudo dnf install fswatch # Fedora
|
|
61
51
|
```
|
|
62
52
|
|
|
63
|
-
The **
|
|
64
|
-
repository with `./install.sh` — not from
|
|
65
|
-
[Agent
|
|
53
|
+
The **agent** (daemon at boot: launchd on macOS, a systemd *user unit* on Linux)
|
|
54
|
+
is installed from a checkout of this repository with `./install.sh` — not from
|
|
55
|
+
the wheel — see [Agent](#agent) below.
|
|
66
56
|
|
|
67
|
-
## Agent
|
|
57
|
+
## Agent
|
|
68
58
|
|
|
69
59
|
```bash
|
|
70
|
-
git clone https://
|
|
60
|
+
git clone https://github.com/foxhound87/safekeep.git
|
|
71
61
|
cd safekeep
|
|
72
62
|
|
|
73
|
-
# only external dependency, if not installed yet
|
|
74
|
-
brew install fswatch
|
|
63
|
+
# only external dependency, if not installed yet (see the per-OS list above)
|
|
64
|
+
brew install fswatch # macOS — the script suggests the right command on Linux
|
|
75
65
|
|
|
76
|
-
# copies the example into ~/.safekeep, renders the plist
|
|
66
|
+
# copies the example into ~/.safekeep, renders the plist / systemd unit,
|
|
67
|
+
# runs preflight checks
|
|
77
68
|
bash install.sh
|
|
78
69
|
```
|
|
79
70
|
|
|
@@ -88,12 +79,21 @@ Then:
|
|
|
88
79
|
3. load the agent:
|
|
89
80
|
|
|
90
81
|
```bash
|
|
82
|
+
# macOS (launchd)
|
|
91
83
|
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.safekeep.agent.plist
|
|
92
84
|
# unload with: launchctl bootout gui/$(id -u)/com.safekeep.agent
|
|
85
|
+
|
|
86
|
+
# Linux (systemd user unit)
|
|
87
|
+
systemctl --user enable --now safekeep
|
|
88
|
+
# status with: systemctl --user status safekeep
|
|
89
|
+
# logs with: journalctl --user -u safekeep -f
|
|
90
|
+
# unload with: systemctl --user disable --now safekeep
|
|
91
|
+
# start at boot without a login session: loginctl enable-linger $USER
|
|
93
92
|
```
|
|
94
93
|
|
|
95
|
-
|
|
96
|
-
interpreter and to `fswatch`, run `safekeep doctor` for diagnostics.
|
|
94
|
+
On macOS, once you have granted TCC/FDA (Full Disk Access) permissions to the
|
|
95
|
+
Python interpreter and to `fswatch`, run `safekeep doctor` for diagnostics. On
|
|
96
|
+
Linux the same command checks the `inotify` watch limit instead.
|
|
97
97
|
|
|
98
98
|
## Quickstart
|
|
99
99
|
|
|
@@ -157,7 +157,7 @@ safekeep <command> [--config PATH] [-v]
|
|
|
157
157
|
| `run` | daemon: initial reconcile, fswatch loop, event dispatch, 24h timer |
|
|
158
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
159
|
| `status` | read-only: config, sources, discovered projects with N rules, destination states |
|
|
160
|
-
| `doctor` | diagnostics: config, fswatch,
|
|
160
|
+
| `doctor` | diagnostics: config, fswatch + platform monitor, python, TCC/launchd plist (macOS), inotify limit and systemd unit (Linux) — exits non-zero if a fatal check fails |
|
|
161
161
|
|
|
162
162
|
## Tests
|
|
163
163
|
|
|
@@ -165,9 +165,12 @@ safekeep <command> [--config PATH] [-v]
|
|
|
165
165
|
python3 -m unittest discover -s tests
|
|
166
166
|
```
|
|
167
167
|
|
|
168
|
-
Stdlib (`unittest`) suite, zero dependencies:
|
|
169
|
-
config, atomic copy, volumes, daemon, auto-discovery
|
|
170
|
-
|
|
168
|
+
Stdlib (`unittest`) suite, zero dependencies: 175 tests covering the matcher,
|
|
169
|
+
config, atomic copy, volumes, daemon, auto-discovery, CLI and the POSIX
|
|
170
|
+
portability layer (platform helper, per-OS fswatch monitor, systemd template).
|
|
171
|
+
`fswatch` is not needed to run the suite — the few `doctor` checks that talk to
|
|
172
|
+
the real binary are skipped when it's missing — but CI installs it so the
|
|
173
|
+
Linux job exercises the real `inotify` monitor.
|
|
171
174
|
|
|
172
175
|
## Documentation
|
|
173
176
|
|
|
@@ -6,25 +6,31 @@ build-backend = "setuptools.build_meta"
|
|
|
6
6
|
[project]
|
|
7
7
|
name = "safekeep"
|
|
8
8
|
dynamic = ["version"]
|
|
9
|
-
description = "Selective always-on backups for macOS: fswatch-driven copy of allow-listed files, never deletes"
|
|
9
|
+
description = "Selective always-on backups for macOS and Linux: fswatch-driven copy of allow-listed files, never deletes"
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
license = "MIT"
|
|
12
12
|
license-files = ["LICENSE"]
|
|
13
13
|
requires-python = ">=3.9"
|
|
14
|
-
keywords = ["backup", "macos", "fswatch", "sync", "allow-list", "launchd"]
|
|
14
|
+
keywords = ["backup", "macos", "linux", "fswatch", "sync", "allow-list", "launchd", "systemd"]
|
|
15
15
|
classifiers = [
|
|
16
16
|
"Development Status :: 4 - Beta",
|
|
17
17
|
"Environment :: Console",
|
|
18
18
|
"Intended Audience :: System Administrators",
|
|
19
19
|
"Operating System :: MacOS",
|
|
20
|
+
"Operating System :: POSIX",
|
|
21
|
+
"Operating System :: POSIX :: Linux",
|
|
20
22
|
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
21
26
|
"Topic :: System :: Archiving :: Backup",
|
|
22
27
|
]
|
|
23
28
|
# zero runtime dependencies: solo stdlib (fswatch è un binario esterno)
|
|
24
29
|
|
|
25
30
|
[project.urls]
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
Repository = "https://github.com/foxhound87/safekeep"
|
|
32
|
+
Homepage = "https://foxhound87.github.io/safekeep/"
|
|
33
|
+
Documentation = "https://foxhound87.github.io/safekeep/"
|
|
28
34
|
|
|
29
35
|
[project.scripts]
|
|
30
36
|
safekeep = "safekeep.cli:main"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""CLI di safekeep: `run`, `sync-once`, `status` e `doctor` (SPEC.md §9).
|
|
2
2
|
|
|
3
3
|
Entry point del package (`safekeep.cli:main`): `bin/safekeep.py` è solo lo
|
|
4
|
-
shim che usano launchd e i test.
|
|
4
|
+
shim che usano launchd/systemd e i test.
|
|
5
5
|
"""
|
|
6
6
|
import argparse
|
|
7
7
|
import os
|
|
@@ -12,13 +12,21 @@ import sys
|
|
|
12
12
|
from safekeep.config import ConfigError, discover_projects, parse_sync
|
|
13
13
|
from safekeep.copier import TMP_INFIX
|
|
14
14
|
from safekeep.daemon import Daemon, setup_logging
|
|
15
|
+
from safekeep.platform import (
|
|
16
|
+
INOTIFY_MIN_WATCHES,
|
|
17
|
+
fswatch_monitor,
|
|
18
|
+
inotify_limit,
|
|
19
|
+
install_hint,
|
|
20
|
+
is_darwin,
|
|
21
|
+
is_linux,
|
|
22
|
+
)
|
|
15
23
|
from safekeep.volumes import dest_state
|
|
16
24
|
|
|
17
25
|
# Radice del repo quando si gira da checkout; in un install pip punta altrove
|
|
18
26
|
# (site-packages) e i riferimenti a file del repo degradano (vedi cmd_doctor).
|
|
19
27
|
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
20
28
|
EXAMPLE = os.path.join(REPO, 'examples', 'safekeep.example')
|
|
21
|
-
REPO_URL = 'https://
|
|
29
|
+
REPO_URL = 'https://github.com/foxhound87/safekeep'
|
|
22
30
|
|
|
23
31
|
|
|
24
32
|
def build_parser():
|
|
@@ -29,7 +37,7 @@ def build_parser():
|
|
|
29
37
|
|
|
30
38
|
parser = argparse.ArgumentParser(
|
|
31
39
|
prog='safekeep',
|
|
32
|
-
description='safekeep — backup event-driven per macOS (vedi SPEC.md)')
|
|
40
|
+
description='safekeep — backup event-driven per macOS e Linux (vedi SPEC.md)')
|
|
33
41
|
sub = parser.add_subparsers(dest='comando', required=True)
|
|
34
42
|
sub.add_parser('run', parents=[common],
|
|
35
43
|
help='daemon: reconcile iniziale + watch fswatch + dispatch eventi + timer 24h')
|
|
@@ -45,7 +53,9 @@ def build_parser():
|
|
|
45
53
|
sub.add_parser('status', parents=[common],
|
|
46
54
|
help='config, progetti e stato delle dest — sola lettura')
|
|
47
55
|
sub.add_parser('doctor', parents=[common],
|
|
48
|
-
help='diagnostica: config, fswatch
|
|
56
|
+
help='diagnostica: config, fswatch + monitor di piattaforma, python, '
|
|
57
|
+
'TCC/plist (macOS), inotify/systemd (Linux) — exit ≠ 0 se un check '
|
|
58
|
+
'fatale fallisce')
|
|
49
59
|
return parser
|
|
50
60
|
|
|
51
61
|
|
|
@@ -186,7 +196,7 @@ def cmd_doctor(args):
|
|
|
186
196
|
|
|
187
197
|
exe = shutil.which('fswatch')
|
|
188
198
|
if exe is None:
|
|
189
|
-
out(False, 'fswatch non trovato — installa con:
|
|
199
|
+
out(False, f'fswatch non trovato — installa con: {install_hint()}',
|
|
190
200
|
is_fatal=True)
|
|
191
201
|
else:
|
|
192
202
|
version = None
|
|
@@ -200,31 +210,50 @@ def cmd_doctor(args):
|
|
|
200
210
|
out(version is not None,
|
|
201
211
|
f'fswatch: {version}' if version else 'fswatch --version senza output',
|
|
202
212
|
is_fatal=True)
|
|
213
|
+
# monitor atteso per piattaforma (SPEC.md §14.2/§14.4)
|
|
214
|
+
expected = fswatch_monitor()
|
|
203
215
|
try:
|
|
204
216
|
monitors = subprocess.run([exe, '-M'], capture_output=True,
|
|
205
217
|
text=True, timeout=15)
|
|
206
|
-
|
|
218
|
+
present = expected in monitors.stdout
|
|
207
219
|
except (OSError, subprocess.SubprocessError):
|
|
208
|
-
|
|
209
|
-
out(
|
|
210
|
-
'fswatch monitor:
|
|
211
|
-
'fswatch monitor:
|
|
220
|
+
present = False
|
|
221
|
+
out(present,
|
|
222
|
+
f'fswatch monitor: {expected} presente' if present else
|
|
223
|
+
f'fswatch monitor: {expected} ASSENTE in `fswatch -M`',
|
|
212
224
|
is_fatal=True)
|
|
213
225
|
|
|
214
226
|
out(sys.version_info >= (3, 9),
|
|
215
227
|
f'python {sys.version.split()[0]} (≥ 3.9)', is_fatal=True)
|
|
216
228
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
out(False, '
|
|
225
|
-
'(
|
|
229
|
+
# Linux: il limite di watch di inotify è il rischio #1 sugli alberi grandi
|
|
230
|
+
# (SPEC.md §14.4) — informativo, NON fatale
|
|
231
|
+
if is_linux():
|
|
232
|
+
limit = inotify_limit()
|
|
233
|
+
if limit is None:
|
|
234
|
+
out(True, 'inotify: limite di watch non leggibile — check saltato')
|
|
235
|
+
elif limit < INOTIFY_MIN_WATCHES:
|
|
236
|
+
out(False, f'inotify: fs.inotify.max_user_watches={limit} '
|
|
237
|
+
f'(< {INOTIFY_MIN_WATCHES}) — su alberi grandi (~25k file) i '
|
|
238
|
+
'watch finiscono: sudo sysctl -w fs.inotify.max_user_watches=524288 '
|
|
239
|
+
'— warning non fatale')
|
|
240
|
+
else:
|
|
241
|
+
out(True, f'inotify: fs.inotify.max_user_watches={limit} '
|
|
242
|
+
f'(≥ {INOTIFY_MIN_WATCHES})')
|
|
243
|
+
|
|
244
|
+
# TCC (Transparency, Consent and Control) è un meccanismo di macOS (SPEC.md §11)
|
|
245
|
+
if is_darwin():
|
|
246
|
+
docs = os.path.expanduser('~/Documents')
|
|
247
|
+
if not os.path.isdir(docs):
|
|
248
|
+
out(True, 'TCC (Transparency, Consent and Control): ~/Documents assente — probe saltato')
|
|
226
249
|
else:
|
|
227
|
-
|
|
250
|
+
try:
|
|
251
|
+
os.listdir(docs)
|
|
252
|
+
except PermissionError:
|
|
253
|
+
out(False, 'TCC: PermissionError su ~/Documents — serve FDA '
|
|
254
|
+
'(Full Disk Access) per i progetti in cartelle protette')
|
|
255
|
+
else:
|
|
256
|
+
out(True, 'TCC: ~/Documents leggibile')
|
|
228
257
|
|
|
229
258
|
if cfg is not None:
|
|
230
259
|
residui = [os.path.join(dirpath, name)
|
|
@@ -236,19 +265,37 @@ def cmd_doctor(args):
|
|
|
236
265
|
'residui *.safekeep.tmp.*: nessuno' if not residui else
|
|
237
266
|
'residui *.safekeep.tmp.*: ' + ', '.join(residui))
|
|
238
267
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
268
|
+
if is_darwin():
|
|
269
|
+
plist = os.path.expanduser('~/Library/LaunchAgents/com.safekeep.agent.plist')
|
|
270
|
+
if not os.path.exists(plist):
|
|
271
|
+
out(True, 'plist non installato (nessun agent da validare)')
|
|
272
|
+
elif shutil.which('plutil') is None:
|
|
273
|
+
out(True, 'plutil assente — lint del plist saltato')
|
|
274
|
+
else:
|
|
275
|
+
try:
|
|
276
|
+
lint = subprocess.run(['plutil', '-lint', plist],
|
|
277
|
+
capture_output=True, text=True, timeout=15)
|
|
278
|
+
detail = (lint.stdout + lint.stderr).strip() or plist
|
|
279
|
+
out(lint.returncode == 0, f'plist lint: {detail}', is_fatal=True)
|
|
280
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
281
|
+
out(False, f'plist lint non eseguibile: {e}', is_fatal=True)
|
|
244
282
|
else:
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
283
|
+
# Linux: user unit systemd al posto del plist (SPEC.md §14.3)
|
|
284
|
+
unit = os.path.expanduser('~/.config/systemd/user/safekeep.service')
|
|
285
|
+
if not os.path.exists(unit):
|
|
286
|
+
out(True, 'unit systemd non installata (nessun agent da validare)')
|
|
287
|
+
else:
|
|
288
|
+
try:
|
|
289
|
+
with open(unit, encoding='utf-8') as fh:
|
|
290
|
+
text = fh.read()
|
|
291
|
+
except (OSError, UnicodeError) as e:
|
|
292
|
+
out(False, f'unit systemd illeggibile: {unit}: {e}', is_fatal=True)
|
|
293
|
+
else:
|
|
294
|
+
if '__REPO__' in text or '__PYTHON__' in text:
|
|
295
|
+
out(False, f'unit systemd: {unit} contiene ancora i placeholder '
|
|
296
|
+
'del template — rilancia install.sh', is_fatal=True)
|
|
297
|
+
else:
|
|
298
|
+
out(True, f'unit systemd: {unit}')
|
|
252
299
|
|
|
253
300
|
return 1 if fatal else 0
|
|
254
301
|
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
Un solo processo, nessun thread: `step()` fa `select` su stdout di fswatch con
|
|
5
5
|
timeout pari alla scadenza più vicina (reconcile giornaliero, retry dest
|
|
6
6
|
absent, respawn fswatch). Le regex `-e` sono un pre-filtro grezzo: la verità
|
|
7
|
-
su cosa copiare resta al matcher Python (SPEC.md §6.4).
|
|
7
|
+
su cosa copiare resta al matcher Python (SPEC.md §6.4). Il monitor di fswatch
|
|
8
|
+
viene da `safekeep.platform` (SPEC.md §14.1).
|
|
8
9
|
"""
|
|
9
10
|
import logging
|
|
10
11
|
import logging.handlers
|
|
@@ -26,6 +27,7 @@ from .config import (
|
|
|
26
27
|
)
|
|
27
28
|
from .copier import MOUNT_ERRNOS, copy_one, needs_copy, prune_project, reconcile_project
|
|
28
29
|
from .matcher import BUILTIN_RULES, Matcher, _glob, parse_rule
|
|
30
|
+
from .platform import fswatch_monitor, init_system
|
|
29
31
|
from .volumes import backoff_schedule, dest_state, partition_dests
|
|
30
32
|
|
|
31
33
|
log = logging.getLogger('safekeep')
|
|
@@ -34,7 +36,7 @@ FLOOD_LIMIT = 5000 # path per progetto in un batch → collapse a reconci
|
|
|
34
36
|
DEDUP_WINDOW = 2.0 # secondi: stessa path entro la finestra → un solo evento
|
|
35
37
|
RECONCILE_INTERVAL = 24 * 3600
|
|
36
38
|
DEATH_WINDOW = 60 # finestra di conteggio delle morti di fswatch
|
|
37
|
-
MAX_DEATHS = 5 # 5 morti in 60s → esco con codice ≠ 0 (
|
|
39
|
+
MAX_DEATHS = 5 # 5 morti in 60s → esco con codice ≠ 0 (l'init system ci riprova)
|
|
38
40
|
RESPAWN_CAP = 60 # backoff respawn 1 → 60s
|
|
39
41
|
|
|
40
42
|
SKIP = 'skip'
|
|
@@ -409,8 +411,9 @@ class Daemon:
|
|
|
409
411
|
# --- fswatch ---------------------------------------------------------
|
|
410
412
|
|
|
411
413
|
def fswatch_argv(self):
|
|
412
|
-
"""`fswatch -0 -m
|
|
413
|
-
|
|
414
|
+
"""`fswatch -0 -m <monitor> -r -l 1.0 -e <regex> -- <roots>` (§6.4, §14.2):
|
|
415
|
+
il monitor è dichiarato per piattaforma, mai il default implicito."""
|
|
416
|
+
argv = ['fswatch', '-0', '-m', fswatch_monitor(), '-r', '-l', '1.0']
|
|
414
417
|
for regex in self.exclude_regexes():
|
|
415
418
|
argv += ['-e', regex]
|
|
416
419
|
return argv + ['--'] + self.discovery_roots()[0]
|
|
@@ -462,8 +465,8 @@ class Daemon:
|
|
|
462
465
|
self.deaths.append(now)
|
|
463
466
|
if len(self.deaths) >= MAX_DEATHS:
|
|
464
467
|
log.error('fswatch morto %d volte in %ds: esco con codice != 0 '
|
|
465
|
-
'(
|
|
466
|
-
len(self.deaths), DEATH_WINDOW)
|
|
468
|
+
'(%s ci riproverà)',
|
|
469
|
+
len(self.deaths), DEATH_WINDOW, init_system())
|
|
467
470
|
self.fatal = True
|
|
468
471
|
return
|
|
469
472
|
if now - self.last_spawn >= DEATH_WINDOW:
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Rilevamento piattaforma (SPEC.md §14.1): l'unico posto che legge `sys.platform`.
|
|
2
|
+
|
|
3
|
+
Funzioni/constanti minime: monitor fswatch atteso, hint di installazione,
|
|
4
|
+
init system, limite di watch di inotify. Tutto letto **a chiamata** (mai a
|
|
5
|
+
import), così i test patchano `sys.platform` ed esercitano entrambi i rami
|
|
6
|
+
anche girando su una sola piattaforma.
|
|
7
|
+
|
|
8
|
+
I nomi dei monitor sono quelli stampati da `fswatch -M`: `fsevents_monitor`
|
|
9
|
+
(FSEvents, macOS) e `inotify_monitor` (inotify, Linux) — attenzione, su Linux
|
|
10
|
+
il nome NON è `inotify` (SPEC.md §14.2).
|
|
11
|
+
"""
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
INOTIFY_LIMIT_PATH = '/proc/sys/fs/inotify/max_user_watches'
|
|
16
|
+
INOTIFY_MIN_WATCHES = 16384 # sotto questa soglia un home medio lo supera
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_darwin():
|
|
20
|
+
return sys.platform == 'darwin'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_linux():
|
|
24
|
+
return sys.platform == 'linux'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def fswatch_monitor():
|
|
28
|
+
"""Monitor fswatch per la piattaforma (SPEC.md §14.2), usato da
|
|
29
|
+
`daemon.fswatch_argv()` e dal check monitor di `doctor`."""
|
|
30
|
+
return 'fsevents_monitor' if is_darwin() else 'inotify_monitor'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def init_system():
|
|
34
|
+
"""`launchd` (Darwin) o `systemd` (Linux) — SPEC.md §14.3."""
|
|
35
|
+
return 'launchd' if is_darwin() else 'systemd'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def install_hint():
|
|
39
|
+
"""Come installare `fswatch` su questa macchina (SPEC.md §14.4)."""
|
|
40
|
+
if is_darwin():
|
|
41
|
+
return 'brew install fswatch'
|
|
42
|
+
for pm, cmd in (('pacman', 'pacman -S fswatch'),
|
|
43
|
+
('apt-get', 'sudo apt-get install fswatch'),
|
|
44
|
+
('dnf', 'sudo dnf install fswatch')):
|
|
45
|
+
if shutil.which(pm):
|
|
46
|
+
return cmd
|
|
47
|
+
return 'installa fswatch col package manager della tua distro'
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def inotify_limit(path=INOTIFY_LIMIT_PATH):
|
|
51
|
+
"""`fs.inotify.max_user_watches`, o None se il file non c'è o è illeggibile
|
|
52
|
+
(SPEC.md §14.4: su Linux è il rischio #1 sugli alberi grandi)."""
|
|
53
|
+
try:
|
|
54
|
+
with open(path) as fh:
|
|
55
|
+
return int(fh.read().strip())
|
|
56
|
+
except (OSError, ValueError):
|
|
57
|
+
return None
|
|
@@ -1,11 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: safekeep
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Selective always-on backups for macOS and Linux: fswatch-driven copy of allow-listed files, never deletes
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/foxhound87/safekeep
|
|
7
|
+
Project-URL: Homepage, https://foxhound87.github.io/safekeep/
|
|
8
|
+
Project-URL: Documentation, https://foxhound87.github.io/safekeep/
|
|
9
|
+
Keywords: backup,macos,linux,fswatch,sync,allow-list,launchd,systemd
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: POSIX
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: System :: Archiving :: Backup
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
1
26
|
# safekeep
|
|
2
27
|
|
|
3
|
-
|
|
28
|
+
[](https://pypi.org/project/safekeep/)
|
|
29
|
+
[](https://pypi.org/project/safekeep/)
|
|
30
|
+
|
|
31
|
+
**Selective always-on backups for macOS and Linux**: watches source folders with
|
|
4
32
|
[fswatch](https://github.com/emcrisostomo/fswatch) and copies only the files you
|
|
5
33
|
chose to their destinations — an **allow-list** model that **never deletes**
|
|
6
34
|
anything in the backup (a file removed or renamed at the source stays in the
|
|
7
35
|
backup).
|
|
8
36
|
|
|
37
|
+
Documentation: https://foxhound87.github.io/safekeep/
|
|
38
|
+
|
|
9
39
|
## Features
|
|
10
40
|
|
|
11
41
|
- **Allow-list**: a file is copied only if it matches at least one `include:`;
|
|
@@ -19,7 +49,8 @@ backup).
|
|
|
19
49
|
don't matter, the next reconcile brings everything back in sync.
|
|
20
50
|
- **Unmounted volumes**: missing destination → `pending` state with exponential
|
|
21
51
|
backoff (1s → 60s cap), then a reconcile once the mount is back.
|
|
22
|
-
- **launchd at boot**: an agent with `RunAtLoad` +
|
|
52
|
+
- **launchd (macOS) or systemd (Linux) at boot**: an agent with `RunAtLoad` +
|
|
53
|
+
`KeepAlive` (or `WantedBy=default.target` + `Restart=always`) keeps the process
|
|
23
54
|
alive and restarts it if it dies.
|
|
24
55
|
- **`.sync` carries rules only, destinations live only in `~/.safekeep`**: a
|
|
25
56
|
`.sync` file can neither add nor remove destinations (a `dest:` line is an
|
|
@@ -34,27 +65,31 @@ pipx install safekeep # recommended for a CLI
|
|
|
34
65
|
pip install safekeep
|
|
35
66
|
```
|
|
36
67
|
|
|
37
|
-
**Requirements**: macOS, Python >= 3.9 and
|
|
68
|
+
**Requirements**: macOS or Linux (POSIX with systemd), Python >= 3.9 and
|
|
38
69
|
[fswatch](https://github.com/emcrisostomo/fswatch):
|
|
39
70
|
|
|
40
71
|
```bash
|
|
41
|
-
brew install fswatch
|
|
72
|
+
brew install fswatch # macOS
|
|
73
|
+
sudo pacman -S fswatch # Arch / Omarchy
|
|
74
|
+
sudo apt install fswatch # Debian / Ubuntu
|
|
75
|
+
sudo dnf install fswatch # Fedora
|
|
42
76
|
```
|
|
43
77
|
|
|
44
|
-
The **
|
|
45
|
-
repository with `./install.sh` — not from
|
|
46
|
-
[Agent
|
|
78
|
+
The **agent** (daemon at boot: launchd on macOS, a systemd *user unit* on Linux)
|
|
79
|
+
is installed from a checkout of this repository with `./install.sh` — not from
|
|
80
|
+
the wheel — see [Agent](#agent) below.
|
|
47
81
|
|
|
48
|
-
## Agent
|
|
82
|
+
## Agent
|
|
49
83
|
|
|
50
84
|
```bash
|
|
51
|
-
git clone https://
|
|
85
|
+
git clone https://github.com/foxhound87/safekeep.git
|
|
52
86
|
cd safekeep
|
|
53
87
|
|
|
54
|
-
# only external dependency, if not installed yet
|
|
55
|
-
brew install fswatch
|
|
88
|
+
# only external dependency, if not installed yet (see the per-OS list above)
|
|
89
|
+
brew install fswatch # macOS — the script suggests the right command on Linux
|
|
56
90
|
|
|
57
|
-
# copies the example into ~/.safekeep, renders the plist
|
|
91
|
+
# copies the example into ~/.safekeep, renders the plist / systemd unit,
|
|
92
|
+
# runs preflight checks
|
|
58
93
|
bash install.sh
|
|
59
94
|
```
|
|
60
95
|
|
|
@@ -69,12 +104,21 @@ Then:
|
|
|
69
104
|
3. load the agent:
|
|
70
105
|
|
|
71
106
|
```bash
|
|
107
|
+
# macOS (launchd)
|
|
72
108
|
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.safekeep.agent.plist
|
|
73
109
|
# unload with: launchctl bootout gui/$(id -u)/com.safekeep.agent
|
|
110
|
+
|
|
111
|
+
# Linux (systemd user unit)
|
|
112
|
+
systemctl --user enable --now safekeep
|
|
113
|
+
# status with: systemctl --user status safekeep
|
|
114
|
+
# logs with: journalctl --user -u safekeep -f
|
|
115
|
+
# unload with: systemctl --user disable --now safekeep
|
|
116
|
+
# start at boot without a login session: loginctl enable-linger $USER
|
|
74
117
|
```
|
|
75
118
|
|
|
76
|
-
|
|
77
|
-
interpreter and to `fswatch`, run `safekeep doctor` for diagnostics.
|
|
119
|
+
On macOS, once you have granted TCC/FDA (Full Disk Access) permissions to the
|
|
120
|
+
Python interpreter and to `fswatch`, run `safekeep doctor` for diagnostics. On
|
|
121
|
+
Linux the same command checks the `inotify` watch limit instead.
|
|
78
122
|
|
|
79
123
|
## Quickstart
|
|
80
124
|
|
|
@@ -138,7 +182,7 @@ safekeep <command> [--config PATH] [-v]
|
|
|
138
182
|
| `run` | daemon: initial reconcile, fswatch loop, event dispatch, 24h timer |
|
|
139
183
|
| `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
184
|
| `status` | read-only: config, sources, discovered projects with N rules, destination states |
|
|
141
|
-
| `doctor` | diagnostics: config, fswatch,
|
|
185
|
+
| `doctor` | diagnostics: config, fswatch + platform monitor, python, TCC/launchd plist (macOS), inotify limit and systemd unit (Linux) — exits non-zero if a fatal check fails |
|
|
142
186
|
|
|
143
187
|
## Tests
|
|
144
188
|
|
|
@@ -146,9 +190,12 @@ safekeep <command> [--config PATH] [-v]
|
|
|
146
190
|
python3 -m unittest discover -s tests
|
|
147
191
|
```
|
|
148
192
|
|
|
149
|
-
Stdlib (`unittest`) suite, zero dependencies:
|
|
150
|
-
config, atomic copy, volumes, daemon, auto-discovery
|
|
151
|
-
|
|
193
|
+
Stdlib (`unittest`) suite, zero dependencies: 175 tests covering the matcher,
|
|
194
|
+
config, atomic copy, volumes, daemon, auto-discovery, CLI and the POSIX
|
|
195
|
+
portability layer (platform helper, per-OS fswatch monitor, systemd template).
|
|
196
|
+
`fswatch` is not needed to run the suite — the few `doctor` checks that talk to
|
|
197
|
+
the real binary are skipped when it's missing — but CI installs it so the
|
|
198
|
+
Linux job exercises the real `inotify` monitor.
|
|
152
199
|
|
|
153
200
|
## Documentation
|
|
154
201
|
|
|
@@ -7,6 +7,7 @@ safekeep/config.py
|
|
|
7
7
|
safekeep/copier.py
|
|
8
8
|
safekeep/daemon.py
|
|
9
9
|
safekeep/matcher.py
|
|
10
|
+
safekeep/platform.py
|
|
10
11
|
safekeep/volumes.py
|
|
11
12
|
safekeep.egg-info/PKG-INFO
|
|
12
13
|
safekeep.egg-info/SOURCES.txt
|
|
@@ -18,4 +19,5 @@ tests/test_config.py
|
|
|
18
19
|
tests/test_copier.py
|
|
19
20
|
tests/test_daemon.py
|
|
20
21
|
tests/test_matcher.py
|
|
22
|
+
tests/test_platform.py
|
|
21
23
|
tests/test_volumes.py
|
|
@@ -2,16 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
Ogni run è un subprocess con HOME finto: niente scritture nella home reale.
|
|
4
4
|
"""
|
|
5
|
+
import argparse
|
|
6
|
+
import contextlib
|
|
7
|
+
import io
|
|
5
8
|
import os
|
|
6
9
|
import shutil
|
|
7
10
|
import subprocess
|
|
8
11
|
import sys
|
|
9
12
|
import tempfile
|
|
10
13
|
import unittest
|
|
14
|
+
from unittest import mock
|
|
15
|
+
|
|
16
|
+
from safekeep import cli
|
|
17
|
+
from safekeep.platform import fswatch_monitor
|
|
11
18
|
|
|
12
19
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
13
20
|
CLI = os.path.join(REPO, 'bin', 'safekeep.py')
|
|
14
21
|
|
|
22
|
+
# Check di `doctor` condivisi da tutte le piattaforme (SPEC.md §9)
|
|
23
|
+
BASE_MARKERS = ('config', 'dest non annidate', 'fswatch', 'python', 'residui')
|
|
24
|
+
# SPEC.md §14.4: TCC e plist sono Darwin-only, inotify e unit systemd Linux-only
|
|
25
|
+
DARWIN_MARKERS = ('TCC', 'plist')
|
|
26
|
+
LINUX_MARKERS = ('inotify', 'unit systemd')
|
|
27
|
+
|
|
15
28
|
|
|
16
29
|
def write(path, text=''):
|
|
17
30
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
@@ -61,13 +74,54 @@ class DoctorTest(CliTestCase):
|
|
|
61
74
|
def test_doctor_gira_tutti_i_check(self):
|
|
62
75
|
r = self.cli('doctor', '--config', self.cfg)
|
|
63
76
|
self.assertIn(r.returncode, (0, 1), r.stdout + r.stderr)
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
markers = BASE_MARKERS + (DARWIN_MARKERS if sys.platform == 'darwin'
|
|
78
|
+
else LINUX_MARKERS)
|
|
79
|
+
for marker in markers:
|
|
66
80
|
self.assertIn(marker, r.stdout, f'check mancante: {marker}')
|
|
67
81
|
for line in r.stdout.splitlines():
|
|
68
82
|
if line.strip():
|
|
69
83
|
self.assertIn(line[0], ('✔', '✗'), f'riga senza esito: {line!r}')
|
|
70
84
|
|
|
85
|
+
def test_doctor_monitor_della_piattaforma(self):
|
|
86
|
+
# SPEC.md §14.4: il monitor atteso va cercato in `fswatch -M`
|
|
87
|
+
if shutil.which('fswatch') is None:
|
|
88
|
+
self.skipTest('fswatch non installato')
|
|
89
|
+
r = self.cli('doctor', '--config', self.cfg)
|
|
90
|
+
self.assertIn(f'fswatch monitor: {fswatch_monitor()} presente', r.stdout)
|
|
91
|
+
|
|
92
|
+
@unittest.skipUnless(sys.platform == 'darwin', 'check TCC macOS')
|
|
93
|
+
def test_doctor_tcc_e_darwin_only(self):
|
|
94
|
+
r = self.cli('doctor', '--config', self.cfg)
|
|
95
|
+
self.assertIn('TCC', r.stdout)
|
|
96
|
+
|
|
97
|
+
@unittest.skipIf(sys.platform == 'darwin', 'check inotify Linux')
|
|
98
|
+
def test_doctor_inotify_e_linux_only(self):
|
|
99
|
+
r = self.cli('doctor', '--config', self.cfg)
|
|
100
|
+
self.assertIn('inotify', r.stdout)
|
|
101
|
+
self.assertNotIn('TCC', r.stdout, 'TCC è un meccanismo di macOS')
|
|
102
|
+
for line in r.stdout.splitlines():
|
|
103
|
+
if line.startswith('✗') and 'inotify' in line:
|
|
104
|
+
self.assertIn('warning non fatale', line)
|
|
105
|
+
|
|
106
|
+
@unittest.skipUnless(shutil.which('fswatch'), 'fswatch non installato')
|
|
107
|
+
def test_doctor_limite_basso_non_e_fatale(self):
|
|
108
|
+
"""SPEC.md §14.4: su Linux il limite di watch basso è informativo —
|
|
109
|
+
doctor esce 0, non 1. I rami Linux si esercitano patchando l'helper,
|
|
110
|
+
senza girare su Linux."""
|
|
111
|
+
args = argparse.Namespace(config=self.cfg, v=False)
|
|
112
|
+
buf = io.StringIO()
|
|
113
|
+
with mock.patch.object(cli, 'is_linux', return_value=True), \
|
|
114
|
+
mock.patch.object(cli, 'is_darwin', return_value=False), \
|
|
115
|
+
mock.patch.object(cli, 'inotify_limit', return_value=8192), \
|
|
116
|
+
contextlib.redirect_stdout(buf):
|
|
117
|
+
code = cli.cmd_doctor(args)
|
|
118
|
+
out = buf.getvalue()
|
|
119
|
+
self.assertIn('max_user_watches=8192', out)
|
|
120
|
+
self.assertIn('warning non fatale', out)
|
|
121
|
+
self.assertIn('unit systemd', out)
|
|
122
|
+
self.assertNotIn('TCC', out, 'TCC è un meccanismo di macOS')
|
|
123
|
+
self.assertEqual(code, 0, out)
|
|
124
|
+
|
|
71
125
|
def test_doctor_config_invalido_esce_1(self):
|
|
72
126
|
bad = write(os.path.join(self.tmp, 'bad.cfg'), 'riga nuda invalida\n')
|
|
73
127
|
r = self.cli('doctor', '--config', bad)
|
|
@@ -413,7 +413,7 @@ class PlanBatchTest(unittest.TestCase):
|
|
|
413
413
|
|
|
414
414
|
|
|
415
415
|
class FswatchArgvTest(unittest.TestCase):
|
|
416
|
-
def
|
|
416
|
+
def _daemon(self):
|
|
417
417
|
tmp = tempfile.mkdtemp()
|
|
418
418
|
self.addCleanup(shutil.rmtree, tmp, ignore_errors=True)
|
|
419
419
|
src = os.path.join(tmp, 'src')
|
|
@@ -424,15 +424,30 @@ class FswatchArgvTest(unittest.TestCase):
|
|
|
424
424
|
daemon = Daemon(cfg_path)
|
|
425
425
|
daemon.load_config()
|
|
426
426
|
daemon.load_projects()
|
|
427
|
+
return daemon, src
|
|
428
|
+
|
|
429
|
+
def test_argv_esatto_dallo_spec(self):
|
|
430
|
+
daemon, src = self._daemon()
|
|
427
431
|
argv = daemon.fswatch_argv()
|
|
428
|
-
|
|
429
|
-
|
|
432
|
+
# posizioni fisse: solo il monitor (-m) è per-piattaforma (SPEC §14.2)
|
|
433
|
+
self.assertEqual(argv[:3], ['fswatch', '-0', '-m'])
|
|
434
|
+
self.assertIn(argv[3], ('fsevents_monitor', 'inotify_monitor'))
|
|
435
|
+
self.assertEqual(argv[4:7], ['-r', '-l', '1.0'])
|
|
430
436
|
regexes = [argv[i + 1] for i, a in enumerate(argv) if a == '-e']
|
|
431
437
|
self.assertTrue(regexes)
|
|
432
438
|
self.assertTrue(any(re.search(rx, '/qualcuno/node_modules/x') for rx in regexes))
|
|
433
439
|
self.assertTrue(any('skipme/' in rx for rx in regexes))
|
|
434
440
|
self.assertEqual(argv[argv.index('--') + 1:], [src])
|
|
435
441
|
|
|
442
|
+
def test_monitor_dichiarato_per_piattaforma(self):
|
|
443
|
+
# SPEC §14.1/§14.2: il monitor è esplicito su entrambi i rami, letto
|
|
444
|
+
# a chiamata — così entrambi si testano anche girando su macOS
|
|
445
|
+
daemon, _ = self._daemon()
|
|
446
|
+
with mock.patch('sys.platform', 'darwin'):
|
|
447
|
+
self.assertEqual(daemon.fswatch_argv()[3], 'fsevents_monitor')
|
|
448
|
+
with mock.patch('sys.platform', 'linux'):
|
|
449
|
+
self.assertEqual(daemon.fswatch_argv()[3], 'inotify_monitor')
|
|
450
|
+
|
|
436
451
|
|
|
437
452
|
class WalkDirTest(TmpTestCase):
|
|
438
453
|
"""Mini-walk su una dir appena creata: filtro allow-list sui file figli."""
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Helper di piattaforma e agent systemd (SPEC.md §14).
|
|
2
|
+
|
|
3
|
+
Entrambi i rami (Darwin/Linux) si esercitano patchando `sys.platform`, così la
|
|
4
|
+
suite copre il design di portabilità anche girando su una sola piattaforma.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
from unittest import mock
|
|
12
|
+
|
|
13
|
+
from safekeep import platform as plat
|
|
14
|
+
|
|
15
|
+
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MonitorTest(unittest.TestCase):
|
|
19
|
+
def test_monitor_dichiarato_per_piattaforma(self):
|
|
20
|
+
with mock.patch('sys.platform', 'darwin'):
|
|
21
|
+
self.assertEqual(plat.fswatch_monitor(), 'fsevents_monitor')
|
|
22
|
+
with mock.patch('sys.platform', 'linux'):
|
|
23
|
+
self.assertEqual(plat.fswatch_monitor(), 'inotify_monitor')
|
|
24
|
+
|
|
25
|
+
def test_monitor_atteso_e_in_fswatch_M(self):
|
|
26
|
+
"""Guardrail contro il nome sbagliato: `-m inotify` (senza `_monitor`)
|
|
27
|
+
non è un alias accettato da fswatch — qui si verifica che il nome che
|
|
28
|
+
passiamo in `-m` sia proprio uno di quelli che `fswatch -M` elenca."""
|
|
29
|
+
exe = shutil.which('fswatch')
|
|
30
|
+
if exe is None:
|
|
31
|
+
self.skipTest('fswatch non installato')
|
|
32
|
+
out = subprocess.run([exe, '-M'], capture_output=True, text=True,
|
|
33
|
+
timeout=15).stdout
|
|
34
|
+
self.assertIn(plat.fswatch_monitor(), out)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InitSystemTest(unittest.TestCase):
|
|
38
|
+
def test_init_system_per_piattaforma(self):
|
|
39
|
+
with mock.patch('sys.platform', 'darwin'):
|
|
40
|
+
self.assertEqual(plat.init_system(), 'launchd')
|
|
41
|
+
with mock.patch('sys.platform', 'linux'):
|
|
42
|
+
self.assertEqual(plat.init_system(), 'systemd')
|
|
43
|
+
|
|
44
|
+
def test_is_darwin_e_is_linux(self):
|
|
45
|
+
with mock.patch('sys.platform', 'darwin'):
|
|
46
|
+
self.assertTrue(plat.is_darwin())
|
|
47
|
+
self.assertFalse(plat.is_linux())
|
|
48
|
+
with mock.patch('sys.platform', 'linux'):
|
|
49
|
+
self.assertTrue(plat.is_linux())
|
|
50
|
+
self.assertFalse(plat.is_darwin())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class InstallHintTest(unittest.TestCase):
|
|
54
|
+
def test_darwin_usa_brew(self):
|
|
55
|
+
with mock.patch('sys.platform', 'darwin'):
|
|
56
|
+
self.assertEqual(plat.install_hint(), 'brew install fswatch')
|
|
57
|
+
|
|
58
|
+
def test_linux_rileva_il_package_manager(self):
|
|
59
|
+
casi = (('pacman', 'pacman -S fswatch'),
|
|
60
|
+
('apt-get', 'sudo apt-get install fswatch'),
|
|
61
|
+
('dnf', 'sudo dnf install fswatch'))
|
|
62
|
+
for pm, atteso in casi:
|
|
63
|
+
with self.subTest(pm=pm), \
|
|
64
|
+
mock.patch('sys.platform', 'linux'), \
|
|
65
|
+
mock.patch('shutil.which',
|
|
66
|
+
side_effect=lambda name, pm=pm:
|
|
67
|
+
'/usr/bin/' + name if name == pm else None):
|
|
68
|
+
self.assertEqual(plat.install_hint(), atteso)
|
|
69
|
+
|
|
70
|
+
def test_linux_senza_package_manager_noto(self):
|
|
71
|
+
with mock.patch('sys.platform', 'linux'), \
|
|
72
|
+
mock.patch('shutil.which', return_value=None):
|
|
73
|
+
self.assertIn('fswatch', plat.install_hint())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class InotifyLimitTest(unittest.TestCase):
|
|
77
|
+
"""SPEC.md §14.4: check informativo non fatale sul limite di watch."""
|
|
78
|
+
|
|
79
|
+
def _fake(self, text):
|
|
80
|
+
fd, path = tempfile.mkstemp()
|
|
81
|
+
self.addCleanup(os.unlink, path)
|
|
82
|
+
with os.fdopen(fd, 'w') as fh:
|
|
83
|
+
fh.write(text)
|
|
84
|
+
return path
|
|
85
|
+
|
|
86
|
+
def test_lettura_da_file_finto(self):
|
|
87
|
+
self.assertEqual(plat.inotify_limit(self._fake('524288\n')), 524288)
|
|
88
|
+
self.assertEqual(plat.inotify_limit(self._fake('8192')), 8192)
|
|
89
|
+
|
|
90
|
+
def test_valore_invalido_o_file_mancante(self):
|
|
91
|
+
self.assertIsNone(plat.inotify_limit(self._fake('non-è-un-numbero\n')))
|
|
92
|
+
self.assertIsNone(plat.inotify_limit('/proc/assente/inotify'))
|
|
93
|
+
|
|
94
|
+
def test_costanti(self):
|
|
95
|
+
self.assertEqual(plat.INOTIFY_MIN_WATCHES, 16384)
|
|
96
|
+
self.assertEqual(plat.INOTIFY_LIMIT_PATH,
|
|
97
|
+
'/proc/sys/fs/inotify/max_user_watches')
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PiattaformaCentralizzataTest(unittest.TestCase):
|
|
101
|
+
def test_sys_platform_letto_solo_nell_helper(self):
|
|
102
|
+
"""SPEC.md §14.1: un solo posto legge `sys.platform`, niente check sparsi."""
|
|
103
|
+
hits = []
|
|
104
|
+
pkg = os.path.join(REPO, 'safekeep')
|
|
105
|
+
for name in sorted(os.listdir(pkg)):
|
|
106
|
+
if not name.endswith('.py'):
|
|
107
|
+
continue
|
|
108
|
+
with open(os.path.join(pkg, name), encoding='utf-8') as fh:
|
|
109
|
+
for i, line in enumerate(fh, 1):
|
|
110
|
+
if 'sys.platform' in line:
|
|
111
|
+
hits.append(f'{name}:{i}')
|
|
112
|
+
self.assertTrue(hits, 'nessun riferimento a sys.platform trovato')
|
|
113
|
+
self.assertTrue(all(h.startswith('platform.py:') for h in hits), hits)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class AgentTemplateTest(unittest.TestCase):
|
|
117
|
+
"""SPEC.md §14.3: la unit systemd è un template con gli stessi placeholder
|
|
118
|
+
del plist e copre RunAtLoad/KeepAlive."""
|
|
119
|
+
|
|
120
|
+
def test_unit_systemd_template(self):
|
|
121
|
+
path = os.path.join(REPO, 'systemd', 'safekeep.service')
|
|
122
|
+
with open(path, encoding='utf-8') as fh:
|
|
123
|
+
text = fh.read()
|
|
124
|
+
for needle in ('ExecStart=__PYTHON__ __REPO__/bin/safekeep.py run',
|
|
125
|
+
'Restart=always',
|
|
126
|
+
'RestartSec=2',
|
|
127
|
+
'StartLimitIntervalSec=0',
|
|
128
|
+
'WantedBy=default.target'):
|
|
129
|
+
self.assertIn(needle, text, needle)
|
|
130
|
+
self.assertNotIn('/Users/', text, 'path assoluto nel template')
|
|
131
|
+
|
|
132
|
+
def test_gli_script_si_ramificano_per_os(self):
|
|
133
|
+
for name in ('install.sh', 'uninstall.sh'):
|
|
134
|
+
with self.subTest(script=name), \
|
|
135
|
+
open(os.path.join(REPO, name), encoding='utf-8') as fh:
|
|
136
|
+
text = fh.read()
|
|
137
|
+
self.assertIn('uname -s', text, name) # ramo Darwin/Linux
|
|
138
|
+
self.assertIn('launchctl', text, name) # ramo macOS invariato
|
|
139
|
+
self.assertIn('systemctl --user', text, name) # ramo Linux
|
|
140
|
+
|
|
141
|
+
def test_il_plist_macos_non_e_toccato(self):
|
|
142
|
+
with open(os.path.join(REPO, 'launchd', 'com.safekeep.agent.plist'),
|
|
143
|
+
encoding='utf-8') as fh:
|
|
144
|
+
text = fh.read()
|
|
145
|
+
for needle in ('RunAtLoad', 'KeepAlive', '__REPO__', '__PYTHON__'):
|
|
146
|
+
self.assertIn(needle, text, needle)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == '__main__':
|
|
150
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|