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/api.py +6 -5
- dron/cli.py +176 -289
- dron/common.py +27 -20
- dron/dron.py +42 -133
- dron/launchd.py +49 -37
- dron/launchd_wrapper.py +16 -4
- dron/monitor.py +2 -4
- dron/notify/common.py +1 -1
- dron/notify/email.py +1 -1
- dron/notify/ntfy_common.py +3 -2
- dron/notify/telegram.py +1 -1
- dron/systemd.py +129 -107
- dron/tests/test_dron.py +81 -10
- {dron-0.1.20241008.dist-info → dron-0.2.20251011.dist-info}/METADATA +11 -17
- dron-0.2.20251011.dist-info/RECORD +23 -0
- {dron-0.1.20241008.dist-info → dron-0.2.20251011.dist-info}/WHEEL +1 -2
- dron/__init__.py +0 -6
- dron-0.1.20241008.dist-info/RECORD +0 -25
- dron-0.1.20241008.dist-info/top_level.txt +0 -1
- {dron-0.1.20241008.dist-info → dron-0.2.20251011.dist-info}/entry_points.txt +0 -0
- {dron-0.1.20241008.dist-info → dron-0.2.20251011.dist-info/licenses}/LICENSE.txt +0 -0
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
|
12
|
-
from
|
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 =
|
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
|
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(
|
217
|
-
|
218
|
-
|
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'
|
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
|
-
|
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(
|
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
|
358
|
-
|
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
|
416
|
-
|
417
|
-
|
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(
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
|
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(
|
443
|
-
|
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
|
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
|
-
'-
|
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
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
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
|
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
|
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
|
-
|
307
|
-
|
308
|
-
|
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
|
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(
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
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
|
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
|
-
|
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
dron/notify/ntfy_common.py
CHANGED
@@ -9,7 +9,8 @@ import sys
|
|
9
9
|
from typing import NoReturn
|
10
10
|
|
11
11
|
|
12
|
-
|
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)
|