# 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
import ast
import logging
import re
from dataclasses import dataclass, field
from pathlib import Path
from textwrap import dedent
from types import SimpleNamespace
import click
import pytest
import click_extra
from click_extra import (
ConfigOption,
config_option,
echo,
group,
make_schema_callable,
option,
pass_context,
search_params,
validate_config_option,
)
from click_extra.config import (
CONFIG_PATH_METADATA_KEY,
ConfigValidator,
field_docstrings,
flatten_config_keys,
get_tool_config,
normalize_config_keys,
run_config_validation,
schema_field_infos,
)
from click_extra.config.schema import (
_collect_opaque_paths_from_schema,
_schema_keys,
_select_app_section,
)
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import Any
# --- 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_normalize_config_keys_keeps_a_key_that_is_not_a_string():
"""YAML reads `1:` as a number: such a key has no hyphen to replace."""
conf: dict[Any, Any] = {1: "x", "a-b": {2: "y"}}
assert normalize_config_keys(conf) == {1: "x", "a_b": {2: "y"}}
[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_cascade(invoke, tmp_path, monkeypatch):
"""With cascade=True, the schema is built from the merged view."""
@dataclass
class AppConfig:
extra_stuff: str = "default_value"
other_stuff: str = "other_default"
app_dir = tmp_path / "appdir"
app_dir.mkdir()
monkeypatch.setattr(
"click_extra.config.option.get_app_dir", lambda *a, **k: str(app_dir)
)
# The local file sets one schema field, the parent the other.
(app_dir / "config.toml").write_text(
'[schema-cascade-cli]\nextra-stuff = "from_local"\n', encoding="UTF-8"
)
(tmp_path / "config.toml").write_text(
dedent(
"""
[schema-cascade-cli]
extra-stuff = "from_parent"
other-stuff = "parent_other"
"""
),
encoding="UTF-8",
)
@group(config_schema=AppConfig)
@pass_context
def schema_cascade_cli(ctx):
config = get_tool_config(ctx)
echo(f"extra_stuff is {config.extra_stuff!r}")
echo(f"other_stuff is {config.other_stuff!r}")
@schema_cascade_cli.command()
def subcommand():
pass
# Configure the auto-injected ConfigOption for cascading discovery.
config_opt = search_params(schema_cascade_cli.params, ConfigOption)
assert isinstance(config_opt, ConfigOption)
config_opt.search_parents = True
config_opt.stop_at = tmp_path
config_opt.cascade = True
result = invoke(schema_cascade_cli, "subcommand", color=False)
assert result.exit_code == 0
# Local value wins on conflict...
assert "extra_stuff is 'from_local'" in result.stdout
# ...and the parent-only field is still picked up.
assert "other_stuff is 'parent_other'" 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_section_warns_once(invoke, create_config):
"""A legacy section loaded with its themes is reported once, not per reader."""
@group(fallback_sections=("old-name",))
def legacy_cli():
echo("ok")
@legacy_cli.command()
def subcommand():
echo("ok")
conf_path = create_config(
"legacy.toml",
dedent("""\
[old-name.themes.dusk]
option = { fg = "bright_cyan" }
"""),
)
result = invoke(legacy_cli, "--config", str(conf_path), "subcommand", color=False)
assert result.exit_code == 0
assert result.stderr.count("Config section [old-name] is deprecated") == 1
[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_config_schema_lax_warns_unknown_when_schema_only(invoke, create_config):
"""A schema-only section warns on unknown keys instead of dropping them silently.
``included_params=()`` means no CLI parameter is merged from the app's
section, so any key the schema does not know can only be a typo: lax mode
then logs a warning while still loading the known fields.
"""
@dataclass
class AppConfig:
known_field: str = "default"
@group(config_schema=AppConfig, included_params=())
@pass_context
def lax_cli(ctx):
config = get_tool_config(ctx)
echo(f"known_field is {config.known_field!r}")
@lax_cli.command()
def subcommand():
echo("ok")
conf_path = create_config(
"lax.toml",
dedent("""\
[lax-cli]
known-field = "good"
typo-field = "oops"
"""),
)
result = invoke(lax_cli, "--config", str(conf_path), "subcommand", color=False)
assert result.exit_code == 0
assert "typo_field" in result.stderr
assert "known_field is 'good'" in result.stdout
SCHEMA_ONLY_CONF = dedent("""\
[schema-only-cli]
known-field = "good"
typo-field = "oops"
""")
"""A schema-only section, holding one key its schema does not know."""
[docs]
@dataclass
class SchemaOnlyConfig:
known_field: str = "default"
def _schema_only_cli():
"""A group reading its section through `SchemaOnlyConfig` alone, in strict mode.
`included_params=()` merges no parameter from the section.
"""
@group(config_schema=SchemaOnlyConfig, included_params=(), config_strict=True)
@pass_context
def schema_only_cli(ctx):
echo(f"known_field is {get_tool_config(ctx).known_field!r}")
@schema_only_cli.command()
def subcommand():
echo("ok")
return schema_only_cli
[docs]
def test_schema_only_section_loads_in_strict_mode(invoke, create_config):
"""The CLI-parameter strict check leaves a schema-only section to the schema.
No parameter is read from it, so the schema alone reports the key it does not
know, which lax mode only warns about.
"""
conf_path = create_config("schema_only.toml", SCHEMA_ONLY_CONF)
result = invoke(
_schema_only_cli(), "--config", str(conf_path), "subcommand", color=False
)
assert result.exit_code == 0
assert "known_field is 'good'" in result.stdout
assert "typo_field" in result.stderr
[docs]
def test_validate_config_warns_unknown_when_schema_only(invoke, create_config):
"""`--validate-config` accepts a schema-only section, naming its unknown key.
The file stays valid, as it loads: lax mode only warns.
"""
conf_path = create_config("schema_only.toml", SCHEMA_ONLY_CONF)
result = invoke(
_schema_only_cli(), "--validate-config", str(conf_path), color=False
)
assert result.exit_code == 0
assert "typo_field" in result.stderr
assert f"Configuration file {conf_path} is valid." in result.stderr
[docs]
def test_config_schema_lax_silent_when_params_merged(invoke, create_config):
"""Without ``included_params=()``, lax mode stays silent on unknown keys.
The section may legitimately mix CLI parameter keys with schema fields,
so a key unknown to the schema is not necessarily a typo.
"""
@dataclass
class AppConfig:
known_field: str = "default"
@group(config_schema=AppConfig)
@pass_context
def mixed_cli(ctx):
config = get_tool_config(ctx)
echo(f"known_field is {config.known_field!r}")
@mixed_cli.command()
def subcommand():
echo("ok")
conf_path = create_config(
"mixed.toml",
dedent("""\
[mixed-cli]
known-field = "good"
typo-field = "oops"
"""),
)
result = invoke(mixed_cli, "--config", str(conf_path), "subcommand", color=False)
assert result.exit_code == 0
assert "typo_field" not in result.stderr
assert "known_field is 'good'" in result.stdout
[docs]
@dataclass
class MixedNestedConfig:
depth: int = 0
[docs]
@dataclass
class MixedConfig:
extra_stuff: str = ""
nested: MixedNestedConfig = field(default_factory=MixedNestedConfig)
sub_section_key: int = 0
MIXED_CONF = dedent("""\
[mixed-cli]
fruit = "plum"
extra-stuff = "x"
[mixed-cli.nested]
depth = 3
[mixed-cli.sub-section]
key = 2
""")
"""A section mixing the `--fruit` parameter with the fields of `MixedConfig`.
Each field takes a shape a schema reads: a scalar, a nested dataclass table, and a
table flattened into a field name.
"""
def _mixed_cli(**kwargs):
"""A group reading its section through both `--fruit` and `MixedConfig`."""
@group(config_schema=MixedConfig, **kwargs)
@option("--fruit", default="apple")
@pass_context
def mixed_cli(ctx, fruit):
config = get_tool_config(ctx)
echo(
f"fruit={fruit} extra_stuff={config.extra_stuff} "
f"depth={config.nested.depth} key={config.sub_section_key}"
)
@mixed_cli.command()
def subcommand():
echo("ok")
return mixed_cli
[docs]
@pytest.mark.parametrize(
("kwargs", "args", "expected"),
(
pytest.param(
{},
("--validate-config",),
"is valid.",
id="validate-config",
),
pytest.param(
{"config_strict": True},
("--config",),
"fruit=plum extra_stuff=x depth=3 key=2",
id="config-strict",
),
),
)
def test_strict_check_leaves_schema_keys_to_the_schema(
invoke, create_config, kwargs, args, expected
):
"""The CLI-parameter strict check accepts the keys the dataclass schema reads.
`--validate-config` always runs the check, and `config_strict=True` runs it as
the file loads.
"""
conf_path = create_config("mixed.toml", MIXED_CONF)
result = invoke(
_mixed_cli(**kwargs), *args, str(conf_path), "subcommand", color=False
)
assert result.exit_code == 0
assert expected in result.output
assert "Unknown configuration key" not in result.stderr
[docs]
def test_strict_check_rejects_a_key_neither_reads(invoke, create_config):
"""A key matching neither a parameter nor a schema field stays an error."""
conf_path = create_config(
"mixed.toml", MIXED_CONF.replace('fruit = "plum"', 'fruit = "plum"\nkiwi = 1')
)
result = invoke(_mixed_cli(), "--validate-config", str(conf_path), color=False)
assert result.exit_code == 1
assert "Unknown configuration key 'kiwi'." in result.stderr
assert "extra-stuff" not in result.stderr
[docs]
@dataclass
class PinnedConfig:
extra_stuff: str = ""
nested: MixedNestedConfig = field(default_factory=MixedNestedConfig)
sub_section_key: int = 0
pinned: dict = field(
default_factory=dict, metadata={CONFIG_PATH_METADATA_KEY: "deep.pinned"}
)
axes: dict[str, str] = field(default_factory=dict)
[docs]
@pytest.mark.parametrize(
("section", "expected"),
(
pytest.param({"extra-stuff": "x"}, {"extra-stuff"}, id="scalar"),
pytest.param({"extra_stuff": "x"}, {"extra_stuff"}, id="snake-case"),
pytest.param({"nested": {"depth": 3}}, {"nested"}, id="nested-dataclass"),
pytest.param({"sub-section": {"key": 2}}, {"sub-section"}, id="flattened"),
pytest.param(
{"sub-section": {"key": 2, "kiwi": 1}}, set(), id="flattened-with-stray"
),
pytest.param({"deep": {"pinned": {"a": 1}}}, {"deep"}, id="pinned-path"),
pytest.param({"deep": {"pinned": {}, "kiwi": 1}}, set(), id="pinned-and-stray"),
pytest.param({"axes": {"python-version": "3.14"}}, {"axes"}, id="mapping"),
pytest.param({"fruit": "plum", 1: "x"}, set(), id="unknown"),
),
)
def test_schema_keys(section, expected):
"""`_schema_keys` returns the keys of a section the dataclass reads in full."""
assert _schema_keys(section, PinnedConfig) == expected
[docs]
def test_schema_keys_of_a_callable_schema():
"""A callable schema does not tell which keys it reads."""
assert _schema_keys({"extra-stuff": "x"}, SimpleNamespace) == frozenset()
[docs]
def test_make_schema_callable_warn_unknown(caplog):
"""``warn_unknown=True`` logs unknown keys, recursing into nested dataclasses."""
@dataclass
class SubConfig:
depth: int = 0
@dataclass
class AppConfig:
known_field: str = "default"
sub: SubConfig = field(default_factory=SubConfig)
schema_callable = make_schema_callable(AppConfig, warn_unknown=True)
assert schema_callable is not None
config = schema_callable({
"known-field": "x",
"typo-field": 1,
"sub": {"depth": 2, "sub-typo": 3},
})
assert config.known_field == "x"
assert config.sub.depth == 2
assert "typo_field" in caplog.text
assert "sub_typo" in caplog.text
[docs]
def test_make_schema_callable_lax_default_is_silent(caplog):
"""Without ``warn_unknown``, lax mode keeps dropping unknown keys silently."""
@dataclass
class AppConfig:
known_field: str = "default"
schema_callable = make_schema_callable(AppConfig)
assert schema_callable is not None
config = schema_callable({"known-field": "x", "typo-field": 1})
assert config.known_field == "x"
assert "typo_field" not in caplog.text
[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
[docs]
@pytest.mark.parametrize("strict", (True, False))
def test_opaque_field_keeps_dotted_keys(invoke, create_config, caplog, strict):
"""A dot in a key of an opaque sub-tree is part of the key, not a path.
Dotted-key expansion skips extension points, whose keys are data. A key with
a trailing, inner or doubled dot reaches the schema whole, logs no
empty-segment warning and passes strict mode.
"""
@dataclass
class AppConfig:
harvest: dict[str, str] = field(default_factory=dict)
@click.group
@option("--dummy-flag/--no-flag")
@config_option(config_schema=AppConfig, strict=strict)
@pass_context
def dotted_opaque_cli(ctx, dummy_flag):
echo(f"harvest is {get_tool_config(ctx).harvest!r}")
@dotted_opaque_cli.command
def sub():
echo("sub")
conf_path = create_config(
"dotted_opaque.toml",
dedent("""\
[dotted-opaque-cli]
dummy_flag = true
[dotted-opaque-cli.harvest]
"Pick when ripe." = "apple"
"citrus.*" = "orange"
"plum..damson" = "plum"
"""),
)
with caplog.at_level(logging.WARNING, logger="click_extra"):
result = invoke(
dotted_opaque_cli, "--config", str(conf_path), "sub", color=False
)
assert result.exit_code == 0
assert (
"harvest is {'Pick when ripe.': 'apple', 'citrus.*': 'orange', "
"'plum..damson': 'plum'}"
) in result.stdout
assert "contains empty segments" not in caplog.text
assert "contains empty segments" not 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_select_app_section_of_an_empty_app_name_is_the_document():
"""An empty app name reads the whole document as the section.
`_rooted_path` and `_scope_app_sections` already read it that way.
"""
conf = {"extra-stuff": "x", "": {"depth": 3}}
assert _select_app_section(conf, "") is conf
[docs]
def test_run_config_validation_of_an_empty_app_name():
"""An empty app name feeds the whole document to the schema and validators."""
seen: list[dict] = []
report = run_config_validation(
{"extra-stuff": "x", "nested": {"depth": 3}, "axes": {"os": "linux"}},
app_name="",
params_template=None,
config_schema=PinnedConfig,
config_validators=(
ConfigValidator(extension_path="axes", validator=seen.append),
),
)
assert report.ok
assert report.schema_instance == PinnedConfig(
extra_stuff="x", nested=MixedNestedConfig(depth=3), axes={"os": "linux"}
)
assert report.opaque_subtrees == {"axes": {"os": "linux"}}
assert seen == [{"os": "linux"}]
[docs]
def test_run_config_validation_reports_a_key_that_is_not_a_string():
"""A key YAML reads as a number is an invalid key to the strict check, and an
unknown option to a strict schema, never a crash."""
report = run_config_validation(
{"my-cli": {"verbose": True, 1: "x"}},
app_name="my-cli",
params_template={"my-cli": {"verbose": None}},
config_schema=MixedConfig,
schema_strict=True,
strict=True,
)
assert [(e.code, e.message) for e in report.errors] == [
("invalid_key", "Configuration key 1 is not a string."),
(
"schema_error",
(
"Unknown configuration option(s): 1, verbose. Valid options: "
"extra_stuff, nested, sub_section_key"
),
),
]
[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
# --- schema introspection tests ---
[docs]
def test_field_docstrings_returns_full_text():
"""Attribute docstrings are recovered whole, paragraph breaks preserved."""
@dataclass
class Orchard:
rows: int = 4
"""Number of tree rows.
Rows are planted north to south so every tree gets morning sun.
"""
undocumented: str = "bare"
docs = field_docstrings(Orchard)
assert docs["rows"] == (
"Number of tree rows.\n\n"
"Rows are planted north to south so every tree gets morning sun."
)
# A field without an attribute docstring produces no entry at all.
assert "undocumented" not in docs
[docs]
def test_field_docstrings_degrades_without_source():
"""A class defined through exec has no source: the mapping is empty."""
namespace: dict = {}
exec(
dedent("""
from dataclasses import dataclass
@dataclass
class Ghost:
size: int = 1
\"\"\"Docstring lost to exec.\"\"\"
"""),
namespace,
)
assert field_docstrings(namespace["Ghost"]) == {}
[docs]
def test_schema_field_infos_flat_schema():
"""Keys are kebab-cased and sorted; defaults, types, and summaries surface."""
@dataclass
class Stand:
opening_hour: int = 8
"""Hour the stand opens.
Deliveries start one hour earlier.
"""
city: str = "Lisbon"
"""City where the stand operates."""
infos = schema_field_infos(Stand)
assert [info.key for info in infos] == ["city", "opening-hour"]
by_key = {info.key: info for info in infos}
assert by_key["opening-hour"].type_hint == "int"
assert by_key["opening-hour"].default == 8
assert by_key["opening-hour"].summary == "Hour the stand opens."
assert by_key["opening-hour"].description == (
"Hour the stand opens.\n\nDeliveries start one hour earlier."
)
assert by_key["city"].default == "Lisbon"
[docs]
def test_schema_field_infos_nested_and_config_path():
"""Nested dataclasses expand to dotted keys; config_path metadata wins."""
@dataclass
class Basket:
apples: int = 3
"""How many apples fit in the basket."""
@dataclass
class Market:
basket: Basket = field(
default_factory=Basket,
metadata={CONFIG_PATH_METADATA_KEY: "hand-basket"},
)
"""Parent docstring: nested tables document their leaves only."""
city: str = "Porto"
infos = schema_field_infos(Market)
assert [info.key for info in infos] == ["city", "hand-basket.apples"]
# Only leaf fields produce records: the parent table has no row of its own.
nested = infos[1]
assert nested.default == 3
assert nested.summary == "How many apples fit in the basket."
[docs]
def test_schema_field_infos_sorts_segment_wise():
"""A sub-table's options stay contiguous when a sibling shares their prefix.
Plain string sort would interleave `pear-cellar` between `pear.crates`
and `pear.pickers` (in ASCII `-` sorts before `.`); segment-wise sort
keeps the `pear` table's options together.
"""
@dataclass
class Pear:
crates: int = 2
pickers: int = 5
@dataclass
class Harvest:
pear: Pear = field(default_factory=Pear)
pear_cellar: bool = False
infos = schema_field_infos(Harvest)
assert [info.key for info in infos] == [
"pear.crates",
"pear.pickers",
"pear-cellar",
]
[docs]
def test_schema_field_infos_rejects_non_dataclass():
"""A non-dataclass schema is refused with a clear error."""
with pytest.raises(TypeError, match="must be a dataclass type"):
schema_field_infos(str)
with pytest.raises(TypeError, match="must be a dataclass type"):
schema_field_infos(42) # type: ignore[arg-type]
[docs]
def test_validation_codes_are_snake_case():
"""Every code Click Extra raises a `ValidationError` with is in snake case.
Scans the source of the package for the `code` of each `ValidationError` it
builds, so a new check cannot bring another spelling back.
"""
package = Path(click_extra.__file__).parent
codes = {}
for path in sorted(package.rglob("*.py")):
for node in ast.walk(ast.parse(path.read_text(encoding="UTF-8"))):
if not isinstance(node, ast.Call):
continue
callee = getattr(node.func, "id", getattr(node.func, "attr", None))
if callee != "ValidationError":
continue
code = next((kw.value for kw in node.keywords if kw.arg == "code"), None)
if code is None and len(node.args) > 2:
code = node.args[2]
if isinstance(code, ast.Constant) and isinstance(code.value, str):
codes[f"{path.name}:{node.lineno}"] = code.value
# The scan sees the codes of both the schema checks and the theme validator.
assert {"invalid_key", "unknown_parameter", "schema_error"} <= set(codes.values())
assert {"invalid_theme", "invalid_theme_shape"} <= set(codes.values())
assert {
where: code
for where, code in codes.items()
if not re.fullmatch(r"[a-z]+(?:_[a-z]+)*", code)
} == {}