Skip to content

Commit

Permalink
Smarter uninstall (#6029)
Browse files Browse the repository at this point in the history
* Initial take at making uninstall like the inverse of upgrade.
* Updates based on testing the uninstall command
* Handle pre flag
* Add news fragment
  • Loading branch information
matteius authored Mar 27, 2024
1 parent 73220cc commit d78232d
Show file tree
Hide file tree
Showing 6 changed files with 133 additions and 128 deletions.
1 change: 1 addition & 0 deletions news/6029.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The ``uninstall`` command now does the inverse of ``upgrade`` which means it no longer invokes a full ``lock`` cycle which was problematic for projects with many dependencies.
5 changes: 4 additions & 1 deletion pipenv/cli/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,15 +284,18 @@ def uninstall(ctx, state, all_dev=False, all=False, **kwargs):
"""Uninstalls a provided package and removes it from Pipfile."""
from pipenv.routines.uninstall import do_uninstall

pre = state.installstate.pre

retcode = do_uninstall(
state.project,
packages=state.installstate.packages,
editable_packages=state.installstate.editables,
python=state.python,
system=state.system,
lock=True,
lock=False,
all_dev=all_dev,
all=all,
pre=pre,
pypi_mirror=state.pypi_mirror,
categories=state.installstate.categories,
ctx=ctx,
Expand Down
10 changes: 10 additions & 0 deletions pipenv/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,16 @@ def remove_package_from_pipfile(self, package_name, category):
return True
return False

def reset_category_in_pipfile(self, category):
# Read and append Pipfile.
p = self.parsed_pipfile
if category:
del p[category]
p[category] = {}
self.write_toml(p)
return True
return False

def remove_packages_from_pipfile(self, packages):
parsed = self.parsed_pipfile
packages = {pep423_name(pkg) for pkg in packages}
Expand Down
232 changes: 113 additions & 119 deletions pipenv/routines/uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,39 @@

from pipenv import exceptions
from pipenv.patched.pip._internal.build_env import get_runnable_pip
from pipenv.patched.pip._vendor.packaging.utils import canonicalize_name
from pipenv.routines.lock import do_lock
from pipenv.utils.dependencies import (
expansive_install_req_from_line,
get_canonical_names,
get_lockfile_section_using_pipfile_category,
get_pipfile_category_using_lockfile_section,
pep423_name,
)
from pipenv.utils.processes import run_command, subprocess_run
from pipenv.utils.project import ensure_project
from pipenv.utils.requirements import BAD_PACKAGES
from pipenv.utils.resolver import venv_resolve_deps
from pipenv.utils.shell import cmd_list_to_shell, project_python
from pipenv.vendor import click


def _uninstall_from_environment(project, package, system=False):
# Execute the uninstall command for the package
click.secho(f"Uninstalling {package}...", fg="green", bold=True)
with project.environment.activated():
cmd = [
project_python(project, system=system),
get_runnable_pip(),
"uninstall",
package,
"-y",
]
c = run_command(cmd, is_verbose=project.s.is_verbose())
click.secho(c.stdout, fg="cyan")
if c.returncode != 0:
click.echo(f"Error occurred while uninstalling package {package}.")
return False
return True


def do_uninstall(
project,
packages=None,
Expand All @@ -28,147 +45,124 @@ def do_uninstall(
lock=False,
all_dev=False,
all=False,
pre=False,
pypi_mirror=None,
ctx=None,
categories=None,
):
# Automatically use an activated virtualenv.
if project.s.PIPENV_USE_SYSTEM:
system = True
# Ensure that virtualenv is available.
ensure_project(project, python=python, pypi_mirror=pypi_mirror)
# Uninstall all dependencies, if --all was provided.
# Initialization similar to the upgrade function
if not any([packages, editable_packages, all_dev, all]):
raise exceptions.PipenvUsageError("No package provided!", ctx=ctx)

if not categories:
categories = project.get_package_categories(for_lockfile=True)
editable_pkgs = []
for p in editable_packages:
if p:
install_req, name = expansive_install_req_from_line(f"-e {p}")
editable_pkgs.append(name)
packages += editable_pkgs
package_names = {p for p in packages if p}
package_map = {canonicalize_name(p): p for p in packages if p}
installed_package_names = project.installed_package_names
if project.lockfile_exists:
project_pkg_names = project.lockfile_package_names
else:
project_pkg_names = project.pipfile_package_names
# Uninstall [dev-packages], if --dev was provided.
categories = ["default"]

lockfile_content = project.lockfile_content

if all_dev:
if (
"dev-packages" not in project.parsed_pipfile
and not project_pkg_names["develop"]
):
click.echo(
click.style(
"No {} to uninstall.".format(
click.style("[dev-packages]", fg="yellow")
),
bold=True,
)
)
return
click.secho(
click.style(
"Un-installing {}...".format(click.style("[dev-packages]", fg="yellow")),
"Un-installing all {}...".format(
click.style("[dev-packages]", fg="yellow")
),
bold=True,
)
)
preserve_packages = set()
dev_packages = set()
for category in project.get_package_categories(for_lockfile=True):
if category == "develop":
dev_packages |= set(project_pkg_names[category])
else:
preserve_packages |= set(project_pkg_names[category])

package_names = dev_packages - preserve_packages

# Remove known "bad packages" from the list.
bad_pkgs = get_canonical_names(BAD_PACKAGES)
ignored_packages = bad_pkgs & set(package_map)
for ignored_pkg in get_canonical_names(ignored_packages):
if project.s.is_verbose():
click.echo(f"Ignoring {ignored_pkg}.", err=True)
package_names.discard(package_map[ignored_pkg])

used_packages = project_pkg_names["combined"] & installed_package_names
failure = False
# Uninstall all dev-packages from environment
for package in project.get_pipfile_section("dev-packages"):
_uninstall_from_environment(project, package, system=system)
# Remove the package from the Pipfile
if project.reset_category_in_pipfile(category="dev-packages"):
click.echo("Removed [dev-packages] from Pipfile.")
# Finalize changes to lockfile
lockfile_content["develop"] = {}
lockfile_content.update({"_meta": project.get_lockfile_meta()})
project.write_lockfile(lockfile_content)

if all:
click.echo(
click.secho(
click.style(
"Un-installing all {} and {}...".format(
click.style("[dev-packages]", fg="yellow"),
click.style("[packages]", fg="yellow"),
),
"Un-installing all {}...".format(click.style("[packages]", fg="yellow")),
bold=True,
)
)
do_purge(project, bare=False, allow_global=system)
sys.exit(0)

selected_pkg_map = {canonicalize_name(p): p for p in package_names}
packages_to_remove = [
package_name
for normalized, package_name in selected_pkg_map.items()
if normalized in (used_packages - bad_pkgs)
]
lockfile = project.get_or_create_lockfile(categories=categories)
# Uninstall all dev-packages from environment
for package in project.get_pipfile_section("packages"):
_uninstall_from_environment(project, package, system=system)
# Remove the package from the Pipfile
if project.reset_category_in_pipfile(category="packages"):
click.echo("Removed [packages] from Pipfile.")

# Finalize changes to lockfile
lockfile_content["default"] = {}
lockfile_content.update({"_meta": project.get_lockfile_meta()})
project.write_lockfile(lockfile_content)

package_args = list(packages) + [f"-e {pkg}" for pkg in editable_packages]

# Determine packages and their dependencies for removal
for category in categories:
category = get_lockfile_section_using_pipfile_category(category)
for normalized_name, package_name in selected_pkg_map.items():
if normalized_name in project.lockfile_content[category]:
click.echo(
"{} {} {} {}".format(
click.style("Removing", fg="cyan"),
click.style(package_name, fg="green"),
click.style("from", fg="cyan"),
click.style("Pipfile.lock...", fg="white"),
)
)
if normalized_name in lockfile[category]:
del lockfile[category][normalized_name]
lockfile.write()

pipfile_category = get_pipfile_category_using_lockfile_section(category)
category = get_lockfile_section_using_pipfile_category(
category
) # In case they passed pipfile category
pipfile_category = get_pipfile_category_using_lockfile_section(category)

for package in package_args[:]:
install_req, _ = expansive_install_req_from_line(package, expand_env=True)
name, normalized_name, pipfile_entry = project.generate_package_pipfile_entry(
install_req, package, category=pipfile_category
)

# Remove the package from the Pipfile
if project.remove_package_from_pipfile(
package_name, category=pipfile_category
normalized_name, category=pipfile_category
):
click.secho(
f"Removed {package_name} from Pipfile category {pipfile_category}",
fg="green",
)

for normalized_name, package_name in selected_pkg_map.items():
still_remains = False
for category in project.get_package_categories():
if project.get_package_name_in_pipfile(normalized_name, category=category):
still_remains = True
if not still_remains:
# Uninstall the package.
if package_name in packages_to_remove:
click.secho(
f"Uninstalling {click.style(package_name)}...",
fg="green",
bold=True,
)
with project.environment.activated():
cmd = [
project_python(project, system=system),
get_runnable_pip(),
"uninstall",
package_name,
"-y",
click.echo(f"Removed {normalized_name} from Pipfile.")

# Rebuild the dependencies for resolution from the updated Pipfile
updated_packages = project.get_pipfile_section(pipfile_category)

# Resolve dependencies with the package removed
resolved_lock_data = venv_resolve_deps(
updated_packages,
which=project._which,
project=project,
lockfile={},
category=pipfile_category,
pre=pre,
allow_global=system,
pypi_mirror=pypi_mirror,
)

# Determine which dependencies are no longer needed
try:
current_lock_data = lockfile_content[category]
if current_lock_data:
deps_to_remove = [
dep for dep in current_lock_data if dep not in resolved_lock_data
]
c = run_command(cmd, is_verbose=project.s.is_verbose())
click.secho(c.stdout, fg="cyan")
if c.returncode != 0:
failure = True
# Remove unnecessary dependencies from Pipfile and lockfile
for dep in deps_to_remove:
if (
category in lockfile_content
and dep in lockfile_content[category]
):
del lockfile_content[category][dep]
except KeyError:
pass # No lockfile data for this category

# Finalize changes to lockfile
lockfile_content.update({"_meta": project.get_lockfile_meta()})
project.write_lockfile(lockfile_content)

# Perform uninstallation of packages and dependencies
failure = False
for package in package_args:
_uninstall_from_environment(project, package, system=system)

if lock:
do_lock(project, system=system, pypi_mirror=pypi_mirror)

sys.exit(int(failure))


Expand Down
4 changes: 2 additions & 2 deletions pipenv/utils/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -771,10 +771,10 @@ def venv_resolve_deps(

if not deps:
if not project.pipfile_exists:
return None
return {}
deps = project.parsed_pipfile.get(category, {})
if not deps:
return None
return {}

if not pipfile:
pipfile = getattr(project, category, {})
Expand Down
9 changes: 3 additions & 6 deletions tests/integration/test_uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,8 @@ def test_uninstall_all_local_files(pipenv_instance_private_pypi, testsroot):
c = p.pipenv(f"install {file_uri}")
assert c.returncode == 0
c = p.pipenv("uninstall --all")
assert c.returncode == 0
assert "tablib" in c.stdout
# Uninstall --all is not supposed to remove things from the pipfile
# Note that it didn't before, but that instead local filenames showed as hashes
assert "tablib" in p.pipfile["packages"]
assert "tablib" not in p.pipfile["packages"]
assert "tablib" not in p.lockfile["default"]


@pytest.mark.install
Expand Down Expand Up @@ -218,7 +215,7 @@ def test_uninstall_category_with_shared_requirement(pipenv_instance_pypi):
c = p.pipenv("install")
assert c.returncode == 0

c = p.pipenv("uninstall six --categories packages")
c = p.pipenv("uninstall six --categories default")
assert c.returncode == 0

assert "six" in p.lockfile["prereq"]
Expand Down

0 comments on commit d78232d

Please sign in to comment.