safekeep 0.3.1__tar.gz → 0.4.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 (25) hide show
  1. {safekeep-0.3.1/safekeep.egg-info → safekeep-0.4.0}/PKG-INFO +1 -1
  2. {safekeep-0.3.1 → safekeep-0.4.0}/safekeep/__init__.py +1 -1
  3. {safekeep-0.3.1 → safekeep-0.4.0}/safekeep/cli.py +20 -0
  4. {safekeep-0.3.1 → safekeep-0.4.0}/safekeep/platform.py +31 -3
  5. {safekeep-0.3.1 → safekeep-0.4.0/safekeep.egg-info}/PKG-INFO +1 -1
  6. {safekeep-0.3.1 → safekeep-0.4.0}/tests/test_cli.py +63 -0
  7. {safekeep-0.3.1 → safekeep-0.4.0}/tests/test_platform.py +65 -0
  8. {safekeep-0.3.1 → safekeep-0.4.0}/LICENSE +0 -0
  9. {safekeep-0.3.1 → safekeep-0.4.0}/README.md +0 -0
  10. {safekeep-0.3.1 → safekeep-0.4.0}/pyproject.toml +0 -0
  11. {safekeep-0.3.1 → safekeep-0.4.0}/safekeep/config.py +0 -0
  12. {safekeep-0.3.1 → safekeep-0.4.0}/safekeep/copier.py +0 -0
  13. {safekeep-0.3.1 → safekeep-0.4.0}/safekeep/daemon.py +0 -0
  14. {safekeep-0.3.1 → safekeep-0.4.0}/safekeep/matcher.py +0 -0
  15. {safekeep-0.3.1 → safekeep-0.4.0}/safekeep/volumes.py +0 -0
  16. {safekeep-0.3.1 → safekeep-0.4.0}/safekeep.egg-info/SOURCES.txt +0 -0
  17. {safekeep-0.3.1 → safekeep-0.4.0}/safekeep.egg-info/dependency_links.txt +0 -0
  18. {safekeep-0.3.1 → safekeep-0.4.0}/safekeep.egg-info/entry_points.txt +0 -0
  19. {safekeep-0.3.1 → safekeep-0.4.0}/safekeep.egg-info/top_level.txt +0 -0
  20. {safekeep-0.3.1 → safekeep-0.4.0}/setup.cfg +0 -0
  21. {safekeep-0.3.1 → safekeep-0.4.0}/tests/test_config.py +0 -0
  22. {safekeep-0.3.1 → safekeep-0.4.0}/tests/test_copier.py +0 -0
  23. {safekeep-0.3.1 → safekeep-0.4.0}/tests/test_daemon.py +0 -0
  24. {safekeep-0.3.1 → safekeep-0.4.0}/tests/test_matcher.py +0 -0
  25. {safekeep-0.3.1 → safekeep-0.4.0}/tests/test_volumes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: safekeep
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
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
6
  Project-URL: Repository, https://github.com/foxhound87/safekeep
@@ -3,4 +3,4 @@ import logging
3
3
 
4
4
  logging.getLogger('safekeep').addHandler(logging.NullHandler())
5
5
 
6
- __version__ = '0.3.1'
6
+ __version__ = '0.4.0'
@@ -20,8 +20,11 @@ from safekeep.platform import (
20
20
  install_hint,
21
21
  is_darwin,
22
22
  is_linux,
23
+ is_wsl,
23
24
  linger_enabled,
24
25
  recent_permission_errors,
26
+ systemd_user_available,
27
+ wsl_distro,
25
28
  )
26
29
  from safekeep.volumes import dest_state
27
30
 
@@ -244,6 +247,23 @@ def cmd_doctor(args):
244
247
  else:
245
248
  out(True, f'errori di permesso recenti: nessuno nelle ultime 24h — log: {log}')
246
249
 
