Repository: jazzband/pip-tools (python)

Issue:
pip-compile reveals default `--config=pyproject.toml` option in header
There is a bug with pip 6.14.0. I just use `pip-compile --allow-unsafe --generate-hashes --output-file=requirements/test.txt --resolver=backtracking --strip-extras requirements/test.in`
but now it adds `--config=pyproject.toml` even there is no configuration in pyproject.toml
When running pip-compile click context has no src_files, so select_config_file returns pyproject.toml, but after compiling click context has src_files as requirements/test.in, so select_config_file returns None and so pyproject.toml != None

Making `--config` is_eager=False works

_Originally posted by @q0w in https://github.com/jazzband/pip-tools/issues/1893#issuecomment-1624405135_
            

Pull request description:
Fix revealed default config in header if requirements in subfolder
Ensure `src_files` are handled prior to `--config`, as `override_defaults_from_config_file` is dependent on `src_files`.

Fixes #1903.

##### Contributor checklist

- [x] Provided the tests for the changes.
- [x] Assure PR title is short, clear, and good to be included in the user-oriented changelog

##### Maintainer checklist

- [x] Assure one of these labels is present: `backwards incompatible`, `feature`, `enhancement`, `deprecation`, `bug`, `dependency`, `docs` or `skip-changelog` as they determine changelog listing.
- [x] Assign the PR to an existing or new milestone for the target version (following [Semantic Versioning](https://blog.versioneye.com/2014/01/16/semantic-versioning/)).


Patch:
diff --git a/piptools/locations.py b/piptools/locations.py
index f31891c..fa2413f 100644
--- a/piptools/locations.py
+++ b/piptools/locations.py
@@ -5,5 +5,5 @@ from pip._internal.utils.appdirs import user_cache_dir
 # The user_cache_dir helper comes straight from pip itself
 CACHE_DIR = user_cache_dir("pip-tools")
 
-# The project defaults specific to pip-tools should be written to this filename
-CONFIG_FILE_NAME = ".pip-tools.toml"
+# The project defaults specific to pip-tools should be written to this filenames
+DEFAULT_CONFIG_FILE_NAMES = (".pip-tools.toml", "pyproject.toml")
diff --git a/piptools/scripts/compile.py b/piptools/scripts/compile.py
index 697c450..acb0a93 100755
--- a/piptools/scripts/compile.py
+++ b/piptools/scripts/compile.py
@@ -20,7 +20,7 @@ from pip._internal.utils.misc import redact_auth_from_url
 from .._compat import parse_requirements
 from ..cache import DependencyCache
 from ..exceptions import NoCandidateFound, PipToolsError
-from ..locations import CACHE_DIR, CONFIG_FILE_NAME
+from ..locations import CACHE_DIR, DEFAULT_CONFIG_FILE_NAMES
 from ..logging import log
 from ..repositories import LocalRequirementsRepository, PyPIRepository
 from ..repositories.base import BaseRepository
@@ -314,8 +314,10 @@ def _determine_linesep(
         allow_dash=False,
         path_type=str,
     ),
-    help=f"Read configuration from TOML file. By default, looks for a {CONFIG_FILE_NAME} or "
-    "pyproject.toml.",
+    help=(
+        f"Read configuration from TOML file. By default, looks for the following "
+        f"files in the given order: {', '.join(DEFAULT_CONFIG_FILE_NAMES)}."
+    ),
     is_eager=True,
     callback=override_defaults_from_config_file,
 )
diff --git a/piptools/scripts/sync.py b/piptools/scripts/sync.py
index e162fea..307ce1b 100755
--- a/piptools/scripts/sync.py
+++ b/piptools/scripts/sync.py
@@ -17,7 +17,7 @@ from pip._internal.metadata import get_environment
 from .. import sync
 from .._compat import Distribution, parse_requirements
 from ..exceptions import PipToolsError
-from ..locations import CONFIG_FILE_NAME
+from ..locations import DEFAULT_CONFIG_FILE_NAMES
 from ..logging import log
 from ..repositories import PyPIRepository
 from ..utils import (
@@ -98,8 +98,10 @@ DEFAULT_REQUIREMENTS_FILE = "requirements.txt"
         allow_dash=False,
         path_type=str,
     ),
-    help=f"Read configuration from TOML file. By default, looks for a {CONFIG_FILE_NAME} or "
-    "pyproject.toml.",
+    help=(
+        f"Read configuration from TOML file. By default, looks for the following "
+        f"files in the given order: {', '.join(DEFAULT_CONFIG_FILE_NAMES)}."
+    ),
     is_eager=True,
     callback=override_defaults_from_config_file,
 )
