Repository: jazzband/pip-tools

Issue:
Using negative flag forms (e.g. no-header, no-annotate) in config has no effect
When testing out the new config behavior, if I add entries to `[tool.pip-tools]` like

```toml
no-annotate = true
```
or
```toml
no-header = true
```

then there are two surprises:

- the option does not take effect; it *must* be given in the positive form (`annotate = false` and `header = false`)
- there is no warning or error about unknown options being given, making this problem easy to miss

#### Environment Versions

1. Linux
1. Python version: `3.10.6`
1. pip version: `23.1.2`
1. pip-tools version: `6.14.0`

#### Steps to replicate

```console
$ git clone https://github.com/jazzband/pip-tools
$ cd pip-tools
$ printf '%s\n' '[tool.pip-tools]' 'no-annotate = true' >>pyproject.toml
$ printf '%s\n' httpx >requirements.in
$ python -m venv .venv
$ . ./.venv/bin/activate
$ pip install -U pip pip-tools
$ pip-compile requirements.in
```

#### Expected result

Either 

- there be no annotations in the output, since `--no-annotate` is a valid flag, and `--no-annotate` should also appear in the generated header, or
- a warning or error should be presented due to an unrecognized option being supplied

#### Actual result

```ini
WARNING: the legacy dependency resolver is deprecated and will be removed in future versions of pip-tools. The default resolver will be changed to 'backtracking' in pip-tools 7.0.0. Specify --resolver=backtracking to silence this warning.
#
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
#    pip-compile requirements.in
#
anyio==3.7.1
    # via httpcore
certifi==2023.5.7
    # via
    #   httpcore
    #   httpx
exceptiongroup==1.1.2
    # via anyio
h11==0.14.0
    # via httpcore
httpcore==0.17.3
    # via httpx
httpx==0.24.1
    # via -r requirements.in
idna==3.4
    # via
    #   anyio
    #   httpx
sniffio==1.3.0
    # via
    #   anyio
    #   httpcore
    #   httpx
```


Discussion:
Can reproduce this. Working on a fix.
>    * there be no annotations in the output, since `--no-annotate` is a valid flag, and `--no-annotate` should also appear in the generated header

I've tracked this down and it looks like `click` currently doesn't allow for setting default values for _negating_ boolean flags. This means `annotate = false` is the equivalent for `no_annotate = true`.

I've tried implementing a [feature switch](https://click.palletsprojects.com/en/8.1.x/options/#feature-switches), but that doesn't work either.


@AndydeCleyre could you try this draft https://github.com/jazzband/pip-tools/pull/1910?

Resolving patch:
diff --git a/piptools/utils.py b/piptools/utils.py
--- a/piptools/utils.py
+++ b/piptools/utils.py
@@ -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."""
@@ -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)
@@ -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",
@@ -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:
@@ -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))):
@@ -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()


Test patch:
diff --git a/tests/test_cli_compile.py b/tests/test_cli_compile.py
--- a/tests/test_cli_compile.py
+++ b/tests/test_cli_compile.py
@@ -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
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -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,
@@ -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),
@@ -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
 
 
@@ -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):