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

Introduce pipx pin and pipx unpin commands #1291

Merged
merged 28 commits into from
May 24, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f2b7117
Initial version
dukecat0 Mar 16, 2024
341dd5e
Update tests
dukecat0 Mar 17, 2024
3915d2e
Add changelog entry
dukecat0 Mar 17, 2024
5fed3f8
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 17, 2024
3bde155
Update tests
dukecat0 Mar 17, 2024
fb48ac9
Add `--pinned` option to `list` command
dukecat0 Mar 17, 2024
3a7eb72
Update changelog
dukecat0 Mar 17, 2024
168cf80
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 17, 2024
0f98f38
Apply suggestions from code review
dukecat0 Mar 18, 2024
94e27b2
Merge branch 'main' into pin
dukecat0 Mar 18, 2024
0dafcd0
Update tests
dukecat0 Mar 18, 2024
a75fcb5
Merge branch 'main' into pin
dukecat0 Mar 18, 2024
3de6788
Merge branch 'pypa:main' into pin
dukecat0 Mar 30, 2024
5184abd
Add `--injected-packages-only` option
dukecat0 Mar 30, 2024
dd8855a
Update tests
dukecat0 Apr 13, 2024
83db9b1
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 13, 2024
8fc5212
Update test_unpin.py
dukecat0 Apr 14, 2024
57bfb03
Update logic
dukecat0 Apr 19, 2024
ea2e280
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 19, 2024
3c1fda4
Update tests
dukecat0 Apr 19, 2024
0e2b811
Allow `--pinned` and `--include-injected` to be passed at the same time
dukecat0 Apr 20, 2024
25e2238
Update tests
dukecat0 May 21, 2024
57758e2
Merge branch 'main' into pin
dukecat0 May 21, 2024
7eefa8a
Handle metadata correctly
dukecat0 May 21, 2024
c121e16
Update tests
dukecat0 May 21, 2024
83bee4c
Apply suggestions from code review
dukecat0 May 21, 2024
6988b90
Apply suggestions from code review
dukecat0 May 22, 2024
054e069
Merge branch 'main' into pin
dukecat0 May 22, 2024
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
4 changes: 4 additions & 0 deletions changelog.d/891.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Introduce `pipx pin` and `pipx unpin` commands, which can be used to pin or unpin the version
of an installed package, so it will not be upgraded by `pipx upgrade` or `pipx upgrade-all`.

In addition, a new option `--pinned` is added to `pipx list` command for listing pinned packages only.
3 changes: 3 additions & 0 deletions src/pipx/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pipx.commands.install import install
from pipx.commands.interpreter import list_interpreters, prune_interpreters
from pipx.commands.list_packages import list_packages
from pipx.commands.pin import pin, unpin
from pipx.commands.reinstall import reinstall, reinstall_all
from pipx.commands.run import run
from pipx.commands.run_pip import run_pip
Expand All @@ -28,4 +29,6 @@
"environment",
"list_interpreters",
"prune_interpreters",
"pin",
"unpin",
]
24 changes: 24 additions & 0 deletions src/pipx/commands/list_packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,33 @@ def list_json(venv_dirs: Collection[Path]) -> VenvProblems:
return all_venv_problems


def list_pinned(venv_dirs: Collection[Path], include_injected: bool) -> VenvProblems:
all_venv_problems = VenvProblems()
for venv_dir in venv_dirs:
venv_metadata, venv_problems, warning_str = get_venv_metadata_summary(venv_dir)
if venv_problems.any_():
logger.warning(warning_str)
else:
if venv_metadata.main_package.pinned:
print(
venv_metadata.main_package.package,
venv_metadata.main_package.package_version,
)
if include_injected:
for pkg, info in venv_metadata.injected_packages.items():
if info.pinned:
print(pkg, info.package_version, f"(injected in venv {venv_dir.name})")
all_venv_problems.or_(venv_problems)

return all_venv_problems