diff --git a/piptools/utils.py b/piptools/utils.py
index fdfba40..70f1ee1 100644
--- a/piptools/utils.py
+++ b/piptools/utils.py
@@ -12,6 +12,8 @@ import sys
 from pathlib import Path
 from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, TypeVar, cast
 
+from click.core import ParameterSource
+
 if sys.version_info >= (3, 11):
     import tomllib
 else:
@@ -31,7 +33,7 @@ from pip._vendor.packaging.version import Version
 from pip._vendor.pkg_resources import get_distribution
 
 from piptools._compat import PIP_VERSION
-from piptools.locations import CONFIG_FILE_NAME
+from piptools.locations import DEFAULT_CONFIG_FILE_NAMES
 from piptools.subprocess_utils import run_python_snippet
 
 if TYPE_CHECKING:
@@ -369,8 +371,11 @@ def get_compile_command(click_ctx: click.Context) -> str:
 
         # Exclude config option if it's the default one
         if option_long_name == "--config":
-            default_config = select_config_file(click_ctx.params.get("src_files", ()))
-            if value == default_config:
+            parameter_source = click_ctx.get_parameter_source(option_name)
+            if (
+                str(value) in DEFAULT_CONFIG_FILE_NAMES
+                or parameter_source == ParameterSource.DEFAULT
+            ):
                 continue
 
         # Skip options without a value
@@ -656,7 +661,7 @@ def select_config_file(src_files: tuple[str, ...]) -> Path | None:
         (
             candidate_dir / config_file
             for candidate_dir in candidate_dirs
-            for config_file in (CONFIG_FILE_NAME, "pyproject.toml")
+            for config_file in DEFAULT_CONFIG_FILE_NAMES
             if (candidate_dir / config_file).is_file()
         ),
         None,


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 62c3b22..4834f4b 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -31,7 +31,7 @@ from pip._vendor.pkg_resources import Requirement
 from piptools._compat import PIP_VERSION, Distribution
 from piptools.cache import DependencyCache
 from piptools.exceptions import NoCandidateFound
-from piptools.locations import CONFIG_FILE_NAME
+from piptools.locations import DEFAULT_CONFIG_FILE_NAMES
 from piptools.logging import log
 from piptools.repositories import PyPIRepository
 from piptools.repositories.base import BaseRepository
