Configuration files

The structure of the configuration file is automatically derived from the parameters of the CLI and their types. There is no need to manually produce a configuration data structure to mirror the CLI.

Tip

After loading, the resolved file path, the full parsed document, and (when a config_schema is set) the typed app section are exposed on ctx.meta as CONF_SOURCE, CONF_FULL, and TOOL_CONFIG. See the available keys table to read them from your own callbacks.

Resolving a configuration file

Before any value is read, Click Extra decides which file, if any, provides the configuration. An explicit --config (or its environment variable or interactive prompt) wins outright. Otherwise autodiscovery applies: pyproject.toml is searched from the current directory up to the VCS root, then the app-dir search pattern takes over. The first file that parses to a non-empty mapping is used, with no merging across files.

        flowchart TD
    start(["@config_option resolves a pattern"]) --> nc{"autodiscovery disabled?"}
    nc -->|yes| skip["Skip loading, use bare defaults"]
    nc -->|no| exp{"--config, env or prompt set?"}
    exp -->|"no, auto-discover"| pyp{"pyproject.toml format enabled?"}
    pyp -->|yes| cwd{"tool.cli table in a pyproject.toml, CWD up to VCS root?"}
    cwd -->|yes| usepyp["Use that tool.cli section"]
    cwd -->|no| search["Search files matching the pattern, try formats in order"]
    pyp -->|no| search
    exp -->|yes| search
    search --> parse{"a file parses to a non-empty config?"}
    parse -->|yes| win["First match wins, no merging"]
    parse -->|"no, explicit"| fail["Exit with code 2"]
    parse -->|"no, auto-discover"| defaults["Use bare defaults"]
    

Once a file is selected, its values feed into the precedence chain below: environment variables, CLI parameters, and interactive prompts all override what the file provides.

Standalone option

The @config_option decorator provided by Click Extra can be used as-is with vanilla Click:

from click import group, option, echo
from click_extra import config_option

@group(context_settings={"show_default": True})
@option("--dummy-flag/--no-flag")
@option("--my-list", multiple=True)
@config_option
def my_cli(dummy_flag, my_list):
    echo(f"dummy_flag    is {dummy_flag!r}")
    echo(f"my_list       is {my_list!r}")

@my_cli.command
@option("--int-param", type=int, default=10)
def subcommand(int_param):
    echo(f"int_parameter is {int_param!r}")

The code above is saved into a file named my_cli.py.

It produces the following help screen:

$ my-cli --help
Usage: my-cli [OPTIONS] COMMAND [ARGS]...

Options:
  --dummy-flag / --no-flag  [default: no-flag]
  --my-list TEXT
  --config CONFIG_PATH      Location of the configuration file. Supports local
                            path with glob patterns or remote URL.  [default:
                            ~/.config/my-cli/{*.toml,*.yaml,*.yml,*.json,*.json5
                            ,*.jsonc,*.hjson,*.ini,*.xml,pyproject.toml}]
  --help                    Show this message and exit.

Commands:
  subcommand

See in the result above, there is an explicit mention of the default location of the configuration file ([default: ~/.config/my-cli/{*.toml,*.yaml,*.yml,*.json,*.json5,*.jsonc,*.hjson,*.ini,*.xml,pyproject.toml}]). This improves discoverability, and makes sysadmins happy, especially those not familiar with your CLI.

A bare call returns:

$ my-cli subcommand
dummy_flag    is False
my_list       is ()
int_parameter is 10

With a simple TOML file in the application folder, we will change the CLI’s default output.

Here is what ~/.config/my-cli/config.toml contains:

~/.config/my-cli/config.toml
# My default configuration file.
top_level_param = "is_ignored"

[my-cli]
extra_value = "is ignored too"
dummy_flag = true                                  # New boolean default.
my_list = ["item 1", "item #2", "Very Last Item!"]

[garbage]
# An empty random section that will be skipped.

[my-cli.subcommand]
int_param = 3
random_stuff = "will be ignored"

In the file above, pay attention to:

  • the default configuration base path, which is OS-dependant (the ~/.config/my-cli/ path here is for Linux) ;

  • the app’s folder (/my-cli/) which is built from the script’s name (my_cli.py);

  • the top-level config section ([my-cli]), based on the CLI’s group ID (def my_cli());

  • all the extra comments, sections and values that will be silently ignored.

Now we can verify the configuration file is properly read and change the defaults:

$ my-cli subcommand
dummy_flag    is True
my_list       is ('item 1', 'item #2', 'Very Last Item!')
int_parameter is 3

Dotted keys

Configuration files support dotted keys as a shorthand for nested structures. Instead of writing:

Nested structure
[my-cli.subcommand]
int_param = 3

You can write:

Dotted key equivalent
[my-cli]
"subcommand.int_param" = 3

Both forms are equivalent. You can also freely mix them in the same file:

Mixed dotted and nested keys in JSON
{
    "my-cli": {
        "dummy_flag": true,
        "subcommand.int_param": 3,
        "subcommand": {
            "other_param": "value"
        }
    }
}

Dotted keys are expanded into nested dicts and deep-merged before the configuration is applied. This works across all supported formats, and at any nesting depth (for example, "subcommand.nested.option" expands to three levels).

Hint

This is especially handy in formats like JSON that have no native section syntax, letting you keep a flat structure when the nesting would be excessive.

Merge rules

When dotted keys and nested structures target the same leaf, the last one in file order wins:

Last value wins
{
    "my-cli": {
        "subcommand": {"int_param": 3},
        "subcommand.int_param": 77
    }
}

Here int_param resolves to 77 because the dotted key appears after the nested one.

Conflicts

A conflict occurs when the same key is used as both a scalar and a namespace. For example:

Conflicting types on the same key
{
    "my-cli": {
        "subcommand": "some_value",
        "subcommand.int_param": 3
    }
}

Here subcommand is a plain string, but subcommand.int_param requires it to be a dict. By default, Click Extra logs a warning and the last value wins: in this case, subcommand becomes {"int_param": 3}, silently dropping "some_value".

In strict mode, conflicts and invalid dotted keys raise a ValueError instead of being silently resolved.

The same conflict detection applies at deeper levels:

Deep conflict
{
    "my-cli": {
        "subcommand.int_param.nested": 1,
        "subcommand.int_param": 2
    }
}

Here int_param is set to both {"nested": 1} (via the first key) and 2 (via the second). A warning is logged and int_param resolves to 2.

Note

Most formats prevent these conflicts at parse time (TOML rejects a key used as both a scalar and a table, YAML forbids duplicate keys), so in practice this mainly affects JSON.

Invalid dotted keys

Dotted keys with empty segments (leading, trailing, or consecutive dots) are skipped with a warning:

Invalid keys that are skipped
{
    "my-cli": {
        ".option": 1,
        "option.": 2,
        "sub..option": 3
    }
}

All three keys above are ignored. Use --verbosity WARNING or higher to see the warnings. In strict mode, they raise a ValueError.

Precedence

The configuration loader fetch values according the following precedence:

        flowchart TD
    P["Interactive prompt"] -->|unset| C["CLI parameters"]
    C -->|unset| E["Environment variables"]
    E -->|unset| F["Configuration file"]
    F -->|unset| D["Defaults"]
    

The parameter will take the first value set in that chain.

Configuration file values are loaded into Click’s default_map, so they are reported as DEFAULT_MAP and sit below environment variables in the hierarchy.

See how inline parameters takes priority on defaults from the previous example:

$ my-cli subcommand --int-param 555
dummy_flag    is True
my_list       is ('item 1', 'item #2', 'Very Last Item!')
int_parameter is 555

Get configuration values

After gathering all the configuration from the different sources, and assembling them together following the precedence rules above, the configuration values are merged back into the Context’s default_map. But only the values that are matching the CLI’s parameters are kept and passed as defaults. All others are silently ignored.

You can still access the full configuration by looking into the context’s meta attribute:

from click_extra import option, echo, pass_context, command, config_option


@command
@option("--int-param", type=int, default=10)
@config_option
@pass_context
def my_cli(ctx, int_param):
    echo(f"Configuration location: {ctx.meta['click_extra.conf_source']}")
    echo(f"Full configuration: {ctx.meta['click_extra.conf_full']}")
    echo(f"Default values: {ctx.default_map}")
    echo(f"int_param is {int_param!r}")
./conf.toml
[my-cli]
int_param = 3
random_stuff = "will be ignored"

[garbage]
dummy_flag = true
$ my-cli --config ./conf.toml --int-param 999
Load configuration matching ./conf.toml
Configuration location: /home/me/conf.toml
Full configuration: {'my-cli': {'int_param': 3, 'random_stuff': 'will be ignored'}, 'garbage': {'dummy_flag': True}}
Default values: {'int_param': 3}
int_parameter is 999

Hint

Variables in meta are presented in their original Python type:

  • click_extra.conf_source is either a normalized Path or URL object

  • click_extra.conf_full is a dict whose values are either str or richer types, depending on the capabilities of each format

Coerce a config dict into a dataclass

When you load configuration yourself (or expose a [tool.<name>] section consumers fill in), make_schema_callable(MyDataclass) returns a callable that turns a raw dict into a validated MyDataclass instance. It is the same machinery config_option and get_tool_config use under the hood: hyphenated keys are normalized to field names, dotted click_extra.config_path field metadata is honored, and nested dataclasses are coerced recursively.

from dataclasses import dataclass
from click_extra import make_schema_callable


@dataclass
class Forecast:
    city: str = "paris"
    high_c: int = 0


load = make_schema_callable(Forecast)
load({"city": "lyon", "high-c": 21})  # Forecast(city="lyon", high_c=21)

Pass strict=True to reject keys that match no field. A non-dataclass callable (a Pydantic .model_validate, say) is returned unchanged, and None passes through.

Strictness

As you can see in the first example above, all unrecognized content is ignored.

If for any reason you do not want to allow any garbage in configuration files provided by the user, you can use the strict argument.

Given this cli.toml file:

cli.toml
[cli]
int_param = 3
random_param = "forbidden"

The use of strict=True parameter in the CLI below:

from click import command, option, echo

from click_extra import config_option

@command
@option("--int-param", type=int, default=10)
@config_option(strict=True)
def cli(int_param):
    echo(f"int_parameter is {int_param!r}")

Will stop the CLI execution on the unrecognized random_param value, before the command runs:

$ cli --config "cli.toml"
Load configuration matching cli.toml
Configuration validation error: Parameter 'random_param' found in second dict but not in first.

The error is reported at critical level and the process exits with code 1, the same failure mode as --validate-config and the extension validators. All three share the single ValidationError type.

Tip

If you want to check a configuration file for unrecognized keys without running the CLI, see the --validate-config option below.

Tip

Strict mode rejects every key it doesn’t recognize as a CLI flag, which is the right default for most apps but breaks sub-tables whose keys are data rather than flag names (per-plugin overrides, matrix axes, user-defined IDs). The Extending validation section covers how to declare such sub-trees as passthrough and route them to your own validator.

Validating configuration files

The @validate_config_option decorator adds a --validate-config CONFIG_PATH option that checks whether a configuration file is well-formed and contains only recognized parameters, then exits. This is useful for CI pipelines, editor integrations, or simply verifying a configuration file before deploying it.

Reusing the standalone option example above:

from click import group, option, echo
from click_extra import config_option, validate_config_option

@group
@option("--dummy-flag/--no-flag")
@option("--my-list", multiple=True)
@config_option
@validate_config_option
def my_cli(dummy_flag, my_list):
    echo(f"dummy_flag    is {dummy_flag!r}")
    echo(f"my_list       is {my_list!r}")

@my_cli.command
@option("--int-param", type=int, default=10)
def subcommand(int_param):
    echo(f"int_parameter is {int_param!r}")

A valid configuration file:

good.toml
[my-cli]
dummy_flag = true
my_list = ["pip", "npm"]

[my-cli.subcommand]
int_param = 3
$ my-cli --validate-config good.toml
Configuration file good.toml is valid.
$ echo $?
0

A configuration file with unrecognized keys:

bad.toml
[my-cli]
dummy_flag = true
unknown_key = "oops"
$ my-cli --validate-config bad.toml
Configuration validation error: Parameter 'unknown_key' found in second dict but not in first.
$ echo $?
1

An unparsable file produces exit code 2:

$ my-cli --validate-config garbage.txt
Error parsing garbage.txt as TOML, YAML, JSON, INI, XML or pyproject.toml.
$ echo $?
2

The exit codes are:

Exit code

Meaning

0

Configuration file is valid

1

Validation error (unrecognized keys)

2

File not found or cannot be parsed

Note

--validate-config always validates in strict mode, regardless of the strict setting on @config_option. It requires a sibling @config_option decorator to be present on the same command.

Exporting the configuration

The @export_config_option decorator adds a --export-config FORMAT option that resolves the CLI’s current configuration and writes it to <stdout> as a ready-to-use configuration file, then exits. It is part of the default options of every @command and @group, so click-extra CLIs ship with it out of the box.

The values are resolved through the usual precedence chain: command-line parameters override environment variables, which override an autodiscovered configuration file, which overrides the defaults. So combining --export-config with other options or environment variables captures them in the generated configuration, which makes it a convenient way to freeze the current invocation into a file or to produce a starting-point template.

from click_extra import command, echo, option

