diff --git a/CHANGELOG.md b/CHANGELOG.md index b51e735..185b02e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,12 +17,17 @@ on version bumps. the `invoke_toolkit.field_resolver` entry-point group. - Resolver-only package scaffolding via `intk -x create.package --provider `. +- `completion.disable_plugins: true` and `INVOKE_COMPLETION_DISABLE_PLUGINS=1` + to omit installed entry-point plugins from shell completion without changing + normal task discovery or execution. ### Fixed - Preserve carriage-return bytes when forwarding terminal input to interactive subprocesses launched with `pty=True`, so Enter behaves like direct execution. +- Avoid importing Copier and its dependency tree while completing built-in + `-x` tasks; Copier now loads only when `create.package` executes. ### Breaking Changes diff --git a/README.md b/README.md index 4c3fd0c..4a89465 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,21 @@ do not update invoke-toolkit itself. Shell completion suggests directly installed plugin names for `plugin.remove` and named `plugin.update` operations. +To reduce shell-completion latency in environments with expensive collection +plugins, set `completion.disable_plugins: true` in `invoke.yaml`: + +```yaml +completion: + disable_plugins: true +``` + +`INVOKE_COMPLETION_DISABLE_PLUGINS=1` enables the same behavior without changing +the configuration file. Completion then skips installed +`invoke_toolkit.collection` entry points while retaining project, local, and +built-in `-x` task suggestions. These settings affect completion only; normal +task execution and listing still load plugins. + + These commands reject `uvx`, `uv run`, project environments, and installations managed by another tool. pipx offers a similar manual workflow: diff --git a/src/invoke_toolkit/config/config.py b/src/invoke_toolkit/config/config.py index 78961cc..2b4adf7 100644 --- a/src/invoke_toolkit/config/config.py +++ b/src/invoke_toolkit/config/config.py @@ -101,6 +101,7 @@ def global_defaults(cls) -> dict[str, Any]: ret["init_shell"] = False ret["completion"] = { "callback_timeout": 10.0, # Timeout in seconds for completion callbacks + "disable_plugins": False, # Skip entry-point plugins during completion } ret["fuzzy_finder"] = { "show_warnings": True, # Show warning when fzf is not available diff --git a/src/invoke_toolkit/extensions/tasks/create.py b/src/invoke_toolkit/extensions/tasks/create.py index 3eec08a..6922441 100644 --- a/src/invoke_toolkit/extensions/tasks/create.py +++ b/src/invoke_toolkit/extensions/tasks/create.py @@ -19,16 +19,23 @@ from invoke_toolkit import Context, __version__, task from invoke_toolkit.loader.entrypoint import COLLECTION_ENTRY_POINT, PLUGIN_PREFIX -try: - from copier import run_copy -except ImportError: - run_copy = None # type: ignore[assignment] - # Git config key for custom template repository/path GIT_CONFIG_TEMPLATE_KEY = "invoke-toolkit.package-template" +def _load_copier(ctx: Context): + """Load Copier only when package generation needs it.""" + try: + from copier import run_copy # pylint: disable=import-outside-toplevel + except ImportError: + ctx.rich_exit( + "copier is required to create packages. " + "Install it with: uv pip install invoke-toolkit[copier]" + ) + return run_copy + + def _get_git_config_value(key: str) -> str | None: """ Get a value from git config. @@ -272,11 +279,7 @@ def package( Provider packages contain only a field-resolver entry point and never expose invoke-toolkit task collections. """ - if run_copy is None: - ctx.rich_exit( - "copier is required to create packages. " - "Install it with: uv pip install invoke-toolkit[copier]" - ) + run_copy = _load_copier(ctx) if provider and (name or ext_name or template): ctx.rich_exit( diff --git a/src/invoke_toolkit/program/program.py b/src/invoke_toolkit/program/program.py index 8b56d52..dfb28cd 100644 --- a/src/invoke_toolkit/program/program.py +++ b/src/invoke_toolkit/program/program.py @@ -605,17 +605,29 @@ def load_collection(self) -> None: ) # Load local tasks if they exist self.collection.load_local_tasks(search_path=parent) - # Also try to load entry points (merge with tasks.py) - ep_collection = self._load_entry_points_collection(parent) - if ep_collection is not None: - debug("Merging entry point collections with tasks.py") - for name, collection in ep_collection.collections.items(): - self.collection.add_collection(collection, name=name) - self._plugin_collection_names.add(name) + # Also try to load entry points (merge with tasks.py), unless the + # user opted out of plugin discovery for latency-sensitive completion. + if not self._completion_plugins_disabled(): + ep_collection = self._load_entry_points_collection(parent) + if ep_collection is not None: + debug("Merging entry point collections with tasks.py") + for name, collection in ep_collection.collections.items(): + self.collection.add_collection(collection, name=name) + self._plugin_collection_names.add(name) except CollectionNotFound as e: - start = self.args["search-root"].value or "." - # First try to load entry points (for packages without tasks.py) - ep_collection = self._load_entry_points_collection(start) + start = start or "." + # A missing tasks.py cannot provide the project location, so load + # configuration from the resolved search root before deciding + # whether completion should discover entry-point plugins. + self.config.set_project_location(start) + self.config.load_project() + # First try entry points for packages without tasks.py. Completion + # may explicitly skip this potentially expensive plugin discovery. + ep_collection = ( + None + if self._completion_plugins_disabled() + else self._load_entry_points_collection(start) + ) if ep_collection is not None: debug("Loading collections from entry points") self.collection = ep_collection @@ -643,9 +655,6 @@ def load_collection(self) -> None: debug("No collection found, will checking for internal") else: debug("No tasks.py found, but local_tasks.py exists, continuing...") - start = self.args["search-root"].value - self.config.set_project_location(start) - self.config.load_project() self.collection = ToolkitCollection(EMPTY_COLLECTION_NAME) # Try to load local tasks if they exist self.collection.load_local_tasks(search_path=start) @@ -668,6 +677,15 @@ def flat_args(self) -> dict[str, bool | int | str | list[str]]: """Flat arguments""" return {name: arg.value for name, arg in self.args.items()} + def _completion_plugins_disabled(self) -> bool: + """Return whether this completion request excludes entry-point plugins.""" + if not self.args.complete.value: + return False + return bool( + self.config.completion.disable_plugins + or env_enabled(os.getenv("INVOKE_COMPLETION_DISABLE_PLUGINS", "0")) + ) + def _has_internal_col_flag_in_completion(self) -> bool: """ Check if -x or --internal-col flag is present in the completion context. diff --git a/tests/extensions/test_create.py b/tests/extensions/test_create.py index 83dafd7..25b56de 100644 --- a/tests/extensions/test_create.py +++ b/tests/extensions/test_create.py @@ -1,4 +1,5 @@ import re +import builtins import subprocess import typing from pathlib import Path @@ -11,6 +12,7 @@ from invoke_toolkit.extensions.tasks.create import ( GIT_CONFIG_TEMPLATE_KEY, _get_git_config_value, + _load_copier, ) from invoke_toolkit.testing import TestingToolkitProgram @@ -18,6 +20,20 @@ from tests.conftest import ShellRunTmp, TempVenv +def test_load_copier_exits_when_optional_dependency_is_missing(monkeypatch, ctx): + real_import = builtins.__import__ + + def import_without_copier(name, *args, **kwargs): + if name == "copier": + raise ImportError("copier unavailable") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", import_without_copier) + + with pytest.raises(SystemExit): + _load_copier(ctx) + + def test_new_script_has_hello_world_task( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_cofig_class.py b/tests/test_cofig_class.py index a98dde1..107f639 100644 --- a/tests/test_cofig_class.py +++ b/tests/test_cofig_class.py @@ -8,3 +8,9 @@ class MyConfig( pass assert MyConfig.prefix == "custom" + + +def test_completion_plugins_are_enabled_by_default(): + config = ToolkitConfig() + + assert config.completion.disable_plugins is False diff --git a/tests/test_program_with_collection.py b/tests/test_program_with_collection.py index b51e122..13a6591 100644 --- a/tests/test_program_with_collection.py +++ b/tests/test_program_with_collection.py @@ -1,7 +1,9 @@ import json +import os import pkgutil import subprocess import sys +from pathlib import Path from typing import Any from invoke_toolkit import Context, task @@ -15,6 +17,64 @@ def get_extension_collection_names() -> set[str]: return {module.name for module in pkgutil.iter_modules(extensions_tasks.__path__)} +def _install_completion_plugin(path: Path) -> Path: + """Create importable plugin metadata and return its import sentinel path.""" + sentinel = path / "plugin-imported" + (path / "fixture_plugin.py").write_text( + "from pathlib import Path\n" + "from invoke_toolkit import Collection, task\n" + f"Path({str(sentinel)!r}).touch()\n" + "@task\n" + "def marker(ctx):\n" + " pass\n" + "collection = Collection(marker)\n", + encoding="utf-8", + ) + metadata = path / "fixture_plugin-1.0.dist-info" + metadata.mkdir() + (metadata / "METADATA").write_text( + "Metadata-Version: 2.1\nName: fixture-plugin\nVersion: 1.0\n", + encoding="utf-8", + ) + (metadata / "entry_points.txt").write_text( + "[invoke_toolkit.collection]\nfixture-plugin = fixture_plugin:collection\n", + encoding="utf-8", + ) + return sentinel + + +def _complete_from( + path: Path, + *, + disable_plugins: bool, + completion_args: list[str] | None = None, +) -> subprocess.CompletedProcess: + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join( + part for part in (str(path), env.get("PYTHONPATH", "")) if part + ) + if disable_plugins: + env["INVOKE_COMPLETION_DISABLE_PLUGINS"] = "1" + else: + env.pop("INVOKE_COMPLETION_DISABLE_PLUGINS", None) + return subprocess.run( + [ + sys.executable, + "-m", + "invoke_toolkit", + "--complete", + "--", + "intk", + *(completion_args or []), + ], + cwd=path, + env=env, + capture_output=True, + text=True, + check=False, + ) + + @task() def example_task_1(ctx: Context): ctx.run("echo example_task_1") @@ -61,6 +121,161 @@ def test_completion_with_x_flag(suppress_stderr_logging): assert "create" in output, "create collection should be in completion with -x" +def test_completion_loads_entry_point_plugins_by_default(tmp_path): + sentinel = _install_completion_plugin(tmp_path) + + result = _complete_from(tmp_path, disable_plugins=False) + + assert result.returncode == 0, result.stderr + assert "fixture-plugin.marker" in result.stdout + assert sentinel.exists() + + +def test_completion_can_skip_entry_point_plugins(tmp_path): + sentinel = _install_completion_plugin(tmp_path) + (tmp_path / "tasks.py").write_text( + "from invoke_toolkit import Collection, task\n" + "@task\n" + "def project_marker(ctx):\n" + " pass\n" + "ns = Collection(project_marker)\n", + encoding="utf-8", + ) + + result = _complete_from(tmp_path, disable_plugins=True) + + assert result.returncode == 0, result.stderr + assert "fixture-plugin.marker" not in result.stdout + assert "project-marker" in result.stdout + assert not sentinel.exists() + + +def test_completion_can_skip_entry_point_plugins_from_project_config(tmp_path): + sentinel = _install_completion_plugin(tmp_path) + (tmp_path / "tasks.py").write_text( + "from invoke_toolkit import Collection, task\n" + "@task\n" + "def project_marker(ctx):\n" + " pass\n" + "ns = Collection(project_marker)\n", + encoding="utf-8", + ) + (tmp_path / "invoke.yaml").write_text( + "completion:\n disable_plugins: true\n", + encoding="utf-8", + ) + + result = _complete_from(tmp_path, disable_plugins=False) + + assert result.returncode == 0, result.stderr + assert "fixture-plugin.marker" not in result.stdout + assert "project-marker" in result.stdout + assert not sentinel.exists() + + +def test_completion_can_skip_plugins_from_config_without_tasks_file(tmp_path): + sentinel = _install_completion_plugin(tmp_path) + (tmp_path / "invoke.yaml").write_text( + "completion:\n disable_plugins: true\n", + encoding="utf-8", + ) + + result = _complete_from(tmp_path, disable_plugins=False) + + assert result.returncode == 0, result.stderr + assert "fixture-plugin.marker" not in result.stdout + assert not sentinel.exists() + + +def test_completion_search_root_keeps_local_tasks_when_plugins_disabled(tmp_path): + project_path = tmp_path / "project" + project_path.mkdir() + (project_path / "local_tasks.py").write_text( + "from invoke_toolkit import task\n@task\ndef local_marker(ctx):\n pass\n", + encoding="utf-8", + ) + + result = _complete_from( + tmp_path, + disable_plugins=True, + completion_args=["--search-root", str(project_path), "local."], + ) + + assert result.returncode == 0, result.stderr + assert "local.local-marker" in result.stdout + + +def test_plugin_completion_opt_out_does_not_affect_execution(tmp_path): + sentinel = _install_completion_plugin(tmp_path) + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join( + part for part in (str(tmp_path), env.get("PYTHONPATH", "")) if part + ) + env["INVOKE_COMPLETION_DISABLE_PLUGINS"] = "1" + + result = subprocess.run( + [sys.executable, "-m", "invoke_toolkit", "--list"], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert "fixture-plugin.marker" in result.stdout + assert sentinel.exists() + + +def test_plugin_completion_opt_out_keeps_internal_collections(tmp_path): + env = os.environ.copy() + env["INVOKE_COMPLETION_DISABLE_PLUGINS"] = "1" + + result = subprocess.run( + [ + sys.executable, + "-m", + "invoke_toolkit", + "--complete", + "--", + "intk", + "-x", + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert "config" in result.stdout + assert "create" in result.stdout + + +def test_internal_completion_does_not_import_copier(): + """Completing built-in task names must not load the optional Copier stack.""" + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import sys; " + "from invoke_toolkit.testing import TestingToolkitProgram; " + "TestingToolkitProgram().run(" + "['intk', '--complete', '--', 'intk', '-x'], exit=False); " + "assert 'copier' not in sys.modules, " + "'copier imported during completion'" + ), + ], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + def test_completion_without_x_flag(suppress_stderr_logging): """Test that completion does not include internal collections without -x""" result = subprocess.run(