safekeep 0.3.0__tar.gz → 0.3.1__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.3.0/safekeep.egg-info → safekeep-0.3.1}/PKG-INFO +1 -1
  2. {safekeep-0.3.0 → safekeep-0.3.1}/safekeep/__init__.py +1 -1
  3. {safekeep-0.3.0 → safekeep-0.3.1}/safekeep/cli.py +30 -1
  4. {safekeep-0.3.0 → safekeep-0.3.1}/safekeep/daemon.py +2 -2
  5. safekeep-0.3.1/safekeep/platform.py +119 -0
  6. {safekeep-0.3.0 → safekeep-0.3.1/safekeep.egg-info}/PKG-INFO +1 -1
  7. {safekeep-0.3.0 → safekeep-0.3.1}/tests/test_cli.py +93 -1
  8. {safekeep-0.3.0 → safekeep-0.3.1}/tests/test_platform.py +81 -0
  9. safekeep-0.3.0/safekeep/platform.py +0 -57
  10. {safekeep-0.3.0 → safekeep-0.3.1}/LICENSE +0 -0
  11. {safekeep-0.3.0 → safekeep-0.3.1}/README.md +0 -0
  12. {safekeep-0.3.0 → safekeep-0.3.1}/pyproject.toml +0 -0
  13. {safekeep-0.3.0 → safekeep-0.3.1}/safekeep/config.py +0 -0
  14. {safekeep-0.3.0 → safekeep-0.3.1}/safekeep/copier.py +0 -0
  15. {safekeep-0.3.0 → safekeep-0.3.1}/safekeep/matcher.py +0 -0
  16. {safekeep-0.3.0 → safekeep-0.3.1}/safekeep/volumes.py +0 -0
  17. {safekeep-0.3.0 → safekeep-0.3.1}/safekeep.egg-info/SOURCES.txt +0 -0
  18. {safekeep-0.3.0 → safekeep-0.3.1}/safekeep.egg-info/dependency_links.txt +0 -0
  19. {safekeep-0.3.0 → safekeep-0.3.1}/safekeep.egg-info/entry_points.txt +0 -0
  20. {safekeep-0.3.0 → safekeep-0.3.1}/safekeep.egg-info/top_level.txt +0 -0
  21. {safekeep-0.3.0 → safekeep-0.3.1}/setup.cfg +0 -0
  22. {safekeep-0.3.0 → safekeep-0.3.1}/tests/test_config.py +0 -0
  23. {safekeep-0.3.0 → safekeep-0.3.1}/tests/test_copier.py +0 -0
  24. {safekeep-0.3.0 → safekeep-0.3.1}/tests/test_daemon.py +0 -0
  25. {safekeep-0.3.0 → safekeep-0.3.1}/tests/test_matcher.py +0 -0
  26. {safekeep-0.3.0 → safekeep-0.3.1}/tests/test_volumes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: safekeep
3
- Version: 0.3.0
3
+ Version: 0.3.1
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.0'
6
+ __version__ = '0.3.1'
@@ -14,11 +14,14 @@ from safekeep.copier import TMP_INFIX
14
14
  from safekeep.daemon import Daemon, setup_logging
15
15
  from safekeep.platform import (
16
16
  INOTIFY_MIN_WATCHES,
17
+ LOG_PATH,
17
18
  fswatch_monitor,
18
19
  inotify_limit,
19
20
  install_hint,
20
21
  is_darwin,
21
22
  is_linux,
23
+ linger_enabled,
24
+ recent_permission_errors,
22
25
  )
23
26
  from safekeep.volumes import dest_state
24
27
 
@@ -54,7 +57,8 @@ def build_parser():
54
57
  help='config, progetti e stato delle dest — sola lettura')
