Guides#

load() vs. load_settings()#

Typed Settings exposes two functions for loading settings: load() and load_settings(). The former is designed to make the common use cases easy. The latter makes special cases possible and lets you configure everything in detail.

load()#

  • Uses the file and environment variable loader.

  • Only supports TOML files.

  • Derives settings for loaders from your appname (but some settings can be overridden).

  • Uses a default Cattrs converter.

load_settings()#

  • You need to explicitly pass the list of loaders.

  • You need to explicitly configure each loader.

  • You can pass a custom/extended Cattrs converter.

Note

load(cls, ...) is basically the same as load_settings(cls, default_loaders(...), default_converter()).

Settings from Environment Variables#

Typed Settings loads environment variables that match {PREFIX}{OPTION_NAME}.

PREFIX is an option for the EnvLoader. It should be UPPER_CASE and end with an _, but this is not enforced. PREFIX can also be an empty string.

If you use load() (or default_loaders()), PREFIX is derived from the appname argument. For example, "appname" becomes "APPNAME_". You can override it with the env_prefix argument. You can also completely disable environment variable loading by setting env_prefix to None.

Values loaded from environment variables are strings. They are converted to the type specified in the settings class via a Cattrs converter. The default_converter() supports the most common types like booleans, dates, enums and paths.

Warning

Never pass secrets via environment variables!

It’s far easier for environment variables to leak outside than for config files. You may, for example, accidentally leak your env via your CI/CD pipeline, or you may be affected by a security incident for which you can’t do anything.

Write your secret to a file and pass its path via a variable like MYAPP_API_TOKEN_FILE=/private/token (instead of just MYAPP_API_TOKEN="3KX93ad...") to your app. Alternatively, store it in a structured config file that you directly load with Typed Settings.

Nested settings#

Settings classes can be nested but environment variables have a flat namespace. So Typed Settings builds a flat list of all option and uses the “dotted path” to an attribute (e.g., attrib.nested_attrib.nested_nested_attrib) for mapping flat names to nested attributes.

Here’s an example:

>>> import os
>>> import typed_settings as ts
>>>
>>> @ts.settings
... class Nested:
...     attrib1: int = 0
...     attrib2: bool = True
>>>
>>> @ts.settings
... class Settings:
...     nested: Nested = Nested()
...     attrib: str = ""
>>>
>>> os.environ["MYAPP_ATTRIB"] = "spam"
>>> os.environ["MYAPP_NESTED_ATTRIB1"] = "42"
>>> os.environ["MYAPP_NESTED_ATTRIB2"] = "0"
>>>
>>> ts.load(Settings, "myapp")
Settings(nested=Nested(attrib1=42, attrib2=False), attrib='spam')

Warning

Settings should not define an attribute nested_attrib1 as it would conflict with nested.attrib1. If you added this attribute to the example above, the value 42 would be assigned to both options.

Overriding the var name for a single option#

Sometimes, you may want to read an option from another variable than Typed Settings would normally do. For example, you company’s convention might be to use SSH_PRIVATE_KEY_FILE, but your app would look for MYAPP_SSH_KEY_FILE:

>>> @ts.settings
... class Settings:
...     ssh_key_file: str = ""
>>>
>>> ts.load(Settings, "myapp")
Settings(ssh_key_file='')

In order to read from the desired env var, you can use os.getenv() and assign its result as default for your option:

>>> import os
>>>
>>> os.environ["SSH_PRIVATE_KEY_FILE"] = "/run/private/id_ed25519"
>>>
>>> @ts.settings
... class Settings:
...     ssh_key_file: str = os.getenv("SSH_PRIVATE_KEY_FILE", "")
>>>
>>> ts.load(Settings, "myapp")
Settings(ssh_key_file='/run/private/id_ed25519')

Working with Config Files#

Besides environment variables, configuration files are another basic way to configure applications.

