Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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 <scheme>`.
- `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

Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/invoke_toolkit/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 13 additions & 10 deletions src/invoke_toolkit/extensions/tasks/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
44 changes: 31 additions & 13 deletions src/invoke_toolkit/program/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions tests/extensions/test_create.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import re
import builtins
import subprocess
import typing
from pathlib import Path
Expand All @@ -11,13 +12,28 @@
from invoke_toolkit.extensions.tasks.create import (
GIT_CONFIG_TEMPLATE_KEY,
_get_git_config_value,
_load_copier,
)
from invoke_toolkit.testing import TestingToolkitProgram

if typing.TYPE_CHECKING:
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,
Expand Down
6 changes: 6 additions & 0 deletions tests/test_cofig_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading