diff --git a/samcli/cli/context.py b/samcli/cli/context.py index 404fd36661..49c5e44c78 100644 --- a/samcli/cli/context.py +++ b/samcli/cli/context.py @@ -7,6 +7,7 @@ from typing import List, Optional, cast import click +from rich.console import Console from samcli.cli.formatters import RootCommandHelpTextFormatter from samcli.commands.exceptions import AWSServiceClientError @@ -44,6 +45,11 @@ def __init__(self): self._session_id = str(uuid.uuid4()) self._experimental = False self._exception = None + self._console = Console() + + @property + def console(self): + return self._console @property def exception(self): diff --git a/samcli/commands/_utils/options.py b/samcli/commands/_utils/options.py index 188b1705b4..5b1b55cc32 100644 --- a/samcli/commands/_utils/options.py +++ b/samcli/commands/_utils/options.py @@ -326,7 +326,7 @@ def no_progressbar_click_option(): default=False, required=False, is_flag=True, - help="Does not showcase a progress bar when uploading artifacts to s3 and pushing docker images to ECR", + help="Does not showcase a progress bar when uploading artifacts to S3 and pushing docker images to ECR", ) @@ -679,9 +679,9 @@ def resolve_s3_click_option(guided): required=False, is_flag=True, callback=callback, - help="Automatically resolve s3 bucket for non-guided deployments. " - "Enabling this option will also create a managed default s3 bucket for you. " - "If you do not provide a --s3-bucket value, the managed bucket will be used. " + help="Automatically resolve AWS S3 bucket for non-guided deployments. " + "Enabling this option will also create a managed default AWS S3 bucket for you. " + "If one does not provide a --s3-bucket value, the managed bucket will be used. " "Do not use --guided with this option.", ) diff --git a/samcli/commands/package/command.py b/samcli/commands/package/command.py index ee74b67c37..41fb10b133 100644 --- a/samcli/commands/package/command.py +++ b/samcli/commands/package/command.py @@ -21,6 +21,7 @@ template_click_option, use_json_option, ) +from samcli.commands.package.core.command import PackageCommand from samcli.lib.bootstrap.bootstrap import manage_stack from samcli.lib.cli_validation.image_repository_validation import image_repository_validation from samcli.lib.telemetry.metric import track_command, track_template_warnings @@ -42,20 +43,30 @@ def resources_and_properties_help_string(): ) -HELP_TEXT = ( - """The SAM package command creates and uploads artifacts based on the package type of a given resource. -It uploads local images to ECR for `Image` package types. -It creates zip of your code and dependencies and uploads it to S3 for other package types. -The command returns a copy of your template, replacing references to local artifacts -with the AWS location where the command uploaded the artifacts. - -The following resources and their property locations are supported. -""" - + resources_and_properties_help_string() -) +DESCRIPTION = """ + Creates and uploads artifacts based on the package type of a given resource. + It uploads local images to ECR for `Image` package types. + It creates a zip of code and dependencies and uploads it to S3 for `Zip` package types. + + A new template is returned which replaces references to local artifacts + with the AWS location where the command uploaded the artifacts. + """ -@click.command("package", short_help=SHORT_HELP, help=HELP_TEXT, context_settings=dict(max_content_width=120)) +@click.command( + "package", + short_help=SHORT_HELP, + context_settings={ + "ignore_unknown_options": False, + "allow_interspersed_args": True, + "allow_extra_args": True, + "max_content_width": 120, + }, + cls=PackageCommand, + help=SHORT_HELP, + description=DESCRIPTION, + requires_credentials=True, +) @configuration_option(provider=TomlProvider(section="parameters")) @template_click_option(include_build=True) @click.option( diff --git a/samcli/commands/package/core/__init__.py b/samcli/commands/package/core/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/samcli/commands/package/core/command.py b/samcli/commands/package/core/command.py new file mode 100644 index 0000000000..16a9ee3d2a --- /dev/null +++ b/samcli/commands/package/core/command.py @@ -0,0 +1,138 @@ +""" +`sam package` command class for help text visual layer. +""" +import click +from click import Context, style +from rich.table import Table + +from samcli.cli.core.command import CoreCommand +from samcli.cli.row_modifiers import RowDefinition, ShowcaseRowModifier +from samcli.commands.package.core.formatters import PackageCommandHelpTextFormatter +from samcli.commands.package.core.options import OPTIONS_INFO +from samcli.lib.utils.resources import resources_generator + +COL_SIZE_MODIFIER = 38 + + +class PackageCommand(CoreCommand): + """ + `sam` package specific command class that specializes in the visual appearance + of `sam package` help text. + It hosts a custom formatter, examples, table for supported resources, acronyms + and how options are to be used in the CLI for `sam package`. + """ + + class CustomFormatterContext(Context): + formatter_class = PackageCommandHelpTextFormatter + + context_class = CustomFormatterContext + + @staticmethod + def format_examples(ctx: Context, formatter: PackageCommandHelpTextFormatter): + with formatter.indented_section(name="Examples", extra_indents=1): + with formatter.indented_section(name="Automatic resolution of S3 buckets", extra_indents=1): + formatter.write_rd( + [ + RowDefinition( + text="\n", + ), + RowDefinition( + name=style(f"$ {ctx.command_path} --resolve-s3"), + extra_row_modifiers=[ShowcaseRowModifier()], + ), + ], + col_max=COL_SIZE_MODIFIER, + ) + with formatter.indented_section(name="Get packaged template", extra_indents=1): + formatter.write_rd( + [ + RowDefinition( + text="\n", + ), + RowDefinition( + name=style(f"$ {ctx.command_path} --resolve-s3 --output-template-file packaged.yaml"), + extra_row_modifiers=[ShowcaseRowModifier()], + ), + ], + col_max=COL_SIZE_MODIFIER, + ) + with formatter.indented_section(name="Customized location for uploading artifacts", extra_indents=1): + formatter.write_rd( + [ + RowDefinition( + text="\n", + ), + RowDefinition( + name=style( + f"$ {ctx.command_path} --s3-bucket S3_BUCKET --output-template-file packaged.yaml" + ), + extra_row_modifiers=[ShowcaseRowModifier()], + ), + ], + col_max=COL_SIZE_MODIFIER, + ) + + @staticmethod + def format_table(formatter: PackageCommandHelpTextFormatter): + with formatter.section(name="Supported Resources"): + pass + ctx = click.get_current_context() + table = Table(width=ctx.max_content_width) + table.add_column("Resource") + table.add_column("Location") + for resource, location in resources_generator(): + table.add_row(resource, location) + with ctx.obj.console.capture() as capture: + ctx.obj.console.print(table) + formatter.write_rd( + [ + RowDefinition(name="\n"), + RowDefinition(name=capture.get()), + ], + col_max=COL_SIZE_MODIFIER, + ) + + @staticmethod + def format_acronyms(formatter: PackageCommandHelpTextFormatter): + with formatter.indented_section(name="Acronyms", extra_indents=1): + formatter.write_rd( + [ + RowDefinition( + text="\n", + ), + RowDefinition( + name="S3", + text="Simple Storage Service", + extra_row_modifiers=[ShowcaseRowModifier()], + ), + RowDefinition( + name="ECR", + text="Elastic Container Registry", + extra_row_modifiers=[ShowcaseRowModifier()], + ), + RowDefinition( + name="KMS", + text="Key Management Service", + extra_row_modifiers=[ShowcaseRowModifier()], + ), + ], + col_max=COL_SIZE_MODIFIER, + ) + + def format_options(self, ctx: Context, formatter: PackageCommandHelpTextFormatter) -> 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) + PackageCommand.format_examples(ctx, formatter) + PackageCommand.format_table(formatter) + PackageCommand.format_acronyms(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/package/core/formatters.py b/samcli/commands/package/core/formatters.py new file mode 100644 index 0000000000..3faca8d644 --- /dev/null +++ b/samcli/commands/package/core/formatters.py @@ -0,0 +1,19 @@ +from samcli.cli.formatters import RootCommandHelpTextFormatter +from samcli.cli.row_modifiers import BaseLineRowModifier +from samcli.commands.deploy.core.options import ALL_OPTIONS + + +class PackageCommandHelpTextFormatter(RootCommandHelpTextFormatter): + # Picked an additive constant that gives an aesthetically pleasing look. + ADDITIVE_JUSTIFICATION = 15 + + 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/package/core/options.py b/samcli/commands/package/core/options.py new file mode 100644 index 0000000000..5a10f943f9 --- /dev/null +++ b/samcli/commands/package/core/options.py @@ -0,0 +1,68 @@ +""" +Package 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. + +REQUIRED_OPTIONS: List[str] = ["s3_bucket", "resolve_s3"] + +AWS_CREDENTIAL_OPTION_NAMES: List[str] = ["region", "profile"] + +INFRASTRUCTURE_OPTION_NAMES: List[str] = [ + "s3_prefix", + "image_repository", + "image_repositories", + "kms_key_id", + "metadata", +] + +DEPLOYMENT_OPTIONS: List[str] = [ + "force_upload", +] + +CONFIGURATION_OPTION_NAMES: List[str] = ["config_env", "config_file"] + +ADDITIONAL_OPTIONS: List[str] = [ + "no_progressbar", + "signing_profiles", + "template_file", + "output_template_file", + "use_json", +] + +ALL_OPTIONS: List[str] = ( + REQUIRED_OPTIONS + + AWS_CREDENTIAL_OPTION_NAMES + + INFRASTRUCTURE_OPTION_NAMES + + DEPLOYMENT_OPTIONS + + CONFIGURATION_OPTION_NAMES + + ADDITIONAL_OPTIONS + + ALL_COMMON_OPTIONS +) + +OPTIONS_INFO: Dict[str, Dict] = { + "Required Options": {"option_names": {opt: {"rank": idx} for idx, opt in enumerate(REQUIRED_OPTIONS)}}, + "AWS Credential Options": { + "option_names": {opt: {"rank": idx} for idx, opt in enumerate(AWS_CREDENTIAL_OPTION_NAMES)} + }, + "Infrastructure Options": { + "option_names": {opt: {"rank": idx} for idx, opt in enumerate(INFRASTRUCTURE_OPTION_NAMES)} + }, + "Package Management Options": {"option_names": {opt: {"rank": idx} for idx, opt in enumerate(DEPLOYMENT_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. " + ), + ], + }, + "Additional Options": {"option_names": {opt: {"rank": idx} for idx, opt in enumerate(ADDITIONAL_OPTIONS)}}, +} +add_common_options_info(OPTIONS_INFO) diff --git a/tests/unit/cli/test_context.py b/tests/unit/cli/test_context.py index 709182e0f5..06dce014a5 100644 --- a/tests/unit/cli/test_context.py +++ b/tests/unit/cli/test_context.py @@ -5,6 +5,8 @@ from unittest import TestCase from unittest.mock import patch, ANY +from rich.console import Console + from samcli.cli.context import Context from samcli.lib.utils.sam_logging import ( SamCliLogger, @@ -20,6 +22,10 @@ def test_must_initialize_with_defaults(self): self.assertEqual(ctx.debug, False, "debug must default to False") + def test_must_have_console(self): + ctx = Context() + self.assertTrue(isinstance(ctx.console, Console)) + def test_must_set_get_debug_flag(self): ctx = Context() diff --git a/tests/unit/commands/package/core/__init__.py b/tests/unit/commands/package/core/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/commands/package/core/test_command.py b/tests/unit/commands/package/core/test_command.py new file mode 100644 index 0000000000..fdfd571461 --- /dev/null +++ b/tests/unit/commands/package/core/test_command.py @@ -0,0 +1,85 @@ +import unittest +from unittest.mock import Mock, patch, MagicMock +from samcli.commands.package.core.command import PackageCommand +from samcli.commands.package.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 TestPackageCommand(unittest.TestCase): + @patch.object(PackageCommand, "get_params") + def test_get_options_package_command_text(self, mock_get_params): + with patch("click.get_current_context", return_value=MagicMock()) as mock_get_current_context: + # Set up the chain of calls to return 'mock' on .get() + mock_get_current_context.return_value.obj.console.capture().__enter__().get.return_value = "mock" + ctx = Mock() + ctx.command_path = "sam package" + 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=("--s3-prefix", ""), name="s3_prefix"), + MockParams(rv=("--s3-bucket", ""), name="s3_bucket"), + MockParams(rv=("--signing-profiles", ""), name="signing_profiles"), + MockParams(rv=("--stack-name", ""), name="stack_name"), + MockParams(rv=("--force-upload", ""), name="force_upload"), + MockParams(rv=("--beta-features", ""), name="beta_features"), + ] + + cmd = PackageCommand(name="package", requires_credentials=False, description=DESCRIPTION) + expected_output = { + "AWS Credential Options": [("", ""), ("--region", ""), ("", "")], + "Acronyms": [("", ""), ("S3", ""), ("ECR", ""), ("KMS", "")], + "Additional Options": [("", ""), ("--signing-profiles", ""), ("", "")], + "Automatic resolution of S3 buckets": [("", ""), ("$ sam package --resolve-s3\x1b[0m", "")], + "Beta Options": [("", ""), ("--beta-features", ""), ("", "")], + "Configuration Options": [("", ""), ("--config-file", ""), ("", "")], + "Customized location for uploading artifacts": [ + ("", ""), + ("$ sam package --s3-bucket " "S3_BUCKET " "--output-template-file " "packaged.yaml\x1b[0m", ""), + ], + "Description": [ + ( + "\n" + " Creates and uploads artifacts based on the package type " + "of a given resource.\n" + " It uploads local images to ECR for `Image` package " + "types.\n" + " It creates a zip of code and dependencies and uploads it " + "to S3 for `Zip` package types. \n" + " \n" + " A new template is returned which replaces references to " + "local artifacts\n" + " with the AWS location where the command uploaded the " + "artifacts.\n" + " \x1b[1m\n" + " This command may not require access to AWS " + "credentials.\x1b[0m", + "", + ) + ], + "Examples": [], + "Get packaged template": [ + ("", ""), + ("$ sam package --resolve-s3 --output-template-file " "packaged.yaml\x1b[0m", ""), + ], + "Infrastructure Options": [("", ""), ("--s3-prefix", ""), ("", "")], + "Other Options": [("", ""), ("--debug", ""), ("", "")], + "Package Management Options": [("", ""), ("--force-upload", ""), ("", "")], + "Required Options": [("", ""), ("--s3-bucket", ""), ("", "")], + "Supported Resources": [("\n", ""), ("mock", "")], + } + + cmd.format_options(ctx, formatter) + self.assertEqual(formatter.data, expected_output) diff --git a/tests/unit/commands/package/core/test_formatter.py b/tests/unit/commands/package/core/test_formatter.py new file mode 100644 index 0000000000..559b247fd6 --- /dev/null +++ b/tests/unit/commands/package/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.package.core.formatters import PackageCommandHelpTextFormatter + + +class TestPackageCommandHelpTextFormatter(TestCase): + def test_deploy_formatter(self): + self.formatter = PackageCommandHelpTextFormatter() + 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/package/core/test_options.py b/tests/unit/commands/package/core/test_options.py new file mode 100644 index 0000000000..534aadb2f3 --- /dev/null +++ b/tests/unit/commands/package/core/test_options.py @@ -0,0 +1,12 @@ +from unittest import TestCase + +from click import Option + +from samcli.commands.package.command import cli +from samcli.commands.package.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"])))