55
58
  sub.add_parser('doctor', parents=[common],
56
59
  help='diagnostica: config, fswatch + monitor di piattaforma, python, '
57
- 'TCC/plist (macOS), inotify/systemd (Linux) — exit ≠ 0 se un check '
60
+ 'errori di permesso recenti nel log, TCC/plist (macOS), '
61
+ 'inotify/systemd/linger (Linux) — exit ≠ 0 se un check '
58
62
  'fatale fallisce')
59
63
  return parser
60
64
 
@@ -226,6 +230,20 @@ def cmd_doctor(args):
226
230
  out(sys.version_info >= (3, 9),
227
231
  f'python {sys.version.split()[0]} (≥ 3.9)', is_fatal=True)
228
232
 
233
+ # Errori di permesso recenti nel log corrente (SPEC.md §14.4): sintomo
234
+ # delle ultime 24h, non stato del sistema → sempre NON fatale
235
+ log = os.path.expanduser(LOG_PATH)
236
+ recent = recent_permission_errors()
237
+ if recent is None:
238
+ out(True, f'errori di permesso recenti: log assente — check saltato ({log})')
239
+ elif recent[0] > 0:
240
+ out(False, f'errori di permesso recenti: {recent[0]} nelle ultime 24h '
241
+ f'(ultimo: {recent[1]}) — log: {log} — warning non fatale: '
242
+ 'vedi i pending con `safekeep sync-once --dry-run`, poi '
243
+ 'riconcedere i permessi al volume (FDA/Full Disk Access)')
244
+ else:
245
+ out(True, f'errori di permesso recenti: nessuno nelle ultime 24h — log: {log}')
246
+
229
247
  # Linux: il limite di watch di inotify è il rischio #1 sugli alberi grandi
230
248
  # (SPEC.md §14.4) — informativo, NON fatale
231
249
  if is_linux():
@@ -296,6 +314,17 @@ def cmd_doctor(args):
296
314
  'del template — rilancia install.sh', is_fatal=True)
297
315
  else:
298
316
  out(True, f'unit systemd: {unit}')
317
+ # linger: solo dove la unit è installata (SPEC.md §14.4) — non fatale
318
+ enabled = linger_enabled()
319
+ if enabled is None:
320
+ out(True, 'linger: non verificabile (/var/lib/systemd/linger '
321
+ 'assente) — check saltato')
322
+ elif enabled:
323
+ out(True, 'linger: abilitato (agent al boot anche senza login)')
324
+ else:
325
+ out(False, "linger: NON abilitato — l'agent non partirà al boot "
326
+ 'senza login → loginctl enable-linger $USER — '
327
+ 'warning non fatale')
299
328
 
300
329
  return 1 if fatal else 0
301
330
 
@@ -27,7 +27,7 @@ from .config import (
27
27
  )
28
28
  from .copier import MOUNT_ERRNOS, copy_one, needs_copy, prune_project, reconcile_project
29
29
  from .matcher import BUILTIN_RULES, Matcher, _glob, parse_rule
30
- from .platform import fswatch_monitor, init_system
30
+ from .platform import LOG_PATH, fswatch_monitor, init_system
31
31
  from .volumes import backoff_schedule, dest_state, partition_dests
32
32
 
33
33
  log = logging.getLogger('safekeep')
@@ -271,7 +271,7 @@ def setup_logging(level='info', logfile=None):
271
271
  logger._safekeep_handlers = True
272
272
  fmt = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
273
273
  if logfile is None:
274
- logfile = os.path.expanduser('~/.local/state/safekeep/safekeep.log')
274
+ logfile = os.path.expanduser(LOG_PATH)
275
275
  try:
276
276
  os.makedirs(os.path.dirname(logfile), exist_ok=True)
