diff --git a/sdk/python/kfp/cli/__main__.py b/sdk/python/kfp/cli/__main__.py index 7b47007f478c..633f363eb507 100644 --- a/sdk/python/kfp/cli/__main__.py +++ b/sdk/python/kfp/cli/__main__.py @@ -16,18 +16,11 @@ import sys import click -from kfp.cli import (cli, components, diagnose_me_cli, experiment, pipeline, - recurring_run, run) +from kfp.cli import cli def main(): logging.basicConfig(format='%(message)s', level=logging.INFO) - cli.cli.add_command(run.run) - cli.cli.add_command(recurring_run.recurring_run) - cli.cli.add_command(pipeline.pipeline) - cli.cli.add_command(diagnose_me_cli.diagnose_me) - cli.cli.add_command(experiment.experiment) - cli.cli.add_command(components.components) try: cli.cli(obj={}, auto_envvar_prefix='KFP') except Exception as e: diff --git a/sdk/python/kfp/cli/cli.py b/sdk/python/kfp/cli/cli.py index dd42ceba5366..78a571d57612 100644 --- a/sdk/python/kfp/cli/cli.py +++ b/sdk/python/kfp/cli/cli.py @@ -1,4 +1,4 @@ -# Copyright 2018 The Kubeflow Authors +# Copyright 2018-2022 The Kubeflow Authors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,11 +13,23 @@ # limitations under the License. import click +from kfp.cli import component +from kfp.cli import diagnose_me_cli +from kfp.cli import experiment +from kfp.cli import pipeline +from kfp.cli import recurring_run +from kfp.cli import run from kfp.cli.output import OutputFormat +from kfp.cli.utils import aliased_plurals_group from kfp.client import Client +COMMANDS = [ + run.run, recurring_run.recurring_run, experiment.experiment, + pipeline.pipeline, diagnose_me_cli.diagnose_me, component.component +] -@click.group() + +@click.group(cls=aliased_plurals_group.AliasedPluralsGroup, commands=COMMANDS) @click.option('--endpoint', help='Endpoint of the KFP API service to connect.') @click.option('--iap-client-id', help='Client ID for IAP protected endpoint.') @click.option( diff --git a/sdk/python/kfp/cli/cli_test.py b/sdk/python/kfp/cli/cli_test.py new file mode 100644 index 000000000000..0c645490065f --- /dev/null +++ b/sdk/python/kfp/cli/cli_test.py @@ -0,0 +1,50 @@ +# Copyright 2022 The Kubeflow Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import functools +import sys +import unittest +from unittest import mock + +from click import testing + +# Docker is an optional install, but we need the import to succeed for tests. +# So we patch it before importing kfp.cli.components. +try: + import docker +except ImportError: + sys.modules['docker'] = mock.Mock() + +from kfp.cli import cli + + +class TestCli(unittest.TestCase): + + def setUp(self): + runner = testing.CliRunner() + self.invoke = functools.partial( + runner.invoke, cli=cli.cli, catch_exceptions=False, obj={}) + + def test_aliases_singular(self): + result = self.invoke(args=['component']) + self.assertEqual(result.exit_code, 0) + + def test_aliases_plural(self): + result = self.invoke(args=['runs']) + self.assertEqual(result.exit_code, 0) + + def test_aliases_fails(self): + result = self.invoke(args=['runss']) + self.assertEqual(result.exit_code, 2) + self.assertEqual("Error: Unrecognized command 'runss'\n", result.output) diff --git a/sdk/python/kfp/cli/components.py b/sdk/python/kfp/cli/component.py similarity index 98% rename from sdk/python/kfp/cli/components.py rename to sdk/python/kfp/cli/component.py index 70fc16d2313f..ed525b3053cd 100644 --- a/sdk/python/kfp/cli/components.py +++ b/sdk/python/kfp/cli/component.py @@ -32,7 +32,9 @@ _DOCKER_IS_PRESENT = False import kfp as kfp -from kfp.components import component_factory, kfp_config, utils +from kfp.components import component_factory +from kfp.components import kfp_config +from kfp.components import utils _REQUIREMENTS_TXT = 'requirements.txt' @@ -329,12 +331,12 @@ def build_image(self, push_image: bool = True): @click.group() -def components(): +def component(): """Builds shareable, containerized components.""" pass -@components.command() +@component.command() @click.argument( "components_directory", type=pathlib.Path, diff --git a/sdk/python/kfp/cli/components_test.py b/sdk/python/kfp/cli/components_test.py index ed90076079d5..d596d5f3beeb 100644 --- a/sdk/python/kfp/cli/components_test.py +++ b/sdk/python/kfp/cli/components_test.py @@ -28,7 +28,7 @@ import docker # pylint: disable=unused-import except ImportError: sys.modules['docker'] = mock.Mock() -from kfp.cli import components +from kfp.cli import component def _make_component(func_name: str, @@ -69,8 +69,8 @@ class Test(unittest.TestCase): def setUp(self) -> None: self.runner = testing.CliRunner() - self.cli = components.components - components._DOCKER_IS_PRESENT = True + self.cli = component.component + component._DOCKER_IS_PRESENT = True patcher = mock.patch('docker.from_env') self._docker_client = patcher.start().return_value @@ -247,6 +247,11 @@ def testComponentFilepatternCanBeUsedToRestrictDiscovery(self): str(self._working_dir), '--component-filepattern=train/*' ], ) + print("OUTPUT", result.output) + print("DONE") + print(result.exc_info) + import traceback + traceback.print_tb(result.exc_info[2]) self.assertEqual(result.exit_code, 0) self.assertFileExistsAndContains( diff --git a/sdk/python/kfp/cli/utils/__init__.py b/sdk/python/kfp/cli/utils/__init__.py new file mode 100644 index 000000000000..45da0a9502db --- /dev/null +++ b/sdk/python/kfp/cli/utils/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2022 The Kubeflow Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. \ No newline at end of file diff --git a/sdk/python/kfp/cli/utils/aliased_plurals_group.py b/sdk/python/kfp/cli/utils/aliased_plurals_group.py new file mode 100644 index 000000000000..d905bc23ed4e --- /dev/null +++ b/sdk/python/kfp/cli/utils/aliased_plurals_group.py @@ -0,0 +1,37 @@ +# Copyright 2022 The Kubeflow Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import List, Tuple, Union + +import click + + +class AliasedPluralsGroup(click.Group): + + def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command: + regular = click.Group.get_command(self, ctx, cmd_name) + if regular is not None: + return regular + elif cmd_name.endswith("s"): + singular = click.Group.get_command(self, ctx, cmd_name[:-1]) + if singular is not None: + return singular + raise click.UsageError(f"Unrecognized command '{cmd_name}'") + + def resolve_command( + self, ctx: click.Context, args: List[str] + ) -> Tuple[Union[str, None], Union[click.Command, None], List[str]]: + # always return the full command name + _, cmd, args = super().resolve_command(ctx, args) + return cmd.name, cmd, args # type: ignore diff --git a/sdk/python/kfp/cli/utils/aliased_plurals_group_test.py b/sdk/python/kfp/cli/utils/aliased_plurals_group_test.py new file mode 100644 index 000000000000..d50aa93c96e4 --- /dev/null +++ b/sdk/python/kfp/cli/utils/aliased_plurals_group_test.py @@ -0,0 +1,55 @@ +# Copyright 2022 The Kubeflow Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import click +from click import testing +from kfp.cli.utils import aliased_plurals_group + + +@click.group(cls=aliased_plurals_group.AliasedPluralsGroup) +def cli(): + pass + + +@cli.command() +def command(): + click.echo('Called command.') + + +class TestAliasedPluralsGroup(unittest.TestCase): + + def setUp(self): + self.runner = testing.CliRunner() + + def test_aliases_default_success(self): + result = self.runner.invoke(cli, ['command']) + self.assertEqual(result.exit_code, 0) + self.assertEqual(result.output, "Called command.\n") + + def test_aliases_plural_success(self): + result = self.runner.invoke(cli, ['commands']) + self.assertEqual(result.exit_code, 0) + self.assertEqual(result.output, "Called command.\n") + + def test_aliases_failure(self): + result = self.runner.invoke(cli, ['commandss']) + self.assertEqual(result.exit_code, 2) + self.assertEqual("Error: Unrecognized command 'commandss'\n", + result.output) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file