Repository: pypa/pipx
Pull request #1060 (2023-09-15 16:48:29)

Add initial RTD configuration
<!-- add an 'x' in the brackets below -->
* [ ] I have added an entry to `docs/changelog.md`

## Summary of changes

Related to #698 

Creates an initial ReadTheDocs configuration file. A maintainer will need to import the project on RTD using his account, and configure building for pull requests.

cc @webknjaz 

## Test plan
<!-- provide evidence of testing, preferably with command(s) that can be copy+pasted by others -->
Tested by running
```
```


webknjaz: @chrysle looks like `publish_docs.yml` should also be deleted.
webknjaz: @gaborbernat I invited you on RTD, could you re-sync the hook @ https://readthedocs.org/dashboard/pipx/integrations/ ?
gaborbernat: I do not have the Github org rights for that 😊 
webknjaz: @gaborbernat do you know who does, then?
gaborbernat: No idea, I'm no longer involved in this project heavily.
webknjaz: @gaborbernat the readme lists you first 🤷‍♂️ 
gaborbernat: Bit outdated, but most rights are with the lead maintainers, they're the ones likely that can help you with this.
webknjaz: @cs01 could you help us out? We're bringing in configuration for the docs migration to RTD. A maintainer with sufficient repository privileges is required to activate the integration. What's your RTD username?
cs01: Hi, I don’t have an RTD username. @uranusjr are you able to assist? 
webknjaz: Invited @uranusjr on RTD. Hopefully, he has sufficient access to the repository settings too.. 
cs01: > Invited @uranusjr on RTD. Hopefully, he has sufficient access to the repository settings too..

Thanks! If he doesn’t I can give him the permissions he needs. 
chrysle: @webknjaz Meanwhile prettified the configuration according to your suggestions! I was mostly following the guide in the RTD docs before.
dukecat0: Perhaps we will need to set up a redirect from the current Github page in the future.

Patch:
diff --git a/.github/workflows/publish_docs.yml b/.github/workflows/publish_docs.yml
deleted file mode 100644
index 4784970..0000000
--- a/.github/workflows/publish_docs.yml
+++ /dev/null
@@ -1,32 +0,0 @@
-name: Publish docs via GitHub Pages
-on:
-  workflow_dispatch:
-  release:
-    types: [published]
-
-env:
-  default-python: "3.11"
-
-jobs:
-  build:
-    name: Deploy docs
-    runs-on: ubuntu-latest
-    steps:
-      - name: Checkout main
-        uses: actions/checkout@v4
-      - name: Set up Python ${{ env.default-python }}
-        uses: actions/setup-python@v4
-        with:
-          python-version: ${{ env.default-python }}
-      - name: Install nox
-        run: |
-          python -m pip install --upgrade pip
-          pip install nox
-      - name: Build Docs
-        run: |
-          nox --error-on-missing-interpreters --non-interactive --session build_docs
-      - name: Deploy docs
-        if: ${{ success() }}
-        uses: mhausenblas/mkdocs-deploy-gh-pages@1.26
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 0000000..3930655
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,11 @@
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+
+version: 2
+
+build:
+  os: ubuntu-22.04
+  tools:
+    python: "3.11"
+  commands:
+  - pip install nox
+  - nox --session build_docs -- "${READTHEDOCS_OUTPUT}"/html
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fa453d2..1288033 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 ## dev
 
+- Drop support for Python 3.7
 - Make usage message in `pipx run` show `package_or_url`, so extra will be printed out as well
 - Add `--force-reinstall` to pip arguments when `--force` was passed
 - Use the py launcher, if available, to select Python version with the `--python` option
@@ -15,6 +16,12 @@
 - Add `pipx install --preinstall` to support preinstalling build requirements
 - Pass `--no-input` to pip when output is not piped to parent stdout
 - Fix program name in generated manual page
+- Print all environment variables in `pipx environment`
+- Return an error message when directory can't be added to PATH successfully
+
+## 1.2.1
+
+- Fix compatibility to packaging 23.2+ by removing reliance on packaging's requirement validation logic and detecting a URL-based requirement in pipx. (#1070)
 
 ## 1.2.0
 
