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.
Files changed (26) hide show
  1. {safekeep-0.2.0 → safekeep-0.3.0}/PKG-INFO +51 -23
  2. safekeep-0.2.0/safekeep.egg-info/PKG-INFO → safekeep-0.3.0/README.md +40 -37
  3. {safekeep-0.2.0 → safekeep-0.3.0}/pyproject.toml +10 -4
  4. safekeep-0.3.0/safekeep/__init__.py +6 -0
  5. {safekeep-0.2.0 → safekeep-0.3.0}/safekeep/cli.py +79 -32
  6. {safekeep-0.2.0 → safekeep-0.3.0}/safekeep/daemon.py +9 -6
  7. safekeep-0.3.0/safekeep/platform.py +57 -0
  8. safekeep-0.2.0/README.md → safekeep-0.3.0/safekeep.egg-info/PKG-INFO +65 -18
  9. {safekeep-0.2.0 → safekeep-0.3.0}/safekeep.egg-info/SOURCES.txt +2 -0
  10. {safekeep-0.2.0 → safekeep-0.3.0}/tests/test_cli.py +56 -2
  11. {safekeep-0.2.0 → safekeep-0.3.0}/tests/test_daemon.py +18 -3
  12. safekeep-0.3.0/tests/test_platform.py +150 -0
  13. safekeep-0.2.0/safekeep/__init__.py +0 -6
  14. {safekeep-0.2.0 → safekeep-0.3.0}/LICENSE +0 -0
  15. {safekeep-0.2.0 → safekeep-0.3.0}/safekeep/config.py +0 -0
  16. {safekeep-0.2.0 → safekeep-0.3.0}/safekeep/copier.py +0 -0
  17. {safekeep-0.2.0 → safekeep-0.3.0}/safekeep/matcher.py +0 -0
  18. {safekeep-0.2.0 → safekeep-0.3.0}/safekeep/volumes.py +0 -0
  19. {safekeep-0.2.0 → safekeep-0.3.0}/safekeep.egg-info/dependency_links.txt +0 -0
  20. {safekeep-0.2.0 → safekeep-0.3.0}/safekeep.egg-info/entry_points.txt +0 -0
  21. {safekeep-0.2.0 → safekeep-0.3.0}/safekeep.egg-info/top_level.txt +0 -0
  22. {safekeep-0.2.0 → safekeep-0.3.0}/setup.cfg +0 -0
  23. {safekeep-0.2.0 → safekeep-0.3.0}/tests/test_config.py +0 -0
  24. {safekeep-0.2.0 → safekeep-0.3.0}/tests/test_copier.py +0 -0
  25. {safekeep-0.2.0 → safekeep-0.3.0}/tests/test_matcher.py +0 -0
  26. {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.2.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: Homepage, https://gitlab.com/foxhound87/safekeep
7
- Project-URL: Repository, https://gitlab.com/foxhound87/safekeep
8
- Keywords: backup,macos,fswatch,sync,allow-list,launchd
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
- **Selective always-on backups for macOS**: watches source folders with
28
+ [![PyPI](https://img.shields.io/pypi/v/safekeep)](https://pypi.org/project/safekeep/)
29
+ [![Python](https://img.shields.io/pypi/pyversions/safekeep)](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` + `KeepAlive` keeps the process
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 **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.
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 (launchd)
82
+ ## Agent
68
83
 
69
84
  ```bash
70
- git clone https://gitlab.com/foxhound87/safekeep.git
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, runs preflight checks
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
- Once you have granted TCC/FDA (Full Disk Access) permissions to the Python
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, TCC, launchd plist — exits non-zero if a fatal check fails |
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: 156 tests covering the matcher,
169
- config, atomic copy, volumes, daemon, auto-discovery and CLI. `fswatch` is not
170
- needed to run the tests.
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
- **Selective always-on backups for macOS**: watches source folders with
3
+ [![PyPI](https://img.shields.io/pypi/v/safekeep)](https://pypi.org/project/safekeep/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/safekeep)](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` + `KeepAlive` keeps the process
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 **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.
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 (launchd)
57
+ ## Agent
68
58
 
69
59
  ```bash
70
- git clone https://gitlab.com/foxhound87/safekeep.git
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, runs preflight checks
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
- Once you have granted TCC/FDA (Full Disk Access) permissions to the Python
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, TCC, launchd plist — exits non-zero if a fatal check fails |
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: 156 tests covering the matcher,
169
- config, atomic copy, volumes, daemon, auto-discovery and CLI. `fswatch` is not
170
- needed to run the tests.
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
- Homepage = "https://gitlab.com/foxhound87/safekeep"
27
- Repository = "https://gitlab.com/foxhound87/safekeep"
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"
@@ -0,0 +1,6 @@
1
+ """safekeep — motore di backup event-driven per macOS e Linux (vedi SPEC.md)."""
2
+ import logging
3
+
4
+ logging.getLogger('safekeep').addHandler(logging.NullHandler())
5
+
6
+ __version__ = '0.3.0'
@@ -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://gitlab.com/foxhound87/safekeep'
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, TCC, plist — exit ≠ 0 se un check fatale fallisce')
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: brew install fswatch',
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
- has_fsevents = 'fsevents_monitor' in monitors.stdout
218
+ present = expected in monitors.stdout
207
219
  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`',
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
- 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')
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
- out(True, 'TCC: ~/Documents leggibile')
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
- 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')
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
- 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)
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 (launchd ci riprova)
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 fsevents_monitor -r -l 1.0 -e <regex> -- <roots>` (§6.4)."""
413
- argv = ['fswatch', '-0', '-m', 'fsevents_monitor', '-r', '-l', '1.0']
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
- '(launchd con KeepAlive ci riproverà)',
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
- **Selective always-on backups for macOS**: watches source folders with
28
+ [![PyPI](https://img.shields.io/pypi/v/safekeep)](https://pypi.org/project/safekeep/)
29
+ [![Python](https://img.shields.io/pypi/pyversions/safekeep)](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` + `KeepAlive` keeps the process
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 **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.
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 (launchd)
82
+ ## Agent
49
83
 
50
84
  ```bash
51
- git clone https://gitlab.com/foxhound87/safekeep.git
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, runs preflight checks
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
- Once you have granted TCC/FDA (Full Disk Access) permissions to the Python
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, TCC, launchd plist — exits non-zero if a fatal check fails |
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: 156 tests covering the matcher,
150
- config, atomic copy, volumes, daemon, auto-discovery and CLI. `fswatch` is not
151
- needed to run the tests.
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
- for marker in ('config', 'dest non annidate', 'fswatch', 'python',
65
- 'TCC', 'residui', 'plist'):
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 test_argv_esatto_dallo_spec(self):
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
- self.assertEqual(argv[:7],
429
- ['fswatch', '-0', '-m', 'fsevents_monitor', '-r', '-l', '1.0'])
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()
@@ -1,6 +0,0 @@
1
- """safekeep — motore di backup event-driven per macOS (vedi SPEC.md)."""
2
- import logging
3
-
4
- logging.getLogger('safekeep').addHandler(logging.NullHandler())
5
-
6
- __version__ = '0.2.0'
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