Configuration schema

By default, ConfigOption only feeds configuration values that match CLI options into the context’s default_map. All other keys are silently ignored. This works when the configuration file mirrors the CLI, but some applications need additional configuration that matches no CLI option.

The config_schema parameter extracts the app’s configuration section, normalizes its keys, and produces 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 a schema-only section (included_params=()) so unknown keys warn. 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]
  -h, --help                   Show this message and exit.

Configuration options:
  --config LOCATION            Location of the configuration file. Supports
                               local path with glob patterns or remote URL.
                               [default: ~/.config/my-app/]
  --no-config                  Ignore all configuration files and only use
                               command line parameters and environment
                               variables.
  --validate-config LOCATION   Validate the configuration file and exit.
  --export-config FORMAT       Export the configuration in the selected format
                               to <stdout>, then exit.

Output options:
  --accessible                 Accessibility mode: disable colors and render
                               tables in a borderless, 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 [auto|dark|dracula|light|manpage|monokai|nord|solarized-dark]
                               Color theme used for help screens.  [default:
                               dark]
  --table-format FORMAT        Rendering style of tables.  [default: rounded-
                               outline]

Logging options:
  --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]
  --debug                      Shorthand for --verbosity DEBUG.

Introspection options:
  --time / --no-time           Measure and print elapsed execution time.
                               [default: no-time]
  --params                     Show all CLI parameters, their provenance,
                               defaults and value, then exit.
  --tree                       Show the tree of nested subcommands and exit.
  --man                        Read the command's manual page and exit.
  --help-format [carapace|json|json-full|man|markdown|markdown-full]
                               Render the command in the given format and exit.
  --version                    Show the version and exit.

Commands:
  greet  Say hello.
  help   Show help for a command.
/home/runner/work/click-extra/click-extra/click_extra/commands.py:920: UserWarning: The parameter --verbose is used more than once. Remove its duplicate as parameters should be unique.
  self._resolve_presentation_eagerly(ctx, args)
/home/runner/work/click-extra/click-extra/.venv/lib/python3.14/site-packages/click/core.py:1369: 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:1988: 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:1948: 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:324: 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:
  -h, --help                   Show this message and exit.

Configuration options:
  --config LOCATION            Location of the configuration file. Supports
                               local path with glob patterns or remote URL.
                               [default: ~/.config/callable-app/]
  --no-config                  Ignore all configuration files and only use
                               command line parameters and environment
                               variables.
  --validate-config LOCATION   Validate the configuration file and exit.
  --export-config FORMAT       Export the configuration in the selected format
                               to <stdout>, then exit.

Output options:
  --accessible                 Accessibility mode: disable colors and render
                               tables in a borderless, 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 [auto|dark|dracula|light|manpage|monokai|nord|solarized-dark]
                               Color theme used for help screens.  [default:
                               dark]
  --table-format FORMAT        Rendering style of tables.  [default: rounded-
                               outline]

Logging options:
  --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]
  --debug                      Shorthand for --verbosity DEBUG.

Introspection options:
  --time / --no-time           Measure and print elapsed execution time.
                               [default: no-time]
  --params                     Show all CLI parameters, their provenance,
                               defaults and value, then exit.
  --tree                       Show the tree of nested subcommands and exit.
  --man                        Read the command's manual page and exit.
  --help-format [carapace|json|json-full|man|markdown|markdown-full]
                               Render the command in the given format and exit.
  --version                    Show the version 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 every format ConfigOption supports. 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 ignored: the section may legitimately mix CLI parameter keys with schema fields, so an unrecognized key is not necessarily a typo. Two mechanisms tighten this:

  • When the section is schema-only (included_params=(), so no CLI parameter is merged from it), any unknown key can only be a typo: lax mode then logs a warning naming it, while still loading the known fields.

  • The schema_strict parameter goes further and reports 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).

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.

click_extra.config.schema API

        classDiagram
  Exception <|-- ValidationError
  tuple <|-- SchemaFieldInfo
    

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

class click_extra.config.schema.SchemaFieldInfo(key: str, type_hint: str, default: Any, summary: str, description: str)[source]

Bases: NamedTuple

Documentation record for one option of a configuration schema.

Produced by schema_field_infos(). Consumed by the click:config Sphinx directive, and by CLIs building their own configuration reference (a show-config table, say) from the same introspection.

Create new instance of SchemaFieldInfo(key, type_hint, default, summary, description)

key: str

Dotted configuration path of the option.

Field names are kebab-cased (setup_guidesetup-guide) unless the field pins an explicit path through click_extra.config.schema.CONFIG_PATH_METADATA_KEY. Nested dataclass fields contribute one segment per nesting level (test-suite.timeout).

type_hint: str

The field’s type annotation, as written in the schema source.

default: Any

The field’s default value, taken from a pristine schema instance.

summary: str

First paragraph of the field’s attribute docstring, collapsed onto a single line. Empty when the field has no docstring or the class source is unavailable (see field_docstrings()).

description: str

Full attribute docstring of the field, paragraph breaks preserved.

click_extra.config.schema.field_docstrings(cls)[source]

Extract attribute docstrings from a class body, keyed by field name.

Attribute docstrings are string literals immediately following an annotated assignment in a class body (the PEP 257 convention used by Sphinx’s autodoc). Python discards them at runtime, so they are recovered by parsing the class source with ast. Each docstring is cleaned up with inspect.cleandoc(), preserving paragraph breaks.

Caution

Returns an empty mapping when the class source is unavailable, as for classes defined in an exec-ed code block (an interactive session, or the body of a click:source Sphinx directive). Import the schema from a real module to get its docstrings documented.

Return type:

dict[str, str]

click_extra.config.schema.schema_field_infos(schema)[source]

Walk a configuration schema dataclass into per-option records.

Introspects the dataclass fields, their type annotations, defaults, and attribute docstrings. Nested dataclass fields expand recursively into dotted keys (test-suite.timeout), honoring click_extra.config.schema.CONFIG_PATH_METADATA_KEY at every level. Records are sorted by key, segment-wise, so a sub-table’s options stay contiguous even when another table’s name shares their prefix (workflow.sync sorts before workflow-pins.sync).

Defaults are read off a pristine schema() instance, so every field must carry a default: configuration schemas are default-complete by construction, since make_schema_callable() instantiates them from partial user data.

Raises:

TypeError – when schema is not a dataclass type.

Return type:

list[SchemaFieldInfo]

click_extra.config.schema.make_schema_callable(schema, *, strict=False, normalize=True, warn_unknown=False)[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).

  • warn_unknown (bool) – If True (and strict is False), log a warning naming those same unknown keys instead of silently dropping them. Meant for configs whose section is schema-only (no CLI parameter is merged from it, i.e. included_params=()), where any unrecognized key can only be a typo. Applies recursively to nested dataclasses.

  • 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, schema_warn_unknown=False, strict=False, blocked_params=(), 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.

  • schema_warn_unknown (bool) – In lax mode, log a warning naming keys the dataclass schema does not recognize (see warn_unknown in make_schema_callable()). Ignored when schema_strict rejects them outright.

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

  • blocked_params (Iterable[str]) – Fully-qualified IDs of parameters excluded from configuration files, used to sharpen strict-mode error messages (a blocked parameter is reported as such, not as unknown).

  • 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.