dron 0.1.20241008__py3-none-any.whl → 0.2.20251011__py3-none-any.whl

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.
dron/dron.py CHANGED
@@ -1,16 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import importlib.util
4
- import os
5
- import shlex
6
4
  import sys
7
5
  from collections import OrderedDict
6
+ from collections.abc import Iterable, Iterator
7
+ from concurrent.futures import ProcessPoolExecutor
8
8
  from difflib import unified_diff
9
9
  from itertools import tee
10
10
  from pathlib import Path
11
- from subprocess import check_call, run
12
- from tempfile import TemporaryDirectory
13
- from typing import Iterable, Iterator, NamedTuple, Union
11
+ from subprocess import check_call
12
+ from typing import NamedTuple
14
13
 
15
14
  import click
16
15
 
@@ -35,9 +34,6 @@ DRON_UNITS_DIR = DRON_DIR / 'units'
35
34
  DRON_UNITS_DIR.mkdir(parents=True, exist_ok=True)
36
35
 
37
36
 
38
- DRONTAB = DRON_DIR / 'drontab.py'
39
-
40
-
41
37
  def verify_units(pre_units: list[tuple[UnitName, Body]]) -> None:
42
38
  # need an inline import here in case we modify this variable from cli/tests
43
39
  from .common import VERIFY_UNITS
@@ -60,7 +56,7 @@ def verify_unit(*, unit_name: UnitName, body: Body) -> None:
60
56
  return verify_units([(unit_name, body)])
61
57
 
62
58
 
63
- def write_unit(*, unit: Unit, body: Body, prefix: Path=DRON_UNITS_DIR) -> None:
59
+ def write_unit(*, unit: Unit, body: Body, prefix: Path = DRON_UNITS_DIR) -> None:
64
60
  unit_file = prefix / unit
65
61
 
66
62
  logger.info(f'writing unit file: {unit_file}')
@@ -146,11 +142,12 @@ class Add(NamedTuple):
146
142
  return self.unit_file.name
147
143
 
148
144
 
149
- Action = Union[Update, Delete, Add]
145
+ Action = Update | Delete | Add
150
146
  Plan = Iterable[Action]
151
147
 
152
148
  # TODO ugh. not sure how to verify them?
153
149
 
150
+
154
151
  def compute_plan(*, current: State, pending: State) -> Plan:
155
152
  # eh, I feel like i'm reinventing something already existing here...
156
153
  currentd = OrderedDict((x.unit_file, unwrap(x.body)) for x in current)
@@ -178,6 +175,7 @@ def apply_state(pending: State) -> None:
178
175
  current = list(managed_units(with_body=True))
179
176
 
180
177
  pending_units = {s.unit_file.name for s in pending}
178
+
181
179
  def is_always_running(unit_path: Path) -> bool:
182
180
  name = unit_path.stem
183
181
  has_timer = f'{name}.timer' in pending_units
@@ -198,7 +196,7 @@ def apply_state(pending: State) -> None:
198
196
  elif isinstance(a, Update):
199
197
  _updates.append(a)
200
198
  else:
201
- raise AssertionError("Can't happen", a)
199
+ raise TypeError("Can't happen", a)
202
200
 
203
201
  if len(deletes) == len(current) and len(deletes) > 0:
204
202
  msg = "Trying to delete all managed jobs"
@@ -213,10 +211,12 @@ def apply_state(pending: State) -> None:
213
211
 
214
212
  for u in _updates:
215
213
  unit = a.unit
216
- diff: Diff = list(unified_diff(
217
- u.old_body.splitlines(keepends=True),
218
- u.new_body.splitlines(keepends=True),
219
- ))
214
+ diff: Diff = list(
215
+ unified_diff(
216
+ u.old_body.splitlines(keepends=True),
217
+ u.new_body.splitlines(keepends=True),
218
+ )
219
+ )
220
220
  if len(diff) == 0:
221
221
  nochange.append(u)
222
222
  else:
@@ -231,15 +231,14 @@ def apply_state(pending: State) -> None:
231
231
  for a in deletes:
232
232
  if IS_SYSTEMD:
233
233
  # TODO stop timer first?
234
- check_call(_systemctl('stop' , a.unit))
234
+ check_call(_systemctl('stop', a.unit))
235
235
  check_call(_systemctl('disable', a.unit))
