# 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.
from __future__ import annotations
import logging
import os
import re
import sys
from pathlib import Path
from textwrap import dedent
import click
import pytest
from boltons.iterutils import flatten, unique
from boltons.pathutils import shrinkuser
from extra_platforms import ( # type: ignore[attr-defined]
is_macos,
is_unix_not_macos,
is_windows,
)
from extra_platforms.pytest import unless_unix_not_macos
from click_extra import (
NO_CONFIG,
VCS,
ConfigFormat,
ConfigOption,
LazyGroup,
config_option,
echo,
get_app_dir,
group,
no_config_option,
option,
pass_context,
search_params,
validate_config_option,
)
from click_extra.colorize import _escape_for_help_screen
from click_extra.config import _expand_dotted_keys
from click_extra.pytest import (
default_debug_uncolored_log_end,
default_debug_uncolored_log_start,
default_debug_uncolored_logging,
default_debug_uncolored_version_details,
)
TOML_FILE, TOML_DATA = (
dedent(
"""
# Comment
top_level_param = "to_ignore"
[config-cli1]
verbosity = "DEBUG"
blahblah = 234
dummy_flag = true
my_list = ["pip", "npm", "gem"]
[garbage]
# An empty random section that will be skipped
[config-cli1.default]
int_param = 3
random_stuff = "will be ignored"
""",
),
{
"top_level_param": "to_ignore",
"config-cli1": {
"verbosity": "DEBUG",
"blahblah": 234,
"dummy_flag": True,
"my_list": ["pip", "npm", "gem"],
"default": {
"int_param": 3,
"random_stuff": "will be ignored",
},
},
"garbage": {},
},
)
YAML_FILE, YAML_DATA = (
dedent(
"""
# Comment
top_level_param: to_ignore
config-cli1:
verbosity : DEBUG
blahblah: 234
dummy_flag: True
my_list:
- pip
- "npm"
- gem
default:
int_param: 3
random_stuff : will be ignored
garbage:
# An empty random section that will be skipped
""",
),
{
"top_level_param": "to_ignore",
"config-cli1": {
"verbosity": "DEBUG",
"blahblah": 234,
"dummy_flag": True,
"my_list": ["pip", "npm", "gem"],
"default": {
"int_param": 3,
"random_stuff": "will be ignored",
},
},
"garbage": None,
},
)
JSON_FILE, JSON_DATA = (
dedent(
"""
{
"top_level_param": "to_ignore",
"config-cli1": {
"blahblah": 234,
"dummy_flag": true,
"my_list": [
"pip",
"npm",
"gem"
],
"verbosity": "DEBUG",
"default": {
"int_param": 3,
"random_stuff": "will be ignored"
}
},
"garbage": {}
}
""",
),
{
"top_level_param": "to_ignore",
"config-cli1": {
"blahblah": 234,
"dummy_flag": True,
"my_list": ["pip", "npm", "gem"],
"verbosity": "DEBUG",
"default": {
"int_param": 3,
"random_stuff": "will be ignored",
},
},
"garbage": {},
},
)
INI_FILE, INI_DATA = (
dedent(
"""
; Comment
# Another kind of comment
[to_ignore]
key=value
spaces in keys=allowed
spaces in values=allowed as well
spaces around the delimiter = obviously
you can also use : to delimit keys from values
[config-cli1.default]
int_param = 3
random_stuff = will be ignored
[garbage]
# An empty random section that will be skipped
[config-cli1]
verbosity : DEBUG
blahblah: 234
dummy_flag = true
my_list = ["pip", "npm", "gem"]
""",
),
{
"to_ignore": {
"key": "value",
"spaces in keys": "allowed",
"spaces in values": "allowed as well",
"spaces around the delimiter": "obviously",
"you can also use": "to delimit keys from values",
},
"config-cli1": {
"default": {
"int_param": "3",
"random_stuff": "will be ignored",
},
"verbosity": "DEBUG",
"blahblah": "234",
"dummy_flag": "true",
"my_list": '["pip", "npm", "gem"]',
},
"garbage": {},
},
)
XML_FILE, XML_DATA = (
dedent(
"""
<!-- Comment -->
<config-cli1 has="an attribute">
<to_ignore>
<key>value</key>
<spaces > </spaces>
<text_as_value>
Ratione omnis sit rerum dolor.
Quas omnis dolores quod sint aspernatur.
Veniam deleniti est totam pariatur temporibus qui
accusantium eaque.
</text_as_value>
</to_ignore>
<verbosity>debug</verbosity>
<blahblah>234</blahblah>
<dummy_flag>true</dummy_flag>
<my_list>pip</my_list>
<my_list>npm</my_list>
<my_list>gem</my_list>
<garbage>
<!-- An empty random section that will be skipped -->
</garbage>
<default>
<int_param>3</int_param>
<random_stuff>will be ignored</random_stuff>
</default>
</config-cli1>
""",
),
{
"config-cli1": {
"@has": "an attribute",
"to_ignore": {
"key": "value",
"spaces": None,
"text_as_value": (
"Ratione omnis sit rerum dolor.\n"
" "
"Quas omnis dolores quod sint aspernatur.\n"
" "
"Veniam deleniti est totam pariatur temporibus qui\n"
" "
"accusantium eaque."
),
},
"verbosity": "debug",
"blahblah": "234",
"dummy_flag": "true",
"my_list": ["pip", "npm", "gem"],
"garbage": None,
"default": {
"int_param": "3",
"random_stuff": "will be ignored",
},
},
},
)
PYPROJECT_TOML_FILE, PYPROJECT_TOML_DATA = (
dedent("""\
[build-system]
requires = ["setuptools"]
[tool.config-cli1]
verbosity = "DEBUG"
blahblah = 234
dummy_flag = true
my_list = ["pip", "npm", "gem"]
[tool.config-cli1.default]
int_param = 3
random_stuff = "will be ignored"
"""),
{
"config-cli1": {
"verbosity": "DEBUG",
"blahblah": 234,
"dummy_flag": True,
"my_list": ["pip", "npm", "gem"],
"default": {
"int_param": 3,
"random_stuff": "will be ignored",
},
},
},
)
all_config_formats = pytest.mark.parametrize(
("conf_name, conf_text, conf_data"),
(
pytest.param(f"configuration.{ext}", content, data, id=ext)
for ext, content, data in (
("toml", TOML_FILE, TOML_DATA),
("yaml", YAML_FILE, YAML_DATA),
("json", JSON_FILE, JSON_DATA),
("ini", INI_FILE, INI_DATA),
("xml", XML_FILE, XML_DATA),
)
),
)
[docs]
@pytest.fixture
def simple_config_cli():
@group(context_settings={"show_envvar": True})
@option("--dummy-flag/--no-flag")
@option("--my-list", multiple=True)
def config_cli1(dummy_flag, my_list):
echo(f"dummy_flag = {dummy_flag!r}")
echo(f"my_list = {my_list!r}")
@config_cli1.command()
@option("--int-param", type=int, default=10)
def default_command(int_param):
echo(f"int_parameter = {int_param!r}")
return config_cli1
[docs]
def test_unset_conf(invoke, simple_config_cli):
result = invoke(simple_config_cli, "default")
assert result.stdout == "dummy_flag = False\nmy_list = ()\nint_parameter = 10\n"
assert not result.stderr
assert result.exit_code == 0
[docs]
def test_unset_conf_debug_message(invoke, simple_config_cli, assert_output_regex):
result = invoke(
simple_config_cli,
"--verbosity",
"DEBUG",
"default",
color=False,
)
assert result.stdout == "dummy_flag = False\nmy_list = ()\nint_parameter = 10\n"
assert_output_regex(
result.stderr,
default_debug_uncolored_log_start + default_debug_uncolored_log_end,
)
assert result.exit_code == 0
[docs]
def test_conf_default_path(invoke, simple_config_cli):
result = invoke(simple_config_cli, "--help", color=False)
# Search for the OS-specific path without glob pattern.
default_path = _escape_for_help_screen(str(shrinkuser(get_app_dir("config-cli1"))))
assert re.search(
rf"\s+\[env\s+var:\s+CONFIG_CLI1_CONFIG;\s+default:\s+{default_path}",
result.stdout,
)
# Reconstruct and search for the glob pattern, as we cannot rely on regexp because
# we cannot predict how Cloup will wrap the help screen lines.
help_screen = "".join(
line.strip()
for line in result.stdout.split("--config CONFIG_PATH")[1].splitlines()
)
fp = ",".join(unique(flatten(f.patterns for f in ConfigFormat if f.enabled)))
assert f"{{{fp}}}]" in help_screen
assert not result.stderr
assert result.exit_code == 0
[docs]
def test_conf_default_pathlib_type(invoke, create_config):
"""Refs https://github.com/kdeldycke/click-extra/issues/1356"""
conf_path = create_config("dummy.toml", TOML_FILE)
assert isinstance(conf_path, Path)
assert conf_path.is_file()
@click.command
@option("--dummy-flag/--no-flag")
@config_option(default=conf_path)
def config_cli1(dummy_flag):
echo(f"dummy_flag = {dummy_flag!r}")
result = invoke(config_cli1, "--help", color=False)
# Reconstruct and search for the glob pattern, as we cannot rely on regexp because
# we cannot predict how Cloup will wrap the help screen lines.
help_screen = "".join(
line.strip()
for line in result.stdout.split("--config CONFIG_PATH")[1].splitlines()
)
assert str(shrinkuser(conf_path)) in help_screen
assert not result.stderr
assert result.exit_code == 0
result = invoke(config_cli1)
assert result.stdout == "dummy_flag = True\n"
assert not result.stderr
assert result.exit_code == 0
[docs]
@pytest.mark.parametrize(
"conf_path",
[
pytest.param(Path("dummy.toml"), id="not-exist"),
pytest.param(Path().parent, id="not-file"),
],
)
def test_conf_not_found(invoke, simple_config_cli, conf_path):
result = invoke(
simple_config_cli,
"--config",
str(conf_path),
"default",
color=False,
)
assert not result.stdout
assert f"Load configuration matching {conf_path}\n" in result.stderr
assert "critical: No configuration file found.\n" in result.stderr
assert result.exit_code == 2
[docs]
def test_conf_unparsable(invoke, simple_config_cli, create_config):
"""Explicit --config pointing to a file with garbage content."""
conf_path = create_config("garbage.toml", "{{{{ not valid anything >>>")
result = invoke(
simple_config_cli,
"--config",
str(conf_path),
"default",
color=False,
)
assert not result.stdout
assert f"Load configuration matching {conf_path}\n" in result.stderr
assert "critical: Error parsing file as" in result.stderr
assert result.exit_code == 2
[docs]
def test_conf_empty_file(invoke, simple_config_cli, create_config):
"""Explicit --config pointing to an empty file."""
conf_path = create_config("empty.toml", "")
result = invoke(
simple_config_cli,
"--config",
str(conf_path),
"default",
color=False,
)
assert not result.stdout
assert f"Load configuration matching {conf_path}\n" in result.stderr
assert "critical: Error parsing file as" in result.stderr
assert result.exit_code == 2
[docs]
def test_no_config_option(invoke, simple_config_cli, create_config):
conf_path = create_config("dummy.toml", TOML_FILE)
for args in (
("--no-config", "default"),
("--config", str(conf_path), "--no-config", "default"),
):
result = invoke(simple_config_cli, args)
assert result.stdout == "dummy_flag = False\nmy_list = ()\nint_parameter = 10\n"
assert result.stderr == "Skip configuration file loading altogether.\n"
assert result.exit_code == 0
[docs]
def test_standalone_no_config_option(invoke):
"""@no_config_option cannot work without @config_option."""
@click.command
@no_config_option
def missing_config_option():
echo("Hello, World!")
result = invoke(missing_config_option)
assert result.exception
assert type(result.exception) is RuntimeError
assert str(result.exception) == (
"--no-config NoConfigOption must be used alongside ConfigOption."
)
assert not result.output
assert result.exit_code == 1
[docs]
@pytest.mark.parametrize(
("conf_text", "expect_error"),
[
pytest.param(
dedent("""\
[config-cli3]
dummy_flag = true
my_list = ["item 1", "item #2", "Very Last Item!"]
[config-cli3.subcommand]
int_param = 3
random_stuff = "will be ignored"
"""),
True,
id="unknown-param-rejected",
),
pytest.param(
dedent("""\
[config-cli3]
dummy_flag = true
my_list = ["item 1", "item #2"]
[config-cli3.subcommand]
int_param = 3
"""),
False,
id="clean-config-accepted",
),
],
)
def test_strict_conf(invoke, create_config, conf_text, expect_error):
"""Strict mode rejects unknown params but accepts clean configs."""
@click.group
@option("--dummy-flag/--no-flag")
@option("--my-list", multiple=True)
@config_option(strict=True)
def config_cli3(dummy_flag, my_list):
echo(f"dummy_flag is {dummy_flag!r}")
echo(f"my_list is {my_list!r}")
@config_cli3.command
@option("--int-param", type=int, default=10)
def subcommand(int_param):
echo(f"int_parameter is {int_param!r}")
conf_path = create_config("strict.toml", conf_text)
result = invoke(config_cli3, "--config", str(conf_path), "subcommand", color=False)
if expect_error:
assert result.exception
assert type(result.exception) is ValueError
assert (
str(result.exception)
== "Parameter 'random_stuff' found in second dict but not in first."
)
assert not result.stdout
assert result.exit_code == 1
else:
assert result.exit_code == 0
assert "dummy_flag is True" in result.stdout
assert "int_parameter is 3" in result.stdout
assert f"Load configuration matching {conf_path}\n" in result.stderr
[docs]
@all_config_formats
def test_conf_file_overrides_defaults(
invoke,
simple_config_cli,
create_config,
httpserver,
conf_name,
conf_text,
conf_data,
assert_output_regex,
):
# Create a local file and remote config.
conf_filepath = create_config(conf_name, conf_text)
httpserver.expect_request(f"/{conf_name}").respond_with_data(conf_text)
conf_url = httpserver.url_for(f"/{conf_name}")
for conf_path, is_url in (conf_filepath, False), (conf_url, True):
result = invoke(
simple_config_cli,
"--config",
str(conf_path),
"default",
color=False,
)
assert result.stdout == (
"dummy_flag = True\nmy_list = ('pip', 'npm', 'gem')\nint_parameter = 3\n"
)
# Debug level has been activated by configuration file.
debug_log = rf"Load configuration matching {re.escape(str(conf_path))}\n"
if is_url:
debug_log += (
r"info: 127\.0\.0\.1 - - \[\S+ \S+\] "
rf'"GET /{re.escape(conf_name)} HTTP/1\.1" 200 -\n'
)
debug_log += (
default_debug_uncolored_logging
+ default_debug_uncolored_version_details
+ default_debug_uncolored_log_end
)
assert_output_regex(result.stderr, debug_log)
assert result.exit_code == 0
[docs]
@all_config_formats
def test_auto_envvar_conf(
invoke,
simple_config_cli,
create_config,
httpserver,
conf_name,
conf_text,
conf_data,
):
# Check the --config option properly documents its environment variable.
result = invoke(simple_config_cli, "--help")
assert "CONFIG_CLI1_CONFIG" in result.stdout
assert not result.stderr
assert result.exit_code == 0
# Create a local config.
conf_filepath = create_config(conf_name, conf_text)
# Create a remote config.
httpserver.expect_request(f"/{conf_name}").respond_with_data(conf_text)
conf_url = httpserver.url_for(f"/{conf_name}")
for conf_path in conf_filepath, conf_url:
conf_path = create_config(conf_name, conf_text)
result = invoke(
simple_config_cli,
"default",
color=False,
env={"CONFIG_CLI1_CONFIG": str(conf_path)},
)
assert result.stdout == (
"dummy_flag = True\nmy_list = ('pip', 'npm', 'gem')\nint_parameter = 3\n"
)
# Debug level has been activated by configuration file.
assert result.stderr.startswith(
f"Load configuration matching {conf_path}\n"
"debug: Set <Logger click_extra (DEBUG)> to DEBUG.\n"
"debug: Set <RootLogger root (DEBUG)> to DEBUG.\n",
)
assert result.exit_code == 0
[docs]
@all_config_formats
def test_conf_file_overridden_by_cli_param(
invoke,
simple_config_cli,
create_config,
httpserver,
conf_name,
conf_text,
conf_data,
):
# Create a local file and remote config.
conf_filepath = create_config(conf_name, conf_text)
httpserver.expect_request(f"/{conf_name}").respond_with_data(conf_text)
conf_url = httpserver.url_for(f"/{conf_name}")
for conf_path in conf_filepath, conf_url:
conf_path = create_config(conf_name, conf_text)
result = invoke(
simple_config_cli,
"--my-list",
"super",
"--config",
str(conf_path),
"--verbosity",
"CRITICAL",
"--no-flag",
"--my-list",
"wow",
"default",
"--int-param",
"15",
)
assert result.stdout == (
"dummy_flag = False\nmy_list = ('super', 'wow')\nint_parameter = 15\n"
)
assert result.stderr == f"Load configuration matching {conf_path}\n"
assert result.exit_code == 0
[docs]
def test_default_map_populated(invoke, create_config):
"""Verify default_map structure when config values match CLI parameters.
Complements test_conf_metadata which only checks the empty default_map case
(where no config values match the CLI's parameter structure).
"""
conf_file = dedent(
"""
[default-map-cli]
flag_a = true
[default-map-cli.sub]
int_param = 7
"""
)
conf_path = create_config("map.toml", conf_file)
@click.group
@option("--flag-a/--no-flag-a")
@config_option
@pass_context
def default_map_cli(ctx, flag_a):
echo(f"flag_a={flag_a!r}")
# After config loading, the group's default_map has group-level values
# consumed by Click, plus the subcommand's nested section.
echo(f"default_map={ctx.default_map}")
@default_map_cli.command()
@option("--int-param", type=int, default=10)
@pass_context
def sub(ctx, int_param):
echo(f"int_param={int_param!r}")
echo(f"sub_default_map={ctx.default_map}")
result = invoke(
default_map_cli,
"--config",
str(conf_path),
"sub",
color=False,
)
assert result.exit_code == 0
assert "flag_a=True" in result.stdout
assert "int_param=7" in result.stdout
# Group's default_map retains the subcommand section after param resolution.
assert (
"default_map=ChainMap({'flag_a': True, 'sub': {'int_param': 7}}, {})"
in result.stdout
)
# Click passes default_map["sub"] to the subcommand's context.
assert "sub_default_map={'int_param': 7}" in result.stdout
[docs]
def test_default_map_none_without_config(invoke):
"""Verify default_map is left alone when --no-config is used."""
@click.group
@option("--flag/--no-flag")
@config_option
@no_config_option
@pass_context
def noconfig_map_cli(ctx, flag):
echo(f"default_map={ctx.default_map}")
@noconfig_map_cli.command()
def sub():
echo("ok")
result = invoke(noconfig_map_cli, "--no-config", "sub", color=False)
assert result.exit_code == 0
assert "default_map=None" in result.stdout
[docs]
def test_nested_subcommand_config(invoke, create_config):
"""Config propagates through group -> subgroup -> leaf command."""
conf_file = dedent(
"""
[nested-cli]
top_param = "from_config"
[nested-cli.mid]
mid_param = "from_config"
[nested-cli.mid.leaf]
leaf_param = 42
"""
)
conf_path = create_config("nested.toml", conf_file)
@group()
@option("--top-param", default="default")
def nested_cli(top_param):
echo(f"top_param={top_param!r}")
@nested_cli.group()
@option("--mid-param", default="default")
def mid(mid_param):
echo(f"mid_param={mid_param!r}")
@mid.command()
@option("--leaf-param", type=int, default=0)
def leaf(leaf_param):
echo(f"leaf_param={leaf_param!r}")
for cli_args, expected in (
(
("--config", str(conf_path), "mid", "leaf"),
("top_param='from_config'", "mid_param='from_config'", "leaf_param=42"),
),
(
(
"--config",
str(conf_path),
"--top-param",
"override",
"mid",
"--mid-param",
"override",
"leaf",
"--leaf-param",
"99",
),
("top_param='override'", "mid_param='override'", "leaf_param=99"),
),
(
("--no-config", "mid", "leaf"),
("top_param='default'", "mid_param='default'", "leaf_param=0"),
),
):
result = invoke(nested_cli, *cli_args, color=False)
assert result.exit_code == 0
for exp in expected:
assert exp in result.stdout
[docs]
def test_multiple_cli_shared_conf(invoke, create_config):
"""Two CLIs sharing the same configuration file.
Refs: https://github.com/kdeldycke/click-extra/issues/1277
"""
conf_file = dedent(
"""
# My shared configuration file.
int_param = 99 # Will be ignored.
[first-cli]
int_param = 7
[second-cli]
int_param = 11
random_stuff = "will be ignored"
""",
)
conf_path = create_config("shared.toml", conf_file)
search_path = conf_path.parent / "*.toml|*.yaml|*.yml|*.json|*.ini|*.xml"
@click.command
@option("--int-param", type=int, default=3)
@config_option(default=search_path)
@no_config_option
def first_cli(int_param):
echo(f"int = {int_param!r}")
@click.command
@option("--int-param", type=int, default=5)
@config_option(default=search_path)
@no_config_option
def second_cli(int_param):
echo(f"int = {int_param!r}")
for cli, args, expected_stdout, expected_stderr in (
(first_cli, (), "int = 7\n", ""),
(second_cli, (), "int = 11\n", ""),
(
first_cli,
("--no-config",),
"int = 3\n",
"Skip configuration file loading altogether.\n",
),
(
second_cli,
("--no-config",),
"int = 5\n",
"Skip configuration file loading altogether.\n",
),
):
result = invoke(cli, *args, color=False)
assert result.stdout == expected_stdout
assert result.stderr == expected_stderr
assert result.exit_code == 0
[docs]
def test_lazy_group_config(invoke, create_config, tmp_path):
"""Test that lazy groups work with config files.
Refs: https://github.com/kdeldycke/click-extra/issues/1332
"""
conf_file = dedent(
"""
[lazy-config-cli]
dummy_flag = true
[lazy-config-cli.foo_cmd]
foo_param = "from_config"
[lazy-config-cli.bar_cmd]
bar_flag = true
"""
)
conf_path = create_config("lazy_config.toml", conf_file)
(tmp_path / "lazy_cfg_foo.py").write_text(
dedent("""\
import click
@click.command()
@click.option("--foo-param", default="default_foo")
def foo_cli(foo_param):
click.echo(f"foo_param = {foo_param!r}")
""")
)
(tmp_path / "lazy_cfg_bar.py").write_text(
dedent("""\
import click
@click.command()
@click.option("--bar-flag/--no-bar-flag", default=False)
def bar_cli(bar_flag):
click.echo(f"bar_flag = {bar_flag!r}")
""")
)
module_names = ("lazy_cfg_foo", "lazy_cfg_bar")
def make_cli():
"""Create a fresh CLI instance.
.. caution::
Each invocation needs its own CLI because LazyGroup caches resolved
commands and the ConfigOption caches its params_template. A stale
cache would prevent config values from reaching lazy subcommands on
subsequent invocations.
"""
for name in module_names:
sys.modules.pop(name, None)
@group(
cls=LazyGroup,
lazy_subcommands={
"foo_cmd": "lazy_cfg_foo.foo_cli",
"bar_cmd": "lazy_cfg_bar.bar_cli",
},
)
@option("--dummy-flag/--no-flag")
def lazy_config_cli(dummy_flag):
echo(f"dummy_flag = {dummy_flag!r}")
return lazy_config_cli
sys.path.insert(0, str(tmp_path))
try:
for cli_args, expected in (
(
("--config", str(conf_path), "foo_cmd"),
("dummy_flag = True", "foo_param = 'from_config'"),
),
(
("--config", str(conf_path), "bar_cmd"),
("dummy_flag = True", "bar_flag = True"),
),
(
(
"--config",
str(conf_path),
"--no-flag",
"foo_cmd",
"--foo-param",
"override",
),
("dummy_flag = False", "foo_param = 'override'"),
),
):
cli = make_cli()
result = invoke(cli, *cli_args, color=False)
assert result.exit_code == 0
for exp in expected:
assert exp in result.stdout
finally:
sys.path.remove(str(tmp_path))
for name in module_names:
sys.modules.pop(name, None)
[docs]
def test_lazy_group_config_no_config_flag(invoke, create_config, tmp_path):
"""Test that --no-config works with lazy groups."""
conf_file = dedent(
"""
[lazy-noconfig-cli]
param_value = "from_config"
[lazy-noconfig-cli.sub_cmd]
sub_param = "sub_from_config"
"""
)
conf_path = create_config("lazy_noconfig.toml", conf_file)
(tmp_path / "lazy_nocfg_sub.py").write_text(
dedent("""\
import click
@click.command()
@click.option("--sub-param", default="sub_default")
def sub_cli(sub_param):
click.echo(f"sub_param = {sub_param!r}")
""")
)
module_names = ("lazy_nocfg_sub",)
def make_cli():
for name in module_names:
sys.modules.pop(name, None)
@group(
cls=LazyGroup,
lazy_subcommands={"sub_cmd": "lazy_nocfg_sub.sub_cli"},
)
@option("--param-value", default="default_value")
def lazy_noconfig_cli(param_value):
echo(f"param_value = {param_value!r}")
return lazy_noconfig_cli
sys.path.insert(0, str(tmp_path))
try:
for cli_args, expected_stdout, skip_msg in (
(
("--config", str(conf_path), "sub_cmd"),
("param_value = 'from_config'", "sub_param = 'sub_from_config'"),
False,
),
(
("--no-config", "sub_cmd"),
("param_value = 'default_value'", "sub_param = 'sub_default'"),
True,
),
(
("--config", str(conf_path), "--no-config", "sub_cmd"),
("param_value = 'default_value'", "sub_param = 'sub_default'"),
True,
),
):
cli = make_cli()
result = invoke(cli, *cli_args, color=False)
assert result.exit_code == 0
for exp in expected_stdout:
assert exp in result.stdout
if skip_msg:
assert "Skip configuration file loading altogether." in result.stderr
finally:
sys.path.remove(str(tmp_path))
for name in module_names:
sys.modules.pop(name, None)
[docs]
@pytest.mark.parametrize(
("file_format_patterns", "expected_pattern"),
[
pytest.param(
None,
",".join(
unique(flatten(fmt.patterns for fmt in ConfigFormat if fmt.enabled))
),
id="default_all_formats",
),
pytest.param(ConfigFormat.TOML, "*.toml", id="single_format"),
pytest.param(ConfigFormat.YAML, "*.yaml,*.yml", id="yaml_multiple_patterns"),
pytest.param(
[ConfigFormat.TOML, ConfigFormat.JSON],
"*.toml,*.json",
id="multiple_formats_iterable",
),
pytest.param(
{
ConfigFormat.TOML: ("*.toml", "*.tml"),
ConfigFormat.JSON: "*.json",
},
"*.toml,*.tml,*.json",
id="custom_patterns_dict",
),
pytest.param(
{
ConfigFormat.TOML: ("*.toml", "*.config"),
ConfigFormat.JSON: ("*.json", "*.config"),
},
"*.toml,*.config,*.json",
id="deduplicated_patterns",
),
],
)
def test_file_pattern(file_format_patterns, expected_pattern):
"""Test the file_pattern property with different file format configurations."""
opt = ConfigOption(file_format_patterns=file_format_patterns)
assert opt.file_pattern == expected_pattern
[docs]
@pytest.mark.parametrize(
("roaming", "force_posix", "current_platform", "expected_path"),
[
(True, False, is_macos(), "~/Library/Application Support/test-cli/"),
(False, False, is_macos(), "~/Library/Application Support/test-cli/"),
(True, True, is_macos(), "~/.test-cli/"),
(False, True, is_macos(), "~/.test-cli/"),
(True, False, is_unix_not_macos(), "~/.config/test-cli/"),
(False, False, is_unix_not_macos(), "~/.config/test-cli/"),
(True, True, is_unix_not_macos(), "~/.test-cli/"),
(False, True, is_unix_not_macos(), "~/.test-cli/"),
(True, False, is_windows(), "~\\AppData\\Roaming\\test-cli\\"),
(False, False, is_windows(), "~\\AppData\\Local\\test-cli\\"),
(True, True, is_windows(), "~\\AppData\\Roaming\\test-cli\\"),
(False, True, is_windows(), "~\\AppData\\Local\\test-cli\\"),
],
)
def test_default_pattern_roaming_force_posix(
roaming, force_posix, current_platform, expected_path, monkeypatch
):
"""Test that roaming and force_posix affect the default pattern generation."""
if not current_platform:
pytest.skip("Platform-specific test.")
# Ensure XDG_CONFIG_HOME doesn't override the default config directory.
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
@click.command
@config_option(roaming=roaming, force_posix=force_posix)
def test_cli():
pass
# Create a context and call default_pattern directly.
with click.Context(test_cli, info_name="test-cli"):
config_opt = search_params(test_cli.params, ConfigOption)
fp = config_opt.file_pattern
suffix = f"{{{fp}}}" if "," in fp else fp
assert config_opt.default_pattern() == (
str(Path(expected_path).expanduser()) + os.path.sep + suffix
)
[docs]
@unless_unix_not_macos
@pytest.mark.parametrize("force_posix", [True, False])
def test_default_pattern_xdg_config_home(force_posix, tmp_path, monkeypatch):
"""Test that default_pattern respects XDG_CONFIG_HOME on Linux."""
custom_config = tmp_path / "custom-config"
custom_config.mkdir()
monkeypatch.setenv("XDG_CONFIG_HOME", str(custom_config))
@click.command
@config_option(force_posix=force_posix)
def test_cli():
pass
with click.Context(test_cli, info_name="test-cli"):
config_opt = search_params(test_cli.params, ConfigOption)
pattern = config_opt.default_pattern()
if force_posix:
# force_posix ignores XDG_CONFIG_HOME and uses ~/.test-cli/.
assert pattern.startswith(str(Path("~/.test-cli").expanduser().resolve()))
else:
# XDG_CONFIG_HOME is resolved into the pattern.
assert pattern.startswith(str(custom_config.resolve() / "test-cli"))
[docs]
@pytest.mark.parametrize(
("search_parents", "subdirs", "create_file", "expected_start"),
[
pytest.param(
False,
("subdir",),
True,
lambda p: [(str(p / "subdir"), "config.toml")],
id="no-search",
),
pytest.param(
True,
("level1", "level2", "level3"),
True,
lambda p: [
(str(p / "level1" / "level2" / "level3"), "config.toml"),
(str(p / "level1" / "level2"), "config.toml"),
(str(p / "level1"), "config.toml"),
(str(p), "config.toml"),
],
id="file-path",
),
pytest.param(
True,
("level1", "level2", "level3"),
False,
lambda p: [
(str(p / "level1" / "level2" / "level3"), ""),
(str(p / "level1" / "level2"), ""),
(str(p / "level1"), ""),
(str(p), ""),
],
id="directory-path",
),
pytest.param(
True,
(),
True,
lambda p: [(str(p), "config.toml")],
id="shallow-reaches-root",
),
pytest.param(
True,
("a", "b", "c"),
True,
lambda p: [
(str(p / "a" / "b" / "c"), "config.toml"),
(str(p / "a" / "b"), "config.toml"),
(str(p / "a"), "config.toml"),
(str(p), "config.toml"),
],
id="deep-order",
),
],
)
def test_parent_patterns(
tmp_path, search_parents, subdirs, create_file, expected_start
):
deep_path = tmp_path
for subdir in subdirs:
deep_path = deep_path / subdir
deep_path.mkdir(parents=True, exist_ok=True)
if create_file:
config_file = deep_path / "config.toml"
config_file.write_text("[test]\nvalue = 1")
input_path = str(config_file)
else:
input_path = str(deep_path)
@click.command
@config_option(search_parents=search_parents)
def test_cli():
pass
with click.Context(test_cli, info_name="test-cli"):
config_opt = search_params(test_cli.params, ConfigOption)
patterns = list(config_opt.parent_patterns(input_path))
expected = expected_start(tmp_path)
for i, exp in enumerate(expected):
assert patterns[i] == exp, f"Pattern {i} mismatch"
assert all(isinstance(p, tuple) and len(p) == 2 for p in patterns)
if search_parents:
assert all(Path(root_dir).is_absolute() for root_dir, _ in patterns)
root_path = Path("/") if not is_windows() else Path(tmp_path.drive + "\\")
assert Path(patterns[-1][0]) == root_path
[docs]
@pytest.mark.parametrize(
("pattern_factory", "expected_factory"),
[
pytest.param(
lambda p: str(p / "a" / "b" / "*.toml"),
lambda p: [
(str(p / "a" / "b"), "*.toml"),
(str(p / "a"), "*.toml"),
(str(p), "*.toml"),
*((str(parent), "*.toml") for parent in p.parents),
],
id="file-glob-at-leaf",
),
pytest.param(
lambda p: "*.toml",
lambda p: [(None, "*.toml")],
id="entirely-magic",
),
pytest.param(
lambda p: str(p / "proj*" / "config.toml"),
lambda p: [
(str(p), str(Path("proj*") / "config.toml")),
*(
(str(parent), str(Path("proj*") / "config.toml"))
for parent in p.parents
),
],
id="magic-in-directory",
),
pytest.param(
lambda p: str(p / "a" / "*.toml|*.yaml|*.yml"),
lambda p: [
(str(p / "a"), "*.toml|*.yaml|*.yml"),
(str(p), "*.toml|*.yaml|*.yml"),
*((str(parent), "*.toml|*.yaml|*.yml") for parent in p.parents),
],
id="pipe-separated-multi-glob",
),
pytest.param(
lambda p: str(p / "proj*" / "*.toml"),
lambda p: [
(str(p), str(Path("proj*", "*.toml"))),
*((str(parent), str(Path("proj*", "*.toml"))) for parent in p.parents),
],
id="multiple-magic-parts-in-suffix",
),
pytest.param(
lambda p: str(p / "*.toml"),
lambda p: [
(str(p), "*.toml"),
*((str(parent), "*.toml") for parent in p.parents),
],
id="single-depth-magic",
),
pytest.param(
lambda p: "~/a/b/*.toml",
lambda p: [(None, "~/a/b/*.toml")],
id="tilde-is-magic",
),
pytest.param(
lambda p: str(Path("**", "config.toml")),
lambda p: [(None, str(Path("**", "config.toml")))],
id="globstar-entirely-magic",
),
],
)
def test_parent_patterns_with_magic_pattern(
tmp_path, pattern_factory, expected_factory
):
"""Test parent_patterns with glob patterns containing magic characters."""
@click.command
@config_option(search_parents=True)
def test_cli():
pass
pattern = pattern_factory(tmp_path)
expected = expected_factory(tmp_path)
with click.Context(test_cli, info_name="test-cli"):
config_opt = search_params(test_cli.params, ConfigOption)
patterns = list(config_opt.parent_patterns(pattern))
assert patterns == expected
[docs]
def test_parent_patterns_magic_no_search(tmp_path):
"""Magic pattern with search_parents=False yields only the original."""
@click.command
@config_option(search_parents=False)
def test_cli():
pass
pattern = str(tmp_path / "a" / "*.toml|*.yaml")
with click.Context(test_cli, info_name="test-cli"):
config_opt = search_params(test_cli.params, ConfigOption)
patterns = list(config_opt.parent_patterns(pattern))
assert patterns == [(str(tmp_path / "a"), "*.toml|*.yaml")]
[docs]
def test_parent_patterns_relative_path(tmp_path):
"""Test parent_patterns resolves relative paths to absolute."""
deep_path = tmp_path / "level1" / "level2"
deep_path.mkdir(parents=True)
config_file = deep_path / "config.toml"
config_file.write_text("[test]\nvalue = 1")
@click.command
@config_option(search_parents=True)
def test_cli():
pass
# Change to the parent directory to create a relative path
import os
old_cwd = os.getcwd()
try:
os.chdir(tmp_path / "level1")
relative_path = "level2/config.toml"
with click.Context(test_cli, info_name="test-cli"):
config_opt = search_params(test_cli.params, ConfigOption)
patterns = list(config_opt.parent_patterns(relative_path))
# All root_dirs should be absolute
assert all(Path(root_dir).is_absolute() for root_dir, _ in patterns)
# First pattern should resolve to the config file's parent
root_dir, file_pattern = patterns[0]
assert Path(root_dir) == config_file.parent
assert file_pattern == config_file.name
finally:
os.chdir(old_cwd)
[docs]
def test_parent_patterns_stop_at_path(tmp_path):
"""stop_at as a path limits the parent directory walk."""
deep_path = tmp_path / "a" / "b" / "c"
deep_path.mkdir(parents=True)
config_file = deep_path / "config.toml"
config_file.write_text("[test]\nvalue = 1")
boundary = tmp_path / "a"
@click.command
@config_option(search_parents=True, stop_at=boundary)
def test_cli():
pass
with click.Context(test_cli, info_name="test-cli"):
config_opt = search_params(test_cli.params, ConfigOption)
patterns = list(config_opt.parent_patterns(str(config_file)))
# First yield should be (parent_of_file, filename).
root_dir, file_pattern = patterns[0]
assert Path(root_dir) == config_file.parent
assert file_pattern == config_file.name
# Every root_dir should be inside or equal to the boundary.
for root_dir, _ in patterns:
assert Path(root_dir).is_relative_to(boundary), (
f"{root_dir} is outside boundary {boundary}"
)
[docs]
@pytest.mark.parametrize(
("has_vcs", "expected_bounded"),
[
pytest.param(True, True, id="with-vcs-root"),
pytest.param(False, False, id="no-vcs-root"),
],
)
def test_parent_patterns_stop_at_vcs(tmp_path, has_vcs, expected_bounded):
"""stop_at=VCS stops at VCS root, or walks to filesystem root if none."""
vcs_root = tmp_path / "repo"
vcs_root.mkdir()
if has_vcs:
(vcs_root / ".git").mkdir()
deep_path = vcs_root / "src" / "pkg"
deep_path.mkdir(parents=True)
@click.command
@config_option(search_parents=True, stop_at=VCS)
def test_cli():
pass
pattern = str(deep_path / "*.toml")
with click.Context(test_cli, info_name="test-cli"):
config_opt = search_params(test_cli.params, ConfigOption)
patterns = list(config_opt.parent_patterns(pattern))
assert patterns[0] == (str(deep_path), "*.toml")
if expected_bounded:
for root_dir, _ in patterns:
assert Path(root_dir).is_relative_to(vcs_root), (
f"{root_dir} is outside VCS root {vcs_root}"
)
else:
root_path = Path("/") if not is_windows() else Path(tmp_path.drive + "\\")
assert Path(patterns[-1][0]) == root_path
[docs]
def test_parent_patterns_inaccessible_directory(tmp_path):
"""Walk stops at an inaccessible directory."""
deep_path = tmp_path / "a" / "b" / "c"
deep_path.mkdir(parents=True)
config_file = deep_path / "config.toml"
config_file.write_text("[test]\nvalue = 1")
@click.command
@config_option(search_parents=True)
def test_cli():
pass
from unittest.mock import patch
original_access = os.access
def fake_access(path, mode, **kwargs):
if Path(path).resolve() == (tmp_path / "a").resolve():
return False
return original_access(path, mode, **kwargs)
with click.Context(test_cli, info_name="test-cli"):
config_opt = search_params(test_cli.params, ConfigOption)
with patch("click_extra.config.os.access", side_effect=fake_access):
patterns = list(config_opt.parent_patterns(str(config_file)))
# First yield: (parent_of_file, filename).
root_dir, file_pattern = patterns[0]
assert Path(root_dir) == config_file.parent
assert file_pattern == config_file.name
# Should stop before tmp_path/a (inaccessible).
for root_dir, _ in patterns:
assert Path(root_dir) != tmp_path / "a"
assert Path(root_dir) != tmp_path
[docs]
@pytest.mark.parametrize(
("vcs_dir", "expected"),
[
pytest.param(".git", "found", id="git"),
pytest.param(".hg", "found", id="hg"),
pytest.param(None, None, id="no-vcs"),
],
)
def test_find_vcs_root(tmp_path, vcs_dir, expected):
"""Test _find_vcs_root with .git, .hg, and no VCS markers."""
repo = tmp_path / "repo"
repo.mkdir()
if vcs_dir:
(repo / vcs_dir).mkdir()
deep = repo / "a" / "b"
deep.mkdir(parents=True)
result = ConfigOption._find_vcs_root(deep)
if expected:
assert result == repo
else:
assert result is None
[docs]
def test_config_option_default_no_config(invoke, create_config):
"""ConfigOption with default=NO_CONFIG disables autodiscovery."""
@click.group
@option("--dummy-flag/--no-flag")
@config_option(default=NO_CONFIG)
def no_autodiscovery_cli(dummy_flag):
echo(f"dummy_flag = {dummy_flag!r}")
@no_autodiscovery_cli.command()
@option("--int-param", type=int, default=10)
def default_command(int_param):
echo(f"int_parameter = {int_param!r}")
# --help shows "disabled" as default.
result = invoke(no_autodiscovery_cli, "--help", color=False)
assert result.exit_code == 0
assert "disabled" in result.stdout
# Running without --config produces no stderr.
result = invoke(no_autodiscovery_cli, "default")
assert result.exit_code == 0
assert result.stdout == "dummy_flag = False\nint_parameter = 10\n"
assert not result.stderr
# Explicit --config still loads the file.
conf_path = create_config(
"custom.toml",
dedent("""\
[no-autodiscovery-cli]
dummy_flag = true
"""),
)
result = invoke(no_autodiscovery_cli, "--config", str(conf_path), "default")
assert result.exit_code == 0
assert "dummy_flag = True" in result.stdout
[docs]
def test_no_config_explicit_with_default_no_config(invoke):
"""--no-config still prints the skip message even when NO_CONFIG is the default."""
@click.group
@option("--dummy-flag/--no-flag")
@config_option(default=NO_CONFIG)
@no_config_option
def no_autodiscovery_cli2(dummy_flag):
echo(f"dummy_flag = {dummy_flag!r}")
@no_autodiscovery_cli2.command()
def default_command():
echo("ok")
# Explicit --no-config should print the skip message.
result = invoke(no_autodiscovery_cli2, "--no-config", "default")
assert result.exit_code == 0
assert result.stderr == "Skip configuration file loading altogether.\n"
[docs]
def test_excluded_params(invoke, create_config):
"""Custom excluded_params prevents config values from being applied."""
conf_file = dedent(
"""\
[excluded-cli]
flag_a = true
flag_b = true
"""
)
conf_path = create_config("excluded.toml", conf_file)
@click.command
@option("--flag-a/--no-flag-a")
@option("--flag-b/--no-flag-b")
@config_option(excluded_params=("excluded-cli.flag_b",))
def excluded_cli(flag_a, flag_b):
echo(f"flag_a={flag_a!r}")
echo(f"flag_b={flag_b!r}")
result = invoke(excluded_cli, "--config", str(conf_path), color=False)
assert result.exit_code == 0
# flag_a is loaded from config.
assert "flag_a=True" in result.stdout
# flag_b is excluded, so it keeps its default.
assert "flag_b=False" in result.stdout
[docs]
def test_included_params(invoke, create_config):
"""Only parameters in included_params are loaded from config."""
conf_file = dedent(
"""\
[included-cli]
flag_a = true
flag_b = true
"""
)
conf_path = create_config("included.toml", conf_file)
@click.command
@option("--flag-a/--no-flag-a")
@option("--flag-b/--no-flag-b")
@config_option(included_params=("included-cli.flag_a",))
def included_cli(flag_a, flag_b):
echo(f"flag_a={flag_a!r}")
echo(f"flag_b={flag_b!r}")
result = invoke(included_cli, "--config", str(conf_path), color=False)
assert result.exit_code == 0
# flag_a is in the allowlist, so it's loaded from config.
assert "flag_a=True" in result.stdout
# flag_b is not in the allowlist, so it keeps its default.
assert "flag_b=False" in result.stdout
[docs]
def test_included_params_empty(invoke, create_config):
"""An empty included_params excludes all params from config."""
conf_file = dedent(
"""\
[empty-included-cli]
flag_a = true
flag_b = true
"""
)
conf_path = create_config("empty_included.toml", conf_file)
@click.command
@option("--flag-a/--no-flag-a")
@option("--flag-b/--no-flag-b")
@config_option(included_params=())
def empty_included_cli(flag_a, flag_b):
echo(f"flag_a={flag_a!r}")
echo(f"flag_b={flag_b!r}")
result = invoke(empty_included_cli, "--config", str(conf_path), color=False)
assert result.exit_code == 0
# Both flags keep their defaults since nothing is included.
assert "flag_a=False" in result.stdout
assert "flag_b=False" in result.stdout
[docs]
def test_included_and_excluded_params_conflict():
"""Providing both included_params and excluded_params raises ValueError."""
with pytest.raises(ValueError, match="mutually exclusive"):
ConfigOption(
excluded_params=("foo.bar",),
included_params=("foo.baz",),
)
[docs]
def test_multiple_files_matching_glob(invoke, create_config, tmp_path):
"""When multiple files match a glob, only the first parseable one is used."""
# Create two config files with different values in the same directory.
# One sets param_a, the other sets param_b. Only one file should be loaded.
(tmp_path / "first.toml").write_text(
dedent("""\
[glob-cli]
param_a = "from_first"
param_b = "from_first"
""")
)
(tmp_path / "second.toml").write_text(
dedent("""\
[glob-cli]
param_a = "from_second"
param_b = "from_second"
""")
)
search_path = tmp_path / "*.toml"
@click.command
@option("--param-a", default="default_a")
@option("--param-b", default="default_b")
@config_option(default=search_path)
def glob_cli(param_a, param_b):
echo(f"param_a={param_a!r}")
echo(f"param_b={param_b!r}")
result = invoke(glob_cli, color=False)
assert result.exit_code == 0
# Both params come from the same file β values are not merged across files.
assert (
"param_a='from_first'" in result.stdout
and "param_b='from_first'" in result.stdout
) or (
"param_a='from_second'" in result.stdout
and "param_b='from_second'" in result.stdout
)
[docs]
def test_forced_flags_warnings(caplog):
"""Warnings fire when SPLIT, BRACE or NODIR flags are missing."""
from wcmatch import fnmatch, glob
with caplog.at_level(logging.WARNING, logger="click_extra"):
ConfigOption(
file_pattern_flags=fnmatch.NEGATE, # missing SPLIT
search_pattern_flags=glob.GLOBSTAR | glob.FOLLOW, # missing BRACE and NODIR
)
assert "Forcing SPLIT flag" in caplog.text
assert "Forcing BRACE flag" in caplog.text
assert "Forcing NODIR flag" in caplog.text
[docs]
def test_root_dir_parent_search_finds_non_toml(invoke, tmp_path):
"""Parent search with root_dir correctly finds non-TOML config in parents.
Before the root_dir refactoring, SPLIT patterns like ``*.toml|*.yaml``
only applied the directory prefix to the first sub-pattern. Now with
root_dir, all sub-patterns are scoped to the correct directory.
"""
parent_dir = tmp_path / "project"
parent_dir.mkdir()
child_dir = parent_dir / "src"
child_dir.mkdir()
# Place a YAML config only in the parent, not the child.
yaml_config = parent_dir / "config.yaml"
yaml_config.write_text("parent-cli:\n int_param: 99\n")
search_pattern = str(child_dir / "*.toml|*.yaml")
@click.command
@option("--int-param", type=int, default=0)
@config_option(default=search_pattern, search_parents=True, stop_at=tmp_path)
def parent_cli(int_param):
echo(f"int_param={int_param!r}")
result = invoke(parent_cli, color=False)
assert result.exit_code == 0
assert "int_param=99" in result.stdout
[docs]
def test_pyproject_toml_in_defaults():
"""ConfigOption() with default file_format_patterns includes PYPROJECT_TOML."""
opt = ConfigOption()
assert ConfigFormat.PYPROJECT_TOML in opt.file_format_patterns
[docs]
def test_file_pattern_with_pyproject_toml():
"""Explicit file_format_patterns with PYPROJECT_TOML works."""
opt = ConfigOption(
file_format_patterns={ConfigFormat.PYPROJECT_TOML: ("pyproject.toml",)},
)
assert ConfigFormat.PYPROJECT_TOML in opt.file_format_patterns
assert opt.file_pattern == "pyproject.toml"
[docs]
def test_pyproject_toml_overrides_defaults(
invoke,
create_config,
):
"""End-to-end: a CLI with default formats reads from pyproject.toml."""
conf_path = create_config("pyproject.toml", PYPROJECT_TOML_FILE)
@click.group
@option("--dummy-flag/--no-flag")
@option("--my-list", multiple=True)
@config_option
def config_cli1(dummy_flag, my_list):
echo(f"dummy_flag = {dummy_flag!r}")
echo(f"my_list = {my_list!r}")
@config_cli1.command()
@option("--int-param", type=int, default=10)
def default_command(int_param):
echo(f"int_parameter = {int_param!r}")
result = invoke(
config_cli1,
"--config",
str(conf_path),
"default",
color=False,
)
assert result.exit_code == 0
assert result.stdout == (
"dummy_flag = True\nmy_list = ('pip', 'npm', 'gem')\nint_parameter = 3\n"
)
[docs]
def test_validate_config_valid(invoke, create_config):
"""--validate-config with a valid config file exits 0."""
conf_text = dedent("""\
[validate-cli]
dummy_flag = true
my_list = ["pip", "npm"]
[validate-cli.sub]
int_param = 3
""")
conf_path = create_config("valid.toml", conf_text)
@click.group
@option("--dummy-flag/--no-flag")
@option("--my-list", multiple=True)
@config_option
@validate_config_option
def validate_cli(dummy_flag, my_list):
echo(f"dummy_flag = {dummy_flag!r}")
@validate_cli.command
@option("--int-param", type=int, default=10)
def sub(int_param):
echo(f"int_parameter = {int_param!r}")
result = invoke(validate_cli, "--validate-config", str(conf_path), color=False)
assert result.exit_code == 0
assert "is valid" in result.stderr
[docs]
def test_validate_config_invalid_keys(invoke, create_config):
"""--validate-config with unrecognized keys exits 1."""
conf_text = dedent("""\
[validate-cli]
dummy_flag = true
unknown_key = "bad"
[validate-cli.sub]
int_param = 3
random_stuff = "will be rejected"
""")
conf_path = create_config("invalid.toml", conf_text)
@click.group
@option("--dummy-flag/--no-flag")
@option("--my-list", multiple=True)
@config_option
@validate_config_option
def validate_cli(dummy_flag, my_list):
echo(f"dummy_flag = {dummy_flag!r}")
@validate_cli.command
@option("--int-param", type=int, default=10)
def sub(int_param):
echo(f"int_parameter = {int_param!r}")
result = invoke(validate_cli, "--validate-config", str(conf_path), color=False)
assert result.exit_code == 1
assert "validation error" in result.stderr.lower()
[docs]
@pytest.mark.parametrize(
("default_pattern", "expected_help_default"),
[
pytest.param("~/*", "~/*", id="broad_glob"),
pytest.param("~/.commandrc", "~/.commandrc", id="exact_path"),
],
)
def test_extensionless_config(
invoke, create_config, default_pattern, expected_help_default
):
"""Both broad and exact default patterns resolve the same .commandrc file.
The ``default`` parameter is printed as-is on the help screen, so an exact
path is more informative than a broad glob, but both locate the same file.
"""
conf_text = dedent("""\
extensionless-cli:
dummy_flag: true
""")
conf_path = create_config(".commandrc", conf_text)
@click.command(context_settings={"show_default": True})
@option("--dummy-flag/--no-flag")
@config_option(
default=default_pattern,
file_format_patterns={ConfigFormat.YAML: ".commandrc"},
)
def extensionless_cli(dummy_flag):
echo(f"dummy_flag = {dummy_flag!r}")
# Help screen shows the raw default pattern as-is.
result = invoke(extensionless_cli, "--help", color=False)
assert result.exit_code == 0
# Join wrapped lines to match the default value regardless of terminal width.
help_screen = " ".join(result.stdout.split())
assert f"[default: {expected_help_default}]" in help_screen
# Both patterns resolve the same config file.
result = invoke(
extensionless_cli,
"--config",
str(conf_path),
color=False,
)
assert result.exit_code == 0
assert result.stdout == "dummy_flag = True\n"
[docs]
def test_validate_config_unparsable(invoke, create_config):
"""--validate-config with garbage content exits 2."""
conf_path = create_config("garbage.toml", "{{{{ not valid anything >>>")
@click.group
@option("--dummy-flag/--no-flag")
@config_option
@validate_config_option
def validate_cli(dummy_flag):
echo(f"dummy_flag = {dummy_flag!r}")
@validate_cli.command
def sub():
pass
result = invoke(validate_cli, "--validate-config", str(conf_path), color=False)
assert result.exit_code == 2
assert "Error parsing" in result.stderr
[docs]
def test_validate_config_missing_file(invoke, tmp_path):
"""--validate-config with a nonexistent file is caught by Click's Path(exists=True)."""
@click.group
@option("--dummy-flag/--no-flag")
@config_option
@validate_config_option
def validate_cli(dummy_flag):
echo(f"dummy_flag = {dummy_flag!r}")
@validate_cli.command
def sub():
pass
missing = str(tmp_path / "nonexistent.toml")
result = invoke(validate_cli, "--validate-config", missing, color=False)
assert result.exit_code == 2
[docs]
def test_validate_config_requires_config_option(invoke, tmp_path):
"""--validate-config without @config_option raises RuntimeError."""
dummy = tmp_path / "dummy.toml"
dummy.touch()
@click.command
@validate_config_option
def missing_config():
echo("Hello, World!")
result = invoke(missing_config, "--validate-config", str(dummy))
assert result.exception
assert type(result.exception) is RuntimeError
assert "ValidateConfigOption must be used alongside ConfigOption" in str(
result.exception
)
assert not result.output
assert result.exit_code == 1
[docs]
def test_validate_config_pyproject_toml(invoke, create_config):
"""--validate-config works with pyproject.toml [tool.*] sections."""
conf_text = dedent("""\
[build-system]
requires = ["setuptools"]
[tool.validate-cli]
dummy_flag = true
[tool.validate-cli.sub]
int_param = 3
""")
conf_path = create_config("pyproject.toml", conf_text)
@click.group
@option("--dummy-flag/--no-flag")
@config_option
@validate_config_option
def validate_cli(dummy_flag):
echo(f"dummy_flag = {dummy_flag!r}")
@validate_cli.command
@option("--int-param", type=int, default=10)
def sub(int_param):
echo(f"int_parameter = {int_param!r}")
result = invoke(validate_cli, "--validate-config", str(conf_path), color=False)
assert result.exit_code == 0
assert "is valid" in result.stderr
# --- _default_subcommands tests ---
[docs]
@pytest.mark.parametrize(
("cli_subcmd", "expected", "unexpected"),
[
pytest.param(None, "backup ran", "sync ran", id="config-default"),
pytest.param("sync", "sync ran", "backup ran", id="cli-override"),
],
)
def test_default_subcommand_selection(
invoke, create_config, cli_subcmd, expected, unexpected
):
"""Config default is used when no subcommand given; CLI wins otherwise."""
conf_text = dedent("""\
[ds-cli]
_default_subcommands = ["backup"]
""")
conf_path = create_config("ds-cli.toml", conf_text)
@group
def ds_cli():
pass
@ds_cli.command()
def backup():
echo("backup ran")
@ds_cli.command()
def sync():
echo("sync ran")
args = ["--config", str(conf_path)]
if cli_subcmd is not None:
args.append(cli_subcmd)
result = invoke(ds_cli, *args, color=False)
assert result.exit_code == 0
assert expected in result.output
assert unexpected not in result.output
[docs]
def test_default_subcommand_chained(invoke, create_config):
"""chain=True group runs multiple config-listed subcommands in order."""
conf_text = dedent("""\
[chained-cli]
_default_subcommands = ["backup", "sync"]
""")
conf_path = create_config("chained-cli.toml", conf_text)
@group(chain=True)
def chained_cli():
pass
@chained_cli.command()
def backup():
echo("backup ran")
@chained_cli.command()
def sync():
echo("sync ran")
result = invoke(chained_cli, "--config", str(conf_path), color=False)
assert result.exit_code == 0
assert "backup ran" in result.output
assert "sync ran" in result.output
# Verify order: backup before sync.
assert result.output.index("backup ran") < result.output.index("sync ran")
[docs]
@pytest.mark.parametrize(
("conf_value", "error_fragment"),
[
pytest.param('["backup", "sync"]', "at most 1", id="non-chained-multi"),
pytest.param('["nonexistent"]', "not found", id="unknown-subcommand"),
pytest.param('"not-a-list"', "must be a list", id="invalid-type"),
],
)
def test_default_subcommand_config_errors(
invoke, create_config, conf_value, error_fragment
):
"""Bad _default_subcommands values produce clear errors."""
conf_text = dedent(f"""\
[err-cli]
_default_subcommands = {conf_value}
""")
conf_path = create_config("err-cli.toml", conf_text)
@group
def err_cli():
pass
@err_cli.command()
def backup():
echo("backup ran")
@err_cli.command()
def sync():
echo("sync ran")
result = invoke(err_cli, "--config", str(conf_path), color=False)
assert result.exit_code != 0
combined = result.output + result.stderr
assert error_fragment in combined
[docs]
def test_default_subcommand_strict_mode_tolerance(invoke, create_config):
"""strict=True config with _default_subcommands doesn't raise."""
conf_text = dedent("""\
[strict-cli]
_default_subcommands = ["backup"]
""")
conf_path = create_config("strict-cli.toml", conf_text)
@click.group
@config_option(strict=True)
def strict_cli():
pass
@strict_cli.command()
def backup():
echo("backup ran")
result = invoke(strict_cli, "--config", str(conf_path), "backup", color=False)
assert result.exit_code == 0
assert "backup ran" in result.output
[docs]
def test_default_subcommand_validate_config_tolerance(invoke, create_config):
"""--validate-config with _default_subcommands reports valid."""
conf_text = dedent("""\
[validate-ds-cli]
_default_subcommands = ["sub"]
dummy_flag = true
[validate-ds-cli.sub]
int_param = 3
""")
conf_path = create_config("validate-ds-cli.toml", conf_text)
@click.group
@option("--dummy-flag/--no-flag")
@config_option
@validate_config_option
def validate_ds_cli(dummy_flag):
echo(f"dummy_flag = {dummy_flag!r}")
@validate_ds_cli.command()
@option("--int-param", type=int, default=10)
def sub(int_param):
echo(f"int_parameter = {int_param!r}")
result = invoke(validate_ds_cli, "--validate-config", str(conf_path), color=False)
assert result.exit_code == 0
assert "is valid" in result.stderr
[docs]
def test_default_subcommand_with_options(invoke, create_config):
"""Default subcommand receives its config-provided options."""
conf_text = dedent("""\
[opts-cli]
_default_subcommands = ["backup"]
[opts-cli.backup]
path = "/home"
""")
conf_path = create_config("opts-cli.toml", conf_text)
@group
def opts_cli():
pass
@opts_cli.command()
@option("--path", default="/tmp")
def backup(path):
echo(f"path={path}")
result = invoke(opts_cli, "--config", str(conf_path), color=False)
assert result.exit_code == 0
assert "path=/home" in result.output
[docs]
def test_default_subcommand_no_config(invoke):
"""Normal behavior when no config file is loaded."""
@group
def no_conf_cli():
pass
@no_conf_cli.command()
def backup():
echo("backup ran")
# Without a subcommand and no config, the group should not run any subcommand.
result = invoke(no_conf_cli, "--no-config", color=False)
assert "backup ran" not in result.output
[docs]
def test_default_subcommand_duplicates_warning(invoke, create_config):
"""Duplicate entries in _default_subcommands are deduplicated with a warning."""
conf_text = dedent("""\
[dup-cli]
_default_subcommands = ["backup", "sync", "backup"]
""")
conf_path = create_config("dup-cli.toml", conf_text)
@group(chain=True)
def dup_cli():
pass
@dup_cli.command()
def backup():
echo("backup ran")
@dup_cli.command()
def sync():
echo("sync ran")
result = invoke(
dup_cli, "--config", str(conf_path), "--verbosity", "WARNING", color=False
)
assert result.exit_code == 0
assert "backup ran" in result.output
assert "sync ran" in result.output
# backup should only run once despite being listed twice.
assert result.output.count("backup ran") == 1
assert "Duplicate entries" in result.stderr
[docs]
def test_default_subcommand_cli_override_debug_log(invoke, create_config):
"""Debug log emitted when CLI subcommands override config defaults."""
conf_text = dedent("""\
[log-cli]
_default_subcommands = ["backup"]
""")
conf_path = create_config("log-cli.toml", conf_text)
@group
def log_cli():
pass
@log_cli.command()
def backup():
echo("backup ran")
@log_cli.command()
def sync():
echo("sync ran")
result = invoke(
log_cli,
"--config",
str(conf_path),
"--verbosity",
"DEBUG",
"sync",
color=False,
)
assert result.exit_code == 0
assert "sync ran" in result.output
assert "backup ran" not in result.output
assert "ignoring _default_subcommands" in result.stderr.lower()
# --- _prepend_subcommands tests ---
[docs]
@pytest.mark.parametrize(
("cli_subcmd", "expected", "unexpected"),
[
pytest.param("sync", "sync ran", "", id="with-cli-arg"),
pytest.param(None, "", "sync ran", id="no-cli-args"),
],
)
def test_prepend_subcommand_selection(
invoke, create_config, cli_subcmd, expected, unexpected
):
"""Prepend fires regardless of whether a CLI subcommand is given."""
conf_text = dedent("""\
[prepend-cli]
_prepend_subcommands = ["debug"]
""")
conf_path = create_config("prepend-cli.toml", conf_text)
@group(chain=True)
def prepend_cli():
pass
@prepend_cli.command()
def debug():
echo("debug ran")
@prepend_cli.command()
def sync():
echo("sync ran")
args = ["--config", str(conf_path)]
if cli_subcmd is not None:
args.append(cli_subcmd)
result = invoke(prepend_cli, *args, color=False)
assert result.exit_code == 0
assert "debug ran" in result.output
if expected:
assert expected in result.output
# debug must come before the CLI subcommand.
assert result.output.index("debug ran") < result.output.index(expected)
if unexpected:
assert unexpected not in result.output
[docs]
@pytest.mark.parametrize(
("cli_subcmd", "expect_backup"),
[
pytest.param(None, False, id="no-cli-defaults-apply"),
pytest.param("sync", False, id="cli-overrides-defaults"),
],
)
def test_prepend_subcommand_with_defaults(
invoke, create_config, cli_subcmd, expect_backup
):
"""Prepend always applies; defaults only fire when no CLI subcommand given."""
conf_text = dedent("""\
[pd-cli]
_default_subcommands = ["sync"]
_prepend_subcommands = ["debug"]
""")
conf_path = create_config("pd-cli.toml", conf_text)
@group(chain=True)
def pd_cli():
pass
@pd_cli.command()
def debug():
echo("debug ran")
@pd_cli.command()
def backup():
echo("backup ran")
@pd_cli.command()
def sync():
echo("sync ran")
args = ["--config", str(conf_path)]
if cli_subcmd is not None:
args.append(cli_subcmd)
result = invoke(pd_cli, *args, color=False)
assert result.exit_code == 0
assert "debug ran" in result.output
assert "sync ran" in result.output
assert result.output.index("debug ran") < result.output.index("sync ran")
if expect_backup:
assert "backup ran" in result.output
else:
assert "backup ran" not in result.output
[docs]
def test_prepend_subcommand_non_chained_error(invoke, create_config):
"""Error on non-chained group."""
conf_text = dedent("""\
[nc-cli]
_prepend_subcommands = ["debug"]
""")
conf_path = create_config("nc-cli.toml", conf_text)
@group
def nc_cli():
pass
@nc_cli.command()
def debug():
echo("debug ran")
@nc_cli.command()
def sync():
echo("sync ran")
result = invoke(nc_cli, "--config", str(conf_path), "sync", color=False)
assert result.exit_code != 0
combined = result.output + result.stderr
assert "chain=True" in combined
[docs]
@pytest.mark.parametrize(
("conf_value", "error_fragment"),
[
pytest.param('"not-a-list"', "must be a list", id="invalid-type"),
pytest.param('["nonexistent"]', "not found", id="unknown-subcommand"),
],
)
def test_prepend_subcommand_config_errors(
invoke, create_config, conf_value, error_fragment
):
"""Bad _prepend_subcommands values produce clear errors."""
conf_text = dedent(f"""\
[perr-cli]
_prepend_subcommands = {conf_value}
""")
conf_path = create_config("perr-cli.toml", conf_text)
@group(chain=True)
def perr_cli():
pass
@perr_cli.command()
def backup():
echo("backup ran")
result = invoke(perr_cli, "--config", str(conf_path), color=False)
assert result.exit_code != 0
combined = result.output + result.stderr
assert error_fragment in combined
[docs]
def test_prepend_subcommand_strict_mode_tolerance(invoke, create_config):
"""strict=True config with _prepend_subcommands doesn't raise."""
conf_text = dedent("""\
[strict-p-cli]
_prepend_subcommands = ["backup"]
""")
conf_path = create_config("strict-p-cli.toml", conf_text)
@click.group(chain=True)
@config_option(strict=True)
def strict_p_cli():
pass
@strict_p_cli.command()
def backup():
echo("backup ran")
result = invoke(strict_p_cli, "--config", str(conf_path), "backup", color=False)
assert result.exit_code == 0
assert "backup ran" in result.output
[docs]
def test_prepend_subcommand_validate_config_tolerance(invoke, create_config):
"""--validate-config with _prepend_subcommands reports valid."""
conf_text = dedent("""\
[validate-ps-cli]
_prepend_subcommands = ["sub"]
dummy_flag = true
[validate-ps-cli.sub]
int_param = 3
""")
conf_path = create_config("validate-ps-cli.toml", conf_text)
@click.group(chain=True)
@option("--dummy-flag/--no-flag")
@config_option
@validate_config_option
def validate_ps_cli(dummy_flag):
echo(f"dummy_flag = {dummy_flag!r}")
@validate_ps_cli.command()
@option("--int-param", type=int, default=10)
def sub(int_param):
echo(f"int_parameter = {int_param!r}")
result = invoke(validate_ps_cli, "--validate-config", str(conf_path), color=False)
assert result.exit_code == 0
assert "is valid" in result.stderr
[docs]
def test_prepend_subcommand_duplicates_warning(invoke, create_config):
"""Duplicate entries in _prepend_subcommands are deduplicated with a warning."""
conf_text = dedent("""\
[pdup-cli]
_prepend_subcommands = ["debug", "debug"]
""")
conf_path = create_config("pdup-cli.toml", conf_text)
@group(chain=True)
def pdup_cli():
pass
@pdup_cli.command()
def debug():
echo("debug ran")
@pdup_cli.command()
def sync():
echo("sync ran")
result = invoke(
pdup_cli,
"--config",
str(conf_path),
"--verbosity",
"WARNING",
"sync",
color=False,
)
assert result.exit_code == 0
assert "debug ran" in result.output
assert "sync ran" in result.output
# debug should only run once despite being listed twice.
assert result.output.count("debug ran") == 1
assert "Duplicate entries" in result.stderr
[docs]
def test_prepend_subcommand_info_log(invoke, create_config):
"""INFO log emitted when _prepend_subcommands are injected."""
conf_text = dedent("""\
[plog-cli]
_prepend_subcommands = ["debug"]
""")
conf_path = create_config("plog-cli.toml", conf_text)
@group(chain=True)
def plog_cli():
pass
@plog_cli.command()
def debug():
echo("debug ran")
@plog_cli.command()
def sync():
echo("sync ran")
result = invoke(
plog_cli,
"--config",
str(conf_path),
"--verbosity",
"INFO",
"sync",
color=False,
)
assert result.exit_code == 0
assert "debug ran" in result.output
assert "sync ran" in result.output
assert "prepending _prepend_subcommands" in result.stderr.lower()
[docs]
def test_prepend_subcommand_multiple(invoke, create_config):
"""Multiple prepend subcommands run in order."""
conf_text = dedent("""\
[pmulti-cli]
_prepend_subcommands = ["init", "debug"]
""")
conf_path = create_config("pmulti-cli.toml", conf_text)
@group(chain=True)
def pmulti_cli():
pass
@pmulti_cli.command()
def init():
echo("init ran")
@pmulti_cli.command()
def debug():
echo("debug ran")
@pmulti_cli.command()
def sync():
echo("sync ran")
result = invoke(pmulti_cli, "--config", str(conf_path), "sync", color=False)
assert result.exit_code == 0
assert "init ran" in result.output
assert "debug ran" in result.output
assert "sync ran" in result.output
# Verify order: init, debug, sync.
assert result.output.index("init ran") < result.output.index("debug ran")
assert result.output.index("debug ran") < result.output.index("sync ran")
# --- _check_pattern_sanity tests ---
[docs]
def test_sanity_disjoint_patterns(caplog):
"""Literal default not matching any format pattern triggers a debug log."""
from wcmatch import glob
with caplog.at_level(logging.DEBUG, logger="click_extra"):
ConfigOption(
default="/etc/myapp/config.conf",
file_format_patterns={ConfigFormat.TOML: "*.toml"},
search_pattern_flags=(
glob.GLOBSTAR
| glob.FOLLOW
| glob.DOTGLOB
| glob.BRACE
| glob.SPLIT
| glob.GLOBTILDE
| glob.NODIR
),
)
assert "does not match any format pattern" in caplog.text
[docs]
def test_sanity_disjoint_matching_literal(caplog):
"""Literal default matching a format pattern does NOT trigger the warning."""
from wcmatch import glob
with caplog.at_level(logging.DEBUG, logger="click_extra"):
ConfigOption(
default="/etc/myapp/config.toml",
file_format_patterns={ConfigFormat.TOML: "*.toml"},
search_pattern_flags=(
glob.GLOBSTAR
| glob.FOLLOW
| glob.DOTGLOB
| glob.BRACE
| glob.SPLIT
| glob.GLOBTILDE
| glob.NODIR
),
)
assert "does not match any format pattern" not in caplog.text
[docs]
def test_sanity_dotfile_without_dotglob(caplog):
"""Dotfile in default without DOTGLOB triggers a debug log."""
from wcmatch import glob
with caplog.at_level(logging.DEBUG, logger="click_extra"):
ConfigOption(
default="~/.myapprc",
file_format_patterns={ConfigFormat.YAML: "*.yaml"},
search_pattern_flags=(
glob.GLOBSTAR
| glob.FOLLOW
| glob.BRACE
| glob.SPLIT
| glob.GLOBTILDE
| glob.NODIR
),
)
assert "DOTGLOB is not set" in caplog.text
[docs]
def test_sanity_dotfile_with_dotglob(caplog):
"""Dotfile with DOTGLOB does NOT trigger the warning."""
from wcmatch import glob
with caplog.at_level(logging.DEBUG, logger="click_extra"):
ConfigOption(
default="~/.myapprc",
file_format_patterns={ConfigFormat.YAML: "*.yaml"},
search_pattern_flags=(
glob.GLOBSTAR
| glob.FOLLOW
| glob.DOTGLOB
| glob.BRACE
| glob.SPLIT
| glob.GLOBTILDE
| glob.NODIR
),
)
assert "DOTGLOB is not set" not in caplog.text
[docs]
def test_sanity_no_explicit_default(caplog):
"""Without an explicit string default, checks 1/2/4 are skipped."""
from wcmatch import glob
with caplog.at_level(logging.DEBUG, logger="click_extra"):
ConfigOption(
file_format_patterns={ConfigFormat.YAML: "*.yaml"},
search_pattern_flags=(
glob.GLOBSTAR
| glob.FOLLOW
| glob.DOTGLOB
| glob.BRACE
| glob.SPLIT
| glob.GLOBTILDE
| glob.NODIR
),
)
assert "Broad search pattern" not in caplog.text
assert "does not match" not in caplog.text
[docs]
@pytest.mark.parametrize(
("input_conf", "expected"),
(
pytest.param(
{"a": 1, "b": 2},
{"a": 1, "b": 2},
id="no_dots",
),
pytest.param(
{"a.b": 1},
{"a": {"b": 1}},
id="single_dotted_key",
),
pytest.param(
{"a.b.c": 1},
{"a": {"b": {"c": 1}}},
id="multi_level_dotted_key",
),
pytest.param(
{"a.b": 1, "a": {"c": 2}},
{"a": {"b": 1, "c": 2}},
id="mixed_dotted_and_nested",
),
pytest.param(
{"a": {"b.c": 1, "d": 2}},
{"a": {"b": {"c": 1}, "d": 2}},
id="nested_dotted_key",
),
pytest.param(
{"a.b": 1, "a.c": 2},
{"a": {"b": 1, "c": 2}},
id="multiple_dotted_same_prefix",
),
pytest.param(
{"a.b": {"c": 3}, "a": {"d": 4}},
{"a": {"b": {"c": 3}, "d": 4}},
id="dotted_key_with_dict_value",
),
pytest.param(
{},
{},
id="empty",
),
),
)
def test_expand_dotted_keys(input_conf, expected):
assert _expand_dotted_keys(input_conf) == expected
[docs]
@pytest.mark.parametrize(
("conf_name", "conf_text"),
(
pytest.param(
"dotted.toml",
dedent("""\
[config-cli1]
"default.int_param" = 77
dummy_flag = true
my_list = ["pip", "npm", "gem"]
verbosity = "DEBUG"
"""),
id="toml",
),
pytest.param(
"dotted.json",
dedent("""\
{
"config-cli1": {
"default.int_param": 77,
"dummy_flag": true,
"my_list": ["pip", "npm", "gem"],
"verbosity": "DEBUG"
}
}
"""),
id="json",
),
pytest.param(
"dotted.yaml",
dedent("""\
config-cli1:
"default.int_param": 77
dummy_flag: true
my_list:
- pip
- npm
- gem
verbosity: DEBUG
"""),
id="yaml",
),
),
)
def test_dotted_keys_in_config(
invoke, simple_config_cli, create_config, conf_name, conf_text
):
"""Dotted keys in config files are expanded into nested structures."""
conf_path = create_config(conf_name, conf_text)
result = invoke(
simple_config_cli,
"--config",
str(conf_path),
"default",
color=False,
)
assert result.exit_code == 0
assert result.stdout == (
"dummy_flag = True\nmy_list = ('pip', 'npm', 'gem')\nint_parameter = 77\n"
)
[docs]
@pytest.mark.parametrize(
("input_conf", "warning_fragment"),
(
pytest.param(
{"a": 1, "a.b": 2},
"Configuration key 'a.b' conflicts with 'a'",
id="scalar_then_dotted",
),
pytest.param(
{"a.b": 2, "a": 1},
"Configuration key 'a' conflicts with 'a'",
id="dotted_then_scalar",
),
pytest.param(
{"a": None, "a.b": 2},
"Configuration key 'a.b' conflicts with 'a'",
id="none_then_dotted",
),
pytest.param(
{"a.b": 2, "a": None},
"Configuration key 'a' conflicts with 'a'",
id="dotted_then_none",
),
pytest.param(
{"a.b.c": 1, "a.b": 2},
"Configuration key 'a.b' conflicts with 'a.b'",
id="deep_conflict",
),
pytest.param(
{"a.b": 2, "a.b.c": 1},
"Configuration key 'a.b.c' conflicts with 'a.b'",
id="deep_conflict_reversed",
),
pytest.param(
{"a": False, "a.b": 2},
"Configuration key 'a.b' conflicts with 'a'",
id="false_then_dotted",
),
pytest.param(
{"a": 0, "a.b": 2},
"Configuration key 'a.b' conflicts with 'a'",
id="zero_then_dotted",
),
pytest.param(
{"a": "", "a.b": 2},
"Configuration key 'a.b' conflicts with 'a'",
id="empty_string_then_dotted",
),
pytest.param(
{"a": [], "a.b": 2},
"Configuration key 'a.b' conflicts with 'a'",
id="empty_list_then_dotted",
),
pytest.param(
{"a.b": 2, "a": False},
"Configuration key 'a' conflicts with 'a'",
id="dotted_then_false",
),
pytest.param(
{"a.b": 2, "a": 0},
"Configuration key 'a' conflicts with 'a'",
id="dotted_then_zero",
),
),
)
def test_expand_dotted_keys_conflict_warning(caplog, input_conf, warning_fragment):
"""Scalar/dict conflicts on the same key emit a warning."""
with caplog.at_level(logging.WARNING, logger="click_extra"):
_expand_dotted_keys(input_conf)
assert warning_fragment in caplog.text
[docs]
@pytest.mark.parametrize(
"input_conf",
(
pytest.param({"...": 1}, id="only_dots"),
pytest.param({".a": 1}, id="leading_dot"),
pytest.param({"a.": 1}, id="trailing_dot"),
),
)
def test_expand_dotted_keys_empty_segments(caplog, input_conf):
"""Dotted keys with empty segments are skipped with a warning."""
with caplog.at_level(logging.WARNING, logger="click_extra"):
result = _expand_dotted_keys(input_conf)
assert result == {}
assert "contains empty segments" in caplog.text
[docs]
@pytest.mark.parametrize(
("input_conf", "expected"),
(
pytest.param(
{"a": {"b": 4}, "a.b": 2},
{"a": {"b": 2}},
id="dict_then_dotted_same_leaf",
),
pytest.param(
{"a.b": 2, "a": {"b": 4}},
{"a": {"b": 4}},
id="dotted_then_dict_same_leaf",
),
pytest.param(
{"a": {}, "a.b": 2},
{"a": {"b": 2}},
id="empty_dict_then_dotted",
),
pytest.param(
{"a.b": None},
{"a": {"b": None}},
id="dotted_with_none_value",
),
pytest.param(
{"a.b": [1, 2]},
{"a": {"b": [1, 2]}},
id="dotted_with_list_value",
),
# Falsy values as leaves.
pytest.param(
{"a.b": False},
{"a": {"b": False}},
id="dotted_with_false",
),
pytest.param(
{"a.b": 0},
{"a": {"b": 0}},
id="dotted_with_zero",
),
pytest.param(
{"a.b": 0.0},
{"a": {"b": 0.0}},
id="dotted_with_zero_float",
),
pytest.param(
{"a.b": ""},
{"a": {"b": ""}},
id="dotted_with_empty_string",
),
pytest.param(
{"a.b": []},
{"a": {"b": []}},
id="dotted_with_empty_list",
),
pytest.param(
{"a.b": ()},
{"a": {"b": ()}},
id="dotted_with_empty_tuple",
),
# Truthy values as leaves.
pytest.param(
{"a.b": True},
{"a": {"b": True}},
id="dotted_with_true",
),
pytest.param(
{"a.b": 1},
{"a": {"b": 1}},
id="dotted_with_one",
),
pytest.param(
{"a.b": " "},
{"a": {"b": " "}},
id="dotted_with_whitespace",
),
# Falsy values at intermediate positions.
pytest.param(
{"a": False, "a.b": 2},
{"a": {"b": 2}},
id="false_then_dotted",
),
pytest.param(
{"a": 0, "a.b": 2},
{"a": {"b": 2}},
id="zero_then_dotted",
),
pytest.param(
{"a": "", "a.b": 2},
{"a": {"b": 2}},
id="empty_string_then_dotted",
),
pytest.param(
{"a": [], "a.b": 2},
{"a": {"b": 2}},
id="empty_list_then_dotted",
),
# Dotted then falsy plain key.
pytest.param(
{"a.b": 2, "a": False},
{"a": False},
id="dotted_then_false",
),
pytest.param(
{"a.b": 2, "a": 0},
{"a": 0},
id="dotted_then_zero",
),
# Empty dict merges cleanly (no data loss).
pytest.param(
{"a.b": 2, "a": {}},
{"a": {"b": 2}},
id="dotted_then_empty_dict",
),
),
)
def test_expand_dotted_keys_edge_cases(input_conf, expected):
assert _expand_dotted_keys(input_conf) == expected
[docs]
@pytest.mark.parametrize(
("input_conf", "error_fragment"),
(
pytest.param(
{"a": 1, "a.b": 2},
"Configuration key 'a.b' conflicts with 'a'",
id="scalar_then_dotted",
),
pytest.param(
{"a.b": 2, "a": 1},
"Configuration key 'a' conflicts with 'a'",
id="dotted_then_scalar",
),
pytest.param(
{"a.b.c": 1, "a.b": 2},
"Configuration key 'a.b' conflicts with 'a.b'",
id="deep_conflict",
),
pytest.param(
{"a": None, "a.b": 2},
"Configuration key 'a.b' conflicts with 'a'",
id="none_then_dotted",
),
),
)
def test_expand_dotted_keys_strict_conflict(input_conf, error_fragment):
"""Strict mode raises ValueError on type conflicts."""
with pytest.raises(ValueError, match=error_fragment):
_expand_dotted_keys(input_conf, strict=True)
[docs]
@pytest.mark.parametrize(
"input_conf",
(
pytest.param({"...": 1}, id="only_dots"),
pytest.param({".a": 1}, id="leading_dot"),
pytest.param({"a.": 1}, id="trailing_dot"),
),
)
def test_expand_dotted_keys_strict_empty_segments(input_conf):
"""Strict mode raises ValueError on dotted keys with empty segments."""
with pytest.raises(ValueError, match="contains empty segments"):
_expand_dotted_keys(input_conf, strict=True)
[docs]
def test_strict_conf_dotted_key_conflict(invoke, create_config):
"""Strict mode rejects configs with dotted-key type conflicts."""
@click.group
@option("--dummy-flag/--no-flag")
@config_option(strict=True)
def strict_cli(dummy_flag):
echo(f"dummy_flag is {dummy_flag!r}")
@strict_cli.command
@option("--int-param", type=int, default=10)
def subcommand(int_param):
echo(f"int_parameter is {int_param!r}")
conf_path = create_config(
"conflict.json",
dedent("""\
{
"strict-cli": {
"subcommand": "not_a_dict",
"subcommand.int_param": 3
}
}
"""),
)
result = invoke(strict_cli, "--config", str(conf_path), "subcommand", color=False)
assert result.exception
assert type(result.exception) is ValueError
assert "conflicts" in str(result.exception)
assert result.exit_code == 1
# --- config_schema and fallback_sections tests ---
[docs]
def test_normalize_config_keys():
from click_extra.config import 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."""
from dataclasses import dataclass, field
from click_extra.config import get_tool_config
@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."""
from types import SimpleNamespace
from click_extra.config import get_tool_config, normalize_config_keys
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, tool_config is not set."""
from dataclasses import dataclass
from click_extra.config import get_tool_config
@dataclass
class AppConfig:
value: str = "default"
@group(config_schema=AppConfig)
@pass_context
def no_file_cli(ctx):
config = get_tool_config(ctx)
echo(f"config is {config!r}")
@no_file_cli.command()
def subcommand():
echo("ok")
result = invoke(no_file_cli, "subcommand", color=False)
assert result.exit_code == 0
assert "config is None" 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."""
from dataclasses import dataclass
from click_extra.config import get_tool_config
@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."""
from dataclasses import dataclass
from click_extra.config import get_tool_config
@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."""
from dataclasses import dataclass
from click_extra.config import get_tool_config
@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 dataclasses import dataclass
from click import group as click_group
from click_extra.config import get_tool_config
@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."""
from dataclasses import dataclass
from click_extra.config import get_tool_config
@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