@command
@option("--city", default="Lisbon")
@option("--temperature", type=int, default=18)
@option("--tags", multiple=True, default=("sunny",))
def weather(city, temperature, tags):
    echo(f"{city}: {temperature}C {tags!r}")

A bare export renders every configurable parameter, including click-extra’s own built-in options:

$ weather --export-config toml
[weather]
city = "Lisbon"
temperature = 18
tags = ["sunny"]
time = false
accessible = false
color = "auto"
no_color = false
progress = true
theme = "dark"
table_format = "rounded-outline"
verbosity = "WARNING"
verbose = 0
quiet = 0
man = false

Any value set on the command line (or via an environment variable) is reflected in the export, so the output can be saved straight into a configuration file:

$ weather --city Oslo --temperature 4 --export-config toml
[weather]
city = "Oslo"
temperature = 4
tags = ["sunny"]
time = false
accessible = false
color = "auto"
no_color = false
progress = true
theme = "dark"
table_format = "rounded-outline"
verbosity = "WARNING"
verbose = 0
quiet = 0
man = false

Redirect the output to your configuration file to persist it:

$ weather --city Oslo --export-config toml > ~/.config/weather/config.toml

The accepted formats are the ones click-extra can serialize: toml, yaml, json, json5, jsonc, hjson and xml. ini and pyproject.toml have no serializer and cannot be exported. A format whose optional dependency is missing exits with code 1 and an install hint.

Note

--export-config is itself excluded from the export, like the other introspection options (--help, --version, --show-params). It requires a sibling @config_option decorator to be present on the same command.

Extending validation

--validate-config and the runtime strict check both speak the language of CLI parameters: every recognized key must correspond to a flag on the command tree. That works for configurations that mirror the CLI one-to-one, but breaks the moment your app declares its own sub-tables whose keys are data, not flag names. The user-defined IDs under [my-cli.managers.<id>], the matrix axes in [my-cli.test-matrix.<axis>], the plugin names in [my-cli.plugins.<plugin>]: none of these are CLI options, so click-extra’s strict mode rightfully refuses to accept them.

Click-extra’s answer is to declare such sub-tables as extension points. Each extension point names a dotted path in the app’s configuration section and pairs with a ConfigValidator that owns the validation logic for it. Click-extra’s machinery treats the path as a passthrough: the strict check skips it, the dataclass schema doesn’t descend into it, and the contents arrive at the app’s validator verbatim. The result is one validation surface that covers both halves: click-extra checks the CLI-flag-bound keys, the app checks its own extension content, and --validate-config reports every failure with the same path-rooted error type.

Tip

Three terms describe the same mechanism from three angles, and you’ll see all of them in this documentation and in the click-extra source:

  • Extension is what the app does: declare a sub-tree that lives outside click-extra’s schema and validate it through your own logic. This is the public-facing name (ConfigValidator, EXTENSION_METADATA_KEY).

  • Passthrough is what click-extra does: let the extension sub-tree flow through the strict-check, normalize, and flatten stages without inspection. Use this term when describing how data moves through the pipeline.

  • Opaque is what the pipeline sees: a path it must not descend into. The internal helpers (_collect_opaque_paths_from_schema, the opaque_keys parameter on normalize_config_keys/flatten_config_keys, the _opaque_paths cache on ConfigOption) all use this term. Same set of paths, viewed from the inside.

All three vocabularies refer to the same dotted-path set. Extension is what you write in your app code, passthrough is what you’d say to explain the behavior, opaque is what you’ll search for when reading click-extra’s source.

Declaring an extension point

The most ergonomic way is to add a dict[str, X]-typed field to your dataclass schema. Click-extra recognizes mapping-typed fields automatically and treats them as extension points without further annotation:

from dataclasses import dataclass, field

@dataclass
class AppConfig:
    """Schema for my-cli."""
    verbose: bool = False
    managers: dict[str, dict] = field(default_factory=dict)

The managers field is now an extension point at the dotted path managers (relative to the app’s section). A configuration like:

[my-cli]
verbose = true

[my-cli.managers.winget]
search_path = ["C:\\Program Files\\WindowsApps"]

[my-cli.managers.brew]
timeout = 600

passes through both the CLI-flag strict check (which sees verbose and ignores everything under managers) and the schema’s typed instantiation (which receives managers as a single dict, not flattened into managers_winget_search_path etc.).

When the underlying Python type isn’t a mapping (for example, a nested dataclass that still represents extension content), mark the field explicitly with EXTENSION_METADATA_KEY:

from click_extra import EXTENSION_METADATA_KEY

@dataclass
class AppConfig:
    plugins: list = field(
        default_factory=list,
        metadata={EXTENSION_METADATA_KEY: True},
    )

The metadata flag and the dict[str, X] type hint are interchangeable for declaring opacity. Use whichever matches your schema’s natural shape.

Registering a validator

A ConfigValidator binds an extension_path to a callable that inspects the sub-tree and raises ValidationError on failure. Pass a tuple of validators through the config_validators= kwarg on @group or @config_option:

from dataclasses import dataclass, field

from click_extra import (
    ConfigValidator,
    ValidationError,
    config_option,
    group,
    option,
)

ALLOWED_KEYS = frozenset({"timeout", "search_path"})


def validate_managers(section: dict) -> None:
    """Lint the [my-cli.managers.<id>] sub-tree."""
    for manager_id, fields in section.items():
        for key in fields:
            if key not in ALLOWED_KEYS:
                raise ValidationError(
                    f"{manager_id}.{key}",
                    f"unknown field {key!r}",
                    code="unknown_field",
                )


@dataclass
class AppConfig:
    managers: dict[str, dict] = field(default_factory=dict)


@group(
    config_schema=AppConfig,
    config_validators=(
        ConfigValidator(
            extension_path="managers",
            validator=validate_managers,
            description="Validate per-manager override blocks.",
        ),
    ),
)
@option("--verbose/--quiet")
def my_cli(verbose):
    """An app that validates its own extension sub-tree."""

The validator receives the value at app_section[extension_path] (the contents of [my-cli.managers], in this example) already extracted from the file. It’s a pure function: no side effects, no click.echo, no sys.exit. Click-extra runs it both during --validate-config (where every error is collected and reported before exit) and during normal --config loading (where the first error fails the run with a clean exit code).

ValidationError carries a dotted path and a message. Validators raise with paths relative to their extension sub-tree:

raise ValidationError("winget.unknown_field", "unknown field 'unknown_field'")

Click-extra re-anchors the path against the configuration file root before surfacing the error, so the user sees my-cli.managers.winget.unknown_field regardless of where in the schema the validator lives.

Validator-only extension paths

A ConfigValidator registration also declares the extension path: even without a corresponding dataclass field, the path is added to click-extra’s opaque set and the strict check skips it. This lets you opt sub-trees out of strict mode without touching the schema:

@group(
    strict=True,
    config_validators=(
        ConfigValidator(
            extension_path="plugins",
            validator=accept_anything,
        ),
    ),
)
def my_cli(): ...

Now [my-cli.plugins.*] content passes through to accept_anything, even though plugins isn’t a field on any schema. Useful for plugin systems where the set of sub-paths isn’t known when the CLI is defined.

Error reporting

--validate-config runs the full pipeline and collects every error before exiting:

$ my-cli --validate-config bad.toml
Configuration validation error: Parameter 'unknown_flag' found in second dict but not in first.
Configuration validation error: my-cli.managers.winget.bad_key: unknown field 'bad_key'
$ echo $?
1

Normal --config loading is fail-fast: the first ValidationError becomes a critical-level log message and the run exits 1, before any subcommand callback fires. Both modes go through the same pipeline, so an unknown CLI-flag key, an unknown schema field (under schema_strict), and an extension-validator failure all surface as the same ValidationError, whichever sub-tree the offending key sits in.

Note

The internal name for an extension path is opaque_path. You’ll see it in click-extra’s source under _collect_opaque_paths_from_schema, in the opaque_keys parameter of normalize_config_keys and flatten_config_keys, and in the cached ConfigOption._opaque_paths attribute. From the pipeline’s point of view those paths are stop markers: places it must not descend into. From your app’s point of view they’re extension points. The vocabulary divergence is intentional: developers reading their own code see extension (intent); developers reading click-extra’s source see opaque (implementation).

Validating programmatically

Both --validate-config and the runtime strict check are built on top of a single primitive, run_config_validation(). It runs all three stages (the CLI-parameter strict check, the typed schema build, and every registered ConfigValidator) in one pass and returns a ValidationReport. Reach for it when you want to validate a parsed configuration document outside Click’s option callbacks: a pre-flight check in a deployment script, a custom subcommand that lints config files, or a test harness.

from dataclasses import dataclass, field

from click_extra import run_config_validation

@dataclass
class Forecast:
    city: str = ""
    stations: dict[str, dict] = field(default_factory=dict)

report = run_config_validation(
    {"weather": {"city": "Oslo", "stations": {"north": {"altitude": 12}}}},
    app_name="weather",
    params_template=None,
    config_schema=Forecast,
)

The report exposes the typed instance, the extracted extension sub-trees, and every error found:

  • report.ok is True when no error was detected.

  • report.schema_instance holds the built Forecast(city="Oslo", stations={"north": {"altitude": 12}}), or None when no schema is configured.

  • report.opaque_subtrees maps each extension path to its sub-tree, here {"stations": {"north": {"altitude": 12}}}.

  • report.errors is a tuple of ValidationError, empty on success.

Pass params_template=None to skip the CLI-parameter strict check (useful for a schema-only validation), or the command’s template to enable it. collect_all=True (the default) gathers every error so a single run yields the full punch list; collect_all=False stops at the first failure.

Built-in extension points

Click-extra auto-registers one extension point on every ConfigOption:

App-supplied ConfigValidators on the same extension_path run alongside the built-in: both validators are called, both sets of errors surface.

Excluding parameters

The excluded_params argument allows you to block some of your CLI options to be loaded from configuration. By setting this argument, you will prevent your CLI users to set these parameters in their configuration file.

It defaults to the value of DEFAULT_EXCLUDED_PARAMS.

You can set your own list of option to ignore with the excluded_params argument:

from click import command, option, echo

from click_extra import config_option

@command
@option("--int-param", type=int, default=10)
@config_option(excluded_params=["my-cli.non_configurable_option", "my-cli.dangerous_param"])
def my_cli(int_param):
    echo(f"int_parameter is {int_param!r}")

Hint

You need to provide the fully-qualified ID of the option you’re looking to block. I.e. the dot-separated ID that is prefixed by the CLI name. That way you can specify an option to ignore at any level, including subcommands.

If you have difficulties identifying your options and their IDs, run your CLI with the --show-params option for introspection.

Including parameters

The included_params argument is the inverse of excluded_params: only the listed parameters will be loaded from the configuration file. All other parameters found in the configuration will be ignored.

from click import command, option, echo

from click_extra import config_option

@command
@option("--flag-a/--no-flag-a")
@option("--flag-b/--no-flag-b")
@config_option(included_params=("my-cli.flag_a",))
def my_cli(flag_a, flag_b):
    echo(f"flag_a={flag_a!r}")
    echo(f"flag_b={flag_b!r}")

In the example above, only flag_a will be loaded from configuration. flag_b will keep its CLI default even if it is present in the configuration file.

Caution

included_params and excluded_params are mutually exclusive. Providing both will raise a ValueError.

Hint

Like excluded_params, you need to provide the fully-qualified ID of the option. Run your CLI with the --show-params option to discover parameter IDs.

Schema-only configuration

When using config_schema for typed configuration access, your config keys typically don’t correspond to CLI parameters: they’re custom fields consumed via get_tool_config(). In that case, passing them through merge_default_map is unnecessary and can cause collisions if a config key happens to share a name with a subcommand.

Set included_params=() (empty tuple) to disable merge_default_map entirely. All configuration access goes through the schema:

from dataclasses import dataclass
from click_extra import group, pass_context
from click_extra.config import get_tool_config


@dataclass
class AppConfig:
    setup_guide: bool = True
    sync_interval: int = 60


@group(config_schema=AppConfig, schema_strict=True, included_params=())
@pass_context
def my_app(ctx):
    config = get_tool_config(ctx)
    # config is always an AppConfig instance, never None

Note

included_params=() is different from included_params=None. None means “not configured, use the default behavior” (which applies excluded_params). () means “the allowlist is explicitly empty: merge nothing into default_map.”

Disabling autodiscovery

By default, @config_option automatically searches for configuration files in the default application folder. If you want to disable this autodiscovery and only load a configuration file when the user explicitly passes --config <path>, use the NO_CONFIG sentinel as the default:

from click import group, option, echo
from click_extra import config_option, NO_CONFIG

@group(context_settings={"show_default": True})
@option("--dummy-flag/--no-flag")
@config_option(default=NO_CONFIG)
def my_cli(dummy_flag):
    echo(f"dummy_flag is {dummy_flag!r}")

With this setup:

  • The --help output shows [default: disabled] instead of a filesystem path.

  • Running the CLI without --config produces no configuration-related output on stderr.

  • Users can still explicitly pass --config <path> to load a specific configuration file.

  • The --no-config flag (if added via @no_config_option) still prints the “Skip configuration file loading altogether.” message when used explicitly.

This is useful for CLIs where configuration files are opt-in rather than opt-out, or when you want to avoid side effects from automatically discovered configuration files during development or testing.

