config_ninja.cli

Create config-ninja's CLI with typer.

config-ninja

Manage operating system configuration files based on data in the cloud.

Usage:

$ config-ninja [OPTIONS] COMMAND [ARGS]...

Options:

  • -c, --config PATH: Path to config-ninja's own configuration file.
  • -v, --version: Print the version and exit.
  • --install-completion: Install completion for the current shell.
  • --show-completion: Show completion for the current shell, to copy it or customize the installation.
  • --help: Show this message and exit.

Commands:

  • apply: Apply the specified configuration to the system.
  • get: Print the value of the specified configuration object.
  • monitor: Apply all configuration objects to the filesystem, and poll for changes.
  • self: Operate on this installation of config-ninja.
  • version: Print the version and exit.

config-ninja apply

Apply the specified configuration to the system.

Usage:

$ config-ninja apply [OPTIONS] [KEY]

Arguments:

  • [KEY]: If specified, only apply the configuration object with this key.

Options:

  • -p, --poll: Enable polling; print the configuration on changes.
  • --help: Show this message and exit.

config-ninja get

Print the value of the specified configuration object.

Usage:

$ config-ninja get [OPTIONS] KEY

Arguments:

  • KEY: The key of the configuration object to retrieve [required]

Options:

  • -p, --poll: Enable polling; print the configuration on changes.
  • --help: Show this message and exit.

config-ninja monitor

Apply all configuration objects to the filesystem, and poll for changes.

Usage:

$ config-ninja monitor [OPTIONS]

Options:

  • --help: Show this message and exit.

config-ninja self

Operate on this installation of config-ninja.

Usage:

$ config-ninja self [OPTIONS] COMMAND [ARGS]...

Options:

  • --help: Show this message and exit.

Commands:

  • install: Install config-ninja as a systemd service.
  • print: If specified, only apply the configuration object with this key.
  • uninstall: Uninstall the config-ninja systemd service.

config-ninja self install

Install config-ninja as a systemd service.

Both --env and --var can be passed multiple times.

Example:

$ config-ninja self install --env FOO,BAR,BAZ --env SPAM --var EGGS=42

The environment variables FOO, BAR, BAZ, and SPAM will be read from the current shell and written to the service file, while EGGS will be set to 42.

Usage:

$ config-ninja self install [OPTIONS]

Options:

  • -e, --env NAME[,NAME...]: Embed these environment variables into the unit file. Can be used multiple times.
  • -p, --print-only: Just print the config-ninja.service file; do not write.
  • --run-as user[:group]: Configure the systemd unit to run the service as this user (and optionally group).
  • -u, --user, --user-mode: User mode installation (does not require sudo)
  • --var VARIABLE=VALUE: Embed the specified VARIABLE=VALUE into the unit file. Can be used multiple times.
  • -w, --workdir PATH: Run the service from this directory.
  • --help: Show this message and exit.

config-ninja self print

Print config-ninja's settings.

Usage:

$ config-ninja self print [OPTIONS]

Options:

  • --help: Show this message and exit.

config-ninja self uninstall

Uninstall the config-ninja systemd service.

Usage:

$ config-ninja self uninstall [OPTIONS]

Options:

  • -p, --print-only: Just print the config-ninja.service file; do not write.
  • -u, --user, --user-mode: User mode installation (does not require sudo)
  • --help: Show this message and exit.

config-ninja version

Print the version and exit.

Usage:

$ config-ninja version [OPTIONS]

Options:

  • --help: Show this message and exit.
