Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
3a3a53f
Update flake8 rules
ryanking13 Jan 27, 2023
fd6f3fc
Explicitly set package directory
ryanking13 Jan 27, 2023
1d315cb
Move fixtures to conftest
ryanking13 Jan 27, 2023
e24bf2e
Implement uninstall
ryanking13 Jan 27, 2023
05ea7a7
Do not load pyparsing
ryanking13 Jan 27, 2023
48f6975
Write basic test
ryanking13 Jan 27, 2023
a92b729
Add more tests
ryanking13 Jan 27, 2023
3185f03
Add importlib helpers
ryanking13 Jan 30, 2023
f410fd0
Merge remote-tracking branch 'upstream/main' into uninstall
ryanking13 Jan 30, 2023
0f77c3c
Remove extra config
ryanking13 Jan 30, 2023
6db6c3f
Simplify removal
ryanking13 Jan 30, 2023
34f7671
Ignore only FileNotFoundError when removal fails
ryanking13 Jan 30, 2023
8138e3f
Invalidate cache after removal
ryanking13 Jan 30, 2023
cce5223
Update tests
ryanking13 Jan 30, 2023
0d28c8a
Merge remote-tracking branch 'upstream/main' into uninstall
ryanking13 Mar 10, 2023
ea7832b
Remove loadedPackages attribute after uninstall
ryanking13 Mar 10, 2023
595324a
Update tests
ryanking13 Mar 10, 2023
e4f42d5
Apply linter
ryanking13 Mar 10, 2023
5ad8718
Show warning instead of error if a package is not installed
ryanking13 Mar 13, 2023
c85cddb
Fix how files are retrieved
ryanking13 Mar 13, 2023
165181e
Fix missing continue statement
ryanking13 Mar 13, 2023
dc6d3f8
Add test wheel
ryanking13 Mar 13, 2023
d1307ad
Use test wheel and add more tests
ryanking13 Mar 13, 2023
6801c13
Add changelog
ryanking13 Mar 13, 2023
555d609
Split common methods into a separate file
ryanking13 Mar 13, 2023
f5e4d7d
Fix typo
ryanking13 Mar 13, 2023
ac43603
Update docstring
ryanking13 Mar 14, 2023
c6e3ca0
Make utility functions private
ryanking13 Mar 15, 2023
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

- Added `micropip.uninstall` to uninstall packages
[#55](https://github.com/pyodide/micropip/pull/55)

## [0.2.2] - 2023/03/04

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions micropip/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
list_mock_packages,
remove_mock_package,
)
from .uninstall import uninstall

try:
from ._version import __version__
Expand All @@ -19,5 +20,6 @@
"add_mock_package",
"list_mock_packages",
"remove_mock_package",
"uninstall",
"__version__",
]
6 changes: 1 addition & 5 deletions micropip/_micropip.py
Original file line number Diff line number Diff line change
Expand Up @@ -692,12 +692,8 @@ def _list() -> PackageDict:
# source is None if PYODIDE_SOURCE does not exist. In this case the
# wheel was installed manually, not via `pyodide.loadPackage` or
# `micropip`.
#
# tzdata is a funny special case: we install it with pip and then
# vendor it into our standard library. We should probably remove
# tzdata's dist-info because it's kind of weird to have dist-info in
# the stdlib.
continue

