From 0cd080d8ea717b3715f75009215909fd702b1c83 Mon Sep 17 00:00:00 2001 From: Sriram Madapusi Vasudevan Date: Wed, 21 Jun 2023 17:11:42 -0700 Subject: [PATCH 1/3] feat: `sam logs` help text --- samcli/commands/logs/command.py | 50 ++++---- samcli/commands/logs/core/__init__.py | 0 samcli/commands/logs/core/command.py | 117 ++++++++++++++++++ samcli/commands/logs/core/formatters.py | 19 +++ samcli/commands/logs/core/options.py | 45 +++++++ tests/unit/commands/logs/core/__init__.py | 0 tests/unit/commands/logs/core/test_command.py | 73 +++++++++++ .../unit/commands/logs/core/test_formatter.py | 12 ++ tests/unit/commands/logs/core/test_options.py | 12 ++ 9 files changed, 301 insertions(+), 27 deletions(-) create mode 100644 samcli/commands/logs/core/__init__.py create mode 100644 samcli/commands/logs/core/command.py create mode 100644 samcli/commands/logs/core/formatters.py create mode 100644 samcli/commands/logs/core/options.py create mode 100644 tests/unit/commands/logs/core/__init__.py create mode 100644 tests/unit/commands/logs/core/test_command.py create mode 100644 tests/unit/commands/logs/core/test_formatter.py create mode 100644 tests/unit/commands/logs/core/test_options.py diff --git a/samcli/commands/logs/command.py b/samcli/commands/logs/command.py index 7a3b1d8c6a..2079db7bca 100644 --- a/samcli/commands/logs/command.py +++ b/samcli/commands/logs/command.py @@ -11,6 +11,7 @@ from samcli.cli.main import common_options as cli_framework_options from samcli.commands._utils.command_exception_handler import command_exception_handler from samcli.commands._utils.options import common_observability_options, generate_next_command_recommendation +from samcli.commands.logs.core.command import LogsCommand from samcli.commands.logs.validation_and_exception_handlers import ( SAM_LOGS_ADDITIONAL_EXCEPTION_HANDLERS, stack_name_cw_log_group_validation, @@ -20,37 +21,32 @@ LOG = logging.getLogger(__name__) +SHORT_HELP = "Fetch logs for your AWS SAM Application." + HELP_TEXT = """ -Use this command to fetch logs generated by your Lambda function.\n -\b -When your functions are a part of a CloudFormation stack, you can fetch logs using the function's -LogicalID when you specify the stack name. -$ sam logs -n HelloWorldFunction --stack-name mystack \n -\b -Or, you can fetch logs using the function's name. -$ sam logs -n mystack-HelloWorldFunction-1FJ8PD36GML2Q \n -\b -You can view logs for a specific time range using the -s (--start-time) and -e (--end-time) options -$ sam logs -n HelloWorldFunction --stack-name mystack -s '10min ago' -e '2min ago' \n -\b -You can also add the --tail option to wait for new logs and see them as they arrive. -$ sam logs -n HelloWorldFunction --stack-name mystack --tail \n -\b -Use the --filter option to quickly find logs that match terms, phrases or values in your log events. -$ sam logs -n HelloWorldFunction --stack-name mystack --filter 'error' \n -\b -Fetch logs for all supported resources in your application, and additionally from the specified log groups. -$ sam logs --cw-log-group /aws/lambda/myfunction-123 --cw-log-group /aws/lambda/myfunction-456 -\b -You can now fetch logs from supported resources, by only providing --stack-name parameter -$ sam logs --stack-name mystack \n -\b -You can also fetch logs from a resource which is defined in a nested stack. -$ sam logs --stack-name mystack -n MyNestedStack/HelloWorldFunction +The sam logs commands fetches logs of Lambda Functions/CloudWatch log groups +with additional filtering by options. +""" + +DESCRIPTION = """ + Fetch logs generated by Lambda functions or other Cloudwatch log groups with additional filtering." """ -@click.command("logs", help=HELP_TEXT, short_help="Fetch logs for a function") +@click.command( + "logs", + short_help=SHORT_HELP, + context_settings={ + "ignore_unknown_options": False, + "allow_interspersed_args": True, + "allow_extra_args": True, + "max_content_width": 120, + }, + cls=LogsCommand, + help=HELP_TEXT, + description=DESCRIPTION, + requires_credentials=True, +) @configuration_option(provider=TomlProvider(section="parameters")) @click.option( "--name", diff --git a/samcli/commands/logs/core/__init__.py b/samcli/commands/logs/core/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/samcli/commands/logs/core/command.py b/samcli/commands/logs/core/command.py new file mode 100644 index 0000000000..7921ef189e --- /dev/null +++ b/samcli/commands/logs/core/command.py @@ -0,0 +1,117 @@ +from click import Context, style + +from samcli.cli.core.command import CoreCommand +from samcli.cli.row_modifiers import RowDefinition, ShowcaseRowModifier +from samcli.commands.logs.core.formatters import LogsCommandHelpTextFormatter +from samcli.commands.logs.core.options import OPTIONS_INFO + +COL_SIZE_MODIFIER = 38 + + +class LogsCommand(CoreCommand): + class CustomFormatterContext(Context): + formatter_class = LogsCommandHelpTextFormatter + + context_class = CustomFormatterContext + + @staticmethod + def format_examples(ctx: Context, formatter: LogsCommandHelpTextFormatter): + with formatter.indented_section(name="Examples", extra_indents=1): + with formatter.indented_section( + name="Fetch logs with Lambda Function Logical ID and Cloudformation Stack Name" + ): + formatter.write_rd( + [ + RowDefinition( + text="\n", + ), + RowDefinition( + name=style(f"$ {ctx.command_path} -n HelloWorldFunction --stack-name mystack"), + extra_row_modifiers=[ShowcaseRowModifier()], + ), + ] + ) + with formatter.indented_section(name="View logs for specific time range"): + formatter.write_rd( + [ + RowDefinition( + text="\n", + ), + RowDefinition( + name=style( + f"$ {ctx.command_path} -n HelloWorldFunction --stack-name mystack -s '10min ago' -e '2min ago'" + ), + extra_row_modifiers=[ShowcaseRowModifier()], + ), + ] + ) + with formatter.indented_section(name="Tail new logs"): + formatter.write_rd( + [ + RowDefinition( + text="\n", + ), + RowDefinition( + name=style(f"$ {ctx.command_path} -n HelloWorldFunction --stack-name mystack --tail"), + extra_row_modifiers=[ShowcaseRowModifier()], + ), + ] + ) + with formatter.indented_section(name="Fetch from Cloudwatch log groups"): + formatter.write_rd( + [ + RowDefinition( + text="\n", + ), + RowDefinition( + name=style( + f"$ {ctx.command_path} --cw-log-group /aws/lambda/myfunction-123 --cw-log-group /aws/lambda/myfunction-456" + ), + extra_row_modifiers=[ShowcaseRowModifier()], + ), + ] + ) + + with formatter.indented_section(name="Fetch logs from supported resources in Cloudformation stack"): + formatter.write_rd( + [ + RowDefinition( + text="\n", + ), + RowDefinition( + name=style(f"$ {ctx.command_path} ---stack-name mystack"), + extra_row_modifiers=[ShowcaseRowModifier()], + ), + ] + ) + + with formatter.indented_section(name="Fetch logs from resource defined in nested Cloudformation stack"): + formatter.write_rd( + [ + RowDefinition( + text="\n", + ), + RowDefinition( + name=style( + f"$ {ctx.command_path} ---stack-name mystack -n MyNestedStack/HelloWorldFunction" + ), + extra_row_modifiers=[ShowcaseRowModifier()], + ), + ] + ) + + def format_options(self, ctx: Context, formatter: LogsCommandHelpTextFormatter) -> None: # type:ignore + # `ignore` is put in place here for mypy even though it is the correct behavior, + # as the `formatter_class` can be set in subclass of Command. If ignore is not set, + # mypy raises argument needs to be HelpFormatter as super class defines it. + + self.format_description(formatter) + LogsCommand.format_examples(ctx, formatter) + + CoreCommand._format_options( + ctx=ctx, + params=self.get_params(ctx), + formatter=formatter, + formatting_options=OPTIONS_INFO, + write_rd_overrides={"col_max": COL_SIZE_MODIFIER}, + ) diff --git a/samcli/commands/logs/core/formatters.py b/samcli/commands/logs/core/formatters.py new file mode 100644 index 0000000000..6a35facce0 --- /dev/null +++ b/samcli/commands/logs/core/formatters.py @@ -0,0 +1,19 @@ +from samcli.cli.formatters import RootCommandHelpTextFormatter +from samcli.cli.row_modifiers import BaseLineRowModifier +from samcli.commands.logs.core.options import ALL_OPTIONS + + +class LogsCommandHelpTextFormatter(RootCommandHelpTextFormatter): + # Picked an additive constant that gives an aesthetically pleasing look. + ADDITIVE_JUSTIFICATION = 22 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Add Additional space after determining the longest option. + # However, do not justify with padding for more than half the width of + # the terminal to retain aesthetics. + self.left_justification_length = min( + max([len(option) for option in ALL_OPTIONS]) + self.ADDITIVE_JUSTIFICATION, + self.width // 2 - self.indent_increment, + ) + self.modifiers = [BaseLineRowModifier()] diff --git a/samcli/commands/logs/core/options.py b/samcli/commands/logs/core/options.py new file mode 100644 index 0000000000..c537c857e6 --- /dev/null +++ b/samcli/commands/logs/core/options.py @@ -0,0 +1,45 @@ +""" +Logs Command Options related Datastructures for formatting. +""" +from typing import Dict, List + +from samcli.cli.core.options import ALL_COMMON_OPTIONS, add_common_options_info +from samcli.cli.row_modifiers import RowDefinition + +# The ordering of the option lists matter, they are the order in which options will be displayed. + +LOG_IDENTIFIER_OPTIONS: List[str] = ["stack_name", "cw_log_group", "name"] + +# Can be used instead of the options in the first list +ADDITIONAL_OPTIONS: List[str] = ["include_traces", "filter", "output", "tail", "start_time", "end_time"] + +AWS_CREDENTIAL_OPTION_NAMES: List[str] = ["region", "profile"] + +CONFIGURATION_OPTION_NAMES: List[str] = ["config_env", "config_file"] + +ALL_OPTIONS: List[str] = ( + LOG_IDENTIFIER_OPTIONS + + AWS_CREDENTIAL_OPTION_NAMES + + ADDITIONAL_OPTIONS + + CONFIGURATION_OPTION_NAMES + + ALL_COMMON_OPTIONS +) + +OPTIONS_INFO: Dict[str, Dict] = { + "Log Identifier Options": {"option_names": {opt: {"rank": idx} for idx, opt in enumerate(LOG_IDENTIFIER_OPTIONS)}}, + "AWS Credential Options": { + "option_names": {opt: {"rank": idx} for idx, opt in enumerate(AWS_CREDENTIAL_OPTION_NAMES)} + }, + "Additional Options": {"option_names": {opt: {"rank": idx} for idx, opt in enumerate(ADDITIONAL_OPTIONS)}}, + "Configuration Options": { + "option_names": {opt: {"rank": idx} for idx, opt in enumerate(CONFIGURATION_OPTION_NAMES)}, + "extras": [ + RowDefinition(name="Learn more about configuration files at:"), + RowDefinition( + name="https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli" + "-config.html. " + ), + ], + }, +} +add_common_options_info(OPTIONS_INFO) diff --git a/tests/unit/commands/logs/core/__init__.py b/tests/unit/commands/logs/core/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/commands/logs/core/test_command.py b/tests/unit/commands/logs/core/test_command.py new file mode 100644 index 0000000000..5b7b4e334e --- /dev/null +++ b/tests/unit/commands/logs/core/test_command.py @@ -0,0 +1,73 @@ +import unittest +from unittest.mock import Mock, patch +from samcli.commands.logs.core.command import LogsCommand +from samcli.commands.logs.command import DESCRIPTION +from tests.unit.cli.test_command import MockFormatter + + +class MockParams: + def __init__(self, rv, name): + self.rv = rv + self.name = name + + def get_help_record(self, ctx): + return self.rv + + +class TestLogsCommand(unittest.TestCase): + @patch.object(LogsCommand, "get_params") + def test_get_options_logs_command_text(self, mock_get_params): + ctx = Mock() + ctx.command_path = "sam logs" + ctx.parent.command_path = "sam" + formatter = MockFormatter(scrub_text=True) + # NOTE(sriram-mv): One option per option section. + mock_get_params.return_value = [ + MockParams(rv=("--region", "Region"), name="region"), + MockParams(rv=("--debug", ""), name="debug"), + MockParams(rv=("--config-file", ""), name="config_file"), + MockParams(rv=("--stack-name", ""), name="stack_name"), + MockParams(rv=("--tail", ""), name="tail"), + MockParams(rv=("--beta-features", ""), name="beta_features"), + ] + + cmd = LogsCommand(name="logs", requires_credentials=True, description=DESCRIPTION) + expected_output = { + "AWS Credential Options": [("", ""), ("--region", ""), ("", "")], + "Additional Options": [("", ""), ("--tail", ""), ("", "")], + "Beta Options": [("", ""), ("--beta-features", ""), ("", "")], + "Configuration Options": [("", ""), ("--config-file", ""), ("", "")], + "Description": [(cmd.description + cmd.description_addendum, "")], + "Examples": [], + "Fetch from Cloudwatch log groups": [ + ("", ""), + ( + "$ sam logs --cw-log-group " + "/aws/lambda/myfunction-123 " + "--cw-log-group " + "/aws/lambda/myfunction-456\x1b[0m", + "", + ), + ], + "Fetch logs from resource defined in nested Cloudformation stack": [ + ("", ""), + ("$ sam " "logs " "---stack-name " "mystack " "-n " "MyNestedStack/HelloWorldFunction\x1b[0m", ""), + ], + "Fetch logs from supported resources in Cloudformation stack": [ + ("", ""), + ("$ sam logs " "---stack-name " "mystack\x1b[0m", ""), + ], + "Fetch logs with Lambda Function Logical ID and Cloudformation Stack Name": [ + ("", ""), + ("$ " "sam " "logs " "-n " "HelloWorldFunction " "--stack-name " "mystack\x1b[0m", ""), + ], + "Log Identifier Options": [("", ""), ("--stack-name", ""), ("", "")], + "Other Options": [("", ""), ("--debug", ""), ("", "")], + "Tail new logs": [("", ""), ("$ sam logs -n HelloWorldFunction --stack-name mystack " "--tail\x1b[0m", "")], + "View logs for specific time range": [ + ("", ""), + ("$ sam logs -n HelloWorldFunction " "--stack-name mystack -s '10min ago' " "-e '2min ago'\x1b[0m", ""), + ], + } + cmd.format_options(ctx, formatter) + self.assertEqual(formatter.data, expected_output) diff --git a/tests/unit/commands/logs/core/test_formatter.py b/tests/unit/commands/logs/core/test_formatter.py new file mode 100644 index 0000000000..e59e90207b --- /dev/null +++ b/tests/unit/commands/logs/core/test_formatter.py @@ -0,0 +1,12 @@ +from shutil import get_terminal_size +from unittest import TestCase + +from samcli.cli.row_modifiers import BaseLineRowModifier +from samcli.commands.logs.core.formatters import LogsCommandHelpTextFormatter + + +class TestLogsCommandHelpTextFormatter(TestCase): + def test_logs_formatter(self): + self.formatter = LogsCommandHelpTextFormatter() + self.assertTrue(self.formatter.left_justification_length <= get_terminal_size().columns // 2) + self.assertIsInstance(self.formatter.modifiers[0], BaseLineRowModifier) diff --git a/tests/unit/commands/logs/core/test_options.py b/tests/unit/commands/logs/core/test_options.py new file mode 100644 index 0000000000..4b2acd844e --- /dev/null +++ b/tests/unit/commands/logs/core/test_options.py @@ -0,0 +1,12 @@ +from unittest import TestCase + +from click import Option + +from samcli.commands.logs.command import cli +from samcli.commands.logs.core.options import ALL_OPTIONS + + +class TestOptions(TestCase): + def test_all_options_formatted(self): + command_options = [param.human_readable_name if isinstance(param, Option) else None for param in cli.params] + self.assertEqual(sorted(ALL_OPTIONS), sorted(filter(lambda item: item is not None, command_options + ["help"]))) From f44c16f51216058b47b0c9ddd3d3b9af3bcfa216 Mon Sep 17 00:00:00 2001 From: Sriram Madapusi Vasudevan Date: Wed, 21 Jun 2023 17:19:10 -0700 Subject: [PATCH 2/3] fix: make ruff happy --- samcli/commands/logs/core/command.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/samcli/commands/logs/core/command.py b/samcli/commands/logs/core/command.py index 7921ef189e..42c1084188 100644 --- a/samcli/commands/logs/core/command.py +++ b/samcli/commands/logs/core/command.py @@ -39,7 +39,8 @@ def format_examples(ctx: Context, formatter: LogsCommandHelpTextFormatter): ), RowDefinition( name=style( - f"$ {ctx.command_path} -n HelloWorldFunction --stack-name mystack -s '10min ago' -e '2min ago'" + f"$ {ctx.command_path} -n HelloWorldFunction --stack-name mystack -s " + f"'10min ago' -e '2min ago'" ), extra_row_modifiers=[ShowcaseRowModifier()], ), @@ -52,7 +53,8 @@ def format_examples(ctx: Context, formatter: LogsCommandHelpTextFormatter): text="\n", ), RowDefinition( - name=style(f"$ {ctx.command_path} -n HelloWorldFunction --stack-name mystack --tail"), + name=style(f"$ {ctx.command_path} -n HelloWorldFunction --stack-name " + f"mystack --tail"), extra_row_modifiers=[ShowcaseRowModifier()], ), ] @@ -65,7 +67,8 @@ def format_examples(ctx: Context, formatter: LogsCommandHelpTextFormatter): ), RowDefinition( name=style( - f"$ {ctx.command_path} --cw-log-group /aws/lambda/myfunction-123 --cw-log-group /aws/lambda/myfunction-456" + f"$ {ctx.command_path} --cw-log-group /aws/lambda/myfunction-123 " + f"--cw-log-group /aws/lambda/myfunction-456" ), extra_row_modifiers=[ShowcaseRowModifier()], ), From 8c9dcb17ce614ec16dcff8afdba0ebf6906ec59b Mon Sep 17 00:00:00 2001 From: Sriram Madapusi Vasudevan Date: Mon, 3 Jul 2023 10:03:27 -0700 Subject: [PATCH 3/3] fix: address comments --- samcli/cli/root/command_list.py | 4 ++-- samcli/commands/logs/command.py | 6 ++++-- samcli/commands/logs/core/command.py | 3 +-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/samcli/cli/root/command_list.py b/samcli/cli/root/command_list.py index 0be843fbe2..cfa7000739 100644 --- a/samcli/cli/root/command_list.py +++ b/samcli/cli/root/command_list.py @@ -6,11 +6,11 @@ "validate": "Validate an AWS SAM template.", "build": "Build your AWS serverless function code.", "local": "Run your AWS serverless function locally.", - "remote": "Invoke or send an event to cloud resources in your CFN stack", + "remote": "Invoke or send an event to cloud resources in your AWS Cloudformation stack.", "package": "Package an AWS SAM application.", "deploy": "Deploy an AWS SAM application.", "delete": "Delete an AWS SAM application and the artifacts created by sam deploy.", - "logs": "Fetch AWS Cloudwatch logs for a function.", + "logs": "Fetch AWS Cloudwatch logs for AWS Lambda Functions or Cloudwatch Log groups.", "publish": "Publish a packaged AWS SAM template to AWS Serverless Application Repository for easy sharing.", "traces": "Fetch AWS X-Ray traces.", "sync": "Sync an AWS SAM project to AWS.", diff --git a/samcli/commands/logs/command.py b/samcli/commands/logs/command.py index 2079db7bca..1146767a60 100644 --- a/samcli/commands/logs/command.py +++ b/samcli/commands/logs/command.py @@ -21,7 +21,9 @@ LOG = logging.getLogger(__name__) -SHORT_HELP = "Fetch logs for your AWS SAM Application." +SHORT_HELP = ( + "Fetch logs for your AWS SAM Application or AWS Cloudformation stack - Lambda Functions/CloudWatch Log groups" +) HELP_TEXT = """ The sam logs commands fetches logs of Lambda Functions/CloudWatch log groups @@ -29,7 +31,7 @@ """ DESCRIPTION = """ - Fetch logs generated by Lambda functions or other Cloudwatch log groups with additional filtering." + Fetch logs generated by Lambda functions or other Cloudwatch log groups with additional filtering. """ diff --git a/samcli/commands/logs/core/command.py b/samcli/commands/logs/core/command.py index 42c1084188..60b1734e50 100644 --- a/samcli/commands/logs/core/command.py +++ b/samcli/commands/logs/core/command.py @@ -53,8 +53,7 @@ def format_examples(ctx: Context, formatter: LogsCommandHelpTextFormatter): text="\n", ), RowDefinition( - name=style(f"$ {ctx.command_path} -n HelloWorldFunction --stack-name " - f"mystack --tail"), + name=style(f"$ {ctx.command_path} -n HelloWorldFunction --stack-name " f"mystack --tail"), extra_row_modifiers=[ShowcaseRowModifier()], ), ]