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
1 change: 1 addition & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* Dropped support for Python 3.9 (EOL Oct 2025). Minimum supported version is now 3.10.

## Bug fixes and other changes
* Bumped `click` dependency to support versions 8.2.0 and above.

## Documentation changes
* Added a note on programmatically creating lambdas when lazily saving a `PartionedDataset`.
Expand Down
4 changes: 2 additions & 2 deletions kedro/framework/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,15 +211,15 @@ def main(
raise

@property
def global_groups(self) -> Sequence[click.MultiCommand]:
def global_groups(self) -> Sequence[click.Group]:
"""Property which loads all global command groups from plugins and
combines them with the built-in ones (eventually overriding the
built-in ones if they are redefined by plugins).
"""
return [cli, *load_entry_points("global"), global_commands]

@property
def project_groups(self) -> Sequence[click.MultiCommand]:
def project_groups(self) -> Sequence[click.Group]:
"""Property which loads all project command groups from the
project and the plugins, then combines them with the built-in ones.
Built-in commands can be overridden by plugins, which can be
Expand Down
13 changes: 7 additions & 6 deletions kedro/framework/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Sequence

from importlib import import_module
from itertools import chain
from pathlib import Path
Expand Down Expand Up @@ -131,7 +132,7 @@ def validate_conf_source(ctx: click.Context, param: Any, value: str) -> str | No
class CommandCollection(click.CommandCollection):
"""Modified from the Click one to still run the source groups function."""

def __init__(self, *groups: tuple[str, Sequence[click.MultiCommand]]):
def __init__(self, *groups: tuple[str, Sequence[click.Group]]):
self.groups = [
(title, self._merge_same_name_collections(cli_list))
for title, cli_list in groups
Expand All @@ -153,9 +154,9 @@ def __init__(self, *groups: tuple[str, Sequence[click.MultiCommand]]):

@staticmethod
def _merge_same_name_collections(
groups: Sequence[click.MultiCommand],
groups: Sequence[click.Group],
) -> list[click.CommandCollection]:
named_groups: defaultdict[str, list[click.MultiCommand]] = defaultdict(list)
named_groups: defaultdict[str, list[click.Group]] = defaultdict(list)
helps: defaultdict[str, list] = defaultdict(list)
for group in groups:
named_groups[group.name].append(group) # type: ignore[index]
Expand Down Expand Up @@ -351,7 +352,7 @@ def _safe_load_entry_point(
return


def load_entry_points(name: str) -> Sequence[click.MultiCommand]:
def load_entry_points(name: str) -> Sequence[click.Group]:
"""Load package entry point commands.

Args:
Expand Down Expand Up @@ -529,12 +530,12 @@ def list_commands(self, ctx: click.Context) -> list[str]:

def get_command( # type: ignore[override]
self, ctx: click.Context, cmd_name: str
) -> click.BaseCommand | click.Command | None:
) -> click.Command | None:
if cmd_name in self.lazy_subcommands:
return self._lazy_load(cmd_name)
return super().get_command(ctx, cmd_name)