typer does not support from __future__ import annotations as of 2023-12-31
  1"""Create `config-ninja`_'s CLI with `typer`_.
  2
  3.. include:: cli.md
  4
  5.. note:: `typer`_ does not support `from __future__ import annotations` as of 2023-12-31
  6
  7.. _config-ninja: https://config-ninja.readthedocs.io/home.html
  8.. _typer: https://typer.tiangolo.com/
  9"""
 10
 11import asyncio
 12import contextlib
 13import copy
 14import logging
 15import logging.config
 16import os
 17import sys
 18import typing
 19from pathlib import Path
 20
 21import rich
 22import typer
 23import yaml
 24from rich.markdown import Markdown
 25
 26from config_ninja import __version__, controller, settings, systemd
 27from config_ninja.settings import schema
 28
 29try:
 30    from typing import Annotated, TypeAlias  # type: ignore[attr-defined,unused-ignore]
 31except ImportError:  # pragma: no cover
 32    from typing import Annotated  # type: ignore[assignment,attr-defined,unused-ignore]
 33
 34    from typing_extensions import TypeAlias
 35
 36
 37# ruff: noqa: PLR0913
 38# pylint: disable=redefined-outer-name,unused-argument,too-many-arguments
 39
 40__all__ = [
 41    'app',
 42    'apply',
 43    'get',
 44    'install',
 45    'main',
 46    'monitor',
 47    'self_print',
 48    'uninstall',
 49    'version',
 50]
 51
 52LOG_MISSING_SETTINGS_MESSAGE = "Could not find [bold blue]config-ninja[/]'s settings file"
 53LOG_VERBOSITY_MESSAGE = 'logging verbosity set to [green]%s[/green]'
 54
 55logger = logging.getLogger(__name__)
 56
 57app_kwargs: dict[str, typing.Any] = {
 58    'context_settings': {'help_option_names': ['-h', '--help']},
 59    'no_args_is_help': True,
 60    'rich_markup_mode': 'rich',
 61}
 62
 63app = typer.Typer(**app_kwargs)
 64"""The root `typer`_ application.
 65
 66.. _typer: https://typer.tiangolo.com/
 67"""
 68
 69self_app = typer.Typer(**app_kwargs)
 70
 71app.add_typer(self_app, name='self', help='Operate on this installation of [bold blue]config-ninja[/].')
 72
 73ActionType = typing.Callable[[str], typing.Any]
 74
 75
 76def help_callback(ctx: typer.Context, value: typing.Optional[bool] = None) -> None:
 77    """Print the help message for the command."""
 78    if ctx.resilient_parsing:  # pragma: no cover
 79        return
 80
 81    if value:
 82        rich.print(ctx.get_help())
 83        raise typer.Exit()
 84
 85
 86HelpAnnotation: TypeAlias = Annotated[
 87    typing.Optional[bool],
 88    typer.Option(
 89        '-h',
 90        '--help',
 91        callback=help_callback,
 92        rich_help_panel='Global',
 93        show_default=False,
 94        is_eager=True,
 95        help='Show this message and exit.',
 96    ),
 97]
 98HookAnnotation: TypeAlias = Annotated[
 99    list[str],
100    typer.Argument(
101        help='Execute the named hook(s) (multiple values may be provided).',
102        show_default=False,
103        metavar='[HOOK...]',
104    ),
105]
106OptionalKeyAnnotation: TypeAlias = Annotated[
107    typing.Optional[list[str]],
108    typer.Argument(
109        help='Apply the configuration object(s) with matching key(s)'
110        ' (multiple values may be provided). If unspecified, all objects will be applied',
111        show_default=False,
112        metavar='[KEY...]',
113    ),
114]
115PollAnnotation: TypeAlias = Annotated[
116    typing.Optional[bool],
117    typer.Option(
118        '-p',
119        '--poll',
120        help='Enable polling; print the configuration on changes.',
121        show_default=False,
122    ),
123]
124PrintAnnotation: TypeAlias = Annotated[
125    typing.Optional[bool],
126    typer.Option(
127        '-p',
128        '--print-only',
129        help='Just print the [bold cyan]config-ninja.service[/] file; do not write.',
130        show_default=False,
131    ),
132]
133
134
135def load_config(ctx: typer.Context, value: typing.Optional[Path]) -> None:
136    """Load the settings file from the given path."""
137    if ctx.resilient_parsing:  # pragma: no cover
138        return
139
140    ctx.ensure_object(dict)
141    if not value and 'settings' in ctx.obj:
142        logger.debug('already loaded settings')
143        return
144
145    try:
146        settings_file = value or settings.resolve_path()
147    except FileNotFoundError as exc:
148        logger.warning(
149            '%s%s',
150            LOG_MISSING_SETTINGS_MESSAGE,
151            (' at any of the following locations:\n  - ' + '\n  - '.join(f'{p}' for p in exc.args[1]))
152            if len(exc.args) > 1
153            else '',
154            extra={'markup': True},
155        )
156        ctx.obj['settings'] = None
157        return
158
159    conf: settings.Config = settings.load(settings_file)
160    ctx.obj['settings'] = conf
161    ctx.obj['settings_file'] = settings_file
162    ctx.obj['settings_from_arg'] = value == settings_file
163
164    if 'logging_config' in ctx.obj and conf.settings.LOGGING:
165        configure_logging(ctx, None)
166
167
168ConfigAnnotation: TypeAlias = Annotated[
169    typing.Optional[Path],
170    typer.Option(
171        '-c',
172        '--config',
173        callback=load_config,
174        help="Path to [bold blue]config-ninja[/]'s own configuration file.",
175        rich_help_panel='Global',
176        show_default=False,
177    ),
178]
179UserAnnotation: TypeAlias = Annotated[
180    bool,
181    typer.Option(
182        '-u',
183        '--user',
184        '--user-mode',
185        help='User mode installation (does not require [bold orange3]sudo[/])',
186        show_default=False,
187    ),
188]
189WorkdirAnnotation: TypeAlias = Annotated[
190    typing.Optional[Path],
191    typer.Option('-w', '--workdir', help='Run the service from this directory.', show_default=False),
192]
193
194
195def parse_env(ctx: typer.Context, value: typing.Optional[list[str]]) -> list[str]:
196    """Parse the environment variables from the command line."""
197    if ctx.resilient_parsing or not value:
198        return []
199
200    return [v for val in value for v in val.split(',')]
201
202
203EnvNamesAnnotation: TypeAlias = Annotated[
204    typing.Optional[list[str]],
205    typer.Option(
206        '-e',
207        '--env',
208        help='Embed these environment variables into the unit file. Can be used multiple times.',
209        show_default=False,
210        callback=parse_env,
211        metavar='NAME[,NAME...]',
212    ),
213]
214
215
216class UserGroup(typing.NamedTuple):
217    """Run the service using this user (and optionally group)."""
218
219    user: str
220    """The user to run the service as."""
221
222    group: typing.Optional[str] = None
223    """The group to run the service as."""
224
225    @classmethod
226    def parse(cls, value: str) -> 'UserGroup':
227        """Parse the `--run-as user[:group]` argument for the `systemd` service."""
228        return cls(*value.split(':'))
229
230
231RunAsAnnotation: TypeAlias = Annotated[
232    typing.Optional[UserGroup],
233    typer.Option(
234        '--run-as',
235        help='Configure the systemd unit to run the service as this user (and optionally group).',
236        metavar='user[:group]',
237        parser=UserGroup.parse,
238    ),
239]
240
241
242class Variable(typing.NamedTuple):
243    """Set this variable in the shell used to run the `systemd` service."""
244
245    name: str
246    """The name of the variable."""
247
248    value: str
249    """The value of the variable."""
250
251
252def parse_var(value: str) -> Variable:
253    """Parse the `--var VARIABLE=VALUE` arguments for setting variables in the `systemd` service."""
254    try:
255        parsed = Variable(*value.split('='))
256    except TypeError as exc:
257        rich.print(f'[red]ERROR[/]: Invalid argument (expected [yellow]VARIABLE=VALUE[/] pair): [purple]{value}[/]')
258        raise typer.Exit(1) from exc
259
260    return parsed
261
262
263VariableAnnotation: TypeAlias = Annotated[
264    typing.Optional[list[Variable]],
265    typer.Option(
266        '--var',
267        help='Embed the specified [yellow]VARIABLE=VALUE[/] into the unit file. Can be used multiple times.',
268        metavar='VARIABLE=VALUE',
269        show_default=False,
270        parser=parse_var,
271    ),
272]
273
274
275def configure_logging(ctx: typer.Context, verbose: typing.Optional[bool] = None) -> None:
276    """Callback for the `--verbose` option to configure logging verbosity.
277
278    By default, log messages at the `logging.INFO` level:
279
280    >>> configure_logging(ctx)
281    >>> caplog.messages
282    ['logging verbosity set to [green]INFO[/green]']
283
284    <!-- Clear the `caplog` fixture for the `doctest`, but exclude this from the docs
285    >>> caplog.clear()
286
287    -->
288    When `verbose` is `True`, log messages at the `logging.DEBUG` level:
289
290    >>> configure_logging(ctx, True)
291    >>> caplog.messages
292    ['logging verbosity set to [green]DEBUG[/green]']
293    """
294    if ctx.resilient_parsing:  # pragma: no cover  # this is for tab completions
295        return
296
297    ctx.ensure_object(dict)
298
299    # the `--verbose` argument always overrides previous verbosity settings
300    verbose = verbose or ctx.obj.get('verbose')
301    verbosity = logging.DEBUG if verbose else logging.INFO
302
303    logging_config: schema.DictConfigDefault = ctx.obj.get(
304        'logging_config', copy.deepcopy(settings.DEFAULT_LOGGING_CONFIG)
305    )
306
307    conf: typing.Optional[settings.Config] = ctx.obj.get('settings')
308    new_logging_config: schema.DictConfig = (conf.settings.LOGGING or {}) if conf else {}  # type: ignore[assignment,typeddict-item,unused-ignore]
309
310    for key, value in new_logging_config.items():
311        base = logging_config.get(key, {})
312        if isinstance(base, dict):
313            base.update(value)  # type: ignore[call-overload]
314        else:
315            logging_config[key] = value  # type: ignore[literal-required]
316
317    if verbose:
318        logging_config['root']['level'] = verbosity
319        ctx.obj['verbose'] = verbose
320
321    logging.config.dictConfig(logging_config)  # type: ignore[arg-type]
322
323    ctx.obj['logging_config'] = logging_config
324
325    logger.debug(LOG_VERBOSITY_MESSAGE, logging.getLevelName(verbosity), extra={'markup': True})
326
327
328VerbosityAnnotation = Annotated[
329    typing.Optional[bool],
330    typer.Option(
331        '-v',
332        '--verbose',
333        callback=configure_logging,
334        rich_help_panel='Global',
335        help='Log messages at the [black]DEBUG[/] level.',
336        is_eager=True,
337        show_default=False,
338    ),
339]
340
341
342def version_callback(ctx: typer.Context, value: typing.Optional[bool] = None) -> None:
343    """Print the version of the package."""
344    if ctx.resilient_parsing:  # pragma: no cover  # this is for tab completions
345        return
346
347    if value:
348        rich.print(__version__)
349        raise typer.Exit()
350
351
352VersionAnnotation = Annotated[
353    typing.Optional[bool],
354    typer.Option(
355        '-V',
356        '--version',
357        callback=version_callback,
358        rich_help_panel='Global',
359        show_default=False,
360        is_eager=True,
361        help='Print the version and exit.',
362    ),
363]
364
365
366@contextlib.contextmanager
367def handle_key_errors(objects: dict[str, typing.Any]) -> typing.Iterator[None]:
368    """Handle KeyError exceptions within the managed context."""
369    try:
370        yield
371    except KeyError as exc:  # pragma: no cover
372        rich.print(f'[red]ERROR[/]: Missing key: [green]{exc.args[0]}[/]\n')
373        rich.print(yaml.dump(objects))
374        raise typer.Exit(1) from exc
375
376
377async def poll_all(
378    controllers: list[controller.BackendController], get_or_write: typing.Literal['get', 'write']
379) -> None:
380    """Run the given controllers within an `asyncio` event loop to monitor and apply changes."""
381    await asyncio.gather(*[ctrl.aget(rich.print) if get_or_write == 'get' else ctrl.awrite() for ctrl in controllers])
382
383
384def _check_systemd() -> None:
385    if not systemd.AVAILABLE:
386        rich.print('[red]ERROR[/]: Missing [bold gray93]systemd[/]!')
387        rich.print('Currently, this command only works on linux.')
388        raise typer.Exit(1)
389
390
391# ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
392#                                             command definitions
393
394
395@app.command()
396def get(
397    ctx: typer.Context,
398    keys: OptionalKeyAnnotation = None,
399    poll: PollAnnotation = False,
400    get_help: HelpAnnotation = None,
401    config: ConfigAnnotation = None,
402    verbose: VerbosityAnnotation = None,
403    version: VersionAnnotation = None,
404) -> None:
405    """Print the value of the specified configuration object."""
406    conf: settings.Config = ctx.obj['settings']
407
408    controllers = [
409        controller.BackendController.from_settings(conf, key, handle_key_errors)
410        for key in keys or conf.settings.OBJECTS
411    ]
412
413    if poll:
414        logger.debug(
415            'Begin monitoring (read-only): %s',
416            ', '.join(f'[yellow]{ctrl.key}[/yellow]' for ctrl in controllers),
417            extra={'markup': True},
418        )
419        asyncio.run(poll_all(controllers, 'get'))
420        return
421
422    for ctrl in controllers:
423        logger.debug('Get [yellow]%s[/yellow]: %s', ctrl.key, ctrl, extra={'markup': True})
424        ctrl.get(rich.print)
425
426
427@app.command()
428def apply(
429    ctx: typer.Context,
430    keys: OptionalKeyAnnotation = None,
431    poll: PollAnnotation = False,
432    get_help: HelpAnnotation = None,
433    config: ConfigAnnotation = None,
434    verbose: VerbosityAnnotation = None,
435    version: VersionAnnotation = None,
436) -> None:
437    """Apply the specified configuration to the system."""
438    conf: settings.Config = ctx.obj['settings']
439    controllers = [
440        controller.BackendController.from_settings(conf, key, handle_key_errors)
441        for key in keys or conf.settings.OBJECTS
442    ]
443
444    if poll:
445        rich.print('Begin monitoring: ' + ', '.join(f'[yellow]{ctrl.key}[/yellow]' for ctrl in controllers))
446        asyncio.run(poll_all(controllers, 'write'))
447        return
448
449    for ctrl in controllers:
450        rich.print(f'Apply [yellow]{ctrl.key}[/yellow]: {ctrl}')
451        ctrl.write()
452
453
454@app.command(
455    deprecated=True,
456    short_help='[dim]Apply all configuration objects to the filesystem, and poll for changes.[/] [red](deprecated)[/]',
457    help='Use [bold blue]config-ninja apply --poll[/] instead.',
458)
459def monitor(ctx: typer.Context) -> None:
460    """Apply all configuration objects to the filesystem, and poll for changes."""
461    conf: settings.Config = ctx.obj['settings']
462    controllers = [
463        controller.BackendController.from_settings(conf, key, handle_key_errors) for key in conf.settings.OBJECTS
464    ]
465
466    rich.print('Begin monitoring: ' + ', '.join(f'[yellow]{ctrl.key}[/yellow]' for ctrl in controllers))
467    asyncio.run(poll_all(controllers, 'write'))
468
469
470@app.command()
471def hook(
472    ctx: typer.Context,
473    hook_names: HookAnnotation,
474    get_help: HelpAnnotation = None,
475    config: ConfigAnnotation = None,
476    verbose: VerbosityAnnotation = None,
477    version: VersionAnnotation = None,
478) -> None:
479    """Execute the named hook.
480
481    This command requires the `poe` extra in order to work.
482    """
483    conf: settings.Config = ctx.obj['settings']
484
485    if not conf.engine:
486        fname = ctx.obj.get('settings_file')
487        rich.print(f'[red]ERROR[/]: failed to load hooks from file: [purple]{fname}[/]')
488        raise typer.Exit(1)
489
490    for name in hook_names:
491        conf.engine.get_hook(name)()
492
493
494@self_app.command(name='print')
495def self_print(
496    ctx: typer.Context,
497    get_help: HelpAnnotation = None,
498    config: ConfigAnnotation = None,
499    verbose: VerbosityAnnotation = None,
500    version: VersionAnnotation = None,
501) -> None:
502    """Print [bold blue]config-ninja[/]'s settings."""
503    conf: typing.Optional[settings.Config] = ctx.obj['settings']
504    if not conf:
505        raise typer.Exit(1)
506
507    rich.print(yaml.dump(conf.settings.OBJECTS))
508
509
510@self_app.command()
511def install(
512    ctx: typer.Context,
513    env_names: EnvNamesAnnotation = None,
514    print_only: PrintAnnotation = None,
515    run_as: RunAsAnnotation = None,
516    user_mode: UserAnnotation = False,
517    variables: VariableAnnotation = None,
518    workdir: WorkdirAnnotation = None,
519    get_help: HelpAnnotation = None,
520    config: ConfigAnnotation = None,
521    verbose: VerbosityAnnotation = None,
522    version: VersionAnnotation = None,
523) -> None:
524    """Install [bold blue]config-ninja[/] as a [bold gray93]systemd[/] service.
525
526    Both --env and --var can be passed multiple times.
527
528    Example:
529            config-ninja self install --env FOO,BAR,BAZ --env SPAM --var EGGS=42
530
531    The environment variables [purple]FOO[/], [purple]BAR[/], [purple]BAZ[/], and [purple]SPAM[/] will be read from the current shell and written to the service file, while [purple]EGGS[/] will be set to [yellow]42[/].
532    """
533    environ = {name: os.environ[name] for name in env_names or [] if name in os.environ}
534    environ.update(variables or [])
535
536    settings_file = ctx.obj.get('settings_file')
537    settings_from_arg = ctx.obj.get('settings_from_arg')
538
539    kwargs = {
540        # the command to use when invoking config-ninja from systemd
541        'config_ninja_cmd': sys.argv[0] if sys.argv[0].endswith('config-ninja') else f'{sys.executable} {sys.argv[0]}',
542        # write these environment variables into the systemd service file
543        'environ': environ,
544        # run `config-ninja` from this directory (if specified)
545        'workdir': workdir,
546        'args': f'--config {settings_file}',
547    }
548
549    # override the config file iff it was overridden via the '--config' CLI argument
550    if not settings_from_arg:
551        del kwargs['args']
552
553    if run_as:
554        kwargs['user'] = run_as.user
555        if run_as.group:
556            kwargs['group'] = run_as.group
557
558    svc = systemd.Service('config_ninja', 'systemd.service.j2', user_mode, settings_file if settings_from_arg else None)
559    if print_only:
560        rendered = svc.render(**kwargs)
561        rich.print(Markdown(f'# {svc.path}\n```systemd\n{rendered}\n```'))
562        raise typer.Exit(0)
563
564    _check_systemd()
565
566    rich.print(f'Installing {svc.path}')
567    rich.print(svc.install(**kwargs))
568
569    rich.print('[green]SUCCESS[/] :white_check_mark:')
570
571
572@self_app.command()
573def uninstall(
574    ctx: typer.Context,
575    print_only: PrintAnnotation = None,
576    user: UserAnnotation = False,
577    get_help: HelpAnnotation = None,
578    config: ConfigAnnotation = None,
579    verbose: VerbosityAnnotation = None,
580    version: VersionAnnotation = None,
581) -> None:
582    """Uninstall the [bold blue]config-ninja[/] [bold gray93]systemd[/] service."""
583    settings_file = ctx.obj.get('settings_file') if ctx.obj.get('settings_from_arg') else None
584    svc = systemd.Service('config_ninja', 'systemd.service.j2', user or False, settings_file)
585    if print_only:
586        rich.print(Markdown(f'# {svc.path}\n```systemd\n{svc.read()}\n```'))
587        raise typer.Exit(0)
588
589    _check_systemd()
590
591    rich.print(f'Uninstalling {svc.path}')
592    svc.uninstall()
593    rich.print('[green]SUCCESS[/] :white_check_mark:')
594
595
596@self_app.callback(invoke_without_command=True)
597def self_main(
598    ctx: typer.Context,
599    get_help: HelpAnnotation = None,
600    config: ConfigAnnotation = None,
601    verbose: VerbosityAnnotation = None,
602    version: VersionAnnotation = None,
603) -> None:
604    """Print the help message for the `self` command."""
605    if not ctx.invoked_subcommand:
606        rich.print(ctx.get_help())
607
608
609@app.command()
610def version(
611    ctx: typer.Context,
612    get_help: HelpAnnotation = None,
613    config: ConfigAnnotation = None,
614    verbose: VerbosityAnnotation = None,
615    version: VersionAnnotation = None,
616) -> None:
617    """Print the version and exit."""
618    version_callback(ctx, True)
619
620
621@app.callback(invoke_without_command=True)
622def main(
623    ctx: typer.Context,
624    get_help: HelpAnnotation = None,
625    config: ConfigAnnotation = None,
626    verbose: VerbosityAnnotation = None,
627    version: VersionAnnotation = None,
628) -> None:
629    """Manage operating system configuration files based on data in the cloud."""
630    ctx.ensure_object(dict)
631
632    if not ctx.invoked_subcommand:  # pragma: no cover
633        rich.print(ctx.get_help())
634
635
636logger.debug('successfully imported %s', __name__)
app = <typer.main.Typer object>

