diff --git a/tools/pyproject.toml b/tools/pyproject.toml index eeba9ee4..055391d9 100644 --- a/tools/pyproject.toml +++ b/tools/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ dependencies = [ "attrs >= 25.3, < 26.2", "certifi >= 2024, < 2027", - "click == 7.1.2", + "click == 8.5.0", "click-configfile == 0.2.3", "configparser >= 7.2, < 7.3", "dvp-libs == 5.2.0", @@ -52,7 +52,7 @@ dev = [ "entrypoints == 0.4", "flake8 == 7.3.0", "httpretty == 1.1.4", - "isort == 8.0.1", + "isort == 9.0.2", "mccabe == 0.7.0", "mock == 5.2.0", "more-itertools == 11.1.0", diff --git a/tools/src/main/python/dlpx/virtualization/_internal/cli.py b/tools/src/main/python/dlpx/virtualization/_internal/cli.py index 850ab757..b513bf14 100644 --- a/tools/src/main/python/dlpx/virtualization/_internal/cli.py +++ b/tools/src/main/python/dlpx/virtualization/_internal/cli.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2019, 2021 by Delphix. All rights reserved. +# Copyright (c) 2019, 2026 by Delphix. All rights reserved. # import logging @@ -64,7 +64,7 @@ def command_error_handler(): exit(2) -@click.group(context_settings=CONTEXT_SETTINGS) +@click.group(context_settings=CONTEXT_SETTINGS, invoke_without_command=True) @click.version_option(__version__) @click.option( '-v', @@ -84,7 +84,8 @@ def command_error_handler(): type=click.IntRange(0, 3), help=('Enable quiet mode. ' 'Can be repeated up to three times for increased suppression.')) -def delphix_sdk(verbose, quiet): +@click.pass_context +def delphix_sdk(ctx, verbose, quiet): """ The tools of the Delphix Virtualization SDK that help develop, build, and upload a plugin. @@ -100,6 +101,16 @@ def delphix_sdk(verbose, quiet): 'Python version check failed.' 'Supported version is 3.11.x, found {}' .format(sys.version_info)) + # + # Click >= 8.1 treats a group invoked with no subcommand as a usage + # error (exit code 2) instead of printing help and exiting 0 as Click + # 7.x did. invoke_without_command=True skips that check, so replicate + # the old exit-0/help behavior explicitly here to keep it unchanged + # for anything scripting around a bare `dvp` invocation. + # + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + ctx.exit() @delphix_sdk.command(context_settings=CONTEXT_SETTINGS_INIT) @@ -108,11 +119,11 @@ def delphix_sdk(verbose, quiet): 'root', default=os.getcwd(), show_default=True, - type=click.Path(exists=True, - file_okay=False, - dir_okay=True, - writable=True, - resolve_path=True), + type=click_util.NonEmptyPath(exists=True, + file_okay=False, + dir_okay=True, + writable=True, + resolve_path=True), callback=click_util.validate_option_exists, help='Set the plugin root directory.') @click.option('-n', @@ -297,11 +308,11 @@ def upload(engine, user, upload_artifact, password, wait): '--directory', default=os.getcwd(), show_default=True, - type=click.Path(exists=True, - file_okay=False, - dir_okay=True, - writable=True, - resolve_path=True), + type=click_util.NonEmptyPath(exists=True, + file_okay=False, + dir_okay=True, + writable=True, + resolve_path=True), callback=click_util.validate_option_exists, help='Specify the directory of where to download the plugin logs.') @click.password_option(cls=click_util.PasswordPromptIf, diff --git a/tools/src/main/python/dlpx/virtualization/_internal/click_util.py b/tools/src/main/python/dlpx/virtualization/_internal/click_util.py index b8b0941c..e606bcd5 100644 --- a/tools/src/main/python/dlpx/virtualization/_internal/click_util.py +++ b/tools/src/main/python/dlpx/virtualization/_internal/click_util.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2019 by Delphix. All rights reserved. +# Copyright (c) 2019, 2026 by Delphix. All rights reserved. # import os @@ -72,6 +72,22 @@ def validate_option_exists(ctx, param, value): return value +class NonEmptyPath(click.Path): + """ + A click.Path that rejects an empty string outright. Click >= 8.0 + resolves an empty string to the current working directory before + running its exists/writable checks (via os.path.realpath('')), so an + empty value for a Path option with resolve_path=True silently passes + instead of failing as it did on Click 7.x. + """ + def convert(self, value, param, ctx): + if value == '': + self.fail( + '{} {!r} does not exist.'.format(self.name.title(), value), + param, ctx) + return super(NonEmptyPath, self).convert(value, param, ctx) + + class MutuallyExclusiveOption(click.Option): """ A Click Option type that is mutually exclusive with another option. Click diff --git a/tools/src/test/python/dlpx/virtualization/_internal/test_cli.py b/tools/src/test/python/dlpx/virtualization/_internal/test_cli.py index 2ec8ad0b..6567ead7 100644 --- a/tools/src/test/python/dlpx/virtualization/_internal/test_cli.py +++ b/tools/src/test/python/dlpx/virtualization/_internal/test_cli.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2019, 2021 by Delphix. All rights reserved. +# Copyright (c) 2019, 2026 by Delphix. All rights reserved. # import os @@ -18,6 +18,9 @@ class TestCli: def test_default_verbosity(): runner = click_testing.CliRunner() + # Click >= 8.1 would otherwise exit 2 (usage error) here instead of + # 0 as in Click 7.x; delphix_sdk explicitly restores the old + # help/exit-0 behavior for a bare invocation with no subcommand. result = runner.invoke(cli.delphix_sdk) assert result.exit_code == 0, 'Output: {}'.format(result.output) @@ -337,7 +340,7 @@ def test_file_not_exist(plugin_config_file): assert result.exit_code == 2 assert result.output == (u"Usage: delphix-sdk build [OPTIONS]" - u"\nTry 'delphix-sdk build -h' for help." + u"\nTry 'delphix-sdk build --help' for help." u"\n" u"\nError: Invalid value for '-c' /" u" '--plugin-config': File" @@ -517,7 +520,7 @@ def test_file_not_exist(artifact_file): assert result.exit_code == 2 assert result.output == (u"Usage: delphix-sdk upload [OPTIONS]" - u"\nTry 'delphix-sdk upload -h' for help." + u"\nTry 'delphix-sdk upload --help' for help." u"\n" u"\nError: Invalid value for '-a' /" u" '--upload-artifact': File" @@ -698,7 +701,7 @@ def test_directory_not_exist(directory): assert result.exit_code == 2 assert result.output == ( u"Usage: delphix-sdk download-logs [OPTIONS]" - u"\nTry 'delphix-sdk download-logs -h' for help." + u"\nTry 'delphix-sdk download-logs --help' for help." u"\n" u"\nError: Invalid value for '-d' /" u" '--directory': Directory" @@ -706,6 +709,25 @@ def test_directory_not_exist(directory): u" does not exist." u"\n") + @staticmethod + def test_empty_directory(): + # Click >= 8.0 resolves an empty string to the current working + # directory before running its exists/writable checks, so this + # would otherwise silently pass instead of erroring as it did on + # Click 7.x; NonEmptyPath (click_util.py) restores that rejection. + engine = 'engine' + user = 'admin' + password = 'delphix' + + runner = click_testing.CliRunner() + result = runner.invoke(cli.delphix_sdk, [ + 'download-logs', '-e', engine, '-u', user, '--password', password, + '-d', '' + ]) + + assert result.exit_code != 0 + assert "Invalid value for '-d'" in result.output + @staticmethod @pytest.mark.parametrize('plugin_config_file', ['/not/a/real/file/plugin_config.yml']) @@ -724,7 +746,7 @@ def test_file_not_exist(plugin_config_file): assert result.exit_code == 2 assert result.output == ( u"Usage: delphix-sdk download-logs [OPTIONS]" - u"\nTry 'delphix-sdk download-logs -h' for help." + u"\nTry 'delphix-sdk download-logs --help' for help." u"\n" u"\nError: Invalid value for '-c' /" u" '--plugin-config': File"