Default subcommands

You can specify which subcommands run by default when a group is invoked without any explicit subcommands on the CLI. This is done via the _default_subcommands reserved configuration key.

Given this CLI:

from click_extra import group, command, echo, config_option, option


@group
@config_option
def my_cli():
    pass


@my_cli.command()
@option("--path", default="/tmp")
def backup(path):
    echo(f"Backing up {path}")


@my_cli.command()
def sync():
    echo("Syncing")

And this TOML configuration:

[my-cli]
_default_subcommands = ["backup"]

[my-cli.backup]
path = "/home"

Running my-cli alone will automatically invoke the backup subcommand:

$ my-cli
Backing up /home

Chained commands

For groups created with chain=True, you can list multiple default subcommands. They run in the order specified:

[my-cli]
_default_subcommands = ["backup", "sync"]
$ my-cli
Backing up /home
Syncing

Note

Non-chained groups only accept a single default subcommand. Listing more than one will produce an error.

CLI precedence

If the user names subcommands explicitly on the command line, the _default_subcommands configuration is ignored:

$ my-cli sync
Syncing

Prepend subcommands

The _prepend_subcommands key always prepends subcommands to every invocation, regardless of whether CLI subcommands are provided. This is useful for always injecting a subcommand (like debug) on a dev machine.

Important

_prepend_subcommands only works with chain=True groups. Non-chained groups resolve exactly one subcommand, so prepending would break the user’s intended command.

[my-cli]
_prepend_subcommands = ["debug"]

Running my-cli sync effectively becomes my-cli debug sync:

$ my-cli sync
Debug mode activated
Syncing

_default_subcommands with _prepend_subcommands

When both keys are set and no CLI subcommands are given, _default_subcommands fires first, then _prepend_subcommands is prepended. The result is [*prepend, *defaults]:

[my-cli]
_default_subcommands = ["sync"]
_prepend_subcommands = ["debug"]
$ my-cli
Debug mode activated
Syncing

When CLI subcommands are given explicitly, _default_subcommands is ignored but _prepend_subcommands still applies:

$ my-cli backup
Debug mode activated
Backing up /tmp

Formats

Several dialects are supported:

Format

Extensions

Description

Enabled by default

TOML

*.toml

-

YAML

*.yaml, *.yml

-

JSON

*.json

-

JSON5

*.json5

A superset of JSON made for configuration file

JSONC

*.jsonc

Like JSON, but with comments and trailing commas

HJSON

*.hjson

Another flavor of a user-friendly JSON

INI

*.ini

With extended interpolation, multi-level sections and non-native types (list, set, …)

XML

*.xml

-

PYPROJECT_TOML

pyproject.toml

Reads [tool.*] sections from pyproject.toml

Formats depending on third-party packages are not enabled by default. You need to install Click Extra with the corresponding extra dependency group to enable them.

Every supported format expresses the same configuration. Here is the my-cli section from the example above, written in each one: they all set the same defaults and produce the same result.

[my-cli]
extra_value = "is ignored too"
dummy_flag = true
my_list = ["item 1", "item #2", "Very Last Item!"]

[my-cli.subcommand]
int_param = 3
random_stuff = "will be ignored"
my-cli:
  extra_value: is ignored too
  dummy_flag: true
  my_list:
    - item 1
    - "item #2"
    - Very Last Item!
  subcommand:
    int_param: 3
    random_stuff: will be ignored
{
  "my-cli": {
    "extra_value": "is ignored too",
    "dummy_flag": true,
    "my_list": ["item 1", "item #2", "Very Last Item!"],
    "subcommand": {
      "int_param": 3,
      "random_stuff": "will be ignored"
    }
  }
}
{
  // Unquoted keys, comments, trailing commas, single quotes.
  'my-cli': {
    extra_value: 'is ignored too',
    dummy_flag: true,
    my_list: ['item 1', 'item #2', 'Very Last Item!'],
    subcommand: {
      int_param: 3,
      random_stuff: 'will be ignored',
    },
  },
}
{
  // JSON, plus comments and trailing commas.
  "my-cli": {
    "extra_value": "is ignored too",
    "dummy_flag": true,
    "my_list": ["item 1", "item #2", "Very Last Item!"],
    "subcommand": {
      "int_param": 3,
      "random_stuff": "will be ignored",
    },
  },
}
{
  # No quotes, no commas.
  my-cli:
  {
    extra_value: is ignored too
    dummy_flag: true
    my_list:
    [
      item 1
      item #2
      Very Last Item!
    ]
    subcommand:
    {
      int_param: 3
      random_stuff: will be ignored
    }
  }
}
[my-cli]
extra_value = is ignored too
dummy_flag = true
my_list = ["item 1", "item #2", "Very Last Item!"]

[my-cli.subcommand]
int_param = 3
random_stuff = will be ignored
<?xml version="1.0"?>
<my-cli>
  <extra_value>is ignored too</extra_value>
  <dummy_flag>true</dummy_flag>
  <my_list>item 1</my_list>
  <my_list>item #2</my_list>
  <my_list>Very Last Item!</my_list>
  <subcommand>
    <int_param>3</int_param>
    <random_stuff>will be ignored</random_stuff>
  </subcommand>
</my-cli>

TOML

TOML is enabled by default, and is the reference format used in the examples throughout this page.

YAML

Important

YAML support requires the yaml extra: install click-extra[yaml].

JSON

JSON is enabled by default.

JSON5

Important

JSON5 support requires the json5 extra: install click-extra[json5].

JSONC

Important

JSONC support requires the jsonc extra: install click-extra[jsonc].

HJSON

Important

HJSON support requires the hjson extra: install click-extra[hjson].

INI

INI files use sections, and a dot (.) in a section name marks a sub-level: [my-cli.subcommand] nests under my-cli. ExtendedInterpolation is enabled by default. Each value is typed after its matching CLI parameter; types INI has no native syntax for (lists, sets, …) are read as JSON-serialized strings, like my_list above.

XML

Important

XML support requires the xml extra: install click-extra[xml].

The root element is the CLI’s name. A repeated element (like my_list above) is collected into a list, and every value is read as a string, then coerced to its matching parameter’s type.

pyproject.toml

The PYPROJECT_TOML format reads [tool.<cli-name>] sections from a pyproject.toml file, following PEP 518. This is useful for any CLI tool that wants to store its configuration alongside project metadata: not just Python projects. Tools like ruff and typos, which are not Python projects, all use this convention, to play nice with other communities and increase adoption.

Tip

pyproject.toml is becoming the standard place to centralize tool configuration for Python projects. Instead of scattering dedicated config files at the root of your repository (ruff.toml, typos.toml, mypy.ini, …), you can consolidate them all under [tool.*] sections in a single pyproject.toml. This keeps the repository root clean, makes it easy to review and coordinate tool configurations in one place, and reduces the number of files contributors need to discover.

PYPROJECT_TOML is included in the default format patterns, so it is automatically discovered alongside other formats. The [tool] wrapper is automatically unwrapped: merge_default_map sees {"cli": {"int_param": 3}}, exactly the same structure as a regular TOML config file.

See also

For a production example of a CLI built on Click Extra’s pyproject.toml configuration with a typed dataclass schema, nested sub-tables, and 48 config options, see repomatic’s configuration reference. Repomatic also uses Click Extra’s config system to bridge [tool.X] sections for third-party tools that don’t read pyproject.toml natively.

CWD-first discovery

When auto-discovering configuration (no explicit --config flag), Click Extra searches for pyproject.toml starting from the current working directory and walking up to the VCS root before checking the standard app config directory. This matches the discovery behavior of uv, ruff, and mypy, so users get the configuration they expect without passing --config explicitly.

The CWD search only applies to pyproject.toml: other config formats (TOML, YAML, JSON, etc.) are still discovered from the app config directory. If a pyproject.toml is found via CWD search, the app-dir search is skipped entirely. If --config is passed explicitly, CWD search is bypassed.

Given a pyproject.toml in the search path:

pyproject.toml
[build-system]
requires = ["setuptools"]

[tool.cli]
int_param = 3

This is especially powerful combined with search_parents to walk up from a project directory:

from click import command, option, echo

from click_extra import config_option

@command
@option("--int-param", type=int, default=10)
@config_option(search_parents=True)
def cli(int_param):
    echo(f"int_parameter is {int_param!r}")

Running cli from anywhere inside the project tree will find pyproject.toml at the repository root and apply [tool.cli] values. The walk automatically stops at the VCS root.

Dedicated file wins, no merging

When both a dedicated configuration file (like my-cli.toml) and a pyproject.toml with a [tool.my-cli] section exist, Click Extra uses the first parseable file it finds and ignores all others. There is no merging across files.

This is the de facto standard across the ecosystem. Every major tool that supports both a dedicated config file and pyproject.toml follows the same strict precedence (dedicated file wins, pyproject.toml is ignored entirely):

Tool

Precedence rule

ruff

.ruff.toml > ruff.toml > pyproject.toml

uv

uv.toml > pyproject.toml

typos

typos.toml / _typos.toml / .typos.toml > Cargo.toml > pyproject.toml

The rationale:

  • No merging surprises. Merging two config sources creates ambiguity: which key wins when both files define it? Are arrays concatenated or replaced? Every tool above chose “first match wins, full stop” to avoid this class of problems entirely.

  • Explicit intent. A dedicated file at the repository root, named after the tool, is the most visible and explicit signal. If someone creates one alongside a [tool.*] section, the dedicated file represents a deliberate override.

  • Clean migration path. Users moving from a dedicated file to pyproject.toml simply delete the dedicated file. Users who need the dedicated file (for example, sharing it across non-Python repos) keep it and pyproject.toml is silently ignored.

See also

Other non-Python tools that support [tool.*] in pyproject.toml: basedpyright, lychee, maturin, pixi, Pyrefly, Pyright, rumdl, Tombi, ty, typos, uv, and Zuban.

Click Extra’s own [tool.*] bridge in repomatic’s tool runner translates [tool.yamllint], [tool.actionlint], [tool.biome], and others into native config files at invocation time, giving tools that lack native pyproject.toml support the same single-file experience.

Other tools are following suit: actionlint#623, biome#9239, gitleaks#2066, Nuitka#3909, taplo#603, zizmor#322. sh#1268 was declined.

Search pattern

The configuration file is searched with a wildcard-based glob pattern.

There is multiple stages to locate and parse the configuration file:

  1. Locate all files matching the search pattern

  2. Match each file against the supported formats, in order, until one is successfully parsed

  3. Use the first successfully parsed file as the configuration source

By default, the pattern is <app_dir>/{*.toml,*.json,*.ini}, where:

Hint

Depending on the formats you enabled in your installation of Click Extra, the default extensions may vary. For example, if you installed Click Extra with all extra dependencies, the default extensions would be extended to {*.toml,*.yaml,*.yml,*.json,*.json5,*.jsonc,*.hjson,*.ini,*.xml,pyproject.toml}.

Tip

The search process can be hard to follow. To help you see clearly, you can enable debug logging for the click_extra logger to see which files are located, matched, parsed, skipped, and finally used.

Or better, just pass the --verbosity DEBUG option to your CLI if it is powered by Click Extra.

Default folder

The configuration file is searched in the default application path, as defined by click.get_app_dir().

To mirror the latter, the @config_option decorator accept a roaming and force_posix argument to alter the default path:

Platform

roaming

force_posix

Folder

macOS (default)

-

False

~/Library/Application Support/Foo Bar

macOS

-

True

~/.foo-bar

Unix (default)

-

False

~/.config/foo-bar

Unix

-

True

~/.foo-bar

Windows (default)

True

-

C:\Users\<user>\AppData\Roaming\Foo Bar

Windows

False

-

C:\Users\<user>\AppData\Local\Foo Bar

Let’s change the default in the following example:

from click import command

from click_extra import config_option

@command(context_settings={"show_default": True})
@config_option(force_posix=True)
def cli():
    pass

See how the default to --config option has been changed to ~/.cli/:

$ cli --help
Usage: cli [OPTIONS]

Options:
  --config CONFIG_PATH  Location of the configuration file. Supports local path
                        with glob patterns or remote URL.  [default: ~/.cli/{*.t
                        oml,*.yaml,*.yml,*.json,*.json5,*.jsonc,*.hjson,*.ini,*.
                        xml,pyproject.toml}]
  --help                Show this message and exit.

See also

The default application folder concept has a long and complicated history in the Unix world.

The oldest reference I can track is from the Where Configurations Live chapter from The Art of Unix Programming.

The XDG base directory specification is the latest iteration of this tradition on Linux. This long-due guidelines brings lots of benefits to the platform. This is what Click Extra is implementing by default.

But there is still a lot of cases for which the XDG doesn’t cut it, like on other platforms (macOS, Windows, …) or for legacy applications. That’s why Click Extra allows you to customize the way configuration is searched and located.

Custom pattern

You can also provide a custom path to the configuration file via the --config option added to your CLI by the @config_option decorator.

To change the default search pattern, pass a customized value to the default argument of the decorator:

from click import command

from click_extra import config_option

@command(context_settings={"show_default": True})
@config_option(default="~/my_special_folder/*.toml")
def cli():
    pass
$ cli --help
Usage: cli [OPTIONS]