The root typer application.

@app.command()
def apply( ctx: typer.models.Context, keys: Annotated[Optional[list[str]], <typer.models.ArgumentInfo object>] = None, poll: Annotated[Optional[bool], <typer.models.OptionInfo object>] = False, get_help: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None, config: Annotated[Optional[pathlib.Path], <typer.models.OptionInfo object>] = None, verbose: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None, version: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None) -> None:
428@app.command()
429def apply(
430    ctx: typer.Context,
431    keys: OptionalKeyAnnotation = None,
432    poll: PollAnnotation = False,
433    get_help: HelpAnnotation = None,
434    config: ConfigAnnotation = None,
435    verbose: VerbosityAnnotation = None,
436    version: VersionAnnotation = None,
437) -> None:
438    """Apply the specified configuration to the system."""
439    conf: settings.Config = ctx.obj['settings']
440    controllers = [
441        controller.BackendController.from_settings(conf, key, handle_key_errors)
442        for key in keys or conf.settings.OBJECTS
443    ]
444
445    if poll:
446        rich.print('Begin monitoring: ' + ', '.join(f'[yellow]{ctrl.key}[/yellow]' for ctrl in controllers))
447        asyncio.run(poll_all(controllers, 'write'))
448        return
449
450    for ctrl in controllers:
451        rich.print(f'Apply [yellow]{ctrl.key}[/yellow]: {ctrl}')
452        ctrl.write()

