dron 0.1.20241008__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/__init__.py +6 -0
- dron/__main__.py +5 -0
- dron/api.py +106 -0
- dron/cli.py +382 -0
- dron/common.py +174 -0
- dron/conftest.py +18 -0
- dron/dron.py +510 -0
- dron/launchd.py +415 -0
- dron/launchd_wrapper.py +90 -0
- dron/monitor.py +157 -0
- dron/notify/common.py +53 -0
- dron/notify/email.py +43 -0
- dron/notify/ntfy_common.py +24 -0
- dron/notify/ntfy_desktop.py +15 -0
- dron/notify/ntfy_telegram.py +12 -0
- dron/notify/telegram.py +42 -0
- dron/py.typed +0 -0
- dron/systemd.py +542 -0
- dron/tests/test_dron.py +119 -0
- dron-0.1.20241008.dist-info/LICENSE.txt +21 -0
- dron-0.1.20241008.dist-info/METADATA +47 -0
- dron-0.1.20241008.dist-info/RECORD +25 -0
- dron-0.1.20241008.dist-info/WHEEL +5 -0
- dron-0.1.20241008.dist-info/entry_points.txt +2 -0
- dron-0.1.20241008.dist-info/top_level.txt +1 -0
dron/__init__.py
ADDED
dron/__main__.py
ADDED
dron/api.py
ADDED
@@ -0,0 +1,106 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import getpass
|
4
|
+
import inspect
|
5
|
+
import re
|
6
|
+
import sys
|
7
|
+
from dataclasses import dataclass
|
8
|
+
from typing import Sequence
|
9
|
+
|
10
|
+
from .common import (
|
11
|
+
IS_SYSTEMD,
|
12
|
+
Command,
|
13
|
+
OnCalendar,
|
14
|
+
When,
|
15
|
+
wrap,
|
16
|
+
)
|
17
|
+
|
18
|
+
OnFailureAction = str
|
19
|
+
|
20
|
+
UnitName = str
|
21
|
+
|
22
|
+
@dataclass
|
23
|
+
class Job:
|
24
|
+
when: When | None
|
25
|
+
command: Command
|
26
|
+
unit_name: UnitName
|
27
|
+
on_failure: Sequence[OnFailureAction]
|
28
|
+
kwargs: dict[str, str]
|
29
|
+
|
30
|
+
|
31
|
+
# staticmethod isn't callable directly prior to 3.10
|
32
|
+
def _email(to: str) -> str:
|
33
|
+
return f'{sys.executable} -m dron.notify.email --job %n --to {to}'
|
34
|
+
|
35
|
+
|
36
|
+
class notify:
|
37
|
+
@staticmethod
|
38
|
+
def email(to: str) -> str:
|
39
|
+
return _email(to)
|
40
|
+
|
41
|
+
email_local = _email(to='%u' if IS_SYSTEMD else getpass.getuser())
|
42
|
+
|
43
|
+
# TODO adapt to macos
|
44
|
+
desktop_notification = f'{sys.executable} -m dron.notify.ntfy_desktop --job %n'
|
45
|
+
|
46
|
+
telegram = f'{sys.executable} -m dron.notify.telegram --job %n'
|
47
|
+
|
48
|
+
|
49
|
+
def job(
|
50
|
+
when: When | None,
|
51
|
+
command: Command,
|
52
|
+
*,
|
53
|
+
unit_name: str | None = None,
|
54
|
+
on_failure: Sequence[OnFailureAction] = (notify.email_local,),
|
55
|
+
**kwargs,
|
56
|
+
) -> Job:
|
57
|
+
"""
|
58
|
+
when: if None, then timer won't be created (still allows running job manually)
|
59
|
+
unit_name: if None, then will attempt to guess from source code (experimental!)
|
60
|
+
"""
|
61
|
+
assert 'extra_email' not in kwargs, unit_name # deprecated
|
62
|
+
|
63
|
+
stacklevel: int = kwargs.pop('stacklevel', 1)
|
64
|
+
|
65
|
+
def guess_name() -> str | Exception:
|
66
|
+
stack = inspect.stack()
|
67
|
+
frame = stack[stacklevel + 1] # +1 for guess_name itself
|
68
|
+
code_context_lines = frame.code_context
|
69
|
+
# python should alway keep single line for code context? but just in case
|
70
|
+
if code_context_lines is None or len(code_context_lines) != 1:
|
71
|
+
return RuntimeError(f"Expected single code context line, got {code_context_lines=}")
|
72
|
+
[code_context] = code_context_lines
|
73
|
+
code_context = code_context.strip()
|
74
|
+
rgx = r'(\w+)\s+='
|
75
|
+
m = re.match(rgx, code_context) # find assignment to variable
|
76
|
+
if m is None:
|
77
|
+
return RuntimeError(f"Couldn't guess from {code_context=} (regex {rgx=})")
|
78
|
+
return m.group(1)
|
79
|
+
|
80
|
+
if unit_name is None:
|
81
|
+
guessed_name = guess_name()
|
82
|
+
|
83
|
+
if isinstance(guessed_name, Exception):
|
84
|
+
raise RuntimeError(f"{when} {command}: couldn't guess job name: {guessed_name}")
|
85
|
+
|
86
|
+
unit_name = guessed_name
|
87
|
+
|
88
|
+
return Job(
|
89
|
+
when=when,
|
90
|
+
command=command,
|
91
|
+
unit_name=unit_name,
|
92
|
+
on_failure=on_failure,
|
93
|
+
kwargs=kwargs,
|
94
|
+
)
|
95
|
+
|
96
|
+
|
97
|
+
__all__ = (
|
98
|
+
'When',
|
99
|
+
'OnCalendar',
|
100
|
+
'OnFailureAction',
|
101
|
+
'Command',
|
102
|
+
'wrap',
|
103
|
+
'job',
|
104
|
+
'notify',
|
105
|
+
'Job', # todo maybe don't expose it?
|
106
|
+
)
|
dron/cli.py
ADDED
@@ -0,0 +1,382 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import argparse
|
4
|
+
import os
|
5
|
+
import shutil
|
6
|
+
import subprocess
|
7
|
+
import sys
|
8
|
+
from pathlib import Path
|
9
|
+
from pprint import pprint
|
10
|
+
from tempfile import TemporaryDirectory
|
11
|
+
|
12
|
+
import click
|
13
|
+
|
14
|
+
from . import launchd, systemd
|
15
|
+
from .api import UnitName
|
16
|
+
from .common import (
|
17
|
+
IS_SYSTEMD,
|
18
|
+
MANAGED_MARKER,
|
19
|
+
MonitorParams,
|
20
|
+
Unit,
|
21
|
+
escape,
|
22
|
+
logger,
|
23
|
+
print_monitor,
|
24
|
+
)
|
25
|
+
from .dron import (
|
26
|
+
DRONTAB,
|
27
|
+
apply,
|
28
|
+
do_lint,
|
29
|
+
drontab_dir,
|
30
|
+
get_entries_for_monitor,
|
31
|
+
load_jobs,
|
32
|
+
manage,
|
33
|
+
managed_units,
|
34
|
+
)
|
35
|
+
|
36
|
+
|
37
|
+
def cmd_edit() -> None:
|
38
|
+
drontab = DRONTAB
|
39
|
+
if not drontab.exists():
|
40
|
+
if click.confirm(f"tabfile {drontab} doesn't exist. Create?", default=True):
|
41
|
+
drontab.write_text(
|
42
|
+
'''\
|
43
|
+
#!/usr/bin/env python3
|
44
|
+
from dron.api import job
|
45
|
+
|
46
|
+
def jobs():
|
47
|
+
# yield job(
|
48
|
+
# 'hourly',
|
49
|
+
# '/bin/echo 123',
|
50
|
+
# unit_name='test_unit'
|
51
|
+
# )
|
52
|
+
pass
|
53
|
+
'''.lstrip()
|
54
|
+
)
|
55
|
+
else:
|
56
|
+
raise RuntimeError()
|
57
|
+
|
58
|
+
editor = os.environ.get('EDITOR')
|
59
|
+
if editor is None:
|
60
|
+
logger.warning('No EDITOR! Fallback to nano')
|
61
|
+
editor = 'nano'
|
62
|
+
|
63
|
+
with TemporaryDirectory() as tdir:
|
64
|
+
tpath = Path(tdir) / 'drontab'
|
65
|
+
shutil.copy2(drontab, tpath)
|
66
|
+
|
67
|
+
orig_mtime = tpath.stat().st_mtime
|
68
|
+
while True:
|
69
|
+
res = subprocess.run([editor, str(tpath)], check=True)
|
70
|
+
|
71
|
+
new_mtime = tpath.stat().st_mtime
|
72
|
+
if new_mtime == orig_mtime:
|
73
|
+
logger.warning('No notification made')
|
74
|
+
return
|
75
|
+
|
76
|
+
ex: Exception | None = None
|
77
|
+
try:
|
78
|
+
state = do_lint(tabfile=tpath)
|
79
|
+
except Exception as e:
|
80
|
+
logger.exception(e)
|
81
|
+
ex = e
|
82
|
+
else:
|
83
|
+
try:
|
84
|
+
manage(state=state)
|
85
|
+
except Exception as ee:
|
86
|
+
logger.exception(ee)
|
87
|
+
ex = ee
|
88
|
+
if ex is not None:
|
89
|
+
if click.confirm('Got errors. Try again?', default=True):
|
90
|
+
continue
|
91
|
+
raise ex
|
92
|
+
|
93
|
+
drontab.write_text(tpath.read_text()) # handles symlinks correctly
|
94
|
+
logger.info(f"Wrote changes to {drontab}. Don't forget to commit!")
|
95
|
+
break
|
96
|
+
|
97
|
+
# TODO show git diff?
|
98
|
+
# TODO perhaps allow to carry on regardless? not sure..
|
99
|
+
# not sure how much we can do without modifying anything...
|
100
|
+
|
101
|
+
|
102
|
+
def cmd_lint(tabfile: Path) -> None:
|
103
|
+
_state = do_lint(tabfile)
|
104
|
+
logger.info('all good')
|
105
|
+
|
106
|
+
|
107
|
+
def cmd_apply(tabfile: Path) -> None:
|
108
|
+
apply(tabfile)
|
109
|
+
|
110
|
+
|
111
|
+
def cmd_print(*, tabfile: Path, pretty: bool) -> None:
|
112
|
+
dtab_dir = Path(drontab_dir())
|
113
|
+
jobs = list(load_jobs(tabfile=tabfile, ppath=dtab_dir))
|
114
|
+
|
115
|
+
if pretty:
|
116
|
+
import tabulate
|
117
|
+
|
118
|
+
items = [
|
119
|
+
{
|
120
|
+
'UNIT': job.unit_name,
|
121
|
+
'SCHEDULE': job.when,
|
122
|
+
'COMMAND': escape(job.command),
|
123
|
+
}
|
124
|
+
for job in jobs
|
125
|
+
]
|
126
|
+
print(tabulate.tabulate(items, headers="keys"))
|
127
|
+
else:
|
128
|
+
for j in jobs:
|
129
|
+
print(j)
|
130
|
+
|
131
|
+
|
132
|
+
def cmd_run(*, unit: Unit, do_exec: bool) -> None:
|
133
|
+
if IS_SYSTEMD:
|
134
|
+
return systemd.cmd_run(unit=unit, do_exec=do_exec)
|
135
|
+
else:
|
136
|
+
return launchd.cmd_run(unit=unit, do_exec=do_exec)
|
137
|
+
|
138
|
+
|
139
|
+
def cmd_past(unit: Unit) -> None:
|
140
|
+
if IS_SYSTEMD:
|
141
|
+
return systemd.cmd_past(unit)
|
142
|
+
else:
|
143
|
+
return launchd.cmd_past(unit)
|
144
|
+
|
145
|
+
|
146
|
+
# TODO think if it's worth integrating with timers?
|
147
|
+
def cmd_monitor(params: MonitorParams) -> None:
|
148
|
+
managed = list(managed_units(with_body=False)) # body slows down this call quite a bit
|
149
|
+
if len(managed) == 0:
|
150
|
+
logger.warning('no managed units!')
|
151
|
+
|
152
|
+
logger.debug('starting monitor...')
|
153
|
+
|
154
|
+
entries = get_entries_for_monitor(managed=managed, params=params)
|
155
|
+
print_monitor(entries)
|
156
|
+
|
157
|
+
|
158
|
+
# TODO test it on CI?
|
159
|
+
def _drontab_example() -> str:
|
160
|
+
return '''
|
161
|
+
from dron.api import job
|
162
|
+
|
163
|
+
# at the moment you're expected to define jobs() function that yields jobs
|
164
|
+
# in the future I might add more mechanisms
|
165
|
+
def jobs():
|
166
|
+
# simple job that doesn't do much
|
167
|
+
yield job(
|
168
|
+
'daily',
|
169
|
+
'/home/user/scripts/run-borg /home/user',
|
170
|
+
unit_name='borg-backup-home',
|
171
|
+
)
|
172
|
+
|
173
|
+
yield job(
|
174
|
+
'daily',
|
175
|
+
'linkchecker https://beepb00p.xyz',
|
176
|
+
unit_name='linkchecker-beepb00p',
|
177
|
+
)
|
178
|
+
|
179
|
+
# drontab is simply python code!
|
180
|
+
# so if you're annoyed by having to rememver Systemd syntax, you can use a helper function
|
181
|
+
def every(*, mins: int) -> str:
|
182
|
+
return f'*:0/{mins}'
|
183
|
+
|
184
|
+
# make sure my website is alive, it will send local email on failure
|
185
|
+
yield job(
|
186
|
+
every(mins=10),
|
187
|
+
'ping https://beepb00p.xyz',
|
188
|
+
unit_name='ping-beepb00p',
|
189
|
+
)
|
190
|
+
'''.lstrip()
|
191
|
+
|
192
|
+
|
193
|
+
def make_parser() -> argparse.ArgumentParser:
|
194
|
+
from .common import VerifyOff
|
195
|
+
|
196
|
+
def add_verify(p: argparse.ArgumentParser) -> None:
|
197
|
+
# specify in readme???
|
198
|
+
# would be nice to use external checker..
|
199
|
+
# https://github.com/systemd/systemd/issues/8072
|
200
|
+
# https://unix.stackexchange.com/questions/493187/systemd-under-ubuntu-18-04-1-fails-with-failed-to-create-user-slice-serv
|
201
|
+
p.add_argument('--no-verify', action=VerifyOff, nargs=0, help='Skip systemctl verify step')
|
202
|
+
|
203
|
+
p = argparse.ArgumentParser(
|
204
|
+
prog='dron',
|
205
|
+
description='''
|
206
|
+
dron -- simple frontend for Systemd, inspired by cron.
|
207
|
+
|
208
|
+
- *d* stands for 'Systemd'
|
209
|
+
- *ron* stands for 'cron'
|
210
|
+
|
211
|
+
dron is my attempt to overcome things that make working with Systemd tedious
|
212
|
+
'''.lstrip(),
|
213
|
+
formatter_class=lambda prog: argparse.RawTextHelpFormatter(prog, width=100),
|
214
|
+
)
|
215
|
+
# TODO ugh. when you type e.g. 'dron apply', help format is wrong..
|
216
|
+
example = ''.join(': ' + l for l in _drontab_example().splitlines(keepends=True))
|
217
|
+
# TODO begin_src python maybe?
|
218
|
+
p.epilog = f'''
|
219
|
+
* What does it do?
|
220
|
+
In short, you type ~dron edit~ and edit your config file, similarly to ~crontab -e~:
|
221
|
+
|
222
|
+
{example}
|
223
|
+
|
224
|
+
After you save your changes and exit the editor, your drontab is checked for syntax and applied
|
225
|
+
|
226
|
+
- if checks have passed, your jobs are mapped onto Systemd units and started up
|
227
|
+
- if there are potential errors, you are prompted to fix them before retrying
|
228
|
+
|
229
|
+
* Why?
|
230
|
+
In short, because I want to benefit from the heavy lifting that Systemd does: timeouts, resource management, restart policies, powerful scheduling specs and logging,
|
231
|
+
while not having to manually manipulate numerous unit files and restart the daemon all over.
|
232
|
+
|
233
|
+
I elaborate on what led me to implement it and motivation [[https://beepb00p.xyz/scheduler.html#what_do_i_want][here]]. Also:
|
234
|
+
|
235
|
+
- why not just use [[https://beepb00p.xyz/scheduler.html#cron][cron]]?
|
236
|
+
- why not just use [[https://beepb00p.xyz/scheduler.html#systemd][systemd]]?
|
237
|
+
'''
|
238
|
+
|
239
|
+
p.add_argument(
|
240
|
+
'--marker', required=False, help=f'Use custom marker instead of default `{MANAGED_MARKER}`. Useful for developing/testing.'
|
241
|
+
)
|
242
|
+
|
243
|
+
def add_tabfile_arg(p: argparse.ArgumentParser) -> None:
|
244
|
+
p.add_argument('tabfile', type=Path, nargs='?')
|
245
|
+
|
246
|
+
sp = p.add_subparsers(dest='mode')
|
247
|
+
|
248
|
+
### actions on drontab file
|
249
|
+
edit_parser = sp.add_parser('edit', help="Edit drontab (like 'crontab -e')")
|
250
|
+
add_verify(edit_parser)
|
251
|
+
|
252
|
+
apply_parser = sp.add_parser('apply', help="Apply drontab (like 'crontab' with no args)")
|
253
|
+
add_tabfile_arg(apply_parser)
|
254
|
+
add_verify(apply_parser)
|
255
|
+
# TODO --force?
|
256
|
+
lint_parser = sp.add_parser('lint', help="Check drontab (no 'crontab' alternative, sadly!)")
|
257
|
+
add_tabfile_arg(lint_parser)
|
258
|
+
add_verify(lint_parser)
|
259
|
+
|
260
|
+
print_parser = sp.add_parser('print', help="Parse and print drontab")
|
261
|
+
add_tabfile_arg(print_parser)
|
262
|
+
print_parser.add_argument('--pretty', action='store_true')
|
263
|
+
###
|
264
|
+
|
265
|
+
### actions on managed jobs
|
266
|
+
debug_parser = sp.add_parser('debug', help='Print some debug info')
|
267
|
+
|
268
|
+
uninstall_parser = sp.add_parser('uninstall', help="Uninstall all managed jobs")
|
269
|
+
add_verify(uninstall_parser)
|
270
|
+
|
271
|
+
run_parser = sp.add_parser('run', help='Run the job right now, ignoring the timer')
|
272
|
+
run_parser.add_argument('unit', type=str, nargs='?') # TODO add shell completion?
|
273
|
+
run_parser.add_argument('--exec', action='store_true', dest='do_exec', help='Run directly, not via systemd/launchd')
|
274
|
+
|
275
|
+
past_parser = sp.add_parser('past', help='List past job runs')
|
276
|
+
past_parser.add_argument('unit', type=str, nargs='?') # TODO add shell completion?
|
277
|
+
###
|
278
|
+
|
279
|
+
### misc actions
|
280
|
+
mp = sp.add_parser('monitor', help='Monitor services/timers managed by dron')
|
281
|
+
mp.add_argument('-n', type=int, default=1, help='refresh every n seconds')
|
282
|
+
mp.add_argument('--once', action='store_true', help='only call once')
|
283
|
+
mp.add_argument('--rate', action='store_true', help='Display success rate (unstable and potentially slow)')
|
284
|
+
mp.add_argument('--command', action='store_true', help='Display command')
|
285
|
+
###
|
286
|
+
return p
|
287
|
+
|
288
|
+
|
289
|
+
def main() -> None:
|
290
|
+
from .cli import make_parser
|
291
|
+
|
292
|
+
p = make_parser()
|
293
|
+
args = p.parse_args()
|
294
|
+
|
295
|
+
marker: str | None = args.marker
|
296
|
+
if marker is not None:
|
297
|
+
from . import common
|
298
|
+
|
299
|
+
common.MANAGED_MARKER = marker
|
300
|
+
|
301
|
+
mode: str = args.mode
|
302
|
+
|
303
|
+
def tabfile_or_default() -> Path:
|
304
|
+
tabfile = args.tabfile
|
305
|
+
if tabfile is None:
|
306
|
+
tabfile = DRONTAB
|
307
|
+
return tabfile
|
308
|
+
|
309
|
+
def prompt_for_unit() -> UnitName:
|
310
|
+
from prompt_toolkit import PromptSession
|
311
|
+
from prompt_toolkit.completion import WordCompleter
|
312
|
+
|
313
|
+
# TODO print options
|
314
|
+
managed = list(managed_units(with_body=False))
|
315
|
+
units = [x.unit_file.stem for x in managed]
|
316
|
+
|
317
|
+
print('Units under dron:', file=sys.stderr)
|
318
|
+
for u in units:
|
319
|
+
print(f'- {u}', file=sys.stderr)
|
320
|
+
|
321
|
+
completer = WordCompleter(units, ignore_case=True)
|
322
|
+
session = PromptSession("Select a unit: ", completer=completer) # type: ignore[var-annotated]
|
323
|
+
selected = session.prompt()
|
324
|
+
return selected
|
325
|
+
|
326
|
+
if mode == 'edit':
|
327
|
+
cmd_edit()
|
328
|
+
elif mode == 'apply':
|
329
|
+
tabfile = tabfile_or_default()
|
330
|
+
cmd_apply(tabfile)
|
331
|
+
elif mode == 'lint':
|
332
|
+
tabfile = tabfile_or_default()
|
333
|
+
cmd_lint(tabfile)
|
334
|
+
elif mode == 'print':
|
335
|
+
tabfile = tabfile_or_default()
|
336
|
+
|
337
|
+
from .cli import cmd_print # lazy due to circular import
|
338
|
+
|
339
|
+
cmd_print(tabfile=tabfile, pretty=args.pretty)
|
340
|
+
elif mode == 'debug':
|
341
|
+
managed = managed_units(with_body=False) # TODO not sure about body
|
342
|
+
for x in managed:
|
343
|
+
pprint(x, stream=sys.stderr)
|
344
|
+
elif mode == 'uninstall':
|
345
|
+
click.confirm('Going to remove all dron managed jobs. Continue?', default=True, abort=True)
|
346
|
+
with TemporaryDirectory() as td:
|
347
|
+
empty = Path(td) / 'empty'
|
348
|
+
empty.write_text(
|
349
|
+
'''\
|
350
|
+
def jobs():
|
351
|
+
yield from []
|
352
|
+
'''
|
353
|
+
)
|
354
|
+
cmd_apply(empty)
|
355
|
+
elif mode == 'run':
|
356
|
+
unit = args.unit if args.unit is not None else prompt_for_unit()
|
357
|
+
do_exec = args.do_exec
|
358
|
+
cmd_run(unit=unit, do_exec=do_exec)
|
359
|
+
elif mode == 'past':
|
360
|
+
unit = args.unit if args.unit is not None else prompt_for_unit()
|
361
|
+
cmd_past(unit=unit)
|
362
|
+
elif mode == 'monitor':
|
363
|
+
once = args.once
|
364
|
+
|
365
|
+
params = MonitorParams(
|
366
|
+
with_success_rate=args.rate,
|
367
|
+
with_command=args.command,
|
368
|
+
)
|
369
|
+
|
370
|
+
if once:
|
371
|
+
# fallback on old style monitor for now?
|
372
|
+
# this can be quite useful for grepping etc..
|
373
|
+
cmd_monitor(params=params)
|
374
|
+
else:
|
375
|
+
from .monitor import MonitorApp
|
376
|
+
|
377
|
+
app = MonitorApp(monitor_params=params, refresh_every=args.n)
|
378
|
+
app.run()
|
379
|
+
else:
|
380
|
+
logger.error(f'Unknown mode: {mode}')
|
381
|
+
p.print_usage(sys.stderr)
|
382
|
+
sys.exit(1)
|
dron/common.py
ADDED
@@ -0,0 +1,174 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import argparse
|
4
|
+
import platform
|
5
|
+
import shlex
|
6
|
+
import sys
|
7
|
+
from dataclasses import asdict, dataclass, replace
|
8
|
+
from datetime import datetime
|
9
|
+
from pathlib import Path
|
10
|
+
from typing import Any, Dict, Iterable, Sequence, TypeVar, Union
|
11
|
+
|
12
|
+
from loguru import logger # noqa: F401
|
13
|
+
|
14
|
+
datetime_aware = datetime
|
15
|
+
datetime_naive = datetime
|
16
|
+
|
17
|
+
|
18
|
+
# TODO can remove this? although might be useful for tests
|
19
|
+
VERIFY_UNITS = True
|
20
|
+
# TODO ugh. verify tries using already installed unit files so if they were bad, everything would fail
|
21
|
+
# I guess could do two stages, i.e. units first, then timers
|
22
|
+
# dunno, a bit less atomic though...
|
23
|
+
|
24
|
+
class VerifyOff(argparse.Action):
|
25
|
+
def __call__(self, parser, namespace, values, option_string = None): # noqa: ARG002
|
26
|
+
global VERIFY_UNITS
|
27
|
+
VERIFY_UNITS = False
|
28
|
+
|
29
|
+
|
30
|
+
@dataclass
|
31
|
+
class MonitorParams:
|
32
|
+
with_success_rate: bool
|
33
|
+
with_command: bool
|
34
|
+
|
35
|
+
|
36
|
+
Unit = str
|
37
|
+
Body = str
|
38
|
+
UnitFile = Path
|
39
|
+
|
40
|
+
|
41
|
+
@dataclass
|
42
|
+
class UnitState:
|
43
|
+
unit_file: UnitFile
|
44
|
+
body: Body | None
|
45
|
+
cmdline: Sequence[str] | None # can be None for timers
|
46
|
+
|
47
|
+
|
48
|
+
@dataclass
|
49
|
+
class LaunchdUnitState(UnitState):
|
50
|
+
# NOTE: can legit be str (e.g. if unit was never ran before)
|
51
|
+
last_exit_code: str | None
|
52
|
+
pid: str | None
|
53
|
+
schedule: str | None
|
54
|
+
|
55
|
+
|
56
|
+
State = Iterable[UnitState]
|
57
|
+
|
58
|
+
|
59
|
+
IS_SYSTEMD = platform.system() != 'Darwin' # if not systemd it's launchd
|
60
|
+
|
61
|
+
|
62
|
+
|
63
|
+
T = TypeVar('T')
|
64
|
+
def unwrap(x: T | None) -> T:
|
65
|
+
assert x is not None
|
66
|
+
return x
|
67
|
+
|
68
|
+
|
69
|
+
PathIsh = Union[str, Path]
|
70
|
+
|
71
|
+
# if it's an str, assume it's already escaped
|
72
|
+
# otherwise we are responsible for escaping..
|
73
|
+
Command = Union[PathIsh, Sequence[PathIsh]]
|
74
|
+
|
75
|
+
|
76
|
+
OnCalendar = str
|
77
|
+
TimerSpec = Dict[str, str] # meh # TODO why is it a dict???
|
78
|
+
ALWAYS = 'always'
|
79
|
+
When = Union[OnCalendar, TimerSpec]
|
80
|
+
|
81
|
+
|
82
|
+
MANAGED_MARKER = '(MANAGED BY DRON)'
|
83
|
+
def is_managed(body: str) -> bool:
|
84
|
+
# switching off it because it's unfriendly to launchd
|
85
|
+
legacy_marker = '<MANAGED BY DRON>'
|
86
|
+
return MANAGED_MARKER in body or legacy_marker in body
|
87
|
+
|
88
|
+
|
89
|
+
|
90
|
+
pytest_fixture: Any
|
91
|
+
under_pytest = 'pytest' in sys.modules
|
92
|
+
if under_pytest:
|
93
|
+
import pytest
|
94
|
+
pytest_fixture = pytest.fixture
|
95
|
+
else:
|
96
|
+
pytest_fixture = lambda f: f # no-op otherwise to prevent pytest import
|
97
|
+
|
98
|
+
|
99
|
+
Escaped = str
|
100
|
+
def escape(command: Command) -> Escaped:
|
101
|
+
if isinstance(command, Escaped):
|
102
|
+
return command
|
103
|
+
elif isinstance(command, Path):
|
104
|
+
return escape([command])
|
105
|
+
else:
|
106
|
+
return ' '.join(shlex.quote(str(part)) for part in command)
|
107
|
+
|
108
|
+
|
109
|
+
def wrap(script: PathIsh, command: Command) -> Escaped:
|
110
|
+
return shlex.quote(str(script)) + ' ' + escape(command)
|
111
|
+
|
112
|
+
|
113
|
+
def test_wrap() -> None:
|
114
|
+
assert wrap('/bin/bash', ['-c', 'echo whatever']) == "/bin/bash -c 'echo whatever'"
|
115
|
+
bin_ = Path('/bin/bash')
|
116
|
+
assert wrap(bin_, "-c 'echo whatever'") == "/bin/bash -c 'echo whatever'"
|
117
|
+
assert wrap(bin_, ['echo', bin_]) == "/bin/bash echo /bin/bash"
|
118
|
+
assert wrap('cat', bin_) == "cat /bin/bash"
|
119
|
+
|
120
|
+
|
121
|
+
|
122
|
+
@dataclass(order=True)
|
123
|
+
class MonitorEntry:
|
124
|
+
unit: str
|
125
|
+
status: str
|
126
|
+
left: str
|
127
|
+
next: str
|
128
|
+
schedule: str
|
129
|
+
command: str | None
|
130
|
+
pid: str | None
|
131
|
+
|
132
|
+
"""
|
133
|
+
'status' is coming from systemd/launchd, and it's a string.
|
134
|
+
|
135
|
+
So status_ok should be used instead if you actually want to rely on something robust.
|
136
|
+
"""
|
137
|
+
status_ok: bool
|
138
|
+
|
139
|
+
|
140
|
+
def print_monitor(entries: Iterable[MonitorEntry]) -> None:
|
141
|
+
entries = sorted(
|
142
|
+
entries,
|
143
|
+
key=lambda e: (e.pid is None, e.status_ok, e),
|
144
|
+
)
|
145
|
+
|
146
|
+
import termcolor # noqa: I001
|
147
|
+
import tabulate
|
148
|
+
tabulate.PRESERVE_WHITESPACE = True
|
149
|
+
|
150
|
+
headers = [
|
151
|
+
'UNIT',
|
152
|
+
'STATUS',
|
153
|
+
'LEFT',
|
154
|
+
'NEXT',
|
155
|
+
'SCHEDULE',
|
156
|
+
]
|
157
|
+
with_command = any(x.command is not None for x in entries)
|
158
|
+
if with_command:
|
159
|
+
headers.append('COMMAND')
|
160
|
+
|
161
|
+
items = []
|
162
|
+
for e in entries:
|
163
|
+
e = replace(
|
164
|
+
e,
|
165
|
+
status=termcolor.colored(e.status, 'green' if e.status_ok else 'red'),
|
166
|
+
)
|
167
|
+
if e.pid is not None:
|
168
|
+
e = replace(
|
169
|
+
e,
|
170
|
+
next=termcolor.colored('running', 'yellow'),
|
171
|
+
left='--',
|
172
|
+
)
|
173
|
+
items.append(list(asdict(e).values())[:len(headers)])
|
174
|
+
print(tabulate.tabulate(items, headers=headers))
|
dron/conftest.py
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
import pytest
|
2
|
+
|
3
|
+
|
4
|
+
@pytest.fixture(scope='session', autouse=True)
|
5
|
+
def disable_verify_units_if_no_systemd():
|
6
|
+
'''
|
7
|
+
If we can't use systemd, we need to suppress systemd-specific linting
|
8
|
+
'''
|
9
|
+
from . import common
|
10
|
+
from .systemd import _is_missing_systemd
|
11
|
+
|
12
|
+
reason = _is_missing_systemd()
|
13
|
+
if reason is not None:
|
14
|
+
common.VERIFY_UNITS = False
|
15
|
+
try:
|
16
|
+
yield
|
17
|
+
finally:
|
18
|
+
common.VERIFY_UNITS = True
|