Skip to content

Commit be74d6e

Browse files
committed
Add support for --suffix option
Optional suffix can be used to install multiple versions of the same tool side-by-side - e.g. ``` $ pipx install --suffix=_3.1 foo==3.1.2 --> foo_3.1 $ pipx install --suffix=_2.2 foo==2.2.0 --> foo_2.2 ```
1 parent f948ea9 commit be74d6e

File tree

7 files changed

+88
-15
lines changed

7 files changed

+88
-15
lines changed

docs/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ dev
33
- [bugfix] For `pipx install`, fixed failure to install if user has `PIP_USER=1` or `user=true` in pip.conf. (#110)
44
- [bugfix] Requiring userpath v1.4.1 or later so ensure Windows bug is fixed for `ensurepath` (#437)
55
- [feature] log pipx version (#423)
6+
- [feature] `--suffix` option for `install` to allow multiple versions of same tool to be installed
67

78
0.15.4.0
89
- [feature] `list` now has a new option `--include-injected` to show the injected packages in the main apps

docs/docs.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,9 @@ subcommands:
6363
```
6464
pipx install --help
6565
usage: pipx install [-h] [--include-deps] [--verbose] [--force]
66-
[--python PYTHON] [--system-site-packages]
67-
[--index-url INDEX_URL] [--editable] [--pip-args PIP_ARGS]
66+
[--suffix SUFFIX] [--python PYTHON]
67+
[--system-site-packages] [--index-url INDEX_URL]
68+
[--editable] [--pip-args PIP_ARGS]
6869
package_spec
6970
7071
The install command is the preferred way to globally install apps
@@ -101,6 +102,8 @@ optional arguments:
101102
--verbose
102103
--force, -f Modify existing virtual environment and files in
103104
PIPX_BIN_DIR
105+
--suffix SUFFIX Optional suffix for virtual environment and executable
106+
names
104107
--python PYTHON The Python executable used to create the Virtual
105108
Environment and run the associated app/apps. Must be
106109
v3.5+.

src/pipx/commands/common.py

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,19 @@
1919

2020

2121
def expose_apps_globally(
22-
local_bin_dir: Path, app_paths: List[Path], package: str, *, force: bool
23-
):
22+
local_bin_dir: Path,
23+
app_paths: List[Path],
24+
package: str,
25+
*,
26+
force: bool,
27+
suffix: str = "",
28+
) -> None:
2429
if not _can_symlink(local_bin_dir):
25-
_copy_package_apps(local_bin_dir, app_paths, package)
30+
_copy_package_apps(local_bin_dir, app_paths, package, suffix=suffix)
2631
else:
27-
_symlink_package_apps(local_bin_dir, app_paths, package, force=force)
32+
_symlink_package_apps(
33+
local_bin_dir, app_paths, package, force=force, suffix=suffix
34+
)
2835

2936

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

5360

54-
def _copy_package_apps(local_bin_dir: Path, app_paths: List[Path], package: str):
61+
def _copy_package_apps(
62+
local_bin_dir: Path, app_paths: List[Path], package: str, suffix: str = "",
63+
) -> None:
5564
for src_unresolved in app_paths:
5665
src = src_unresolved.resolve()
5766
app = src.name
58-
dest = Path(local_bin_dir / app)
67+
dest = Path(local_bin_dir / add_suffix(app, suffix))
5968
if not dest.parent.is_dir():
6069
mkdir(dest.parent)
6170
if dest.exists():
@@ -66,11 +75,16 @@ def _copy_package_apps(local_bin_dir: Path, app_paths: List[Path], package: str)
6675

6776

6877
def _symlink_package_apps(
69-
local_bin_dir: Path, app_paths: List[Path], package: str, *, force: bool
70-
):
78+
local_bin_dir: Path,
79+
app_paths: List[Path],
80+
package: str,
81+
*,
82+
force: bool,
83+
suffix: str = "",
84+
) -> None:
7185
for app_path in app_paths:
7286
app_name = app_path.name
73-
symlink_path = Path(local_bin_dir / app_name)
87+
symlink_path = Path(local_bin_dir / add_suffix(app_name, suffix))
7488
if not symlink_path.parent.is_dir():
7589
mkdir(symlink_path.parent)
7690

@@ -117,6 +131,7 @@ def get_package_summary(
117131
package: str = None,
118132
new_install: bool = False,
119133
include_injected: bool = False,
134+
suffix: str = "",
120135
) -> str:
121136
venv = Venv(path)
122137
python_path = venv.python_path.resolve()
@@ -143,7 +158,8 @@ def get_package_summary(
143158
)
144159
exposed_binary_names = sorted(p.name for p in exposed_app_paths)
145160
unavailable_binary_names = sorted(
146-
set(package_metadata.apps) - set(exposed_binary_names)
161+
set(add_suffix(name, suffix) for name in package_metadata.apps)
162+
- set(exposed_binary_names)
147163
)
148164
# The following is to satisfy mypy that python_version is str and not
149165
# Optional[str]
@@ -252,6 +268,7 @@ def run_post_install_actions(
252268
include_dependencies: bool,
253269
*,
254270
force: bool,
271+
suffix: Optional[str] = None,
255272
):
256273
package_metadata = venv.package_metadata[package]
257274

@@ -298,15 +315,21 @@ def run_post_install_actions(
298315
"Consider using pip or a similar tool instead."
299316
)
300317

318+
if suffix is None:
319+
suffix = ""
301320
expose_apps_globally(
302-
local_bin_dir, package_metadata.app_paths, package, force=force
321+
local_bin_dir, package_metadata.app_paths, package, force=force, suffix=suffix
303322
)
304323

305324
if include_dependencies:
306325
for _, app_paths in package_metadata.app_paths_of_dependencies.items():
307-
expose_apps_globally(local_bin_dir, app_paths, package, force=force)
326+
expose_apps_globally(
327+
local_bin_dir, app_paths, package, force=force, suffix=suffix
328+
)
308329

309-
print(get_package_summary(venv_dir, package=package, new_install=True))
330+
print(
331+
get_package_summary(venv_dir, package=package, new_install=True, suffix=suffix)
332+
)
310333
warn_if_not_on_path(local_bin_dir)
311334
print(f"done! {stars}", file=sys.stderr)
312335

@@ -320,3 +343,10 @@ def warn_if_not_on_path(local_bin_dir: Path):
320343
"automatically add it, or manually modify your PATH in your shell's "
321344
"config file (i.e. ~/.bashrc)."
322345
)
346+
347+
348+
def add_suffix(name: str, suffix: str) -> str:
349+
"""Add suffix to app."""
350+
351+
app = Path(name)
352+
return f"{app.stem}{suffix}{app.suffix}"

src/pipx/commands/install.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def install(
1818
*,
1919
force: bool,
2020
include_dependencies: bool,
21+
suffix: Optional[str] = None,
2122
):
2223
# package_spec is anything pip-installable, including package_name, vcs spec,
2324
# zip file, or tar.gz file.
@@ -28,6 +29,8 @@ def install(
2829
)
2930
venv_container = VenvContainer(constants.PIPX_LOCAL_VENVS)
3031
venv_dir = venv_container.get_venv_dir(package_name)
32+
if suffix is not None:
33+
venv_dir = venv_dir.parent / f"{venv_dir.stem}{suffix}"
3134

3235
try:
3336
exists = venv_dir.exists() and next(venv_dir.iterdir())
@@ -63,6 +66,7 @@ def install(
6366
venv_dir,
6467
include_dependencies,
6568
force=force,
69+
suffix=suffix,
6670
)
6771
except (Exception, KeyboardInterrupt):
6872
print("")

src/pipx/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ def run_pipx_command(args: argparse.Namespace): # noqa: C901
159159
verbose,
160160
force=args.force,
161161
include_dependencies=args.include_deps,
162+
suffix=args.suffix,
162163
)
163164
elif args.command == "inject":
164165
if not args.include_apps and args.include_deps:
@@ -261,6 +262,9 @@ def _add_install(subparsers):
261262
action="store_true",
262263
help="Modify existing virtual environment and files in PIPX_BIN_DIR",
263264
)
265+
p.add_argument(
266+
"--suffix", help="Optional suffix for virtual environment and executable names"
267+
)
264268
p.add_argument(
265269
"--python",
266270
default=constants.DEFAULT_PYTHON,

tests/test_install.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,22 @@ def test_pip_args_forwarded_to_package_name_determination(
167167
)
168168
captured = capsys.readouterr()
169169
assert "Cannot determine package name from spec" in captured.err
170+
171+
172+
def test_install_suffix(pipx_temp_env, capsys):
173+
name = "pbr"
174+
175+
suffix = "_a"
176+
assert not run_pipx_cli(["install", "pbr", f"--suffix={suffix}"])
177+
captured = capsys.readouterr()
178+
name_a = f"{name}{suffix}{'.exe' if constants.WINDOWS else ''}"
179+
assert f"- {name_a}" in captured.out
180+
181+
suffix = "_b"
182+
assert not run_pipx_cli(["install", "pbr", f"--suffix={suffix}"])
183+
captured = capsys.readouterr()
184+
name_b = f"{name}{suffix}{'.exe' if constants.WINDOWS else ''}"
185+
assert f"- {name_b}" in captured.out
186+
187+
assert (constants.LOCAL_BIN_DIR / name_a).exists()
188+
assert (constants.LOCAL_BIN_DIR / name_b).exists()

tests/test_uninstall.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ def test_uninstall(pipx_temp_env, capsys):
88
assert not run_pipx_cli(["uninstall", "pycowsay"])
99

1010

11+
def test_uninstall_suffix(pipx_temp_env, capsys):
12+
name = "pbr"
13+
suffix = "_a"
14+
executable = f"{name}{suffix}{'.exe' if constants.WINDOWS else ''}"
15+
16+
assert not run_pipx_cli(["install", "pbr", f"--suffix={suffix}"])
17+
assert (constants.LOCAL_BIN_DIR / executable).exists()
18+
19+
assert not run_pipx_cli(["uninstall", f"{name}{suffix}"])
20+
assert not (constants.LOCAL_BIN_DIR / executable).exists()
21+
22+
1123
def test_uninstall_with_missing_interpreter(pipx_temp_env, capsys):
1224
assert not run_pipx_cli(["install", "pycowsay"])
1325

0 commit comments

Comments
 (0)