250
+ # WSL (SPEC.md §17.2): tre righe informative — nessuna è mai fatale
251
+ if is_wsl():
252
+ out(True, f'WSL rilevato: {wsl_distro() or "sconosciuta"} '
253
+ '(supporto best-effort, SPEC §17)')
254
+ if not systemd_user_available():
255
+ out(False, 'systemd user non disponibile (WSL1 o systemd spento) — '
256
+ 'abilita systemd in /etc/wsl.conf: [boot] systemd=true '
257
+ '(poi wsl --shutdown da Windows) oppure esegui '
258
+ '`safekeep run` a mano — warning non fatale')
259
+ if daemon is not None:
260
+ sotto_mnt = [p for p in list(daemon.cfg.sources)
261
+ + list(daemon.cfg.dests) if p.startswith('/mnt/')]
262
+ if sotto_mnt:
263
+ out(True, f'drvfs: {len(sotto_mnt)} path sotto /mnt/ — I/O lento '
264
+ 'e case-insensitive di default: valuta una dest su ext4 '
265
+ 'nativa della distro (SPEC §17.2)')
266
+
247
267
  # Linux: il limite di watch di inotify è il rischio #1 sugli alberi grandi
248
268
  # (SPEC.md §14.4) — informativo, NON fatale
249
269
  if is_linux():
@@ -1,9 +1,10 @@
1
1
  """Rilevamento piattaforma (SPEC.md §14.1): l'unico posto che legge `sys.platform`.
2
2
 
3
3
  Funzioni/constanti minime: monitor fswatch atteso, hint di installazione,
4
- init system, limite di watch di inotify, log corrente, linger systemd.
5
- Tutto letto **a chiamata** (mai a import), così i test patchano `sys.platform`
6
- ed esercitano entrambi i rami anche girando su una sola piattaforma.
4
+ init system, limite di watch di inotify, log corrente, linger systemd,
5
+ rilevamento WSL. Tutto letto **a chiamata** (mai a import), così i test
6
+ patchano `sys.platform` ed esercitano entrambi i rami anche girando su una
7
+ sola piattaforma.
7
8
 
8
9
  I nomi dei monitor sono quelli stampati da `fswatch -M`: `fsevents_monitor`
9
10
  (FSEvents, macOS) e `inotify_monitor` (inotify, Linux) — attenzione, su Linux
@@ -11,6 +12,7 @@ il nome NON è `inotify` (SPEC.md §14.2).
11
12
  """
12
13
  import getpass
13
14
  import os
15
+ import platform as _platform
14
16
  import re
15
17
  import shutil
16
18
  import sys
@@ -20,6 +22,7 @@ INOTIFY_LIMIT_PATH = '/proc/sys/fs/inotify/max_user_watches'
20
22
  INOTIFY_MIN_WATCHES = 16384 # sotto questa soglia un home medio lo supera
21
23
  LOG_PATH = '~/.local/state/safekeep/safekeep.log' # log corrente (SPEC.md §3)
22
24
  LINGER_DIR = '/var/lib/systemd/linger'
25
+ SYSTEMD_RUN_PATH = '/run/systemd/system' # assente su WSL1/systemd off
23
26
  # EPERM (Errno 1) / EACCES (Errno 13) come li scrive il logger: codice errno
24
27
  # + testo del kernel (SPEC.md §14.4)
25
28
  PERM_ERR_RE = re.compile(r'\[Errno (?:1|13)\]|Operation not permitted|Permission denied')
@@ -117,3 +120,28 @@ def linger_enabled(user=None, linger_dir=None):
117
120
  except OSError:
118
121
  return None
119
122
  return user in entries
123
+
124
+
125
+ def is_wsl():
126
+ """True dentro WSL (Windows Subsystem for Linux) — SPEC.md §17.1.
127
+
128
+ `sys.platform` resta `linux` su WSL: il rilevamento passa dall'env
129
+ `WSL_DISTRO_NAME` (ogni distro WSL lo settà) oppure dal kernel
130
+ (`platform.release()` contiene `microsoft` su WSL1 e WSL2)."""
131
+ if os.environ.get('WSL_DISTRO_NAME'):
132
+ return True
133
+ return 'microsoft' in _platform.release().lower()
134
+
135
+
136
+ def wsl_distro():
137
+ """Nome della distro WSL (env `WSL_DISTRO_NAME`), vuoto se assente —
138
+ SPEC.md §17.1."""
139
+ return os.environ.get('WSL_DISTRO_NAME', '')
140
+
141
+
142
+ def systemd_user_available(path=None):
143
+ """True se c'è un init systemd vivo (`/run/systemd/system`): assente su
144
+ WSL1 o con systemd spento in WSL2 — SPEC.md §17.2."""
145
+ if path is None:
146
+ path = SYSTEMD_RUN_PATH
147
+ return os.path.exists(path)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: safekeep
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
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
6
  Project-URL: Repository, https://github.com/foxhound87/safekeep
