Skip to content

Add support for --suffix option #445

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 14, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ dev
- [bugfix] For `pipx install`, fixed failure to install if user has `PIP_USER=1` or `user=true` in pip.conf. (#110)
- [bugfix] Requiring userpath v1.4.1 or later so ensure Windows bug is fixed for `ensurepath` (#437)
- [feature] log pipx version (#423)
- [feature] `--suffix` option for `install` to allow multiple versions of same tool to be installed (#445)

0.15.4.0
- [feature] `list` now has a new option `--include-injected` to show the injected packages in the main apps
Expand Down
7 changes: 5 additions & 2 deletions docs/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ subcommands:
```
pipx install --help
usage: pipx install [-h] [--include-deps] [--verbose] [--force]
[--python PYTHON] [--system-site-packages]
[--index-url INDEX_URL] [--editable] [--pip-args PIP_ARGS]
[--suffix SUFFIX] [--python PYTHON]
[--system-site-packages] [--index-url INDEX_URL]
[--editable] [--pip-args PIP_ARGS]
package_spec

The install command is the preferred way to globally install apps
Expand Down Expand Up @@ -101,6 +102,8 @@ optional arguments:
--verbose
--force, -f Modify existing virtual environment and files in
PIPX_BIN_DIR
--suffix SUFFIX Optional suffix for virtual environment and executable
names
--python PYTHON The Python executable used to create the Virtual
Environment and run the associated app/apps. Must be
v3.5+.
Expand Down
56 changes: 43 additions & 13 deletions src/pipx/commands/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,19 @@


def expose_apps_globally(
local_bin_dir: Path, app_paths: List[Path], package: str, *, force: bool
):
local_bin_dir: Path,
app_paths: List[Path],
package: str,
*,
force: bool,
suffix: str = "",
) -> None:
if not _can_symlink(local_bin_dir):
_copy_package_apps(local_bin_dir, app_paths, package)
_copy_package_apps(local_bin_dir, app_paths, package, suffix=suffix)
else:
_symlink_package_apps(local_bin_dir, app_paths, package, force=force)
_symlink_package_apps(
local_bin_dir, app_paths, package, force=force, suffix=suffix
)


_can_symlink_cache: Dict[Path, bool] = {}
Expand All @@ -51,11 +58,13 @@ def _can_symlink(local_bin_dir: Path) -> bool:
return _can_symlink_cache[local_bin_dir]


def _copy_package_apps(local_bin_dir: Path, app_paths: List[Path], package: str):
def _copy_package_apps(
local_bin_dir: Path, app_paths: List[Path], package: str, suffix: str = "",
) -> None:
for src_unresolved in app_paths:
src = src_unresolved.resolve()
app = src.name
dest = Path(local_bin_dir / app)
dest = Path(local_bin_dir / add_suffix(app, suffix))
if not dest.parent.is_dir():
mkdir(dest.parent)
if dest.exists():
Expand All @@ -66,11 +75,16 @@ def _copy_package_apps(local_bin_dir: Path, app_paths: List[Path], package: str)


def _symlink_package_apps(
local_bin_dir: Path, app_paths: List[Path], package: str, *, force: bool
):
local_bin_dir: Path,
app_paths: List[Path],
package: str,
*,
force: bool,
suffix: str = "",
) -> None:
for app_path in app_paths:
app_name = app_path.name
symlink_path = Path(local_bin_dir / app_name)
symlink_path = Path(local_bin_dir / add_suffix(app_name, suffix))
if not symlink_path.parent.is_dir():
mkdir(symlink_path.parent)

Expand Down Expand Up @@ -117,6 +131,7 @@ def get_package_summary(
package: str = None,
new_install: bool = False,
include_injected: bool = False,
suffix: str = "",
) -> str:
venv = Venv(path)
python_path = venv.python_path.resolve()
Expand All @@ -143,7 +158,8 @@ def get_package_summary(
)
exposed_binary_names = sorted(p.name for p in exposed_app_paths)
unavailable_binary_names = sorted(
set(package_metadata.apps) - set(exposed_binary_names)
set(add_suffix(name, suffix) for name in package_metadata.apps)
- set(exposed_binary_names)
)
# The following is to satisfy mypy that python_version is str and not
# Optional[str]
Expand Down Expand Up @@ -252,6 +268,7 @@ def run_post_install_actions(
include_dependencies: bool,
*,
force: bool,
suffix: Optional[str] = None,
):
package_metadata = venv.package_metadata[package]

Expand Down Expand Up @@ -298,15 +315,21 @@ def run_post_install_actions(
"Consider using pip or a similar tool instead."
)

if suffix is None:
suffix = ""
expose_apps_globally(
local_bin_dir, package_metadata.app_paths, package, force=force
local_bin_dir, package_metadata.app_paths, package, force=force, suffix=suffix
)

if include_dependencies:
for _, app_paths in package_metadata.app_paths_of_dependencies.items():
expose_apps_globally(local_bin_dir, app_paths, package, force=force)
expose_apps_globally(
local_bin_dir, app_paths, package, force=force, suffix=suffix
)

print(get_package_summary(venv_dir, package=package, new_install=True))
print(
get_package_summary(venv_dir, package=package, new_install=True, suffix=suffix)
)
warn_if_not_on_path(local_bin_dir)
print(f"done! {stars}", file=sys.stderr)

Expand All @@ -320,3 +343,10 @@ def warn_if_not_on_path(local_bin_dir: Path):
"automatically add it, or manually modify your PATH in your shell's "
"config file (i.e. ~/.bashrc)."
)


def add_suffix(name: str, suffix: str) -> str:
"""Add suffix to app."""

app = Path(name)
return f"{app.stem}{suffix}{app.suffix}"
4 changes: 4 additions & 0 deletions src/pipx/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def install(
*,
force: bool,
include_dependencies: bool,
suffix: Optional[str] = None,
):
# package_spec is anything pip-installable, including package_name, vcs spec,
# zip file, or tar.gz file.
Expand All @@ -28,6 +29,8 @@ def install(
)
venv_container = VenvContainer(constants.PIPX_LOCAL_VENVS)
venv_dir = venv_container.get_venv_dir(package_name)
if suffix is not None:
venv_dir = venv_dir.parent / f"{venv_dir.stem}{suffix}"

try:
exists = venv_dir.exists() and next(venv_dir.iterdir())
Expand Down Expand Up @@ -63,6 +66,7 @@ def install(
venv_dir,
include_dependencies,
force=force,
suffix=suffix,
)
except (Exception, KeyboardInterrupt):
print("")
Expand Down
4 changes: 4 additions & 0 deletions src/pipx/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ def run_pipx_command(args: argparse.Namespace): # noqa: C901
verbose,
force=args.force,
include_dependencies=args.include_deps,
suffix=args.suffix,
)
elif args.command == "inject":
if not args.include_apps and args.include_deps:
Expand Down Expand Up @@ -261,6 +262,9 @@ def _add_install(subparsers):
action="store_true",
help="Modify existing virtual environment and files in PIPX_BIN_DIR",
)
p.add_argument(
"--suffix", help="Optional suffix for virtual environment and executable names"
)
p.add_argument(
"--python",
default=constants.DEFAULT_PYTHON,
Expand Down
19 changes: 19 additions & 0 deletions tests/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,22 @@ def test_pip_args_forwarded_to_package_name_determination(
)
captured = capsys.readouterr()
assert "Cannot determine package name from spec" in captured.err


def test_install_suffix(pipx_temp_env, capsys):
name = "pbr"

suffix = "_a"
assert not run_pipx_cli(["install", "pbr", f"--suffix={suffix}"])
captured = capsys.readouterr()
name_a = f"{name}{suffix}{'.exe' if constants.WINDOWS else ''}"
assert f"- {name_a}" in captured.out

suffix = "_b"
assert not run_pipx_cli(["install", "pbr", f"--suffix={suffix}"])
captured = capsys.readouterr()
name_b = f"{name}{suffix}{'.exe' if constants.WINDOWS else ''}"
assert f"- {name_b}" in captured.out

assert (constants.LOCAL_BIN_DIR / name_a).exists()
assert (constants.LOCAL_BIN_DIR / name_b).exists()
12 changes: 12 additions & 0 deletions tests/test_uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ def test_uninstall(pipx_temp_env, capsys):
assert not run_pipx_cli(["uninstall", "pycowsay"])


def test_uninstall_suffix(pipx_temp_env, capsys):
name = "pbr"
suffix = "_a"
executable = f"{name}{suffix}{'.exe' if constants.WINDOWS else ''}"

assert not run_pipx_cli(["install", "pbr", f"--suffix={suffix}"])
assert (constants.LOCAL_BIN_DIR / executable).exists()

assert not run_pipx_cli(["uninstall", f"{name}{suffix}"])
assert not (constants.LOCAL_BIN_DIR / executable).exists()


def test_uninstall_with_missing_interpreter(pipx_temp_env, capsys):
assert not run_pipx_cli(["install", "pycowsay"])

Expand Down