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 CHANGED
@@ -4,8 +4,8 @@ import getpass
4
4
  import inspect
5
5
  import re
6
6
  import sys
7
+ from collections.abc import Sequence
7
8
  from dataclasses import dataclass
8
- from typing import Sequence
9
9
 
10
10
  from .common import (
11
11
  IS_SYSTEMD,
@@ -19,6 +19,7 @@ OnFailureAction = str
19
19
 
20
20
  UnitName = str
21
21
 
22
+
22
23
  @dataclass
23
24
  class Job:
24
25
  when: When | None
@@ -95,12 +96,12 @@ def job(
95
96
 
96
97
 
97
98
  __all__ = (
98
- 'When',
99
+ 'Command',
100
+ 'Job', # todo maybe don't expose it?
99
101
  'OnCalendar',
100
102
  'OnFailureAction',
101
- 'Command',
102
- 'wrap',
103
+ 'When',
103
104
  'job',
104
105
  'notify',
105
- 'Job', # todo maybe don't expose it?
106
+ 'wrap',
106
107
  )
dron/cli.py CHANGED
@@ -1,32 +1,25 @@
1
1
  from __future__ import annotations
2
2
 
3
- import argparse
4
- import os
5
- import shutil
6
- import subprocess
3
+ import socket
7
4
  import sys
8
- from pathlib import Path
9
5
  from pprint import pprint
10
- from tempfile import TemporaryDirectory
11
6
 
12
7
  import click
13
8
 
14
- from . import launchd, systemd
9
+ from . import common, launchd, systemd
15
10
  from .api import UnitName
16
11
  from .common import (
17
12
  IS_SYSTEMD,
18
- MANAGED_MARKER,
19
13
  MonitorParams,
20
14
  Unit,
21
15
  escape,
22
16
  logger,
23
17
  print_monitor,
18
+ set_verify_off,
24
19
  )
25
20
  from .dron import (
26
- DRONTAB,
27
21
  apply,
28
22
  do_lint,
29
- drontab_dir,
30
23
  get_entries_for_monitor,
31
24
  load_jobs,
32
25
  manage,
@@ -34,128 +27,8 @@ from .dron import (
34
27
  )
35
28
 
36
29
 
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
30
  # TODO test it on CI?
31
+ # TODO explicitly inject it into readme?
159
32
  def _drontab_example() -> str:
160
33
  return '''
161
34
  from dron.api import job
@@ -190,193 +63,207 @@ def jobs():
190
63
  '''.lstrip()
191
64
 
192
65
 
193
- def make_parser() -> argparse.ArgumentParser:
194
- from .common import VerifyOff
66
+ def _get_epilog() -> str:
67
+ return '''
68
+ * Why?
69
+
70
+ In short, because I want to benefit from the heavy lifting that Systemd does: timeouts, resource management, restart policies, powerful scheduling specs and logging,
71
+ while not having to manually manipulate numerous unit files and restart the daemon all over.
72
+
73
+ I elaborate on what led me to implement it and motivation [[https://beepb00p.xyz/scheduler.html#what_do_i_want][here]]. Also:
74
+
75
+ \b
76
+ - why not just use [[https://beepb00p.xyz/scheduler.html#cron][cron]]?
77
+ - why not just use [[https://beepb00p.xyz/scheduler.html#systemd][systemd]]?
78
+ '''.strip()
195
79
 
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
80
 
203
- p = argparse.ArgumentParser(
204
- prog='dron',
205
- description='''
81
+ @click.group(
82
+ context_settings={'show_default': True},
83
+ help="""
206
84
  dron -- simple frontend for Systemd, inspired by cron.
207
85
 
86
+ \b
208
87
  - *d* stands for 'Systemd'
209
88
  - *ron* stands for 'cron'
210
89
 
211
90
  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~:
91
+ """.strip(),
92
+ epilog=_get_epilog(),
93
+ )
94
+ @click.option(
95
+ '--marker',
96
+ required=False,
97
+ help=f'Use custom marker instead of default `{common.MANAGED_MARKER}`. Useful for developing/testing.',
98
+ )
99
+ def cli(*, marker: str | None) -> None:
100
+ if marker is not None:
101
+ common.MANAGED_MARKER = marker
221
102
 
222
- {example}
223
103
 
224
- After you save your changes and exit the editor, your drontab is checked for syntax and applied
104
+ arg_tab_module = click.option(
105
+ '--module',
106
+ 'tab_module',
107
+ type=str,
108
+ default=f'drontab.{socket.gethostname()}',
109
+ )
225
110
 
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
111
 
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.
112
+ # specify in readme???
113
+ # would be nice to use external checker..
114
+ # https://github.com/systemd/systemd/issues/8072
115
+ # https://unix.stackexchange.com/questions/493187/systemd-under-ubuntu-18-04-1-fails-with-failed-to-create-user-slice-serv
116
+ def _set_verify_off(ctx, param, value) -> None: # noqa: ARG001
117
+ if value is True:
118
+ set_verify_off()
232
119
 
233
- I elaborate on what led me to implement it and motivation [[https://beepb00p.xyz/scheduler.html#what_do_i_want][here]]. Also:
234
120
 
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
- '''
121
+ arg_no_verify = click.option(
122
+ '--no-verify',
123
+ is_flag=True,
124
+ callback=_set_verify_off,
125
+ expose_value=False,
126
+ help='Skip systemctl verify step',
127
+ )
238
128
 
239
- p.add_argument(
240
- '--marker', required=False, help=f'Use custom marker instead of default `{MANAGED_MARKER}`. Useful for developing/testing.'
241
- )
242
129
 
243
- def add_tabfile_arg(p: argparse.ArgumentParser) -> None:
244
- p.add_argument('tabfile', type=Path, nargs='?')
130
+ @cli.command('lint')
131
+ @arg_tab_module
132
+ @arg_no_verify
133
+ def cmd_lint(*, tab_module: str) -> None:
134
+ # FIXME how to disable verity?
135
+ # FIXME lint command isn't very interesting now btw?
136
+ # perhaps instead, either add dry mode to apply
137
+ # or split into the 'diff' part and side effect apply part
138
+ _state = do_lint(tab_module)
139
+ logger.info('all good')
245
140
 
246
- sp = p.add_subparsers(dest='mode')
247
141
 
248
- ### actions on drontab file
249
- edit_parser = sp.add_parser('edit', help="Edit drontab (like 'crontab -e')")
250
- add_verify(edit_parser)
142
+ @cli.command('print')
143
+ @arg_tab_module
144
+ @click.option('--pretty', is_flag=True, help='Pretty print')
145
+ @arg_no_verify
146
+ def cmd_print(*, tab_module: str, pretty: bool) -> None:
147
+ """Parse and print drontab"""
148
+ jobs = list(load_jobs(tab_module=tab_module))
251
149
 
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)
150
+ if pretty:
151
+ import tabulate
259
152
 
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
- ###
153
+ items = [
154
+ {
155
+ 'UNIT': job.unit_name,
156
+ 'SCHEDULE': job.when,
157
+ 'COMMAND': escape(job.command),
158
+ }
159
+ for job in jobs
160
+ ]
161
+ print(tabulate.tabulate(items, headers="keys"))
162
+ else:
163
+ for j in jobs:
164
+ print(j)
264
165
 
265
- ### actions on managed jobs
266
- debug_parser = sp.add_parser('debug', help='Print some debug info')
267
166
 
268
- uninstall_parser = sp.add_parser('uninstall', help="Uninstall all managed jobs")
269
- add_verify(uninstall_parser)
167
+ # TODO --force?
168
+ @cli.command('apply')
169
+ @arg_tab_module
170
+ def cmd_apply(*, tab_module: str) -> None:
171
+ """Apply drontab (like 'crontab' with no args)"""
172
+ apply(tab_module)
270
173
 
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
174
 
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
- ###
175
+ @cli.command('debug')
176
+ def cmd_debug() -> None:
177
+ """Print some debug info"""
178
+ managed = managed_units(with_body=False) # TODO not sure about body
179
+ for x in managed:
180
+ pprint(x, stream=sys.stderr)
278
181
 
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
182
 
183
+ @cli.command('uninstall')
184
+ def cmd_uninstall() -> None:
185
+ """Remove all managed jobs (will ask for confirmation)"""
186
+ click.confirm('Going to remove all dron managed jobs. Continue?', default=True, abort=True)
187
+ manage([])
288
188
 
289
- def main() -> None:
290
- from .cli import make_parser
291
189
 
292
- p = make_parser()
293
- args = p.parse_args()
190
+ @cli.group('job')
191
+ def cli_job() -> None:
192
+ """Actions on individual jobs"""
193
+ pass
294
194
 
295
- marker: str | None = args.marker
296
- if marker is not None:
297
- from . import common
298
195
 
299
- common.MANAGED_MARKER = marker
196
+ def _prompt_for_unit() -> UnitName:
197
+ from prompt_toolkit import PromptSession
198
+ from prompt_toolkit.completion import WordCompleter
300
199
 
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()
200
+ # TODO print options
201
+ managed = list(managed_units(with_body=False))
202
+ units = [x.unit_file.stem for x in managed]
203
+
204
+ print('Units under dron:', file=sys.stderr)
205
+ for u in units:
206
+ print(f'- {u}', file=sys.stderr)
207
+
208
+ completer = WordCompleter(units, ignore_case=True)
209
+ session = PromptSession("Select a unit: ", completer=completer) # type: ignore[var-annotated]
210
+ selected = session.prompt()
211
+ return selected
212
+
213
+
214
+ arg_unit = click.argument('unit', type=Unit, default=_prompt_for_unit)
215
+
216
+
217
+ @cli_job.command('past')
218
+ @arg_unit
219
+ def cmd_past(unit: Unit) -> None:
220
+ if IS_SYSTEMD:
221
+ # TODO hmm seems like this just exit with 0 if unit diesn't exist
222
+ return systemd.cmd_past(unit)
379
223
  else:
380
- logger.error(f'Unknown mode: {mode}')
381
- p.print_usage(sys.stderr)
382
- sys.exit(1)
224
+ return launchd.cmd_past(unit)
225
+
226
+
227
+ @cli_job.command('run')
228
+ @arg_unit
229
+ @click.option('--exec', 'do_exec', is_flag=True, help='Run directly, not via systemd/launchd')
230
+ def cmd_run(*, unit: Unit, do_exec: bool) -> None:
231
+ """Run the job right now, ignoring the timer"""
232
+ if IS_SYSTEMD:
233
+ return systemd.cmd_run(unit=unit, do_exec=do_exec)
234
+ else:
235
+ return launchd.cmd_run(unit=unit, do_exec=do_exec)
236
+
237
+
238
+ @cli.command('monitor')
239
+ @click.option('-n', type=float, default=1.0, help='refresh every n seconds')
240
+ @click.option('--once', is_flag=True, help='only call once')
241
+ @click.option('--rate', is_flag=True, help='Display success rate (unstable and potentially slow)')
242
+ @click.option('--command', is_flag=True, help='Display command')
243
+ def cmd_monitor(*, n: float, once: bool, rate: bool, command: bool) -> None:
244
+ """Monitor services/timers managed by dron"""
245
+ params = MonitorParams(
246
+ with_success_rate=rate,
247
+ with_command=command,
248
+ )
249
+
250
+ if once:
251
+ # old style monitor
252
+ # TODO think if it's worth integrating with timers?
253
+ managed = list(managed_units(with_body=False)) # body slows down this call quite a bit
254
+ if len(managed) == 0:
255
+ logger.warning('no managed units!')
256
+
257
+ logger.debug('starting monitor...')
258
+
259
+ entries = get_entries_for_monitor(managed=managed, params=params)
260
+ print_monitor(entries)
261
+ else:
262
+ from .monitor import MonitorApp
263
+
264
+ app = MonitorApp(monitor_params=params, refresh_every=n)
265
+ app.run()
266
+
267
+
268
+ def main() -> None:
269
+ cli()
dron/common.py CHANGED
@@ -1,13 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
- import argparse
4
3
  import platform
5
4
  import shlex
6
5
  import sys
6
+ from collections.abc import Iterable, Sequence
7
7
  from dataclasses import asdict, dataclass, replace
8
8
  from datetime import datetime
9
9
  from pathlib import Path
10
- from typing import Any, Dict, Iterable, Sequence, TypeVar, Union
10
+ from typing import Any
11
11
 
12
12
  from loguru import logger # noqa: F401
13
13
 
@@ -16,15 +16,15 @@ datetime_naive = datetime
16
16
 
17
17
 
18
18
  # TODO can remove this? although might be useful for tests
19
- VERIFY_UNITS = True
19
+ VERIFY_UNITS: bool = True
20
20
  # TODO ugh. verify tries using already installed unit files so if they were bad, everything would fail
21
21
  # I guess could do two stages, i.e. units first, then timers
22
22
  # dunno, a bit less atomic though...
23
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
24
+
25
+ def set_verify_off() -> None:
26
+ global VERIFY_UNITS
27
+ VERIFY_UNITS = False
28
28
 
29
29
 
30
30
  @dataclass
@@ -45,6 +45,11 @@ class UnitState:
45
45
  cmdline: Sequence[str] | None # can be None for timers
46
46
 
47
47
 
48
+ @dataclass
49
+ class SystemdUnitState(UnitState):
50
+ dbus_properties: Any # seems like keeping this around massively speeds up dbus access...
51
+
52
+
48
53
  @dataclass
49
54
  class LaunchdUnitState(UnitState):
50
55
  # NOTE: can legit be str (e.g. if unit was never ran before)
@@ -59,44 +64,46 @@ State = Iterable[UnitState]
59
64
  IS_SYSTEMD = platform.system() != 'Darwin' # if not systemd it's launchd
60
65
 
61
66
 
62
-
63
- T = TypeVar('T')
64
- def unwrap(x: T | None) -> T:
67
+ def unwrap[T](x: T | None) -> T:
65
68
  assert x is not None
66
69
  return x
67
70
 
68
71
 
69
- PathIsh = Union[str, Path]
72
+ PathIsh = str | Path
70
73
 
71
74
  # if it's an str, assume it's already escaped
72
75
  # otherwise we are responsible for escaping..
73
- Command = Union[PathIsh, Sequence[PathIsh]]
76
+ Command = PathIsh | Sequence[PathIsh]
74
77
 
75
78
 
76
79
  OnCalendar = str
77
- TimerSpec = Dict[str, str] # meh # TODO why is it a dict???
80
+ TimerSpec = dict[str, str] # meh # TODO why is it a dict???
78
81
  ALWAYS = 'always'
79
- When = Union[OnCalendar, TimerSpec]
82
+ When = OnCalendar | TimerSpec
83
+
84
+
85
+ MANAGED_MARKER: str = '(MANAGED BY DRON)'
80
86
 
81
87
 
82
- MANAGED_MARKER = '(MANAGED BY DRON)'
83
88
  def is_managed(body: str) -> bool:
84
89
  # switching off it because it's unfriendly to launchd
85
90
  legacy_marker = '<MANAGED BY DRON>'
86
91
  return MANAGED_MARKER in body or legacy_marker in body
87
92
 
88
93
 
89
-
90
94
  pytest_fixture: Any
91
95
  under_pytest = 'pytest' in sys.modules
92
96
  if under_pytest:
93
97
  import pytest
98
+
94
99
  pytest_fixture = pytest.fixture
95
100
  else:
96
- pytest_fixture = lambda f: f # no-op otherwise to prevent pytest import
101
+ pytest_fixture = lambda f: f # no-op otherwise to prevent pytest import
97
102
 
98
103
 
99
104
  Escaped = str
105
+
106
+
100
107
  def escape(command: Command) -> Escaped:
101
108
  if isinstance(command, Escaped):
102
109
  return command
@@ -118,7 +125,6 @@ def test_wrap() -> None:
118
125
  assert wrap('cat', bin_) == "cat /bin/bash"
119
126
 
120
127
 
121
-
122
128
  @dataclass(order=True)
123
129
  class MonitorEntry:
124
130
  unit: str
@@ -143,8 +149,9 @@ def print_monitor(entries: Iterable[MonitorEntry]) -> None:
143
149
  key=lambda e: (e.pid is None, e.status_ok, e),
144
150
  )
145
151
 
146
- import termcolor # noqa: I001
147
152
  import tabulate
153
+ import termcolor
154
+
148
155
  tabulate.PRESERVE_WHITESPACE = True
149
156
 
150
157
  headers = [
@@ -170,5 +177,5 @@ def print_monitor(entries: Iterable[MonitorEntry]) -> None:
170
177
  next=termcolor.colored('running', 'yellow'),
171
178
  left='--',
172
179
  )
173
- items.append(list(asdict(e).values())[:len(headers)])
180
+ items.append(list(asdict(e).values())[: len(headers)])
174
181
  print(tabulate.tabulate(items, headers=headers))