# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Tests for typed configuration schemas, validation, and extension points."""
from __future__ import annotations
from dataclasses import dataclass, field
from textwrap import dedent
from types import SimpleNamespace
import click
import pytest
from click_extra import (
config_option,
echo,
group,
option,
pass_context,
validate_config_option,
)
from click_extra.config import (
flatten_config_keys,
get_tool_config,
normalize_config_keys,
)
from click_extra.config.schema import _collect_opaque_paths_from_schema
# --- config_schema and fallback_sections tests ---
[docs]
def test_normalize_config_keys():
assert normalize_config_keys({}) == {}
assert normalize_config_keys({"foo-bar": 1}) == {"foo_bar": 1}
assert normalize_config_keys({"a-b": {"c-d": 2}}) == {"a_b": {"c_d": 2}}
# Keys without hyphens are unchanged.
assert normalize_config_keys({"snake_case": 3}) == {"snake_case": 3}
[docs]
def test_config_schema_dataclass(invoke, create_config):
"""Dataclass schemas are auto-detected and instantiated with normalized keys."""
@dataclass
class AppConfig:
extra_stuff: str = "default_value"
my_list: list[str] = field(default_factory=list)
@group(config_schema=AppConfig)
@option("--dummy-flag/--no-flag")
@pass_context
def schema_cli(ctx, dummy_flag):
config = get_tool_config(ctx)
echo(f"dummy_flag is {dummy_flag!r}")
echo(f"extra_stuff is {config.extra_stuff!r}")
echo(f"my_list is {config.my_list!r}")
@schema_cli.command()
@option("--int-param", type=int, default=10)
def subcommand(int_param):
echo(f"int_param is {int_param!r}")
conf_path = create_config(
"schema.toml",
dedent("""\
[schema-cli]
dummy_flag = true
extra-stuff = "from_config"
my-list = ["a", "b"]
[schema-cli.subcommand]
int_param = 42
"""),
)
result = invoke(schema_cli, "--config", str(conf_path), "subcommand", color=False)
assert result.exit_code == 0
# CLI options use underscores in the default_map.
assert "dummy_flag is True" in result.stdout
# Schema normalizes hyphens to underscores.
assert "extra_stuff is 'from_config'" in result.stdout
assert "my_list is ['a', 'b']" in result.stdout
assert "int_param is 42" in result.stdout
[docs]
def test_config_schema_callable(invoke, create_config):
"""A plain callable can be used as config_schema."""
def my_schema(raw):
return SimpleNamespace(**normalize_config_keys(raw))
@group(config_schema=my_schema)
@option("--dummy-flag/--no-flag")
@pass_context
def callable_cli(ctx, dummy_flag):
config = get_tool_config(ctx)
echo(f"extra is {config.extra_value!r}")
@callable_cli.command()
def subcommand():
echo("ok")
conf_path = create_config(
"callable.toml",
dedent("""\
[callable-cli]
extra-value = "hello"
"""),
)
result = invoke(callable_cli, "--config", str(conf_path), "subcommand", color=False)
assert result.exit_code == 0
assert "extra is 'hello'" in result.stdout
[docs]
def test_config_schema_no_config_file(invoke):
"""When no config file is found, schema defaults are used."""
@dataclass
class AppConfig:
value: str = "default"
@group(config_schema=AppConfig)
@pass_context
def no_file_cli(ctx):
config = get_tool_config(ctx)
echo(f"value is {config.value!r}")
@no_file_cli.command()
def subcommand():
echo("ok")
result = invoke(no_file_cli, "subcommand", color=False)
assert result.exit_code == 0
assert "value is 'default'" in result.stdout
[docs]
def test_config_schema_dataclass_defaults(invoke, create_config):
"""Dataclass defaults are used for fields not present in the config file."""
@dataclass
class AppConfig:
present: str = "default_present"
missing: str = "default_missing"
@group(config_schema=AppConfig)
@pass_context
def defaults_cli(ctx):
config = get_tool_config(ctx)
echo(f"present is {config.present!r}")
echo(f"missing is {config.missing!r}")
@defaults_cli.command()
def subcommand():
echo("ok")
conf_path = create_config(
"defaults.toml",
dedent("""\
[defaults-cli]
present = "from_file"
"""),
)
result = invoke(defaults_cli, "--config", str(conf_path), "subcommand", color=False)
assert result.exit_code == 0
assert "present is 'from_file'" in result.stdout
assert "missing is 'default_missing'" in result.stdout
[docs]
def test_fallback_sections(invoke, create_config):
"""Legacy section names are recognized with a deprecation warning."""
@dataclass
class AppConfig:
value: str = "default"
@group(config_schema=AppConfig, fallback_sections=("old-name", "older-name"))
@pass_context
def fallback_cli(ctx):
config = get_tool_config(ctx)
echo(f"value is {config.value!r}")
@fallback_cli.command()
def subcommand():
echo("ok")
# Config uses the old section name.
conf_path = create_config(
"fallback.toml",
dedent("""\
[old-name]
value = "from_legacy"
"""),
)
result = invoke(fallback_cli, "--config", str(conf_path), "subcommand", color=False)
assert result.exit_code == 0
assert "value is 'from_legacy'" in result.stdout
assert "deprecated" in result.stderr.lower()
[docs]
def test_fallback_sections_prefers_current(invoke, create_config):
"""When both current and legacy sections exist, current wins."""
@dataclass
class AppConfig:
value: str = "default"
@group(config_schema=AppConfig, fallback_sections=("old-name",))
@pass_context
def current_cli(ctx):
config = get_tool_config(ctx)
echo(f"value is {config.value!r}")
@current_cli.command()
def subcommand():
echo("ok")
conf_path = create_config(
"both.toml",
dedent("""\
[current-cli]
value = "current"
[old-name]
value = "legacy"
"""),
)
result = invoke(current_cli, "--config", str(conf_path), "subcommand", color=False)
assert result.exit_code == 0
assert "value is 'current'" in result.stdout
# Should still warn about leftover legacy section.
assert "deprecated" in result.stderr.lower()
[docs]
def test_config_schema_on_config_option_directly(invoke, create_config):
"""Config schema can be set directly on ConfigOption via the decorator."""
from click import group as click_group
@dataclass
class AppConfig:
extra: str = "default"
@click_group(context_settings={"show_default": True})
@config_option(config_schema=AppConfig)
@pass_context
def direct_cli(ctx):
config = get_tool_config(ctx)
echo(f"extra is {config.extra!r}")
@direct_cli.command()
def subcommand():
echo("ok")
conf_path = create_config(
"direct.toml",
dedent("""\
[direct-cli]
extra = "works"
"""),
)
result = invoke(direct_cli, "--config", str(conf_path), "subcommand", color=False)
assert result.exit_code == 0
assert "extra is 'works'" in result.stdout
[docs]
def test_get_tool_config_defaults_to_current_context(invoke, create_config):
"""get_tool_config() works without passing ctx explicitly."""
@dataclass
class AppConfig:
value: str = "default"
@group(config_schema=AppConfig)
def auto_ctx_cli():
# Call without explicit ctx.
config = get_tool_config()
echo(f"value is {config.value!r}")
@auto_ctx_cli.command()
def subcommand():
echo("ok")
conf_path = create_config(
"auto.toml",
dedent("""\
[auto-ctx-cli]
value = "auto"
"""),
)
result = invoke(auto_ctx_cli, "--config", str(conf_path), "subcommand", color=False)
assert result.exit_code == 0
assert "value is 'auto'" in result.stdout
[docs]
def test_flatten_config_keys():
# Empty dict.
assert flatten_config_keys({}) == {}
# Flat dict is unchanged.
assert flatten_config_keys({"a": 1, "b": 2}) == {"a": 1, "b": 2}
# One level of nesting.
assert flatten_config_keys({"sub": {"key": "val"}}) == {"sub_key": "val"}
# Multiple keys in a nested dict.
assert flatten_config_keys({"dep": {"output": "x", "all": True}}) == {
"dep_output": "x",
"dep_all": True,
}
# Mixed flat and nested.
assert flatten_config_keys({"top": 1, "sub": {"inner": 2}}) == {
"top": 1,
"sub_inner": 2,
}
# Deeply nested.
assert flatten_config_keys({"a": {"b": {"c": 3}}}) == {"a_b_c": 3}
# Custom separator.
assert flatten_config_keys({"a": {"b": 1}}, sep=".") == {"a.b": 1}
[docs]
def test_flatten_config_keys_with_normalize():
"""flatten + normalize maps nested kebab-case config to flat snake_case fields."""
raw = {
"dependency-graph": {"all-groups": True, "output": "deps.mmd"},
"pypi-package-history": ["old-name"],
}
result = flatten_config_keys(normalize_config_keys(raw))
assert result == {
"dependency_graph_all_groups": True,
"dependency_graph_output": "deps.mmd",
"pypi_package_history": ["old-name"],
}
[docs]
def test_config_schema_nested_toml(invoke, create_config):
"""Nested TOML sub-tables map to flat dataclass fields via flattening."""
@dataclass
class AppConfig:
dependency_graph_output: str = "default.mmd"
dependency_graph_all_groups: bool = True
gitignore_sync: bool = True
top_level_list: list[str] = field(default_factory=list)
@group(config_schema=AppConfig)
@pass_context
def nested_cli(ctx):
config = get_tool_config(ctx)
echo(f"output is {config.dependency_graph_output!r}")
echo(f"all_groups is {config.dependency_graph_all_groups!r}")
echo(f"git_sync is {config.gitignore_sync!r}")
echo(f"top_list is {config.top_level_list!r}")
@nested_cli.command()
def subcommand():
echo("ok")
conf_path = create_config(
"nested.toml",
dedent("""\
[nested-cli]
top-level-list = ["x", "y"]
[nested-cli.dependency-graph]
output = "custom.mmd"
all-groups = false
[nested-cli.gitignore]
sync = false
"""),
)
result = invoke(nested_cli, "--config", str(conf_path), "subcommand", color=False)
assert result.exit_code == 0
assert "output is 'custom.mmd'" in result.stdout
assert "all_groups is False" in result.stdout
assert "git_sync is False" in result.stdout
assert "top_list is ['x', 'y']" in result.stdout
[docs]
def test_config_schema_strict_rejects_unknown(invoke, create_config):
"""schema_strict=True raises ValueError on unrecognized config keys."""
@dataclass
class AppConfig:
known_field: str = "default"
@group(config_schema=AppConfig, schema_strict=True)
@pass_context
def strict_cli(ctx):
config = get_tool_config(ctx)
echo(f"known_field is {config.known_field!r}")
@strict_cli.command()
def subcommand():
echo("ok")
conf_path = create_config(
"strict.toml",
dedent("""\
[strict-cli]
known-field = "ok"
typo-field = "oops"
"""),
)
result = invoke(strict_cli, "--config", str(conf_path), "subcommand", color=False)
# The dataclass adapter's unknown-key error reaches the user as a clean
# critical-level log and exit 1, unified with the other validation paths.
assert result.exit_code == 1
assert "typo_field" in result.stderr
[docs]
def test_config_schema_strict_passes_when_valid(invoke, create_config):
"""schema_strict=True does not raise when all config keys are known."""
@dataclass
class AppConfig:
known_field: str = "default"
@group(config_schema=AppConfig, schema_strict=True)
@pass_context
def strict_ok_cli(ctx):
config = get_tool_config(ctx)
echo(f"known_field is {config.known_field!r}")
@strict_ok_cli.command()
def subcommand():
echo("ok")
conf_path = create_config(
"strict_ok.toml",
dedent("""\
[strict-ok-cli]
known-field = "good"
"""),
)
result = invoke(
strict_ok_cli,
"--config",
str(conf_path),
"subcommand",
color=False,
)
assert result.exit_code == 0
assert "known_field is 'good'" in result.stdout
[docs]
def test_config_schema_strict_with_nested(invoke, create_config):
"""schema_strict=True validates flattened keys from nested sub-tables."""
@dataclass
class AppConfig:
section_known: str = "default"
@group(config_schema=AppConfig, schema_strict=True)
@pass_context
def strict_nested_cli(ctx):
config = get_tool_config(ctx)
echo(f"section_known is {config.section_known!r}")
@strict_nested_cli.command()
def subcommand():
echo("ok")
conf_path = create_config(
"strict_nested.toml",
dedent("""\
[strict-nested-cli.section]
known = "found"
unknown = "oops"
"""),
)
result = invoke(
strict_nested_cli,
"--config",
str(conf_path),
"subcommand",
color=False,
)
assert result.exit_code == 1
assert "section_unknown" in result.stderr
[docs]
def test_pyproject_toml_cwd_discovery(invoke, tmp_path, monkeypatch):
"""pyproject.toml in CWD is discovered automatically without --config."""
@dataclass
class AppConfig:
extra_stuff: str = "default_value"
@group(config_schema=AppConfig)
@pass_context
def cwd_cli(ctx):
config = get_tool_config(ctx)
if config is not None:
echo(f"extra_stuff is {config.extra_stuff!r}")
else:
echo("config is None")
@cwd_cli.command()
def subcommand():
echo("ok")
# Write a pyproject.toml in the tmp directory.
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
dedent("""\
[tool.cwd-cli]
extra-stuff = "from_cwd"
"""),
)
# Run from that directory so CWD discovery finds it.
monkeypatch.chdir(tmp_path)
result = invoke(cwd_cli, "subcommand", color=False)
assert result.exit_code == 0
assert "extra_stuff is 'from_cwd'" in result.stdout
[docs]
def test_pyproject_toml_cwd_discovery_walks_up(invoke, tmp_path, monkeypatch):
"""pyproject.toml discovery walks up from CWD to parent directories."""
@dataclass
class AppConfig:
value: str = "default"
@group(config_schema=AppConfig)
@pass_context
def walk_cli(ctx):
config = get_tool_config(ctx)
if config is not None:
echo(f"value is {config.value!r}")
else:
echo("config is None")
@walk_cli.command()
def subcommand():
echo("ok")
# Write pyproject.toml in parent, run from a subdirectory.
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
dedent("""\
[tool.walk-cli]
value = "from_parent"
"""),
)
subdir = tmp_path / "src" / "pkg"
subdir.mkdir(parents=True)
monkeypatch.chdir(subdir)
result = invoke(walk_cli, "subcommand", color=False)
assert result.exit_code == 0
assert "value is 'from_parent'" in result.stdout
[docs]
def test_pyproject_toml_explicit_config_skips_cwd(
invoke, create_config, tmp_path, monkeypatch
):
"""Explicit --config skips CWD pyproject.toml discovery."""
@dataclass
class AppConfig:
value: str = "default"
@group(config_schema=AppConfig)
@pass_context
def explicit_cli(ctx):
config = get_tool_config(ctx)
if config is not None:
echo(f"value is {config.value!r}")
else:
echo("config is None")
@explicit_cli.command()
def subcommand():
echo("ok")
# CWD pyproject.toml with one value.
cwd_pyproject = tmp_path / "pyproject.toml"
cwd_pyproject.write_text(
dedent("""\
[tool.explicit-cli]
value = "from_cwd"
"""),
)
monkeypatch.chdir(tmp_path)
# Explicit config with a different value.
conf_path = create_config(
"explicit.toml",
dedent("""\
[explicit-cli]
value = "from_explicit"
"""),
)
result = invoke(
explicit_cli,
"--config",
str(conf_path),
"subcommand",
color=False,
)
assert result.exit_code == 0
# Explicit --config wins over CWD pyproject.toml.
assert "value is 'from_explicit'" in result.stdout
[docs]
def test_flatten_config_keys_opaque():
"""opaque_keys stops flattening at matching key boundaries."""
conf = {
"test_matrix": {
"exclude": [{"os": "windows"}],
"replace": {"os": {"old": "new"}, "python-ver": {"3.12": "3.13"}},
},
"other": {"nested": "val"},
}
# Without opaque_keys: everything flattened recursively.
flat = flatten_config_keys(conf)
assert "test_matrix_replace_os_old" in flat
assert "test_matrix_replace_python-ver_3.12" in flat
# With opaque_keys: replace kept intact.
flat = flatten_config_keys(
conf,
opaque_keys=frozenset({"test_matrix_replace"}),
)
assert flat["test_matrix_replace"] == {
"os": {"old": "new"},
"python-ver": {"3.12": "3.13"},
}
# Non-opaque siblings still flattened.
assert flat["test_matrix_exclude"] == [{"os": "windows"}]
assert flat["other_nested"] == "val"
[docs]
def test_flatten_config_keys_opaque_nested():
"""opaque_keys works at deeper nesting levels."""
conf = {"a": {"b": {"c": 1, "d": 2}, "e": 3}}
flat = flatten_config_keys(conf, opaque_keys=frozenset({"a_b"}))
assert flat == {"a_b": {"c": 1, "d": 2}, "a_e": 3}
[docs]
def test_schema_type_aware_flattening(invoke, create_config):
"""dict-typed dataclass fields stop flattening automatically."""
@dataclass
class AppConfig:
simple_value: str = ""
opaque_map: dict[str, list[str]] = field(default_factory=dict)
@group(config_schema=AppConfig)
@pass_context
def type_aware_cli(ctx):
config = get_tool_config(ctx)
echo(f"simple is {config.simple_value!r}")
echo(f"opaque is {config.opaque_map!r}")
@type_aware_cli.command()
def subcommand():
echo("ok")
conf_path = create_config(
"type_aware.toml",
dedent("""\
[type-aware-cli]
simple-value = "hello"
[type-aware-cli.opaque-map]
python-version = ["3.12", "3.13"]
os = ["ubuntu", "macos"]
"""),
)
result = invoke(
type_aware_cli,
"--config",
str(conf_path),
"subcommand",
color=False,
)
assert result.exit_code == 0
assert "simple is 'hello'" in result.stdout
# Dict keys preserved as-is (not flattened into opaque_map_python_version).
assert "python-version" in result.stdout
assert "os" in result.stdout
[docs]
def test_schema_nested_dataclass(invoke, create_config):
"""Nested dataclass fields are recursively instantiated."""
@dataclass
class SubConfig:
enabled: bool = False
items: list[str] = field(default_factory=list)
@dataclass
class AppConfig:
name: str = ""
sub: SubConfig = field(default_factory=SubConfig)
@group(config_schema=AppConfig)
@pass_context
def nested_dc_cli(ctx):
config = get_tool_config(ctx)
echo(f"name is {config.name!r}")
echo(f"sub type is {type(config.sub).__name__}")
echo(f"sub.enabled is {config.sub.enabled!r}")
echo(f"sub.items is {config.sub.items!r}")
@nested_dc_cli.command()
def subcommand():
echo("ok")
conf_path = create_config(
"nested_dc.toml",
dedent("""\
[nested-dc-cli]
name = "hello"
[nested-dc-cli.sub]
enabled = true
items = ["a", "b"]
"""),
)
result = invoke(
nested_dc_cli,
"--config",
str(conf_path),
"subcommand",
color=False,
)
assert result.exit_code == 0
assert "name is 'hello'" in result.stdout
assert "sub type is SubConfig" in result.stdout
assert "sub.enabled is True" in result.stdout
assert "sub.items is ['a', 'b']" in result.stdout
[docs]
def test_schema_nested_dataclass_with_opaque_fields(invoke, create_config):
"""Nested dataclass with dict-typed fields preserves opaque keys."""
@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:
matrix: MatrixConfig = field(
default_factory=MatrixConfig,
metadata={
"click_extra.config_path": "test-matrix",
"click_extra.normalize_keys": False,
},
)
@group(config_schema=AppConfig)
@pass_context
def matrix_cli(ctx):
config = get_tool_config(ctx)
echo(f"exclude is {config.matrix.exclude!r}")
echo(f"replace is {config.matrix.replace!r}")
echo(f"variations is {config.matrix.variations!r}")
@matrix_cli.command()
def subcommand():
echo("ok")
conf_path = create_config(
"matrix.toml",
dedent("""\
[matrix-cli.test-matrix]
exclude = [{os = "windows-11-arm"}]
[matrix-cli.test-matrix.replace]
os = {"ubuntu-slim" = "ubuntu-24.04"}
[matrix-cli.test-matrix.variations]
python-version = ["3.14"]
os = ["custom-runner"]
"""),
)
result = invoke(
matrix_cli,
"--config",
str(conf_path),
"subcommand",
color=False,
)
assert result.exit_code == 0
# Exclude list preserved with original keys.
assert "windows-11-arm" in result.stdout
# Replace dict keys not normalized (os stays, ubuntu-slim stays).
assert "ubuntu-slim" in result.stdout
assert "ubuntu-24.04" in result.stdout
# Variations keys not normalized (python-version stays as-is).
assert "python-version" in result.stdout
assert "custom-runner" in result.stdout
[docs]
def test_schema_nested_dataclass_defaults(invoke, create_config):
"""Nested dataclass uses defaults when config section is absent."""
@dataclass
class SubConfig:
enabled: bool = True
count: int = 42
@dataclass
class AppConfig:
name: str = "default_name"
sub: SubConfig = field(default_factory=SubConfig)
@group(config_schema=AppConfig)
@pass_context
def default_dc_cli(ctx):
config = get_tool_config(ctx)
echo(f"name is {config.name!r}")
echo(f"sub.enabled is {config.sub.enabled!r}")
echo(f"sub.count is {config.sub.count!r}")
@default_dc_cli.command()
def subcommand():
echo("ok")
conf_path = create_config(
"default_dc.toml",
dedent("""\
[default-dc-cli]
name = "custom"
"""),
)
result = invoke(
default_dc_cli,
"--config",
str(conf_path),
"subcommand",
color=False,
)
assert result.exit_code == 0
assert "name is 'custom'" in result.stdout
# Sub-config uses defaults since [default-dc-cli.sub] is absent.
assert "sub.enabled is True" in result.stdout
assert "sub.count is 42" in result.stdout
# Opaque-aware strict check: schema-declared extension sub-trees pass through
# both runtime strict mode and --validate-config without tripping unknown-key
# detection.
[docs]
def test_strict_skips_opaque_dict_field(invoke, create_config):
"""Strict mode does not reject keys inside a ``dict[str, X]`` schema field.
A field typed as ``dict[str, dict]`` is user-controlled: the keys are data,
not CLI flag names. Click-extra strips that sub-tree before running its
unknown-key check so app extensions don't trip strict mode.
"""
@dataclass
class AppConfig:
extensions: dict[str, dict] = field(default_factory=dict)
@click.group
@option("--dummy-flag/--no-flag")
@config_option(config_schema=AppConfig, strict=True)
def opaque_cli(dummy_flag):
echo("ok")
@opaque_cli.command
def sub():
echo("sub")
conf_path = create_config(
"opaque_dict.toml",
dedent("""\
[opaque-cli]
dummy_flag = true
[opaque-cli.extensions.plugin-a]
anything = "goes"
and_so_does = ["this", "list"]
"""),
)
result = invoke(opaque_cli, "--config", str(conf_path), "sub", color=False)
assert result.exit_code == 0
[docs]
def test_validate_config_skips_opaque_field(invoke, create_config):
"""--validate-config also skips opaque sub-trees, so a config that the
runtime accepts also passes validation."""
@dataclass
class AppConfig:
extensions: dict[str, dict] = field(default_factory=dict)
@click.group
@option("--dummy-flag/--no-flag")
@config_option(config_schema=AppConfig, strict=True)
@validate_config_option
def validate_opaque_cli(dummy_flag):
echo("ok")
@validate_opaque_cli.command
def sub():
echo("sub")
conf_path = create_config(
"validate_opaque.toml",
dedent("""\
[validate-opaque-cli]
dummy_flag = true
[validate-opaque-cli.extensions.plugin-a]
anything = "goes"
"""),
)
result = invoke(
validate_opaque_cli,
"--validate-config",
str(conf_path),
color=False,
)
assert result.exit_code == 0
assert "is valid" in result.stderr
# ConfigValidator: app-registered validators run against opaque sub-trees
# during both --validate-config and normal --config loading.
[docs]
def test_config_validator_runs_and_fails_under_validate_config(invoke, create_config):
"""A registered ``ConfigValidator`` runs during ``--validate-config`` and
surfaces its ``ValidationError`` with a path rooted at the config file."""
from click_extra import ConfigValidator, ValidationError
@dataclass
class AppConfig:
managers: dict[str, dict] = field(default_factory=dict)
def validate_managers(section: dict) -> None:
for manager_id, fields in section.items():
for key in fields:
if key not in {"timeout", "search_path"}:
raise ValidationError(
f"{manager_id}.{key}",
f"unknown field {key!r}",
code="unknown_field",
)
@click.group
@option("--dummy-flag/--no-flag")
@config_option(
config_schema=AppConfig,
strict=True,
config_validators=(
ConfigValidator(
extension_path="managers",
validator=validate_managers,
description="Validates [<app>.managers.<id>] sub-tables.",
),
),
)
@validate_config_option
def validator_cli(dummy_flag):
echo("ok")
@validator_cli.command
def sub():
echo("sub")
# Valid config first: validator passes.
valid_path = create_config(
"validator_valid.toml",
dedent("""\
[validator-cli]
dummy_flag = true
[validator-cli.managers.winget]
timeout = 30
"""),
)
result = invoke(validator_cli, "--validate-config", str(valid_path), color=False)
assert result.exit_code == 0
assert "is valid" in result.stderr
# Invalid config: validator fails with rooted path.
invalid_path = create_config(
"validator_invalid.toml",
dedent("""\
[validator-cli]
dummy_flag = true
[validator-cli.managers.winget]
timeout = 30
badkey = "oops"
"""),
)
result = invoke(validator_cli, "--validate-config", str(invalid_path), color=False)
assert result.exit_code == 1
assert "validator-cli.managers.winget.badkey: unknown field 'badkey'" in (
result.stderr
)
[docs]
def test_config_validator_runs_during_normal_load(invoke, create_config):
"""A misconfigured opaque sub-tree fails fast during normal config loading,
not only under ``--validate-config``."""
from click_extra import ConfigValidator, ValidationError
@dataclass
class AppConfig:
managers: dict[str, dict] = field(default_factory=dict)
def reject_all(section: dict) -> None:
if section:
raise ValidationError("", "no entries allowed")
@click.group
@config_option(
config_schema=AppConfig,
config_validators=(
ConfigValidator(extension_path="managers", validator=reject_all),
),
)
def runtime_cli():
echo("ok")
@runtime_cli.command
def sub():
echo("sub")
conf_path = create_config(
"runtime_invalid.toml",
dedent("""\
[runtime-cli.managers.x]
anything = 1
"""),
)
result = invoke(runtime_cli, "--config", str(conf_path), "sub", color=False)
# The validator's failure produces a clean exit-1 with the rooted path in
# the critical-level log, rather than a raw ValidationError traceback.
assert result.exit_code == 1
assert "runtime-cli.managers: no entries allowed" in result.stderr
[docs]
def test_config_validator_extension_path_strips_strict_check(invoke, create_config):
"""A ConfigValidator(extension_path=...) registration alone is enough to skip
strict-check on that path, even when the schema doesn't have the field."""
from click_extra import ConfigValidator
def noop_validator(section: dict) -> None:
pass
@click.group
@config_option(
strict=True,
config_validators=(
ConfigValidator(extension_path="extras", validator=noop_validator),
),
)
def strip_only_cli():
echo("ok")
@strip_only_cli.command
def sub():
echo("sub")
conf_path = create_config(
"strip_only.toml",
dedent("""\
[strip-only-cli.extras.foo]
arbitrary = "data"
"""),
)
result = invoke(strip_only_cli, "--config", str(conf_path), "sub", color=False)
assert result.exit_code == 0
[docs]
def test_config_validator_collects_all_errors(invoke, create_config):
"""``--validate-config`` reports every detected error in one pass.
A config with both an unknown CLI flag key and a validator-flagged field
should surface both messages before the run exits non-zero, so the user
sees the full punch list.
"""
from click_extra import ConfigValidator, ValidationError
@dataclass
class AppConfig:
managers: dict[str, dict] = field(default_factory=dict)
def reject_badkey(section: dict) -> None:
for manager_id, fields in section.items():
if "badkey" in fields:
raise ValidationError(
manager_id, "badkey is not allowed", code="unknown_field"
)
@click.group
@option("--dummy-flag/--no-flag")
@config_option(
config_schema=AppConfig,
strict=True,
config_validators=(
ConfigValidator(extension_path="managers", validator=reject_badkey),
),
)
@validate_config_option
def both_errors_cli(dummy_flag):
echo("ok")
@both_errors_cli.command
def sub():
echo("sub")
conf_path = create_config(
"both_errors.toml",
dedent("""\
[both-errors-cli]
dummy_flag = true
unknown_flag = true
[both-errors-cli.managers.x]
badkey = "oops"
"""),
)
result = invoke(both_errors_cli, "--validate-config", str(conf_path), color=False)
assert result.exit_code == 1
# Both errors appear in the same run.
assert "unknown_flag" in result.stderr
assert "badkey is not allowed" in result.stderr
[docs]
def test_collect_opaque_paths_from_schema():
"""Schema introspection picks up dict-typed fields, metadata-marked
fields, and nested-dataclass opaque fields with dotted prefixes."""
from click_extra import EXTENSION_METADATA_KEY
# Use ``dict`` (the builtin) instead of typing.Any inside ``dict[str, X]``
# so type-hint resolution doesn't depend on a module-level Any import.
@dataclass
class Nested:
extras: dict[str, dict] = field(default_factory=dict)
flat: int = 0
@dataclass
class AppConfig:
timeout: int = 0
managers: dict[str, dict] = field(default_factory=dict)
nested: Nested = field(default_factory=Nested)
marked: list = field(
default_factory=list,
metadata={EXTENSION_METADATA_KEY: True},
)
assert _collect_opaque_paths_from_schema(AppConfig) == frozenset({
"managers",
"nested.extras",
"marked",
})
# Empty result for non-dataclass schemas.
assert _collect_opaque_paths_from_schema(None) == frozenset()
assert _collect_opaque_paths_from_schema(int) == frozenset()
# run_config_validation: the unified pipeline primitive, exercised directly.
[docs]
def test_run_config_validation_valid_document():
"""A clean document yields an ok report with the schema instance built and
every opaque sub-tree extracted."""
from click_extra import ConfigValidator, run_config_validation
@dataclass
class AppConfig:
verbose: bool = False
managers: dict[str, dict] = field(default_factory=dict)
def accept(section: dict) -> None:
pass
conf = {"my-cli": {"verbose": True, "managers": {"brew": {"timeout": 1}}}}
report = run_config_validation(
conf,
app_name="my-cli",
params_template=None,
config_schema=AppConfig,
config_validators=(
ConfigValidator(extension_path="managers", validator=accept),
),
)
assert report.ok
assert report.errors == ()
assert report.schema_instance == AppConfig(
verbose=True, managers={"brew": {"timeout": 1}}
)
assert report.opaque_subtrees == {"managers": {"brew": {"timeout": 1}}}
# No template was supplied, so there is no default_map payload to carry.
assert report.merged_conf is None
[docs]
def test_run_config_validation_exposes_merged_conf():
"""A passing strict check carries the template-filtered config as merged_conf,
with recognized values merged in and unknown keys dropped."""
from click_extra import run_config_validation
report = run_config_validation(
{"my-cli": {"verbose": True, "unknown": "dropped"}},
app_name="my-cli",
params_template={"my-cli": {"verbose": None, "count": None}},
config_schema=None,
)
assert report.ok
assert report.merged_conf is not None
assert report.merged_conf["my-cli"]["verbose"] is True
assert "unknown" not in report.merged_conf["my-cli"]
[docs]
def test_run_config_validation_collects_all_then_short_circuits():
"""collect_all=True gathers errors from every stage in order; collect_all=False
stops after the first."""
from click_extra import ConfigValidator, run_config_validation
@dataclass
class AppConfig:
managers: dict[str, dict] = field(default_factory=dict)
def reject(section: dict) -> None:
from click_extra import ValidationError
if section:
raise ValidationError("x", "no entries allowed")
conf = {
"my-cli": {
"bogus_flag": True,
"managers": {"x": {"badkey": "oops"}},
}
}
params_template = {"my-cli": {"verbose": None}}
full = run_config_validation(
conf,
app_name="my-cli",
params_template=params_template,
config_schema=AppConfig,
config_validators=(
ConfigValidator(extension_path="managers", validator=reject),
),
strict=True,
collect_all=True,
)
assert not full.ok
# Stage order: CLI-flag strict check first, validator failure last.
assert [e.code for e in full.errors] == ["unknown_parameter", None]
assert "bogus_flag" in full.errors[0].message
assert full.errors[1].path == "my-cli.managers.x"
# The strict check raised, so no default_map payload is carried.
assert full.merged_conf is None
first_only = run_config_validation(
conf,
app_name="my-cli",
params_template=params_template,
config_schema=AppConfig,
config_validators=(
ConfigValidator(extension_path="managers", validator=reject),
),
strict=True,
collect_all=False,
)
assert len(first_only.errors) == 1
assert first_only.errors[0].code == "unknown_parameter"
[docs]
def test_run_config_validation_wraps_schema_errors():
"""A schema_strict failure is recorded as a ValidationError with the
schema_error code, and the message is preserved verbatim."""
from click_extra import run_config_validation
@dataclass
class AppConfig:
known: str = "default"
conf = {"my-cli": {"known": "ok", "typo": "oops"}}
report = run_config_validation(
conf,
app_name="my-cli",
params_template=None,
config_schema=AppConfig,
schema_strict=True,
)
assert not report.ok
assert len(report.errors) == 1
assert report.errors[0].code == "schema_error"
assert report.errors[0].path == ""
assert "typo" in report.errors[0].message
# Empty path keeps the rendered string identical to the raw message.
assert str(report.errors[0]) == report.errors[0].message
[docs]
def test_run_config_validation_no_schema_no_template():
"""With neither a template nor a schema, the report is ok and carries no
schema instance."""
from click_extra import run_config_validation
report = run_config_validation(
{"my-cli": {"anything": 1}},
app_name="my-cli",
params_template=None,
config_schema=None,
)
assert report.ok
assert report.schema_instance is None
assert report.opaque_subtrees == {}
[docs]
def test_make_schema_callable_coerces_dict_to_dataclass():
"""The public make_schema_callable turns a raw config dict into a dataclass."""
from click_extra import make_schema_callable
@dataclass
class Forecast:
city: str = "paris"
high_c: int = 0
load = make_schema_callable(Forecast)
assert load is not None
# Hyphenated keys are normalized to field names.
assert load({"city": "lyon", "high-c": 21}) == Forecast(city="lyon", high_c=21)
# A non-dataclass callable is returned as-is; None passes through.
assert make_schema_callable(str) is str
assert make_schema_callable(None) is None