Apply the specified configuration to the system.

@app.command()
def get( ctx: typer.models.Context, keys: Annotated[Optional[list[str]], <typer.models.ArgumentInfo object>] = None, poll: Annotated[Optional[bool], <typer.models.OptionInfo object>] = False, get_help: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None, config: Annotated[Optional[pathlib.Path], <typer.models.OptionInfo object>] = None, verbose: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None, version: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None) -> None:
396@app.command()
397def get(
398    ctx: typer.Context,
399    keys: OptionalKeyAnnotation = None,
400    poll: PollAnnotation = False,
401    get_help: HelpAnnotation = None,
402    config: ConfigAnnotation = None,
403    verbose: VerbosityAnnotation = None,
404    version: VersionAnnotation = None,
405) -> None:
406    """Print the value of the specified configuration object."""
407    conf: settings.Config = ctx.obj['settings']
408
409    controllers = [
410        controller.BackendController.from_settings(conf, key, handle_key_errors)
411        for key in keys or conf.settings.OBJECTS
412    ]
413
414    if poll:
415        logger.debug(
416            'Begin monitoring (read-only): %s',
417            ', '.join(f'[yellow]{ctrl.key}[/yellow]' for ctrl in controllers),
418            extra={'markup': True},
419        )
420        asyncio.run(poll_all(controllers, 'get'))
421        return
422
423    for ctrl in controllers:
424        logger.debug('Get [yellow]%s[/yellow]: %s', ctrl.key, ctrl, extra={'markup': True})
425        ctrl.get(rich.print)