236
236
  else:
237
237
  launchd.launchctl_unload(unit=Path(a.unit).stem)
238
238
  for a in deletes:
239
239
  (DRON_UNITS_DIR / a.unit).unlink()
240
240
 
241
-
242
- for (u, diff) in updates:
241
+ for u, diff in updates:
243
242
  unit = u.unit
244
243
  unit_file = u.unit_file
245
244
  logger.info(f'updating {unit}')
@@ -297,47 +296,16 @@ def manage(state: State) -> None:
297
296
  Error = str
298
297
  # TODO perhaps, return Plan or error instead?
299
298
 
300
- # eh, implicit convention that only one state will be emitted. oh well
301
- def lint(tabfile: Path) -> Iterator[Exception | State]:
302
- linters = [
303
- [sys.executable, '-m', 'mypy', '--no-incremental', '--check-untyped', str(tabfile)],
304
- ]
305
-
306
- ldir = tabfile.parent
307
- # TODO not sure if should always lint in temporary dir to prevent turds?
308
-
309
- dron_dir = str(Path(__file__).resolve().absolute().parent)
310
- dtab_dir = drontab_dir()
311
-
312
- # meh.
313
- def extra_path(variable: str, path: str, env) -> dict[str, str]:
314
- vv = env.get(variable)
315
- pp = path + ('' if vv is None else ':' + vv)
316
- return {**env, variable: pp}
317
-
318
- errors = []
319
- for l in linters:
320
- scmd = ' '.join(map(shlex.quote, l))
321
- logger.info(f'Running: {scmd}')
322
- with TemporaryDirectory() as td:
323
- env = {**os.environ}
324
- env = extra_path('MYPYPATH' , dtab_dir, env)
325
-
326
- r = run(l, cwd=str(ldir), env=env, check=False)
327
- if r.returncode == 0:
328
- logger.info('OK')
329
- continue
330
- else:
331
- logger.error(f'FAIL: code: {r.returncode}')
332
- errors.append('error')
333
- if len(errors) > 0:
334
- yield RuntimeError('Python linting failed!')
335
- return
336
299
 
337
- # TODO just add options to skip python lint? so it always goes through same code paths
300
+ # eh, implicit convention that only one state will be emitted. oh well
301
+ # FIXME rename from lint? just use compileall or something as a syntax check?
302
+ def lint(tab_module: str) -> Iterator[Exception | State]:
303
+ # TODO tbh compileall is pointless
304
+ # - we can't find out source names property without importing
305
+ # - we'll find out about errors during importing anyway
338
306
 
339
307
  try:
340
- jobs = load_jobs(tabfile=tabfile, ppath=Path(dtab_dir))
308
+ jobs = load_jobs(tab_module)
341
309
  except Exception as e:
342
310
  # TODO could add better logging here? 'i.e. error while loading jobs'
343
311
  logger.exception(e)
@@ -354,101 +322,42 @@ def lint(tabfile: Path) -> Iterator[Exception | State]:
354
322
  yield state
355
323
 
356
324
 
357
- def test_do_lint(tmp_path: Path) -> None:
358
- import pytest
359
-
360
-
361
- def ok(body: str) -> None:
362
- tpath = Path(tmp_path) / 'drontab.py'
363
- tpath.write_text(body)
364
- do_lint(tabfile=tpath)
365
-
366
- def fails(body: str) -> None:
367
- with pytest.raises(Exception):
368
- ok(body)
369
-
370
- fails(body='''
371
- None.whatever
372
- ''')
373
-
374
- # no jobs
375
- fails(body='''
376
- ''')
377
-
378
- ok(body='''
379
- def jobs():
380
- yield from []
381
- ''')
382
-
383
- ok(body='''
384
- from dron.api import job
385
- def jobs():
386
- yield job(
387
- 'hourly',
388
- ['/bin/echo', '123'],
389
- unit_name='unit_test',
390
- )
391
- ''')
392
-
393
- from .systemd import _is_missing_systemd
394
-
395
- if not _is_missing_systemd():
396
- from .cli import _drontab_example
397
-
398
- # this test doesn't work without systemd yet, because launchd adapter doesn't support unquoted commands, at least yet..
399
- example = _drontab_example()
400
- # ugh. some hackery to make it find the executable..
401
- echo = " '/bin/echo"
402
- example = example.replace(" 'linkchecker", echo).replace(" '/home/user/scripts/run-borg", echo).replace(" 'ping", " '/bin/ping")
403
- ok(body=example)
404
-
405
-
406
- def do_lint(tabfile: Path) -> State:
407
- eit, vit = tee(lint(tabfile))
408
- errors = [r for r in eit if isinstance(r, Exception)]
325
+ def do_lint(tab_module: str) -> State:
326
+ eit, vit = tee(lint(tab_module))
327
+ errors = [r for r in eit if isinstance(r, Exception)]
409
328
  values = [r for r in vit if not isinstance(r, Exception)]