@@ -452,13 +452,16 @@ def make_config_file(tmpdir_cwd):
     """
 
     def _maker(
-        pyproject_param: str, new_default: Any, config_file_name: str = CONFIG_FILE_NAME
+        pyproject_param: str,
+        new_default: Any,
+        config_file_name: str = DEFAULT_CONFIG_FILE_NAMES[0],
     ) -> Path:
-        # Make a config file with this one config default override
-        config_path = tmpdir_cwd / pyproject_param
-        config_file = config_path / config_file_name
-        config_path.mkdir(exist_ok=True)
+        # Create a nested directory structure if config_file_name includes directories
+        config_dir = (tmpdir_cwd / config_file_name).parent
+        config_dir.mkdir(exist_ok=True, parents=True)
 
+        # Make a config file with this one config default override
+        config_file = tmpdir_cwd / config_file_name
         config_to_dump = {"tool": {"pip-tools": {pyproject_param: new_default}}}
         config_file.write_text(tomli_w.dumps(config_to_dump))
         return cast(Path, config_file.relative_to(tmpdir_cwd))
diff --git a/tests/test_cli_compile.py b/tests/test_cli_compile.py
index 498b4a8..7dd3013 100644
--- a/tests/test_cli_compile.py
+++ b/tests/test_cli_compile.py
@@ -2978,6 +2978,18 @@ def test_config_option(pip_conf, runner, tmp_path, make_config_file):
     assert "Dry-run, so nothing updated" in out.stderr
 
 
+def test_default_config_option(pip_conf, runner, make_config_file, tmpdir_cwd):
+    make_config_file("dry-run", True)
+
+    req_in = tmpdir_cwd / "requirements.in"
+    req_in.touch()
+
+    out = runner.invoke(cli)
+
+    assert out.exit_code == 0
+    assert "Dry-run, so nothing updated" in out.stderr
+
+
 def test_no_config_option_overrides_config_with_defaults(
     pip_conf, runner, tmp_path, make_config_file
 ):
diff --git a/tests/test_cli_sync.py b/tests/test_cli_sync.py
index 88457bd..20e9689 100644
--- a/tests/test_cli_sync.py
+++ b/tests/test_cli_sync.py
@@ -374,6 +374,19 @@ def test_default_python_executable_option(run, runner):
     ]
 
 
+@mock.patch("piptools.sync.run")
+def test_default_config_option(run, runner, make_config_file, tmpdir_cwd):
+    make_config_file("dry-run", True)
+
+    with open(sync.DEFAULT_REQUIREMENTS_FILE, "w") as reqs_txt:
+        reqs_txt.write("six==1.10.0")
+
+    out = runner.invoke(cli)
+
+    assert out.exit_code == 1
+    assert "Would install:" in out.stdout
+
+
 @mock.patch("piptools.sync.run")
 def test_config_option(run, runner, make_config_file):
     config_file = make_config_file("dry-run", True)
diff --git a/tests/test_utils.py b/tests/test_utils.py
index c89461f..7d31af9 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -6,6 +6,7 @@ import os
 import shlex
 import sys
 from pathlib import Path
+from textwrap import dedent
 
 import pip
 import pytest
@@ -31,6 +32,7 @@ from piptools.utils import (
     lookup_table,
     lookup_table_from_tuples,
     override_defaults_from_config_file,
+    select_config_file,
 )
 
 
@@ -415,6 +417,36 @@ def test_get_compile_command_with_config(tmpdir_cwd, config_file, expected_comma
         assert get_compile_command(ctx) == expected_command
 
 
+@pytest.mark.parametrize("config_file", ("pyproject.toml", ".pip-tools.toml"))
+@pytest.mark.parametrize(
+    "config_file_content",
+    (
+        pytest.param("", id="empty config file"),
+        pytest.param("[tool.pip-tools]", id="empty config section"),
+        pytest.param("[tool.pip-tools]\ndry-run = true", id="non-empty config section"),
+    ),
+)
+def test_get_compile_command_does_not_include_default_config_if_reqs_file_in_subdir(
+    tmpdir_cwd, config_file, config_file_content
+):
+    """
+    Test that ``get_compile_command`` does not include default config file
+    if requirements file is in a subdirectory.
+    Regression test for issue GH-1903.
+    """
+    default_config_file = Path(config_file)
+    default_config_file.write_text(config_file_content)
+
+    (tmpdir_cwd / "subdir").mkdir()
+    req_file = Path("subdir/requirements.in")
+    req_file.touch()
+    req_file.write_bytes(b"")
+
+    # Make sure that the default config file is not included
+    with compile_cli.make_context("pip-compile", [req_file.as_posix()]) as ctx:
+        assert get_compile_command(ctx) == f"pip-compile {req_file.as_posix()}"
+
+
 def test_get_compile_command_escaped_filenames(tmpdir_cwd):
     """
     Test that get_compile_command output (re-)escapes ' -- '-escaped filenames.
@@ -683,3 +715,52 @@ def test_callback_config_file_defaults_unreadable_toml(make_config_file):
             "config",
             "/dev/null/path/does/not/exist/my-config.toml",
         )
+
+
+def test_select_config_file_no_files(tmpdir_cwd):
+    assert select_config_file(()) is None
+
+
+@pytest.mark.parametrize("filename", ("pyproject.toml", ".pip-tools.toml"))
+def test_select_config_file_returns_config_in_cwd(make_config_file, filename):
+    config_file = make_config_file("dry-run", True, filename)
+    assert select_config_file(()) == config_file
+
+
+def test_select_config_file_returns_empty_config_file_in_cwd(tmpdir_cwd):
+    config_file = Path(".pip-tools.toml")
+    config_file.touch()
+
+    assert select_config_file(()) == config_file
+
+
+def test_select_config_file_cannot_find_config_in_cwd(tmpdir_cwd, make_config_file):
+    make_config_file("dry-run", True, "subdir/pyproject.toml")
+    assert select_config_file(()) is None
+
+
+def test_select_config_file_with_config_file_in_subdir(tmpdir_cwd, make_config_file):
+    config_file = make_config_file("dry-run", True, "subdir/.pip-tools.toml")
+
+    requirement_file = Path("subdir/requirements.in")
+    requirement_file.touch()
+
+    assert select_config_file((requirement_file.as_posix(),)) == config_file
+
+
+def test_select_config_file_prefers_pip_tools_toml_over_pyproject_toml(tmpdir_cwd):
+    pip_tools_file = Path(".pip-tools.toml")
+    pip_tools_file.touch()
+
+    pyproject_file = Path("pyproject.toml")
+    pyproject_file.write_text(
+        dedent(
+            """\
+            [build-system]
+            requires = ["setuptools>=63", "setuptools_scm[toml]>=7"]
+            build-backend = "setuptools.build_meta"
+            """
+        )
+    )
+
+    assert select_config_file(()) == pip_tools_file