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/systemd.py
CHANGED
@@ -5,14 +5,14 @@ import os
|
|
5
5
|
import re
|
6
6
|
import shlex
|
7
7
|
import shutil
|
8
|
-
from
|
8
|
+
from collections.abc import Iterator, Sequence
|
9
|
+
from datetime import UTC, datetime, timedelta
|
9
10
|
from functools import lru_cache
|
10
11
|
from itertools import groupby
|
11
12
|
from pathlib import Path
|
12
13
|
from subprocess import PIPE, Popen, run
|
13
14
|
from tempfile import TemporaryDirectory
|
14
|
-
from typing import Any
|
15
|
-
|
15
|
+
from typing import Any
|
16
16
|
from zoneinfo import ZoneInfo
|
17
17
|
|
18
18
|
from .api import (
|
@@ -26,9 +26,9 @@ from .common import (
|
|
26
26
|
MonitorEntry,
|
27
27
|
MonitorParams,
|
28
28
|
State,
|
29
|
+
SystemdUnitState,
|
29
30
|
TimerSpec,
|
30
31
|
Unit,
|
31
|
-
UnitState,
|
32
32
|
datetime_aware,
|
33
33
|
escape,
|
34
34
|
is_managed,
|
@@ -81,11 +81,11 @@ WantedBy=timers.target
|
|
81
81
|
# TODO add Restart=always and RestartSec?
|
82
82
|
# TODO allow to pass extra args
|
83
83
|
def service(
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
84
|
+
*,
|
85
|
+
unit_name: str,
|
86
|
+
command: Command,
|
87
|
+
on_failure: Sequence[OnFailureAction],
|
88
|
+
**kwargs: str,
|
89
89
|
) -> str:
|
90
90
|
# TODO not sure if something else needs to be escaped for ExecStart??
|
91
91
|
# todo systemd-escape? but only can be used for names
|
@@ -96,14 +96,11 @@ def service(
|
|
96
96
|
cmd = escape(command)
|
97
97
|
|
98
98
|
exec_stop_posts = [
|
99
|
-
f"ExecStopPost=/bin/sh -c 'if [ $$EXIT_STATUS != 0 ]; then {action}; fi'"
|
100
|
-
for action in on_failure
|
99
|
+
f"ExecStopPost=/bin/sh -c 'if [ $$EXIT_STATUS != 0 ]; then {action}; fi'" for action in on_failure
|
101
100
|
]
|
102
101
|
|
103
102
|
sections: dict[str, list[str]] = {}
|
104
|
-
sections['[Unit]'] = [f''
|
105
|
-
Description=Service for {unit_name} {MANAGED_MARKER}
|
106
|
-
'''.strip()]
|
103
|
+
sections['[Unit]'] = [f'Description=Service for {unit_name} {MANAGED_MARKER}']
|
107
104
|
|
108
105
|
sections['[Service]'] = [
|
109
106
|
f'ExecStart={cmd}',
|
@@ -188,40 +185,48 @@ def test_verify_systemd() -> None:
|
|
188
185
|
skip_if_no_systemd()
|
189
186
|
from .dron import verify_unit
|
190
187
|
|
191
|
-
def
|
188
|
+
def FAILS(body: str) -> None:
|
192
189
|
import pytest
|
190
|
+
|
193
191
|
with pytest.raises(Exception):
|
194
192
|
verify_unit(unit_name='whatever.service', body=body)
|
195
193
|
|
196
|
-
def
|
194
|
+
def OK(body: str) -> None:
|
197
195
|
verify_unit(unit_name='ok.service', body=body)
|
198
196
|
|
199
|
-
|
197
|
+
OK(
|
198
|
+
body='''
|
200
199
|
[Service]
|
201
200
|
ExecStart=/bin/echo 123
|
202
|
-
'''
|
201
|
+
'''
|
202
|
+
)
|
203
203
|
|
204
204
|
from .api import notify
|
205
|
+
|
205
206
|
on_failure = (
|
206
207
|
notify.email('test@gmail.com'),
|
207
208
|
notify.desktop_notification,
|
208
209
|
)
|
209
|
-
|
210
|
+
OK(body=service(unit_name='alala', command='/bin/echo 123', on_failure=on_failure))
|
210
211
|
|
211
212
|
# garbage
|
212
|
-
|
213
|
+
FAILS(body='fewfewf')
|
213
214
|
|
214
215
|
# no execstart
|
215
|
-
|
216
|
+
FAILS(
|
217
|
+
body='''
|
216
218
|
[Service]
|
217
219
|
StandardOutput=journal
|
218
|
-
'''
|
220
|
+
'''
|
221
|
+
)
|
219
222
|
|
220
|
-
|
223
|
+
FAILS(
|
224
|
+
body='''
|
221
225
|
[Service]
|
222
226
|
ExecStart=yes
|
223
227
|
StandardOutput=baaad
|
224
|
-
'''
|
228
|
+
'''
|
229
|
+
)
|
225
230
|
|
226
231
|
|
227
232
|
def _sd(s: str) -> str:
|
@@ -235,6 +240,7 @@ class BusManager:
|
|
235
240
|
Interface,
|
236
241
|
SessionBus,
|
237
242
|
)
|
243
|
+
|
238
244
|
self.Interface = Interface # meh
|
239
245
|
|
240
246
|
self.bus = SessionBus() # note: SystemBus is for system-wide services
|
@@ -261,7 +267,7 @@ def systemd_state(*, with_body: bool) -> State:
|
|
261
267
|
states = bus.manager.ListUnits() # ok nice, it's basically instant
|
262
268
|
|
263
269
|
for state in states:
|
264
|
-
name
|
270
|
+
name = state[0]
|
265
271
|
descr = state[1]
|
266
272
|
if not is_managed(descr):
|
267
273
|
continue
|
@@ -271,17 +277,19 @@ def systemd_state(*, with_body: bool) -> State:
|
|
271
277
|
|
272
278
|
# useful for debugging, can also use .Service if it's not a timer
|
273
279
|
# all_properties = props.GetAll(_sd('.Unit'))
|
280
|
+
# however GetAll seems slower than gettind individual properties
|
274
281
|
|
275
282
|
# stale = int(bus.prop(props, '.Unit', 'NeedDaemonReload')) == 1
|
283
|
+
# TODO do we actually need to resolve?
|
276
284
|
unit_file = Path(str(bus.prop(props, '.Unit', 'FragmentPath'))).resolve()
|
277
285
|
body = unit_file.read_text() if with_body else None
|
278
286
|
cmdline: Sequence[str] | None
|
279
|
-
if '.timer' in name:
|
287
|
+
if '.timer' in name: # meh
|
280
288
|
cmdline = None
|
281
289
|
else:
|
282
290
|
cmdline = BusManager.exec_start(props)
|
283
291
|
|
284
|
-
yield
|
292
|
+
yield SystemdUnitState(unit_file=unit_file, body=body, cmdline=cmdline, dbus_properties=props)
|
285
293
|
|
286
294
|
|
287
295
|
def test_managed_units() -> None:
|
@@ -297,27 +305,28 @@ def test_managed_units() -> None:
|
|
297
305
|
# dbus.exceptions.DBusException: org.freedesktop.DBus.Error.BadAddress: Address does not contain a colon
|
298
306
|
# todo maybe don't need it anymore with 20.04 circleci?
|
299
307
|
if 'CI' not in os.environ:
|
300
|
-
cmd_monitor(
|
308
|
+
cmd_monitor.callback(n=1, once=True, command=True, rate=True) # type: ignore[misc]
|
301
309
|
|
302
310
|
|
303
311
|
def skip_if_no_systemd() -> None:
|
304
312
|
import pytest
|
313
|
+
|
305
314
|
reason = _is_missing_systemd()
|
306
315
|
if reason is not None:
|
307
316
|
pytest.skip(f'No systemd: {reason}')
|
308
317
|
|
309
318
|
|
310
|
-
_UTCMAX = datetime.max.replace(tzinfo=
|
319
|
+
_UTCMAX = datetime.max.replace(tzinfo=UTC)
|
311
320
|
|
312
321
|
|
313
322
|
class MonitorHelper:
|
314
323
|
def from_usec(self, usec) -> datetime_aware:
|
315
324
|
u = int(usec)
|
316
|
-
if u == 2
|
325
|
+
if u == 2**64 - 1: # apparently systemd uses max uint64
|
317
326
|
# happens if the job is running ATM?
|
318
327
|
return _UTCMAX
|
319
328
|
else:
|
320
|
-
return datetime.fromtimestamp(u / 10
|
329
|
+
return datetime.fromtimestamp(u / 10**6, tz=UTC)
|
321
330
|
|
322
331
|
@property
|
323
332
|
@lru_cache # noqa: B019
|
@@ -326,30 +335,65 @@ class MonitorHelper:
|
|
326
335
|
# it's a required dependency, but still might fail in some weird environments?
|
327
336
|
# e.g. if zoneinfo information isn't available
|
328
337
|
from tzlocal import get_localzone
|
338
|
+
|
329
339
|
return get_localzone()
|
330
|
-
except Exception
|
340
|
+
except Exception:
|
331
341
|
logger.error("Couldn't determine local timezone! Falling back to UTC")
|
332
342
|
return ZoneInfo('UTC')
|
333
343
|
|
334
344
|
|
335
|
-
|
336
|
-
|
337
|
-
|
345
|
+
# TODO maybe format seconds prettier. dunno
|
346
|
+
def _fmt_delta(d: timedelta) -> str:
|
347
|
+
# format to reduce constant countdown...
|
348
|
+
ad = abs(d)
|
349
|
+
# get rid of microseconds
|
350
|
+
ad = ad - timedelta(microseconds=ad.microseconds)
|
351
|
+
|
352
|
+
day = timedelta(days=1)
|
353
|
+
hour = timedelta(hours=1)
|
354
|
+
minute = timedelta(minutes=1)
|
355
|
+
gt = False
|
356
|
+
if ad > day:
|
357
|
+
full_days = ad // day
|
358
|
+
hours = (ad % day) // hour
|
359
|
+
ads = f'{full_days}d {hours}h'
|
360
|
+
gt = True
|
361
|
+
elif ad > minute:
|
362
|
+
full_mins = ad // minute
|
363
|
+
ad = timedelta(minutes=full_mins)
|
364
|
+
ads = str(ad)
|
365
|
+
gt = True
|
366
|
+
else:
|
367
|
+
# show exact
|
368
|
+
ads = str(ad)
|
369
|
+
if len(ads) == 7:
|
370
|
+
ads = '0' + ads # meh. fix missing leading zero in hours..
|
371
|
+
ads = ('>' if gt else '') + ads
|
372
|
+
return ads
|
373
|
+
|
338
374
|
|
375
|
+
def get_entries_for_monitor(managed: State, *, params: MonitorParams) -> list[MonitorEntry]:
|
339
376
|
mon = MonitorHelper()
|
340
377
|
|
341
|
-
UTCNOW = datetime.now(tz=
|
378
|
+
UTCNOW = datetime.now(tz=UTC)
|
342
379
|
|
343
380
|
bus = BusManager()
|
344
381
|
|
345
382
|
entries: list[MonitorEntry] = []
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
383
|
+
|
384
|
+
# sort so that neigbouring unit.service/unit.timer go one after another for grouping
|
385
|
+
sort_key = lambda unit: unit.unit_file.name
|
386
|
+
# for grouping, group by common stem of timers and services
|
387
|
+
stem_name = lambda unit: sort_key(unit).split('.')[0]
|
388
|
+
for k, _gr in groupby(sorted(managed, key=sort_key), key=stem_name):
|
389
|
+
gr: list[SystemdUnitState] = []
|
390
|
+
for x in _gr:
|
391
|
+
assert isinstance(x, SystemdUnitState), x # guaranteed by managed_units function
|
392
|
+
gr.append(x)
|
393
|
+
|
350
394
|
# if timer is None, guess that means the job is always running?
|
351
|
-
timer:
|
352
|
-
service:
|
395
|
+
timer: SystemdUnitState | None
|
396
|
+
service: SystemdUnitState
|
353
397
|
if len(gr) == 2:
|
354
398
|
[service, timer] = gr
|
355
399
|
else:
|
@@ -357,14 +401,16 @@ def get_entries_for_monitor(managed: State, *, params: MonitorParams) -> list[Mo
|
|
357
401
|
[service] = gr
|
358
402
|
timer = None
|
359
403
|
|
404
|
+
service_props = service.dbus_properties
|
405
|
+
|
360
406
|
if timer is not None:
|
361
|
-
props =
|
362
|
-
|
407
|
+
props = timer.dbus_properties
|
408
|
+
# FIXME this might be io bound? maybe make async or use thread pool?
|
409
|
+
cal = bus.prop(props, '.Timer', 'TimersCalendar')
|
363
410
|
next_ = bus.prop(props, '.Timer', 'NextElapseUSecRealtime')
|
364
411
|
|
365
|
-
unit_props = bus.properties(service)
|
366
412
|
# note: there is also bus.prop(props, '.Timer', 'LastTriggerUSec'), but makes more sense to use unit to account for manual runs
|
367
|
-
last
|
413
|
+
last = bus.prop(service_props, '.Unit', 'ActiveExitTimestamp')
|
368
414
|
|
369
415
|
schedule = cal[0][1] # TODO is there a more reliable way to retrieve it??
|
370
416
|
# todo not sure if last is really that useful..
|
@@ -376,101 +422,77 @@ def get_entries_for_monitor(managed: State, *, params: MonitorParams) -> list[Mo
|
|
376
422
|
if next_dt == datetime.max:
|
377
423
|
left_delta = timedelta(0)
|
378
424
|
else:
|
379
|
-
left_delta
|
425
|
+
left_delta = next_dt - UTCNOW
|
380
426
|
else:
|
381
|
-
left_delta = timedelta(0)
|
427
|
+
left_delta = timedelta(0) # TODO
|
382
428
|
last_dt = UTCNOW
|
383
429
|
nexts = 'n/a'
|
384
430
|
schedule = 'always'
|
385
431
|
|
386
|
-
|
387
|
-
def fmt_delta(d: timedelta) -> str:
|
388
|
-
# format to reduce constant countdown...
|
389
|
-
ad = abs(d)
|
390
|
-
# get rid of microseconds
|
391
|
-
ad = ad - timedelta(microseconds=ad.microseconds)
|
392
|
-
|
393
|
-
day = timedelta(days=1)
|
394
|
-
hour = timedelta(hours=1)
|
395
|
-
minute = timedelta(minutes=1)
|
396
|
-
gt = False
|
397
|
-
if ad > day:
|
398
|
-
full_days = ad // day
|
399
|
-
hours = (ad % day) // hour
|
400
|
-
ads = f'{full_days}d {hours}h'
|
401
|
-
gt = True
|
402
|
-
elif ad > minute:
|
403
|
-
full_mins = ad // minute
|
404
|
-
ad = timedelta(minutes=full_mins)
|
405
|
-
ads = str(ad)
|
406
|
-
gt = True
|
407
|
-
else:
|
408
|
-
# show exact
|
409
|
-
ads = str(ad)
|
410
|
-
if len(ads) == 7:
|
411
|
-
ads = '0' + ads # meh. fix missing leading zero in hours..
|
412
|
-
ads = ('>' if gt else '') + ads
|
413
|
-
return ads
|
414
|
-
|
415
|
-
|
416
|
-
left = f'{fmt_delta(left_delta)!s:<9}'
|
432
|
+
left = f'{_fmt_delta(left_delta)!s:<9}'
|
417
433
|
if last_dt.timestamp() == 0:
|
418
|
-
ago = 'never'
|
434
|
+
ago = 'never' # TODO yellow?
|
419
435
|
else:
|
420
436
|
passed_delta = UTCNOW - last_dt
|
421
|
-
ago = str(
|
437
|
+
ago = str(_fmt_delta(passed_delta))
|
422
438
|
# TODO instead of hacking microsecond, use 'NOW' or something?
|
423
439
|
|
424
|
-
props = bus.properties(service)
|
425
440
|
# TODO some summary too? e.g. how often in failed
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
441
|
+
if params.with_command:
|
442
|
+
exec_start = service.cmdline
|
443
|
+
assert exec_start is not None, service # not None for services
|
444
|
+
command = shlex.join(exec_start)
|
445
|
+
else:
|
446
|
+
command = None
|
447
|
+
_pid: int | None = int(bus.prop(service_props, '.Service', 'MainPID'))
|
448
|
+
pid = None if _pid == 0 else str(_pid)
|
433
449
|
|
434
450
|
if params.with_success_rate:
|
435
|
-
rate = _unit_success_rate(service)
|
451
|
+
rate = _unit_success_rate(service.unit_file.name)
|
436
452
|
rates = f' {rate:.2f}'
|
437
453
|
else:
|
438
454
|
rates = ''
|
439
455
|
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
456
|
+
service_result = bus.prop(service_props, '.Service', 'Result')
|
457
|
+
status_ok = service_result == 'success'
|
458
|
+
status = f'{service_result:<9} {ago:<8}{rates}'
|
459
|
+
|
460
|
+
entries.append(
|
461
|
+
MonitorEntry(
|
462
|
+
unit=k,
|
463
|
+
status=status,
|
464
|
+
left=left,
|
465
|
+
next=nexts,
|
466
|
+
schedule=schedule,
|
467
|
+
command=command,
|
468
|
+
pid=pid,
|
469
|
+
status_ok=status_ok,
|
470
|
+
)
|
471
|
+
)
|
453
472
|
return entries
|
454
473
|
|
455
474
|
|
456
475
|
Json = dict[str, Any]
|
476
|
+
|
477
|
+
|
457
478
|
def _unit_logs(unit: Unit) -> Iterator[Json]:
|
458
479
|
# TODO so do I need to parse logs to get failure stats? perhaps json would be more reliable
|
459
480
|
cmd = f'journalctl --user -u {unit} -o json -t systemd --output-fields UNIT_RESULT,JOB_TYPE,MESSAGE'
|
460
481
|
with Popen(cmd.split(), stdout=PIPE) as po:
|
461
|
-
stdout = po.stdout
|
482
|
+
stdout = po.stdout
|
483
|
+
assert stdout is not None
|
462
484
|
for line in stdout:
|
463
485
|
j = json.loads(line.decode('utf8'))
|
464
486
|
# apparently, successful runs aren't getting logged? not sure why
|
465
|
-
jt = j.get('JOB_TYPE')
|
466
|
-
ur = j.get('UNIT_RESULT')
|
487
|
+
# jt = j.get('JOB_TYPE')
|
488
|
+
# ur = j.get('UNIT_RESULT')
|
467
489
|
# not sure about this..
|
468
490
|
yield j
|
469
491
|
|
470
492
|
|
471
493
|
def _unit_success_rate(unit: Unit) -> float:
|
472
494
|
started = 0
|
473
|
-
failed
|
495
|
+
failed = 0
|
474
496
|
# TODO not sure how much time it takes to query all journals?
|
475
497
|
for j in _unit_logs(unit):
|
476
498
|
jt = j.get('JOB_TYPE')
|
dron/tests/test_dron.py
CHANGED
@@ -1,14 +1,27 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
import sys
|
4
|
+
from collections.abc import Iterator
|
3
5
|
from pathlib import Path
|
4
6
|
|
5
7
|
import pytest
|
6
8
|
|
7
|
-
from ..dron import load_jobs
|
9
|
+
from ..dron import do_lint, load_jobs
|
8
10
|
|
9
11
|
|
10
|
-
|
11
|
-
|
12
|
+
@pytest.fixture
|
13
|
+
def tmp_pythonpath(tmp_path: Path) -> Iterator[Path]:
|
14
|
+
ps = str(tmp_path)
|
15
|
+
assert ps not in sys.path # just in case
|
16
|
+
sys.path.insert(0, ps)
|
17
|
+
try:
|
18
|
+
yield tmp_path
|
19
|
+
finally:
|
20
|
+
sys.path.remove(ps)
|
21
|
+
|
22
|
+
|
23
|
+
def test_load_jobs_basic(tmp_pythonpath: Path) -> None:
|
24
|
+
tpath = Path(tmp_pythonpath) / 'test_drontab.py'
|
12
25
|
tpath.write_text(
|
13
26
|
'''
|
14
27
|
from typing import Iterator
|
@@ -37,7 +50,7 @@ def jobs() -> Iterator[Job]:
|
|
37
50
|
|
38
51
|
'''
|
39
52
|
)
|
40
|
-
loaded = list(load_jobs(
|
53
|
+
loaded = list(load_jobs(tab_module='test_drontab'))
|
41
54
|
[job1, job2, job3] = loaded
|
42
55
|
|
43
56
|
assert job1.when == '01:10'
|
@@ -53,8 +66,8 @@ def jobs() -> Iterator[Job]:
|
|
53
66
|
assert job3.unit_name == 'job3'
|
54
67
|
|
55
68
|
|
56
|
-
def test_load_jobs_dupes(
|
57
|
-
tpath = Path(
|
69
|
+
def test_load_jobs_dupes(tmp_pythonpath: Path) -> None:
|
70
|
+
tpath = Path(tmp_pythonpath) / 'test_drontab.py'
|
58
71
|
tpath.write_text(
|
59
72
|
'''
|
60
73
|
from typing import Iterator
|
@@ -69,11 +82,11 @@ def jobs() -> Iterator[Job]:
|
|
69
82
|
'''
|
70
83
|
)
|
71
84
|
with pytest.raises(AssertionError):
|
72
|
-
_loaded = list(load_jobs(
|
85
|
+
_loaded = list(load_jobs(tab_module='test_drontab'))
|
73
86
|
|
74
87
|
|
75
|
-
def test_jobs_auto_naming(
|
76
|
-
tpath = Path(
|
88
|
+
def test_jobs_auto_naming(tmp_pythonpath: Path) -> None:
|
89
|
+
tpath = Path(tmp_pythonpath) / 'test_drontab.py'
|
77
90
|
tpath.write_text(
|
78
91
|
'''
|
79
92
|
from typing import Iterator
|
@@ -105,7 +118,7 @@ def jobs() -> Iterator[Job]:
|
|
105
118
|
yield job4
|
106
119
|
'''
|
107
120
|
)
|
108
|
-
loaded = list(load_jobs(
|
121
|
+
loaded = list(load_jobs(tab_module='test_drontab'))
|
109
122
|
(job2, job_named, job_1, job5, job4) = loaded
|
110
123
|
assert job_1.unit_name == 'job_1'
|
111
124
|
assert job_1.when == '00:01'
|
@@ -117,3 +130,61 @@ def jobs() -> Iterator[Job]:
|
|
117
130
|
assert job4.when == '00:04'
|
118
131
|
assert job5.unit_name == 'job5'
|
119
132
|
assert job5.when == '00:05'
|
133
|
+
|
134
|
+
|
135
|
+
def test_do_lint(tmp_pythonpath: Path) -> None:
|
136
|
+
def OK(body: str) -> None:
|
137
|
+
tpath = Path(tmp_pythonpath) / 'test_drontab.py'
|
138
|
+
tpath.write_text(body)
|
139
|
+
do_lint(tab_module='test_drontab')
|
140
|
+
|
141
|
+
def FAILS(body: str) -> None:
|
142
|
+
with pytest.raises(Exception):
|
143
|
+
OK(body)
|
144
|
+
|
145
|
+
FAILS(
|
146
|
+
body='''
|
147
|
+
None.whatever
|
148
|
+
'''
|
149
|
+
)
|
150
|
+
|
151
|
+
# no jobs
|
152
|
+
FAILS(
|
153
|
+
body='''
|
154
|
+
'''
|
155
|
+
)
|
156
|
+
|
157
|
+
OK(
|
158
|
+
body='''
|
159
|
+
def jobs():
|
160
|
+
yield from []
|
161
|
+
'''
|
162
|
+
)
|
163
|
+
|
164
|
+
OK(
|
165
|
+
body='''
|
166
|
+
from dron.api import job
|
167
|
+
def jobs():
|
168
|
+
yield job(
|
169
|
+
'hourly',
|
170
|
+
['/bin/echo', '123'],
|
171
|
+
unit_name='unit_test',
|
172
|
+
)
|
173
|
+
'''
|
174
|
+
)
|
175
|
+
|
176
|
+
from ..systemd import _is_missing_systemd
|
177
|
+
|
178
|
+
if not _is_missing_systemd():
|
179
|
+
from ..cli import _drontab_example
|
180
|
+
|
181
|
+
# this test doesn't work without systemd yet, because launchd adapter doesn't support unquoted commands, at least yet..
|
182
|
+
example = _drontab_example()
|
183
|
+
# ugh. some hackery to make it find the executable..
|
184
|
+
echo = " '/bin/echo"
|
185
|
+
example = (
|
186
|
+
example.replace(" 'linkchecker", echo)
|
187
|
+
.replace(" '/home/user/scripts/run-borg", echo)
|
188
|
+
.replace(" 'ping", " '/bin/ping")
|
189
|
+
)
|
190
|
+
OK(body=example)
|
@@ -1,6 +1,8 @@
|
|
1
|
-
Metadata-Version: 2.
|
1
|
+
Metadata-Version: 2.4
|
2
2
|
Name: dron
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.2.20251011
|
4
|
+
Summary: What if cron and systemd had a baby?
|
5
|
+
Project-URL: Homepage, https://github.com/karlicoss/dron
|
4
6
|
Author-email: "Dima Gerasimov (@karlicoss)" <karlicoss@gmail.com>
|
5
7
|
Maintainer-email: "Dima Gerasimov (@karlicoss)" <karlicoss@gmail.com>
|
6
8
|
License: The MIT License (MIT)
|
@@ -24,24 +26,16 @@ License: The MIT License (MIT)
|
|
24
26
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
25
27
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
26
28
|
SOFTWARE.
|
27
|
-
|
28
|
-
Project-URL: Homepage, https://github.com/karlicoss/dron
|
29
|
-
Requires-Python: >=3.9
|
30
29
|
License-File: LICENSE.txt
|
30
|
+
Requires-Python: >=3.12
|
31
31
|
Requires-Dist: click
|
32
|
+
Requires-Dist: dbus-python; platform_system != 'Darwin'
|
33
|
+
Requires-Dist: loguru
|
34
|
+
Requires-Dist: mypy
|
32
35
|
Requires-Dist: prompt-toolkit
|
33
|
-
Requires-Dist: tzlocal
|
34
|
-
Requires-Dist: textual
|
35
36
|
Requires-Dist: tabulate
|
36
37
|
Requires-Dist: termcolor
|
37
|
-
Requires-Dist:
|
38
|
-
Requires-Dist:
|
39
|
-
Requires-Dist: dbus-python ; platform_system != "Darwin"
|
38
|
+
Requires-Dist: textual
|
39
|
+
Requires-Dist: tzlocal
|
40
40
|
Provides-Extra: notify-telegram
|
41
|
-
Requires-Dist: telegram-send
|
42
|
-
Provides-Extra: testing
|
43
|
-
Requires-Dist: pytest ; extra == 'testing'
|
44
|
-
Requires-Dist: ruff ; extra == 'testing'
|
45
|
-
Requires-Dist: mypy ; extra == 'testing'
|
46
|
-
Requires-Dist: lxml ; extra == 'testing'
|
47
|
-
|
41
|
+
Requires-Dist: telegram-send>=0.37; extra == 'notify-telegram'
|
@@ -0,0 +1,23 @@
|
|
1
|
+
dron/__main__.py,sha256=M4GBcqkUcwPRIZIUmoK_lCq5eYghDkte9Avzj1ythG4,126
|
2
|
+
dron/api.py,sha256=ca2aLoV1Q7ForYGrIpEUKQBlKNpSglmb_0G1Ws-oOWg,2741
|
3
|
+
dron/cli.py,sha256=q0kwLMLT5gr_yBLvhR7vFpS8X4mgCnLX0v7oq1f3P60,7313
|
4
|
+
dron/common.py,sha256=Na-lNf_U8NHsNNHTBvtLIcQQic27jAIp0TAQHj9cjRo,4232
|
5
|
+
dron/conftest.py,sha256=qI8vwJ6NpTaNofjYDLghCeKjps-i_1DflVTY5Xugqmc,435
|
6
|
+
dron/dron.py,sha256=vU5LLSZlJsRos-sl0pmFu8LK3KExOVdO60BbPdUkcPo,12521
|
7
|
+
dron/launchd.py,sha256=qZUAg502KYUt7W0s-QcnZ2k-4tiymCEhbtpa2BeP4NA,13091
|
8
|
+
dron/launchd_wrapper.py,sha256=0GfrlHt0LpGZ1aZ3vCdLMsL-lwpZHTZIP4ysiK_mujg,3591
|
9
|
+
dron/monitor.py,sha256=zIWS8r-D-mcA0PNndwmI7Q8DFqwEXbcW9D8DkyKxCQk,5033
|
10
|
+
dron/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
11
|
+
dron/systemd.py,sha256=XAHVTufpMB8zQAK3n8jh--lx7g9TpTwZLlUTeutqvy0,17159
|
12
|
+
dron/notify/common.py,sha256=UHcO1DIWMm6_y8ZNoMLYDT3Fe7yo603odoka6LBTgnA,1831
|
13
|
+
dron/notify/email.py,sha256=RColplMF4xmkudW3P8VMX30fZbCjLE61SWwpJcB7jt0,1089
|
14
|
+
dron/notify/ntfy_common.py,sha256=cY_OXHGbhWxbZchF2PpqnmKs7_BSc-zlkrrNu9qfDhc,754
|
15
|
+
dron/notify/ntfy_desktop.py,sha256=-dxwgKtMFlI3U_eg5vDiVZ6IqrAIIheYSpXK8X551pk,278
|
16
|
+
dron/notify/ntfy_telegram.py,sha256=DNscsx_qucuqFvkDVOkwMTZouATz-Jpke6HoAc682Is,221
|
17
|
+
dron/notify/telegram.py,sha256=UPcjNOCz3W1qDSzCzraPqhQaLy5hnFs2D1esJDbO5QA,916
|
18
|
+
dron/tests/test_dron.py,sha256=mVtNtWx7kD7X1peEJRdACkis4MJl7suERbsTWNnYldQ,4400
|
19
|
+
dron-0.2.20251011.dist-info/METADATA,sha256=E23BAzLpep_kr6lB7H7Txb5ghQkZwxo8A6ev-eDrTUI,1933
|
20
|
+
dron-0.2.20251011.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
21
|
+
dron-0.2.20251011.dist-info/entry_points.txt,sha256=GR-EicMii9C6-3vaenLjUNoJvCksPIEx7y3qppT2YuM,44
|
22
|
+
dron-0.2.20251011.dist-info/licenses/LICENSE.txt,sha256=J8WsmQpd6swLqaeiFNct4MCsLdSYKX8T5uGw1p7WC8Q,1081
|
23
|
+
dron-0.2.20251011.dist-info/RECORD,,
|
dron/__init__.py
DELETED
@@ -1,25 +0,0 @@
|
|
1
|
-
dron/__init__.py,sha256=o_sIdoGVpOxBO1sSvKj9ftfEtRF4Af4CUhGCuaElMHI,160
|
2
|
-
dron/__main__.py,sha256=M4GBcqkUcwPRIZIUmoK_lCq5eYghDkte9Avzj1ythG4,126
|
3
|
-
dron/api.py,sha256=G5sB60Hvdzl1domilH90NWyJ_MUkMKmxIxZXYitbCOE,2731
|
4
|
-
dron/cli.py,sha256=NH_cXcDPpzcOy5Pmq0GJB9ciLdrYXpRTwKtEWWtthRs,11801
|
5
|
-
dron/common.py,sha256=D7xXSeab74N6iKCiQ0jTzIxMs7kTQyM0PhZbZbulEoI,4230
|
6
|
-
dron/conftest.py,sha256=qI8vwJ6NpTaNofjYDLghCeKjps-i_1DflVTY5Xugqmc,435
|
7
|
-
dron/dron.py,sha256=z8lXVfOmMn5_67vpU4vfW0r9_NXQW-SgPaUVNkVtWb0,14665
|
8
|
-
dron/launchd.py,sha256=eQkNjUBQOeZk32Zy4RcTcMT5wcnE3vshu-xylv1H9C0,12681
|
9
|
-
dron/launchd_wrapper.py,sha256=gv-t_yyNj_xRAOv0qWgrZUPkmShWX4SySXZtfzFJe_g,2847
|
10
|
-
dron/monitor.py,sha256=3bUk18YRGysYKOe2EsMRLBDOGo-G6HZG4Qa_vToJ_3Y,5041
|
11
|
-
dron/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
12
|
-
dron/systemd.py,sha256=NT9EnbAEv7rIq1VTFGKAogEnRRhRrPqL0qe6Gv0z2Ds,16797
|
13
|
-
dron/notify/common.py,sha256=J-EFJNSUc001H-HBj8kMEve8zQ9eFOi-eFQ5YKRfyrY,1822
|
14
|
-
dron/notify/email.py,sha256=INSjvQXp4ze4b_Cdhw3bdpmrCAn7GtjzFKY0Lw5dYtA,1080
|
15
|
-
dron/notify/ntfy_common.py,sha256=ZXwg7oowpfJt8pmhhFMsworLDHK5CEDKPkGP7yIken0,621
|
16
|
-
dron/notify/ntfy_desktop.py,sha256=-dxwgKtMFlI3U_eg5vDiVZ6IqrAIIheYSpXK8X551pk,278
|
17
|
-
dron/notify/ntfy_telegram.py,sha256=DNscsx_qucuqFvkDVOkwMTZouATz-Jpke6HoAc682Is,221
|
18
|
-
dron/notify/telegram.py,sha256=-Cv52rPh9x1Y58A4bcJ2Snjf4KzVpiKNGQFGTaAlcxU,900
|
19
|
-
dron/tests/test_dron.py,sha256=0s6joSFfOfoA6JJoOUjNL8_tzuPYFiUQls4tHynaSvc,2806
|
20
|
-
dron-0.1.20241008.dist-info/LICENSE.txt,sha256=J8WsmQpd6swLqaeiFNct4MCsLdSYKX8T5uGw1p7WC8Q,1081
|
21
|
-
dron-0.1.20241008.dist-info/METADATA,sha256=cDy255T8UsmUknk2cPUjANdQ0RqjoDxifATHV_FkZVM,2089
|
22
|
-
dron-0.1.20241008.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
|
23
|
-
dron-0.1.20241008.dist-info/entry_points.txt,sha256=GR-EicMii9C6-3vaenLjUNoJvCksPIEx7y3qppT2YuM,44
|
24
|
-
dron-0.1.20241008.dist-info/top_level.txt,sha256=pgU6oqDihvZc9UeOnpnhZ7LmhOoOaBRQAccMTlnEYwI,5
|
25
|
-
dron-0.1.20241008.dist-info/RECORD,,
|
@@ -1 +0,0 @@
|
|
1
|
-
dron
|
File without changes
|