@@ -363,5 +363,68 @@ class DoctorLingerTest(CliTestCase):
363
363
  self.assertEqual(code, 0, out)
364
364
 
365
365
 
366
+ class DoctorWslTest(CliTestCase):
367
+ """SPEC.md §17.2: le tre righe WSL di `doctor` sono informative e non fatali."""
368
+
369
+ def doctor_wsl(self, distro='Ubuntu', systemd=False, dest='/mnt/c/backup'):
370
+ cfg = write(os.path.join(self.tmp, 'wsl.cfg'), f'dest: {dest}\n')
371
+ args = argparse.Namespace(config=cfg, v=False)
372
+ buf = io.StringIO()
373
+ with mock.patch.dict(os.environ, {'HOME': self.home}), \
374
+ mock.patch.object(cli, 'is_wsl', return_value=True), \
375
+ mock.patch.object(cli, 'wsl_distro', return_value=distro), \
376
+ mock.patch.object(cli, 'systemd_user_available',
377
+ return_value=systemd), \
378
+ contextlib.redirect_stdout(buf):
379
+ code = cli.cmd_doctor(args)
380
+ return code, buf.getvalue()
381
+
382
+ @unittest.skipUnless(shutil.which('fswatch'), 'fswatch non installato')
383
+ def test_wsle_righe_con_hint_systemd_e_drvfs(self):
384
+ code, out = self.doctor_wsl()
385
+ self.assertIn('✔ WSL rilevato: Ubuntu', out)
386
+ hint = next(l for l in out.splitlines()
387
+ if 'systemd user non disponibile' in l)
388
+ self.assertTrue(hint.startswith('✗'), hint)
389
+ for needle in ('/etc/wsl.conf', 'systemd=true', 'safekeep run',
390
+ 'warning non fatale'):
391
+ self.assertIn(needle, hint, needle)
392
+ drvfs = next(l for l in out.splitlines() if 'drvfs' in l)
393
+ self.assertIn('/mnt/', drvfs)
394
+ self.assertEqual(code, 0, out) # mai fatale
395
+
396
+ @unittest.skipUnless(shutil.which('fswatch'), 'fswatch non installato')
397
+ def test_systemd_attivo_nessun_hint(self):
398
+ code, out = self.doctor_wsl(systemd=True)
399
+ self.assertNotIn('systemd user non disponibile', out)
400
+ self.assertIn('WSL rilevato', out)
401
+ self.assertEqual(code, 0, out)
402
+
403
+ @unittest.skipUnless(shutil.which('fswatch'), 'fswatch non installato')
404
+ def test_nessun_path_sotto_mnt_niente_drvfs(self):
405
+ code, out = self.doctor_wsl(dest=os.path.join(self.tmp, 'dst'))
406
+ self.assertNotIn('drvfs', out)
407
+ self.assertEqual(code, 0, out)
408
+
409
+ def test_detection_reale_via_env_fino_a_doctor(self):
410
+ """Detection reale (env) fino all'output di doctor: niente patch."""
411
+ env = dict(os.environ, HOME=self.home, WSL_DISTRO_NAME='Debian')
412
+ r = subprocess.run([sys.executable, CLI, 'doctor', '--config', self.cfg],
413
+ capture_output=True, text=True, env=env, cwd=REPO,
414
+ timeout=60)
415
+ self.assertIn('WSL rilevato: Debian', r.stdout)
416
+
417
+ @unittest.skipUnless(shutil.which('fswatch'), 'fswatch non installato')
418
+ def test_senza_wsl_nulla_cambia(self):
419
+ """Su Linux/macOS non-WSL nessuna riga WSL compare (SPEC §17)."""
420
+ env = {k: v for k, v in os.environ.items() if k != 'WSL_DISTRO_NAME'}
421
+ env['HOME'] = self.home
422
+ r = subprocess.run([sys.executable, CLI, 'doctor', '--config', self.cfg],
423
+ capture_output=True, text=True, env=env, cwd=REPO,
424
+ timeout=60)
425
+ self.assertNotIn('WSL', r.stdout)
426
+ self.assertNotIn('drvfs', r.stdout)
427
+
428
+
366
429
  if __name__ == '__main__':