There are several locations where configuration files are usually stored:

  • In the system’s main configuration directory (e.g., /etc/myapp/settings.toml)

  • In your users’ home (e.g., ~/.config/myapp.toml or ~/.myapp.toml)

  • In your project’s root directory (e.g., ~/Projects/myapp/pyproject.toml)

  • In your current working directory

  • At a location pointed to by an environment variable (e.g., MYAPP_SETTINGS=/run/private/secrets.toml)

As you can see, there are many possibilities and depending on your app, any of them may make sense (or not).

That’s why Typed Settings has no default search paths for config files but lets you very flexibly configure them:

  • You can specify a static list of search paths

  • You can search for specific files at runtime

  • You can specify search paths at runtime via an environment variable

When multiple files are configured, Typed Settings loads every file that it finds. Each file that is loaded updates the settings that have been loaded so far.

Optional and Mandatory Config Files#

Config files – no matter how they are configured – are optional by default. That means that no error is raised if some (or all) of the files do not exist:

>>> @ts.settings
... class Settings:
...     option1: str = "default"
...     option2: str = "default"
>>>
>>> # Not an error:
>>> ts.load(Settings, "myapp", config_files=["/spam"])
Settings(option1='default', option2='default')

You can mark files as mandatory by prefixing them with !:

>>> # Raises an error:
>>> ts.load(Settings, "myapp", config_files=["!/spam"])
Traceback (most recent call last):
...
FileNotFoundError: [Errno 2] No such file or directory: '/spam'

Static Search Paths#

You can pass a static list of files to load() and FileLoader(). Paths can be strings or instances of pathlib.Path. If multiple files are found, they are loaded from left to right. That means that the last file has the highest precedence.

The following example first loads a global configuration file and overrides it with user specific settings:

>>> from pathlib import Path
>>>
>>> @ts.settings
... class Settings:
...     option: str = ""
>>>
>>> config_files = [
...     "/etc/myapp/settings.toml",
...     Path.home().joinpath(".config", "myapp.toml"),
... ]
>>> ts.load(Settings, "myapp", config_files)
Settings(option='')

Note

You should not hard-code configuration directories like /etc or ~/.config. The library platformdirs (a friendly fork of the inactive Appdirs) determines the correct paths depending on the user’s operating system.

Finding Files at Runtime#

Especially tools that are used for software development (i.e. linters or code formatters) search for their configuration in the current (Git) project.

The function find() does exactly that: It searches for a given filename from the current working directory upwards until it hits a defined stop directory or file. By default it stops when the current directory contains a .git or .hg folder. When the file is not found, it returns ./filename.

You can append the pathlib.Path that this function returns to the list of static config files as described in the section above:

>>> @ts.settings
... class Settings:
...     option: str = ""
>>>
>>> config_files = [
...     Path.home().joinpath(".config", "mylint.toml"),
...     ts.find("mylint.toml"),
... ]
>>> ts.load(Settings, "mylint", config_files)
Settings(option='')

Using pyproject.toml#

Since Typed Settings supports TOML files out-of-the box, you may wish to use pyproject.toml for your tool’s configuration.

There are just two things you need to do:

To demonstrate this, we’ll first create a “fake project” and change our working directory to its src directory:

>>> # Create a "project" in a temp. directory
>>> config = """[tool.myapp]
... option = "spam"
... """
>>> project_dir = getfixture("tmp_path")
>>> project_dir.joinpath("src").mkdir()
>>> project_dir.joinpath("pyproject.toml").write_text(config)
29
>>> # Change to the "src" dir of our "porject"
>>> monkeypatch = getfixture("monkeypatch")
>>> with monkeypatch.context() as m:
...     m.chdir(project_dir / "src")
...

Now, we should be able to find the pyproject.toml and load our settings from it:

...     ts.load(
...          Settings,
...          "myapp",
...          [ts.find("pyproject.toml")],
...          config_file_section="tool.myapp",
...     )
Settings(option='spam')

