Skip to content

Commit

Permalink
Add support for negating CLI boolean flags in config (#1913)
Browse files Browse the repository at this point in the history
Co-authored-by: Albert Tugushev <[email protected]>
  • Loading branch information
chrysle and atugushev authored Jul 25, 2023
1 parent 7ccf975 commit 9aa6ec4
Show file tree
Hide file tree
Showing 5 changed files with 116 additions and 24 deletions.
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,18 +290,26 @@ for a `.pip-tools.toml` file and then in your `pyproject.toml`. You can
also specify an alternate TOML configuration file with the `--config` option.

For example, to by default generate `pip` hashes in the resulting
requirements file output, you can specify in a configuration file
requirements file output, you can specify in a configuration file:

```toml
[tool.pip-tools]
generate-hashes = true

```

Options to `pip-compile` and `pip-sync` that may be used more than once
must be defined as lists in a configuration file, even if they only have one
value.

`pip-tools` supports default values vor [all valid command-line flags](/cli/index.md)
of its subcommands. Configuration keys may contain underscores instead of dashes,
so the above could also be specified in this format:

```toml
[tool.pip-tools]
generate_hashes = true
```

You might be wrapping the `pip-compile` command in another script. To avoid
confusing consumers of your custom script you can override the update command
generated at the top of requirements files by setting the
Expand Down
79 changes: 63 additions & 16 deletions piptools/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@
"--no-config",
}

# Set of option that are only negative, i.e. --no-<option>
ONLY_NEGATIVE_OPTIONS = {"--no-index"}


def key_from_ireq(ireq: InstallRequirement) -> str:
"""Get a standardized key for an InstallRequirement."""
Expand Down Expand Up @@ -560,7 +563,7 @@ def override_defaults_from_config_file(
else:
config_file = Path(value)

config = parse_config_file(config_file)
config = parse_config_file(ctx, config_file)
if config:
_validate_config(ctx, config)
_assign_config_to_cli_context(ctx, config)
Expand Down Expand Up @@ -659,17 +662,6 @@ def select_config_file(src_files: tuple[str, ...]) -> Path | None:
}


def get_click_dest_for_option(option_name: str) -> str:
"""
Returns the click ``dest`` value for the given option name.
"""
# Format the keys properly
option_name = option_name.lstrip("-").replace("-", "_").lower()
# Some options have dest values that are overrides from the click generated default
option_name = NON_STANDARD_OPTION_DEST_MAP.get(option_name, option_name)
return option_name


# Ensure that any default overrides for these click options are lists, supporting multiple values
MULTIPLE_VALUE_OPTIONS = [
"extras",
Expand All @@ -681,7 +673,19 @@ def get_click_dest_for_option(option_name: str) -> str:
]


def parse_config_file(config_file: Path) -> dict[str, Any]:
def get_cli_options(ctx: click.Context) -> dict[str, click.Parameter]:
cli_opts = {
opt: option
for option in ctx.command.params
for opt in itertools.chain(option.opts, option.secondary_opts)
if opt.startswith("--") and option.name is not None
}
return cli_opts


def parse_config_file(
click_context: click.Context, config_file: Path
) -> dict[str, Any]:
try:
config = tomllib.loads(config_file.read_text(encoding="utf-8"))
except OSError as os_err:
Expand All @@ -697,9 +701,13 @@ def parse_config_file(config_file: Path) -> dict[str, Any]:

# In a TOML file, we expect the config to be under `[tool.pip-tools]`
piptools_config: dict[str, Any] = config.get("tool", {}).get("pip-tools", {})
piptools_config = {
get_click_dest_for_option(k): v for k, v in piptools_config.items()
}

piptools_config = _normalize_keys_in_config(piptools_config)
piptools_config = _invert_negative_bool_options_in_config(
ctx=click_context,
config=piptools_config,
)

# Any option with multiple values needs to be a list in the pyproject.toml
for mv_option in MULTIPLE_VALUE_OPTIONS:
if not isinstance(piptools_config.get(mv_option), (list, type(None))):
Expand All @@ -710,6 +718,45 @@ def parse_config_file(config_file: Path) -> dict[str, Any]:
return piptools_config


def _normalize_keys_in_config(config: dict[str, Any]) -> dict[str, Any]:
return {_normalize_config_key(key): value for key, value in config.items()}


