Repository: pypa/pipx
Pull request #923 (2022-12-07 18:08:06)

Bump pypa/gh-action-pypi-publish from 1.5.2 to 1.6.4
Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.5.2 to 1.6.4.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/pypa/gh-action-pypi-publish/releases">pypa/gh-action-pypi-publish's releases</a>.</em></p>
<blockquote>
<h2>v1.6.4</h2>
<h2>oh, boi! again?</h2>
<p>This is the last one tonight, promise! It fixes this embarrassing bug that was actually caught by the CI but got overlooked due to the lack of sleep.
TL;DR GH passed <code>$HOME</code> from the external env into the container and that tricked the Python's <code>site</code> module to think that the home directory is elsewhere, adding non-existent paths to the env vars. See <a href="https://github-redirect.dependabot.com/pypa/gh-action-pypi-publish/issues/115">#115</a>.</p>
<p><strong>Full Diff</strong>: <a href="https://github.com/pypa/gh-action-pypi-publish/compare/v1.6.3...v1.6.4">https://github.com/pypa/gh-action-pypi-publish/compare/v1.6.3...v1.6.4</a></p>
<h2>v1.6.3</h2>
<h1>Another Release!? Why?</h1>
<p>In <a href="https://github-redirect.dependabot.com/pypa/gh-action-pypi-publish/issues/112#issuecomment-1340133013">pypa/gh-action-pypi-publish#112</a>, it was discovered that passing a <code>$PATH</code> variable even breaks the shebang. So this version adds more safeguards to make sure it keeps working with a fully broken <code>$PATH</code>.</p>
<p><strong>Full Diff</strong>: <a href="https://github.com/pypa/gh-action-pypi-publish/compare/v1.6.2...v1.6.3">https://github.com/pypa/gh-action-pypi-publish/compare/v1.6.2...v1.6.3</a></p>
<h2>v1.6.2</h2>
<h2>What's Fixed</h2>
<ul>
<li>Made the <code>$PATH</code> and <code>$PYTHONPATH</code> environment variables resilient to broken values passed from the host runner environment, which previously allowed the users to accidentally break the container's internal runtime as reported in <a href="https://github-redirect.dependabot.com/pypa/gh-action-pypi-publish/issues/112">pypa/gh-action-pypi-publish#112</a></li>
</ul>
<h2>Internal Maintenance Improvements</h2>
<ul>
<li>Added a devpi-based smoke-test GitHub Actions CI/CD workflow by <a href="https://github.com/sesdaile-varmour"><code>@​sesdaile-varmour</code></a> in <a href="https://github-redirect.dependabot.com/pypa/gh-action-pypi-publish/pull/111">pypa/gh-action-pypi-publish#111</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/sesdaile-varmour"><code>@​sesdaile-varmour</code></a> made their first contribution in <a href="https://github-redirect.dependabot.com/pypa/gh-action-pypi-publish/pull/111">pypa/gh-action-pypi-publish#111</a></li>
</ul>
<p><strong>Full Diff</strong>: <a href="https://github.com/pypa/gh-action-pypi-publish/compare/v1.6.1...v1.6.2">https://github.com/pypa/gh-action-pypi-publish/compare/v1.6.1...v1.6.2</a></p>
<h2>v1.6.1</h2>
<h2>What's happened?!</h2>
<p>There was a sneaky bug in v1.6.0 which caused Twine to be outside the import path in the Python runtime. It is fixed in v1.6.1 by updating <code>$PYTHONPATH</code> to point to a correct location of the user-global <code>site-packages/</code> directory.</p>
<p><strong>Full Diff</strong>: <a href="https://github.com/pypa/gh-action-pypi-publish/compare/v1.6.0...v1.6.1">https://github.com/pypa/gh-action-pypi-publish/compare/v1.6.0...v1.6.1</a></p>
<h2>v1.6.0</h2>
<h2>Anything's changed?</h2>
<p>The only update is that the Python runtime has been upgraded from 3.9 to 3.11. There are no functional changes in this release.</p>
<p><strong>Full Changelog</strong>: <a href="https://github.com/pypa/gh-action-pypi-publish/compare/v1.5.2...v1.6.0">https://github.com/pypa/gh-action-pypi-publish/compare/v1.5.2...v1.6.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/pypa/gh-action-pypi-publish/commit/c7f29f7adef1a245bd91520e94867e5c6eedddcc"><code>c7f29f7</code></a> 🐛 Override <code>$HOME</code> in the container with <code>/root</code></li>
<li><a href="https://github.com/pypa/gh-action-pypi-publish/commit/644926c9722664f88c9f456a1c367031ffb065f8"><code>644926c</code></a> 🧪 Always run smoke testing in debug mode</li>
<li><a href="https://github.com/pypa/gh-action-pypi-publish/commit/e71a4a4c1d3837e77d0353f9229be9217526a2c4"><code>e71a4a4</code></a> Add support for verbose bash execusion w/ <code>$DEBUG</code></li>
<li><a href="https://github.com/pypa/gh-action-pypi-publish/commit/e56e8212f48a2dd7d76d426d4bcab2f5ce15277d"><code>e56e821</code></a> 🐛 Make <code>id</code> always available in <code>twine-upload</code></li>
<li><a href="https://github.com/pypa/gh-action-pypi-publish/commit/c879b84594122637ac80295111bfd478444c7983"><code>c879b84</code></a> 🐛 Use full path to <code>bash</code> in shebang</li>
<li><a href="https://github.com/pypa/gh-action-pypi-publish/commit/57e7d53102237d3c8f3e745ed8be27cc0e543819"><code>57e7d53</code></a> 🐛Ensure the default <code>$PATH</code> value is pre-loaded</li>
<li><a href="https://github.com/pypa/gh-action-pypi-publish/commit/ce291dce5b39b74daf2a1a0dcb652314e3263edb"><code>ce291dc</code></a> 🎨🐛Fix the branch @ pre-commit.ci badge links</li>
<li><a href="https://github.com/pypa/gh-action-pypi-publish/commit/102d8ab13f40a06246caac2b1008617a8d4673cc"><code>102d8ab</code></a> 🐛 Rehardcode devpi port for GHA srv container</li>
<li><a href="https://github.com/pypa/gh-action-pypi-publish/commit/3a9eaef3ef4ec31b99bda836b8b667475e6ee532"><code>3a9eaef</code></a> 🐛Use different ports in/out of GHA containers</li>
<li><a href="https://github.com/pypa/gh-action-pypi-publish/commit/a01fa7442e281f2856175aee1545561a54c01d6c"><code>a01fa74</code></a> 🐛 Use <code>localhost</code> @ GHA outside the containers</li>
<li>Additional commits viewable in <a href="https://github.com/pypa/gh-action-pypi-publish/compare/v1.5.2...v1.6.4">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=pypa/gh-action-pypi-publish&package-manager=github_actions&previous-version=1.5.2&new-version=1.6.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>


Patch:
diff --git a/.github/workflows/publish_docs.yml b/.github/workflows/publish_docs.yml
index f1bdf7d..071722b 100644
--- a/.github/workflows/publish_docs.yml
+++ b/.github/workflows/publish_docs.yml
@@ -27,6 +27,6 @@ jobs:
           nox --error-on-missing-interpreters --non-interactive --session build_docs
       - name: Deploy docs
         if: ${{ success() }}
-        uses: mhausenblas/mkdocs-deploy-gh-pages@1.24
+        uses: mhausenblas/mkdocs-deploy-gh-pages@1.26
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d35a81f..ea031d9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,9 +2,13 @@
 - 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`
+- Improve the detection logic for MSYS2 to avoid entering infinite loop (#908)
+- Remove extra trailing quote from exception message
+- Fix EncodingWarning in `pipx_metadata_file`.
 
 ## 1.1.0
 
diff --git a/README.md b/README.md
index 038c9ab..85e922d 100644
--- a/README.md
+++ b/README.md
@@ -67,6 +67,8 @@ pipx ensurepath
 This will add both the above mentioned path and the `%USERPROFILE%\.local\bin` folder to your search path.
 Restart your terminal session and verify `pipx` does run.
 
+Upgrade pipx with `python3 -m pip install --user -U pipx`.
+
 ### Via zipapp
 
 You can also use pipx without installing it.
@@ -76,8 +78,6 @@ The zipapp can be downloaded from [Github releases](https://github.com/pypa/pipx
 python pipx.pyz ensurepath
 ```
 
-Upgrade pipx with `python3 -m pip install --user -U pipx`.
-
 ### Shell completions
 
 Shell completions are available by following the instructions printed with this command:
