Skip to content

Commands to manage application settings

Typical applications built with Typer are essentially stateless. That is, to control their behavior, you need to provide all of the configuration for the app through the use of positional arguments, options, and environment variables.

For a complex application with many commands, this can be frustrating and slow. You find yourself passing the same parameters over and over.

Thus, typerdrive provides a settings subcommand to help with this.

Overview

The typerdrive package provides functionality to store, reuse, and update application settings through a set of subcommands. These subcommands are bound to your app under the settings subcommand. These subcommands manipulate the your app's settings and allow your other commands to access the settings values via the @attach_settings decorator.

Let's take a look at how we can use this powerful feature set.

Usage

Let's start by looking at a code example:

from typing import Annotated

import typer
from pydantic import AfterValidator, BaseModel
from snick import unwrap
from typerdrive import add_settings_subcommand, attach_settings, set_typerdrive_config


def valid_alignment(value: str) -> str:
    if value not in ["good", "neutral", "evil"]:
        raise ValueError(f"{value} is an invalid alignment")
    return value


class SettingsModel(BaseModel):
    name: str
    planet: str
    is_humanoid: bool = True
    alignment: Annotated[str, AfterValidator(valid_alignment)] = "neutral"


cli = typer.Typer()
add_settings_subcommand(cli, SettingsModel)
set_typerdrive_config(app_name="settings-attach-example")


@cli.command()
@attach_settings(SettingsModel)
def report(ctx: typer.Context, cfg: SettingsModel):
    print(
        unwrap(
            f"""
            Look at this {cfg.alignment} {cfg.name} from {cfg.planet}
            {"walking" if cfg.is_humanoid else "slithering"} by.
            """
        )
    )


if __name__ == "__main__":
    cli()

In this example, the app provides a Pydantic model that describes all of the settings values that the app needs. Then, the app calls the add_settings_subcommand() to add the settings feature to the CLI. That's all you need to utilize the settings feature in your app. Now, you can access and manage your settings through the various settings subcommands.

In the report command, you can see how the settings values may be accessed within one of the app's other commands. The @attach_settings decorator adds the settings object to the app's typer.Context. Then, the settings can be accessed by providing a parameter to the command that matches the SettingsModel type. The argument that will get the settings object can be named anything you like!

Settings model type agreement

The type of the pydantic model passed to @attach_settings() MUST match the type used for the settings parameter of the command function. If the types do not match, a Typer exception will be raised saying that Typer doesn't know how to handle the argument.

Great, now let's try a few commands in this app to see how the settings commands work.

First, we will just show the config

$ python examples/settings/commands.py settings show

╭─ Current settings ──────────────────────────────────────────────────────────────────────────╮
│                                                                                             │
│   is-humanoid -> True                                                                       │
│     alignment -> neutral                                                                    │
│                                                                                             │
│   Configuration is invalid:                                                                 │
│          name -> Field required                                                             │
│        planet -> Field required                                                             │
│                                                                                             │
╰─────────────────────────────────────────────────────────────────────────────────────────────╯

As you can see, our settings initially just matches the defaults provided in the settings model. The fields that still need to be defined are clearly identified and the settings are shown to be invalid.

Next, let's make the settings valid by setting the missing values with bind:

╭─ Current settings ──────────────────────────────────────────────────────────────────────────╮
│                                                                                             │
│          name -> jawa                                                                       │
│        planet -> tatooine                                                                   │
│   is-humanoid -> True                                                                       │
│     alignment -> neutral                                                                    │
│                                                                                             │
╰─ saved to /home/dusktreader/.local/share/commands.py/settings.json ─────────────────────────╯

Now, the settings are valid. You can also see that the settings were saved to disk for your app to use in future commands.

Let's make an adjustment to the settings using the update command:

$ python examples/settings/commands.py settings update --name=hutt --no-is-humanoid

╭─ Current settings ──────────────────────────────────────────────────────────────────────────╮
│                                                                                             │
│          name -> hutt                                                                       │
│        planet -> tatooine                                                                   │
│   is-humanoid -> False                                                                      │
│     alignment -> neutral                                                                    │
│                                                                                             │
╰─ saved to /home/dusktreader/.local/share/commands.py/settings.json ─────────────────────────╯

Notice that the update command only changed the values specified and left the others alone.

Now that we're happy with our settings, lets run our report command to try out using these app settings:

$ python examples/settings/commands.py report
Look at this neutral hutt from tatooine slithering by.

Great! Our app is able to use the settings in any command!

Finally, let's clear out the settings with reset:

╭─ Current settings ──────────────────────────────────────────────────────────────────────────╮
│                                                                                             │
│   is-humanoid -> True                                                                       │
│     alignment -> neutral                                                                    │
│                                                                                             │
│   Configuration is invalid:                                                                 │
│          name -> Field required                                                             │
│        planet -> Field required                                                             │
│                                                                                             │
╰─ saved to /home/dusktreader/.local/share/commands.py/settings.json ─────────────────────────╯

Now, all the settings are returned to their initial values. Those that have no default values are now invalid.

Details

Let's take a closer look at details of each settings subcommand.

bind

The bind command is used to set all your app settings at once. It is very similar to the update command with a few key differences. First, the bind command will not allow you to have an invalid configuration when it is done. It will require each settings value without a default to be explicitly set. After you have provided the values through command options, the final configuration will be validated before it is saved.

Like the other settings subcommands that modify the settings, bind will write a settings file to disk when it is finished. The settings file is stored in ~/.local/share/<your-app-name>/settings.json. If the parent directories for this file don't exist, they will be created.

