Skip to content
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

Add "install-all" command to install packages according to spec metadata file #1301

Merged
merged 11 commits into from
Apr 3, 2024
1 change: 1 addition & 0 deletions changelog.d/687.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `install-all` command to install packages according to spec metadata file.
21 changes: 21 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,24 @@ binaries are exposed on your $PATH at /Users/user/.local/bin
black 18.9b0
pipx 0.10.0
```

## `pipx install-all` example

```shell
> pipx list --json > pipx.json
> pipx instal-all pipx.json
'black' already seems to be installed. Not modifying existing installation in '/usr/local/pipx/venvs/black'. Pass '--force' to force installation.
'pipx' already seems to be installed. Not modifying existing installation in '/usr/local/pipx/venvs/black'. Pass '--force' to force installation.
> pipx install-all pipx.json --force
Installing to existing venv 'black'
installed package black 24.3.0, installed using Python 3.10.12
These apps are now globally available
- black
- blackd
done! ✨ 🌟 ✨
Installing to existing venv 'pipx'
installed package pipx 1.4.3, installed using Python 3.10.12
These apps are now globally available
- pipx
done! ✨ 🌟 ✨
```
3 changes: 2 additions & 1 deletion src/pipx/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from pipx.commands.ensure_path import ensure_pipx_paths
from pipx.commands.environment import environment
from pipx.commands.inject import inject
from pipx.commands.install import install
from pipx.commands.install import install, install_all
from pipx.commands.interpreter import list_interpreters, prune_interpreters
from pipx.commands.list_packages import list_packages
from pipx.commands.reinstall import reinstall, reinstall_all
Expand All @@ -16,6 +16,7 @@
"upgrade_all",
"run",
"install",
"install_all",
"inject",
"uninject",
"uninstall",
Expand Down
113 changes: 110 additions & 3 deletions src/pipx/commands/install.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import json
from pathlib import Path
from typing import List, Optional
from typing import Iterator, List, Optional

from pipx import paths
from pipx import commands, paths
from pipx.commands.common import package_name_from_spec, run_post_install_actions
from pipx.constants import (
EXIT_CODE_INSTALL_VENV_EXISTS,
EXIT_CODE_OK,
ExitCode,
)
from pipx.interpreter import DEFAULT_PYTHON
from pipx.util import pipx_wrap
from pipx.pipx_metadata_file import PackageInfo, PipxMetadata, _json_decoder_object_hook
from pipx.util import PipxError, pipx_wrap
from pipx.venv import Venv, VenvContainer


Expand Down Expand Up @@ -117,3 +119,108 @@ def install(

# Any failure to install will raise PipxError, otherwise success
return EXIT_CODE_OK


def extract_venv_metadata(spec_metadata_file: Path) -> Iterator[PipxMetadata]:
"""Extract venv metadata from spec metadata file."""
with open(spec_metadata_file) as spec_metadata_fh:
try:
spec_metadata_dict = json.load(spec_metadata_fh, object_hook=_json_decoder_object_hook)
except json.decoder.JSONDecodeError as exc:
raise PipxError("The spec metadata file is an invalid JSON file.") from exc

if not ("venvs" in spec_metadata_dict and len(spec_metadata_dict["venvs"])):
raise PipxError("No packages found in the spec metadata file.")

venvs_metadata_dict = spec_metadata_dict["venvs"]

if not isinstance(venvs_metadata_dict, dict):
raise PipxError("The spec metadata file is invalid.")

for package_path_name in venvs_metadata_dict:
venv_dir = paths.ctx.venvs.joinpath(package_path_name)
venv_metadata = PipxMetadata(venv_dir, read=False)
venv_metadata.from_dict(venvs_metadata_dict[package_path_name]["metadata"])
yield venv_metadata


def generate_package_spec(package_info: PackageInfo) -> str:
"""Generate more precise package spec from package info."""
if not package_info.package_or_url:
raise PipxError(f"A package spec is not available for {package_info.package}")

if package_info.package == package_info.package_or_url:
return f"{package_info.package}=={package_info.package_version}"
return package_info.package_or_url


def get_python_interpreter(
source_interpreter: Optional[Path],
) -> Optional[str]:
"""Get appropriate python interpreter."""
if source_interpreter is not None and source_interpreter.is_file():
return str(source_interpreter)

print(
pipx_wrap(
f"""
The exported python interpreter '{source_interpreter}' is ignored
as not found.
"""
)
)

return None


def install_all(
spec_metadata_file: Path,
local_bin_dir: Path,
local_man_dir: Path,
python: Optional[str],
pip_args: List[str],
venv_args: List[str],
verbose: bool,
*,
force: bool,
) -> ExitCode:
"""Return pipx exit code."""
venv_container = VenvContainer(paths.ctx.venvs)

for venv_metadata in extract_venv_metadata(spec_metadata_file):
# Install the main package
main_package = venv_metadata.main_package
venv_dir = venv_container.get_venv_dir(f"{main_package.package}{main_package.suffix}")
install(
venv_dir,
None,
[generate_package_spec(main_package)],
local_bin_dir,
local_man_dir,
python or get_python_interpreter(venv_metadata.source_interpreter),
pip_args,
venv_args,
verbose,
force=force,
reinstall=False,
chrysle marked this conversation as resolved.
Show resolved Hide resolved
include_dependencies=main_package.include_dependencies,
preinstall_packages=[],
suffix=main_package.suffix,
)

# Install the injected packages
for inject_package in venv_metadata.injected_packages.values():
commands.inject(
venv_dir,
None,
[generate_package_spec(inject_package)],
pip_args,
verbose=verbose,
include_apps=inject_package.include_apps,
include_dependencies=inject_package.include_dependencies,
force=force,
suffix=inject_package.suffix == main_package.suffix,
)

# Any failure to install will raise PipxError, otherwise success
return EXIT_CODE_OK
31 changes: 31 additions & 0 deletions src/pipx/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,17 @@ def run_pipx_command(args: argparse.Namespace, subparsers: Dict[str, argparse.Ar
suffix=args.suffix,
python_flag_passed=python_flag_passed,
)
elif args.command == "install-all":
return commands.install_all(
args.spec_metadata_file,
paths.ctx.bin_dir,
paths.ctx.man_dir,
args.python,
pip_args,
venv_args,
verbose,
force=args.force,
)
elif args.command == "inject":
return commands.inject(
venv_dir,
Expand Down Expand Up @@ -434,6 +445,25 @@ def _add_install(subparsers: argparse._SubParsersAction, shared_parser: argparse
add_pip_venv_args(p)


def _add_install_all(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None:
p = subparsers.add_parser(
"install-all",
help="Install all packages",
formatter_class=LineWrapRawTextHelpFormatter,
description="Installs all the packages according to spec metadata file.",
parents=[shared_parser],
)
p.add_argument("spec_metadata_file", help="Spec metadata file generated from pipx list --json")
p.add_argument(
"--force",
"-f",
action="store_true",
help="Modify existing virtual environment and files in PIPX_BIN_DIR and PIPX_MAN_DIR",
)
add_python_options(p)
add_pip_venv_args(p)


def _add_inject(subparsers, venv_completer: VenvCompleter, shared_parser: argparse.ArgumentParser) -> None:
p = subparsers.add_parser(
"inject",
Expand Down Expand Up @@ -803,6 +833,7 @@ def get_command_parser() -> Tuple[argparse.ArgumentParser, Dict[str, argparse.Ar

subparsers_with_subcommands = {}
_add_install(subparsers, shared_parser)
_add_install_all(subparsers, shared_parser)
_add_uninject(subparsers, completer_venvs.use, shared_parser)
_add_inject(subparsers, completer_venvs.use, shared_parser)
_add_upgrade(subparsers, completer_venvs.use, shared_parser)
Expand Down