diff --git a/changelog.d/1607.feature.md b/changelog.d/1607.feature.md new file mode 100644 index 000000000..7a488e22e --- /dev/null +++ b/changelog.d/1607.feature.md @@ -0,0 +1 @@ +Add `--with` flag to `pipx run` to allow injecting dependencies diff --git a/src/pipx/commands/inject.py b/src/pipx/commands/inject.py index b315b59c5..49a4f64f9 100644 --- a/src/pipx/commands/inject.py +++ b/src/pipx/commands/inject.py @@ -110,7 +110,6 @@ def inject_dep( def inject( venv_dir: Path, - package_name: Optional[str], package_specs: Iterable[str], requirement_files: Iterable[str], pip_args: List[str], diff --git a/src/pipx/commands/install.py b/src/pipx/commands/install.py index dbf26ba28..1677d852b 100644 --- a/src/pipx/commands/install.py +++ b/src/pipx/commands/install.py @@ -220,7 +220,6 @@ def install_all( for inject_package in venv_metadata.injected_packages.values(): commands.inject( venv_dir=venv_dir, - package_name=None, package_specs=[generate_package_spec(inject_package)], requirement_files=[], pip_args=pip_args, diff --git a/src/pipx/commands/run.py b/src/pipx/commands/run.py index 996b44fd3..833f99727 100644 --- a/src/pipx/commands/run.py +++ b/src/pipx/commands/run.py @@ -8,12 +8,13 @@ import urllib.request from pathlib import Path from shutil import which -from typing import List, NoReturn, Optional, Union +from typing import List, NoReturn, Optional, Tuple, Union from packaging.requirements import InvalidRequirement, Requirement from pipx import paths from pipx.commands.common import package_name_from_spec +from pipx.commands.inject import inject_dep from pipx.constants import TEMP_VENV_EXPIRATION_THRESHOLD_DAYS, WINDOWS from pipx.emojis import hazard from pipx.util import ( @@ -43,8 +44,8 @@ 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. + """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) @@ -111,6 +112,7 @@ def run_script( def run_package( app: str, package_or_url: str, + dependencies: List[str], app_args: List[str], python: str, pip_args: List[str], @@ -157,15 +159,13 @@ def run_package( if venv.has_app(app, app_filename): logger.info(f"Reusing cached venv {venv_dir}") - venv.run_app(app, app_filename, app_args) else: logger.info(f"venv location is {venv_dir}") - _download_and_run( + venv, app, app_filename = _prepare_venv( Path(venv_dir), package_or_url, app, app_filename, - app_args, python, pip_args, venv_args, @@ -173,10 +173,24 @@ def run_package( verbose, ) + for dep in dependencies: + inject_dep( + venv_dir=venv_dir, + package_name=None, + package_spec=dep, + pip_args=pip_args, + verbose=verbose, + include_apps=False, + include_dependencies=False, + force=False, + ) + venv.run_app(app, app_filename, app_args) + def run( app: str, spec: str, + dependencies: List[str], is_path: bool, app_args: List[str], python: str, @@ -206,6 +220,7 @@ def run( run_package( package_name, package_or_url, + dependencies, app_args, python, pip_args, @@ -216,18 +231,17 @@ def run( ) -def _download_and_run( +def _prepare_venv( venv_dir: Path, package_or_url: str, app: str, app_filename: str, - app_args: List[str], python: str, pip_args: List[str], venv_args: List[str], use_cache: bool, verbose: bool, -) -> NoReturn: +) -> Tuple[Venv, str, str]: venv = Venv(venv_dir, python=python, verbose=verbose) venv.check_upgrade_shared_libs(pip_args=pip_args, verbose=verbose) @@ -275,7 +289,7 @@ def _download_and_run( # Let future _remove_all_expired_venvs know to remove this (venv_dir / VENV_EXPIRED_FILENAME).touch() - venv.run_app(app, app_filename, app_args) + return venv, app, app_filename def _get_temporary_venv_path(requirements: List[str], python: str, pip_args: List[str], venv_args: List[str]) -> Path: diff --git a/src/pipx/main.py b/src/pipx/main.py index f1b9569cc..ccd1802e8 100644 --- a/src/pipx/main.py +++ b/src/pipx/main.py @@ -267,6 +267,7 @@ def run_pipx_command(args: argparse.Namespace, subparsers: Dict[str, argparse.Ar commands.run( args.app_with_args[0], args.spec, + args.with_, args.path, args.app_with_args[1:], args.python, @@ -310,7 +311,6 @@ def run_pipx_command(args: argparse.Namespace, subparsers: Dict[str, argparse.Ar elif args.command == "inject": return commands.inject( venv_dir, - None, args.dependencies, args.requirements, pip_args, @@ -846,6 +846,13 @@ def _add_run(subparsers: argparse._SubParsersAction, shared_parser: argparse.Arg action="store_true", help="Require app to be run from local __pypackages__ directory", ) + p.add_argument( + "--with", + dest="with_", + action="append", + default=[], + help="Extra dependencies to add to the temporary environment", + ) p.add_argument("--spec", help=SPEC_HELP) add_python_options(p) add_pip_venv_args(p) diff --git a/tests/test_run.py b/tests/test_run.py index bd21fc31f..8057a0e98 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -443,3 +443,24 @@ def test_run_local_path_entry_point(pipx_temp_env, caplog, root): run_pipx_cli_exit(["run", empty_project_path]) assert "Using discovered entry point for 'pipx run'" in caplog.text + + +@mock.patch("os.execvpe", new=execvpe_mock) +def test_run_with(capsys): + run_pipx_cli_exit(["run", "--with", "black", "pycowsay", "--help"]) + captured = capsys.readouterr() + assert "injected package black into venv pycowsay" in captured.out + + +@mock.patch("os.execvpe", new=execvpe_mock) +def test_run_with_cache(capsys, caplog): + # Maybe there's a better way to remove the previous venv cache? + run_pipx_cli_exit(["run", "--no-cache", "pycowsay", "cowsay", "args"]) + run_pipx_cli_exit(["run", "pycowsay", "cowsay", "args"], assert_exit=0) + + caplog.set_level(logging.DEBUG) + caplog.clear() + run_pipx_cli_exit(["run", "--verbose", "--with", "black", "pycowsay", "args"], assert_exit=0) + captured = capsys.readouterr() + assert "Reusing cached venv" in caplog.text + assert "injected package black into venv pycowsay" in captured.out