Print the value of the specified configuration object.

@self_app.command()
def install( ctx: typer.models.Context, env_names: Annotated[Optional[list[str]], <typer.models.OptionInfo object>] = None, print_only: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None, run_as: Annotated[Optional[config_ninja.cli.UserGroup], <typer.models.OptionInfo object>] = None, user_mode: Annotated[bool, <typer.models.OptionInfo object>] = False, variables: Annotated[Optional[list[config_ninja.cli.Variable]], <typer.models.OptionInfo object>] = None, workdir: Annotated[Optional[pathlib.Path], <typer.models.OptionInfo object>] = None, get_help: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None, config: Annotated[Optional[pathlib.Path], <typer.models.OptionInfo object>] = None, verbose: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None, version: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None) -> None:
511@self_app.command()
512def install(
513    ctx: typer.Context,
514    env_names: EnvNamesAnnotation = None,
515    print_only: PrintAnnotation = None,
516    run_as: RunAsAnnotation = None,
517    user_mode: UserAnnotation = False,
518    variables: VariableAnnotation = None,
519    workdir: WorkdirAnnotation = None,
520    get_help: HelpAnnotation = None,
521    config: ConfigAnnotation = None,
522    verbose: VerbosityAnnotation = None,
523    version: VersionAnnotation = None,
524) -> None:
525    """Install [bold blue]config-ninja[/] as a [bold gray93]systemd[/] service.
526
527    Both --env and --var can be passed multiple times.
528
529    Example:
530            config-ninja self install --env FOO,BAR,BAZ --env SPAM --var EGGS=42
531
532    The environment variables [purple]FOO[/], [purple]BAR[/], [purple]BAZ[/], and [purple]SPAM[/] will be read from the current shell and written to the service file, while [purple]EGGS[/] will be set to [yellow]42[/].
533    """
534    environ = {name: os.environ[name] for name in env_names or [] if name in os.environ}
535    environ.update(variables or [])
536
537    settings_file = ctx.obj.get('settings_file')
538    settings_from_arg = ctx.obj.get('settings_from_arg')
539
540    kwargs = {
541        # the command to use when invoking config-ninja from systemd
542        'config_ninja_cmd': sys.argv[0] if sys.argv[0].endswith('config-ninja') else f'{sys.executable} {sys.argv[0]}',
543        # write these environment variables into the systemd service file
544        'environ': environ,
545        # run `config-ninja` from this directory (if specified)
546        'workdir': workdir,
547        'args': f'--config {settings_file}',
548    }
549
550    # override the config file iff it was overridden via the '--config' CLI argument
551    if not settings_from_arg:
552        del kwargs['args']
553
554    if run_as:
555        kwargs['user'] = run_as.user
556        if run_as.group:
557            kwargs['group'] = run_as.group
558
559    svc = systemd.Service('config_ninja', 'systemd.service.j2', user_mode, settings_file if settings_from_arg else None)
560    if print_only:
561        rendered = svc.render(**kwargs)
562        rich.print(Markdown(f'# {svc.path}\n```systemd\n{rendered}\n```'))
563        raise typer.Exit(0)
564
565    _check_systemd()
566
567    rich.print(f'Installing {svc.path}')
568    rich.print(svc.install(**kwargs))
569
570    rich.print('[green]SUCCESS[/] :white_check_mark:')

