Repository: pypa/pipx (python)

Issue:
Old setuptools_scm issue (FreeBSD, for example)
<!--
Thank you for filing a bug! Please feel free to answer as much or as little of this template as you can.

Please check pipx's Troubleshooting page to see if any of those solutions help solve your issue:
https://pipx.pypa.io/stable/troubleshooting/
-->

**Describe the bug**

Pipx expects `setuptools_scm` (via `hatch_vcs`) to produce `__version__` in the default template. However, older versions, such as the one supplied in FreeBSD, do not (this alias was added in version 7; for other reasons, FreeBSD is stuck on 6 and treats 7 as a new package).

https://github.com/pypa/pipx/blob/6918c4de5898d65807a2255eed43b6f5a6cf1a21/src/pipx/main.py#L31C1-L32C1

Two easy fixes and one less so: the one I usually do is to import `version` (the original name) as `__version__` (like in https://github.com/scikit-build/scikit-build-core/blob/f8c5798897852e107df0683893de59e15c558baf/src/scikit_build_core/__init__.py#L9). That was the original name, and is always available. You can also specify the template, which allows you to put whatever you want in `version.py`, but does require a bit more configuration. And the less-ideal fix would be to limit the version of setuptools_scm that hatch_vcs pulls in to some minimum that includes the addition of this feature (v7).


**How to reproduce**

Seen in https://github.com/abravalheri/validate-pyproject/pull/142, logs: https://github.com/abravalheri/validate-pyproject/pull/142/checks?check_run_id=20500888256

```pytb
pipx run coverage xml -o coverage.xml
Traceback (most recent call last):
  File "/usr/local/bin/pipx", line 5, in <module>
    from pipx.main import cli
  File "/usr/local/lib/python3.9/site-packages/pipx/main.py", line 31, in <module>
    from pipx.version import __version__
ImportError: cannot import name '__version__' from 'pipx.version' (/usr/local/lib/python3.9/site-packages/pipx/version.py)
```

**Expected behavior**

Would be nice not to have pipx crash on FreeBSD.


Pull request description:
fix: allow older setuptools_scm versions when building
<!-- add an 'x' in the brackets below -->

- [x] I have added a news fragment under `changelog.d/` (if the patch affects the end users)

Fixes #1207, supports older versions of `setuptools_scm`, not just 7+.

## Summary of changes

This uses `version.py`'s `version` attribute, which was present since the beginning, not the `__version__` alias that was added in version 7.

This also adds a `version.pyi` file, allowing mypy to check this, and removes the ignore that was needed when that file was missing.

## Test plan

<!-- provide evidence of testing, preferably with command(s) that can be copy+pasted by others -->

Tested in CI.


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 521f92f..7cae495 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -7,11 +7,11 @@ repos:
   - id: trailing-whitespace
   - id: check-yaml
 - repo: https://github.com/tox-dev/pyproject-fmt
-  rev: "1.5.3"
+  rev: "1.6.0"
   hooks:
     - id: pyproject-fmt
 - repo: https://github.com/astral-sh/ruff-pre-commit
-  rev: v0.1.11
+  rev: v0.1.13
   hooks:
   - id: ruff-format
   - id: ruff
diff --git a/changelog.d/1191.fix.md b/changelog.d/1191.fix.md
new file mode 100644
index 0000000..7ea5acd
--- /dev/null
+++ b/changelog.d/1191.fix.md
@@ -0,0 +1,1 @@
+Report correct filename in tracebacks with `pipx run <scriptname>`
diff --git a/changelog.d/1208.bugfix.md b/changelog.d/1208.bugfix.md
new file mode 100644
index 0000000..c71b1f1
--- /dev/null
+++ b/changelog.d/1208.bugfix.md
@@ -0,0 +1,1 @@
+Support building pipx wheels with setuptools-scm<7, such as on FreeBSD.
diff --git a/pyproject.toml b/pyproject.toml
index 23255bd..5c21a57 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -84,5 +84,5 @@ issue_format = "[#{issue}](https://github.com/pypa/pipx/issues/{issue})"
 package = "pipx"
 
 [[tool.mypy.overrides]]
-module = ["pipx.version", "pycowsay.*"]
+module = ["pycowsay.*"]
 ignore_missing_imports = true
diff --git a/src/pipx/commands/run.py b/src/pipx/commands/run.py
index eff9af0..1e036de 100644
--- a/src/pipx/commands/run.py
+++ b/src/pipx/commands/run.py
@@ -8,7 +8,7 @@ import urllib.parse
 import urllib.request
 from pathlib import Path
 from shutil import which
-from typing import List, NoReturn, Optional
+from typing import List, NoReturn, Optional, Union
 
 from packaging.requirements import InvalidRequirement, Requirement
 
@@ -42,14 +42,14 @@ Available executable scripts:
     {app_lines}"""
 
 
-def maybe_script_content(app: str, is_path: bool) -> Optional[str]:
+def maybe_script_content(app: str, is_path: bool) -> Optional[Union[str, Path]]:
     # If the app is a script, return its content.
     # Return None if it should be treated as a package name.
 
     # Look for a local file first.
     app_path = Path(app)
     if app_path.is_file():
-        return app_path.read_text(encoding="utf-8")
+        return app_path
     elif is_path:
         raise PipxError(f"The specified path {app} does not exist")
 
@@ -71,7 +71,7 @@ def maybe_script_content(app: str, is_path: bool) -> Optional[str]:
 
 
 def run_script(
-    content: str,
+    content: Union[str, Path],
     app_args: List[str],
     python: str,
     pip_args: List[str],
@@ -81,7 +81,7 @@ def run_script(
 ) -> NoReturn:
     requirements = _get_requirements_from_script(content)
     if requirements is None:
-        exec_app([python, "-c", content, *app_args])
+        python_path = Path(python)
     else:
         # Note that the environment name is based on the identified
         # requirements, and *not* on the script name. This is deliberate, as
@@ -99,7 +99,12 @@ def run_script(
             venv = Venv(venv_dir, python=python, verbose=verbose)
             venv.create_venv(venv_args, pip_args)
             venv.install_unmanaged_packages(requirements, pip_args)
-        exec_app([venv.python_path, "-c", content, *app_args])
+        python_path = venv.python_path
+
+    if isinstance(content, Path):
+        exec_app([python_path, content, *app_args])
+    else:
+        exec_app([python_path, "-c", content, *app_args])
 
 
 def run_package(
@@ -323,11 +328,14 @@ def _http_get_request(url: str) -> str:
 INLINE_SCRIPT_METADATA = re.compile(r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$")
 
 
-def _get_requirements_from_script(content: str) -> Optional[List[str]]:
+def _get_requirements_from_script(content: Union[str, Path]) -> Optional[List[str]]:
     """
     Supports inline script metadata.
     """
 
+    if isinstance(content, Path):
+        content = content.read_text(encoding="utf-8")
+
     name = "script"
 
     # Windows is currently getting un-normalized line endings, so normalize
diff --git a/src/pipx/main.py b/src/pipx/main.py
index aa7ff86..86fe38a 100644
--- a/src/pipx/main.py
+++ b/src/pipx/main.py
@@ -28,7 +28,7 @@ from pipx.emojis import hazard
 from pipx.interpreter import DEFAULT_PYTHON, InterpreterResolutionError, find_python_interpreter
 from pipx.util import PipxError, mkdir, pipx_wrap, rmdir
 from pipx.venv import VenvContainer
-from pipx.version import __version__
+from pipx.version import version as __version__
 
 logger = logging.getLogger(__name__)
 
diff --git a/src/pipx/version.pyi b/src/pipx/version.pyi
new file mode 100644
index 0000000..d05fd88
--- /dev/null
+++ b/src/pipx/version.pyi
@@ -0,0 +1,5 @@
+version: str
+version_tuple: tuple[int, int, int, str, str] | tuple[int, int, int]
+
+# Note that newer versions of setuptools_scm also add __version__, but we are
+# not forcing new versions of setuptools_scm, so only these imports are allowed.


Test patch:
diff --git a/tests/test_run.py b/tests/test_run.py
index 0de3920..7d93556 100644
--- a/tests/test_run.py
+++ b/tests/test_run.py
@@ -243,6 +243,24 @@ def test_run_with_requirements_old(caplog, pipx_temp_env, tmp_path):
         run_pipx_cli_exit(["run", script.as_uri()])
 
 
+@mock.patch("os.execvpe", new=execvpe_mock)
+def test_run_correct_traceback(capfd, pipx_temp_env, tmp_path):
+    script = tmp_path / "test.py"
+    script.write_text(
+        textwrap.dedent(
+            """
+                raise RuntimeError("Should fail")
+            """
+        ).strip()
+    )
+
+    with pytest.raises(SystemExit):
+        run_pipx_cli(["run", str(script)])
+
+    captured = capfd.readouterr()
+    assert "test.py" in captured.err
+
+
 @mock.patch("os.execvpe", new=execvpe_mock)
 def test_run_with_args(caplog, pipx_temp_env, tmp_path):
     script = tmp_path / "test.py"