def _lazy_load(self, cmd_name: str) -> click.BaseCommand:
def _lazy_load(self, cmd_name: str) -> click.Command:
# lazily loading a command, first get the module name and attribute name
import_path = self.lazy_subcommands[cmd_name]
modname, cmd_object_name = import_path.rsplit(".", 1)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ dependencies = [
"attrs>=21.3",
"build>=0.7.0",
"cachetools>=4.1",
"click>=4.0,<8.2.0",
"click>=8.2",
"cookiecutter>=2.1.1,<3.0",
"dynaconf>=3.1.2,<4.0",
"fsspec>=2021.4",
Expand Down
20 changes: 18 additions & 2 deletions tests/framework/cli/pipeline/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,15 @@ def test_pipeline_delete_confirmation(
f"Are you sure you want to delete pipeline '{PIPELINE_NAME}'"
in result.output
)
assert "Deletion aborted!" in result.output
# Click < 8.2: "random" input asks again, gets EOF, uses default (no)
# -> "Deletion aborted!"
# Click >= 8.2: "random" input with EOF immediately aborts
# -> "Aborted!"
# Valid "n" or "N" always shows "Deletion aborted!"
if input_ == "random":
assert "Aborted!" in result.output or "Deletion aborted!" in result.output
else:
assert "Deletion aborted!" in result.output

assert source_path.is_dir()
assert tests_path.is_dir()
Expand Down Expand Up @@ -546,7 +554,15 @@ def test_pipeline_delete_confirmation_skip(
f"Are you sure you want to delete pipeline '{PIPELINE_NAME}'"
in result.output
)
assert "Deletion aborted!" in result.output
# Click < 8.2: "random" input asks again, gets EOF, uses default (no)
# -> "Deletion aborted!"
# Click >= 8.2: "random" input with EOF immediately aborts
# -> "Aborted!"
# Valid "n" or "N" always shows "Deletion aborted!"
if input_ == "random":
assert "Aborted!" in result.output or "Deletion aborted!" in result.output
else:
assert "Deletion aborted!" in result.output

assert tests_path.is_dir()
assert params_path.is_file()
Expand Down
22 changes: 16 additions & 6 deletions tests/framework/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ def test_cli(self):
"""Run `kedro` without arguments."""
result = CliRunner().invoke(cli, [])

assert result.exit_code == 0
# Exit code 2: click 8.2+ exits with code 2 when a group is invoked
# without a subcommand
assert result.exit_code == 2
assert "kedro" in result.output

def test_print_version(self):
Expand Down Expand Up @@ -200,7 +202,9 @@ def test_help(self):
"""Check that help output includes stub_cli group description."""
cmd_collection = CommandCollection(("Commands", [cli, stub_cli]))
result = CliRunner().invoke(cmd_collection, [])
assert result.exit_code == 0
# Exit code 2: click 8.2+ exits with code 2 when a group is invoked
# without a subcommand
assert result.exit_code == 2
assert "Stub CLI group description" in result.output
assert "Kedro is a CLI" in result.output

Expand Down Expand Up @@ -453,7 +457,9 @@ def test_kedro_cli_no_project(self, mocker, tmp_path):

result = CliRunner().invoke(kedro_cli, [])

assert result.exit_code == 0
# Exit code 2: click 8.2+ exits with code 2 when a group is invoked
# without a subcommand
assert result.exit_code == 2
assert "Global commands from kedro" in result.output
assert "Project specific commands from kedro" not in result.output

Expand Down Expand Up @@ -487,7 +493,9 @@ def test_kedro_cli_with_project(self, mocker, fake_metadata):
]

result = CliRunner().invoke(kedro_cli, [])
assert result.exit_code == 0
# Exit code 2: click 8.2+ exits with code 2 when a group is invoked
# without a subcommand
assert result.exit_code == 2
assert "Global commands from kedro" in result.output
assert "Project specific commands from kedro" in result.output

Expand Down Expand Up @@ -878,9 +886,10 @@ def test_bad_runtime_params(self, fake_project_cli, fake_metadata, bad_arg):
fake_project_cli, ["run", "--params", bad_arg], obj=fake_metadata
)
assert result.exit_code
# Click 8.2+ sends error messages to stderr, so check result.output
assert (
"Item `bad` must contain a key and a value separated by `=`."
in result.stdout
in result.output
)

@mark.parametrize("bad_arg", ["=", "=value", " =value"])
Expand All @@ -889,7 +898,8 @@ def test_bad_params_key(self, fake_project_cli, fake_metadata, bad_arg):
fake_project_cli, ["run", "--params", bad_arg], obj=fake_metadata
)
assert result.exit_code
assert "Parameter key cannot be an empty string" in result.stdout
# Click 8.2+ sends error messages to stderr, so check result.output
assert "Parameter key cannot be an empty string" in result.output

@mark.parametrize(
"lv_input, lv_dict",
Expand Down
7 changes: 5 additions & 2 deletions tests/framework/cli/test_cli_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ def fake_plugin_distribution(mocker):
class TestKedroCLIHooks:
@pytest.mark.parametrize(
"command, exit_code",
[("-V", 0), ("info", 0), ("pipeline list", 2), ("starter", 0)],
# Exit code 2: click 8.2+ exits with code 2 when a group is invoked
# without a subcommand (applies to 'starter')
[("-V", 0), ("info", 0), ("pipeline list", 2), ("starter", 2)],
)
def test_kedro_cli_should_invoke_cli_hooks_from_plugin(
self,
Expand Down Expand Up @@ -122,5 +124,6 @@ def test_kedro_cli_should_invoke_cli_hooks_from_plugin(
# 'pipeline list' isn't actually in the click structure and
# return exit code 2 ('invalid usage of some shell built-in command')
assert (
f"After command `{command}` run for project {fake_metadata} (exit: {exit_code})"
f"After command `{command}` run for project {fake_metadata} "
f"(exit: {exit_code})"
) in result.output