Repository: pypa/pipx
Pull request #1100 (2023-11-03 19:45:06)

feat: support PEP 723 run requirements
<!-- add an 'x' in the brackets below -->
* [x] I have added an entry to `docs/changelog.md`

## Summary of changes

Moved the unreleased feature from the rejected PEP 722 syntax to the (provisionally accepted a few moments ago in https://github.com/python/peps/pull/3505) PEP 723 syntax.

## Test plan
<!-- provide evidence of testing, preferably with command(s) that can be copy+pasted by others -->
Tested by running
```
nox -s tests-3.11
```


pradyunsg: :wave: I would request that y'all hold off on merging this until there's agreement on what exactly the provisional status for the PEP means, in the context of user-facing tooling implementing the described functionality.

I've [asked about what tooling is supposed to do right now](https://discuss.python.org/t/pep-722-723-decision/36763/94?u=pradyunsg) in the acceptance thread for the PEP, because what they're supposed to do seems to be ambigous/unclear to me, and I think we should make the call explicitly to implement something that we might rip out later.

henryiii: > explicitly to implement something that we might rip out later.

To be clear, PEP 722 (rejected) is already implemented in main, and this is just moving that unreleased code to PEP 723 (provisionally accepted). IMO, if this exists at all, it better in the provisionally accepted form rather than the rejected form.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b2bfd4d..9ca9c78 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 ## dev
 
+- Support PEP 723 run requirements in `pipx run`.
 - Imply `--include-apps` when running `pipx inject --include-deps`
 - Add `--with-suffix` for `pipx inject` command
 - `pipx install`: emit a warning when `--force` and `--python` were passed at the same time
diff --git a/README.md b/README.md
index 08f410f..f2a6aa2 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,12 @@ _For comparison to other tools including pipsi, see [Comparison to Other Tools](
 
 ## Install pipx
 
+> [!NOTE]
+> It is not recommended to install `pipx` via `pipx`. If you'd like
+> to do this anyway, take a look at the
+> [`pipx-in-pipx`](https://github.com/mattsb42-meta/pipx-in-pipx) project and
+> read about the limitations there.
+
 ### On macOS
 
 ```
@@ -69,7 +75,7 @@ Restart your terminal session and verify `pipx` does run.
 
 Upgrade pipx with `py -m pip install --user --upgrade pipx`.
 
-### Via zipapp
+### Using pipx without installing (via zipapp)
 
 You can also use pipx without installing it.
 The zipapp can be downloaded from [Github releases](https://github.com/pypa/pipx/releases) and you can invoke it with a Python 3.7+ interpreter:
diff --git a/docs/installation.md b/docs/installation.md
index e8da257..d559104 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -6,7 +6,7 @@ You also need to have `pip` installed on your machine for `python3`. Installing
 
 pipx works on macOS, linux, and Windows.
 
-## Install pipx
+## Installing pipx
 
 On macOS:
 
@@ -29,18 +29,24 @@ python3 -m pip install --user pipx
 python3 -m pipx ensurepath
 ```
 
-Or via zipapp:
+!!!caution
+    It is not recommended to install `pipx` via `pipx`. If you'd like
+    to do this anyway, take a look at the
+    [`pipx-in-pipx`](https://github.com/mattsb42-meta/pipx-in-pipx) project and
+    read about the limitations there.
 
-You can also use pipx without installing it.
+
+### Using pipx without installing (via zipapp)
 The zipapp can be downloaded from [Github releases](https://github.com/pypa/pipx/releases) and you can invoke it with a Python 3.7+ interpreter:
 
 ```
 python pipx.pyz ensurepath
 ```
 
-<a name="pre-commit"></a>Or use with pre-commit:
+### <a name="pre-commit"></a> Using pipx with pre-commit
 
 Pipx has [pre-commit](https://pre-commit.com/) support. This lets you run applications:
+
 * That can be run using `pipx run` but don't have native pre-commit support.
 * Using its prebuilt wheel from pypi.org instead of building it from source.
 * Using pipx's `--spec` and `--index-url` flags.
@@ -58,7 +64,7 @@ Example configuration for use of the code linter [yapf](https://github.com/googl
     types: ['python']
 ```
 
-### Installation Options
+## Installation Options
 
 The default binary location for pipx-installed apps is `~/.local/bin`. This can be overridden with the environment variable `PIPX_BIN_DIR`.
 
diff --git a/pyproject.toml b/pyproject.toml
index 4539d31..950f0f5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,6 +25,7 @@ dependencies = [
   "colorama>=0.4.4; sys_platform == 'win32'",
   "packaging>=20.0",
   "platformdirs>=2.1.0",
+  "tomli; python_version < '3.11'",
   "userpath>=1.6.0,!=1.9.0",
 ]
 dynamic = ["version"]
diff --git a/src/pipx/commands/run.py b/src/pipx/commands/run.py
index 15428e0..575565c 100644
--- a/src/pipx/commands/run.py
+++ b/src/pipx/commands/run.py
@@ -1,6 +1,8 @@
 import datetime
 import hashlib
 import logging
+import re
+import sys
 import time
 import urllib.parse
 import urllib.request
@@ -24,6 +26,11 @@ from pipx.util import (
 )
 from pipx.venv import Venv
 
+if sys.version_info < (3, 11):
+    import tomli as tomllib
+else:
+    import tomllib
+
 logger = logging.getLogger(__name__)
 
 
@@ -319,41 +326,46 @@ def _http_get_request(url: str) -> str:
         raise PipxError(str(e)) from e
 
 
+# This regex comes from PEP 723
+PEP723 = re.compile(
+    r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$"
+)
+
+
 def _get_requirements_from_script(content: str) -> Optional[List[str]]:
-    # An iterator over the lines in the script. We will
-    # read through this in sections, so it needs to be an
-    # iterator, not just a list.
-    lines = iter(content.splitlines())
-
-    for line in lines:
-        if not line.startswith("#"):
-            continue
-        line_content = line[1:].strip()
-        if line_content == "Requirements:":
-            break
-    else:
-        # No "Requirements:" line in the file
+    """
+    Supports PEP 723.
+    """
+
+    name = "pyproject"
+
+    # Windows is currently getting un-normalized line endings, so normalize
+    content = content.replace("\r\n", "\n")
+
+    matches = [m for m in PEP723.finditer(content) if m.group("type") == name]
+
+    if not matches:
         return None
 
-    # We are now at the first requirement
-    requirements = []
-    for line in lines:
-        # Stop at the end of the comment block
-        if not line.startswith("#"):
-            break
-        line_content = line[1:].strip()
-        # Stop at a blank comment line
-        if not line_content:
-            break
+    if len(matches) > 1:
+        raise ValueError(f"Multiple {name} blocks found")
 
+    content = "".join(
+        line[2:] if line.startswith("# ") else line[1:]
+        for line in matches[0].group("content").splitlines(keepends=True)
+    )
+
+    pyproject = tomllib.loads(content)
+
+    requirements = []
+    for requirement in pyproject.get("run", {}).get("requirements", []):
         # Validate the requirement
         try:
-            req = Requirement(line_content)
+            req = Requirement(requirement)
         except InvalidRequirement as e:
-            raise PipxError(f"Invalid requirement {line_content}: {str(e)}") from e
+            raise PipxError(f"Invalid requirement {requirement}: {e}") from e
 
-        # Use the normalised form of the requirement,
-        # not the original line.
+        # Use the normalised form of the requirement
         requirements.append(str(req))
 
     return requirements


Test patch:
diff --git a/tests/test_run.py b/tests/test_run.py
index f4199a0..6a6708d 100644
--- a/tests/test_run.py
+++ b/tests/test_run.py
@@ -208,8 +208,9 @@ def test_run_with_requirements(caplog, pipx_temp_env, tmp_path):
     script.write_text(
         textwrap.dedent(
             f"""
-                # Requirements:
-                # requests==2.28.1
+                # /// pyproject
+                # run.requirements = ["requests==2.28.1"]
+                # ///
 
                 # Check requests can be imported
                 import requests
@@ -219,7 +220,8 @@ def test_run_with_requirements(caplog, pipx_temp_env, tmp_path):
                 from pathlib import Path
                 Path({repr(str(out))}).write_text(requests.__version__)
             """
-        ).strip()
+        ).strip(),
+        encoding="utf-8",
     )
     run_pipx_cli_exit(["run", script.as_uri()])
     assert out.read_text() == "2.28.1"
@@ -249,9 +251,9 @@ def test_run_with_requirements_and_args(caplog, pipx_temp_env, tmp_path):
     script.write_text(
         textwrap.dedent(
             f"""
-                # Requirements:
-                # packaging
-
+                # /// pyproject
+                # run.requirements = ["packaging"]
+                # ///
                 import packaging
                 import sys
                 from pathlib import Path
@@ -269,8 +271,9 @@ def test_run_with_invalid_requirement(capsys, pipx_temp_env, tmp_path):
     script.write_text(
         textwrap.dedent(
             """
-                # Requirements:
-                # this is an invalid requirement
+                # /// pyproject
+                # run.requirements = ["this is an invalid requirement"]
+                # ///
                 print()
             """
         ).strip()