Repository: pypa/pipx (python)

Issue:
Install a .whl file containing console entry point
**How would this feature be useful?**

If a user creates a `.whl` file that contains an [`entry_point` `console_script`](https://python-packaging.readthedocs.io/en/latest/command-line-scripts.html#the-console-scripts-entry-point), normally they can run:

```bash
(venv) $ pip install myproject-0.1.0-py3-none-any.whl
```

And then those entry points are available within the virtual environment.

But when I try to do something similar with `pipx`:

```
$ pipx install myproject-0.1.0-py3-none-any.whl                               
Fatal error from pip prevented installation. Full pip output in file:
    ~/.local/pipx/logs/cmd_2021-03-02_14.39.35_pip_errors.log

Some possibly relevant errors from pip install:
    ERROR: Could not find a version that satisfies the requirement myproject-0-1-0-py3-none-any-whl
    ERROR: No matching distribution found for myproject-0-1-0-py3-none-any-whl

Error installing myproject-0-1-0-py3-none-any-whl.
```

It seems like the `.whl` file name is being normalized into a PyPI name?

**Describe the solution you'd like**

Be able to run

```
$ pipx install myproject-0.1.0-py3-none-any.whl
```

And it installs the package into the `pipx` environment, making the console entry points available in the PATH.

Pull request:
Match pip's behavior when package name looks like an archive
<!-- add an 'x' in the brackets below -->
* [x] I have added an entry to `docs/changelog.md`

## Summary of changes

Match pip's behaviour when package name ends with an archive extension such as `.whl`, `.tar.gz` (Treat it as a local path).

Closes #641

## Test plan
<!-- provide evidence of testing, preferably with command(s) that can be copy+pasted by others -->
Tested by running
```
pipx install black-22.6.0-py3-none-any.whl --verbose
```


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d35a81f..47e1f05 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
 - Add `pipx uninject` command (#820)
 - [docs] Fix `pipx run` examples and update Python versions used by `pipx install` examples
 - [docs] Add an example for installation from source with extras
+- Match pip's behaviour when package name ends with archive extension (treat it as a path)
 - Ship a [zipapp](https://docs.python.org/3/library/zipapp.html) of pipx
 
 - Change the program name to `path/to/python -m pipx` when running as `python -m pipx`
diff --git a/src/pipx/package_specifier.py b/src/pipx/package_specifier.py
index 88b6be7..ad48018 100644
--- a/src/pipx/package_specifier.py
+++ b/src/pipx/package_specifier.py
@@ -20,6 +20,8 @@ from pipx.util import PipxError, pipx_wrap
 
 logger = logging.getLogger(__name__)
 
+ARCHIVE_EXTENSIONS = (".whl", ".tar.gz", ".zip")
+
 
 class ParsedPackage(NamedTuple):
     valid_pep508: Optional[Requirement]
@@ -36,6 +38,13 @@ def _split_path_extras(package_spec: str) -> Tuple[str, str]:
         return (package_spec, "")
 
 
+def _check_package_path(package_path: str) -> Tuple[Path, bool]:
+    pkg_path = Path(package_path)
+    pkg_path_exists = pkg_path.exists()
+
+    return (pkg_path, pkg_path_exists)
+
+
 def _parse_specifier(package_spec: str) -> ParsedPackage:
     """Parse package_spec as would be given to pipx"""
     # If package_spec is valid pypi name, pip will always treat it as a
@@ -55,6 +64,15 @@ def _parse_specifier(package_spec: str) -> ParsedPackage:
         # valid PEP508 package specification
         valid_pep508 = package_req
 
+    if valid_pep508 and package_req.name.endswith(ARCHIVE_EXTENSIONS):
+        # It might be a local archive
+        (package_path, package_path_exists) = _check_package_path(package_req.name)
+
+        if package_path_exists:
+            valid_local_path = str(package_path.resolve())
+        else:
+            raise PipxError(f"{package_path} does not exist")
+
     # packaging currently (2020-07-19) only does basic syntax checks on URL.
     #   Some examples of what it will not catch:
     #       - invalid RCS string (e.g. "gat+https://...")
@@ -71,11 +89,7 @@ def _parse_specifier(package_spec: str) -> ParsedPackage:
     if not valid_pep508 and not valid_url:
         (package_path_str, package_extras_str) = _split_path_extras(package_spec)
 
-        package_path = Path(package_path_str)
-        try:
-            package_path_exists = package_path.exists()
-        except OSError:
-            package_path_exists = False
+        (package_path, package_path_exists) = _check_package_path(package_path_str)
 
         if package_path_exists:
             valid_local_path = str(package_path.resolve()) + package_extras_str
@@ -83,6 +97,11 @@ def _parse_specifier(package_spec: str) -> ParsedPackage:
     if not valid_pep508 and not valid_url and not valid_local_path:
         raise PipxError(f"Unable to parse package spec: {package_spec}")
 
+    if valid_pep508 and valid_local_path:
+        # It is a valid local path without "./"
+        # Use valid_local_path
+        valid_pep508 = None
+
     return ParsedPackage(
         valid_pep508=valid_pep508,
         valid_url=valid_url,
@@ -206,8 +225,9 @@ def valid_pypi_name(package_spec: str) -> Optional[str]:
         # not a valid PEP508 package specification
         return None
 
-    if package_req.url:
+    if package_req.url or package_req.name.endswith(ARCHIVE_EXTENSIONS):
         # package name supplied by user might not match package found in URL,
+        # also if package name ends with archive extension, it might be a local archive file,
         #   so force package name determination the long way
         return None
 
@@ -221,6 +241,9 @@ def fix_package_name(package_or_url: str, package_name: str) -> str:
         # not a valid PEP508 package specification
         return package_or_url
 
+    if package_req.name.endswith(ARCHIVE_EXTENSIONS):
+        return str(package_req)
+
     if canonicalize_name(package_req.name) != canonicalize_name(package_name):
         logger.warning(
             pipx_wrap(


Test patch:
diff --git a/tests/test_install.py b/tests/test_install.py
index a6e5484..a135b86 100644
--- a/tests/test_install.py
+++ b/tests/test_install.py
@@ -1,5 +1,6 @@
 import os
 import re
+import subprocess
 import sys
 from pathlib import Path
 from unittest import mock
@@ -252,3 +253,12 @@ def test_install_pip_failure(pipx_temp_env, capsys):
     assert Path(pip_log_file_match.group(1)).exists()
 
     assert re.search(r"pip (failed|seemed to fail) to build package", captured.err)
+
+
+def test_install_local_archive(pipx_temp_env, monkeypatch, capsys):
+    monkeypatch.chdir(Path(TEST_DATA_PATH) / "local_extras")
+
+    subprocess.run([sys.executable, "-m", "pip", "wheel", "."])
+    assert not run_pipx_cli(["install", "repeatme-0.1-py3-none-any.whl"])
+    captured = capsys.readouterr()
+    assert f"- {app_name('repeatme')}\n" in captured.out
diff --git a/tests/test_package_specifier.py b/tests/test_package_specifier.py
index 986b1f6..154416e 100644
--- a/tests/test_package_specifier.py
+++ b/tests/test_package_specifier.py
@@ -20,6 +20,8 @@ TEST_DATA_PATH = "./testdata/test_package_specifier"
         ("Black", "black"),
         ("https://github.com/ambv/black/archive/18.9b0.zip", None),
         ("black @ https://github.com/ambv/black/archive/18.9b0.zip", None),
+        ("black-18.9b0-py36-none-any.whl", None),
+        ("black-18.9b0.tar.gz", None),
     ],
 )
 def test_valid_pypi_name(package_spec_in, package_name_out):