Dynamic Search Paths via Environment Variables#

Sometimes, you don’t know the location of your configuration files in advance. Sometimes, you don’t even know where to search for them. This may, for example, be the case when your app runs in a container and the configuration files are mounted to an arbitrary location inside the container.

For these cases, Typed Settings can read search paths for config files from an environment variable. If you use load(), its name is derived from the appname argument and is APPNAME_SETTINGS.

Multiple paths are separated by :, similarly to the PATH variable. However, in contrast to PATH, all existing files are loaded one after another:

>>> @ts.settings
... class Settings:
...     option1: str = "default"
...     option2: str = "default"
>>>
>>> # Create two config files and expose their paths via an env var
>>> project_dir = getfixture("tmp_path")
>>> f1 = project_dir.joinpath("conf1.toml")
>>> f1.write_text("""[myapp]
... option1 = "spam"
... option2 = "spam"
... """)
42
>>> f2 = project_dir.joinpath("conf2.toml")
>>> f2.write_text("""[myapp]
... option1 = "eggs"
... """)
25
>>> with monkeypatch.context() as m:
...     # Export the env var that holds the paths to our config files
...     m.setenv("MYAPP_SETTINGS", f"{f1}:{f2}")
...
...     # Loading the files from the env var is enabled by default
...     ts.load(Settings, "myapp")
Settings(option1='eggs', option2='spam')

You can override the default using the config_files_var argument (or env_var if you use the FileLoader directly):

>>> with monkeypatch.context() as m:
...     m.setenv("MY_SETTINGS", str(f2))
...     ts.load(Settings, "myapp", config_files_var="MY_SETTINGS")
Settings(option1='eggs', option2='default')

If you set it to None, loading filenames from an environment variable is disabled:

>>> with monkeypatch.context() as m:
...     m.setenv("MYAPP_SETTINGS", f"{f1}:{f2}")
...     ts.load(Settings, "myapp", config_files_var=None)
Settings(option1='default', option2='default')

Config File Precedence#

Typed-Settings loads all files that it finds and merges their contents with all previously loaded settings.

The list of static files (passed to load() or FileLoader) is always loaded first. The files specified via an environment variable are loaded afterwards:

>>> with monkeypatch.context() as m:
...     m.setenv("MYAPP_SETTINGS", f"loaded_3rd.toml:loaded_4th.toml")
...     s = ts.load(Settings, "myapp", ["loaded_1st.toml", ts.find("loaded_2nd.toml")])

Dynamic Options#

The benefit of class based settings is that you can use properties to create “dynamic” or “aggregated” settings.

Imagine, you want to configure the URL for a REST API but the only part that usually changes with every deployment is the hostname.

For these cases, you can make each part of the URL configurable and create a property that returns the full URL:

>>> @ts.settings
... class ServiceConfig:
...     scheme: str = "https"
...     host: str = "example.com"
...     port: int = 443
...     path: Path() = Path("api")
...
...     @property
...     def url(self) -> str:
...         return f"{self.scheme}://{self.host}:{self.port}/{self.path}"
...
>>> print(ServiceConfig().url)
https://example.com:443/api

Another use case is loading data from files, e.g., secrets like SSH keys:

>>> from pathlib import Path
>>>
>>> @ts.settings
... class SSH:
...     key_file: Path
...
...     @property
...     def key(self) -> str:
...         return self.key_file.read_text()
...
>>> key_file = getfixture("tmp_path").joinpath("id_1337")
>>> key_file.write_text("le key")
6
>>> print(SSH(key_file=key_file).key)
le key

Adding Support for Additional File Types#

The function load() uses a FileLoader that only supports TOML files. However, the supported file formats are not hard-coded but can be configured and extended.

If you use load_settings(), you can (and must) pass a custom FileLoader instance that can be configured with loaders for different file formats.

Before we start, we’ll need a setting class and Pyton config file:

