Skip to content

Commit 35d86e4

Browse files
authored
🐛 fix(run): discover entry points for inferred names (#1996)
`pipx run git+file:///tmp/src1` fails on the default uv backend with `An executable named 'rat-demo' is not provided by package 'rat-demo'`, while `pipx run --backend pip` on the same URL runs it, as [#1994](#1994) reports. The same guess breaks `pipx run pip_search` from [#1189](#1189), where pipx normalizes the typed name to `pip-search` and the installed script keeps its underscore. Both failures start in pipx. It passes `uv tool run` a script name the package never declared, and uv reports the name it could not find. 🔍 pipx now passes `uv tool run` a script name only when the user typed one. The three specs it has to infer from (a VCS URL, a local project path, or a name it normalizes) go through the venv path, where pipx installs the package first and reads the entry points it declares. A lone script runs under its own name, `[pipx.run]` entry points work again, and a package shipping several scripts gets pipx's own error listing them. uv still creates and populates that venv, so the run gives up uv's tool cache. Two more bugs sat behind that fallback and go away with it: pipx rebuilt the cached venv on every run, because the cache check looked for a script named after the package, and on Windows the inferred name grew a second `.exe`, the original complaint in #1189. ✨ `pipx run black` and `pipx run ruff==0.5.0` still exec `uv tool run` unchanged. The runs that move to the venv path pay a first-run install in exchange for working at all, and `pipx run --spec <spec> <script>` keeps them on uv's cache. Focus review on the routing predicate in `run()`; it decides which spec shapes lose the uvx fast path.
1 parent d8b11f3 commit 35d86e4

5 files changed

Lines changed: 109 additions & 50 deletions

File tree

‎changelog.d/1189.bugfix.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Stop appending a second `.exe` on Windows to the app name `pipx run` infers.

‎changelog.d/1994.bugfix.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Run the console script a package declares when `pipx run` infers the app name from a VCS URL, a local path, or a name it
2+
normalized, instead of passing a guess to `uv tool run`. Reusing that venv no longer reinstalls the package on every
3+
run.

‎docs/how-to/use-uv-backend.rst‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,16 @@ all accept ``--backend``. Switch an installed venv with ``pipx reinstall NAME --
7373
************
7474

7575
- ``pipx install pip --backend uv`` errors: a uv venv has no pip. Use ``--backend pip``.
76-
- ``pipx run --backend uv`` does not honor ``[pipx.run]`` entry points; ``uv tool run`` sees only standard console
77-
scripts. Use ``--backend pip`` when your package declares them.
76+
- ``pipx run --backend uv`` does not honor ``[pipx.run]`` entry points when it hands the run to ``uv tool run``, which
77+
sees only standard console scripts. Use ``--backend pip`` when your package declares them.
7878
- Some ``--pip-args`` values have no ``uv tool run`` equivalent (``--editable``, ``--no-build-isolation``); pipx errors
7979
instead of dropping them. See :doc:`use-private-index` for which flags uv translates.
8080
- ``pipx run --backend uv`` against URL or named-pipe scripts falls back to a pipx-managed venv, because ``uv run
8181
--script`` reads PEP 723 metadata off disk and runtime-fetched content has no on-disk path. pipx logs a warning.
82+
- ``pipx run`` falls back to a pipx-managed venv when it has to infer the script name: from a VCS URL, a local project
83+
path, or a name it normalizes (``pipx run pip_search`` installs ``pip-search``). ``uv tool run`` takes that name up
84+
front and would guess it from the package, so pipx installs the package first and runs the script it declares. Pass
85+
``pipx run --spec <spec> <script>`` to keep uv's cache.
8286

8387
*************
8488
Cache layout

‎src/pipx/commands/run.py‎

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -254,11 +254,7 @@ def run_package( # ruff:ignore[too-many-arguments] # mirrors the flat `pipx ru
254254
)
255255
)
256256

257-
if WINDOWS:
258-
app_filename = f"{app}.exe"
259-
_LOGGER.info("Assuming app is %r (Windows only)", app_filename)
260-
else:
261-
app_filename = app
257+
app, app_filename = _app_names(app)
262258

263259
pypackage_bin_path = get_pypackage_bin_path(app)
264260
if pypackage_bin_path.exists():
@@ -288,9 +284,9 @@ def run_package( # ruff:ignore[too-many-arguments] # mirrors the flat `pipx ru
288284

289285
with _locked_venv_cache(venv_dir):
290286
venv = Venv(venv_dir, backend=backend, env_backend=env_backend)
291-
if infer_app_name and venv.pipx_metadata.main_package.package is not None:
292-
app = venv.pipx_metadata.main_package.package
293-
app_filename = f"{app}.exe" if WINDOWS else app
287+
if infer_app_name and (cached := venv.pipx_metadata.main_package).package is not None:
288+
# repeat ``_prepare_venv``'s sole-script fallback, else the cache check below misses and rebuilds the venv
289+
app, app_filename = _app_names(cached.apps[0] if len(cached.apps) == 1 else cached.package)
294290
bin_path = venv.bin_path / app_filename
295291
_prepare_venv_cache(venv, bin_path, use_cache=use_cache, refresh=refresh)
296292

@@ -331,6 +327,16 @@ def run_package( # ruff:ignore[too-many-arguments] # mirrors the flat `pipx ru
331327
venv.run_app(app, app_filename, app_args, python_args=python_args)
332328

333329

330+
def _app_names(app: str) -> tuple[str, str]:
331+
"""Return an app's entry-point name and the filename it has inside the venv."""
332+
if not WINDOWS:
333+
return app, app
334+
# apps recorded in the venv metadata carry the .exe stub suffix, so drop it before re-appending
335+
name = app[: -len(".exe")] if app.lower().endswith(".exe") else app
336+
_LOGGER.info("Assuming app is %r (Windows only)", app_filename := f"{name}.exe")
337+
return name, app_filename
338+
339+
334340
def run( # ruff:ignore[too-many-arguments, too-many-positional-arguments] # mirrors the flat `pipx run` CLI options across the script/uvx/venv paths
335341
app: str,
336342
spec: str | None,
@@ -357,12 +363,17 @@ def run( # ruff:ignore[too-many-arguments, too-many-positional-arguments] # mi
357363
package
358364
"""
359365

360-
package_name: Final[str] = _package_name_from_app(app, inferred=spec is None)
366+
requirement_name: Final[str] = _requirement_name(app)
367+
package_name: Final[str] = canonicalize_name(requirement_name) if spec is None else requirement_name
368+
# a VCS URL names no script at all, and a normalized name is no longer the one on disk (`pip_search` installs a
369+
# `pip_search` script but canonicalizes to `pip-search`), so pipx picks the app from the installed entry points
370+
infer_app_name: Final[bool] = spec is None and (_is_vcs_url(app) or package_name != requirement_name)
361371

362372
# ``resolved_backend`` only decides ROUTING (uv tool run vs Venv); cli/env
363373
# stay separate when we hand off so the Venv's source-attribution stays right.
364374
resolved_backend, _ = resolve_backend_name(cli_value=backend, env_value=env_backend)
365-
use_uvx = resolved_backend == UV and not pypackages and not python_args
375+
# `uv tool run` takes the script name up front and would guess an inferred one from the package name
376+
use_uvx = resolved_backend == UV and not pypackages and not python_args and not infer_app_name
366377

367378
content = None if spec is not None else maybe_script_content(app, is_path=is_path)
368379
if content is not None:
@@ -415,7 +426,7 @@ def run( # ruff:ignore[too-many-arguments, too-many-positional-arguments] # mi
415426
verbose=verbose,
416427
use_cache=use_cache,
417428
refresh=refresh,
418-
infer_app_name=spec is None and _is_vcs_url(app),
429+
infer_app_name=infer_app_name,
419430
backend=backend,
420431
env_backend=env_backend,
421432
resolved_backend=resolved_backend,
@@ -425,12 +436,11 @@ def run( # ruff:ignore[too-many-arguments, too-many-positional-arguments] # mi
425436
)
426437

427438

428-
def _package_name_from_app(app: str, *, inferred: bool) -> str:
439+
def _requirement_name(app: str) -> str:
429440
try:
430-
package_name = Requirement(app).name
441+
return Requirement(app).name
431442
except InvalidRequirement:
432443
return app
433-
return canonicalize_name(package_name) if inferred else package_name
434444

435445

436446
def _prepare_venv( # ruff:ignore[too-many-arguments] # mirrors the flat run/install option set for one temporary venv
@@ -464,8 +474,7 @@ def _prepare_venv( # ruff:ignore[too-many-arguments] # mirrors the flat run/in
464474
)
465475

466476
if infer_app_name:
467-
app = package_name
468-
app_filename = f"{app}.exe" if WINDOWS else app
477+
app, app_filename = _app_names(package_name)
469478

470479
override_shared = package_name == "pip"
471480

@@ -486,13 +495,8 @@ def _prepare_venv( # ruff:ignore[too-many-arguments] # mirrors the flat run/in
486495

487496
# If there's a single app inside the package, run that by default
488497
if app == package_name and len(apps) == 1:
489-
app = apps[0]
498+
app, app_filename = _app_names(apps[0])
490499
print(f"NOTE: running app {app!r} from {package_name!r}") # ruff:ignore[print] # user-facing CLI output
491-
if WINDOWS:
492-
app_filename = f"{app}.exe"
493-
_LOGGER.info("Assuming app is %r (Windows only)", app_filename)
494-
else:
495-
app_filename = app
496500
else:
497501
all_apps = (f"{a} - usage: 'pipx run --spec {package_or_url} {a} [arguments?]'" for a in apps)
498502
raise PipxError(

‎tests/test_run.py‎

Lines changed: 73 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ def test_run_normalizes_inferred_package_name(
7979
run_pipx_cli_exit(["run", "--backend", backend, "bLaCk", "--version"], assert_exit=0)
8080

8181

82+
@pytest.mark.usefixtures("pipx_temp_env")
83+
@mock.patch("os.execvpe", new=execvpe_mock)
84+
def test_run_normalized_name_skips_uv_tool_run(caplog: pytest.LogCaptureFixture) -> None:
85+
# `uv tool run PyCowSay` would look for a `PyCowSay` script; only the venv path knows what the package installed
86+
run_pipx_cli_exit(["run", "--backend", "uv", "PyCowSay", "cowsay", "hi"], assert_exit=0)
87+
88+
assert f"exec_app: {paths.ctx.venv_cache}" in caplog.text
89+
90+
8291
@pytest.mark.usefixtures("pipx_temp_env")
8392
def test_run_preserves_explicit_app_name(
8493
capsys: pytest.CaptureFixture[str],
@@ -978,41 +987,79 @@ def test_run_local_path_entry_point(
978987
assert "Using discovered entry point for 'pipx run'" in caplog.text
979988

980989

990+
@pytest.fixture
991+
def vcs_project(root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Callable[[str, str], str]:
992+
"""Apply one pyproject substitution to the empty project, commit it and return its git URL."""
993+
994+
def create(old: str, new: str) -> str:
995+
project = tmp_path / "vcs-project"
996+
shutil.copytree(root / "testdata" / "empty_project", project)
997+
pyproject = project / "pyproject.toml"
998+
pyproject.write_text(pyproject.read_text().replace(old, new))
999+
# uv clones through `git submodule`, a shell script needing the platform utilities the sandboxed PATH drops
1000+
monkeypatch.setenv("PATH", os.environ["PATH_ORIG"])
1001+
subprocess.run(["git", "init", "--quiet"], cwd=project, check=True) # ruff:ignore[start-process-with-partial-path] # git resolved from PATH in tests
1002+
subprocess.run(["git", "add", "."], cwd=project, check=True) # ruff:ignore[start-process-with-partial-path] # git resolved from PATH in tests
1003+
subprocess.run(
1004+
[ # ruff:ignore[start-process-with-partial-path] # git resolved from PATH in tests
1005+
"git",
1006+
"-c",
1007+
"user.name=pipx tests",
1008+
"-c",
1009+
"user.email=pipx@example.invalid",
1010+
"-c",
1011+
"commit.gpgsign=false",
1012+
"commit",
1013+
"--quiet",
1014+
"-m",
1015+
"test fixture",
1016+
],
1017+
cwd=project,
1018+
check=True,
1019+
)
1020+
return f"git+{project.as_uri()}"
1021+
1022+
return create
1023+
1024+
1025+
@pytest.mark.parametrize("backend", ["pip", "uv"])
9811026
@pytest.mark.usefixtures("pipx_temp_env")
9821027
@mock.patch("os.execvpe", new=execvpe_mock)
983-
def test_run_vcs_url_infers_app_name(root: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
984-
project = tmp_path / "empty-project-vcs"
985-
shutil.copytree(root / "testdata" / "empty_project", project)
986-
pyproject = project / "pyproject.toml"
987-
pyproject.write_text(pyproject.read_text().replace("empty_project.main:cli", "empty_project.main:main"))
988-
subprocess.run(["git", "init", "--quiet"], cwd=project, check=True) # ruff:ignore[start-process-with-partial-path] # git resolved from PATH in tests
989-
subprocess.run(["git", "add", "."], cwd=project, check=True) # ruff:ignore[start-process-with-partial-path] # git resolved from PATH in tests
990-
subprocess.run(
991-
[ # ruff:ignore[start-process-with-partial-path] # git resolved from PATH in tests
992-
"git",
993-
"-c",
994-
"user.name=pipx tests",
995-
"-c",
996-
"user.email=pipx@example.invalid",
997-
"-c",
998-
"commit.gpgsign=false",
999-
"commit",
1000-
"--quiet",
1001-
"-m",
1002-
"test fixture",
1003-
],
1004-
cwd=project,
1005-
check=True,
1006-
)
1007-
1008-
command = ["run", "--backend", "pip", f"git+{project.as_uri()}"]
1028+
def test_run_vcs_url_infers_app_name(
1029+
vcs_project: Callable[[str, str], str],
1030+
caplog: pytest.LogCaptureFixture,
1031+
backend: str,
1032+
) -> None:
1033+
command = ["run", "--backend", backend, vcs_project("empty_project.main:cli", "empty_project.main:main")]
10091034
run_pipx_cli_exit(command, assert_exit=0)
10101035
run_pipx_cli_exit(command, assert_exit=0)
10111036

10121037
assert caplog.text.count("Determined package name: empty-project") == 1
10131038
assert "Reusing cached venv" in caplog.text
10141039

10151040

1041+
@pytest.mark.parametrize("backend", ["pip", "uv"])
1042+
@pytest.mark.usefixtures("pipx_temp_env")
1043+
@mock.patch("os.execvpe", new=execvpe_mock)
1044+
def test_run_vcs_url_runs_sole_script_of_other_name(
1045+
vcs_project: Callable[[str, str], str],
1046+
caplog: pytest.LogCaptureFixture,
1047+
capsys: pytest.CaptureFixture[str],
1048+
backend: str,
1049+
) -> None:
1050+
# drop the pipx.run entry point as well: it matches on package name and would hide the console script lookup
1051+
declared = (
1052+
'scripts.empty-project = "empty_project.main:cli"\n'
1053+
'entry-points."pipx.run".empty-project = "empty_project.main:cli"'
1054+
)
1055+
command = ["run", "--backend", backend, vcs_project(declared, 'scripts.renamed = "empty_project.main:main"')]
1056+
run_pipx_cli_exit(command, assert_exit=0)
1057+
assert "NOTE: running app 'renamed' from 'empty-project'" in capsys.readouterr().out
1058+
1059+
run_pipx_cli_exit(command, assert_exit=0)
1060+
assert "Reusing cached venv" in caplog.text
1061+
1062+
10161063
@mock.patch("os.execvpe", new=execvpe_mock)
10171064
def test_run_with(capsys: pytest.CaptureFixture[str]) -> None:
10181065
run_pipx_cli_exit(["run", "--refresh", "--with", "black", "pycowsay", "--help"])

0 commit comments

Comments
 (0)