Not supported on Windows

Currently, the typerdrive settings commands are only configured to work on Linux and MacOS. I have plans to add support for Windows as well eventually, but at the moment typerdrive is dependent on settings being stored below ~/.local/share

Each settings value from the settings model you provide is mapped to a CLI option for the bind subcommand. If the value has a default in the model, then the option will use the same default. Boolean values use the normal convention from Typer with --flag or --no-flag controlling the value of the boolean.

The help text from our example above for the bind subcommand looks like this:

$ python examples/settings/commands.py settings bind --help

 Usage: commands.py settings bind [OPTIONS]

╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ *  --name                               TEXT  [default: None] [required]                                                                                                                                                                                                                │
│ *  --planet                             TEXT  [default: None] [required]                                                                                                                                                                                                                │
│    --is-humanoid    --no-is-humanoid          [default: is-humanoid]                                                                                                                                                                                                                    │
│    --alignment                          TEXT  [default: neutral]                                                                                                                                                                                                                        │
│    --help                                     Show this message and exit.                                                                                                                                                                                                               │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

update

The update command is used to update a subset of the available settings values. It works very similarly to the bind command, however, the update command will allow your configuration to be invalid when it is finished. This might be useful if you want to establish some values in your settings now but need to look something up before you are finished configuring the app.

Like the other subcommands that modify settings, update will save all changes to disk.

Each settings value from the settings model is mapped to an optional CLI option for the update subcommand. If the settings value is a boolean, it will use the --flag / --no-flag format. All other commands will default to None if they are not passed and the update command will ignore them.

The help text from our example above for the update subcommand looks like this:

$ python examples/settings/commands.py settings update --help

 Usage: commands.py settings update [OPTIONS]

╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --name                               TEXT  [default: None]                                                                                                                                                                                                                              │
│ --planet                             TEXT  [default: None]                                                                                                                                                                                                                              │
│ --is-humanoid    --no-is-humanoid          [default: is-humanoid]                                                                                                                                                                                                                       │
│ --alignment                          TEXT  [default: None]                                                                                                                                                                                                                              │
│ --help                                     Show this message and exit.                                                                                                                                                                                                                  │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

Notice how now all the options have a default.

unset

The unset command is used to return a settings value to its initial state. If the value has a default, it will be set to that value. If it does not have a default, it will simply be removed. Like the update subcommand, unset allows the settings to be in invalid state.

Each settings value from the settings model is mapped to a CLI option that takes no value. If you supply the option, then the corresponding setting value will be unset.

The help text from our example above for the unset subcommand looks like this:

$ python examples/settings/commands.py settings unset --help

 Usage: commands.py settings unset [OPTIONS]

╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --name                                                                                                                                                                                                                                                                                  │
│ --planet                                                                                                                                                                                                                                                                                │
│ --is-humanoid                                                                                                                                                                                                                                                                           │
│ --alignment                                                                                                                                                                                                                                                                             │
│ --help                 Show this message and exit.                                                                                                                                                                                                                                      │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

show

The show command just shows the current value of the settings. That's it!

reset

The reset command returns all settings values to their initial state. It allows the settings to be in an invalid state when it is finished. It will also show the new settings values when it is done. The reset subcommand takes no arguments.

The get_settings() functions

In order for typerdrive to provide the settings through an argument to the command function, we have to tap into a bit of Python and Typer's "mystical energy field". If you want to use something more direct, you can access the settings object using the get_settings() function to extract it from the typer.Context instead. Rewriting the report() command to use the get_settings() function would look like this:

@cli.command()
@attach_settings(SettingsModel)
def report(ctx: typer.Context):
    cfg = get_settings(ctx, SettingsModel)
    print(
        unwrap(
            f"""
            Look at this {cfg.alignment} {cfg.name} from {cfg.planet}
            {'walking' if cfg.is_humanoid else 'slithering'} by.
            """
        )
    )

The type_hint argument to get_settings()

Because the model is bound to the settings commands dynamically, the get_settings() function needs a type hint to cast it to the appropriate model type. This type_hint argument must match with the settings model that was attached or an exception will be raised.

The add_.*() functions

The typerdrive.settings.commands module has several add_.*() functions. These work by adding a subcommand to the CLI app that is passed in. In general, you only need to use the add_settings_subcommand() in your app. However, if you want to customize where the settings subcommands appear, you may call the other add_.*() functions directly

add_bind()

This method adds the bind subcommand to the provided CLI app. It uses the build_command() function to dynamically create a command and then adds it to the cli argument.

Function Reference

add_update()

This method adds the update subcommand to the provided CLI app. It uses the build_command() function to dynamically create a command and then adds it to the cli argument.

Function Reference

add_unset()

This method adds the unset subcommand to the provided CLI app. It uses the build_command() function to dynamically create a command and then adds it to the cli argument.

Function Reference

add_show()

This method adds the show subcommand to the provided CLI app. It uses the build_command() function to dynamically create a command and then adds it to the cli argument.

Function Reference

add_reset()

This method adds the reset subcommand to the provided CLI app. It uses the build_command() function to dynamically create a command and then adds it to the cli argument.

Function Reference

add_settings_subcommand()

This method does three things:

  • Creates a new Typer app
  • Adds all the settings subcommands to the new app
  • Adds the new app as a subcommand of the Typer CLI that you provide

The result is that all the subcommands are available under one settings subcommand.

Function Reference