>>> @ts.settings
... class Settings:
...     option1: str = "default"
...     option2: str = "default"
>>>
>>> py_file = getfixture("tmp_path").joinpath("conf.py")
>>> py_file.write_text("""
... class MYAPP:
...     OPTION1 = "spam"
... """)
35

We now create our loaders configuration. The formats argument expects a dictionary that maps glob patterns to file format loaders:

>>> from typed_settings.loaders import PythonFormat, TomlFormat
>>>
>>> file_loader = ts.FileLoader(
...     formats={
...         "*.toml": TomlFormat(section="myapp"),
...         "*.py": PythonFormat("MYAPP", key_transformer=PythonFormat.to_lower),
...     },
...     files=[py_file],
...     env_var=None,
... )

Now we can load settings from Python files:

>>> ts.load_settings(Settings, loaders=[file_loader])
Settings(option1='spam', option2='default')

Writing Your Own File Format Loader#

File format loaders must implement the FileFormat protocol:

  • They have to be callables (i.e., functions or a classes with a __call__() method).

  • They have to accept a Path, the user’s settings class and a list of typed_settings.types.OptionInfo instances.

  • They have to return a dictionary with the loaded settings.

Why return a dict and not a settings instance?

(File format) loaders return a dictionary with loaded settings instead of instances of the user’s settings class.

The reason for this is simply, that dicts can easier be created and merged than class instances.

Typed Settings validates and cleans the settings of all loaders automatically and converts them to instances of your settings class. So there’s no need for you to do it on your own in your loader.

A very simple JSON loader could look like this:

>>> import json
>>>
>>> def load_json(path, _settings_cls, _options):
...     return json.load(path.open())

If you want to use this in production, you should add proper error handling and documentation, though. You can take the TomlFormat as an example.

Using your file format loader works like in the example above:

>>> json_file = getfixture("tmp_path").joinpath("conf.json")
>>> json_file.write_text('{"option1": "spam", "option2": "eggs"}')
38
>>>
>>> file_loader = ts.FileLoader(
...     formats={"*.json": load_json},
...     files=[json_file],
...     env_var=None,
... )
>>> ts.load_settings(Settings, loaders=[file_loader])
Settings(option1='spam', option2='eggs')

Writing Custom Loaders#

When you want to load settings from a completely new source, you can implement your own Loader (which is similar – but not equal – to FileFormat):

  • It has to be a callable (i.e., a function or a class with a __call__() method).

  • It has to accept the user’s settings class and a list of typed_settings.types.OptionInfo instances.

  • It has to return a dictionary with the loaded settings.

In the following example, we’ll write a class that loads settings from an instance of the settings class. This maybe useful for libraries that want to give using applications the possibility to specify application specific defaults for that library.

This time, we need some setup, because the settings instance has to be passed when we configure our loaders. When the settings are actually loaded and our InstanceLoader is invoked, it converts the instances to a dictionary with settings and returns it:

>>> import attrs
>>>
>>> class InstanceLoader:
...     def __init__(self, instance):
...         self.instance = instance
...
...     def __call__(self, settings_cls, options):
...         if not isinstance(self.instance, settings_cls):
...             raise ValueError(
...                 f'"self.instance" is not an instance of {settings_cls}: '
...                 f"{type(self.instance)}"
...             )
...         return attrs.asdict(self.instance)

Using the new loader works the same way as we’ve seen before:

>>> inst_loader = InstanceLoader(Settings("a", "b"))
>>> ts.load_settings(Settings, loaders=[inst_loader])
Settings(option1='a', option2='b')

Tip

Classes with just an __init__() and a single method can also be implemented as partial functions:

>>> from functools import partial
>>>
>>> def load_from_instance(instance, settings_cls, options):
...     if not isinstance(instance, settings_cls):
...         raise ValueError(
...             f'"instance" is not an instance of {settings_cls}: '
...             f"{type(instance)}"
...         )
...     return attrs.asdict(instance)
...
>>> inst_loader = partial(load_from_instance, Settings("a", "b"))
>>> ts.load_settings(Settings, loaders=[inst_loader])
Settings(option1='a', option2='b')

