diff --git a/src/ape/plugins/_utils.py b/src/ape/plugins/_utils.py index b949a4501d..8d38475318 100644 --- a/src/ape/plugins/_utils.py +++ b/src/ape/plugins/_utils.py @@ -210,6 +210,12 @@ class PluginMetadata(BaseInterfaceModel): version: Optional[str] = None """The version requested, if there is one.""" + pip_command: List[str] = PIP_COMMAND + """ + The pip base command to use. Is a property to afford + altering during tests. + """ + @model_validator(mode="before") @classmethod def validate_name(cls, values): @@ -224,6 +230,7 @@ def validate_name(cls, values): # Just some small validation so you can't put a repo # that isn't this plugin here. NOTE: Forks should still work. raise ValueError("Plugin mismatch with remote git version.") + elif not version: # Only check name for version constraint if not in version. # NOTE: This happens when using the CLI to provide version constraints. @@ -236,7 +243,8 @@ def validate_name(cls, values): name, version = _split_name_and_version(name) break - return {"name": clean_plugin_name(name), "version": version} + pip_cmd = values.get("pip_command", PIP_COMMAND) + return {"name": clean_plugin_name(name), "version": version, "pip_command": pip_cmd} @cached_property def package_name(self) -> str: @@ -373,7 +381,7 @@ def _prepare_install( logger.warning(f"Plugin '{self.name}' is not an trusted plugin.") result_handler = ModifyPluginResultHandler(self) - pip_arguments = [*PIP_COMMAND, "install"] + pip_arguments = [*self.pip_command, "install"] if upgrade: logger.info(f"Upgrading '{self.name}' plugin ...") @@ -406,6 +414,15 @@ def _prepare_install( ) return None + def _get_uninstall_args(self) -> List[str]: + arguments = [*self.pip_command, "uninstall"] + + if self.pip_command[0] != "uv": + arguments.append("-y") + + arguments.extend((self.package_name, "--quiet")) + return arguments + class ModifyPluginResultHandler: def __init__(self, plugin: PluginMetadata): diff --git a/src/ape_plugins/_cli.py b/src/ape_plugins/_cli.py index cdca353412..a45704c6fb 100644 --- a/src/ape_plugins/_cli.py +++ b/src/ape_plugins/_cli.py @@ -191,12 +191,12 @@ def uninstall(cli_ctx, plugins, skip_confirmation): skip_confirmation or click.confirm(f"Remove plugin '{plugin}'?") ): cli_ctx.logger.info(f"Uninstalling '{plugin.name}'...") - args = [*PIP_COMMAND, "uninstall", "-y", plugin.package_name, "--quiet"] + arguments = plugin._get_uninstall_args() # NOTE: Be *extremely careful* with this command, as it modifies the user's # installed packages, to potentially catastrophic results # NOTE: This is not abstracted into another function *on purpose* - result = subprocess.call(args) + result = subprocess.call(arguments) failures_occurred = not result_handler.handle_uninstall_result(result) if failures_occurred: diff --git a/tests/functional/test_plugins.py b/tests/functional/test_plugins.py index c18c248691..efda410c13 100644 --- a/tests/functional/test_plugins.py +++ b/tests/functional/test_plugins.py @@ -25,6 +25,9 @@ "specifier", (f"<{ape_version[0]}", f">0.1,<{ape_version[0]}", f"==0.{int(ape_version[2]) - 1}"), ) +parametrize_pip_cmd = pytest.mark.parametrize( + "pip_command", [["python", "-m", "pip"], ["uv", "pip"]] +) @pytest.fixture(autouse=True) @@ -163,36 +166,48 @@ def test_is_available(self): metadata = PluginMetadata(name="foobar") assert not metadata.is_available - def test_prepare_install(self): - metadata = PluginMetadata(name=list(AVAILABLE_PLUGINS)[0]) + @parametrize_pip_cmd + def test_prepare_install(self, pip_command): + metadata = PluginMetadata(name=list(AVAILABLE_PLUGINS)[0], pip_command=pip_command) actual = metadata._prepare_install(skip_confirmation=True) assert actual is not None arguments = actual.get("args", []) - expected = [ - "-m", + shared = [ "pip", "install", f"ape-available>=0.{ape_version.minor},<0.{ape_version.minor + 1}", "--quiet", ] - assert "python" in arguments[0] - assert arguments[1:] == expected - - def test_prepare_install_upgrade(self): - metadata = PluginMetadata(name=list(AVAILABLE_PLUGINS)[0]) + if arguments[0] == "uv": + expected = ["uv", *shared] + assert arguments == expected + else: + expected = ["-m", *shared] + assert "python" in arguments[0] + assert arguments[1:] == expected + + @parametrize_pip_cmd + def test_prepare_install_upgrade(self, pip_command): + metadata = PluginMetadata(name=list(AVAILABLE_PLUGINS)[0], pip_command=pip_command) actual = metadata._prepare_install(upgrade=True, skip_confirmation=True) assert actual is not None arguments = actual.get("args", []) - expected = [ - "-m", + shared = [ "pip", "install", "--upgrade", f"ape-available>=0.{ape_version.minor},<0.{ape_version.minor + 1}", "--quiet", ] - assert "python" in arguments[0] - assert arguments[1:] == expected + + if pip_command[0].startswith("uv"): + expected = ["uv", *shared] + assert arguments == expected + + else: + expected = ["-m", *shared] + assert "python" in arguments[0] + assert arguments[1:] == expected @mark_specifiers_less_than_ape def test_prepare_install_version_smaller_than_ape(self, specifier, ape_caplog): @@ -223,6 +238,22 @@ def test_check_installed_dist_no_name_attr(self, mocker, get_dists_patch): # This triggers looping through the dist w/o a name attr. metadata.check_installed() + @parametrize_pip_cmd + def test_get_uninstall_args(self, pip_command): + metadata = PluginMetadata(name="dontmatter", pip_command=pip_command) + arguments = metadata._get_uninstall_args() + pip_cmd_len = len(metadata.pip_command) + + for idx, pip_pt in enumerate(pip_command): + assert arguments[idx] == pip_pt + + expected = ["uninstall"] + if pip_command[0] == "python": + expected.append("-y") + + expected.extend(("ape-dontmatter", "--quiet")) + assert arguments[pip_cmd_len:] == expected + class TestApePluginsRepr: def test_str(self, plugin_metadata):