diff --git a/README.md b/README.md index cbebeb6..a7941f8 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,31 @@ Provider packages expose no task collection. Providers return text only; for `Path` fields invoke-toolkit materializes and cleans the resulting temporary file according to that Field's cleanup lifetime. +## uv tool plugins + +This plugin-management workflow is currently for persistent `uv tool` installations only. Other package-manager plugin workflows are future work. + +Install a plugin from Git or a local editable checkout with uv: + +```console +uv tool install invoke-toolkit --with git+https://github.com/D3f0/invoke-toolkit-litellm +uv tool install invoke-toolkit --with-editable ./invoke-toolkit-litellm +``` + +When `intk` is running from a detectable uv tool environment, use the internal tasks to inspect and manage the installed `invoke-toolkit-*` plugins: + +```console +intk -x plugin.list +intk -x plugin.add --package git+https://github.com/D3f0/invoke-toolkit-litellm +intk -x plugin.add --package invoke-toolkit-litellm --editable ./invoke-toolkit-litellm +intk -x plugin.remove invoke-toolkit-litellm +intk -x plugin.update +``` + +The `version` task identifies the uv-tool context and reports plugin versions when package metadata makes them available. For `uvx`, `uv run`, project virtual environments, or other package managers, plugin management is not claimed. Re-run `uv tool install` with the complete desired set of `--with` and `--with-editable` options when changing supplemental requirements. + +> **Scope disclaimer:** pipx, Poetry, pip, and other package-manager plugin management options should come in a future release. + ## Development This project utilizes the `pre-commit` framework, make sure you run: diff --git a/docs/index.qmd b/docs/index.qmd index 46aa539..d7ed094 100644 --- a/docs/index.qmd +++ b/docs/index.qmd @@ -70,6 +70,38 @@ With `pipx` pipx run invoke-toolkit ``` +## Managing uv tool plugins + +When `invoke-toolkit` is installed as a persistent [`uv tool`](https://docs.astral.sh/uv/concepts/tools/), extensions created with `intk -x create.package` can be added to the same isolated tool environment. This section describes the `uv` workflow only; support for plugin management through other package managers is future work. + +Install a plugin published from Git: + +```console +uv tool install invoke-toolkit --with git+https://github.com/D3f0/invoke-toolkit-litellm +``` + +For a local checkout, use an editable supplemental requirement: + +```console +uv tool install invoke-toolkit --with-editable ./invoke-toolkit-litellm +``` + +If the toolkit is already installed, re-run `uv tool install` with the complete set of `--with` and `--with-editable` options. `uv` recreates the tool environment with those supplemental requirements. The internal plugin tasks provide a guided version of this workflow when `intk` can positively identify its active uv tool environment: + +```console +intk -x plugin.list +intk -x plugin.add --package git+https://github.com/D3f0/invoke-toolkit-litellm +intk -x plugin.add --package invoke-toolkit-litellm --editable ./invoke-toolkit-litellm +intk -x plugin.remove invoke-toolkit-litellm +intk -x plugin.update +``` + +The `version` task reports `(uv tool)` and lists installed `invoke-toolkit-*` plugin distributions and their versions when that environment can be detected. Plugin versions are read from installed package metadata; editable plugins may show their source path. A task run from `uvx`, `uv run`, a project virtual environment, or another package manager will not claim uv-tool management. + +`plugin.update` uses `uv tool upgrade` for the base toolkit. Use `plugin.add` or `plugin.remove` when the supplemental plugin requirement set itself needs to change. The commands must be run from a persistent `uv tool install` environment; they do not mutate arbitrary project environments. + +> **Scope disclaimer:** This feature currently supports plugin management for `uv` tools only. Equivalent workflows for pipx, Poetry, pip, or other package managers should be added in the future. + ## Simple task example ```python diff --git a/src/invoke_toolkit/extensions/tasks/create.py b/src/invoke_toolkit/extensions/tasks/create.py index 7e9e7e2..3eec08a 100644 --- a/src/invoke_toolkit/extensions/tasks/create.py +++ b/src/invoke_toolkit/extensions/tasks/create.py @@ -122,6 +122,10 @@ def script( ctx.print_err( f"You can run it with `uv run {path}`. This file contains the following code" ) + ctx.print_err( + "For a persistent uv tool with an editable plugin, use `uv tool install " + "invoke-toolkit --with-editable `." + ) ctx.print_err(code) diff --git a/src/invoke_toolkit/extensions/tasks/plugin.py b/src/invoke_toolkit/extensions/tasks/plugin.py new file mode 100644 index 0000000..700f430 --- /dev/null +++ b/src/invoke_toolkit/extensions/tasks/plugin.py @@ -0,0 +1,96 @@ +"""Internal tasks for managing uv-installed invoke-toolkit plugins.""" + +from __future__ import annotations + +from typing import Annotated + +from invoke_toolkit import Context, task +from invoke_toolkit.extensions.uv_tools import ( + Plugin, + active_tool, + add_command, + installed_plugins, + plugin_matches, + reinstall_command, + upgrade_command, +) + + +def _require_active_tool(ctx: Context): + tool = active_tool() + if tool is None: + ctx.rich_exit( + "Could not detect an active uv tool installation for invoke-toolkit. " + "Run this command from an installed uv tool, not uvx or a project environment." + ) + return tool + + +def _print_plugin(ctx: Context, plugin: Plugin) -> None: + version = f" v{plugin.version}" if plugin.version else " (version unavailable)" + source = f" [editable: {plugin.editable_path}]" if plugin.editable_path else "" + ctx.print(f"- {plugin.name}{version}{source}") + + +@task(name="list", autoprint=False) +def list_(ctx: Context) -> None: + """List plugins installed with the active uv-managed invoke-toolkit.""" + tool = _require_active_tool(ctx) + plugins = installed_plugins() + ctx.print(f"invoke-toolkit v{tool.version or 'unknown'} (uv tool)") + if not plugins: + ctx.print("No invoke-toolkit plugins detected.") + return + ctx.print("Installed plugins:") + for plugin in plugins: + _print_plugin(ctx, plugin) + + +@task(name="add") +def add( + ctx: Context, + package: Annotated[str, "Package requirement or git URL"] = "", + editable: Annotated[ + str, "Local plugin directory to install with --with-editable" + ] = "", +) -> None: + """Add a registry, git, or editable plugin to the active uv tool.""" + if not package and not editable: + ctx.rich_exit("Provide a package requirement or --editable plugin path.") + tool = _require_active_tool(ctx) + command = add_command(tool, package, editable) + ctx.print(f"Running: {command}") + ctx.run(command, pty=True) + + +@task() +def remove( + ctx: Context, + package: Annotated[str, "Plugin package or generated short name"], +) -> None: + """Remove a plugin and reinstall the active uv tool without it.""" + tool = _require_active_tool(ctx) + plugins = installed_plugins() + matches = [plugin for plugin in plugins if plugin_matches(plugin, package)] + if not matches: + ctx.rich_exit(f"Plugin not found: {package}") + command = reinstall_command(tool, remove=matches[0].name) + ctx.print(f"Running: {command}") + ctx.run(command, pty=True) + + +@task() +def update(ctx: Context) -> None: + """Upgrade the base invoke-toolkit package in the active uv tool.""" + tool = _require_active_tool(ctx) + command = upgrade_command(tool) + ctx.print(f"Running: {command}") + ctx.run(command, pty=True) + ctx.print( + "Note: supplemental plugin requirements retain their uv constraints; " + "use plugin.add/remove to rebuild the tool requirement set." + ) + + +def _matches(plugin: Plugin, requested: str) -> bool: + return requested.lower() in {plugin.name.lower(), plugin.short_name.lower()} diff --git a/src/invoke_toolkit/extensions/uv_tools.py b/src/invoke_toolkit/extensions/uv_tools.py new file mode 100644 index 0000000..8b7fed6 --- /dev/null +++ b/src/invoke_toolkit/extensions/uv_tools.py @@ -0,0 +1,334 @@ +"""Utilities for inspecting and managing uv-installed invoke-toolkit tools.""" + +from __future__ import annotations + +import importlib.metadata +import json +import os +import re +import shlex +import subprocess +import sys +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.parse import unquote, urlparse + +from tomlkit import loads as toml_loads + +from invoke_toolkit.loader.entrypoint import PLUGIN_PREFIX + +TOOL_NAME = "invoke-toolkit" +TOOL_LIST_COMMAND = ( + "uv tool list --show-paths --show-with --show-version-specifiers --show-extras" +) + + +@dataclass(frozen=True) +class UvTool: + """A tool entry reported by ``uv tool list``.""" + + name: str + version: str | None + environment: Path + requirements: tuple[str, ...] = () + entrypoints: tuple[Path, ...] = () + + +@dataclass(frozen=True) +class Plugin: + """An invoke-toolkit plugin installed in the active Python environment.""" + + name: str + version: str | None + editable_path: Path | None = None + + @property + def short_name(self) -> str: + """Return the generated plugin name without its standard prefix.""" + return self.name.removeprefix(PLUGIN_PREFIX) + + @property + def requirement(self) -> str: + """Return a conservative registry requirement for the plugin.""" + if self.version: + return f"{self.name}=={self.version}" + return self.name + + +@dataclass(frozen=True) +class ReceiptRequirement: + """One requirement recorded in uv's tool receipt.""" + + name: str + value: str + option: str | None = None + + +def parse_tool_list(output: str) -> tuple[UvTool, ...]: + """Parse the block-oriented output of ``uv tool list``.""" + tools: list[UvTool] = [] + current: dict[str, Any] | None = None + header = re.compile( + r"^(?P\S+)\s+v(?P\S+)" + r"(?P.*?)\s+\((?P[^)]+)\)\s*$" + ) + entrypoint = re.compile(r"^-\s+\S+(?:\s+\((?P[^)]+)\))?\s*$") + + def finish() -> None: + """Append the current parsed tool, if one is active.""" + if current is None: + return + tools.append( + UvTool( + name=current["name"], + version=current["version"], + environment=Path(current["environment"]), + requirements=tuple(current["requirements"]), + entrypoints=tuple(current["entrypoints"]), + ) + ) + + for raw_line in output.splitlines(): + line = raw_line.strip() + if not line or line.startswith("warning:"): + continue + match = header.match(line) + if match: + finish() + requirements = [] + for item in re.findall(r"\[([^]]+)\]", match.group("metadata")): + if item.startswith("with: "): + requirements.extend( + requirement.strip() + for requirement in item.removeprefix("with: ").split(",") + ) + current = { + "name": match.group("name"), + "version": match.group("version"), + "environment": match.group("path"), + "requirements": requirements, + "entrypoints": [], + } + continue + if current is not None: + match = entrypoint.match(line) + if match and match.group("path"): + current["entrypoints"].append(Path(match.group("path"))) + finish() + return tuple(tools) + + +def _run(command: str) -> subprocess.CompletedProcess[str]: + """Run a uv inspection command without raising for unavailable uv.""" + try: + return subprocess.run( + command, + shell=True, + check=False, + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return subprocess.CompletedProcess(command, 1, "", "") + + +def list_tools() -> tuple[UvTool, ...]: + """Return tools reported by uv, or an empty tuple when uv is unavailable.""" + result = _run(TOOL_LIST_COMMAND) + return parse_tool_list(result.stdout + result.stderr) + + +def _same_path(left: Path, right: Path) -> bool: + try: + return left.expanduser().resolve() == right.expanduser().resolve() + except OSError: + return os.path.abspath(left) == os.path.abspath(right) + + +def active_tool(tools: tuple[UvTool, ...] | None = None) -> UvTool | None: + """Find the uv tool environment containing the running interpreter.""" + tools = list_tools() if tools is None else tools + prefix = Path(sys.prefix) + for tool in tools: + if _same_path(prefix, tool.environment): + return tool + return None + + +def _receipt_path(tool: UvTool) -> Path: + return tool.environment / "uv-receipt.toml" + + +def _load_receipt(tool: UvTool) -> dict[str, Any] | None: + try: + text = _receipt_path(tool).read_text(encoding="utf-8") + return dict(toml_loads(text)) + except (OSError, ValueError): + return None + + +def _requirement_value(requirement: dict[str, Any], name: str) -> ReceiptRequirement: + extras = requirement.get("extras", []) + suffix = f"[{','.join(str(extra) for extra in extras)}]" if extras else "" + if requirement.get("editable"): + return ReceiptRequirement(name, str(requirement["editable"]), "--with-editable") + if requirement.get("directory"): + return ReceiptRequirement( + name, str(requirement["directory"]), "--with-editable" + ) + if requirement.get("git"): + value = str(requirement["git"]) + if not value.startswith("git+"): + value = f"git+{value}" + if requirement.get("rev"): + value = f"{value}@{requirement['rev']}" + return ReceiptRequirement(name, value, "--with") + if requirement.get("url"): + return ReceiptRequirement(name, str(requirement["url"]), "--with") + return ReceiptRequirement( + name, + f"{name}{suffix}{requirement.get('specifier', '')}", + None, + ) + + +def receipt_requirements(tool: UvTool) -> tuple[ReceiptRequirement, ...]: + """Return all original tool and supplemental requirements from uv's receipt.""" + receipt = _load_receipt(tool) + if not receipt: + return (ReceiptRequirement(tool.name, tool.name),) + raw_requirements = receipt.get("tool", {}).get("requirements", []) + result: list[ReceiptRequirement] = [] + for raw in raw_requirements: + if isinstance(raw, str): + result.append(ReceiptRequirement(raw, raw)) + elif isinstance(raw, dict): + name = str(raw.get("name", tool.name)) + result.append(_requirement_value(raw, name)) + return tuple(result) or (ReceiptRequirement(tool.name, tool.name),) + + +def receipt_requirement(tool: UvTool) -> str: + """Recover the original base requirement from uv's receipt when possible.""" + return receipt_requirements(tool)[0].value + + +def _editable_path(distribution: importlib.metadata.Distribution) -> Path | None: + """Return a direct editable source path when package metadata provides one.""" + try: + direct_url = distribution.read_text("direct_url.json") + except FileNotFoundError: + return None + if not direct_url: + return None + try: + data = json.loads(direct_url) + except (TypeError, ValueError): + return None + if data.get("dir_info", {}).get("editable") and data.get("url", "").startswith( + "file://" + ): + return Path(unquote(urlparse(data["url"]).path)) + return None + + +def installed_plugins() -> tuple[Plugin, ...]: + """List installed invoke-toolkit-prefixed distributions and their versions.""" + unique: dict[tuple[str, str | None, Path | None], Plugin] = {} + for distribution in importlib.metadata.distributions(): + name = distribution.metadata["Name"] or "" + if not name.lower().startswith(PLUGIN_PREFIX): + continue + plugin = Plugin( + name=name, + version=distribution.version or None, + editable_path=_editable_path(distribution), + ) + unique[(plugin.name.lower(), plugin.version, plugin.editable_path)] = plugin + return tuple(sorted(unique.values(), key=lambda plugin: plugin.name.lower())) + + +def add_command( + tool: UvTool, + package: str = "", + editable: str = "", +) -> str: + """Build a forceful uv install preserving existing receipt requirements.""" + requirements = list(receipt_requirements(tool)) + if editable: + requirements.append( + ReceiptRequirement( + package or Path(editable).name, editable, "--with-editable" + ) + ) + else: + requirements.append(ReceiptRequirement(package, package, "--with")) + return _command_for_requirements(requirements) + + +def plugin_matches(plugin: Plugin, requested: str) -> bool: + """Match either a full package name or its generated short name.""" + return requested.lower() in {plugin.name.lower(), plugin.short_name.lower()} + + +def install_arguments(plugins: tuple[Plugin, ...]) -> list[str]: + """Build repeatable uv options for known installed plugins.""" + args: list[str] = [] + for plugin in plugins: + if plugin.editable_path: + args.extend(["--with-editable", str(plugin.editable_path)]) + else: + args.extend(["--with", plugin.requirement]) + return args + + +def shell_quote(value: str) -> str: + """Quote one command argument for the host shell.""" + return shlex.quote(value) + + +def _command_for_requirements(requirements: Iterable[ReceiptRequirement]) -> str: + """Build a uv install command from ordered receipt requirements.""" + all_requirements = tuple(requirements) + base, *supplemental = all_requirements + args = ["uv", "tool", "install", "--force"] + for requirement in supplemental: + args.extend([requirement.option or "--with", shell_quote(requirement.value)]) + if base.option: + args.extend(["--editable", shell_quote(base.value)]) + else: + args.append(shell_quote(base.value)) + return " ".join(args) + + +def reinstall_command( + tool: UvTool, + plugins: tuple[Plugin, ...] | None = None, + *, + remove: str | None = None, +) -> str: + """Build a forceful uv install preserving receipt requirements.""" + requirements = list(receipt_requirements(tool)) + if remove: + requirements = [ + requirement + for requirement in requirements + if requirement.name.lower() != remove.lower() + ] + elif plugins is not None: + requirements = [ + requirements[0], + *( + ReceiptRequirement(plugin.name, plugin.requirement, "--with") + for plugin in plugins + ), + ] + return _command_for_requirements(requirements) + + +def upgrade_command(tool: UvTool) -> str: + """Build the native uv command for upgrading the active tool.""" + return f"uv tool upgrade {shell_quote(tool.name)}" diff --git a/tasks.py b/tasks.py index 7ea7b5c..6e072a0 100644 --- a/tasks.py +++ b/tasks.py @@ -13,6 +13,7 @@ from rich.prompt import Prompt from invoke_toolkit import Context, task +from invoke_toolkit.extensions.uv_tools import active_tool, installed_plugins try: _repo_root = Path( @@ -26,17 +27,33 @@ REPO_ROOT: Path = _repo_root +def _uv_tool_summary() -> str: + tool = active_tool() + if tool is None: + return "" + plugins = installed_plugins() + plugin_text = ( + ", ".join( + f"{plugin.name} {plugin.version or 'version unavailable'}" + for plugin in plugins + ) + or "no invoke-toolkit plugins" + ) + return f" (uv tool; plugins: {plugin_text})" + + @task(default=True, autoprint=True, aliases=["v"]) def version( ctx: Context, ): - """Shows package version (git based)""" + """Shows package version (git based), including uv tool plugins when detectable.""" with ctx.cd(REPO_ROOT): with ctx.status("Computing version from SCM"): - return ctx.run( + version_text = ctx.run( "uvx --with uv-dynamic-versioning hatchling version", hide=not ctx.config.run.echo, ).stdout.strip() + return version_text + _uv_tool_summary() @task(autoprint=True) diff --git a/tests/extensions/test_uv_plugins.py b/tests/extensions/test_uv_plugins.py new file mode 100644 index 0000000..9786e7e --- /dev/null +++ b/tests/extensions/test_uv_plugins.py @@ -0,0 +1,211 @@ +"""Tests for uv-managed invoke-toolkit plugin support.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from invoke_toolkit.extensions.uv_tools import ( + Plugin, + UvTool, + active_tool, + add_command, + install_arguments, + parse_tool_list, + receipt_requirement, + receipt_requirements, + reinstall_command, + upgrade_command, +) + + +def test_parse_tool_list_handles_metadata_and_warnings(tmp_path: Path): + output = f"""warning: Ignoring malformed tool glances +invoke-toolkit v0.0.69 [with: invoke-toolkit-litellm, other>=1] [extras: full] ({tmp_path}) +- intk ({tmp_path}/bin/intk) +- invoke-toolkit ({tmp_path}/bin/invoke-toolkit) +ruff v0.15.6 ({tmp_path}/ruff) +- ruff ({tmp_path}/bin/ruff) +""" + + tools = parse_tool_list(output) + + assert [tool.name for tool in tools] == ["invoke-toolkit", "ruff"] + assert tools[0].version == "0.0.69" + assert tools[0].requirements == ("invoke-toolkit-litellm", "other>=1") + assert tools[0].entrypoints == ( + tmp_path / "bin/intk", + tmp_path / "bin/invoke-toolkit", + ) + + +def test_active_tool_matches_running_prefix(tmp_path: Path, monkeypatch): + tool = UvTool("invoke-toolkit", "1.2.3", tmp_path) + monkeypatch.setattr("invoke_toolkit.extensions.uv_tools.sys.prefix", str(tmp_path)) + + assert active_tool((tool,)) == tool + + +def test_receipt_requirement_preserves_extras(tmp_path: Path): + (tmp_path / "uv-receipt.toml").write_text( + '[tool]\nrequirements = [{ name = "invoke-toolkit", extras = ["full"] }]\n', + encoding="utf-8", + ) + tool = UvTool("invoke-toolkit", "1.2.3", tmp_path) + + assert receipt_requirement(tool) == "invoke-toolkit[full]" + + +def test_install_arguments_use_editable_and_pinned_plugins(tmp_path: Path): + plugins = ( + Plugin("invoke-toolkit-litellm", "0.1.0", tmp_path / "litellm"), + Plugin("invoke-toolkit-other", "2.0.0"), + ) + + assert install_arguments(plugins) == [ + "--with-editable", + str(tmp_path / "litellm"), + "--with", + "invoke-toolkit-other==2.0.0", + ] + + +def test_reinstall_and_upgrade_commands(tmp_path: Path): + tool = UvTool("invoke-toolkit", "1.2.3", tmp_path) + plugin = Plugin("invoke-toolkit-litellm", "0.1.0") + (tmp_path / "uv-receipt.toml").write_text( + '[tool]\nrequirements = [{ name = "invoke-toolkit", extras = ["full"] }]\n', + encoding="utf-8", + ) + + assert reinstall_command(tool, (plugin,)) == ( + "uv tool install --force --with invoke-toolkit-litellm==0.1.0 " + "'invoke-toolkit[full]'" + ) + assert upgrade_command(tool) == "uv tool upgrade invoke-toolkit" + + +def test_receipt_preserves_git_and_editable_sources(tmp_path: Path): + (tmp_path / "uv-receipt.toml").write_text( + """[tool] +requirements = [ + { name = "invoke-toolkit", specifier = ">=1" }, + { name = "invoke-toolkit-litellm", git = "https://github.com/D3f0/invoke-toolkit-litellm" }, + { name = "invoke-toolkit-local", editable = "/tmp/plugin" }, +] +""", + encoding="utf-8", + ) + tool = UvTool("invoke-toolkit", "1.2.3", tmp_path) + + requirements = receipt_requirements(tool) + assert requirements[0].value == "invoke-toolkit>=1" + assert requirements[1].value == "git+https://github.com/D3f0/invoke-toolkit-litellm" + assert requirements[2].option == "--with-editable" + assert requirements[2].value == "/tmp/plugin" + command = reinstall_command(tool, remove="invoke-toolkit-litellm") + assert "git+https://github.com/D3f0/invoke-toolkit-litellm" not in command + assert "/tmp/plugin" in command + + +def test_add_command_preserves_existing_receipt_requirements(tmp_path: Path): + (tmp_path / "uv-receipt.toml").write_text( + '[tool]\nrequirements = [{ name = "invoke-toolkit", git = "https://example.test/intk" }]\n', + encoding="utf-8", + ) + tool = UvTool("invoke-toolkit", "1.2.3", tmp_path) + + command = add_command(tool, editable="/tmp/plugin") + assert "git+https://example.test/intk" in command + assert "--with-editable /tmp/plugin" in command + + +def test_plugin_add_accepts_editable_only(tmp_path: Path): + from invoke_toolkit.extensions.tasks import plugin + + ctx = MagicMock() + tool = UvTool("invoke-toolkit", "1.2.3", tmp_path) + (tmp_path / "uv-receipt.toml").write_text( + '[tool]\nrequirements = [{ name = "invoke-toolkit" }]\n', + encoding="utf-8", + ) + with ( + patch.object(plugin, "active_tool", return_value=tool), + patch.object(plugin, "installed_plugins", return_value=()), + ): + plugin.add.body(ctx, editable=str(tmp_path / "plugin")) + + assert "--with-editable" in ctx.run.call_args.args[0] + assert str(tmp_path / "plugin") in ctx.run.call_args.args[0] + + +def test_create_script_prints_generated_code(tmp_path: Path, monkeypatch): + from invoke_toolkit.extensions.tasks.create import script + + monkeypatch.chdir(tmp_path) + ctx = MagicMock() + script.body(ctx, name="tasks.py", location=".") + + assert (tmp_path / "tasks.py").exists() + assert ctx.print_err.call_count == 4 + + +def test_plugin_remove_reinstalls_without_selected_plugin(tmp_path: Path): + from invoke_toolkit.extensions.tasks import plugin + + ctx = MagicMock() + tool = UvTool("invoke-toolkit", "1.2.3", tmp_path) + (tmp_path / "uv-receipt.toml").write_text( + """[tool] +requirements = [ + { name = "invoke-toolkit" }, + { name = "invoke-toolkit-litellm", specifier = "==0.1.0" }, + { name = "invoke-toolkit-other", specifier = "==0.2.0" }, +] +""", + encoding="utf-8", + ) + plugins = ( + Plugin("invoke-toolkit-litellm", "0.1.0"), + Plugin("invoke-toolkit-other", "0.2.0"), + ) + with ( + patch.object(plugin, "active_tool", return_value=tool), + patch.object(plugin, "installed_plugins", return_value=plugins), + ): + plugin.remove.body(ctx, package="litellm") + + command = ctx.run.call_args.args[0] + assert "invoke-toolkit-litellm" not in command + assert "invoke-toolkit-other==0.2.0" in command + + +def _load_root_tasks(): + import importlib.util + + root = Path(__file__).parents[2] / "tasks.py" + spec = importlib.util.spec_from_file_location("issue84_root_tasks", root) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_version_summary_is_empty_outside_uv_tool(monkeypatch): + tasks = _load_root_tasks() + monkeypatch.setattr(tasks, "active_tool", lambda: None) + assert tasks._uv_tool_summary() == "" + + +def test_version_summary_lists_plugin_versions(tmp_path: Path, monkeypatch): + tasks = _load_root_tasks() + monkeypatch.setattr( + tasks, "active_tool", lambda: UvTool("invoke-toolkit", "1.2.3", tmp_path) + ) + monkeypatch.setattr( + tasks, + "installed_plugins", + lambda: (Plugin("invoke-toolkit-litellm", "0.1.0"),), + ) + + assert tasks._uv_tool_summary() == ( + " (uv tool; plugins: invoke-toolkit-litellm 0.1.0)" + )