Repository: jazzband/pip-tools

Issue:
--upgrade-package downgrades unrelated pre-release package when --pre not given
<!-- Describe the issue briefly here. -->

#### Environment Versions

1. OS Type: macOS 10.15.4
1. Python version: 3.7.7
1. pip version:  20.0.2
1. pip-tools version: 4.5.1

#### Steps to replicate

(Note: this example will stop working when `gevent` releases 1.5 final but it can be replicated with any other package that currently has a pre-release version.)

1. Example `req.in` file:
    ```
    click<7
    gevent
    ```
2. `pip-compile req.in`
    Output:
    ```
    #
    # This file is autogenerated by pip-compile
    # To update, run:
    #
    #    pip-compile req.in
    #
    click==6.7                # via -r req.in
    gevent==1.4.0             # via -r req.in
    greenlet==0.4.15          # via gevent
    ```
3. Upgrade gevent to pre-relese
    `pip-compile --pre --upgrade-package gevent req.in`
    Output:
    ```
    #
    # This file is autogenerated by pip-compile
    # To update, run:
    #
    #    pip-compile --pre req.in
    #
    click==6.7                # via -r req.in
    gevent==1.5a4             # via -r req.in
    greenlet==0.4.15          # via gevent
    ```
4. Remove version pin of `click` in `.in` file:
    ```
    click
    gevent
    ```
5. Upgrade click:
    `pip-compile --upgrade-package click req.in`
    Output:
    ```
    #
    # This file is autogenerated by pip-compile
    # To update, run:
    #
    #    pip-compile req.in
    #
    click==6.7                # via -r req.in
    gevent==1.4.0             # via -r req.in
    greenlet==0.4.15          # via gevent
    ```

#### Expected result

Once a package has been resolved to a pre-release version it should never "magically" be downgraded. Especially if only unrelated other packages are concerned.

I could see that there may be an argument for a plain `pip-compile` run to revert to the non-prerelease version, but I would disagree even there. But for `--upgrade-package` I see no way where this is correct behaviour.

#### Actual result

Not giving `--pre` at any time after it has been used once and a package is resolved to a pre-release version will downgrade it back to the last released version.


Discussion:
Hello @ulope,

Thanks for the issue! Agree with you, version (even a pre-version) should be preserved. This patch would preserve a `prereleases` flag of an existing package:

```diff
diff --git piptools/repositories/local.py piptools/repositories/local.py
index d85f5c5..dc2f969 100644
--- piptools/repositories/local.py
+++ piptools/repositories/local.py
@@ -16,7 +16,7 @@ def ireq_satisfied_by_existing_pin(ireq, existing_pin):
     previously encountered version pin.
     """
     version = next(iter(existing_pin.req.specifier)).version
-    return version in ireq.req.specifier
+    return ireq.req.specifier.contains(version, prereleases=existing_pin.req.specifier.prereleases)


 class LocalRequirementsRepository(BaseRepository):
```

Resolving patch:
diff --git a/piptools/repositories/local.py b/piptools/repositories/local.py
--- a/piptools/repositories/local.py
+++ b/piptools/repositories/local.py
@@ -17,7 +17,9 @@ def ireq_satisfied_by_existing_pin(ireq, existing_pin):
     previously encountered version pin.
     """
     version = next(iter(existing_pin.req.specifier)).version
-    return version in ireq.req.specifier
+    return ireq.req.specifier.contains(
+        version, prereleases=existing_pin.req.specifier.prereleases
+    )
 
 
 class LocalRequirementsRepository(BaseRepository):


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
@@ -1075,3 +1075,16 @@ def test_sub_dependencies_with_constraints(pip_conf, runner):
         "small-fake-with-deps-and-sub-deps==0.1",
         "small-fake-with-unpinned-deps==0.1",
     }.issubset(req_out_lines)
+
+
+def test_preserve_compiled_prerelease_version(pip_conf, runner):
+    with open("requirements.in", "w") as req_in:
+        req_in.write("small-fake-a")
+
+    with open("requirements.txt", "w") as req_txt:
+        req_txt.write("small-fake-a==0.3b1")
+
+    out = runner.invoke(cli, ["--no-annotate", "--no-header"])
+
+    assert out.exit_code == 0, out
+    assert "small-fake-a==0.3b1" in out.stderr.splitlines()