Repository: jazzband/pip-tools

Issue:
`pip-compile` and `pip-sync` have different options, but single config section
<!-- Describe the issue briefly here. -->

#### Environment Versions

1. OS Type: any
1. Python version: any
1. pip version: any
1. pip-tools version: `pip-compile, version 7.1.0`

#### Steps to replicate

```bash
echo pytz > requirements.in
echo -e "[tool.pip-tools]\ngenerate-hashes = true" > .pip-tools.toml
pip-compile
pip-sync
```

#### Expected result

Successful sync.

#### Actual result


```console
$ pip-compile
#
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
#    pip-compile --generate-hashes
#
pytz==2023.3 \
    --hash=sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588 \
    --hash=sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb
    # via -r requirements.in


$ pip-sync
Usage: pip-sync [OPTIONS] [SRC_FILES]...
Try 'pip-sync -h' for help.

Error: No such config key 'generate_hashes'.
```


Discussion:

Resolving patch:
diff --git a/piptools/utils.py b/piptools/utils.py
--- a/piptools/utils.py
+++ b/piptools/utils.py
@@ -591,16 +591,23 @@ def _validate_config(
     :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
+    from piptools.scripts.compile import cli as compile_cli
+    from piptools.scripts.sync import cli as sync_cli
+
+    compile_cli_params = {
+        param.name: param for param in compile_cli.params if param.name is not None
+    }
+
+    sync_cli_params = {
+        param.name: param for param in sync_cli.params if param.name is not None
     }
 
+    all_keys = set(compile_cli_params) | set(sync_cli_params)
+
     for key, value in config.items():
-        # Validate unknown keys
-        if key not in cli_params:
-            possibilities = difflib.get_close_matches(key, cli_params.keys())
+        # Validate unknown keys in both compile and sync
+        if key not in all_keys:
+            possibilities = difflib.get_close_matches(key, all_keys)
             raise click.NoSuchOption(
                 option_name=key,
                 message=f"No such config key {key!r}.",
@@ -608,19 +615,26 @@ def _validate_config(
                 ctx=click_context,
             )
 
-        # Validate invalid values
-        param = cli_params[key]
-        try:
-            param.type_cast_value(value=value, ctx=click_context)
-        except Exception as e:
-            raise click.BadOptionUsage(
-                option_name=key,
-                message=(
-                    f"Invalid value for config key {key!r}: {value!r}.{os.linesep}"
-                    f"Details: {e}"
-                ),
-                ctx=click_context,
-            ) from e
+        # Get all params associated with this key in both compile and sync
+        associated_params = (
+            cli_params[key]
+            for cli_params in (compile_cli_params, sync_cli_params)
+            if key in cli_params
+        )
+
+        # Validate value against types of all associated params
+        for param in associated_params:
+            try:
+                param.type_cast_value(value=value, ctx=click_context)
+            except Exception as e:
+                raise click.BadOptionUsage(
+                    option_name=key,
+                    message=(
+                        f"Invalid value for config key {key!r}: {value!r}.{os.linesep}"
+                        f"Details: {e}"
+                    ),
+                    ctx=click_context,
+                ) from e
 
 
 def select_config_file(src_files: tuple[str, ...]) -> Path | None:


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
@@ -3019,6 +3019,20 @@ def test_raise_error_on_invalid_config_option(
     assert "Invalid value for config key 'dry_run': ['invalid', 'value']" in out.stderr
 
 
+def test_allow_in_config_pip_sync_option(pip_conf, runner, tmp_path, make_config_file):
+    config_file = make_config_file("--ask", True)  # pip-sync's option
+
+    req_in = tmp_path / "requirements.in"
+    req_in.touch()
+
+    out = runner.invoke(
+        cli, [req_in.as_posix(), "--verbose", "--config", config_file.as_posix()]
+    )
+
+    assert out.exit_code == 0
+    assert "Using pip-tools configuration defaults found" in out.stderr
+
+
 def test_cli_boolean_flag_config_option_has_valid_context(
     pip_conf, runner, tmp_path, make_config_file
 ):
diff --git a/tests/test_cli_sync.py b/tests/test_cli_sync.py
--- a/tests/test_cli_sync.py
+++ b/tests/test_cli_sync.py
@@ -424,3 +424,16 @@ def test_raise_error_on_invalid_config_option(run, runner, tmp_path, make_config
 
     assert out.exit_code == 2
     assert "Invalid value for config key 'dry_run': ['invalid', 'value']" in out.stderr
+
+
+@mock.patch("piptools.sync.run")
+def test_allow_in_config_pip_compile_option(run, runner, tmp_path, make_config_file):
+    config_file = make_config_file("generate-hashes", True)  # pip-compile's option
+
+    with open(sync.DEFAULT_REQUIREMENTS_FILE, "w") as reqs_txt:
+        reqs_txt.write("six==1.10.0")
+
+    out = runner.invoke(cli, ["--verbose", "--config", config_file.as_posix()])
+
+    assert out.exit_code == 0
+    assert "Using pip-tools configuration defaults found" in out.stderr