packages[name] = PackageMetadata(
name=name,
version=version,
Expand Down
49 changes: 49 additions & 0 deletions micropip/_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from importlib.metadata import Distribution
from pathlib import Path


def get_dist_info(dist: Distribution) -> Path:
"""
Get the .dist-info directory of a distribution.
"""
return dist._path # type: ignore[attr-defined]


def get_root(dist: Distribution) -> Path:
"""
Get the root directory where a package is installed.
This is normally the site-packages directory.
"""
return get_dist_info(dist).parent


def get_files_in_distribution(dist: Distribution) -> set[Path]:
"""
Get a list of files in a distribution, using the metadata.

Parameters
----------
dist
Distribution to get files from.

Returns
-------
A list of files in the distribution.
"""

root = get_root(dist)
dist_info = get_dist_info(dist)

files_to_remove = set()
pkg_files = dist.files or []
metadata_files = dist_info.glob("*")

for file in pkg_files:
abspath = (root / file).resolve()
files_to_remove.add(abspath)

# Also add all files in the .dist-info directory.
# Since micropip adds some extra files there, we need to remove them too.
files_to_remove.update(metadata_files)

return files_to_remove
86 changes: 86 additions & 0 deletions micropip/uninstall.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import importlib
import importlib.metadata
import warnings
from importlib.metadata import Distribution

from ._compat import loadedPackages
from ._utils import get_files_in_distribution, get_root


def uninstall(packages: str | list[str]) -> None:
"""Uninstall the given packages.

This function only supports uninstalling packages that are installed
using a wheel file, i.e. packages that have distribution metadata.

It is possible to reinstall a package after uninstalling it, but
note that modules / functions that are already imported will not be
automatically removed from the namespace. So make sure to reload
the module after reinstalling by e.g. running `importlib.reload(module)`.

Parameters
----------
packages
Packages to uninstall.
"""

if isinstance(packages, str):
packages = [packages]

distributions: list[Distribution] = []
for package in packages:
try:
dist = importlib.metadata.distribution(package)
distributions.append(dist)
except importlib.metadata.PackageNotFoundError:
warnings.warn(f"WARNING: Skipping '{package}' as it is not installed.")

for dist in distributions:
# Note: this value needs to be retrieved before removing files, as
# dist.name uses metadata file to get the name
name = dist.name
Comment thread
ryanking13 marked this conversation as resolved.

root = get_root(dist)
files = get_files_in_distribution(dist)
directories = set()

for file in files:
if not file.is_file():
if not file.is_relative_to(root):
# This file is not in the site-packages directory. Probably one of:
# - data_files
# - scripts
# - entry_points
# Since we don't support these, we can ignore them (except for data_files (TODO))
continue

warnings.warn(
f"WARNING: A file '{file}' listed in the metadata of '{dist.name}' does not exist."
)

continue

file.unlink()

if file.parent != root:
directories.add(file.parent)

# Remove directories in reverse hierarchical order
for directory in sorted(directories, key=lambda x: len(x.parts), reverse=True):
try:
directory.rmdir()
except OSError:
warnings.warn(
f"WARNING: A directory '{directory}' is not empty after uninstallation of '{name}'. "
"This might cause problems when installing a new version of the package. "
)

if hasattr(loadedPackages, name):
delattr(loadedPackages, name)
else:
# This should not happen, but just in case
warnings.warn(
f"WARNING: a package '{name}' was not found in loadedPackages."
)

importlib.invalidate_caches()
63 changes: 63 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from pathlib import Path

import pytest
from pytest_pyodide import spawn_web_server


def _build(build_dir, dist_dir):
import build
from build.env import IsolatedEnvBuilder

with IsolatedEnvBuilder() as env:
builder = build.ProjectBuilder(build_dir)
builder.python_executable = env.executable
builder.scripts_dir = env.scripts_dir
env.install(builder.build_system_requires)
builder.build("wheel", output_directory=dist_dir)


@pytest.fixture(scope="session")
def wheel_path(tmp_path_factory):
# Build a micropip wheel for testing
output_dir = tmp_path_factory.mktemp("wheel")

_build(Path(__file__).parent.parent, output_dir)

yield output_dir


@pytest.fixture(scope="session")
def test_wheel_path(tmp_path_factory):
# Build a test wheel for testing
output_dir = tmp_path_factory.mktemp("wheel")

_build(Path(__file__).parent / "test_data" / "test_wheel_uninstall", output_dir)

yield output_dir


@pytest.fixture
def selenium_standalone_micropip(selenium_standalone, wheel_path):
"""Import micropip before entering test so that global initialization of
micropip doesn't count towards hiwire refcount.
"""

wheel_dir = Path(wheel_path)
wheel_files = list(wheel_dir.glob("*.whl"))

if not wheel_files:
pytest.exit("No wheel files found in wheel/ directory")

wheel_file = wheel_files[0]
with spawn_web_server(wheel_dir) as server:
server_hostname, server_port, _ = server
base_url = f"http://{server_hostname}:{server_port}/"
selenium_standalone.run_js(
f"""
await pyodide.loadPackage("{base_url + wheel_file.name}");
await pyodide.loadPackage(["packaging"]);
pyodide.runPython("import micropip");
"""
)

yield selenium_standalone
Empty file.
1 change: 1 addition & 0 deletions tests/test_data/test_wheel_uninstall/deep/data/data.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nonpythonfile
Empty file.
17 changes: 17 additions & 0 deletions tests/test_data/test_wheel_uninstall/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[project]
name = "test_wheel_uninstall"
description = "Test wheel uninstall"
requires-python = ">=3.10"
version = "1.0.0"

[tool.setuptools]
packages = ["deep", "deep.deep", "shallow", "test_wheel_uninstall"]
py-modules = ["top_level"]

[tool.setuptools.package-data]
deep = ["data/*.txt"]

[build-system]
requires = ["setuptools>=42", "wheel"]

build-backend = "setuptools.build_meta"
Empty file.
Empty file.
Empty file.
47 changes: 1 addition & 46 deletions tests/test_micropip.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,51 +195,6 @@ def mock_fetch(monkeypatch, mock_importlib, wheel_base):
return result


@pytest.fixture(scope="module")
def wheel_path(tmp_path_factory):
# Build a micropip wheel for testing
import build
from build.env import IsolatedEnvBuilder

output_dir = tmp_path_factory.mktemp("wheel")

with IsolatedEnvBuilder() as env:
builder = build.ProjectBuilder(Path(__file__).parent.parent)
builder.python_executable = env.executable
builder.scripts_dir = env.scripts_dir
env.install(builder.build_system_requires)
builder.build("wheel", output_directory=output_dir)

yield output_dir


@pytest.fixture
def selenium_standalone_micropip(selenium_standalone, wheel_path):
"""Import micropip before entering test so that global initialization of
micropip doesn't count towards hiwire refcount.
"""

wheel_dir = Path(wheel_path)
wheel_files = list(wheel_dir.glob("*.whl"))

if not wheel_files:
pytest.exit("No wheel files found in wheel/ directory")

wheel_file = wheel_files[0]
with spawn_web_server(wheel_dir) as server:
server_hostname, server_port, _ = server
base_url = f"http://{server_hostname}:{server_port}/"
selenium_standalone.run_js(
f"""
await pyodide.loadPackage("{base_url + wheel_file.name}");
await pyodide.loadPackage(["packaging", "pyparsing"]);
pyodide.runPython("import micropip");
"""
)

yield selenium_standalone


SNOWBALL_WHEEL = "snowballstemmer-2.0.0-py2.py3-none-any.whl"


Expand All @@ -251,7 +206,7 @@ def test_install_simple(selenium_standalone_micropip):
return await pyodide.runPythonAsync(`
import os
import micropip
from pyodide import to_js
from pyodide.ffi import to_js
# Package 'pyodide-micropip-test' has dependency on 'snowballstemmer'
# It is used to test markers support
await micropip.install('pyodide-micropip-test')
Expand Down
Loading