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 ADDED
@@ -0,0 +1,6 @@
1
+ # NOTE: backwards compatibility, now relying on dron.__main__
2
+ # should probably remove this later
3
+ from .dron import main
4
+
5
+ if __name__ == '__main__':
6
+ main()
dron/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ # NOTE: import needs to be on top level as it's the entry point
2
+ from .dron import main
3
+
4
+ if __name__ == '__main__':
5
+ main()
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