367
430
  unittest.main()
@@ -227,5 +227,70 @@ class LingerTest(unittest.TestCase):
227
227
  self.assertFalse(plat.linger_enabled(linger_dir=self._dir()))
228
228
 
229
229
 
230
+ class WslTest(unittest.TestCase):
231
+ """SPEC.md §17.1: rilevamento WSL via env o kernel, letto a chiamata."""
232
+
233
+ def test_env_wsl_distro_name(self):
234
+ with mock.patch.dict(os.environ, {'WSL_DISTRO_NAME': 'Ubuntu'}):
235
+ self.assertTrue(plat.is_wsl())
236
+ self.assertEqual(plat.wsl_distro(), 'Ubuntu')
237
+
238
+ def test_kernel_microsoft_senza_env(self):
239
+ for release in ('5.15.167.4-microsoft-standard-WSL2', # WSL2
240
+ '4.4.0-19041-Microsoft'): # WSL1 (maiuscolo)
241
+ with self.subTest(release=release), \
242
+ mock.patch.dict(os.environ, {'WSL_DISTRO_NAME': ''}), \
243
+ mock.patch.object(plat, '_platform') as p:
244
+ p.release.return_value = release
245
+ self.assertTrue(plat.is_wsl())
246
+ self.assertEqual(plat.wsl_distro(), '')
247
+
248
+ def test_non_wsl(self):
249
+ with mock.patch.dict(os.environ, {'WSL_DISTRO_NAME': ''}), \
250
+ mock.patch.object(plat, '_platform') as p:
251
+ p.release.return_value = '24.6.0' # macOS, niente microsoft
252
+ self.assertFalse(plat.is_wsl())
253
+ self.assertEqual(plat.wsl_distro(), '')
254
+
255
+ def test_systemd_user_available(self):
256
+ with tempfile.TemporaryDirectory() as d:
257
+ self.assertTrue(plat.systemd_user_available(path=d))
258
+ self.assertFalse(
259
+ plat.systemd_user_available(path='/non/esiste/qui'),
260
+ '/run/systemd/system assente = WSL1 o systemd spento (SPEC §17.2)')
261
+
262
+
263
+ @unittest.skipUnless(shutil.which('fswatch'), 'fswatch non installato')
264
+ class InstallShSenzaSystemdTest(unittest.TestCase):
265
+ """SPEC.md §17.3: `install.sh` su Linux senza systemd → unit NON installata,
266
+ istruzioni di avvio manuale ed exit 0 (nessun errore duro)."""
267
+
268
+ def test_ramo_linux_senza_systemd_esce_0(self):
269
+ if os.path.isdir('/run/systemd/system'):
270
+ self.skipTest('questa macchina ha systemd: ramo diverso')
271
+ tmp = tempfile.mkdtemp()
272
+ self.addCleanup(shutil.rmtree, tmp, ignore_errors=True)
273
+ home, fake_bin = os.path.join(tmp, 'home'), os.path.join(tmp, 'bin')
274
+ os.makedirs(home)
275
+ os.makedirs(fake_bin)
276
+ uname = os.path.join(fake_bin, 'uname') # uname finto → Linux
277
+ with open(uname, 'w') as fh:
278
+ fh.write('#!/bin/bash\necho Linux\n')
279
+ os.chmod(uname, 0o755)
280
+ env = dict(os.environ, HOME=home,
281
+ PATH=fake_bin + os.pathsep + os.environ['PATH'])
282
+ r = subprocess.run(['bash', os.path.join(REPO, 'install.sh')],
283
+ capture_output=True, text=True, env=env, timeout=60)
284
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
285
+ self.assertIn('systemd non disponibile', r.stdout)
286
+ self.assertIn('safekeep.py run', r.stdout) # avvio manuale
287
+ self.assertIn('systemd=true', r.stdout) # hint /etc/wsl.conf
288
+ self.assertTrue(os.path.exists(os.path.join(home, '.safekeep')),
289
+ 'la config viene creata comunque')
290
+ self.assertFalse(os.path.exists(
291
+ os.path.join(home, '.config/systemd/user/safekeep.service')),
292
+ 'niente unit senza systemd')
293
+
294
+
230
295
  if __name__ == '__main__':
231
296
  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
File without changes