def list_packages(
venv_container: VenvContainer,
include_injected: bool,
json_format: bool,
short_format: bool,
pinned_only: bool,
) -> ExitCode:
"""Returns pipx exit code."""
venv_dirs: Collection[Path] = sorted(venv_container.iter_venv_dirs())
Expand All @@ -99,6 +121,8 @@ def list_packages(
all_venv_problems = list_json(venv_dirs)
elif short_format:
all_venv_problems = list_short(venv_dirs)
elif pinned_only:
all_venv_problems = list_pinned(venv_dirs, include_injected)
else:
if not venv_dirs:
return EXIT_CODE_OK
Expand Down
97 changes: 97 additions & 0 deletions src/pipx/commands/pin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import logging
from pathlib import Path
from typing import Sequence

from pipx.colors import bold
from pipx.constants import ExitCode
from pipx.emojis import sleep
from pipx.util import PipxError
from pipx.venv import Venv

logger = logging.getLogger(__name__)


def _update_pin_info(venv: Venv, package_name: str, is_main_package: bool, unpin: bool) -> int:
package_metadata = venv.package_metadata[package_name]
venv.update_package_metadata(
package_name=str(package_metadata.package),
package_or_url=str(package_metadata.package_or_url),
pip_args=package_metadata.pip_args,
include_dependencies=package_metadata.include_dependencies,
include_apps=package_metadata.include_apps,
is_main_package=is_main_package,
suffix=package_metadata.suffix,
pinned=not unpin,
)
return 1


def pin(
venv_dir: Path,
verbose: bool,
skip: Sequence[str],
injected_only: bool = False,
) -> ExitCode:
venv = Venv(venv_dir, verbose=verbose)
try:
main_package_metadata = venv.package_metadata[venv.main_package_name]
except KeyError as e:
raise PipxError(f"Package {venv.name} is not installed") from e

if main_package_metadata.pinned:
logger.warning(f"Package {main_package_metadata.package} already pinned {sleep}")
elif skip and not injected_only:
raise PipxError("--skip must be used with --injected-only")
elif injected_only:
pinned_packages_count = 0
pinned_packages_list = []
for package_name in venv.package_metadata:
if package_name == venv.main_package_name or package_name in skip:
continue

if venv.package_metadata[package_name].pinned:
logger.warning(f"{package_name} was pinned. Not modifying.")
continue

pinned_packages_count += _update_pin_info(venv, package_name, is_main_package=False, unpin=False)
pinned_packages_list.append(package_name)

if pinned_packages_count != 0:
print(bold(f"Pinned {pinned_packages_count} packages in venv {venv.name}"))
for package in pinned_packages_list:
print(" -", package)
else:
for package_name in venv.package_metadata:
if package_name == venv.main_package_name:
_update_pin_info(venv, venv.main_package_name, is_main_package=True, unpin=False)
else:
_update_pin_info(venv, package_name, is_main_package=False, unpin=False)

return ExitCode(0)


def unpin(venv_dir: Path, verbose: bool) -> ExitCode:
venv = Venv(venv_dir, verbose=verbose)
try:
main_package_metadata = venv.package_metadata[venv.main_package_name]
except KeyError as e:
raise PipxError(f"Package {venv.name} is not installed") from e

unpinned_packages_count = 0
unpinned_packages_list = []

for package_name in venv.package_metadata:
if package_name == main_package_metadata.package and main_package_metadata.pinned:
unpinned_packages_count += _update_pin_info(venv, package_name, is_main_package=True, unpin=True)
elif venv.package_metadata[package_name].pinned:
unpinned_packages_count += _update_pin_info(venv, package_name, is_main_package=False, unpin=True)
unpinned_packages_list.append(package_name)

if unpinned_packages_count != 0:
print(bold(f"Unpinned {unpinned_packages_count} packages in venv {venv.name}"))
for package in unpinned_packages_list:
print(" -", package)
else:
logger.warning(f"No packages to unpin in venv {venv.name}")

return ExitCode(0)
9 changes: 9 additions & 0 deletions src/pipx/commands/upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ def _upgrade_package(

if package_metadata.package_or_url is None:
raise PipxError(f"Internal Error: package {package_name} has corrupt pipx metadata.")
elif package_metadata.pinned and package_metadata.package != venv.main_package_name:
logger.warning(
f"Not upgrading pinned package {package_metadata.package} in venv {venv.name}. "
f"Run `pipx unpin {venv.name}` to unpin it."
)
return 0
elif package_metadata.pinned:
logger.warning(f"Not upgrading pinned package {venv.name}. Run `pipx unpin {venv.name}` to unpin it.")
return 0

package_or_url = parse_specifier_for_upgrade(package_metadata.package_or_url)
old_version = package_metadata.package_version
Expand Down
56 changes: 54 additions & 2 deletions src/pipx/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ def run_pipx_command(args: argparse.Namespace, subparsers: Dict[str, argparse.Ar
args.include_injected,
args.json,
args.short,
args.pinned,
)
elif args.command == "interpreter":
if args.interpreter_command == "list":
Expand All @@ -302,6 +303,10 @@ def run_pipx_command(args: argparse.Namespace, subparsers: Dict[str, argparse.Ar
return EXIT_CODE_OK
else:
raise PipxError(f"Unknown interpreter command {args.interpreter_command}")
elif args.command == "pin":
return commands.pin(venv_dir, verbose, skip_list, args.injected_only)
elif args.command == "unpin":
return commands.unpin(venv_dir, verbose)
elif args.command == "uninstall":
return commands.uninstall(venv_dir, paths.ctx.bin_dir, paths.ctx.man_dir, verbose)
elif args.command == "uninstall-all":
Expand Down Expand Up @@ -485,6 +490,38 @@ def _add_uninject(subparsers, venv_completer: VenvCompleter, shared_parser: argp
)


def _add_pin(subparsers, venv_completer: VenvCompleter, shared_parser: argparse.ArgumentParser) -> None:
p = subparsers.add_parser(
"pin",
description="Pin the specified package to prevent it from being upgraded",
parents=[shared_parser],
)
p.add_argument("package", help="Installed package to pin")
p.add_argument(
"--injected-only",
action="store_true",
help=(
"Pin injected packages in main app only, so that they will not be upgraded during `pipx upgrade-all --include-injected`. "
"Note that this should not be passed if you wish to pin both main package and injected packages."
),
)
p.add_argument(
"--skip",
nargs="+",
default=[],
help="Skip these packages. Must be used with `--injected-only`.",
)


def _add_unpin(subparsers, venv_completer: VenvCompleter, shared_parser: argparse.ArgumentParser) -> None:
p = subparsers.add_parser(
"unpin",
description="Unpin the specified package and all injected packages in its venv to allow them being upgraded",
parents=[shared_parser],
)
p.add_argument("package", help="Installed package to unpin")


def _add_upgrade(subparsers, venv_completer: VenvCompleter, shared_parser: argparse.ArgumentParser) -> None:
p = subparsers.add_parser(
"upgrade",
Expand Down Expand Up @@ -610,6 +647,11 @@ def _add_list(subparsers: argparse._SubParsersAction, shared_parser: argparse.Ar
g = p.add_mutually_exclusive_group()
g.add_argument("--json", action="store_true", help="Output rich data in json format.")
g.add_argument("--short", action="store_true", help="List packages only.")
g.add_argument(
"--pinned",
action="store_true",
help="List pinned packages only. Pass --include-injected at the same time to list injected packages that were pinned.",
)
g.add_argument("--skip-maintenance", action="store_true", help="(deprecated) No-op")


Expand All @@ -627,8 +669,16 @@ def _add_interpreter(
description="Get help for commands with pipx interpreter COMMAND --help",
dest="interpreter_command",
)
s.add_parser("list", help="List available interpreters", description="List available interpreters")
s.add_parser("prune", help="Prune unused interpreters", description="Prune unused interpreters")
s.add_parser(
"list",
help="List available interpreters",
description="List available interpreters",
)
s.add_parser(
"prune",
help="Prune unused interpreters",
description="Prune unused interpreters",
)
return p


Expand Down Expand Up @@ -793,6 +843,8 @@ def get_command_parser() -> Tuple[argparse.ArgumentParser, Dict[str, argparse.Ar
_add_install(subparsers, shared_parser)
_add_uninject(subparsers, completer_venvs.use, shared_parser)
_add_inject(subparsers, completer_venvs.use, shared_parser)
_add_pin(subparsers, completer_venvs.use, shared_parser)
_add_unpin(subparsers, completer_venvs.use, shared_parser)
_add_upgrade(subparsers, completer_venvs.use, shared_parser)
_add_upgrade_all(subparsers, shared_parser)
_add_uninstall(subparsers, completer_venvs.use, shared_parser)
Expand Down
6 changes: 4 additions & 2 deletions src/pipx/pipx_metadata_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class PackageInfo(NamedTuple):
man_pages_of_dependencies: List[str] = []
man_paths_of_dependencies: Dict[str, List[Path]] = {}
suffix: str = ""
pinned: bool = False


class PipxMetadata:
Expand All @@ -50,7 +51,8 @@ class PipxMetadata:
# V0.2 -> Improve handling of suffixes
# V0.3 -> Add man pages fields
# V0.4 -> Add source interpreter
__METADATA_VERSION__: str = "0.4"
# V0.5 -> Add pinned
__METADATA_VERSION__: str = "0.5"

def __init__(self, venv_dir: Path, read: bool = True):
self.venv_dir = venv_dir
Expand Down Expand Up @@ -96,7 +98,7 @@ def to_dict(self) -> Dict[str, Any]:
def _convert_legacy_metadata(self, metadata_dict: Dict[str, Any]) -> Dict[str, Any]:
if metadata_dict["pipx_metadata_version"] in (self.__METADATA_VERSION__):
pass
elif metadata_dict["pipx_metadata_version"] in ("0.2", "0.3"):
elif metadata_dict["pipx_metadata_version"] in ("0.2", "0.3", "0.4"):
metadata_dict["source_interpreter"] = None
elif metadata_dict["pipx_metadata_version"] == "0.1":
main_package_data = metadata_dict["main_package"]
Expand Down
8 changes: 5 additions & 3 deletions src/pipx/venv.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ def install_package(
if pip_process.returncode:
raise PipxError(f"Error installing {full_package_description(package_name, package_or_url)}.")

self._update_package_metadata(
self.update_package_metadata(
package_name=package_name,
package_or_url=package_or_url,
pip_args=pip_args,
Expand Down Expand Up @@ -348,7 +348,7 @@ def get_venv_metadata_for_package(self, package_name: str, package_extras: Set[s
logger.info(f"get_venv_metadata_for_package: {1e3*(time.time()-data_start):.0f}ms")
return venv_metadata

def _update_package_metadata(
def update_package_metadata(
self,
package_name: str,
package_or_url: str,
Expand All @@ -357,6 +357,7 @@ def _update_package_metadata(
include_apps: bool,
is_main_package: bool,
suffix: str = "",
pinned: bool = False,
) -> None:
venv_package_metadata = self.get_venv_metadata_for_package(package_name, get_extras(package_or_url))
package_info = PackageInfo(
Expand All @@ -375,6 +376,7 @@ def _update_package_metadata(
man_paths_of_dependencies=venv_package_metadata.man_paths_of_dependencies,
package_version=venv_package_metadata.package_version,
suffix=suffix,
pinned=pinned,
)
if is_main_package:
self.pipx_metadata.main_package = package_info
Expand Down Expand Up @@ -449,7 +451,7 @@ def upgrade_package(
pip_process = self._run_pip(["--no-input", "install"] + pip_args + ["--upgrade", package_or_url])
subprocess_post_check(pip_process)

self._update_package_metadata(
self.update_package_metadata(
package_name=package_name,
package_or_url=package_or_url,
pip_args=pip_args,
Expand Down
31 changes: 31 additions & 0 deletions tests/test_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,34 @@ def test_list_does_not_trigger_maintenance(pipx_temp_env, caplog):
run_pipx_cli(["list", "--skip-maintenance"])
assert not shared_libs.shared_libs.has_been_updated_this_run
assert shared_libs.shared_libs.needs_upgrade


def test_list_pinned_packages(pipx_temp_env, monkeypatch, capsys):
assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]])
assert not run_pipx_cli(["install", PKG["black"]["spec"]])
captured = capsys.readouterr()

assert not run_pipx_cli(["pin", "black"])
assert not run_pipx_cli(["list", "--pinned"])

captured = capsys.readouterr()
assert "black 22.8.0" in captured.out
assert "pycowsay 0.0.0.2" not in captured.out


def test_list_pinned_packages_include_injected(pipx_temp_env, monkeypatch, capsys):
assert not run_pipx_cli(["install", PKG["pylint"]["spec"], PKG["nox"]["spec"]])
assert not run_pipx_cli(["inject", "pylint", PKG["black"]["spec"]])

assert not run_pipx_cli(["pin", "pylint"])
assert not run_pipx_cli(["pin", "nox"])

captured = capsys.readouterr()

assert not run_pipx_cli(["list", "--pinned", "--include-injected"])

captured = capsys.readouterr()

assert "nox 2023.4.22" in captured.out
assert "pylint 2.3.1" in captured.out
assert "black 22.8.0 (injected in venv pylint)" in captured.out
Loading