Repository: pipxproject/pipx (python)

Issue:
reinstall-all don't use the initial spec but install latest version of each packages
<!-- Thank you for filing a bug! Please feel free to answer as much or as little of this template as you can. -->

**Describe the bug**
When using the command `pipx resintall-all` the packages are uninstalled and installed again but without taking into account the spec used during the initial installation. it's mean it reinstall only latest version for each packages.

**How to reproduce**
`pipx install --suffix=1010 poetry==1.0.10` will install poetry 1.0.10
`pipx reinstall-all` will install poetry 1.1.3 


**Expected behavior**
The initial spec should be reused during reinstall-all or at least give an option to choose to have latest versions or initial versions.


Pull request:
Fix: reinstall should use the initial specifier
<!-- add an 'x' in the brackets below -->
* [x] I have added an entry to `docs/changelog.md`

## Summary of changes
Fix #527 

To allow reinstall using the specifier used during installation, the specifier has been added to metadata.
The specifier is removed just before upgrading to allow `pip` command to fetch latest version. 

Test suite have been updated.

## Test plan
<!-- provide evidence of testing, preferably with command(s) that can be copy+pasted by others -->
Tested by running
```
pipx install pylint==2.3.1
pipx reinstall pylint
pipx upgrade pylint
```



Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index a95436d..438541e 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,7 @@ dev
 
 - Added reinstall command for reinstalling a single venv.
 - Changed `pipx run` on non-Windows systems to actually replace pipx process with the app process instead of running it as a subprocess.  (Now using python's `os.exec*`)
+- [bugfix] Fixed bug with reinstall-all command when package have been installed using a specifier. Now the initial specifier is used.
 
 0.15.6.0
 
diff --git a/src/pipx/commands/upgrade.py b/src/pipx/commands/upgrade.py
index 65b1537..f6e5abb 100644
--- a/src/pipx/commands/upgrade.py
+++ b/src/pipx/commands/upgrade.py
@@ -6,6 +6,7 @@ from pipx import constants
 from pipx.colors import bold, red
 from pipx.commands.common import expose_apps_globally
 from pipx.emojies import sleep
+from pipx.package_specifier import parse_specifier_for_upgrade
 from pipx.util import PipxError
 from pipx.venv import Venv, VenvContainer
 
@@ -42,7 +43,7 @@ def upgrade(
     if package_metadata.package_or_url is None:
         raise PipxError(f"Internal Error: package {package} has corrupt pipx metadata.")
 
-    package_or_url = package_metadata.package_or_url
+    package_or_url = parse_specifier_for_upgrade(package_metadata.package_or_url)
     old_version = package_metadata.package_version
     include_apps = package_metadata.include_apps
     include_dependencies = package_metadata.include_dependencies
diff --git a/src/pipx/package_specifier.py b/src/pipx/package_specifier.py
index 7f599da..a9e6eec 100644
--- a/src/pipx/package_specifier.py
+++ b/src/pipx/package_specifier.py
@@ -159,6 +159,20 @@ def parse_specifier_for_install(
 def parse_specifier_for_metadata(package_spec: str) -> str:
     """Return package_or_url suitable for pipx metadata
 
+    Specifically:
+    * Strip any markers (e.g. python_version > 3.4)
+    * Convert local paths to absolute paths
+    """
+    parsed_package = _parse_specifier(package_spec)
+    package_or_url = _parsed_package_to_package_or_url(
+        parsed_package, remove_version_specifiers=False
+    )
+    return package_or_url
+
+
+def parse_specifier_for_upgrade(package_spec: str) -> str:
+    """Return package_or_url suitable for pip upgrade
+
     Specifically:
     * Strip any version specifiers (e.g. package == 1.5.4)
     * Strip any markers (e.g. python_version > 3.4)


Test patch:
diff --git a/tests/test_package_specifier.py b/tests/test_package_specifier.py
index b11c8b9..2f8d540 100644
--- a/tests/test_package_specifier.py
+++ b/tests/test_package_specifier.py
@@ -6,6 +6,7 @@ from pipx.package_specifier import (
     fix_package_name,
     parse_specifier_for_install,
     parse_specifier_for_metadata,
+    parse_specifier_for_upgrade,
     valid_pypi_name,
 )
 from pipx.util import PipxError
@@ -50,6 +51,75 @@ def test_fix_package_name(package_spec_in, package_name, package_spec_out):
 
 
 # TODO: Make sure git+ works with tests, correct in test_install as well
+@pytest.mark.parametrize(
+    "package_spec_in,package_or_url_correct,valid_spec",
+    [
+        ("pipx", "pipx", True),
+        ("PiPx_stylized.name", "pipx-stylized-name", True),
+        ("pipx==0.15.0", "pipx==0.15.0", True),
+        ("pipx>=0.15.0", "pipx>=0.15.0", True),
+        ("pipx<=0.15.0", "pipx<=0.15.0", True),
+        ('pipx;python_version>="3.6"', "pipx", True),
+        ('pipx==0.15.0;python_version>="3.6"', "pipx==0.15.0", True),
+        ("pipx[extra1]", "pipx[extra1]", True),
+        ("pipx[extra1, extra2]", "pipx[extra1,extra2]", True),
+        ("src/pipx", str(Path("src/pipx").resolve()), True),
+        (
+            "git+https://github.com/cs01/nox.git@5ea70723e9e6",
+            "git+https://github.com/cs01/nox.git@5ea70723e9e6",
+            True,
+        ),
+        (
+            "nox@git+https://github.com/cs01/nox.git@5ea70723e9e6",
+            "nox@ git+https://github.com/cs01/nox.git@5ea70723e9e6",
+            True,
+        ),
+        (
+            "https://github.com/ambv/black/archive/18.9b0.zip",
+            "https://github.com/ambv/black/archive/18.9b0.zip",
+            True,
+        ),
+        (
+            "black@https://github.com/ambv/black/archive/18.9b0.zip",
+            "black@ https://github.com/ambv/black/archive/18.9b0.zip",
+            True,
+        ),
+        (
+            "black @ https://github.com/ambv/black/archive/18.9b0.zip",
+            "black@ https://github.com/ambv/black/archive/18.9b0.zip",
+            True,
+        ),
+        (
+            "black[extra] @ https://github.com/ambv/black/archive/18.9b0.zip",
+            "black[extra]@ https://github.com/ambv/black/archive/18.9b0.zip",
+            True,
+        ),
+        (
+            'my-project[cli] @ git+ssh://git@bitbucket.org/my-company/myproject.git ; python_version<"3.7"',
+            "my-project[cli]@ git+ssh://git@bitbucket.org/my-company/myproject.git",
+            True,
+        ),
+        ("path/doesnt/exist", "non-existent-path", False,),
+        (
+            "https:/github.com/ambv/black/archive/18.9b0.zip",
+            "URL-syntax-error-slash",
+            False,
+        ),
+    ],
+)
+def test_parse_specifier_for_metadata(
+    package_spec_in, package_or_url_correct, valid_spec
+):
+    if valid_spec:
+        package_or_url = parse_specifier_for_metadata(package_spec_in)
+        assert package_or_url == package_or_url_correct
+    else:
+        # print package_spec_in for info in case no error is raised
+        print(f"package_spec_in = {package_spec_in}")
+        with pytest.raises(PipxError, match=r"^Unable to parse package spec"):
+            package_or_url = parse_specifier_for_metadata(package_spec_in)
+
+
 @pytest.mark.parametrize(
     "package_spec_in,package_or_url_correct,valid_spec",
     [
@@ -106,17 +176,17 @@ def test_fix_package_name(package_spec_in, package_name, package_spec_out):
         ),
     ],
 )
-def test_parse_specifier_for_metadata(
+def test_parse_specifier_for_upgrade(
     package_spec_in, package_or_url_correct, valid_spec
 ):
     if valid_spec:
-        package_or_url = parse_specifier_for_metadata(package_spec_in)
+        package_or_url = parse_specifier_for_upgrade(package_spec_in)
         assert package_or_url == package_or_url_correct
     else:
         # print package_spec_in for info in case no error is raised
         print(f"package_spec_in = {package_spec_in}")
         with pytest.raises(PipxError, match=r"^Unable to parse package spec"):
-            package_or_url = parse_specifier_for_metadata(package_spec_in)
+            package_or_url = parse_specifier_for_upgrade(package_spec_in)
 
 
 @pytest.mark.parametrize(
diff --git a/tests/test_pipx_metadata_file.py b/tests/test_pipx_metadata_file.py
index 440a314..3068402 100644
--- a/tests/test_pipx_metadata_file.py
+++ b/tests/test_pipx_metadata_file.py
@@ -47,7 +47,7 @@ PYCOWSAY_PACKAGE_REF = PackageInfo(
 )
 BLACK_PACKAGE_REF = PackageInfo(
     package="black",
-    package_or_url="black",
+    package_or_url="black==19.10b0",
     pip_args=[],
     include_dependencies=False,
     include_apps=True,
diff --git a/tests/test_reinstall.py b/tests/test_reinstall.py
index 04bfd35..9e3bb3d 100644
--- a/tests/test_reinstall.py
+++ b/tests/test_reinstall.py
@@ -41,3 +41,12 @@ def test_reinstall_suffix_legacy_venv(pipx_temp_env, capsys, metadata_version):
     assert not run_pipx_cli(
         ["reinstall", "--python", sys.executable, f"pycowsay{suffix}"]
     )
+
+
+def test_reinstall_specifier(pipx_temp_env, capsys):
+    with capsys.disabled():
+        assert not run_pipx_cli(["install", "pylint==2.3.1"])
+
+    assert not run_pipx_cli(["reinstall", "--python", sys.executable, "pylint"])
+    captured = capsys.readouterr()
+    assert "installed package pylint 2.3.1" in captured.out
diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py
index 02ce34b..8056fa8 100644
--- a/tests/test_upgrade.py
+++ b/tests/test_upgrade.py
@@ -35,3 +35,14 @@ def test_upgrade_suffix_legacy_venv(pipx_temp_env, capsys, metadata_version):
     mock_legacy_venv(f"{name}{suffix}", metadata_version=metadata_version)
     assert run_pipx_cli(["upgrade", f"{name}"])
     assert not run_pipx_cli(["upgrade", f"{name}{suffix}"])
+
+
+def test_upgrade_specifier(pipx_temp_env, capsys):
+    name = "pylint"
+    specifier = "==2.3.1"
+    initial_version = "2.3.1"
+
+    assert not run_pipx_cli(["install", f"{name}{specifier}"])
+    assert run_pipx_cli(["upgrade", f"{name}"])
+    captured = capsys.readouterr()
+    assert f"upgraded package {name} from {initial_version} to" in captured.out