diff --git a/src/pipx/commands/common.py b/src/pipx/commands/common.py
index 6a79031..0c572ef 100644
--- a/src/pipx/commands/common.py
+++ b/src/pipx/commands/common.py
@@ -380,7 +380,7 @@ def run_post_install_actions(
                 with '--include-deps' to include apps of dependent packages,
                 which are listed above. If you are attempting to install a
                 library, pipx should not be used. Consider using pip or a
-                similar tool instead."
+                similar tool instead.
                 """
             )
 
diff --git a/src/pipx/constants.py b/src/pipx/constants.py
index 34b2ea9..6373b66 100644
--- a/src/pipx/constants.py
+++ b/src/pipx/constants.py
@@ -1,5 +1,6 @@
 import os
 import sys
+import sysconfig
 from pathlib import Path
 from textwrap import dedent
 from typing import NewType, Optional
@@ -38,7 +39,12 @@ def is_windows() -> bool:
     return sys.platform == "win32"
 
 
+def is_mingw() -> bool:
+    return sysconfig.get_platform() == "mingw"
+
+
 WINDOWS: bool = is_windows()
+MINGW: bool = is_mingw()
 
 completion_instructions = dedent(
     """
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(
diff --git a/src/pipx/pipx_metadata_file.py b/src/pipx/pipx_metadata_file.py
index 48ea598..b3db6f7 100644
--- a/src/pipx/pipx_metadata_file.py
+++ b/src/pipx/pipx_metadata_file.py
@@ -123,7 +123,9 @@ class PipxMetadata:
     def write(self) -> None:
         self._validate_before_write()
         try:
-            with open(self.venv_dir / PIPX_INFO_FILENAME, "w") as pipx_metadata_fh:
+            with open(
+                self.venv_dir / PIPX_INFO_FILENAME, "w", encoding="utf-8"
+            ) as pipx_metadata_fh:
                 json.dump(
                     self.to_dict(),
                     pipx_metadata_fh,
@@ -146,7 +148,7 @@ class PipxMetadata:
 
     def read(self, verbose: bool = False) -> None:
         try:
-            with open(self.venv_dir / PIPX_INFO_FILENAME, "r") as pipx_metadata_fh:
+            with open(self.venv_dir / PIPX_INFO_FILENAME, "rb") as pipx_metadata_fh:
                 self.from_dict(
                     json.load(pipx_metadata_fh, object_hook=_json_decoder_object_hook)
                 )
diff --git a/src/pipx/util.py b/src/pipx/util.py
index 5c872fc..b390fff 100644
--- a/src/pipx/util.py
+++ b/src/pipx/util.py
@@ -23,7 +23,7 @@ from typing import (
 
 import pipx.constants
 from pipx.animate import show_cursor
-from pipx.constants import PIPX_TRASH_DIR, WINDOWS
+from pipx.constants import MINGW, PIPX_TRASH_DIR, WINDOWS
 
 logger = logging.getLogger(__name__)
 
@@ -115,7 +115,7 @@ def run_pypackage_bin(bin_path: Path, args: List[str]) -> NoReturn:
 if WINDOWS:
 
     def get_venv_paths(root: Path) -> Tuple[Path, Path]:
-        bin_path = root / "Scripts"
+        bin_path = root / "Scripts" if not MINGW else root / "bin"
         python_path = bin_path / "python.exe"
         return bin_path, python_path
 


Test patch:
diff --git a/.github/workflows/publish-testpypi.yml b/.github/workflows/publish-testpypi.yml
index 8644cc1..54e5576 100644
--- a/.github/workflows/publish-testpypi.yml
+++ b/.github/workflows/publish-testpypi.yml
@@ -32,7 +32,7 @@ jobs:
       run: |
         nox --error-on-missing-interpreters --non-interactive --session build
     - name: Publish to Test PyPi
-      uses: pypa/gh-action-pypi-publish@v1.5.2
+      uses: pypa/gh-action-pypi-publish@v1.6.4
       with:
         user: __token__
         password: ${{ secrets.test_pypi_password }}
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 32075b0..c9a1961 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -110,7 +110,7 @@ jobs:
       run: |
         nox --error-on-missing-interpreters --non-interactive --session build
     - name: Publish to PyPi
-      uses: pypa/gh-action-pypi-publish@v1.5.2
+      uses: pypa/gh-action-pypi-publish@v1.6.4
       with:
         user: __token__
         password: ${{ secrets.pypi_password }}
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):