diff --git a/README.md b/README.md
index 060526d..08f410f 100644
--- a/README.md
+++ b/README.md
@@ -47,8 +47,8 @@ Upgrade pipx with `python3 -m pip install --user --upgrade pipx`.
 ### On Windows, install via pip (requires pip 19.0 or later)
 
 ```
-# If you installed python using the app-store, replace `python` with `python3` in the next line.
-python -m pip install --user pipx
+# If you installed python using Microsoft Store, replace `py` with `python3` in the next line.
+py -m pip install --user pipx
 ```
 
 It is possible (even most likely) the above finishes with a WARNING looking similar to this:
@@ -67,7 +67,7 @@ Enter the following line (even if you did not get the warning):
 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 --upgrade pipx`.
+Upgrade pipx with `py -m pip install --user --upgrade pipx`.
 
 ### Via zipapp
 
diff --git a/docs/installation.md b/docs/installation.md
index cfd2189..2174ecf 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -18,8 +18,8 @@ pipx ensurepath
 On Windows (requires pip 19.0 or later):
 
 ```
-py -3 -m pip install --user pipx
-py -3 -m pipx ensurepath
+py -m pip install --user pipx
+py -m pipx ensurepath
 ```
 
 Otherwise, install via pip (requires pip 19.0 or later):
diff --git a/mkdocs.yml b/mkdocs.yml
index 4f7c54d..c31254a 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -3,6 +3,19 @@ site_description: execute binaries from Python packages in isolated environments
 
 theme:
   name: "material"
+  palette:
+    - media: "(prefers-color-scheme: light)"
+      scheme: default
+      toggle:
+        icon: material/brightness-7
+        name: Switch to dark mode
+
+    - media: "(prefers-color-scheme: dark)"
+      scheme: slate
+      toggle:
+        icon: material/brightness-4
+        name: Switch to light mode
+
 repo_name: pypa/pipx
 repo_url: https://github.com/pypa/pipx
 edit_uri: edit/main/docs/
diff --git a/noxfile.py b/noxfile.py
index 55f94e5..fe9047a 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -4,7 +4,7 @@ from pathlib import Path
 
 import nox  # type: ignore
 
-PYTHON_ALL_VERSIONS = ["3.7", "3.8", "3.9", "3.10", "3.11"]
+PYTHON_ALL_VERSIONS = ["3.8", "3.9", "3.10", "3.11"]
 PYTHON_DEFAULT_VERSION = "3.11"
 DOC_DEPENDENCIES = [".", "jinja2", "mkdocs", "mkdocs-material"]
 MAN_DEPENDENCIES = [".", "argparse-manpage[setuptools]"]
@@ -193,21 +193,14 @@ def publish(session):
 
 @nox.session(python=PYTHON_DEFAULT_VERSION)
 def build_docs(session):
+    site_dir = session.posargs or ["site/"]
     session.run("python", "-m", "pip", "install", "--upgrade", "pip")
     session.install(*DOC_DEPENDENCIES)
     session.env[
         "PIPX__DOC_DEFAULT_PYTHON"
     ] = "typically the python used to execute pipx"
     session.run("python", "scripts/generate_docs.py")
-    session.run("mkdocs", "build", "--strict")
-
-
-@nox.session(python=PYTHON_DEFAULT_VERSION)
-def publish_docs(session):
-    session.run("python", "-m", "pip", "install", "--upgrade", "pip")
-    session.install(*DOC_DEPENDENCIES)
-    build_docs(session)
-    session.run("mkdocs", "gh-deploy", "--strict")
+    session.run("mkdocs", "build", "--strict", "--site-dir", *site_dir)
 
 
 @nox.session(python=PYTHON_DEFAULT_VERSION)
diff --git a/pyproject.toml b/pyproject.toml
index 9fc75e0..582fd38 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ name = "pipx"
 description = "Install and Run Python Applications in Isolated Environments"
 readme = "README.md"
 license = "MIT"
-requires-python = ">=3.7"
+requires-python = ">=3.8"
 keywords = ["pip", "install", "cli", "workflow", "Virtual Environment"]
 authors = [{ name = "Chad Smith", email = "chadsmith.software@gmail.com" }]
 classifiers = [
@@ -15,7 +15,6 @@ classifiers = [
   "Operating System :: OS Independent",
   "Programming Language :: Python",
   "Programming Language :: Python :: 3 :: Only",
-  "Programming Language :: Python :: 3.7",
   "Programming Language :: Python :: 3.8",
   "Programming Language :: Python :: 3.9",
   "Programming Language :: Python :: 3.10",
@@ -24,10 +23,9 @@ classifiers = [
 dependencies = [
   "argcomplete>=1.9.4",
   "colorama>=0.4.4; sys_platform == 'win32'",
-  "importlib-metadata>=3.3.0; python_version < '3.8'",
   "packaging>=20.0",
   "platformdirs>=2.1.0",
-  "userpath>=1.6.0",
+  "userpath>=1.6.0,!=1.9.0",
 ]
 dynamic = ["version"]
 
diff --git a/src/pipx/__init__.py b/src/pipx/__init__.py
index ec98d9c..c4155c4 100644
--- a/src/pipx/__init__.py
+++ b/src/pipx/__init__.py
@@ -1,8 +1,8 @@
 import sys
 
-if sys.version_info < (3, 6, 0):
+if sys.version_info < (3, 8, 0):
     sys.exit(
-        "Python 3.6 or later is required. "
+        "Python 3.8 or later is required. "
         "See https://github.com/pypa/pipx "
         "for installation instructions."
     )
diff --git a/src/pipx/commands/ensure_path.py b/src/pipx/commands/ensure_path.py
index 8151177..6e3de6f 100644
--- a/src/pipx/commands/ensure_path.py
+++ b/src/pipx/commands/ensure_path.py
@@ -61,14 +61,22 @@ def ensure_path(location: Path, *, force: bool) -> Tuple[bool, bool]:
     in_current_path = userpath.in_current_path(location_str)
 
     if force or (not in_current_path and not need_shell_restart):
-        userpath.append(location_str, "pipx")
-        print(
-            pipx_wrap(
-                f"Success! Added {location_str} to the PATH environment variable.",
-                subsequent_indent=" " * 4,
+        path_added = userpath.append(location_str, "pipx")
+        if not path_added:
+            print(
+                pipx_wrap(
+                    f"{hazard}  {location_str} is not added to the PATH environment variable successfully. "
+                    f"You may need to add it to PATH manually.",
+                    subsequent_indent=" " * 4,
+                )
+            )
+        else:
+            print(
+                pipx_wrap(
+                    f"Success! Added {location_str} to the PATH environment variable.",
+                    subsequent_indent=" " * 4,
+                )
             )
-        )
-        path_added = True
         need_shell_restart = userpath.need_shell_restart(location_str)
     elif not in_current_path and need_shell_restart:
         print(
diff --git a/src/pipx/commands/environment.py b/src/pipx/commands/environment.py
index 461bcb7..155311e 100644
--- a/src/pipx/commands/environment.py
+++ b/src/pipx/commands/environment.py
@@ -1,3 +1,5 @@
+import os
+
 from pipx.constants import (
     EXIT_CODE_OK,
     LOCAL_BIN_DIR,
@@ -9,12 +11,21 @@ from pipx.constants import (
     PIPX_VENV_CACHEDIR,
     ExitCode,
 )
+from pipx.emojis import EMOJI_SUPPORT
+from pipx.interpreter import DEFAULT_PYTHON
 from pipx.util import PipxError
 
 
 def environment(value: str) -> ExitCode:
-    """Print a list of variables used in `pipx.constants`"""
-    environment_variables = {
+    """Print a list of environment variables and paths used by pipx"""
+    environment_variables = [
+        "PIPX_HOME",
+        "PIPX_BIN_DIR",
+        "PIPX_SHARED_LIBS",
+        "PIPX_DEFAULT_PYTHON",
+        "USE_EMOJI",
+    ]
+    derived_values = {
         "PIPX_HOME": PIPX_HOME,
         "PIPX_BIN_DIR": LOCAL_BIN_DIR,
         "PIPX_SHARED_LIBS": PIPX_SHARED_LIBS,
@@ -22,14 +33,22 @@ def environment(value: str) -> ExitCode:
         "PIPX_LOG_DIR": PIPX_LOG_DIR,
         "PIPX_TRASH_DIR": PIPX_TRASH_DIR,
         "PIPX_VENV_CACHEDIR": PIPX_VENV_CACHEDIR,
+        "PIPX_DEFAULT_PYTHON": DEFAULT_PYTHON,
+        "USE_EMOJI": str(EMOJI_SUPPORT).lower(),
     }
     if value is None:
+        print("Environment variables (set by user):")
+        print("")
         for env_variable in environment_variables:
-            print(f"{env_variable}={environment_variables[env_variable]}")
+            env_value = os.getenv(env_variable, "")
+            print(f"{env_variable}={env_value}")
+        print("")
+        print("Derived values (computed by pipx):")
         print("")
-        print("Only PIPX_HOME and PIPX_BIN_DIR can be set by users in the above list.")
-    elif value in environment_variables:
-        print(environment_variables[value])
+        for env_variable in derived_values:
+            print(f"{env_variable}={derived_values[env_variable]}")
+    elif value in derived_values:
+        print(derived_values[value])
     else:
         raise PipxError("Variable not found.")
 
diff --git a/src/pipx/main.py b/src/pipx/main.py
index 7f12fba..bb5750d 100644
--- a/src/pipx/main.py
+++ b/src/pipx/main.py
@@ -668,15 +668,16 @@ def _add_environment(subparsers: argparse._SubParsersAction) -> None:
     p = subparsers.add_parser(
         "environment",
         formatter_class=LineWrapRawTextHelpFormatter,
-        help=("Print a list of variables used in pipx.constants."),
+        help="Print a list of environment variables and paths used by pipx.",
         description=textwrap.dedent(
             """
+            Prints the names and current values of environment variables used by pipx,
+            followed by internal pipx variables which are derived from the environment
+            variables and platform specific default values.
+
             Available variables:
             PIPX_HOME, PIPX_BIN_DIR, PIPX_SHARED_LIBS, PIPX_LOCAL_VENVS, PIPX_LOG_DIR,
-            PIPX_TRASH_DIR, PIPX_VENV_CACHEDIR
-
-            Only PIPX_HOME and PIPX_BIN_DIR can be set by users in the above list.
-
+            PIPX_TRASH_DIR, PIPX_VENV_CACHEDIR, PIPX_DEFAULT_PYTHON, USE_EMOJI
             """
         ),
     )
diff --git a/src/pipx/package_specifier.py b/src/pipx/package_specifier.py
index ad48018..c4f47bc 100644
--- a/src/pipx/package_specifier.py
+++ b/src/pipx/package_specifier.py
@@ -8,6 +8,7 @@
 
 import logging
 import re
+import urllib.parse
 from pathlib import Path
 from typing import List, NamedTuple, Optional, Set, Tuple
 
@@ -73,24 +74,17 @@ def _parse_specifier(package_spec: str) -> ParsedPackage:
         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://...")
-    #       - non-existent scheme (e.g. "zzzzz://...")
+    # If this looks like a URL, treat it as such.
     if not valid_pep508:
-        try:
-            package_req = Requirement("notapackagename @ " + package_spec)
-        except InvalidRequirement:
-            # not a valid url
-            pass
-        else:
+        parsed_url = urllib.parse.urlsplit(package_spec)
+        if parsed_url.scheme and parsed_url.netloc:
             valid_url = package_spec
 
+    # Treat the input as a local path if it does not look like a PEP 508
+    # specifier nor a URL. In this case we want to split out the extra part.
     if not valid_pep508 and not valid_url:
         (package_path_str, package_extras_str) = _split_path_extras(package_spec)
-
         (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
 
diff --git a/src/pipx/venv_inspect.py b/src/pipx/venv_inspect.py
index 193a09e..3b794cb 100644
--- a/src/pipx/venv_inspect.py
+++ b/src/pipx/venv_inspect.py
@@ -2,7 +2,7 @@ import json
 import logging
 import textwrap
 from pathlib import Path
-from typing import Dict, List, NamedTuple, Optional, Set, Tuple
+from typing import Collection, Dict, List, NamedTuple, Optional, Set, Tuple
 
 from packaging.requirements import Requirement
 from packaging.utils import canonicalize_name
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
 
 
 class VenvInspectInformation(NamedTuple):
-    distributions: List[metadata.Distribution]
+    distributions: Collection[metadata.Distribution]
     env: Dict[str, str]
     bin_path: Path
 
@@ -34,7 +34,7 @@ class VenvMetadata(NamedTuple):
 
 
 def get_dist(
-    package: str, distributions: List[metadata.Distribution]
+    package: str, distributions: Collection[metadata.Distribution]
 ) -> Optional[metadata.Distribution]:
     """Find matching distribution in the canonicalized sense."""
     for dist in distributions:
@@ -237,10 +237,16 @@ def inspect_venv(
         venv_python_path
     )
 
+    # Collect the generator created from metadata.distributions()
+    # (see `itertools.chain.from_iterable`) into a tuple because we
+    # need to iterate over it multiple times in `_dfs_package_apps`.
+
+    # Tuple is chosen over a list because the program only iterate over
+    # the distributions and never modify it.
+    distributions = tuple(metadata.distributions(path=venv_sys_path))
+
     venv_inspect_info = VenvInspectInformation(
-        bin_path=venv_bin_path,
-        env=venv_env,
-        distributions=list(metadata.distributions(path=venv_sys_path)),
+        bin_path=venv_bin_path, env=venv_env, distributions=distributions
     )
 
     root_dist = get_dist(root_req.name, venv_inspect_info.distributions)


Test patch:
diff --git a/.github/workflows/create_tests_package_lists.yml b/.github/workflows/create_tests_package_lists.yml
index 83c0924..27a9b52 100644
--- a/.github/workflows/create_tests_package_lists.yml
+++ b/.github/workflows/create_tests_package_lists.yml
@@ -9,7 +9,7 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-latest, macos-latest, windows-latest]
-        python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
+        python-version: ["3.8", "3.9", "3.10", "3.11"]
 
     steps:
     - uses: actions/checkout@v4
diff --git a/.github/workflows/exhaustive_package_test.yml b/.github/workflows/exhaustive_package_test.yml
index 4de52da..09885bb 100644
--- a/.github/workflows/exhaustive_package_test.yml
+++ b/.github/workflows/exhaustive_package_test.yml
@@ -10,7 +10,7 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-latest, macos-latest, windows-latest]
-        python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
+        python-version: ["3.8", "3.9", "3.10", "3.11"]
 
     steps:
     - uses: actions/checkout@v4
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index d522dfb..26fc033 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -55,7 +55,7 @@ jobs:
       fail-fast: false
       matrix:
         os: [ubuntu-latest]
-        python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
+        python-version: ["3.8", "3.9", "3.10", "3.11"]
         include:
           - os: windows-latest
             python-version: "3.11"
diff --git a/scripts/list_test_packages.py b/scripts/list_test_packages.py
index 0f701b3..cae97e1 100644
--- a/scripts/list_test_packages.py
+++ b/scripts/list_test_packages.py
@@ -146,7 +146,6 @@ def create_test_packages_list(
         all_packages.append(f"{package_name}=={package_version}")
 
     with platform_package_list_path.open("w") as package_list_fh:
-        "scripts/list_test_packages.py",
         for package in sorted(all_packages):
             print(package, file=package_list_fh)
 
diff --git a/tests/test_environment.py b/tests/test_environment.py
index d15cabe..8c55d94 100644
--- a/tests/test_environment.py
+++ b/tests/test_environment.py
@@ -11,10 +11,9 @@ def test_cli(monkeypatch, capsys):
     assert "PIPX_LOG_DIR" in captured.out
     assert "PIPX_TRASH_DIR" in captured.out
     assert "PIPX_VENV_CACHEDIR" in captured.out
-    assert (
-        "Only PIPX_HOME and PIPX_BIN_DIR can be set by users in the above list."
-        in captured.out
-    )
+    assert "PIPX_DEFAULT_PYTHON" in captured.out
+    assert "USE_EMOJI" in captured.out
+    assert "Environment variables (set by user):" in captured.out
 
 
 def test_cli_with_args(monkeypatch, capsys):
@@ -25,6 +24,8 @@ def test_cli_with_args(monkeypatch, capsys):
     assert not run_pipx_cli(["environment", "--value", "PIPX_LOG_DIR"])
     assert not run_pipx_cli(["environment", "--value", "PIPX_TRASH_DIR"])
     assert not run_pipx_cli(["environment", "--value", "PIPX_VENV_CACHEDIR"])
+    assert not run_pipx_cli(["environment", "--value", "PIPX_DEFAULT_PYTHON"])
+    assert not run_pipx_cli(["environment", "--value", "USE_EMOJI"])
 
     assert run_pipx_cli(["environment", "--value", "SSS"])
     captured = capsys.readouterr()
diff --git a/tests/test_package_specifier.py b/tests/test_package_specifier.py
index 458ff7c..6351d20 100644
--- a/tests/test_package_specifier.py
+++ b/tests/test_package_specifier.py
@@ -96,7 +96,7 @@ def test_fix_package_name(package_spec_in, package_name, package_spec_out):
             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 ; python_version<"3.8"',
             "my-project[cli]@ git+ssh://git@bitbucket.org/my-company/myproject.git",
             True,
         ),
@@ -165,7 +165,7 @@ def test_parse_specifier_for_metadata(
             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 ; python_version<"3.8"',
             "my-project[cli]@ git+ssh://git@bitbucket.org/my-company/myproject.git",
             True,
         ),