410
329
  assert len(errors) == 0, errors
411
330
  [state] = values
412
331
  return state
413
332
 
414
333
 
415
- def drontab_dir() -> str:
416
- # meeh
417
- return str(DRONTAB.resolve().absolute().parent)
334
+ def _import_jobs(tab_module: str) -> list[Job]:
335
+ module = importlib.import_module(tab_module)
336
+ jobs_gen = getattr(module, 'jobs') # get dynamically to make type checking happy
337
+ return list(jobs_gen())
418
338
 
419
339
 
420
- def load_jobs(tabfile: Path, ppath: Path) -> Iterator[Job]:
421
- pp = str(ppath)
422
- sys.path.insert(0, pp)
423
- try:
424
- spec = importlib.util.spec_from_file_location(tabfile.name, tabfile)
425
- assert spec is not None, tabfile
426
- loader = spec.loader
427
- assert loader is not None, (tabfile, spec)
428
- module = importlib.util.module_from_spec(spec)
429
- loader.exec_module(module)
430
- finally:
431
- sys.path.remove(pp) # extremely meh..
432
-
433
- jobs = module.jobs
340
+ def load_jobs(tab_module: str) -> Iterator[Job]:
341
+ # actually import in a separate process to avoid mess with polluting sys.modules
342
+ # shouldn't be a problem in most cases, but it was annoying during tests
343
+ with ProcessPoolExecutor(max_workers=1) as pool:
344
+ jobs = pool.submit(_import_jobs, tab_module).result()
345
+
434
346
  emitted: dict[str, Job] = {}
435
- for job in jobs():
347
+ for job in jobs:
436
348
  assert isinstance(job, Job), job # just in case for dumb typos
437
349
  assert job.unit_name not in emitted, (job, emitted[job.unit_name])
438
350
  yield job
439
351
  emitted[job.unit_name] = job
440
352
 
441
353
 
442
- def apply(tabfile: Path) -> None:
443
- state = do_lint(tabfile)
354
+ def apply(tab_module: str) -> None:
355
+ # TODO rename do_lint to get_state?
356
+ state = do_lint(tab_module)
444
357
  manage(state=state)
445
358
 
446
359
 
447
- get_entries_for_monitor = (
448
- systemd.get_entries_for_monitor
449
- if IS_SYSTEMD else
450
- launchd.get_entries_for_monitor
451
- )
360
+ get_entries_for_monitor = systemd.get_entries_for_monitor if IS_SYSTEMD else launchd.get_entries_for_monitor
452
361
 
453
362
 
454
363
  def main() -> None:
dron/launchd.py CHANGED
@@ -7,11 +7,12 @@ import re
7
7
  import shlex
8
8
  import sys
9
9
  import textwrap
10
+ from collections.abc import Iterator, Sequence
10
11
  from datetime import timedelta
11
12
  from pathlib import Path
12
13
  from subprocess import PIPE, Popen, check_call, check_output
13
14
  from tempfile import TemporaryDirectory
14
- from typing import Any, Iterator, Sequence
15
+ from typing import Any
15
16
 
16
17
  from .api import (
17
18
  OnCalendar,
@@ -84,16 +85,14 @@ def launchctl_reload(*, unit: Unit, unit_file: UnitFile) -> None:
84
85
 
85
86
 
86
87
  def launchd_wrapper(*, job: str, on_failure: list[str]) -> list[str]:
87
- # fmt: off
88
88
  return [
89
89
  sys.executable,
90
- '-m',
91
- 'dron.launchd_wrapper',
90
+ '-B', # do not write byte code, otherwise it shits into dron directory if we're using editable install
91
+ '-m', 'dron.launchd_wrapper',
92
92
  *itertools.chain.from_iterable(('--notify', n) for n in on_failure),
93
93
  '--job', job,
94
94
  '--',
95
- ]
96
- # fmt: on
95
+ ] # fmt: skip
97
96
 