Options:
  --config CONFIG_PATH  Location of the configuration file. Supports local path
                        with glob patterns or remote URL.  [default:
                        ~/my_special_folder/*.toml]
  --help                Show this message and exit.

The rules for the pattern are described in the next section.

Search pattern specifications

Patterns provided to @config_option’s default argument:

  • Are based on wcmatch.glob syntax.

  • Should be written with Unix separators (/), even for Windows: the pattern will be normalized to the local platform dialect.

  • Can be absolute or relative paths.

  • Have their default case-sensitivity aligned with the local platform:

    • Windows is insensitive to case,

    • Unix and macOS are case-sensitive.

  • Are setup with the following default flags:

    Flag

    Description

    GLOBSTAR

    Recursive directory search via ** glob notation.

    FOLLOW

    Traverse symlink directories.

    DOTGLOB

    Include file or directory starting with a literal dot (.).

    BRACE

    Expand {pat1,pat2,...} brace expressions into multiple patterns.

    SPLIT

    Allow multiple patterns separated by |.

    GLOBTILDE

    Allow user’s home path ~ to be expanded.

    NODIR

    Restricts results to files.

Important

The BRACE flag is always forced, so that multi-format default patterns using {pat1,pat2,...} syntax expand correctly. The NODIR flag is always forced, to optimize the search for files only.

The flags above can be changed via the search_pattern_flags argument of the decorator. So to make the matching case-insensitive, add the IGNORECASE flag:

from wcmatch.glob import (
    GLOBSTAR,
    FOLLOW,
    DOTGLOB,
    BRACE,
    SPLIT,
    GLOBTILDE,
    NODIR,
    IGNORECASE
)

@config_option(
    search_pattern_flags=(
        GLOBSTAR | FOLLOW | DOTGLOB | BRACE | SPLIT | GLOBTILDE | NODIR | IGNORECASE
    )
)

But because of the way flags works, you have to re-specify all flags you want to keep, including the default ones.

See also

This is the same pinciple as file pattern flags.

Multi-format matching

The default behavior consist in searching for all files matching the default {*.toml,*.json,*.ini} pattern. Or more, depending on the extra dependencies installed with Click Extra.

As soon as files are located, they are matched against each supported format, in order, until one is successfully parsed.

The first successfully parsed file is used to feed the CLI’s default values.

The search will only consider matches that:

  • exists,

  • are a file,

  • are not empty,

  • matches file format patterns,

  • can be parsed successfully, and

  • produce a non-empty data structure.

All others are skipped. And the search continues with the next matching file.

To influence which formats are supported, see the next section.

Format selection

If you want to limit the formats supported by your CLI, you can use the file_format_patterns argument to specify which formats are allowed:

from click import command, option, echo

from click_extra import config_option, ConfigFormat

@command(context_settings={"show_default": True})
@option("--int-param", type=int, default=10)
@config_option(file_format_patterns=[ConfigFormat.JSON, ConfigFormat.TOML])
def cli(int_param):
    echo(f"int_parameter is {int_param!r}")

Notice how the default search pattern has been restricted to only *.json and *.toml files, and also that the order is reflected in the help:

$ cli --help
Usage: cli [OPTIONS]

Options:
  --int-param INTEGER   [default: 10]
  --config CONFIG_PATH  Location of the configuration file. Supports local path
                        with glob patterns or remote URL.  [default:
                        ~/.config/cli/{*.json,*.toml}]
  --help                Show this message and exit.

You can also specify a single format:

from click import command, option, echo

from click_extra import config_option, ConfigFormat

@command(context_settings={"show_default": True})
@option("--int-param", type=int, default=10)
@config_option(file_format_patterns=ConfigFormat.XML)
def cli(int_param):
    echo(f"int_parameter is {int_param!r}")
$ cli --help
Usage: cli [OPTIONS]

Options:
  --int-param INTEGER   [default: 10]
  --config CONFIG_PATH  Location of the configuration file. Supports local path
                        with glob patterns or remote URL.  [default:
                        ~/.config/cli/*.xml]
  --help                Show this message and exit.

Custom file format patterns

Each format is associated with default file patterns. But you can also change these with the same file_format_patterns argument:

from click import command, option, echo

from click_extra import config_option, ConfigFormat

@command(context_settings={"show_default": True})
@option("--int-param", type=int, default=10)
@config_option(
    file_format_patterns={
        ConfigFormat.TOML: ["*.toml", "my_app.conf"],
        ConfigFormat.JSON: ["settings*.js", "*.json"],
    }
)
def cli(int_param):
    echo(f"int_parameter is {int_param!r}")

Again, this is reflected in the help:

$ cli --help
Usage: cli [OPTIONS]

Options:
  --int-param INTEGER   [default: 10]
  --config CONFIG_PATH  Location of the configuration file. Supports local path
                        with glob patterns or remote URL.  [default:
                        ~/.config/cli/{*.toml,my_app.conf,settings*.js,*.json}]
  --help                Show this message and exit.

Parsing priority

The syntax of file_format_patterns argument allows you to specify either a list of formats, a single format, or a mapping of formats to patterns. And we can even have multiple formats share the same pattern:

from click import command, option, echo

from click_extra import config_option, ConfigFormat

@command(context_settings={"show_default": True})
@option("--int-param", type=int, default=10)
@config_option(
    file_format_patterns={
        ConfigFormat.TOML: "*.toml",
        ConfigFormat.JSON5: "config*.js",
        ConfigFormat.JSON: ["config*.js", "*.js"],
    }
)
def cli(int_param):
    echo(f"int_parameter is {int_param!r}")

Notice how all formats are merged into the same pattern:

$ cli --help
Usage: cli [OPTIONS]

Options:
  --int-param INTEGER   [default: 10]
  --config CONFIG_PATH  Location of the configuration file. Supports local path
                        with glob patterns or remote URL.  [default:
                        ~/.config/cli/{*.toml,config*.js,*.js}]
  --help                Show this message and exit.

What will happen in this case is that the search will try to parse matching files first as JSON5, then as JSON. The first format that successfully parses the file will be used.

So a file named config123.js containing valid JSON5 syntax will be parsed as such, even if it also contains valid JSON syntax and match the *.js pattern. But if for any reason the JSON5 parsing fails, the search will try to parse it as JSON next, which is the second-best match.

On the other hand, a file named settings.js will only be tried as JSON, since it doesn’t match the JSON5 pattern.

This illustrates the flexibility of this approach, but how the order of formats matter.

File pattern flags

The file_pattern_flags argument controls the matching behavior of file patterns.

These flags are defined in wcmatch.fnmatch and default to:

Flag

Description

NEGATE

Adds support of ! negation to define exclusions.

SPLIT

Allow multiple patterns separated by `

Important

The SPLIT flag is always forced, as our multi-pattern design relies on it.

If for example, you want to make the matching case-insensitive, you do that by adding the IGNORECASE flag:

from wcmatch.fnmatch import NEGATE, SPLIT, IGNORECASE

@config_option(file_pattern_flags=NEGATE | SPLIT | IGNORECASE)

But because of the way flags works, you have to re-specify all flags you want to keep, including the default ones.

See also

This is the same pinciple as search pattern specifications.

Excluding files

Negation is active by default, which is useful when you want to exclude some files from being considered during the search.

To ignore, for example, all your template files residing alongside real configuration files. Then, to exclude all files starting with template_ in their name, you can do:

@config_option(
    file_format_patterns={
        ConfigFormat.TOML: ["*.toml", "!template_*.toml"],
    }
)

Extension-less files

This demonstrate the popular case on Unix-like systems, where the configuration file is an extension-less dotfile in the home directory.

Here is how to set up @config_option for a pre-defined .commandrc file in YAML:

from click import command

from click_extra import config_option, ConfigFormat

@command(context_settings={"show_default": True})
@config_option(
    default="~/.commandrc",
    file_format_patterns={ConfigFormat.YAML: ".commandrc"}
)
def cli():
    pass
$ cli --help
Usage: cli [OPTIONS]

Options:
  --config CONFIG_PATH  Location of the configuration file. Supports local path
                        with glob patterns or remote URL.  [default:
                        ~/.commandrc]
  --help                Show this message and exit.

Caution

Depending on how you set up your patterns, files starting with a dot (.) may not be matched by default. Make sure to include the DOTMATCH flag in file_pattern_flags if needed.

Remote URL

Remote URL can be passed directly to the --config option:

$ my-cli --config "https://example.com/dummy/configuration.yaml" subcommand
dummy_flag    is True
my_list       is ('point 1', 'point #2', 'Very Last Point!')
int_parameter is 77

Warning

URLs do not support multi-format matching. You need to provide a direct link to the configuration file, including its extension.

Glob patterns are also not supported for URLs. Unless you want to let your users download the whole internet…

Typed configuration schema

By default, ConfigOption only feeds configuration values that match CLI options into the context’s default_map. Any other keys in the configuration file are silently ignored. This works well when the configuration file mirrors the CLI structure, but some applications need access to additional configuration that doesn’t correspond to any CLI option.

The config_schema parameter solves this by extracting the app’s configuration section, normalizing its keys, and producing a typed object available to all commands via ctx.meta["click_extra.tool_config"].

Tip

repomatic is a production CLI that uses all of the features below: a 48-field Config dataclass with nested sub-dataclasses, opaque dict fields for GitHub Actions matrices, config_path metadata for kebab-case TOML keys, and schema_strict=True to catch typos. It can serve as a reference for building complex typed configuration.

Dataclass schema

The most common pattern is a Python dataclass. Click Extra auto-detects dataclass types, normalizes hyphenated keys to underscores, flattens nested sections, and filters to known fields:

from dataclasses import dataclass, field
from click_extra import command, echo, group, option, pass_context
from click_extra.config import get_tool_config

@dataclass
class AppConfig:
    """Typed configuration for my-app."""
    extra_categories: list[str] = field(default_factory=list)
    output_format: str = "text"

@group(config_schema=AppConfig)
@option("--verbose/--no-verbose")
@pass_context
def my_app(ctx, verbose):
    """An app with typed configuration."""
    config = get_tool_config(ctx)
    if config is not None:
        echo(f"output_format: {config.output_format}")
        echo(f"extra_categories: {config.extra_categories}")

@my_app.command()
@option("--name", default="World")
def greet(name):
    """Say hello."""
    echo(f"Hello, {name}!")

With a TOML configuration file:

~/.config/my-app/config.toml
[my-app]
verbose = true
extra-categories = ["docs", "tests"]
output-format = "json"

[my-app.greet]
name = "Alice"

The CLI options (verbose, name) are fed into default_map as before. The additional keys (extra-categories, output-format) are normalized (hyphens to underscores) and passed to the AppConfig dataclass. Fields not present in the file get their dataclass defaults.

$ my-app --help
Usage: my-app [OPTIONS] COMMAND [ARGS]...

  An app with typed configuration.

Options:
  --verbose / --no-verbose     [default: no-verbose]
  --time / --no-time           Measure and print elapsed execution time.
                               [default: no-time]
  --config CONFIG_PATH         Location of the configuration file. Supports
                               local path with glob patterns or remote URL.
                               [default: ~/.config/my-app/{*.toml,*.yaml,*.yml,*
                               .json,*.json5,*.jsonc,*.hjson,*.ini,*.xml,pyproje
                               ct.toml}]
  --no-config                  Ignore all configuration files and only use
                               command line parameters and environment
                               variables.
  --validate-config FILE       Validate the configuration file and exit.
  --export-config FORMAT       Export the configuration in the selected format
                               to <stdout>, then exit.
  --accessible                 Accessibility mode: disable colors and render
                               tables in a plain, screen-reader-friendly format.
  --color [auto|always|never]  Colorize the output. A bare --color is the same
                               as --color=always.  [default: auto]
  --no-color                   Disable colorization (alias of --color=never).
  --progress / --no-progress   Show progress indicators during long operations.
                               Disabled for non-interactive output (pipes, dumb
                               terminals, CI) and by --accessible.  [default:
                               progress]
  --theme [dark|dracula|light|manpage|monokai|nord|solarized_dark]
                               Color theme used for help screens.  [default:
                               dark]
  --show-params                Show all CLI parameters, their provenance,
                               defaults and value, then exit.
  --table-format [aligned|asciidoc|colon-grid|csv|csv-excel|csv-excel-tab|csv-unix|double-grid|double-outline|fancy-grid|fancy-outline|github|grid|heavy-grid|heavy-outline|hjson|html|jira|json|json5|jsonc|latex|latex-booktabs|latex-longtable|latex-raw|mediawiki|mixed-grid|mixed-outline|moinmoin|orgtbl|outline|pipe|plain|presto|pretty|psql|rounded-grid|rounded-outline|rst|simple|simple-grid|simple-outline|textile|toml|tsv|unsafehtml|vertical|xml|yaml|youtrack]
                               Rendering style of tables.  [default: rounded-
                               outline]
  --verbosity LEVEL            Either CRITICAL, ERROR, WARNING, INFO, DEBUG.
                               [default: WARNING]
  -v, --verbose                Increase the default WARNING verbosity by one
                               level for each additional repetition of the
                               option.  [default: 0]
  -q, --quiet                  Decrease the default WARNING verbosity by one
                               level for each additional repetition of the
                               option.  [default: 0]
  --man                        Show the command's man page (roff) and exit.
  --version                    Show the version and exit.
  -h, --help                   Show this message and exit.

Commands:
  greet  Say hello.
  help   Show help for a command.
/home/runner/work/click-extra/click-extra/click_extra/commands.py:488: UserWarning: The parameter --verbose is used more than once. Remove its duplicate as parameters should be unique.
  self._resolve_color_eagerly(ctx, args)
/home/runner/work/click-extra/click-extra/.venv/lib/python3.14/site-packages/click/core.py:1307: UserWarning: The parameter --verbose is used more than once. Remove its duplicate as parameters should be unique.
  parser = self.make_parser(ctx)
/home/runner/work/click-extra/click-extra/.venv/lib/python3.14/site-packages/click/core.py:1926: UserWarning: The parameter --verbose is used more than once. Remove its duplicate as parameters should be unique.
  rest = super().parse_args(ctx, args)
/home/runner/work/click-extra/click-extra/.venv/lib/python3.14/site-packages/click/core.py:1886: UserWarning: The parameter --verbose is used more than once. Remove its duplicate as parameters should be unique.
  rv = super().collect_usage_pieces(ctx)
/home/runner/work/click-extra/click-extra/click_extra/highlight.py:326: UserWarning: The parameter --verbose is used more than once. Remove its duplicate as parameters should be unique.
  formatter.keywords = self.collect_keywords(ctx)

Callable schema

Any callable that accepts a dict and returns an object can be used as config_schema. This supports Pydantic models, attrs classes, or custom factories:

from types import SimpleNamespace
from click_extra import echo, group, pass_context
from click_extra.config import get_tool_config, normalize_config_keys

def parse_config(raw):
    """Custom config parser that normalizes keys."""
    return SimpleNamespace(**normalize_config_keys(raw))

@group(config_schema=parse_config)
@pass_context
def callable_app(ctx):
    """An app with a callable schema."""
    config = get_tool_config(ctx)
    if config is not None:
        echo(f"value: {config.custom_value}")

@callable_app.command()
def run():
    """Run the app."""
    echo("done")
$ callable-app --help
Usage: callable-app [OPTIONS] COMMAND [ARGS]...

  An app with a callable schema.

Options:
  --time / --no-time           Measure and print elapsed execution time.
                               [default: no-time]
  --config CONFIG_PATH         Location of the configuration file. Supports
                               local path with glob patterns or remote URL.
                               [default: ~/.config/callable-app/{*.toml,*.yaml,*
                               .yml,*.json,*.json5,*.jsonc,*.hjson,*.ini,*.xml,p
                               yproject.toml}]
  --no-config                  Ignore all configuration files and only use
                               command line parameters and environment
                               variables.
  --validate-config FILE       Validate the configuration file and exit.
  --export-config FORMAT       Export the configuration in the selected format
                               to <stdout>, then exit.
  --accessible                 Accessibility mode: disable colors and render
                               tables in a plain, screen-reader-friendly format.
  --color [auto|always|never]  Colorize the output. A bare --color is the same
                               as --color=always.  [default: auto]
  --no-color                   Disable colorization (alias of --color=never).
  --progress / --no-progress   Show progress indicators during long operations.
                               Disabled for non-interactive output (pipes, dumb
                               terminals, CI) and by --accessible.  [default:
                               progress]
  --theme [dark|dracula|light|manpage|monokai|nord|solarized_dark]
                               Color theme used for help screens.  [default:
                               dark]
  --show-params                Show all CLI parameters, their provenance,
                               defaults and value, then exit.
  --table-format [aligned|asciidoc|colon-grid|csv|csv-excel|csv-excel-tab|csv-unix|double-grid|double-outline|fancy-grid|fancy-outline|github|grid|heavy-grid|heavy-outline|hjson|html|jira|json|json5|jsonc|latex|latex-booktabs|latex-longtable|latex-raw|mediawiki|mixed-grid|mixed-outline|moinmoin|orgtbl|outline|pipe|plain|presto|pretty|psql|rounded-grid|rounded-outline|rst|simple|simple-grid|simple-outline|textile|toml|tsv|unsafehtml|vertical|xml|yaml|youtrack]
                               Rendering style of tables.  [default: rounded-
                               outline]
  --verbosity LEVEL            Either CRITICAL, ERROR, WARNING, INFO, DEBUG.
                               [default: WARNING]
  -v, --verbose                Increase the default WARNING verbosity by one
                               level for each additional repetition of the
                               option.  [default: 0]
  -q, --quiet                  Decrease the default WARNING verbosity by one
                               level for each additional repetition of the
                               option.  [default: 0]
  --man                        Show the command's man page (roff) and exit.
  --version                    Show the version and exit.
  -h, --help                   Show this message and exit.

Commands:
  help  Show help for a command.
  run   Run the app.

Retrieving the config object

The typed configuration is stored in ctx.meta["click_extra.tool_config"] and can be accessed in two ways:

# Via the convenience helper (uses current context by default):
from click_extra.config import get_tool_config

config = get_tool_config()

# Or directly from the context:
config = ctx.find_root().meta.get("click_extra.tool_config")

If no config_schema was set, get_tool_config() returns None. When a config_schema is configured but no configuration file is found, the schema is instantiated with its defaults so get_tool_config() always returns a usable object.

Format-agnostic

The config_schema feature works with all configuration formats supported by ConfigOption: TOML, YAML, JSON, JSON5, JSONC, Hjson, INI, and XML. The parsed configuration is normalized into a Python dict before the schema is applied, so the same schema works regardless of the source format.

For example, the same AppConfig dataclass works with YAML:

~/.config/my-app/config.yaml
my-app:
  extra-categories:
    - docs
    - tests
  output-format: json

Or JSON:

~/.config/my-app/config.json
{
    "my-app": {
        "extra-categories": ["docs", "tests"],
        "output-format": "json"
    }
}

Key normalization

Configuration formats commonly use kebab-case (extra-categories), while Python identifiers use snake_case (extra_categories). The normalize_config_keys utility handles this conversion recursively:

from click_extra.config import normalize_config_keys

raw = {"extra-categories": ["a", "b"], "nested-section": {"sub-key": 1}}
normalized = normalize_config_keys(raw)
# {"extra_categories": ["a", "b"], "nested_section": {"sub_key": 1}}

For dataclass schemas, this normalization is applied automatically. For callable schemas, call normalize_config_keys explicitly if needed.

Nested configuration sections

TOML and YAML configurations often group related settings under sub-tables (like [tool.myapp.dependency-graph]). When using a dataclass schema, Click Extra automatically flattens these nested sections by joining parent and child keys with _, so they map directly to flat dataclass fields:

from click_extra.config import flatten_config_keys, normalize_config_keys

raw = {"dependency-graph": {"all-groups": True, "output": "deps.mmd"}}
flatten_config_keys(normalize_config_keys(raw))
# {"dependency_graph_all_groups": True, "dependency_graph_output": "deps.mmd"}

This means a dataclass with flat fields like dependency_graph_output and dependency_graph_all_groups can be populated from nested TOML:

Nested sub-tables map to flat dataclass fields.
[my-app.dependency-graph]
output = "deps.mmd"
all-groups = false

The full pipeline applied to dataclass schemas is: normalize keys (hyphens to underscores), flatten nested dicts (joining with _), then match against dataclass field names. Top-level keys and nested sub-table keys can be mixed freely.

For callable schemas, use flatten_config_keys and normalize_config_keys explicitly if you need the same behavior.

Type-aware flattening

By default, flatten_config_keys recurses into every nested dict. This breaks fields typed as dict[str, X] where the dict keys are data rather than config structure (for example, GitHub Actions matrix axis names like os or python-version).

When using a dataclass schema, Click Extra inspects field type hints and automatically stops flattening at dict-typed field boundaries: the same extension-point detection covered in Extending validation, seen from the flattening pipeline’s side. The dict value is assigned whole to the matching field:

from dataclasses import dataclass, field


@dataclass
class AppConfig:
    simple_value: str = ""
    matrix_axes: dict[str, list[str]] = field(default_factory=dict)
Dict-typed fields are kept intact, not flattened.
[my-app]
simple-value = "hello"

[my-app.matrix-axes]
python-version = ["3.12", "3.13"]
os = ["ubuntu", "macos"]

Here matrix_axes receives {"python_version": ["3.12", "3.13"], "os": ["ubuntu", "macos"]} as a single dict, rather than being split into matrix_axes_python_version and matrix_axes_os. The pipeline calls this passthrough behavior internally: each extension path is added to an opaque keys set that normalize_config_keys and flatten_config_keys consult before recursing.

Both helpers accept an opaque_keys parameter for manual control, useful when working with raw config dicts outside the schema pipeline:

from click_extra.config import flatten_config_keys

conf = {"matrix": {"replace": {"os": {"old": "new"}}, "count": 3}}
flatten_config_keys(conf, opaque_keys=frozenset({"matrix_replace"}))
# {"matrix_replace": {"os": {"old": "new"}}, "matrix_count": 3}

Field metadata

Dataclass fields can carry metadata to control how their values are extracted from the raw config:

  • click_extra.config_path (alias: CONFIG_PATH_METADATA_KEY): A dotted TOML path (like "test-matrix.replace"). The value is extracted directly from the raw config before normalization and flattening, bypassing the standard pipeline.

  • click_extra.normalize_keys (alias: NORMALIZE_KEYS_METADATA_KEY): Set to False to skip key normalization on the extracted value. Useful when the value contains keys that are external identifiers (for example, GitHub Actions axis names like python-version) that must not be converted to python_version.

  • click_extra.extension (alias: EXTENSION_METADATA_KEY): Set to True to declare the field as an extension point. The sub-tree at that field becomes a passthrough: strict-check skips it, the flatten pipeline treats it as opaque, and a registered ConfigValidator (or your own code) takes over its validation. Equivalent to typing the field as dict[str, X]; use the metadata form when the field’s runtime type isn’t a mapping.

from dataclasses import dataclass, field


@dataclass
class AppConfig:
    special: dict[str, str] = field(
        default_factory=dict,
        metadata={
            "click_extra.config_path": "deep.section",
            "click_extra.normalize_keys": False,
        },
    )
Keys in the extracted section are preserved as-is.
[my-app.deep.section]
kebab-key = "preserved"

With normalize_keys=False, special receives {"kebab-key": "preserved"} instead of {"kebab_key": "preserved"}.

Nested dataclass schemas

Fields whose type is another dataclass are recursively instantiated with the same normalize/flatten/opaque logic. This allows complex config sections to be modeled as typed sub-schemas:

from dataclasses import dataclass, field


@dataclass
class MatrixConfig:
    exclude: list[dict[str, str]] = field(default_factory=list)
    replace: dict[str, dict[str, str]] = field(default_factory=dict)
    variations: dict[str, list[str]] = field(default_factory=dict)


@dataclass
class AppConfig:
    name: str = ""
    matrix: MatrixConfig = field(
        default_factory=MatrixConfig,
        metadata={
            "click_extra.config_path": "test-matrix",
            "click_extra.normalize_keys": False,
        },
    )
Nested dataclass with opaque sub-fields.
[my-app]
name = "my-project"

[my-app.test-matrix]
exclude = [{os = "windows-11-arm"}]

[my-app.test-matrix.replace]
os = {"ubuntu-slim" = "ubuntu-24.04"}

[my-app.test-matrix.variations]
python-version = ["3.14"]

The matrix field receives a MatrixConfig instance. Because normalize_keys=False, axis names like python-version and runner identifiers like ubuntu-slim are preserved verbatim in the replace and variations dicts.

Nested dataclass fields without config_path metadata are matched by their normalized field name in the flattened config, just like scalar fields. The nesting is detected from the type hint and the sub-dict is recursively processed.

Schema validation

By default, configuration keys that don’t match any dataclass field are silently ignored. The schema_strict parameter changes this to report a validation error, catching typos and stale configuration entries:

@group(config_schema=AppConfig, schema_strict=True)
def my_app(): ...

Or directly on the config option:

@config_option(config_schema=AppConfig, schema_strict=True)

When schema_strict=True, an unrecognized key stops the run with a critical-level log and exit code 1. The message lists both the unrecognized keys and all valid options:

Configuration validation error: Unknown configuration option(s): typo_field. Valid options: known_field, output_format

Note

schema_strict is separate from the existing strict parameter. strict controls whether config keys that don’t match CLI parameters are rejected; schema_strict validates against dataclass fields instead. The two can be used independently, and both report through the same ValidationError type (see Error reporting).

Fallback sections

When a CLI tool is renamed, existing configuration files may still use the old section name. The fallback_sections parameter lets you accept legacy names with a deprecation warning:

from dataclasses import dataclass
from click_extra import echo, group, pass_context
from click_extra.config import get_tool_config

@dataclass
class ToolConfig:
    value: str = "default"

@group(
    config_schema=ToolConfig,
    fallback_sections=("old-tool-name", "even-older-name"),
)
@pass_context
def new_tool(ctx):
    """A tool that was renamed."""
    config = get_tool_config(ctx)
    if config is not None:
        echo(f"value: {config.value}")

@new_tool.command()
def run():
    """Run the tool."""
    echo("done")

With the following TOML:

Legacy configuration still using the old name.
[old-tool-name]
value = "from-legacy"

The CLI loads the [old-tool-name] section and logs a deprecation warning to stderr:

Config section [old-tool-name] is deprecated, migrate to [new-tool].

If both [new-tool] and [old-tool-name] exist, the current name always wins, and a warning is emitted about the leftover legacy section.

$ new-tool --help
Usage: new-tool [OPTIONS] COMMAND [ARGS]...

  A tool that was renamed.

Options:
  --time / --no-time           Measure and print elapsed execution time.
                               [default: no-time]
  --config CONFIG_PATH         Location of the configuration file. Supports
                               local path with glob patterns or remote URL.
                               [default: ~/.config/new-tool/{*.toml,*.yaml,*.yml
                               ,*.json,*.json5,*.jsonc,*.hjson,*.ini,*.xml,pypro
                               ject.toml}]
  --no-config                  Ignore all configuration files and only use
                               command line parameters and environment
                               variables.
  --validate-config FILE       Validate the configuration file and exit.
  --export-config FORMAT       Export the configuration in the selected format
                               to <stdout>, then exit.
  --accessible                 Accessibility mode: disable colors and render
                               tables in a plain, screen-reader-friendly format.
  --color [auto|always|never]  Colorize the output. A bare --color is the same
                               as --color=always.  [default: auto]
  --no-color                   Disable colorization (alias of --color=never).
  --progress / --no-progress   Show progress indicators during long operations.
                               Disabled for non-interactive output (pipes, dumb
                               terminals, CI) and by --accessible.  [default:
                               progress]
  --theme [dark|dracula|light|manpage|monokai|nord|solarized_dark]
                               Color theme used for help screens.  [default:
                               dark]
  --show-params                Show all CLI parameters, their provenance,
                               defaults and value, then exit.
  --table-format [aligned|asciidoc|colon-grid|csv|csv-excel|csv-excel-tab|csv-unix|double-grid|double-outline|fancy-grid|fancy-outline|github|grid|heavy-grid|heavy-outline|hjson|html|jira|json|json5|jsonc|latex|latex-booktabs|latex-longtable|latex-raw|mediawiki|mixed-grid|mixed-outline|moinmoin|orgtbl|outline|pipe|plain|presto|pretty|psql|rounded-grid|rounded-outline|rst|simple|simple-grid|simple-outline|textile|toml|tsv|unsafehtml|vertical|xml|yaml|youtrack]
                               Rendering style of tables.  [default: rounded-
                               outline]
  --verbosity LEVEL            Either CRITICAL, ERROR, WARNING, INFO, DEBUG.
                               [default: WARNING]
  -v, --verbose                Increase the default WARNING verbosity by one
                               level for each additional repetition of the
                               option.  [default: 0]
  -q, --quiet                  Decrease the default WARNING verbosity by one
                               level for each additional repetition of the
                               option.  [default: 0]
  --man                        Show the command's man page (roff) and exit.
  --version                    Show the version and exit.
  -h, --help                   Show this message and exit.

Commands:
  help  Show help for a command.
  run   Run the tool.

This works identically across all configuration formats (TOML, YAML, JSON, INI, etc.), since the section lookup operates on the normalized dict structure after parsing.

click_extra.config API

Click Extra’s own built-in configuration schema and validators.

These are concrete instances of the configuration machinery, kept apart from the reusable engine in schema and the option classes in option:

  • the dataclasses describing click-extra’s own [tool.click-extra] section (ClickExtraConfig and its test-suite and prebake sub-tables);

  • _builtin_config_validators(), the validators click-extra registers on every ConfigOption.

A downstream project defines its own equivalent of this module; click-extra just happens to ship one, built on the same generic engine.

click_extra.config.builtin.THEMES_CONFIG_KEY: str = 'themes'

Sub-key under [tool.<cli>] where user-defined themes live in config.

Used by ConfigOption to find [tool.<cli>.themes.<name>] tables, build them via HelpTheme.from_dict, and stash the result on ctx.meta[click_extra.context.THEME_OVERRIDES]. The constant is the single source of truth shared by _builtin_config_validators, ConfigOption._apply_theme_overrides, and click_extra.theme.themes_from_config().

class click_extra.config.builtin.TestSuiteConfig(file='./tests/cli-test-suite.toml', cases=<factory>, timeout=None)[source]

Bases: object

Config schema for a project’s test suite, read from [tool.<cli>.test-suite].

The test-suite CLI command resolves its cases from this config when no suite is given on the command line. Map it onto an app’s config section with a field carrying metadata={CONFIG_PATH_METADATA_KEY: "test-suite"}.

file: str = './tests/cli-test-suite.toml'

Path to a test suite file, resolved relative to the project root.

Its format is detected from the extension; the default is TOML, which (like JSON) parses with no optional dependency, unlike YAML and the others.

cases: list[dict]

Test cases written natively in the config format, an alternative to a file suite, taking precedence over it when both are set.

Each entry is a mapping of CLITestCase directive names, equivalent to one item of a suite list. In TOML this reads as a [[tool.<cli>.test-suite.cases]] array of tables. Declared as an extension point so the configuration engine passes the raw mappings through unprocessed; the test-suite command turns them into CLITestCase instances.

timeout: int | None = None

Default timeout (seconds) for each case that does not set its own.

None leaves cases unbounded unless --timeout is passed.

class click_extra.config.builtin.PrebakeConfig(module=None)[source]

Bases: object

Config schema for the prebake commands, read from [tool.<cli>.prebake].

Lets a project pin the target __init__.py once for its build pipeline, instead of passing --module to every click-extra prebake command.

module: str | None = None

Path to the __init__.py to pre-bake, resolved relative to the project root. Overrides the [project.scripts] auto-discovery; leave unset to keep it.

class click_extra.config.builtin.ClickExtraConfig(test_suite=<factory>, prebake=<factory>)[source]

Bases: object

Schema for the [tool.click-extra] configuration section.

Wired as the config_schema of the top-level click-extra group, so every subcommand reads the same section and pulls its own sub-table through get_tool_config().

test_suite: TestSuiteConfig

The [tool.click-extra.test-suite] sub-table (file/cases/timeout).

prebake: PrebakeConfig

The [tool.click-extra.prebake] sub-table (target module).

        classDiagram
  Enum <|-- ConfigFormat
    

Configuration file formats and their stateless content parsers.

Holds the ConfigFormat enum, the optional third-party parser probes that decide which formats are enabled, and parse_content(), the stateless dispatch used by ConfigOption for every format that does not need the CLI parameter structure.

click_extra.config.formats.PARSER_SUPPORT: dict[str, bool] = {'hjson': True, 'json5': True, 'jsonc': True, 'xml': True, 'yaml': True}

Availability of each optional parser, keyed by click-extra[extra] name.

Populated once at import time by probing each module in _OPTIONAL_PARSERS with importlib.util.find_spec(). Read by ConfigFormat to mark the matching format as enabled or disabled. The probe does not import the module, so the actual parser is loaded lazily by parse_content() only when used.

class click_extra.config.formats.ConfigFormat(*values)[source]

Bases: Enum

All configuration formats, associated to their support status.

The first element of the tuple is a sequence of file extensions associated to the format. Patterns are fed to wcmatch.glob for matching, and are influenced by the flags set on the ConfigOption instance.

The second element indicates whether the format is supported or not, depending on the availability of the required third-party packages. This evaluation is performed at runtime when this module is imported.

Caution

The order is important for both format members and file patterns. It defines the priority order in which formats are tried when multiple candidate files are found.

Todo

Add support for JWCC / hujson format?

TOML = (('*.toml',), True, 'TOML')
YAML = (('*.yaml', '*.yml'), True, 'YAML')
JSON = (('*.json',), True, 'JSON')
JSON5 = (('*.json5',), True, 'JSON5')
JSONC = (('*.jsonc',), True, 'JSONC')
HJSON = (('*.hjson',), True, 'Hjson')
INI = (('*.ini',), True, 'INI')
XML = (('*.xml',), True, 'XML')
PYPROJECT_TOML = (('pyproject.toml',), True, 'pyproject.toml')
property label: str

Human-friendly name of the format for display in messages.

property enabled: bool

Returns True if the format is supported, False otherwise.

property patterns: tuple[str, ...]

Returns the default file patterns associated to the format.

click_extra.config.formats.parse_content(fmt, content)[source]

Parse content with a single stateless format.

INI is excluded: it needs the CLI parameter structure for type coercion and is handled by ConfigOption.load_ini_config.

Note

Optional third-party parsers are imported lazily, at the point of use, rather than at module load. Only enabled formats reach this function (disabled ones are filtered out of ConfigOption.file_format_patterns), so the import always resolves for the formats actually parsed here.

Return type:

Any

click_extra.config.formats.SERIALIZABLE_FORMATS: tuple[ConfigFormat, ...] = (ConfigFormat.TOML, ConfigFormat.YAML, ConfigFormat.JSON, ConfigFormat.JSON5, ConfigFormat.JSONC, ConfigFormat.HJSON, ConfigFormat.XML)

Configuration formats serialize_content() can write, in priority order.

Every ConfigFormat except INI and PYPROJECT_TOML, which have no serializer. JSON, JSON5 and JSONC are emitted as plain JSON through the standard library, so they need no optional dependency; the others require their format’s extra.

Caution

Keep this in sync with the match statement in serialize_content().

click_extra.config.formats.serialize_content(fmt, data, **kwargs)[source]

Serialize a Python object to a string in the given format.

The dumping counterpart to parse_content(). Per-format defaults can be overridden through kwargs (forwarded to the underlying serializer). JSON5 and JSONC are emitted as plain JSON, a valid subset of both.

Caution

Not every format round-trips: TOML and XML have no null type, and XML expects a single root mapping, so the caller is responsible for shaping data accordingly. INI and pyproject.toml have no serializer here.

Note

Optional third-party serializers are imported lazily, at the point of use. Writing TOML uses tomlkit (the [toml] extra), unlike reading which relies on the built-in tomllib.

Raises:

ValueError – the format has no serializer.

Return type:

str

click_extra.config.formats.format_from_path(path, formats=None)[source]

Return the configuration format whose patterns match a file name.

The name is matched against each format’s patterns, so app.toml resolves to TOML and app.yml to YAML. formats restricts and orders the candidates (the first match wins); it defaults to every ConfigFormat.

Return type:

ConfigFormat | None

click_extra.config.formats.disabled_format_message(fmt)[source]

Build the “format support disabled, install the extra” message for a format.

The single source for the ImportError text raised when a format whose optional parser is not installed is requested, shared by read_file() and click_extra.test_suite.parse_test_suite(). A format’s label, lower-cased, is its click-extra[<extra>] install target.

Return type:

str

click_extra.config.formats.read_file(path, formats=None)[source]

Read a file and parse it, picking the format from its name.

The format is resolved with format_from_path() over formats (every ConfigFormat by default), then the content is parsed with parse_content().

Raises:
  • ValueError – the file name matches none of the candidate formats.

  • ImportError – the matched format’s optional parser is not installed.

Return type:

Any

        classDiagram
  Enum <|-- Sentinel
  ExtraOption <|-- ConfigOption
  ExtraOption <|-- ExportConfigOption
  ExtraOption <|-- NoConfigOption
  ExtraOption <|-- ValidateConfigOption
  ParamStructure <|-- ConfigOption
    

Utilities to load parameters and options from a configuration file.

Hint

Why config?

That whole namespace is using the common config short-name to designate configuration files.

Not conf, not cfg, not configuration, not settings. Just config.

A quick survey of existing practices, and poll to my friends informed me that config is more explicit and less likely to be misunderstood.

After all, is there a chance for it to be misunderstood, in the context of a CLI, for something else? Confirm? Conference? Conflict Confuse?…

So yes, config is good enough.

Dotted keys in configuration files (like "subcommand.option": value) are automatically expanded into nested dicts before merging, so users can freely mix flat dot-notation and nested structures in any supported format.

click_extra.config.option.VCS_DIRS = ('.git', '.hg', '.svn', '.bzr', 'CVS', '.darcs')

VCS directory names used to identify version control system roots.

Includes: - .git: Git - .hg: Mercurial - .svn: Subversion - .bzr: Bazaar - CVS: CVS (note: uppercase, no leading dot) - .darcs: Darcs

click_extra.config.option.CONFIG_OPTION_NAME = 'config'

Hardcoded name of the configuration option.

This name is going to be shared by both the --config and --no-config options below, so they can compete with each other to either set a path pattern or disable the use of any configuration file at all.

click_extra.config.option.DEFAULT_EXCLUDED_PARAMS = ('config', 'export_config', 'help', 'show_params', 'version')

Default parameter IDs to exclude from the configuration file.

Defaults to:

  • --config option, which cannot be used to recursively load another configuration file.

  • --export-config flag, which like --show-params introspects the CLI and exits, so it has no place in the configuration it would export.

  • --help, as it makes no sense to have the configurable file always forces a CLI to show the help and exit.

  • --show-params flag, which is like --help and stops the CLI execution.

  • --version, which is not a configurable option per-se.

class click_extra.config.option.Sentinel(*values)[source]

Bases: Enum

Enum used to define sentinel values.

Note

This reuse the same pattern as Click._utils.Sentinel.

NO_CONFIG = <object object>
VCS = <object object>
click_extra.config.option.NO_CONFIG = Sentinel.NO_CONFIG

Sentinel used to indicate that no configuration file must be used at all.

click_extra.config.option.VCS = Sentinel.VCS

Sentinel used to stop parent directory walking at the nearest VCS root.

class click_extra.config.option.ConfigOption(param_decls=None, metavar='CONFIG_PATH', type=UNPROCESSED, help='Location of the configuration file. Supports local path with glob patterns or remote URL.', is_eager=True, expose_value=False, file_format_patterns=None, file_pattern_flags=4104, roaming=True, force_posix=False, search_pattern_flags=285504, search_parents=False, stop_at=Sentinel.VCS, excluded_params=None, included_params=None, strict=False, config_schema=None, schema_strict=False, fallback_sections=(), config_validators=(), **kwargs)[source]

Bases: ExtraOption, ParamStructure

A pre-configured option adding --config CONFIG_PATH.

Takes as input a path to a file or folder, a glob pattern, or an URL.

  • is_eager is active by default so the callback gets the opportunity to set the default_map of the CLI before any other parameter is processed.

  • default is set to the value returned by self.default_pattern(), which is a pattern combining the default configuration folder for the CLI (as returned by click.get_app_dir()) and all supported file formats.

    Attention

    Default search pattern must follow the syntax of wcmatch.glob.

  • excluded_params are parameters which, if present in the configuration file, will be ignored and not applied to the CLI. Items are expected to be the fully-qualified ID of the parameter, as produced in the output of --show-params. Will default to the value of DEFAULT_EXCLUDED_PARAMS.

  • included_params is the inverse of excluded_params: only the listed parameters will be loaded from the configuration file. Cannot be used together with excluded_params.

file_format_patterns: dict[ConfigFormat, tuple[str, ...]]

Mapping of ConfigFormat to their associated file patterns.

Can be a string or a sequence of strings. This defines which configuration file formats are supported, and which file patterns are used to search for them.

Note

All formats depending on third-party dependencies that are not installed will be ignored.

Attention

File patterns must follow the syntax of wcmatch.fnmatch.

file_pattern_flags

Flags provided to all calls of wcmatch.fnmatch.

Applies to the matching of file names against supported format patterns specified in file_format_patterns.

Important

The SPLIT flag is always forced, as our multi-pattern design relies on it.

force_posix

Configuration for default folder search.

roaming and force_posix are fed to click.get_app_dir() to determine the location of the default configuration folder.

search_pattern_flags

Flags provided to all calls of wcmatch.glob.

Applies to both the default pattern and any user-provided pattern.

Important

The BRACE flag is always forced, so that multi-format default patterns using {pat1,pat2,...} syntax expand correctly.

The NODIR flag is always forced, to optimize the search for files only.

search_parents

Indicates whether to walk back the tree of parent folders when searching for configuration files.

stop_at

Boundary for parent directory walking.

  • None: walk up to filesystem root.

  • VCS: stop at the nearest VCS root (.git or .hg) (default).

  • A Path or str: stop at that directory.

included_params: frozenset[str] | None

Allowlist of parameter IDs, mutually exclusive with excluded_params.

None disables the allowlist. It is resolved into excluded_params by build_param_trees(), once every parameter ID is known.

strict

Defines the strictness of the configuration loading.

  • If True, raise an error if the configuration file contain parameters not recognized by the CLI.

  • If False, silently ignore unrecognized parameters.

config_schema

Optional schema for structured access to configuration values.

When set, the app’s configuration section is extracted from the parsed config file, normalized (hyphens replaced with underscores), flattened (nested dicts joined with _), and passed to this callable to produce a typed configuration object.

Supports:

  • Dataclass types: detected via __dataclass_fields__. Keys are normalized, nested dicts are flattened, and the result is filtered to known fields before instantiation. This allows nested config sections (like [tool.myapp.sub-section]) to map directly to flat dataclass fields (like sub_section_key).

  • Any callable dict T: called directly with the raw dict. Works with Pydantic’s Model.model_validate, attrs, or custom factory functions. The caller is responsible for key normalization and flattening.

The resulting object is stored in ctx.meta[click_extra.context.TOOL_CONFIG] and can be retrieved via get_tool_config.

schema_strict

Strictness for schema validation (separate from strict).

  • If True, raise ValueError when the config section contains keys that do not match any dataclass field (after normalization and flattening). Only applies when config_schema is a dataclass.

  • If False, silently ignore unrecognized keys.

Note

This is distinct from strict, which controls whether merge_default_map rejects config keys not matching CLI parameters. schema_strict validates against dataclass fields instead.

fallback_sections: Sequence[str]

Legacy section names to try when the app’s own section is empty.

Useful when a CLI tool has been renamed: old configuration files that still use [tool.old-name] (TOML), old-name: (YAML), or {"old-name": …} (JSON) are recognized with a deprecation warning. Works with all configuration formats.

config_validators: tuple[ConfigValidator, ...]

Extension validators for sub-trees of the configuration file.

Each ConfigValidator targets a dotted extension_path relative to the app section. Validators run after click-extra’s built-in CLI-parameter strict check (during --validate-config) and after the schema callable produces the typed configuration object (during normal config loading).

The list is seeded with click-extra’s built-in validators (currently the one for [tool.<cli>.themes.<name>] tables, see click_extra.theme.validate_themes_config()); user-supplied validators are appended after them. App code that registers its own validator on the same extension_path simply runs alongside the built-in: both validators are called, both sets of errors surface.

property excluded_params: frozenset[str][source]

Generates the default list of fully-qualified IDs to exclude.

Danger

It is only called once to produce the default exclusion list if the user did not provided its own.

It was not implemented in the constructor but made as a property, to allow for a just-in-time call within the current context. Without this trick we could not have fetched the CLI name.

property file_pattern: str[source]

Compile all file patterns from the supported formats.

Uses , (comma) notation to combine multiple patterns, suitable for wcmatch brace expansion ({pat1,pat2,...}).

Returns a single pattern string.

default_pattern()[source]

Returns the default pattern used to search for the configuration file.

Defaults to <app_dir>/{*.toml,*.json,*.ini}. Where <app_dir> is produced by the click.get_app_dir() method. The result depends on OS and is influenced by the roaming and force_posix properties.

Multiple file format patterns are wrapped with {…} brace-expansion syntax so that wcmatch.glob correctly applies the directory prefix to every sub-pattern.

Todo

Use platformdirs for more advanced configuration folder detection?

Return type:

str

get_help_extra(ctx)[source]

Replaces the default value of the configuration option.

Display a pretty path that is relative to the user’s home directory:

~/folder/my_cli/{*.toml,*.json,*.ini}

Instead of the full absolute path:

/home/user/folder/my_cli/{*.toml,*.json,*.ini}

Caution

This only applies when the GLOBTILDE flag is set in search_pattern_flags.

Return type:

OptionHelpExtra

parent_patterns(pattern)[source]

Generate (root_dir, file_pattern) pairs for searching.

Each yielded pair can be passed directly to glob.iglob(file_pattern, root_dir=root_dir) so that every sub-pattern (whether from BRACE or SPLIT expansion) is correctly scoped to the same directory.

root_dir is None for entirely magic patterns that will be evaluated relative to the current working directory.

Stops when reaching the root folder, the stop_at boundary, or an inaccessible directory.

Return type:

Iterable[tuple[str | None, str]]

search_and_read_file(pattern)[source]

Search filesystem or URL for files matching the pattern.

If pattern is an URL, download its content. A pattern is considered an URL only if it validates as one and starts with http:// or https://. All other patterns are considered glob patterns for local filesystem search.

Returns an iterator of the normalized location and its raw content, for each one matching the pattern. Only files are returned, directories are silently skipped.

This method returns the raw content of all matching patterns, without trying to parse them. If the content is empty, it is still returned as-is.

Also includes lookups into parents directories if self.search_parents is True.

Raises FileNotFoundError if no file was found after searching all locations.

Return type:

Iterable[tuple[Path | URL, str]]

parse_conf(content, formats)[source]

Parse the content with the given formats.

Tries to parse the given raw content string with each of the given formats, in order. Yields the resulting data structure for each successful parse.

Attention

Formats whose parsing raises an exception or does not return a dict are considered a failure and are skipped.

This follows the parse, don’t validate principle.

Return type:

Iterable[dict[str, Any] | None]

read_and_parse_conf(pattern)[source]

Search for a parseable configuration file.

Returns the location and data structure of the first configuration matching the pattern.

Only return the first match that:

  • exists,

  • is a file,

  • is not empty,

  • match file format patterns,

  • can be parsed successfully, and

  • produce a non-empty data structure.

Raises FileNotFoundError if no configuration file was found matching the criteria above.

Returns (None, None) if files were found but none could be parsed.

Return type:

tuple[Path | URL, dict[str, Any]] | tuple[None, None]

load_ini_config(content)[source]

Utility method to parse INI configuration file.

Internal convention is to use a dot (., as set by PARAM_PATH_SEP) in section IDs as a separator between levels. This is a workaround the limitation of INI format which doesn’t allow for sub-sections.

Returns a ready-to-use data structure.

Return type:

dict[str, Any]

merge_default_map(ctx, user_conf)[source]

Save the user configuration into the context’s default_map.

Merge the user configuration into the pre-computed template structure, which filters out all unrecognized options not supported by the command, then hand the result to _install_default_map().

Opaque sub-trees declared by the schema or by registered ConfigValidator instances are stripped from the conf before the CLI-parameter strict check, so user-controlled keys (like mappings whose keys are data, not flag names) don’t trip strict=True.

Note

This recomputes the filtered config that run_config_validation() already produces as merged_conf. load_conf() installs that result directly and skips this method; it stays as the standalone entry point for external callers.

Return type:

None

load_conf(ctx, param, path_pattern)[source]

Fetch parameter values from a configuration file and set them as defaults.

User configuration is merged to the context’s default_map, like Click does.

By relying on Click’s default_map, we make sure that precedence is respected. Direct CLI parameters, environment variables or interactive prompts take precedence over any values from the config file.

Hint

Once loading is complete, the resolved file path and its full parsed content are stored in ctx.meta[click_extra.context.CONF_SOURCE] and ctx.meta[click_extra.context.CONF_FULL] respectively. This is the recommended way to identify which configuration file was loaded.

We intentionally do not add a custom ParameterSource.CONFIG_FILE enum member: ParameterSource is a closed enum in Click, and monkeypatching it would be fragile. Besides, config values end up in default_map, so Click already reports them as ParameterSource.DEFAULT_MAP, which is accurate.

Return type:

None

class click_extra.config.option.NoConfigOption(param_decls=None, type=UNPROCESSED, help='Ignore all configuration files and only use command line parameters and environment variables.', is_flag=True, flag_value=Sentinel.NO_CONFIG, is_eager=True, expose_value=False, **kwargs)[source]

Bases: ExtraOption

A pre-configured option adding --no-config.

This option is supposed to be used alongside the --config option (ConfigOption) to allow users to explicitly disable the use of any configuration file.

This is especially useful to debug side-effects caused by autodetection of configuration files.

flag_value=NO_CONFIG is the Sentinel enum member that signals “skip configuration loading” to ConfigOption. Click 8.4.0 (PR pallets/click#3363) auto-detects type=UNPROCESSED for non-basic flag_value types, but click-extra still supports Click 8.3.x where that auto-detection is absent, so the type=UNPROCESSED override is kept explicit to let the sentinel pass through Option unchanged on every supported Click.

See also

An alternative implementation of this class would be to create a custom click.ParamType instead of a custom Option subclass. Here is for example.

check_sibling_config_option(ctx, param, value)[source]

Ensure that this option is used alongside a ConfigOption instance.

Return type:

None

class click_extra.config.option.ValidateConfigOption(param_decls=None, type=<click.types.Path object>, is_eager=True, expose_value=False, help='Validate the configuration file and exit.', **kwargs)[source]

Bases: ExtraOption

A pre-configured option adding --validate-config CONFIG_PATH.

Loads the config file at the given path, validates it against the CLI’s parameter structure in strict mode, reports results, and exits.

validate_config(ctx, param, value)[source]

Load, parse, and validate the configuration file, then exit.

Validation runs three checks in order, every one of them under the same ValidationError shape so the reported path is always rooted at the configuration file:

  1. CLI-parameter strict check on the non-opaque part of the document.

  2. Schema processing, if a config_schema is configured: catches type errors and unknown keys inside the dataclass-described section.

  3. Each registered ConfigValidator runs against its declared opaque sub-tree.

Every detected error is emitted before exiting, so a single --validate-config run surfaces the full list of fixes the user needs to apply.

Return type:

None

class click_extra.config.option.ExportConfigOption(param_decls=None, type=None, metavar='FORMAT', is_eager=True, expose_value=False, help='Export the configuration in the selected format to <stdout>, then exit.', **kwargs)[source]

Bases: ExtraOption

A pre-configured option adding --export-config FORMAT.

Resolves the CLI’s current parameter values following Click’s precedence chain (command line, then environment variables, then configuration file, then defaults), renders them as a configuration file in the requested format on <stdout>, and exits.

Hint

Combine the flag with other options or environment variables to capture them in the generated configuration. For example, mycli --verbosity DEBUG --export-config toml emits a configuration whose verbosity is already set to DEBUG.

Like ValidateConfigOption, it relies on a sibling ConfigOption to provide the parameter structure and the excluded_params / included_params filter, so the export contains exactly the parameters that can be loaded back from a configuration file.

Note

The accepted formats are those serialize_content() can write (SERIALIZABLE_FORMATS). INI and pyproject.toml have no serializer and cannot be dumped.

build_config(ctx, config_option)[source]

Resolve every config-eligible parameter into a dumpable tree.

Walks the sibling ConfigOption’s parameter structure, resolves each parameter’s effective value by replaying RAW_ARGS (falling back to defaults when the command did not capture them), drops the excluded_params, and layers the coerced values into the {cli-name: {param: value, ...}} shape a configuration file uses. Blank values are removed, mirroring the clean-up ConfigOption._install_default_map() applies on load.

Return type:

dict[str, Any]

export_config(ctx, param, value)[source]

Render the resolved configuration to <stdout> and exit.

Return type:

None

        classDiagram
  Exception <|-- ValidationError
    

Schema-building and validation engine behind config_option and –validate-config.

click_extra.config.schema.DEFAULT_SUBCOMMANDS_KEY = '_default_subcommands'

Reserved configuration key for specifying default subcommands.

When a group is invoked without explicit subcommands on the CLI, the subcommands listed under this key execute automatically in order. CLI always wins: if the user names subcommands explicitly, the config is ignored.

Example TOML configuration:

[my-cli]
_default_subcommands = ["backup", "sync"]

[my-cli.backup]
path = "/home"
click_extra.config.schema.PREPEND_SUBCOMMANDS_KEY = '_prepend_subcommands'

Reserved configuration key for prepending subcommands to every invocation.

Unlike _default_subcommands which only fires when no subcommands are given on the CLI, _prepend_subcommands always prepends the listed subcommands. This is useful for always injecting a debug subcommand on a dev machine, for example.

Only works with chain=True groups (non-chained groups resolve exactly one subcommand, so prepending would break the user’s intended command).

Example TOML configuration:

[my-cli]
_prepend_subcommands = ["debug"]
click_extra.config.schema.EXTENSION_METADATA_KEY = 'click_extra.extension'

Dataclass field metadata flag marking a field as an extension point.

Schema authors set metadata={EXTENSION_METADATA_KEY: True} on a field when its sub-tree should pass through click-extra’s CLI-parameter strict check and be validated by app-specific logic instead. Equivalent to typing the field as dict[str, X]: both forms are recognized by _collect_opaque_paths_from_schema (the internal pipeline still calls these paths “opaque” since they’re skipped by the normalize/flatten/strict machinery). The metadata form is useful when the underlying Python type is something other than a dict (for example, a nested dataclass that nonetheless represents user-extensible content).

click_extra.config.schema.CONFIG_PATH_METADATA_KEY = 'click_extra.config_path'

Dataclass field metadata key pinning a field to an explicit config sub-path.

Schema authors set metadata={CONFIG_PATH_METADATA_KEY: "test-suite"} on a field so the dataclass loader (_from_dataclass) reads its value from that dotted path under the app’s configuration section, rather than from a key named after the field. The named counterpart to EXTENSION_METADATA_KEY.

click_extra.config.schema.NORMALIZE_KEYS_METADATA_KEY = 'click_extra.normalize_keys'

Dataclass field metadata key toggling key normalization on a field’s value.

Defaults to True. Schema authors set metadata={NORMALIZE_KEYS_METADATA_KEY: False} to keep a sub-tree’s keys verbatim, so external identifiers (like python-version axis names) are not rewritten to Python-style names (python_version). Read by _from_dataclass alongside CONFIG_PATH_METADATA_KEY.

exception click_extra.config.schema.ValidationError(path, message, code=None)[source]

Bases: Exception

Raised when a configuration file fails validation.

A single, structured exception type that uniformly carries the dotted path of the offending key, a human-readable message, and an optional code for programmatic handling. Used by click-extra’s built-in strict-mode check and by every user-registered ConfigValidator, so downstream apps and --validate-config see the same error shape regardless of who detected the problem.

Parameters:
  • path (str) – Dotted path to the offending key, relative to the configuration file root (like "my-cli.managers.winget.cli_searchpath"). An empty string means the error applies to the document as a whole.

  • message (str) – Human-readable description of the failure. Should be a single sentence, no trailing punctuation, no path repeated.

  • code (str | None) – Optional machine-readable error code (like "unknown_field") for callers that want to dispatch on error type without parsing the message string.

class click_extra.config.schema.ConfigValidator(extension_path, validator, description='')[source]

Bases: object

Register an app-defined extension validator for one sub-tree of the configuration file.

Apps register validators via the config_validators= kwarg on ConfigOption (or the matching decorator) to extend click-extra’s built-in CLI-parameter strict check with custom validation logic. Each validator targets a single dotted extension_path relative to the app’s configuration section. Click-extra passes the matching sub-tree straight through to the registered validator: the strict check skips it, the schema machinery treats it as opaque, and the user’s logic owns the result. The validator runs both during --validate-config and during normal config loading.

Parameters:
  • extension_path (str) – Dotted path of the sub-tree the validator owns, relative to the app’s section in the configuration file. For example, an app named my-cli with extension_path="managers" receives the contents of the [my-cli.managers] table.

  • validator (Callable[[dict[str, Any]], None]) – Callable taking the sub-tree dict and raising ValidationError on failure. Must be a pure function: no side effects on the click context, no print statements. The caller decides how to surface the error.

  • description (str) – Optional human-readable summary of what the validator checks. Surfaces in documentation generators that introspect the decorator (like autodoc), and may be reused in --help text in a future release.

extension_path: str
validator: Callable[[dict[str, Any]], None]
description: str = ''
click_extra.config.schema.normalize_config_keys(conf, opaque_keys=frozenset({}), _prefix='')[source]

Normalize configuration keys to valid Python identifiers.

Recursively replaces hyphens with underscores in all dict keys, using the same str.replace("-", "_") transform that Click applies internally when deriving parameter names from option declarations (--foo-bar becomes foo_bar). Click does not expose this as a public function, so we replicate the one-liner here.

Handles the convention mismatch between configuration formats (TOML, YAML, JSON all commonly use kebab-case) and Python identifiers. Works with all configuration formats supported by ConfigOption.

Parameters:
  • opaque_keys (frozenset[str]) – Fully-qualified key names (using "_" as separator) where recursion stops. The key itself is still normalized, but its dict value is kept as-is. Used in tandem with flatten_config_keys’s opaque_keys to protect data dicts (like GitHub Actions matrix axes) from normalization.

  • _prefix (str) – Internal parameter for tracking the accumulated key path during recursion. Callers should not set this.

Todo

Propose upstream to Click to extract the inline name.replace("-", "_") into a private _normalize_param_name helper, so downstream projects like Click Extra can reuse it instead of duplicating the transform.

Return type:

dict[str, Any]

click_extra.config.schema.flatten_config_keys(conf, sep='_', opaque_keys=frozenset({}), _prefix='')[source]

Flatten nested dicts into a single level by joining keys with a separator.

Useful for mapping nested configuration structures (like TOML sub-tables) to flat Python dataclass fields. After normalization with normalize_config_keys, the flattened keys match dataclass field names directly:

>>> from click_extra.config import (
...     flatten_config_keys,
...     normalize_config_keys,
... )
>>> raw = {"dependency-graph": {"all-groups": True, "output": "deps.mmd"}}
>>> flatten_config_keys(normalize_config_keys(raw))
{'dependency_graph_all_groups': True, 'dependency_graph_output': 'deps.mmd'}
Parameters:
  • conf (dict[str, Any]) – Nested dictionary to flatten.

  • sep (str) – Separator used to join parent and child keys. Defaults to "_" which produces valid Python identifiers when combined with normalize_config_keys.

  • opaque_keys (frozenset[str]) – Fully-qualified key names where flattening stops. When the accumulated key matches an entry in this set, the dict value is kept as-is instead of being recursively flattened. This is useful for fields typed as dict[str, X] where the dict keys are data (like GitHub Actions matrix axis names), not config structure.

  • _prefix (str) – Internal parameter for tracking the accumulated key path during recursion. Callers should not set this.

Return type:

dict[str, Any]

click_extra.config.schema.get_tool_config(ctx=None)[source]

Retrieve the typed tool configuration from the context.

Returns the object stored under click_extra.context.TOOL_CONFIG by ConfigOption when a config_schema is set, or None if no schema was configured or no configuration was loaded.

Parameters:

ctx (Context | None) – Click context. Defaults to the current context.

Return type:

Any

click_extra.config.schema.make_schema_callable(schema, *, strict=False, normalize=True)[source]

Wrap a schema type into a callable that accepts a raw config dict.

  • Dataclass types (detected via dataclasses.is_dataclass) are auto-wrapped: keys are normalized (hyphens to underscores), nested dicts are flattened, and the result is filtered to known fields before instantiation. Three schema-aware features refine this process:

    1. Type-aware flattening. Fields typed as dict[str, X] are treated as opaque: flatten_config_keys stops at their boundary so the dict value is kept intact.

    2. Field metadata. Dataclass fields may carry click_extra.config_path (a dotted TOML path like "test-matrix.replace") and click_extra.normalize_keys (False to skip key normalization on the extracted value). Fields with an explicit path are extracted from the raw config before normalization and flattening.

    3. Nested dataclass support. Fields whose resolved type is itself a dataclass are recursively processed with the same logic.

  • Any other callable is returned as-is. The caller is responsible for key normalization if needed.

  • None returns None.

Parameters:
  • strict (bool) – If True, raise ValueError when the config contains keys that do not match any dataclass field (after normalization and flattening).

  • normalize (bool) – If False, skip normalize_config_keys on the remaining config dict. Used internally when recursing into nested dataclasses whose parent opted out of normalization via click_extra.normalize_keys = False.

Return type:

Callable[[dict[str, Any]], Any] | None

class click_extra.config.schema.ValidationReport(schema_instance, opaque_subtrees, errors, merged_conf=None)[source]

Bases: object

Outcome of one pass through run_config_validation().

Bundles everything a caller needs after validating a parsed configuration document: the typed schema instance, the extracted opaque sub-trees, the template-filtered config ready for default_map, and every error detected across all validation stages.

Note

The report holds references to the parsed sub-trees, not copies, so building it is cheap regardless of document size.

schema_instance: Any | None

Typed object produced by the configured schema callable.

None when no schema is configured, or when the schema stage raised (in which case the failure is recorded in errors).

opaque_subtrees: dict[str, dict[str, Any]]

Extracted extension sub-trees, keyed by dotted path relative to the app section. Only paths actually present in the document appear here, so callers can re-route them to per-path validators or stash them on ctx.meta.

errors: tuple[ValidationError, ...]

Every ValidationError detected, in stage order (unknown CLI-flag keys first, then schema errors, then validator failures). Empty on success.

With collect_all=False this holds at most one error: the first failure short-circuits the remaining stages.

merged_conf: dict[str, Any] | None = None

The CLI-flag-bound configuration merged onto params_template: the payload _install_default_map() layers into the context’s default_map.

None when params_template was None (no strict check) or the strict check raised. Read it only on a successful report: it is the same value merge_default_map() would recompute, so reusing it avoids a second normalize/strip/merge pass.

property ok: bool

True when no error was detected.

click_extra.config.schema.run_config_validation(user_conf, *, app_name, params_template, config_schema=None, config_validators=(), fallback_sections=(), schema_strict=False, strict=False, collect_all=True)[source]

Validate a parsed configuration document in one schema-driven pass.

This is the module-level entry point that unifies click-extra’s three historical validation paths (CLI-parameter strict check, dataclass schema, and app-registered ConfigValidator hooks) behind a single function yielding a single error type. It is deliberately not named validate_config: that name belongs to validate_config(), the callback powering the --validate-config flag.

Stages, in order:

  1. Normalize. Strip reserved keys and expand dotted keys.

  2. Partition. Split opaque sub-trees (schema extension fields plus every registered validator’s extension_path) from the CLI-flag-bound content. Extracted sub-trees land in ValidationReport.opaque_subtrees.

  3. Strict-check the CLI-flag-bound part against params_template, keeping the merged result as ValidationReport.merged_conf (skipped when params_template is None).

  4. Schema-build the app section through the configured callable, producing ValidationReport.schema_instance.

  5. Validate every opaque sub-tree through its registered validator.

Parameters:
  • user_conf (dict[str, Any]) – The full parsed configuration document.

  • app_name (str) – Name of the app’s section (used to resolve the section and to root opaque paths and error paths at the document level).

  • params_template (dict[str, Any] | None) – The CLI-parameter template the strict check runs against. Pass None to skip the strict check entirely (for example, for a schema-only validation).

  • config_schema (type | Callable[[dict[str, Any]], Any] | None) – Dataclass type or callable describing the typed configuration, or None.

  • config_validators (Sequence[ConfigValidator]) – Extension validators to run against opaque sub-trees.

  • fallback_sections (Sequence[str]) – Legacy section names to try when app_name is absent or empty.

  • schema_strict (bool) – Reject keys the dataclass schema does not recognize.

  • strict (bool) – Reject keys the CLI-parameter template does not recognize.

  • collect_all (bool) – When True (default), run every stage and collect all errors. When False, the first error short-circuits the rest.

Return type:

ValidationReport

Returns:

A ValidationReport. ValidationError is the single error type recorded by every stage; ValueError / TypeError raised by the strict check or schema callable are wrapped into it.