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

The root typer application.

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

Apply the specified configuration to the system.

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

Print the value of the specified configuration object.

@self_app.command()
def install( ctx: typer.models.Context, env_names: Annotated[list[str] | None, <typer.models.OptionInfo object>] = None, print_only: Annotated[bool | None, <typer.models.OptionInfo object>] = None, run_as: Annotated[config_ninja.cli.UserGroup | None, <typer.models.OptionInfo object>] = None, user_mode: Annotated[bool, <typer.models.OptionInfo object>] = False, variables: Annotated[list[config_ninja.cli.Variable] | None, <typer.models.OptionInfo object>] = None, workdir: Annotated[pathlib.Path | None, <typer.models.OptionInfo object>] = None, get_help: Annotated[bool | None, <typer.models.OptionInfo object>] = None, config: Annotated[pathlib.Path | None, <typer.models.OptionInfo object>] = None, verbose: Annotated[bool | None, <typer.models.OptionInfo object>] = None, version: Annotated[bool | None, <typer.models.OptionInfo object>] = None) -> None:
504@self_app.command()
505def install(
506    ctx: typer.Context,
507    env_names: EnvNamesAnnotation = None,
508    print_only: PrintAnnotation = None,
509    run_as: RunAsAnnotation = None,
510    user_mode: UserAnnotation = False,
511    variables: VariableAnnotation = None,
512    workdir: WorkdirAnnotation = None,
513    get_help: HelpAnnotation = None,
514    config: ConfigAnnotation = None,
515    verbose: VerbosityAnnotation = None,
516    version: VersionAnnotation = None,
517) -> None:
518    """Install [bold blue]config-ninja[/] as a [bold gray93]systemd[/] service.
519
520    Both --env and --var can be passed multiple times.
521
522    Example:
523            config-ninja self install --env FOO,BAR,BAZ --env SPAM --var EGGS=42
524
525    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[/].
526    """
527    environ = {name: os.environ[name] for name in env_names or [] if name in os.environ}
528    environ.update(variables or [])
529
530    settings_file = ctx.obj.get('settings_file')
531    settings_from_arg = ctx.obj.get('settings_from_arg')
532
533    kwargs = {
534        # the command to use when invoking config-ninja from systemd
535        'config_ninja_cmd': sys.argv[0] if sys.argv[0].endswith('config-ninja') else f'{sys.executable} {sys.argv[0]}',
536        # write these environment variables into the systemd service file
537        'environ': environ,
538        # run `config-ninja` from this directory (if specified)
539        'workdir': workdir,
540        'args': f'--config {settings_file}',
541    }
542
543    # override the config file iff it was overridden via the '--config' CLI argument
544    if not settings_from_arg:
545        del kwargs['args']
546
547    if run_as:
548        kwargs['user'] = run_as.user
549        if run_as.group:
550            kwargs['group'] = run_as.group
551
552    svc = systemd.Service('config_ninja', 'systemd.service.j2', user_mode, settings_file if settings_from_arg else None)
553    if print_only:
554        rendered = svc.render(**kwargs)
555        rich.print(Markdown(f'# {svc.path}\n```systemd\n{rendered}\n```'))
556        raise typer.Exit(0)
557
558    _check_systemd()
559
560    rich.print(f'Installing {svc.path}')
561    rich.print(svc.install(**kwargs))
562
563    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[bool | None, <typer.models.OptionInfo object>] = None, config: Annotated[pathlib.Path | None, <typer.models.OptionInfo object>] = None, verbose: Annotated[bool | None, <typer.models.OptionInfo object>] = None, version: Annotated[bool | None, <typer.models.OptionInfo object>] = None) -> None:
615@app.callback(invoke_without_command=True)
616def main(
617    ctx: typer.Context,
618    get_help: HelpAnnotation = None,
619    config: ConfigAnnotation = None,
620    verbose: VerbosityAnnotation = None,
621    version: VersionAnnotation = None,
622) -> None:
623    """Manage operating system configuration files based on data in the cloud."""
624    ctx.ensure_object(dict)
625
626    if not ctx.invoked_subcommand:  # pragma: no cover
627        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:
448@app.command(
449    deprecated=True,
450    short_help='[dim]Apply all configuration objects to the filesystem, and poll for changes.[/] [red](deprecated)[/]',
451    help='Use [bold blue]config-ninja apply --poll[/] instead.',
452)
453def monitor(ctx: typer.Context) -> None:
454    """Apply all configuration objects to the filesystem, and poll for changes."""
455    conf: settings.Config = ctx.obj['settings']
456    controllers = [
457        controller.BackendController.from_settings(conf, key, handle_key_errors) for key in conf.settings.OBJECTS
458    ]
459
460    rich.print('Begin monitoring: ' + ', '.join(f'[yellow]{ctrl.key}[/yellow]' for ctrl in controllers))
461    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[bool | None, <typer.models.OptionInfo object>] = None, config: Annotated[pathlib.Path | None, <typer.models.OptionInfo object>] = None, verbose: Annotated[bool | None, <typer.models.OptionInfo object>] = None, version: Annotated[bool | None, <typer.models.OptionInfo object>] = None) -> None:
488@self_app.command(name='print')
489def self_print(
490    ctx: typer.Context,
491    get_help: HelpAnnotation = None,
492    config: ConfigAnnotation = None,
493    verbose: VerbosityAnnotation = None,
494    version: VersionAnnotation = None,
495) -> None:
496    """Print [bold blue]config-ninja[/]'s settings."""
497    conf: settings.Config | None = ctx.obj['settings']
498    if not conf:
499        raise typer.Exit(1)
500
501    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[bool | None, <typer.models.OptionInfo object>] = None, user: Annotated[bool, <typer.models.OptionInfo object>] = False, get_help: Annotated[bool | None, <typer.models.OptionInfo object>] = None, config: Annotated[pathlib.Path | None, <typer.models.OptionInfo object>] = None, verbose: Annotated[bool | None, <typer.models.OptionInfo object>] = None, version: Annotated[bool | None, <typer.models.OptionInfo object>] = None) -> None:
566@self_app.command()
567def uninstall(
568    ctx: typer.Context,
569    print_only: PrintAnnotation = None,
570    user: UserAnnotation = False,
571    get_help: HelpAnnotation = None,
572    config: ConfigAnnotation = None,
573    verbose: VerbosityAnnotation = None,
574    version: VersionAnnotation = None,
575) -> None:
576    """Uninstall the [bold blue]config-ninja[/] [bold gray93]systemd[/] service."""
577    settings_file = ctx.obj.get('settings_file') if ctx.obj.get('settings_from_arg') else None
578    svc = systemd.Service('config_ninja', 'systemd.service.j2', user or False, settings_file)
579    if print_only:
580        rich.print(Markdown(f'# {svc.path}\n```systemd\n{svc.read()}\n```'))
581        raise typer.Exit(0)
582
583    _check_systemd()
584
585    rich.print(f'Uninstalling {svc.path}')
586    svc.uninstall()
587    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[bool | None, <typer.models.OptionInfo object>] = None, config: Annotated[pathlib.Path | None, <typer.models.OptionInfo object>] = None, verbose: Annotated[bool | None, <typer.models.OptionInfo object>] = None, version: Annotated[bool | None, <typer.models.OptionInfo object>] = None) -> None:
603@app.command()
604def version(
605    ctx: typer.Context,
606    get_help: HelpAnnotation = None,
607    config: ConfigAnnotation = None,
608    verbose: VerbosityAnnotation = None,
609    version: VersionAnnotation = None,
610) -> None:
611    """Print the version and exit."""
612    version_callback(ctx, True)

Print the version and exit.