98
97
 
99
98
  def remove_launchd_wrapper(cmd: str) -> str:
@@ -109,7 +108,7 @@ def plist(
109
108
  unit_name: str,
110
109
  command: Command,
111
110
  on_failure: Sequence[OnFailureAction],
112
- when: When | None=None,
111
+ when: When | None = None,
113
112
  ) -> str:
114
113
  # TODO hmm, kinda mirrors 'escape' method, not sure
115
114
  cmd: Sequence[str]
@@ -136,17 +135,21 @@ def plist(
136
135
  else:
137
136
  assert isinstance(when, OnCalendar), when
138
137
  # https://www.freedesktop.org/software/systemd/man/systemd.time.html#
138
+ # fmt: off
139
139
  seconds = {
140
140
  'minutely': 60,
141
141
  'hourly' : 60 * 60,
142
142
  'daily' : 60 * 60 * 24,
143
143
  }.get(when)
144
+ # fmt: on
144
145
  if seconds is None:
145
146
  # ok, try systemd-like spec..
147
+ # fmt: off
146
148
  specs = [
147
149
  (re.escape('*:0/') + r'(\d+)', 60),
148
- (re.escape('*:*:0/') + r'(\d+)', 1 ),
150
+ (re.escape('*:*:0/') + r'(\d+)', 1),
149
151
  ]
152
+ # fmt: on
150
153
  for rgx, mult in specs:
151
154
  m = re.fullmatch(rgx, when)
152
155
  if m is not None:
@@ -159,23 +162,24 @@ def plist(
159
162
  assert m is not None, when
160
163
  hh = m.group(1)
161
164
  mm = m.group(2)
162
- mschedule = '\n'.join([
163
- '<key>StartCalendarInterval</key>',
164
- '<dict>',
165
- '<key>Hour</key>' , f'<integer>{int(hh)}</integer>',
166
- '<key>Minute</key>', f'<integer>{int(mm)}</integer>',
167
- '</dict>',
168
- ])
165
+ mschedule = '\n'.join(
166
+ [
167
+ '<key>StartCalendarInterval</key>',
168
+ '<dict>',
169
+ '<key>Hour</key>',
170
+ f'<integer>{int(hh)}</integer>',
171
+ '<key>Minute</key>',
172
+ f'<integer>{int(mm)}</integer>',
173
+ '</dict>',
174
+ ]
175
+ )
169
176
  else:
170
177
  mschedule = '\n'.join(('<key>StartInterval</key>', f'<integer>{seconds}</integer>'))
171
178
 
172
179
  assert mschedule != '', unit_name
173
180
 
174
181
  # meh.. not sure how to reconcile it better with systemd
175
- on_failure = [
176
- x.replace('--job %n', f'--job {unit_name}') + ' --stdin'
177
- for x in on_failure
178
- ]
182
+ on_failure = [x.replace('--job %n', f'--job {unit_name}') + ' --stdin' for x in on_failure]
179
183
 
180
184
  # attempt to set argv[0] properly
181
185
  # hmm I was hoping it would make desktop notifications ('background service added' nicer)
@@ -252,14 +256,14 @@ def launchd_state(*, with_body: bool) -> Iterator[LaunchdUnitState]:
252
256
  periodic_schedule = extras.get('run interval')
253
257
  calendal_schedule = 'com.apple.launchd.calendarinterval' in unwrap(all_props)
254
258
 
255
- schedule: str | None = None
259
+ schedule: str | None
256
260
  if periodic_schedule is not None:
257
261
  schedule = 'every ' + periodic_schedule
258
262
  elif calendal_schedule:
259
263
  # TODO parse properly
260
264
  schedule = 'calendar'
261
265
  else:
262
- # NOTE: seems like keepalive attribute isn't present in launcd dumpstate output
266
+ # NOTE: seems like keepalive attribute isn't present in launchd dumpstate output
263
267
  schedule = 'always'
264
268
 
265
269
  yield LaunchdUnitState(
@@ -302,15 +306,19 @@ def verify_unit(*, unit_name: str, body: str) -> None:
302
306
  with TemporaryDirectory() as tdir:
303
307
  tfile = Path(tdir) / unit_name
304
308
  tfile.write_text(body)
305
- check_call([
306
- 'plutil', '-lint',
307
- '-s', # silent on success
308
- tfile,
309
- ])
309
+ check_call(
310
+ [
311
+ 'plutil',
312
+ '-lint',
313
+ '-s', # silent on success
314
+ tfile,
315
+ ]
316
+ )
310
317
 
311
318
 
312
319
  def cmd_past(unit: Unit) -> None:
313
320
  sub = fqn('dron.' + unit)
321
+ # fmt: off
314
322
  cmd = [
315
323
  # todo maybe use 'stream'??
316
324
  'log', 'show', '--info',
@@ -323,8 +331,10 @@ def cmd_past(unit: Unit) -> None:
323
331
  '--style', 'ndjson',
324
332
  '--color', 'always',
325
333
  ]
334
+ # fmt: on
326
335
  with Popen(cmd, stdout=PIPE, encoding='utf8') as p:
327
- out = p.stdout; assert out is not None
336
+ out = p.stdout
337
+ assert out is not None
328
338
  for line in out:
329
339
  j = json.loads(line)
330
340
  if j.get('finished') == 1:
@@ -402,14 +412,16 @@ def get_entries_for_monitor(managed: State, *, params: MonitorParams) -> list[Mo
402
412
 
403
413
  pid = s.pid
404
414
 
405
- entries.append(MonitorEntry(
406
- unit=name,
407
- status=status,
408
- left='n/a',
409
- next='n/a',
410
- schedule=schedule,
411
- command=command,
412
- pid=pid,
413
- status_ok=status_ok,
414
- ))
415
+ entries.append(
416
+ MonitorEntry(
417
+ unit=name,
418
+ status=status,
419
+ left='n/a',
420
+ next='n/a',
421
+ schedule=schedule,
422
+ command=command,
423
+ pid=pid,
424
+ status_ok=status_ok,
425
+ )
426
+ )
415
427
  return entries
dron/launchd_wrapper.py CHANGED
@@ -1,17 +1,26 @@
1
1
  #!/usr/bin/env python3
2
2
  import argparse
3
+ import os
3
4
  import shlex
4
5
  import sys
6
+ from collections.abc import Iterator
5
7
  from pathlib import Path
6
8
  from subprocess import PIPE, STDOUT, Popen
7
- from typing import Iterator, NoReturn
9
+ from typing import NoReturn
8
10
 
9
11
  from loguru import logger
10
12
 
11
13
  LOG_DIR = Path('~/Library/Logs/dron').expanduser()
12
14
 
15
+ # OSX/launchd is a piece of shit and doesn't seem possible to just set it globally everywhere?
16
+ # this works: launchctl setenv PYTHONPYCACHEPREFIX $PYTHONPYCACHEPREFIX
17
+ # however unclear how to set it in a way that it's running before all other agents
18
+ # allegedly possible to use global LaunchDaemon running as root, but doesn't seem possible to execute launchctl commands as other user from launchd plist??
19
+ PYCACHE_PATH = Path('~/.cache/pycache').expanduser()
13
20
 
14
- def main() -> NoReturn:
21
+
22
+ # ty doesn't support NoReturn yet, see https://github.com/astral-sh/ty/issues/180
23
+ def main() -> NoReturn: # ty: ignore[invalid-return-type]
15
24
  p = argparse.ArgumentParser()
16
25
  p.add_argument('--notify', action='append')
17
26
  p.add_argument('--job', required=True)
@@ -30,10 +39,14 @@ def main() -> NoReturn:
30
39
 
31
40
  logger.add(log_file, rotation='100 MB') # todo configurable? or rely on osx rotation?
32
41
 
42
+ env = {**os.environ}
43
+ if "PYTHONPYCACHEPREFIX" not in env:
44
+ env["PYTHONPYCACHEPREFIX"] = str(PYCACHE_PATH)
45
+
33
46
  # hmm, a bit crap transforming everything to stdout? but not much we can do?
34
47
  captured_log = []
35
48
  try:
36
- with Popen(cmd, stdout=PIPE, stderr=STDOUT) as po:
49
+ with Popen(cmd, stdout=PIPE, stderr=STDOUT, env=env) as po:
37
50
  out = po.stdout
38
51
  assert out is not None
39
52
  for line in out:
@@ -50,7 +63,6 @@ def main() -> NoReturn:
50
63
  captured_log.append(str(e).encode('utf8'))
51
64
  rc = 123
52
65
 
53
-
54
66
  def payload() -> Iterator[bytes]:
55
67
  yield f"exit code: {rc}\n".encode()
56
68
  yield b'command: \n'
dron/monitor.py CHANGED
@@ -41,7 +41,6 @@ def as_row(entry: MonitorEntry) -> dict[str, Any]:
41
41
  cols = get_columns()
42
42
  res = {k: v for k, v in asdict(entry).items() if k in cols}
43
43
 
44
- v = res['status']
45
44
  color = 'green' if entry.status_ok else 'red'
46
45
  res['status'] = f'[{color}]' + res['status'] + f'[/{color}]'
47
46
 
@@ -57,8 +56,7 @@ def as_row(entry: MonitorEntry) -> dict[str, Any]:
57
56
 
58
57
 
59
58
  class MonitorApp(App):
60
-
61
- def __init__(self, *, monitor_params: MonitorParams, refresh_every: int) -> None:
59
+ def __init__(self, *, monitor_params: MonitorParams, refresh_every: float) -> None:
62
60
  super().__init__()
63
61
  self.monitor_params = monitor_params
64
62
  self.refresh_every = refresh_every
@@ -126,7 +124,7 @@ class MonitorApp(App):
126
124
  columns = get_columns()
127
125
 
128
126
  def sort_key(row):
129
- data = dict(zip(columns, row))
127
+ data = dict(zip(columns, row, strict=True))
130
128
  is_running = 'running' in data['next']
131
129
  failed = 'exit-code' in data['status']
132
130
  return (not is_running, not failed, data['unit'])
dron/notify/common.py CHANGED
@@ -2,8 +2,8 @@ import argparse
2
2
  import platform
3
3
  import shlex
4
4
  import sys
5
+ from collections.abc import Iterator
5
6
  from subprocess import PIPE, STDOUT, Popen, check_output
6
- from typing import Iterator
7
7
 
8
8
  IS_SYSTEMD = platform.system() != 'Darwin' # if not systemd it's launchd
9
9
 
dron/notify/email.py CHANGED
@@ -1,6 +1,6 @@
1
1
  import socket
2
+ from collections.abc import Iterator
2
3
  from subprocess import PIPE, Popen
3
- from typing import Iterator
4
4
 
5
5
  from .common import get_last_systemd_log, get_parser, get_stdin
6
6
 
@@ -9,7 +9,8 @@ import sys
9
9
  from typing import NoReturn
10
10
 
11
11
 
12
- def run_ntfy(*, job: str, backend: str) -> NoReturn:
12
+ # ty doesn't support NoReturn yet, see https://github.com/astral-sh/ty/issues/180
13
+ def run_ntfy(*, job: str, backend: str) -> NoReturn: # ty: ignore[invalid-return-type]
13
14
  # TODO not sure what to do with --stdin arg here?
14
15
  # could probably use last N lines of log or something
15
16
  # TODO get last logs here?
@@ -18,7 +19,7 @@ def run_ntfy(*, job: str, backend: str) -> NoReturn:
18
19
  try:
19
20
  subprocess.check_call(['ntfy', '-b', backend, '-t', title, 'send', body])
20
21
  except Exception as e:
21
- logging.exception(e)
22
+ logging.exception(e) # noqa: LOG015
22
23
  # TODO fallback on email?
23
24
  sys.exit(1)
24
25
  sys.exit(0)
dron/notify/telegram.py CHANGED
@@ -32,7 +32,7 @@ def main() -> None:
32
32
  try:
33
33
  send(message=body)
34
34
  except Exception as e:
35
- logging.exception(e)
35
+ logging.exception(e) # noqa: LOG015
36
36
  # TODO fallback on email?
37
37
  sys.exit(1)
38
38
  sys.exit(0)