Note

The InstanceLoader was added to Typed Settings in version 1.0.0 but we’ll keep this example.

Command Line Arguments with Click#

You can generate Click command line options for your settings. These let the users of your application override settings loaded from other sources (like config files).

The general algorithm for generating a Click CLI for your settings looks like this:

  1. You decorate a Click command with click_options().

  2. The decorator will immediately (namely, at module import time)

    • load your settings (e.g., from config files or env vars),

    • create a click.option() for each setting and use the loaded settings value as default for that option.

  3. You add a positional/keyword argument to your CLI function.

  4. When you run your CLI, the decorator :

    • updates the settings with option values from the command line,

    • stores the settings instance in the Click context object (see click.Context.obj),

    • passes the updated settings instances as positional/keyword argument to your CLI function.

Note

By default, the settings are passed as positional argument. You can optionally specify a keyword argument name if you want your settings to be passed as keyword argument.

See Order of Decorators and Settings as Keyword Arguments for details about argument passing.

Take this minimal example:

>>> import click
>>> import typed_settings as ts
>>>
>>> monkeypatch.setenv("EXAMPLE_SPAM", "23")
>>>
>>> @ts.settings
... class Settings:
...     spam: int = 42
...
>>> @click.command()
... @ts.click_options(Settings, "example")
... def cli(settings: Settings):
...     print(settings)

As you can see, an option is generated for each setting:

>>> import click.testing
>>>
>>> runner = click.testing.CliRunner()
>>> print(runner.invoke(cli, ["--help"]).output)
Usage: cli [OPTIONS]

Options:
  --spam INTEGER  [default: 23]
  --help          Show this message and exit.

>>> print(runner.invoke(cli, ["--spam=3"]).output)
Settings(spam=3)

The code above is roughly equivalent to:

>>> @ts.settings
... class Settings:
...     spam: int = 42
...
>>> defaults = ts.load(Settings, "example")
>>>
>>> @click.command()
... @click.option("--spam", type=int, default=defaults.spam, show_default=True)
... def cli(spam: int):
...     print(spam)
...
>>> print(runner.invoke(cli, ["--help"]).output)
Usage: cli [OPTIONS]

Options:
  --spam INTEGER  [default: 23]
  --help          Show this message and exit.

>>> print(runner.invoke(cli, ["--spam=3"]).output)
3

The major difference between the two is that Typed Settings passes the complete settings instances and not individual options.

Customizing the Generated Options#

Typed Settings does its best to generate the Click option in the most sensible way. However, you can override everything if you want to.

Changing the Param Decls#

Typed Settings generate a single param declaration for each option: --option-name. One reason you might want to change this is to add an additional short version (e.g., -o):

>>> @ts.settings
... class Settings:
...     spam: int = ts.option(default=23, click={"param_decls": ("--spam", "-s")})
...
>>> @click.command()
... @ts.click_options(Settings, "example")
... def cli(settings: Settings):
...     print(settings)

>>> print(runner.invoke(cli, ["--help"]).output)
Usage: cli [OPTIONS]

Options:
  -s, --spam INTEGER  [default: 23]
  --help              Show this message and exit.

>>> print(runner.invoke(cli, ["-s", "3"]).output)
Settings(spam=3)

Tuning Boolean Flags#

Another use case is changing how binary flags for bool() typed options are generated. By default, Typed Settings generates --flag/--no-flag.

But imagine this example, where our flag is always False and we only want to allow users to enable it:

>>> @ts.settings
... class Settings:
...     flag: bool = False

We can achieve this by providing a custom param decl. and the is_flag option:

>>> @ts.settings
... class Settings:
...     flag: bool = ts.option(
...         default=False,
...         help='Turn "flag" on.',
...         click={"param_decls": ("--on", "flag"), "is_flag": True},
...     )
...
>>> @click.command()
... @ts.click_options(Settings, "example")
... def cli(settings: Settings):
...     print(settings)

>>> print(runner.invoke(cli, ["--help"]).output)
Usage: cli [OPTIONS]

Options:
  --on    Turn "flag" on.
  --help  Show this message and exit.

>>> print(runner.invoke(cli, ["--on"]).output)
Settings(flag=True)

>>> print(runner.invoke(cli, []).output)
Settings(flag=False)

Note, that we added the param decl. flag in addition to --on. This is required for Click to map the flag to the correct option. We would not need that if we named our flag --flag.

Configuring Loaders and Converters#

When you just pass an application name to click_options() (as in the example above), it uses default_loaders() to get the default loaders and default_converter() to get the default converter.

Instead of passing an app name, you can pass your own list of loaders to click_options():

>>> # Only load envs vars, no config files
>>> loaders = ts.default_loaders(
...     appname="example",
...     config_files=(),
...     config_files_var=None,
... )
>>> @click.command()
... @ts.click_options(Settings, loaders)
... def cli(settings: Settings):
...     pass

In a similar fashion, you can use your own converter:

>>> converter = ts.default_converter()
>>> # converter.register_structure_hook(my_type, my_converter)
>>>
>>> @click.command()
... @ts.click_options(Settings, "example", converter=converter)
... def cli(settings: Settings):
...     pass

Passing Settings to Sub-Commands#

One of Click’s main advantages is that it makes it quite easy to create CLIs with sub commands (think of Git).

If you want to load your settings once in the main command and make them accessible in all subcommands, you can use the pass_settings() decorator. It searches all context objects from the current one via all parent context until it finds a settings instances and passes it to the decorated command:

>>> @ts.settings
... class Settings:
...     spam: int = 42
...
>>> @click.group()
... @ts.click_options(Settings, "example")
... def cli(settings: Settings):
...     pass
>>>
>>> @cli.command()
... @ts.pass_settings
... def sub_cmd(settings: Settings):
...     click.echo(settings)
>>> print(runner.invoke(cli, ["--spam=3", "sub-cmd"]).output)
Settings(spam=3)

Note

The example above only works well if either:

  • Only the parent group loads settings

  • Only concrete commands load settings

This is because the settings instance is stored in the click.Context.obj with a fixed key.

If you want your sub-commands to additonally load their own settings, please continue to read the next two setions.

Order of Decorators#

Click passes the settings instance to your CLI function as positional argument by default. If you use other decorators that behave similarly (e.g., click.pass_context()), the order of decorators and arguments matters.

The innermost decorator (the one closest to the def) will be passed as first argument, The second-innermost as second argument and so forth:

>>> @click.command()
... @ts.click_options(Settings, loaders)
... @click.pass_context
... def cli(ctx: click.Context, settings: Settings):
...     print(ctx, settings)
...
>>> print(runner.invoke(cli, []).stdout)
<click.core.Context object at 0x...> Settings(spam=23)

Settings as Keyword Arguments#

If a command wants to load multiple types of settings or if you use command groups where both, the parent group and its sub commands, want to load settings, then the “store a single settings instance ans pass it as positional argument” approach no longer works.

Instead, you need to specify an argname for click_options() and pass_settings(). The settings instance is then stored under that key in the click.Context.obj and passed as keyword argument to the decorated function:

>>> @ts.settings
... class CmdSettings:
...     eggs: str = ""
>>>
>>> @click.group()
... @ts.click_options(Settings, "example", argname="main_settings")
... @click.pass_obj
... def cli(ctx_obj: dict, *, main_settings: Settings):
...     # "main_settings" is now a keyword argument
...     # It is stored in the ctx object under the same key
...     print(main_settings is ctx_obj["main_settings"])
>>>
>>> @cli.command()
... # Require the parent group's settings as "main_settings"
... @ts.pass_settings(argname="main_settings")
... # Define command specific settings as "cmd_settings"
... @ts.click_options(CmdSettings, "example-cmd", argname="cmd_settings")
... def cmd(*, main_settings: Settings, cmd_settings: CmdSettings):
...     print(main_settings)
...     print(cmd_settings)
>>>
>>> print(runner.invoke(cli, ["--spam=42", "cmd", "--eggs=many"]).stdout)
True
Settings(spam=42)
CmdSettings(eggs='many')

Help!#

As you may have noticed in the examples above, the generated options were lacking a proper help string. You can add one via ts.option() and ts.secret():

>>> @ts.settings
... class Settings:
...     spam: int = ts.option(default=23, help="Amount of SPAM required")
...
>>> @click.command()
... @ts.click_options(Settings, "example")
... def cli(settings: Settings):
...     print(settings)
...
>>> print(runner.invoke(cli, ["--help"]).output)
Usage: cli [OPTIONS]

Options:
  --spam INTEGER  Amount of SPAM required  [default: 23]
  --help          Show this message and exit.

Extending supported types#

Typed Settings and it’s Click utilities support the data types for the most common use cases out-of-the-box (in fact, it was quite hard to come up with an example that makes at least some sense …;-)).

But let’s assume you have a dataclass class that represents an RGB color and you want to use a single command line option for it (like --color R G B).

>>> import attrs
>>> import dataclasses
>>>
>>> @dataclasses.dataclass
... class RGB:
...     r: int = 0
...     g: int = 0
...     b: int = 0
...
>>> @ts.settings
... class Settings:
...     color: RGB = RGB(0, 0, 0)

Note

If we used attrs instead of dataclasses here, Typed Settings would automatically generate three options --color-r, --color-g, and --color-b.

Since Cattrs has no built-in support for dataclasses, we need to register a converter for it:

>>> converter = ts.default_converter()
>>> converter.register_structure_hook(
...     RGB, lambda val, cls: val if isinstance(val, RGB) else cls(*val)
... )

Typed Settings uses a TypeHandler to generate type specific arguments for click.option(). The TypeHandler takes a dictionary that maps Python types to handler functions. These functions receive that type and the default value for the option. They return a dictionary with keyword arguments for click.option().

For our use case, we need an int options that takes exactly three arguments and has the metavar R G B. If (and only if) there is a default value for our option, we want to use it.

>>> from typed_settings.click_utils import DEFAULT_TYPES, StrDict, TypeHandler
>>>
>>> def handle_rgb(_type: type, default: object) -> StrDict:
...     type_info = {
...         "type": int,
...         "nargs": 3,
...         "metavar": "R G B",
...     }
...     if default is not attrs.NOTHING:
...         type_info["default"] = dataclasses.astuple(default)
...     return type_info

We now update the dict with built-in type handlers with our own and create a new TypeHandler instance with it:

>>> type_dict = {
...     **DEFAULT_TYPES,
...     RGB: handle_rgb,
... }
>>> type_handler = TypeHandler(type_dict)

Finally, we need to pass the type handler as well as our updated converter to click_options() and we are ready to go:

>>> @click.command()
... @ts.click_options(Settings, "example", converter, type_handler=type_handler)
... def cli(settings: Settings):
...     print(settings)
...
>>> # Check if our metavar and default value is used:
>>> print(runner.invoke(cli, ["--help"]).output)
Usage: cli [OPTIONS]

Options:
  --color R G B  [default: 0, 0, 0]
  --help         Show this message and exit.

>>> # Try passing our own color:
>>> print(runner.invoke(cli, "--color 23 42 7".split()).output)
Settings(color=RGB(r=23, g=42, b=7))

The way described above should be sufficient for most extensions. However, if you need to achieve something more complicated, like adding support for new kinds of container types, you can also sub-class TypeHandler().