277
277
  handler = logging.handlers.RotatingFileHandler(
@@ -0,0 +1,119 @@
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, 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.
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 getpass
13
+ import os
14
+ import re
15
+ import shutil
16
+ import sys
17
+ from datetime import datetime
18
+
19
+ INOTIFY_LIMIT_PATH = '/proc/sys/fs/inotify/max_user_watches'
20
+ INOTIFY_MIN_WATCHES = 16384 # sotto questa soglia un home medio lo supera
21
+ LOG_PATH = '~/.local/state/safekeep/safekeep.log' # log corrente (SPEC.md §3)
22
+ LINGER_DIR = '/var/lib/systemd/linger'
23
+ # EPERM (Errno 1) / EACCES (Errno 13) come li scrive il logger: codice errno
24
+ # + testo del kernel (SPEC.md §14.4)
25
+ PERM_ERR_RE = re.compile(r'\[Errno (?:1|13)\]|Operation not permitted|Permission denied')
26
+
27
+
28
+ def is_darwin():
29
+ return sys.platform == 'darwin'
30
+
31
+
32
+ def is_linux():
33
+ return sys.platform == 'linux'
34
+
35
+
36
+ def fswatch_monitor():
37
+ """Monitor fswatch per la piattaforma (SPEC.md §14.2), usato da
38
+ `daemon.fswatch_argv()` e dal check monitor di `doctor`."""
39
+ return 'fsevents_monitor' if is_darwin() else 'inotify_monitor'
40
+
41
+
42
+ def init_system():
43
+ """`launchd` (Darwin) o `systemd` (Linux) — SPEC.md §14.3."""
44
+ return 'launchd' if is_darwin() else 'systemd'
45
+
46
+
47
+ def install_hint():
48
+ """Come installare `fswatch` su questa macchina (SPEC.md §14.4)."""
49
+ if is_darwin():
50
+ return 'brew install fswatch'
51
+ for pm, cmd in (('pacman', 'pacman -S fswatch'),
52
+ ('apt-get', 'sudo apt-get install fswatch'),
53
+ ('dnf', 'sudo dnf install fswatch')):
54
+ if shutil.which(pm):
55
+ return cmd
56
+ return 'installa fswatch col package manager della tua distro'
57
+
58
+
59
+ def inotify_limit(path=INOTIFY_LIMIT_PATH):
60
+ """`fs.inotify.max_user_watches`, o None se il file non c'è o è illeggibile
61
+ (SPEC.md §14.4: su Linux è il rischio #1 sugli alberi grandi)."""
62
+ try:
63
+ with open(path) as fh:
64
+ return int(fh.read().strip())
65
+ except (OSError, ValueError):
66
+ return None
67
+
68
+
69
+ def recent_permission_errors(path=None, now=None, window=86400):
70
+ """`(conteggio, ultimo timestamp)` degli errori di permesso nelle ultime
71
+ `window` secondi del log corrente, o None se il log non c'è/non è leggibile
72
+ (SPEC.md §14.4: check non fatale, finestra 24h — segue l'incidente EPERM).
73
+
74
+ Il timestamp è il prefisso `%(asctime)s` della riga (`YYYY-MM-DD HH:MM:SS`,
75
+ prime 19 posizioni). **Limite dichiarato**: una riga senza timestamp
76
+ parsificabile o fuori dalla finestra (clock skew incluso) non viene contata —
77
+ non essendo sicuri che sia recente, non la contiamo: il conteggio è un minimo."""
78
+ if path is None:
79
+ path = os.path.expanduser(LOG_PATH)
80
+ if now is None:
81
+ now = datetime.now()
82
+ count, last = 0, None
83
+ try:
84
+ with open(path, encoding='utf-8', errors='replace') as fh:
85
+ for line in fh:
86
+ if not PERM_ERR_RE.search(line):
87
+ continue
88
+ try:
89
+ ts = datetime.strptime(line[:19], '%Y-%m-%d %H:%M:%S')
90
+ except ValueError:
91
+ continue # timestamp illeggibile → non sicuri, salta
92
+ age = (now - ts).total_seconds()
93
+ if not 0 <= age <= window:
94
+ continue # troppo vecchio (o nel futuro: clock skew)
95
+ count += 1
96
+ if last is None or ts > last:
97
+ last = ts
98
+ except OSError:
99
+ return None
100
+ return count, last
101
+
102
+
103
+ def linger_enabled(user=None, linger_dir=None):
104
+ """`True`/`False` se l'utente è in `/var/lib/systemd/linger/`, None se la
105
+ directory non c'è (niente systemd → non determinabile, SPEC.md §14.4).
106
+ Legge la directory direttamente: stessa informazione di
107
+ `loginctl show-user $USER -p Linger`, senza root."""
108
+ if linger_dir is None:
109
+ linger_dir = LINGER_DIR
110
+ try:
111
+ entries = os.listdir(linger_dir)
112
+ except OSError:
113
+ return None
114
+ if user is None:
115
+ try:
116
+ user = getpass.getuser()
117
+ except OSError:
118
+ return None
119
+ return user in entries
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: safekeep
3
- Version: 0.3.0
3
+ Version: 0.3.1
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
@@ -4,6 +4,7 @@ Ogni run è un subprocess con HOME finto: niente scritture nella home reale.
4
4
  """
5
5
  import argparse
6
6
  import contextlib
7
+ import getpass
7
8
  import io
8
9
  import os
9
10
  import shutil
@@ -11,9 +12,10 @@ import subprocess
11
12
  import sys
12
13
  import tempfile
13
14
  import unittest
15
+ from datetime import datetime, timedelta
14
16
  from unittest import mock
15
17
 
16
- from safekeep import cli
18
+ from safekeep import cli, platform as plat
17
19
  from safekeep.platform import fswatch_monitor
18
20
 
19
21
  REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -271,5 +273,95 @@ class PruneCliTest(CliTestCase):
271
273
  self.assertTrue(os.path.exists(os.path.join(self.dest, keep)), keep)
272
274
 
273
275
 
276
+ class DoctorPermessiRecentiTest(CliTestCase):
277
+ """SPEC.md §14.4: errori di permesso nel log corrente — finestra 24h,
278
+ warning non fatale, log assente ⇒ check saltato."""
279
+
280
+ def log_path(self):
281
+ return os.path.join(self.home, '.local/state/safekeep/safekeep.log')
282
+
283
+ @staticmethod
284
+ def stamp(delta):
285
+ return (datetime.now() - delta).strftime('%Y-%m-%d %H:%M:%S,000')
286
+
287
+ def riga(self, delta, msg='[Errno 1] Operation not permitted: /x.tmp.1'):
288
+ return f'{self.stamp(delta)} ERROR copia fallita a → b: {msg}\n'
289
+
290
+ def riga_doctor(self, stdout):
291
+ self.assertIn('errori di permesso', stdout, stdout)
292
+ return next(l for l in stdout.splitlines() if 'errori di permesso' in l)
293
+
294
+ def test_errore_recente_warning_non_fatale(self):
295
+ write(self.log_path(), self.riga(timedelta(hours=3)))
296
+ r = self.cli('doctor', '--config', self.cfg)
297
+ line = self.riga_doctor(r.stdout)
298
+ self.assertTrue(line.startswith('✗'), line)
299
+ for needle in ('nelle ultime 24h', 'warning non fatale',
300
+ 'sync-once --dry-run', 'Full Disk Access',
301
+ self.log_path()):
302
+ self.assertIn(needle, line, needle)
303
+ self.assertEqual(r.returncode, 0, r.stdout) # NON fatale
304
+
305
+ def test_errore_vecchio_verde(self):
306
+ write(self.log_path(),
307
+ self.riga(timedelta(days=3), '[Errno 13] Permission denied'))
308
+ r = self.cli('doctor', '--config', self.cfg)
309
+ line = self.riga_doctor(r.stdout)
310
+ self.assertTrue(line.startswith('✔'), line)
311
+ self.assertIn('nessuno nelle ultime 24h', line)
312
+
313
+ def test_log_assente_check_saltato(self):
314
+ r = self.cli('doctor', '--config', self.cfg) # nessun log nel HOME finto
315
+ line = self.riga_doctor(r.stdout)
316
+ self.assertTrue(line.startswith('✔'), line)
317
+ self.assertIn('check saltato', line)
318
+
319
+
320
+ @unittest.skipUnless(shutil.which('fswatch'), 'fswatch non installato')
321
+ class DoctorLingerTest(CliTestCase):
322
+ """SPEC.md §14.4: linger solo dove c'è la unit systemd, warning non fatale."""
323
+
324
+ def doctor_linux(self, unit=True, linger=()):
325
+ if unit:
326
+ write(os.path.join(self.home, '.config/systemd/user/safekeep.service'),
327
+ '[Unit]\nDescription=safekeep\n')
328
+ linger_dir = tempfile.mkdtemp()
329
+ self.addCleanup(shutil.rmtree, linger_dir, ignore_errors=True)
330
+ for name in linger:
331
+ open(os.path.join(linger_dir, name), 'w').close()
332
+ args = argparse.Namespace(config=self.cfg, v=False)
333
+ buf = io.StringIO()
334
+ with mock.patch.dict(os.environ, {'HOME': self.home}), \
335
+ mock.patch.object(cli, 'is_linux', return_value=True), \
336
+ mock.patch.object(cli, 'is_darwin', return_value=False), \
337
+ mock.patch.object(plat, 'LINGER_DIR', linger_dir), \
338
+ contextlib.redirect_stdout(buf):
339
+ code = cli.cmd_doctor(args)
340
+ return code, buf.getvalue()
341
+
342
+ @staticmethod
343
+ def riga_linger(out):
344
+ return next(l for l in out.splitlines() if 'linger' in l)
345
+
346
+ def test_unit_assenta_niente_linger(self):
347
+ code, out = self.doctor_linux(unit=False)
348
+ self.assertNotIn('linger', out, 'il check esiste solo con la unit installata')
349
+ self.assertEqual(code, 0, out)
350
+
351
+ def test_linger_disabilitato_warning_non_fatale(self):
352
+ code, out = self.doctor_linux(unit=True)
353
+ line = self.riga_linger(out)
354
+ self.assertTrue(line.startswith('✗'), line)
355
+ self.assertIn('loginctl enable-linger', line)
356
+ self.assertIn('warning non fatale', line)
357
+ self.assertEqual(code, 0, out) # NON fatale
358
+
359
+ def test_linger_abilitato_verde(self):
360
+ code, out = self.doctor_linux(unit=True, linger=(getpass.getuser(),))
361
+ line = self.riga_linger(out)
362
+ self.assertTrue(line.startswith('✔'), line)
363
+ self.assertEqual(code, 0, out)
364
+
365
+
274
366
  if __name__ == '__main__':
275
367
  unittest.main()
@@ -8,6 +8,7 @@ import shutil
8
8
  import subprocess
9
9
  import tempfile
10
10
  import unittest
11
+ from datetime import datetime, timedelta
11
12
  from unittest import mock
12
13
 
13
14
  from safekeep import platform as plat
@@ -146,5 +147,85 @@ class AgentTemplateTest(unittest.TestCase):
146
147
  self.assertIn(needle, text, needle)
147
148
 
148
149
 
150
+ class RecentPermissionErrorsTest(unittest.TestCase):
151
+ """SPEC.md §14.4: errori di permesso recenti nel log, finestra 24h,
152
+ non fatale; righe senza timestamp sicuro non vengono contate."""
153
+
154
+ NOW = datetime(2026, 10, 4, 20, 0, 0)
155
+
156
+ def _log(self, text):
157
+ fd, path = tempfile.mkstemp(suffix='.log')
158
+ self.addCleanup(os.unlink, path)
159
+ with os.fdopen(fd, 'w') as fh:
160
+ fh.write(text)
161
+ return path
162
+
163
+ @staticmethod
164
+ def _stamp(dt):
165
+ return dt.strftime('%Y-%m-%d %H:%M:%S,000') # formato %(asctime)s
166
+
167
+ def test_ricente_vecchio_e_normale_nello_stesso_log(self):
168
+ text = (
169
+ f'{self._stamp(self.NOW - timedelta(hours=2))} ERROR copia fallita '
170
+ "a → b: [Errno 1] Operation not permitted: '/x.tmp.1'\n"
171
+ f'{self._stamp(self.NOW - timedelta(days=3))} ERROR copia fallita '
172
+ "a → b: [Errno 13] Permission denied: '/y'\n"
173
+ f'{self._stamp(self.NOW - timedelta(minutes=5))} INFO reconcile: 0 copie\n'
174
+ )
175
+ count, last = plat.recent_permission_errors(self._log(text), now=self.NOW)
176
+ self.assertEqual(count, 1, 'solo la riga nelle ultime 24h')
177
+ self.assertEqual(last, self.NOW - timedelta(hours=2))
178
+
179
+ def test_log_assente(self):
180
+ self.assertIsNone(
181
+ plat.recent_permission_errors('/non/esiste/safekeep.log'),
182
+ 'nessun log ⇒ check saltato, non warning')
183
+
184
+ def test_timestamp_illeggibile_non_e_contato(self):
185
+ text = ("senza-data ERROR [Errno 1] Operation not permitted: '/x'\n"
186
+ + f'{self._stamp(self.NOW - timedelta(hours=1))} ERROR '
187
+ "[Errno 1] Operation not permitted: '/y'\n"
188
+ + f'{self._stamp(self.NOW + timedelta(hours=1))} ERROR '
189
+ "[Errno 1] Operation not permitted: '/z'\n")
190
+ count, _ = plat.recent_permission_errors(self._log(text), now=self.NOW)
191
+ self.assertEqual(count, 1, 'non sicuri (o clock skew) ⇒ non contiamo')
192
+
193
+ def test_copre_eperm_e_eacces(self):
194
+ for riga in ('[Errno 1] Operation not permitted',
195
+ 'qualcosa Operation not permitted',
196
+ '[Errno 13] Permission denied',
197
+ 'qualcosa Permission denied'):
198
+ with self.subTest(riga=riga):
199
+ self.assertIsNotNone(plat.PERM_ERR_RE.search(riga))
200
+ self.assertIsNone(plat.PERM_ERR_RE.search('INFO reconcile: 0 copie'))
201
+
202
+
203
+ class LingerTest(unittest.TestCase):
204
+ """SPEC.md §14.4: linger systemd, directory finta al posto di
205
+ /var/lib/systemd/linger."""
206
+
207
+ def _dir(self, *entries):
208
+ path = tempfile.mkdtemp()
209
+ self.addCleanup(shutil.rmtree, path, ignore_errors=True)
210
+ for name in entries:
211
+ open(os.path.join(path, name), 'w').close()
212
+ return path
213
+
214
+ def test_abilitato_disabilitato_e_dir_assente(self):
215
+ d = self._dir('utente')
216
+ self.assertTrue(plat.linger_enabled(user='utente', linger_dir=d))
217
+ self.assertFalse(plat.linger_enabled(user='altro', linger_dir=d))
218
+ self.assertIsNone(
219
+ plat.linger_enabled(user='utente',
220
+ linger_dir=os.path.join(os.sep, 'niente', 'qui')),
221
+ 'niente systemd ⇒ non determinabile, check saltato')
222
+
223
+ def test_user_default_e_costante(self):
224
+ self.assertEqual(plat.LINGER_DIR, '/var/lib/systemd/linger')
225
+ with mock.patch('getpass.getuser', return_value='chi'):
226
+ self.assertTrue(plat.linger_enabled(linger_dir=self._dir('chi')))
227
+ self.assertFalse(plat.linger_enabled(linger_dir=self._dir()))
228
+
229
+
149
230
  if __name__ == '__main__':
150
231
  unittest.main()
@@ -1,57 +0,0 @@
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
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