From 9191e870152189c503131130e705650858ed76af Mon Sep 17 00:00:00 2001 From: Jim Wilkinson Date: Mon, 7 Apr 2025 16:13:05 +0100 Subject: [PATCH 1/8] Add profile as an option to every click command --- localstack-core/localstack/cli/localstack.py | 58 ++++++++++++++------ localstack-core/localstack/cli/profiles.py | 2 +- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/localstack-core/localstack/cli/localstack.py b/localstack-core/localstack/cli/localstack.py index 9abbf4e53775a..7c4a25d8b0034 100644 --- a/localstack-core/localstack/cli/localstack.py +++ b/localstack-core/localstack/cli/localstack.py @@ -4,6 +4,7 @@ import sys import traceback from typing import Dict, List, Optional, Tuple, TypedDict +import functools import click import requests @@ -144,6 +145,13 @@ def _setup_cli_debug() -> None: help="The formatting style for the command output.", ) +# Common global options for all commands. +def common_options(func): + @click.option("-p", "--profile", type=str, help="Set the configuration profile", hidden=True) + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + return wrapper @click.group( name="localstack", @@ -187,7 +195,8 @@ def localstack(debug, profile) -> None: name="config", short_help="Manage your LocalStack config", ) -def localstack_config() -> None: +@common_options +def localstack_config(profile: str) -> None: """ Inspect and validate your LocalStack configuration. """ @@ -196,8 +205,9 @@ def localstack_config() -> None: @localstack_config.command(name="show", short_help="Show your config") @_click_format_option +@common_options @publish_invocation -def cmd_config_show(format_: str) -> None: +def cmd_config_show(format_: str, profile: str) -> None: """ Print the current LocalStack config values. @@ -230,6 +240,7 @@ def cmd_config_show(format_: str) -> None: @localstack_config.command(name="validate", short_help="Validate your config") +@common_options @click.option( "-f", "--file", @@ -238,7 +249,7 @@ def cmd_config_show(format_: str) -> None: type=click.Path(exists=True, file_okay=True, readable=True), ) @publish_invocation -def cmd_config_validate(file: str) -> None: +def cmd_config_validate(profile: str, file: str) -> None: """ Validate your LocalStack configuration (docker compose). @@ -295,7 +306,8 @@ def _print_config_table() -> None: invoke_without_command=True, ) @click.pass_context -def localstack_status(ctx: click.Context) -> None: +@common_options +def localstack_status(ctx: click.Context, profile: str) -> None: """ Query status information about the currently running LocalStack instance. """ @@ -305,7 +317,8 @@ def localstack_status(ctx: click.Context) -> None: @localstack_status.command(name="docker", short_help="Query LocalStack Docker status") @_click_format_option -def cmd_status_docker(format_: str) -> None: +@common_options +def cmd_status_docker(format_: str, profile: str) -> None: """ Query information about the currently running LocalStack Docker image, its container, and the LocalStack runtime. @@ -380,7 +393,8 @@ def _print_docker_status_table(status: DockerStatus) -> None: @localstack_status.command(name="services", short_help="Query LocalStack services status") @_click_format_option -def cmd_status_services(format_: str) -> None: +@common_options +def cmd_status_services(format_: str, profile: str) -> None: """ Query information about the services of the currently running LocalStack instance. """ @@ -432,6 +446,7 @@ def _print_service_table(services: Dict[str, str]) -> None: @localstack.command(name="start", short_help="Start LocalStack") +@common_options @click.option("--docker", is_flag=True, help="Start LocalStack in a docker container [default]") @click.option("--host", is_flag=True, help="Start LocalStack directly on the host") @click.option("--no-banner", is_flag=True, help="Disable LocalStack banner", default=False) @@ -474,6 +489,7 @@ def _print_service_table(services: Dict[str, str]) -> None: ) @publish_invocation def cmd_start( + profile: str, docker: bool, host: bool, no_banner: bool, @@ -563,8 +579,9 @@ def cmd_start( @localstack.command(name="stop", short_help="Stop LocalStack") +@common_options @publish_invocation -def cmd_stop() -> None: +def cmd_stop(profile: str) -> None: """ Stops the current LocalStack runtime. @@ -590,8 +607,9 @@ def cmd_stop() -> None: @localstack.command(name="restart", short_help="Restart LocalStack") +@common_options @publish_invocation -def cmd_restart() -> None: +def cmd_restart(profile: str) -> None: """ Restarts the current LocalStack runtime. """ @@ -614,6 +632,7 @@ def cmd_restart() -> None: name="logs", short_help="Show LocalStack logs", ) +@common_options @click.option( "-f", "--follow", @@ -630,7 +649,7 @@ def cmd_restart() -> None: metavar="N", ) @publish_invocation -def cmd_logs(follow: bool, tail: int) -> None: +def cmd_logs(profile: str, follow: bool, tail: int) -> None: """ Show the logs of the current LocalStack runtime. @@ -670,6 +689,7 @@ def cmd_logs(follow: bool, tail: int) -> None: @localstack.command(name="wait", short_help="Wait for LocalStack") +@common_options @click.option( "-t", "--timeout", @@ -679,7 +699,7 @@ def cmd_logs(follow: bool, tail: int) -> None: metavar="N", ) @publish_invocation -def cmd_wait(timeout: Optional[float] = None) -> None: +def cmd_wait(profile: str, timeout: Optional[float] = None) -> None: """ Wait for the LocalStack runtime to be up and running. @@ -697,8 +717,9 @@ def cmd_wait(timeout: Optional[float] = None) -> None: @localstack.command(name="ssh", short_help="Obtain a shell in LocalStack") +@common_options @publish_invocation -def cmd_ssh() -> None: +def cmd_ssh(profile: str) -> None: """ Obtain a shell in the current LocalStack runtime. @@ -718,7 +739,8 @@ def cmd_ssh() -> None: @localstack.group(name="update", short_help="Update LocalStack") -def localstack_update() -> None: +@common_options +def localstack_update(profile: str) -> None: """ Update different LocalStack components. """ @@ -727,8 +749,9 @@ def localstack_update() -> None: @localstack_update.command(name="all", short_help="Update all LocalStack components") @click.pass_context +@common_options @publish_invocation -def cmd_update_all(ctx: click.Context) -> None: +def cmd_update_all(ctx: click.Context, profile: str) -> None: """ Update all LocalStack components. @@ -743,8 +766,9 @@ def cmd_update_all(ctx: click.Context) -> None: @localstack_update.command(name="localstack-cli", short_help="Update LocalStack CLI") +@common_options @publish_invocation -def cmd_update_localstack_cli() -> None: +def cmd_update_localstack_cli(profile: str) -> None: """ Update the LocalStack CLI. @@ -776,8 +800,9 @@ def cmd_update_localstack_cli() -> None: @localstack_update.command( name="docker-images", short_help="Update docker images LocalStack depends on" ) +@common_options @publish_invocation -def cmd_update_docker_images() -> None: +def cmd_update_docker_images(profile: str) -> None: """ Update all Docker images LocalStack depends on. @@ -849,11 +874,12 @@ def update_images(image_list: List[str]) -> None: @localstack.command(name="completion", short_help="CLI shell completion") @click.pass_context +@common_options @click.argument( "shell", required=True, type=click.Choice(["bash", "zsh", "fish"], case_sensitive=False) ) @publish_invocation -def localstack_completion(ctx: click.Context, shell: str) -> None: +def localstack_completion(ctx: click.Context, profile: str, shell: str) -> None: """ Print shell completion code for the specified shell (bash, zsh, or fish). The shell code must be evaluated to enable the interactive shell completion of LocalStack CLI commands. diff --git a/localstack-core/localstack/cli/profiles.py b/localstack-core/localstack/cli/profiles.py index 1625b802f73a4..ac81dc76adc8a 100644 --- a/localstack-core/localstack/cli/profiles.py +++ b/localstack-core/localstack/cli/profiles.py @@ -34,7 +34,7 @@ def parse_profile_argument(args) -> Optional[str]: # otherwise use the next arg in the args list as value try: return args[i + 1] - except KeyError: + except IndexError: return None return None From a1118c12fc336b53934af801eb04280da7e2d362 Mon Sep 17 00:00:00 2001 From: Jim Wilkinson Date: Wed, 9 Apr 2025 17:30:19 +0100 Subject: [PATCH 2/8] Remove profile options before invoking the CLI --- localstack-core/localstack/cli/localstack.py | 58 +++++----------- localstack-core/localstack/cli/profiles.py | 45 ++++++++----- tests/unit/cli/test_profiles.py | 70 +++++++++++++++++--- 3 files changed, 106 insertions(+), 67 deletions(-) diff --git a/localstack-core/localstack/cli/localstack.py b/localstack-core/localstack/cli/localstack.py index 7c4a25d8b0034..9abbf4e53775a 100644 --- a/localstack-core/localstack/cli/localstack.py +++ b/localstack-core/localstack/cli/localstack.py @@ -4,7 +4,6 @@ import sys import traceback from typing import Dict, List, Optional, Tuple, TypedDict -import functools import click import requests @@ -145,13 +144,6 @@ def _setup_cli_debug() -> None: help="The formatting style for the command output.", ) -# Common global options for all commands. -def common_options(func): - @click.option("-p", "--profile", type=str, help="Set the configuration profile", hidden=True) - @functools.wraps(func) - def wrapper(*args, **kwargs): - return func(*args, **kwargs) - return wrapper @click.group( name="localstack", @@ -195,8 +187,7 @@ def localstack(debug, profile) -> None: name="config", short_help="Manage your LocalStack config", ) -@common_options -def localstack_config(profile: str) -> None: +def localstack_config() -> None: """ Inspect and validate your LocalStack configuration. """ @@ -205,9 +196,8 @@ def localstack_config(profile: str) -> None: @localstack_config.command(name="show", short_help="Show your config") @_click_format_option -@common_options @publish_invocation -def cmd_config_show(format_: str, profile: str) -> None: +def cmd_config_show(format_: str) -> None: """ Print the current LocalStack config values. @@ -240,7 +230,6 @@ def cmd_config_show(format_: str, profile: str) -> None: @localstack_config.command(name="validate", short_help="Validate your config") -@common_options @click.option( "-f", "--file", @@ -249,7 +238,7 @@ def cmd_config_show(format_: str, profile: str) -> None: type=click.Path(exists=True, file_okay=True, readable=True), ) @publish_invocation -def cmd_config_validate(profile: str, file: str) -> None: +def cmd_config_validate(file: str) -> None: """ Validate your LocalStack configuration (docker compose). @@ -306,8 +295,7 @@ def _print_config_table() -> None: invoke_without_command=True, ) @click.pass_context -@common_options -def localstack_status(ctx: click.Context, profile: str) -> None: +def localstack_status(ctx: click.Context) -> None: """ Query status information about the currently running LocalStack instance. """ @@ -317,8 +305,7 @@ def localstack_status(ctx: click.Context, profile: str) -> None: @localstack_status.command(name="docker", short_help="Query LocalStack Docker status") @_click_format_option -@common_options -def cmd_status_docker(format_: str, profile: str) -> None: +def cmd_status_docker(format_: str) -> None: """ Query information about the currently running LocalStack Docker image, its container, and the LocalStack runtime. @@ -393,8 +380,7 @@ def _print_docker_status_table(status: DockerStatus) -> None: @localstack_status.command(name="services", short_help="Query LocalStack services status") @_click_format_option -@common_options -def cmd_status_services(format_: str, profile: str) -> None: +def cmd_status_services(format_: str) -> None: """ Query information about the services of the currently running LocalStack instance. """ @@ -446,7 +432,6 @@ def _print_service_table(services: Dict[str, str]) -> None: @localstack.command(name="start", short_help="Start LocalStack") -@common_options @click.option("--docker", is_flag=True, help="Start LocalStack in a docker container [default]") @click.option("--host", is_flag=True, help="Start LocalStack directly on the host") @click.option("--no-banner", is_flag=True, help="Disable LocalStack banner", default=False) @@ -489,7 +474,6 @@ def _print_service_table(services: Dict[str, str]) -> None: ) @publish_invocation def cmd_start( - profile: str, docker: bool, host: bool, no_banner: bool, @@ -579,9 +563,8 @@ def cmd_start( @localstack.command(name="stop", short_help="Stop LocalStack") -@common_options @publish_invocation -def cmd_stop(profile: str) -> None: +def cmd_stop() -> None: """ Stops the current LocalStack runtime. @@ -607,9 +590,8 @@ def cmd_stop(profile: str) -> None: @localstack.command(name="restart", short_help="Restart LocalStack") -@common_options @publish_invocation -def cmd_restart(profile: str) -> None: +def cmd_restart() -> None: """ Restarts the current LocalStack runtime. """ @@ -632,7 +614,6 @@ def cmd_restart(profile: str) -> None: name="logs", short_help="Show LocalStack logs", ) -@common_options @click.option( "-f", "--follow", @@ -649,7 +630,7 @@ def cmd_restart(profile: str) -> None: metavar="N", ) @publish_invocation -def cmd_logs(profile: str, follow: bool, tail: int) -> None: +def cmd_logs(follow: bool, tail: int) -> None: """ Show the logs of the current LocalStack runtime. @@ -689,7 +670,6 @@ def cmd_logs(profile: str, follow: bool, tail: int) -> None: @localstack.command(name="wait", short_help="Wait for LocalStack") -@common_options @click.option( "-t", "--timeout", @@ -699,7 +679,7 @@ def cmd_logs(profile: str, follow: bool, tail: int) -> None: metavar="N", ) @publish_invocation -def cmd_wait(profile: str, timeout: Optional[float] = None) -> None: +def cmd_wait(timeout: Optional[float] = None) -> None: """ Wait for the LocalStack runtime to be up and running. @@ -717,9 +697,8 @@ def cmd_wait(profile: str, timeout: Optional[float] = None) -> None: @localstack.command(name="ssh", short_help="Obtain a shell in LocalStack") -@common_options @publish_invocation -def cmd_ssh(profile: str) -> None: +def cmd_ssh() -> None: """ Obtain a shell in the current LocalStack runtime. @@ -739,8 +718,7 @@ def cmd_ssh(profile: str) -> None: @localstack.group(name="update", short_help="Update LocalStack") -@common_options -def localstack_update(profile: str) -> None: +def localstack_update() -> None: """ Update different LocalStack components. """ @@ -749,9 +727,8 @@ def localstack_update(profile: str) -> None: @localstack_update.command(name="all", short_help="Update all LocalStack components") @click.pass_context -@common_options @publish_invocation -def cmd_update_all(ctx: click.Context, profile: str) -> None: +def cmd_update_all(ctx: click.Context) -> None: """ Update all LocalStack components. @@ -766,9 +743,8 @@ def cmd_update_all(ctx: click.Context, profile: str) -> None: @localstack_update.command(name="localstack-cli", short_help="Update LocalStack CLI") -@common_options @publish_invocation -def cmd_update_localstack_cli(profile: str) -> None: +def cmd_update_localstack_cli() -> None: """ Update the LocalStack CLI. @@ -800,9 +776,8 @@ def cmd_update_localstack_cli(profile: str) -> None: @localstack_update.command( name="docker-images", short_help="Update docker images LocalStack depends on" ) -@common_options @publish_invocation -def cmd_update_docker_images(profile: str) -> None: +def cmd_update_docker_images() -> None: """ Update all Docker images LocalStack depends on. @@ -874,12 +849,11 @@ def update_images(image_list: List[str]) -> None: @localstack.command(name="completion", short_help="CLI shell completion") @click.pass_context -@common_options @click.argument( "shell", required=True, type=click.Choice(["bash", "zsh", "fish"], case_sensitive=False) ) @publish_invocation -def localstack_completion(ctx: click.Context, profile: str, shell: str) -> None: +def localstack_completion(ctx: click.Context, shell: str) -> None: """ Print shell completion code for the specified shell (bash, zsh, or fish). The shell code must be evaluated to enable the interactive shell completion of LocalStack CLI commands. diff --git a/localstack-core/localstack/cli/profiles.py b/localstack-core/localstack/cli/profiles.py index ac81dc76adc8a..6e3e6edcb9268 100644 --- a/localstack-core/localstack/cli/profiles.py +++ b/localstack-core/localstack/cli/profiles.py @@ -10,12 +10,12 @@ def set_profile_from_sys_argv(): Reads the --profile flag from sys.argv and then sets the 'CONFIG_PROFILE' os variable accordingly. This is later picked up by ``localstack.config``. """ - profile = parse_profile_argument(sys.argv) + profile = parse_profile_argument() if profile: os.environ["CONFIG_PROFILE"] = profile.strip() -def parse_profile_argument(args) -> Optional[str]: +def parse_profile_argument() -> Optional[str]: """ Lightweight arg parsing to find ``--profile ``, or ``--profile=`` and return the value of ```` from the given arguments. @@ -23,18 +23,29 @@ def parse_profile_argument(args) -> Optional[str]: :param args: list of CLI arguments :returns: the value of ``--profile``. """ - for i, current_arg in enumerate(args): - if current_arg.startswith("--profile="): - # if using the "=" notation, we remove the "--profile=" prefix to get the value - return current_arg[10:] - elif current_arg.startswith("-p="): - # if using the "=" notation, we remove the "-p=" prefix to get the value - return current_arg[3:] - if current_arg in ["--profile", "-p"]: - # otherwise use the next arg in the args list as value - try: - return args[i + 1] - except IndexError: - return None - - return None + args_without_profile = [] + profile_is_next_arg = False + profile = None + + for i, current_arg in enumerate(sys.argv): + if profile_is_next_arg: + # if the previous arg was "--profile", we take the next arg as value. + profile = current_arg + profile_is_next_arg = False + else: + if current_arg.startswith("--profile="): + # if using the "=" notation, we remove the "--profile=" prefix to get the value + profile = current_arg[10:] + elif current_arg.startswith("-p="): + # if using the "=" notation, we remove the "-p=" prefix to get the value + profile = current_arg[3:] + elif current_arg in ["--profile", "-p"]: + # otherwise use the next arg in the args list as value + profile_is_next_arg = True + else: + args_without_profile.append(current_arg) + + # Now replace the command line arguments with the ones without the profile argument. + sys.argv = args_without_profile + + return profile diff --git a/tests/unit/cli/test_profiles.py b/tests/unit/cli/test_profiles.py index d519fe73b2609..d3edb338f957c 100644 --- a/tests/unit/cli/test_profiles.py +++ b/tests/unit/cli/test_profiles.py @@ -3,16 +3,70 @@ from localstack.cli.profiles import set_profile_from_sys_argv - -def test_profiles_equals_notation(monkeypatch): - monkeypatch.setattr(sys, "argv", ["--profile=non-existing-test-profile"]) +def profile_test(monkeypatch, + input_args, + expected_profile, + expected_argv): + monkeypatch.setattr(sys, "argv", input_args) monkeypatch.setenv("CONFIG_PROFILE", "") set_profile_from_sys_argv() - assert os.environ["CONFIG_PROFILE"] == "non-existing-test-profile" + assert os.environ["CONFIG_PROFILE"] == expected_profile + assert sys.argv == expected_argv + + +def test_profiles_equals_notation(monkeypatch): + profile_test( + monkeypatch, + input_args=["--profile=non-existing-test-profile"], + expected_profile="non-existing-test-profile", + expected_argv=[]) def test_profiles_separate_args_notation(monkeypatch): - monkeypatch.setattr(sys, "argv", ["--profile", "non-existing-test-profile"]) - monkeypatch.setenv("CONFIG_PROFILE", "") - set_profile_from_sys_argv() - assert os.environ["CONFIG_PROFILE"] == "non-existing-test-profile" + profile_test( + monkeypatch, + input_args=["--profile", "non-existing-test-profile"], + expected_profile="non-existing-test-profile", + expected_argv=[]) + + +def test_p_equals_notation(monkeypatch): + profile_test( + monkeypatch, + input_args=["-p=non-existing-test-profile"], + expected_profile="non-existing-test-profile", + expected_argv=[]) + + +def test_p_separate_args_notation(monkeypatch): + profile_test( + monkeypatch, + input_args=["-p", "non-existing-test-profile"], + expected_profile="non-existing-test-profile", + expected_argv=[]) + + +def test_profiles_args_before_and_after(monkeypatch): + profile_test( + monkeypatch, + input_args=["cli", "-D", "--profile=non-existing-test-profile", "start"], + expected_profile="non-existing-test-profile", + expected_argv=["cli", "-D", "start"]) + + +def test_profiles_args_before_and_after_separate(monkeypatch): + profile_test( + monkeypatch, + input_args=["cli", "-D", "--profile", "non-existing-test-profile", "start"], + expected_profile="non-existing-test-profile", + expected_argv=["cli", "-D", "start"]) + + +def test_profiles_args_multiple_profile_args(monkeypatch): + profile_test( + monkeypatch, + input_args=["cli", "--profile", "non-existing-test-profile", "start", "--profile", "another-profile"], + expected_profile="another-profile", + expected_argv=["cli", "start"]) + + From 483078670d78cead80ea26db0ae48ed76bfd7d6f Mon Sep 17 00:00:00 2001 From: Jim Wilkinson Date: Wed, 9 Apr 2025 17:52:57 +0100 Subject: [PATCH 3/8] Fix lint errors --- tests/unit/cli/test_profiles.py | 38 +++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/tests/unit/cli/test_profiles.py b/tests/unit/cli/test_profiles.py index d3edb338f957c..591eefe569eaf 100644 --- a/tests/unit/cli/test_profiles.py +++ b/tests/unit/cli/test_profiles.py @@ -3,10 +3,8 @@ from localstack.cli.profiles import set_profile_from_sys_argv -def profile_test(monkeypatch, - input_args, - expected_profile, - expected_argv): + +def profile_test(monkeypatch, input_args, expected_profile, expected_argv): monkeypatch.setattr(sys, "argv", input_args) monkeypatch.setenv("CONFIG_PROFILE", "") set_profile_from_sys_argv() @@ -19,7 +17,8 @@ def test_profiles_equals_notation(monkeypatch): monkeypatch, input_args=["--profile=non-existing-test-profile"], expected_profile="non-existing-test-profile", - expected_argv=[]) + expected_argv=[], + ) def test_profiles_separate_args_notation(monkeypatch): @@ -27,7 +26,8 @@ def test_profiles_separate_args_notation(monkeypatch): monkeypatch, input_args=["--profile", "non-existing-test-profile"], expected_profile="non-existing-test-profile", - expected_argv=[]) + expected_argv=[], + ) def test_p_equals_notation(monkeypatch): @@ -35,7 +35,8 @@ def test_p_equals_notation(monkeypatch): monkeypatch, input_args=["-p=non-existing-test-profile"], expected_profile="non-existing-test-profile", - expected_argv=[]) + expected_argv=[], + ) def test_p_separate_args_notation(monkeypatch): @@ -43,7 +44,8 @@ def test_p_separate_args_notation(monkeypatch): monkeypatch, input_args=["-p", "non-existing-test-profile"], expected_profile="non-existing-test-profile", - expected_argv=[]) + expected_argv=[], + ) def test_profiles_args_before_and_after(monkeypatch): @@ -51,7 +53,8 @@ def test_profiles_args_before_and_after(monkeypatch): monkeypatch, input_args=["cli", "-D", "--profile=non-existing-test-profile", "start"], expected_profile="non-existing-test-profile", - expected_argv=["cli", "-D", "start"]) + expected_argv=["cli", "-D", "start"], + ) def test_profiles_args_before_and_after_separate(monkeypatch): @@ -59,14 +62,21 @@ def test_profiles_args_before_and_after_separate(monkeypatch): monkeypatch, input_args=["cli", "-D", "--profile", "non-existing-test-profile", "start"], expected_profile="non-existing-test-profile", - expected_argv=["cli", "-D", "start"]) + expected_argv=["cli", "-D", "start"], + ) def test_profiles_args_multiple_profile_args(monkeypatch): profile_test( monkeypatch, - input_args=["cli", "--profile", "non-existing-test-profile", "start", "--profile", "another-profile"], + input_args=[ + "cli", + "--profile", + "non-existing-test-profile", + "start", + "--profile", + "another-profile", + ], expected_profile="another-profile", - expected_argv=["cli", "start"]) - - + expected_argv=["cli", "start"], + ) From 4a0245f4ee5112f3966c557db8a235847fe2a28f Mon Sep 17 00:00:00 2001 From: Jim Wilkinson Date: Wed, 9 Apr 2025 18:07:10 +0100 Subject: [PATCH 4/8] Change function names to something more appropriate Add warnings to comments that sys.argv is modified. --- localstack-core/localstack/cli/main.py | 5 +++-- localstack-core/localstack/cli/profiles.py | 22 ++++++++++++++++------ tests/unit/cli/test_profiles.py | 4 ++-- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/localstack-core/localstack/cli/main.py b/localstack-core/localstack/cli/main.py index d9162bb098a4d..de1f04e38cac5 100644 --- a/localstack-core/localstack/cli/main.py +++ b/localstack-core/localstack/cli/main.py @@ -6,9 +6,10 @@ def main(): os.environ["LOCALSTACK_CLI"] = "1" # config profiles are the first thing that need to be loaded (especially before localstack.config!) - from .profiles import set_profile_from_sys_argv + from .profiles import set_and_remove_profile_from_sys_argv - set_profile_from_sys_argv() + # WARNING: This function modifies sys.argv to remove the profile argument. + set_and_remove_profile_from_sys_argv() # initialize CLI plugins from .localstack import create_with_plugins diff --git a/localstack-core/localstack/cli/profiles.py b/localstack-core/localstack/cli/profiles.py index 6e3e6edcb9268..2fbf770090949 100644 --- a/localstack-core/localstack/cli/profiles.py +++ b/localstack-core/localstack/cli/profiles.py @@ -5,22 +5,32 @@ # important: this needs to be free of localstack imports -def set_profile_from_sys_argv(): +def set_and_remove_profile_from_sys_argv(): """ Reads the --profile flag from sys.argv and then sets the 'CONFIG_PROFILE' os variable accordingly. This is later picked up by ``localstack.config``. + + WARNING: Any profile options are REMOVED from sys.argv, so that they are not passed to the localstack CLI. + This allows the profile option to be set at any point on the command line. """ - profile = parse_profile_argument() + profile = extract_profile_argument() if profile: os.environ["CONFIG_PROFILE"] = profile.strip() -def parse_profile_argument() -> Optional[str]: +def extract_profile_argument() -> Optional[str]: """ - Lightweight arg parsing to find ``--profile ``, or ``--profile=`` and return the value of - ```` from the given arguments. + Lightweight arg parsing to find one of the following patterns for the profile argument: + + ``--profile ``, or + ``--profile=``, or + ``-p ``, or + ``-p=`` + + ... and return the value of ```` from the given arguments. + + WARNING: This function modifies sys.argv to remove the profile argument. - :param args: list of CLI arguments :returns: the value of ``--profile``. """ args_without_profile = [] diff --git a/tests/unit/cli/test_profiles.py b/tests/unit/cli/test_profiles.py index 591eefe569eaf..35934831fae4c 100644 --- a/tests/unit/cli/test_profiles.py +++ b/tests/unit/cli/test_profiles.py @@ -1,13 +1,13 @@ import os import sys -from localstack.cli.profiles import set_profile_from_sys_argv +from localstack.cli.profiles import set_and_remove_profile_from_sys_argv def profile_test(monkeypatch, input_args, expected_profile, expected_argv): monkeypatch.setattr(sys, "argv", input_args) monkeypatch.setenv("CONFIG_PROFILE", "") - set_profile_from_sys_argv() + set_and_remove_profile_from_sys_argv() assert os.environ["CONFIG_PROFILE"] == expected_profile assert sys.argv == expected_argv From cfbbfb480b41db3b1057f1e1ca65d54917e90861 Mon Sep 17 00:00:00 2001 From: Jim Wilkinson Date: Fri, 11 Apr 2025 10:27:05 +0100 Subject: [PATCH 5/8] Fix problem with -p args --- localstack-core/localstack/cli/profiles.py | 91 ++++++++++++---------- tests/unit/cli/test_profiles.py | 63 ++++++++++++++- 2 files changed, 108 insertions(+), 46 deletions(-) diff --git a/localstack-core/localstack/cli/profiles.py b/localstack-core/localstack/cli/profiles.py index 2fbf770090949..6569e9eca6494 100644 --- a/localstack-core/localstack/cli/profiles.py +++ b/localstack-core/localstack/cli/profiles.py @@ -1,61 +1,66 @@ import os import sys from typing import Optional +import argparse # important: this needs to be free of localstack imports def set_and_remove_profile_from_sys_argv(): """ - Reads the --profile flag from sys.argv and then sets the 'CONFIG_PROFILE' os variable accordingly. This is later - picked up by ``localstack.config``. + Performs the following steps: - WARNING: Any profile options are REMOVED from sys.argv, so that they are not passed to the localstack CLI. - This allows the profile option to be set at any point on the command line. - """ - profile = extract_profile_argument() - if profile: - os.environ["CONFIG_PROFILE"] = profile.strip() + 1. Use argparse to parse the command line arguments for the --profile flag. + All occurrences are removed from the sys.argv list, and the value from + the last occurrence is used. This allows the user to specify a profile + at any point on the command line. + 2. If a --profile flag is not found, check for the -p flag. The first + occurrence of the -p flag is used and it is not removed from sys.argv. + The reasoning for this is that at least one of the CLI subcommands has + a -p flag, and we want to keep it in sys.argv for that command to + pick up. An existing bug means that if a -p flag is used with a + subcommand, it could erroneously be used as the profile value as well. + This behaviour is undesired, but we must maintain back-compatibility of + allowing the profile to be specified using -p. -def extract_profile_argument() -> Optional[str]: + 3. If a profile is found, the 'CONFIG_PROFILE' os variable is set + accordingly. This is later picked up by ``localstack.config``. + + WARNING: Any --profile options are REMOVED from sys.argv, so that they are + not passed to the localstack CLI. This allows the profile option + to be set at any point on the command line. """ - Lightweight arg parsing to find one of the following patterns for the profile argument: + parser = argparse.ArgumentParser() + parser.add_argument('--profile') + namespace, sys.argv = parser.parse_known_args(sys.argv) + profile = namespace.profile - ``--profile ``, or - ``--profile=``, or - ``-p ``, or - ``-p=`` + if not profile: + # if no profile is given, check for the -p argument + profile = parse_p_argument(sys.argv) + + if profile: + os.environ["CONFIG_PROFILE"] = profile.strip() - ... and return the value of ```` from the given arguments. - WARNING: This function modifies sys.argv to remove the profile argument. +def parse_p_argument(args) -> Optional[str]: + """ + Lightweight arg parsing to find the first occurrence of ``-p ``, or ``-p=`` and return the value of + ```` from the given arguments. - :returns: the value of ``--profile``. + :param args: list of CLI arguments + :returns: the value of ``-p``. """ - args_without_profile = [] - profile_is_next_arg = False - profile = None - - for i, current_arg in enumerate(sys.argv): - if profile_is_next_arg: - # if the previous arg was "--profile", we take the next arg as value. - profile = current_arg - profile_is_next_arg = False - else: - if current_arg.startswith("--profile="): - # if using the "=" notation, we remove the "--profile=" prefix to get the value - profile = current_arg[10:] - elif current_arg.startswith("-p="): - # if using the "=" notation, we remove the "-p=" prefix to get the value - profile = current_arg[3:] - elif current_arg in ["--profile", "-p"]: - # otherwise use the next arg in the args list as value - profile_is_next_arg = True - else: - args_without_profile.append(current_arg) - - # Now replace the command line arguments with the ones without the profile argument. - sys.argv = args_without_profile - - return profile + for i, current_arg in enumerate(args): + if current_arg.startswith("-p="): + # if using the "=" notation, we remove the "-p=" prefix to get the value + return current_arg[3:] + if current_arg == "-p": + # otherwise use the next arg in the args list as value + try: + return args[i + 1] + except IndexError: + return None + + return None \ No newline at end of file diff --git a/tests/unit/cli/test_profiles.py b/tests/unit/cli/test_profiles.py index 35934831fae4c..9f75731d71bc3 100644 --- a/tests/unit/cli/test_profiles.py +++ b/tests/unit/cli/test_profiles.py @@ -35,7 +35,7 @@ def test_p_equals_notation(monkeypatch): monkeypatch, input_args=["-p=non-existing-test-profile"], expected_profile="non-existing-test-profile", - expected_argv=[], + expected_argv=["-p=non-existing-test-profile"], ) @@ -44,7 +44,7 @@ def test_p_separate_args_notation(monkeypatch): monkeypatch, input_args=["-p", "non-existing-test-profile"], expected_profile="non-existing-test-profile", - expected_argv=[], + expected_argv=["-p", "non-existing-test-profile"], ) @@ -66,7 +66,16 @@ def test_profiles_args_before_and_after_separate(monkeypatch): ) -def test_profiles_args_multiple_profile_args(monkeypatch): +def test_p_args_before_and_after_separate(monkeypatch): + profile_test( + monkeypatch, + input_args=["cli", "-D", "-p", "non-existing-test-profile", "start"], + expected_profile="non-existing-test-profile", + expected_argv=["cli", "-D", "-p", "non-existing-test-profile", "start"], + ) + + +def test_profiles_args_multiple(monkeypatch): profile_test( monkeypatch, input_args=[ @@ -80,3 +89,51 @@ def test_profiles_args_multiple_profile_args(monkeypatch): expected_profile="another-profile", expected_argv=["cli", "start"], ) + + +def test_p_args_multiple(monkeypatch): + profile_test( + monkeypatch, + input_args=[ + "cli", + "-p", + "non-existing-test-profile", + "start", + "-p", + "another-profile", + ], + expected_profile="non-existing-test-profile", + expected_argv=[ + "cli", + "-p", + "non-existing-test-profile", + "start", + "-p", + "another-profile", + ], + ) + + +def test_p_and_profile_args(monkeypatch): + profile_test( + monkeypatch, + input_args=[ + "cli", + "-p", + "non-existing-test-profile", + "start", + "--profile", + "the_profile", + "-p", + "another-profile", + ], + expected_profile="the_profile", + expected_argv=[ + "cli", + "-p", + "non-existing-test-profile", + "start", + "-p", + "another-profile", + ], + ) From 66ef00469892b789e301f84ee6d07f57eadc15da Mon Sep 17 00:00:00 2001 From: Jim Wilkinson Date: Fri, 11 Apr 2025 14:33:28 +0100 Subject: [PATCH 6/8] Lint fixes --- localstack-core/localstack/cli/profiles.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/localstack-core/localstack/cli/profiles.py b/localstack-core/localstack/cli/profiles.py index 6569e9eca6494..585757496e08c 100644 --- a/localstack-core/localstack/cli/profiles.py +++ b/localstack-core/localstack/cli/profiles.py @@ -1,7 +1,7 @@ +import argparse import os import sys from typing import Optional -import argparse # important: this needs to be free of localstack imports @@ -32,7 +32,7 @@ def set_and_remove_profile_from_sys_argv(): to be set at any point on the command line. """ parser = argparse.ArgumentParser() - parser.add_argument('--profile') + parser.add_argument("--profile") namespace, sys.argv = parser.parse_known_args(sys.argv) profile = namespace.profile @@ -63,4 +63,4 @@ def parse_p_argument(args) -> Optional[str]: except IndexError: return None - return None \ No newline at end of file + return None From 669b59ecce1b1817a848bf944ab9ebb698a8f620 Mon Sep 17 00:00:00 2001 From: Jim Wilkinson Date: Mon, 14 Apr 2025 07:38:44 +0100 Subject: [PATCH 7/8] Add test for trailing -p option --- tests/unit/cli/test_profiles.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/unit/cli/test_profiles.py b/tests/unit/cli/test_profiles.py index 9f75731d71bc3..3bc74bde23f1b 100644 --- a/tests/unit/cli/test_profiles.py +++ b/tests/unit/cli/test_profiles.py @@ -137,3 +137,12 @@ def test_p_and_profile_args(monkeypatch): "another-profile", ], ) + + +def test_trailing_p_argument(monkeypatch): + profile_test( + monkeypatch, + input_args=["cli", "start", "-p"], + expected_profile="the_profile", + expected_argv=["cli", "start", "-p"], + ) From 3d21af54467c93c4e0e07612cca5df1ca3635a11 Mon Sep 17 00:00:00 2001 From: Jim Wilkinson Date: Mon, 14 Apr 2025 09:32:01 +0100 Subject: [PATCH 8/8] Fix failing test --- tests/unit/cli/test_profiles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/cli/test_profiles.py b/tests/unit/cli/test_profiles.py index 3bc74bde23f1b..c48fd4b9e739d 100644 --- a/tests/unit/cli/test_profiles.py +++ b/tests/unit/cli/test_profiles.py @@ -143,6 +143,6 @@ def test_trailing_p_argument(monkeypatch): profile_test( monkeypatch, input_args=["cli", "start", "-p"], - expected_profile="the_profile", + expected_profile="", expected_argv=["cli", "start", "-p"], )