Install [bold blue]config-ninja[/] as a [bold gray93]systemd[/] service.

Both --env and --var can be passed multiple times.

Example:

config-ninja self install --env FOO,BAR,BAZ --env SPAM --var EGGS=42

The environment variables [purple]FOO[/], [purple]BAR[/], [purple]BAZ[/], and [purple]SPAM[/] will be read from the current shell and written to the service file, while [purple]EGGS[/] will be set to [yellow]42[/].

@app.callback(invoke_without_command=True)
def main( ctx: typer.models.Context, get_help: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None, config: Annotated[Optional[pathlib.Path], <typer.models.OptionInfo object>] = None, verbose: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None, version: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None) -> None:
622@app.callback(invoke_without_command=True)
623def main(
624    ctx: typer.Context,
625    get_help: HelpAnnotation = None,
626    config: ConfigAnnotation = None,
627    verbose: VerbosityAnnotation = None,
628    version: VersionAnnotation = None,
629) -> None:
630    """Manage operating system configuration files based on data in the cloud."""
631    ctx.ensure_object(dict)
632
633    if not ctx.invoked_subcommand:  # pragma: no cover
634        rich.print(ctx.get_help())

Manage operating system configuration files based on data in the cloud.

@app.command(deprecated=True, short_help='[dim]Apply all configuration objects to the filesystem, and poll for changes.[/] [red](deprecated)[/]', help='Use [bold blue]config-ninja apply --poll[/] instead.')
def monitor(ctx: typer.models.Context) -> None:
455@app.command(
456    deprecated=True,
457    short_help='[dim]Apply all configuration objects to the filesystem, and poll for changes.[/] [red](deprecated)[/]',
458    help='Use [bold blue]config-ninja apply --poll[/] instead.',
459)
460def monitor(ctx: typer.Context) -> None:
461    """Apply all configuration objects to the filesystem, and poll for changes."""
462    conf: settings.Config = ctx.obj['settings']
463    controllers = [
464        controller.BackendController.from_settings(conf, key, handle_key_errors) for key in conf.settings.OBJECTS
465    ]
466
467    rich.print('Begin monitoring: ' + ', '.join(f'[yellow]{ctrl.key}[/yellow]' for ctrl in controllers))
468    asyncio.run(poll_all(controllers, 'write'))