def _invert_negative_bool_options_in_config(
ctx: click.Context, config: dict[str, Any]
) -> dict[str, Any]:
new_config = {}
cli_opts = get_cli_options(ctx)

for key, value in config.items():
# Transform config key to its equivalent in the CLI
long_option = _convert_to_long_option(key)
new_key = cli_opts[long_option].name if long_option in cli_opts else key
assert new_key is not None

# Invert negative boolean according to the CLI
new_value = (
not value
if long_option.startswith("--no-")
and long_option not in ONLY_NEGATIVE_OPTIONS
and isinstance(value, bool)
else value
)
new_config[new_key] = new_value

return new_config


def _normalize_config_key(key: str) -> str:
"""Transform given ``some-key`` into ``some_key``."""
return key.lstrip("-").replace("-", "_").lower()


def _convert_to_long_option(key: str) -> str:
"""Transform given ``some-key`` into ``--some-key``."""
return "--" + key.lstrip("-").replace("_", "-").lower()


def is_path_relative_to(path1: Path, path2: Path) -> bool:
"""Return True if ``path1`` is relative to ``path2``."""
# TODO: remove this function in favor of Path.is_relative_to()
Expand Down
37 changes: 37 additions & 0 deletions tests/test_cli_compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -3017,3 +3017,40 @@ def test_raise_error_on_invalid_config_option(

assert out.exit_code == 2
assert "Invalid value for config key 'dry_run': ['invalid', 'value']" in out.stderr


def test_cli_boolean_flag_config_option_has_valid_context(
pip_conf, runner, tmp_path, make_config_file
):
config_file = make_config_file("no-annotate", True)

req_in = tmp_path / "requirements.in"
req_in.write_text("small-fake-a==0.1")
out = runner.invoke(
cli,
[
req_in.as_posix(),
"--config",
config_file.as_posix(),
"--no-emit-options",
"--no-header",
"--output-file",
"-",
],
)
assert out.exit_code == 0
assert out.stdout == "small-fake-a==0.1\n"


def test_invalid_cli_boolean_flag_config_option_captured(
pip_conf, runner, tmp_path, make_config_file
):
config_file = make_config_file("no-annnotate", 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 'no_annnotate'." in out.stderr
10 changes: 5 additions & 5 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
flat_map,
format_requirement,
format_specifier,
get_click_dest_for_option,
get_cli_options,
get_compile_command,
get_hashes_from_ireq,
get_pip_version_for_python_executable,
Expand Down Expand Up @@ -585,7 +585,7 @@ def test_get_sys_path_for_python_executable():
("pip-args", "changed"),
("pre", True),
("rebuild", True),
("extras", ["changed"]),
("extra", ["changed"]),
("all-extras", True),
("index-url", "changed"),
("header", False),
Expand Down Expand Up @@ -615,10 +615,11 @@ def test_callback_config_file_defaults(pyproject_param, new_default, make_config
# Create a "compile" run example pointing to the config file
ctx = Context(compile_cli)
ctx.params["src_files"] = (str(config_file),)
cli_opts = get_cli_options(ctx)
found_config_file = override_defaults_from_config_file(ctx, "config", None)
assert found_config_file == config_file
# Make sure the default has been updated
lookup_param = get_click_dest_for_option(pyproject_param)
lookup_param = cli_opts["--" + pyproject_param].name
assert ctx.default_map[lookup_param] == new_default


Expand Down Expand Up @@ -661,8 +662,7 @@ def test_callback_config_file_defaults_precedence(make_config_file):
found_config_file = override_defaults_from_config_file(ctx, "config", None)
# The pip-tools specific config file should take precedence over pyproject.toml
assert found_config_file == piptools_config_file
lookup_param = get_click_dest_for_option("newline")
assert ctx.default_map[lookup_param] == "LF"
assert ctx.default_map["newline"] == "LF"


def test_callback_config_file_defaults_unreadable_toml(make_config_file):
Expand Down
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ commands =
-W \
-d "{temp_dir}/.doctrees" \
. \
--watch ../README.rst \
--watch ../README.md \
--watch ../CHANGELOG.md \
"{envdir}/docs_out"

Expand Down

0 comments on commit 9aa6ec4

Please sign in to comment.