diff --git a/piptools/utils.py b/piptools/utils.py index 3065fee63..68a46f51d 100644 --- a/piptools/utils.py +++ b/piptools/utils.py @@ -2,6 +2,7 @@ import collections import copy +import difflib import itertools import json import os @@ -561,6 +562,7 @@ def override_defaults_from_config_file( config = parse_config_file(config_file) if config: + _validate_config(ctx, config) _assign_config_to_cli_context(ctx, config) return config_file @@ -576,6 +578,45 @@ def _assign_config_to_cli_context( click_context.default_map.update(cli_config_mapping) +def _validate_config( + click_context: click.Context, + config: dict[str, Any], +) -> None: + """ + Validate parsed config against click command params. + + :raises click.NoSuchOption: if config contains unknown keys. + :raises click.BadOptionUsage: if config contains invalid values. + """ + cli_params = { + param.name: param + for param in click_context.command.params + if param.name is not None + } + + for key, value in config.items(): + # Validate unknown keys + if key not in cli_params: + possibilities = difflib.get_close_matches(key, cli_params.keys()) + raise click.NoSuchOption( + option_name=key, + message=f"No such config key {key!r}.", + possibilities=possibilities, + ctx=click_context, + ) + + # Validate invalid values + param = cli_params[key] + try: + param.type.convert(value=value, param=param, ctx=click_context) + except Exception as e: + raise click.BadOptionUsage( + option_name=key, + message=f"Invalid value for config key {key!r}: {value!r}.", + ctx=click_context, + ) from e + + def select_config_file(src_files: tuple[str, ...]) -> Path | None: """ Returns the config file to use for defaults given ``src_files`` provided. @@ -614,6 +655,7 @@ def select_config_file(src_files: tuple[str, ...]) -> Path | None: "upgrade_package": "upgrade_packages", "resolver": "resolver_name", "user": "user_only", + "pip_args": "pip_args_str", } diff --git a/tests/test_cli_compile.py b/tests/test_cli_compile.py index edb4d2087..693054400 100644 --- a/tests/test_cli_compile.py +++ b/tests/test_cli_compile.py @@ -2989,3 +2989,31 @@ def test_no_config_option_overrides_config_with_defaults( assert out.exit_code == 0 assert "Dry-run, so nothing updated" not in out.stderr + + +def test_raise_error_on_unknown_config_option( + pip_conf, runner, tmp_path, make_config_file +): + config_file = make_config_file("unknown-option", True) + + req_in = tmp_path / "requirements.in" + req_in.touch() + + out = runner.invoke(cli, [req_in.as_posix(), "--config", config_file.as_posix()]) + + assert out.exit_code == 2 + assert "No such config key 'unknown_option'" in out.stderr + + +def test_raise_error_on_invalid_config_option( + pip_conf, runner, tmp_path, make_config_file +): + config_file = make_config_file("dry-run", ["invalid", "value"]) + + req_in = tmp_path / "requirements.in" + req_in.touch() + + out = runner.invoke(cli, [req_in.as_posix(), "--config", config_file.as_posix()]) + + assert out.exit_code == 2 + assert "Invalid value for config key 'dry_run': ['invalid', 'value']" in out.stderr diff --git a/tests/test_cli_sync.py b/tests/test_cli_sync.py index 0ebcddf8d..9eca78082 100644 --- a/tests/test_cli_sync.py +++ b/tests/test_cli_sync.py @@ -398,3 +398,29 @@ def test_no_config_option_overrides_config_with_defaults(run, runner, make_confi assert out.exit_code == 0 assert "Would install:" not in out.stdout + + +@mock.patch("piptools.sync.run") +def test_raise_error_on_unknown_config_option(run, runner, tmp_path, make_config_file): + config_file = make_config_file("unknown-option", True) + + with open(sync.DEFAULT_REQUIREMENTS_FILE, "w") as reqs_txt: + reqs_txt.write("six==1.10.0") + + out = runner.invoke(cli, ["--config", config_file.as_posix()]) + + assert out.exit_code == 2 + assert "No such config key 'unknown_option'" in out.stderr + + +@mock.patch("piptools.sync.run") +def test_raise_error_on_invalid_config_option(run, runner, tmp_path, make_config_file): + config_file = make_config_file("dry-run", ["invalid", "value"]) + + with open(sync.DEFAULT_REQUIREMENTS_FILE, "w") as reqs_txt: + reqs_txt.write("six==1.10.0") + + out = runner.invoke(cli, ["--config", config_file.as_posix()]) + + assert out.exit_code == 2 + assert "Invalid value for config key 'dry_run': ['invalid', 'value']" in out.stderr diff --git a/tests/test_utils.py b/tests/test_utils.py index 4820492d6..f06a42100 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -573,21 +573,16 @@ def test_get_sys_path_for_python_executable(): @pytest.mark.parametrize( ("pyproject_param", "new_default"), ( - # From sync - ("ask", True), ("dry-run", True), ("find-links", ["changed"]), ("extra-index-url", ["changed"]), ("trusted-host", ["changed"]), ("no-index", True), - ("python-executable", "changed"), ("verbose", True), ("quiet", True), - ("user", True), ("cert", "changed"), ("client-cert", "changed"), ("pip-args", "changed"), - # From compile, unless also in sync ("pre", True), ("rebuild", True), ("extras", ["changed"]),