Apply all configuration objects to the filesystem, and poll for changes.

@self_app.command(name='print')
def self_print( ctx: typer.models.Context, get_help: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None, config: Annotated[Optional[pathlib.Path], <typer.models.OptionInfo object>] = None, verbose: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None, version: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None) -> None:
495@self_app.command(name='print')
496def self_print(
497    ctx: typer.Context,
498    get_help: HelpAnnotation = None,
499    config: ConfigAnnotation = None,
500    verbose: VerbosityAnnotation = None,
501    version: VersionAnnotation = None,
502) -> None:
503    """Print [bold blue]config-ninja[/]'s settings."""
504    conf: typing.Optional[settings.Config] = ctx.obj['settings']
505    if not conf:
506        raise typer.Exit(1)
507
508    rich.print(yaml.dump(conf.settings.OBJECTS))

Print [bold blue]config-ninja[/]'s settings.

@self_app.command()
def uninstall( ctx: typer.models.Context, print_only: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None, user: Annotated[bool, <typer.models.OptionInfo object>] = False, get_help: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None, config: Annotated[Optional[pathlib.Path], <typer.models.OptionInfo object>] = None, verbose: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None, version: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None) -> None:
573@self_app.command()
574def uninstall(
575    ctx: typer.Context,
576    print_only: PrintAnnotation = None,
577    user: UserAnnotation = False,
578    get_help: HelpAnnotation = None,
579    config: ConfigAnnotation = None,
580    verbose: VerbosityAnnotation = None,
581    version: VersionAnnotation = None,
582) -> None:
583    """Uninstall the [bold blue]config-ninja[/] [bold gray93]systemd[/] service."""
584    settings_file = ctx.obj.get('settings_file') if ctx.obj.get('settings_from_arg') else None
585    svc = systemd.Service('config_ninja', 'systemd.service.j2', user or False, settings_file)
586    if print_only:
587        rich.print(Markdown(f'# {svc.path}\n```systemd\n{svc.read()}\n```'))
588        raise typer.Exit(0)
589
590    _check_systemd()
591
592    rich.print(f'Uninstalling {svc.path}')
593    svc.uninstall()
594    rich.print('[green]SUCCESS[/] :white_check_mark:')

Uninstall the [bold blue]config-ninja[/] [bold gray93]systemd[/] service.

@app.command()
def version( ctx: typer.models.Context, get_help: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None, config: Annotated[Optional[pathlib.Path], <typer.models.OptionInfo object>] = None, verbose: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None, version: Annotated[Optional[bool], <typer.models.OptionInfo object>] = None) -> None:
610@app.command()
611def version(
612    ctx: typer.Context,
613    get_help: HelpAnnotation = None,
614    config: ConfigAnnotation = None,
615    verbose: VerbosityAnnotation = None,
616    version: VersionAnnotation = None,
617) -> None:
618    """Print the version and exit."""
619    version_callback(ctx, True)

Print the version and exit.