Repository: jazzband/pip-tools

Issue:
Inconsistent `via` comments for backtracking resolver on constrained requirements
`pip-compile` generates inconsistent `via` comments when using backtracking resolver on constrained requirements.

When a constrained `<constrained_requirements_file>.txt` file is not yet generated, `-c <requirements_file.txt>` references are correctly added to `via` comments, but they are removed when re-compiling the requirements when the constrained `<constrained_requirements_file>.txt` file already exists.

This seems to apply only to the backtracking resolver, since I wasn't able to reproduce the issue using the legacy resolver.

#### Environment Versions

1. OS Type: Linux (x86_64)
2. Python version: 3.10.8
3. pip version: 22.3
4. pip-tools version: 6.9.0 (also tried on `master` branch, at commit https://github.com/jazzband/pip-tools/commit/1fb2e69c4a58896eb5a860eb63f410ec7980913b)

#### Steps to replicate

I've created a reproduction repository (https://github.com/mkniewallner/pip-compile-inconsistent-via-backtracking-resolver) with a README and commits history that highlight the issue, but basically:

1. Create a `requirements.in` file with the following content:

```python
starlette==0.21.0
```
2. Create a `requirements-dev.in` file with the following content:

```python
-c requirements.txt

httpx==0.22.0
```
3. Run `pip-compile --resolver=backtracking requirements.in`
4. Run `pip-compile --resolver=backtracking requirements-dev.in`
5. Run `pip-compile --resolver=backtracking requirements-dev.in` again

#### Expected result

`pip-compile` writes the following `requirements-dev.txt` for both runs of `pip-compile --resolver=backtracking requirements-dev.in`:

```python
#
# This file is autogenerated by pip-compile with python 3.10
# To update, run:
#
#    pip-compile --resolver=backtracking requirements-dev.in
#
anyio==3.6.2
    # via
    #   -c requirements.txt
    #   httpcore
certifi==2022.9.24
    # via
    #   httpcore
    #   httpx
charset-normalizer==3.0.0
    # via httpx
h11==0.12.0
    # via httpcore
httpcore==0.14.7
    # via httpx
httpx==0.22.0
    # via -r requirements-dev.in
idna==3.4
    # via
    #   -c requirements.txt
    #   anyio
    #   rfc3986
rfc3986[idna2008]==1.5.0
    # via httpx
sniffio==1.3.0
    # via
    #   -c requirements.txt
    #   anyio
    #   httpcore
    #   httpx
```

#### Actual result

The first time `pip-compile --resolver=backtracking requirements-dev.in` is run (where `requirements-dev.txt` doesn't yet exist), the correct `requirements-dev.txt` is generated:

```python
#
# This file is autogenerated by pip-compile with python 3.10
# To update, run:
#
#    pip-compile --resolver=backtracking requirements-dev.in
#
anyio==3.6.2
    # via
    #   -c requirements.txt
    #   httpcore
certifi==2022.9.24
    # via
    #   httpcore
    #   httpx
charset-normalizer==3.0.0
    # via httpx
h11==0.12.0
    # via httpcore
httpcore==0.14.7
    # via httpx
httpx==0.22.0
    # via -r requirements-dev.in
idna==3.4
    # via
    #   -c requirements.txt
    #   anyio
    #   rfc3986
rfc3986[idna2008]==1.5.0
    # via httpx
sniffio==1.3.0
    # via
    #   -c requirements.txt
    #   anyio
    #   httpcore
    #   httpx
```

The second time `pip-compile --resolver=backtracking requirements-dev.in` is run (where `requirements-dev.txt` exists), the following diff is applied:

```diff
diff --git a/requirements-dev.txt b/requirements-dev.txt
index eaaa0f0..70ed934 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -5,9 +5,7 @@
 #    pip-compile --resolver=backtracking requirements-dev.in
 #
 anyio==3.6.2
-    # via
-    #   -c requirements.txt
-    #   httpcore
+    # via httpcore
 certifi==2022.9.24
     # via
     #   httpcore
@@ -22,14 +20,12 @@ httpx==0.22.0
     # via -r requirements-dev.in
 idna==3.4
     # via
-    #   -c requirements.txt
     #   anyio
     #   rfc3986
 rfc3986[idna2008]==1.5.0
     # via httpx
 sniffio==1.3.0
     # via
-    #   -c requirements.txt
     #   anyio
     #   httpcore
     #   httpx
```


Discussion:

Resolving patch:
diff --git a/piptools/resolver.py b/piptools/resolver.py
--- a/piptools/resolver.py
+++ b/piptools/resolver.py
@@ -770,7 +770,7 @@ def _get_install_requirement_from_candidate(
 
         # Save source for annotation
         source_ireq = self._constraints_map.get(ireq_key)
-        if source_ireq is not None and ireq_key not in self.existing_constraints:
+        if source_ireq is not None:
             pinned_ireq._source_ireqs = [source_ireq]
 
         return pinned_ireq


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
@@ -2450,3 +2450,40 @@ def test_resolver_reaches_max_rounds(runner):
     out = runner.invoke(cli, ["--max-rounds", 0])
 
     assert out.exit_code != 0, out
+
+
+def test_preserve_via_requirements_constrained_dependencies_when_run_twice(
+    pip_conf, runner
+):
+    """
+    Test that 2 consecutive runs of pip-compile (first with a non-existing requirements.txt file,
+    second with an existing file) produce the same output.
+    """
+    with open("constraints.txt", "w") as constraints_in:
+        constraints_in.write("small-fake-a==0.1")
+
+    with open("requirements.in", "w") as req_in:
+        req_in.write("-c constraints.txt\nsmall_fake_with_deps")
+
+    cli_arguments = ["--no-emit-options", "--no-header"]
+
+    # First run of the command will generate `requirements.txt`, which doesn't yet exist.
+    first_out = runner.invoke(cli, cli_arguments)
+
+    # Second run of the command will update `requirements.txt`.
+    second_out = runner.invoke(cli, cli_arguments)
+
+    expected_output = dedent(
+        """\
+        small-fake-a==0.1
+            # via
+            #   -c constraints.txt
+            #   small-fake-with-deps
+        small-fake-with-deps==0.1
+            # via -r requirements.in
+        """
+    )
+
+    for output in (first_out, second_out):
+        assert output.exit_code == 0, output
+        assert output.stderr == expected_output