From 2fba8f65c9f2ca364965870e8fc9a2e3ce0e641e Mon Sep 17 00:00:00 2001 From: Daniel Mil Date: Thu, 16 Mar 2023 13:02:46 -0700 Subject: [PATCH 01/15] Add docs sub-commands --- samcli/cli/context.py | 8 ++ samcli/commands/docs/command.py | 99 ++++++++++++++++++++---- samcli/commands/docs/docs_context.py | 33 -------- samcli/lib/docs/documentation.py | 21 ++++- samcli/lib/docs/documentation_links.json | 6 ++ 5 files changed, 116 insertions(+), 51 deletions(-) delete mode 100644 samcli/commands/docs/docs_context.py create mode 100644 samcli/lib/docs/documentation_links.json diff --git a/samcli/cli/context.py b/samcli/cli/context.py index 51d38f722e..66eabfafce 100644 --- a/samcli/cli/context.py +++ b/samcli/cli/context.py @@ -49,6 +49,14 @@ def __init__(self): def exception(self): return self._exception + @property + def invoked_subcommand(self): + click_core_ctx = click.get_current_context() + if click_core_ctx: + return click_core_ctx.invoked_subcommand + + return None + @exception.setter def exception(self, value: Exception): """ diff --git a/samcli/commands/docs/command.py b/samcli/commands/docs/command.py index ddea9472cb..71328b5c77 100644 --- a/samcli/commands/docs/command.py +++ b/samcli/commands/docs/command.py @@ -1,10 +1,16 @@ """ CLI command for "docs" command """ +import functools +import sys +from typing import List + import click -from samcli.cli.main import common_options, print_cmdline_args +from samcli.cli.main import common_options, pass_context, print_cmdline_args from samcli.commands._utils.command_exception_handler import command_exception_handler +from samcli.lib.docs.browser_configuration import BrowserConfiguration, BrowserConfigurationError +from samcli.lib.docs.documentation import Documentation from samcli.lib.telemetry.metric import track_command from samcli.lib.utils.version_checker import check_newer_version @@ -13,27 +19,88 @@ AWS SAM CLI lifecycle and other useful details. """ +SUCCESS_MESSAGE = "Documentation page opened in a browser" +ERROR_MESSAGE = "Failed to open a web browser. Use the following link to navigate to the documentation page: {URL}" + + +class DocsSubcommand(click.MultiCommand): + def __init__(self, commands: list, full_command, command_callback, *args, **kwargs): + super().__init__(*args, **kwargs) + if not commands: + raise ValueError("Events library is necessary to run this command") + self.commands = commands + self.full_command = full_command + self.command_callback = command_callback + + def get_command(self, ctx, cmd_name): + next_command = self.commands.pop(0) + if not len(self.commands): + return click.Command( + name=next_command, + short_help=f"Documentation for {self.full_command}", + callback=self.command_callback, + ) + return DocsSubcommand( + commands=self.commands, full_command=self.full_command, command_callback=self.command_callback + ) + + def list_commands(self, ctx): + return Documentation.load().keys() + + +class DocsCommand(DocsSubcommand): + def __init__(self, *args, **kwargs): + impl = CommandImplementation(command=self.fully_resolved_command) + if self.fully_resolved_command not in Documentation.load().keys(): + raise ValueError("Invalid command") + command_callback = functools.partial(impl.run_command) + super().__init__( + commands=self.command_hierarchy, + full_command=self.fully_resolved_command, + command_callback=command_callback, + *args, + **kwargs, + ) + + @property + def fully_resolved_command(self): + return " ".join(self.command_hierarchy) + + @property + def command_hierarchy(self): + return self._get_command_hierarchy() + + def _get_command_hierarchy(self) -> List[str]: + return self._filter_flags(sys.argv[2:]) + + @staticmethod + def _filter_flags(commands): + return list(filter(lambda arg: not arg.startswith("--"), commands)) -@click.command("docs", help=HELP_TEXT) + +class CommandImplementation: + def __init__(self, command): + self.command = command + + def run_command(self): + browser = BrowserConfiguration() + documentation = Documentation(browser=browser, command=self.command) + try: + documentation.open_docs() + except BrowserConfigurationError: + click.echo(ERROR_MESSAGE.format(URL=documentation.url)) + else: + click.echo(SUCCESS_MESSAGE) + + +@click.command(name="docs", help=HELP_TEXT, cls=DocsCommand) @common_options +@pass_context @track_command @check_newer_version @print_cmdline_args @command_exception_handler -def cli(): +def cli(ctx): """ `sam docs` command entry point """ - - # All logic must be implemented in the ``do_cli`` method. This helps with easy unit testing - do_cli() # pragma: no cover - - -def do_cli(): - """ - Implementation of the ``cli`` method - """ - from samcli.commands.docs.docs_context import DocsContext - - with DocsContext() as docs_context: - docs_context.run() diff --git a/samcli/commands/docs/docs_context.py b/samcli/commands/docs/docs_context.py deleted file mode 100644 index c3e6ad15d2..0000000000 --- a/samcli/commands/docs/docs_context.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -Context class for handling `sam docs` command logic -""" -from click import echo - -from samcli.lib.docs.browser_configuration import BrowserConfiguration, BrowserConfigurationError -from samcli.lib.docs.documentation import Documentation - -SUCCESS_MESSAGE = "Documentation page opened in a browser" -ERROR_MESSAGE = "Failed to open a web browser. Use the following link to navigate to the documentation page: {URL}" - - -class DocsContext: - URL = "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html" - - def __enter__(self): - return self - - def __exit__(self, *args): - pass - - def run(self): - """ - Run the necessary logic for the `sam docs` command - """ - browser = BrowserConfiguration() - documentation = Documentation(browser=browser, url=self.URL) - try: - documentation.open_docs() - except BrowserConfigurationError: - echo(ERROR_MESSAGE.format(URL=self.URL)) - else: - echo(SUCCESS_MESSAGE) diff --git a/samcli/lib/docs/documentation.py b/samcli/lib/docs/documentation.py index 72037ce21d..8801bf6f8d 100644 --- a/samcli/lib/docs/documentation.py +++ b/samcli/lib/docs/documentation.py @@ -1,17 +1,26 @@ """ Library housing the logic for handling AWS SAM CLI documentation pages """ +import json import logging +from pathlib import Path from samcli.lib.docs.browser_configuration import BrowserConfiguration LOG = logging.getLogger(__name__) +DOCS_CONFIG_FILE = "documentation_links.json" +LANDING_PAGE = "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html" + class Documentation: - def __init__(self, browser: BrowserConfiguration, url: str): + def __init__(self, browser: BrowserConfiguration, command: str): self.browser = browser - self.url = url + self.command = command + + @property + def url(self): + return self.get_docs_link_for_command() def open_docs(self): """ @@ -23,3 +32,11 @@ def open_docs(self): """ LOG.debug(f"Launching {self.url} in a browser.") self.browser.launch(self.url) + + def get_docs_link_for_command(self): + return Documentation.load().get(self.command, LANDING_PAGE) + + @staticmethod + def load() -> dict: + with open(Path(__file__).parent / DOCS_CONFIG_FILE) as f: + return json.load(f) diff --git a/samcli/lib/docs/documentation_links.json b/samcli/lib/docs/documentation_links.json new file mode 100644 index 0000000000..ee3028ec91 --- /dev/null +++ b/samcli/lib/docs/documentation_links.json @@ -0,0 +1,6 @@ +{ + "config": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html", + "build": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-using-build.html", + "local": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-test-and-debug.html", + "local invoke": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-using-invoke.html" +} \ No newline at end of file From 7b85f7ccd759ea1234f728c6a7a657d7538e02f3 Mon Sep 17 00:00:00 2001 From: Daniel Mil Date: Thu, 16 Mar 2023 18:01:14 -0700 Subject: [PATCH 02/15] Base command functionality --- samcli/cli/context.py | 8 --- samcli/commands/docs/command.py | 108 ++++++++++++++++++++------------ 2 files changed, 68 insertions(+), 48 deletions(-) diff --git a/samcli/cli/context.py b/samcli/cli/context.py index 66eabfafce..51d38f722e 100644 --- a/samcli/cli/context.py +++ b/samcli/cli/context.py @@ -49,14 +49,6 @@ def __init__(self): def exception(self): return self._exception - @property - def invoked_subcommand(self): - click_core_ctx = click.get_current_context() - if click_core_ctx: - return click_core_ctx.invoked_subcommand - - return None - @exception.setter def exception(self, value: Exception): """ diff --git a/samcli/commands/docs/command.py b/samcli/commands/docs/command.py index 71328b5c77..7b8980dc33 100644 --- a/samcli/commands/docs/command.py +++ b/samcli/commands/docs/command.py @@ -3,12 +3,14 @@ """ import functools import sys -from typing import List +from typing import List, Optional import click +from click import Command, Context from samcli.cli.main import common_options, pass_context, print_cmdline_args from samcli.commands._utils.command_exception_handler import command_exception_handler +from samcli.commands.exceptions import UserException from samcli.lib.docs.browser_configuration import BrowserConfiguration, BrowserConfigurationError from samcli.lib.docs.documentation import Documentation from samcli.lib.telemetry.metric import track_command @@ -23,66 +25,92 @@ ERROR_MESSAGE = "Failed to open a web browser. Use the following link to navigate to the documentation page: {URL}" +class InvalidDocsCommandException(UserException): + """ + Exception when the docs command fails + """ + + +class DocsBaseCommand(click.Command): + def __init__(self, *args, **kwargs): + command_callback = DocsCommand().command_callback + super().__init__(name="docs", callback=command_callback) + + class DocsSubcommand(click.MultiCommand): - def __init__(self, commands: list, full_command, command_callback, *args, **kwargs): + def __init__(self, command: Optional[List[str]] = None, *args, **kwargs): super().__init__(*args, **kwargs) - if not commands: - raise ValueError("Events library is necessary to run this command") - self.commands = commands - self.full_command = full_command - self.command_callback = command_callback - - def get_command(self, ctx, cmd_name): - next_command = self.commands.pop(0) - if not len(self.commands): + self.docs_command = DocsCommand() + self.command = command or self.docs_command.sub_commands + self.command_string = self.docs_command.sub_command_string + self.command_callback = self.docs_command.command_callback + + def get_command(self, ctx: Context, cmd_name: str) -> Command: + """ + Overriding the get_command method from the parent class. + + This method recursively gets creates sub-commands until + it reaches the leaf command, then it returns that as a click command. + + Parameters + ---------- + ctx + cmd_name + + Returns + ------- + + """ + next_command = self.command.pop(0) + if not len(self.command): return click.Command( name=next_command, - short_help=f"Documentation for {self.full_command}", + short_help=f"Documentation for {self.command_string}", callback=self.command_callback, ) - return DocsSubcommand( - commands=self.commands, full_command=self.full_command, command_callback=self.command_callback - ) + return DocsSubcommand(command=self.command) - def list_commands(self, ctx): - return Documentation.load().keys() + def list_commands(self, ctx: Context): + return list(Documentation.load().keys()) -class DocsCommand(DocsSubcommand): - def __init__(self, *args, **kwargs): - impl = CommandImplementation(command=self.fully_resolved_command) - if self.fully_resolved_command not in Documentation.load().keys(): - raise ValueError("Invalid command") - command_callback = functools.partial(impl.run_command) - super().__init__( - commands=self.command_hierarchy, - full_command=self.fully_resolved_command, - command_callback=command_callback, - *args, - **kwargs, - ) +class DocsCommand: + @property + def command_callback(self): + impl = CommandImplementation(command=self.sub_command_string) + return functools.partial(impl.run_command) @property - def fully_resolved_command(self): - return " ".join(self.command_hierarchy) + def all_commands(self): + return list(Documentation.load().keys()) @property - def command_hierarchy(self): - return self._get_command_hierarchy() + def sub_command_string(self): + return " ".join(self.sub_commands) - def _get_command_hierarchy(self) -> List[str]: - return self._filter_flags(sys.argv[2:]) + @property + def sub_commands(self): + return self._filter_arguments(sys.argv[2:]) @staticmethod - def _filter_flags(commands): - return list(filter(lambda arg: not arg.startswith("--"), commands)) + def _filter_arguments(commands): + return list(filter(lambda arg: not arg.startswith("-"), commands)) + + def create_command(self): + if self.sub_commands: + return DocsSubcommand + return DocsBaseCommand class CommandImplementation: - def __init__(self, command): + def __init__(self, command: str): self.command = command def run_command(self): + """ + Run the necessary logic for the `sam docs` command + """ + # TODO: Make sure the docs page exists in the list of docs pages browser = BrowserConfiguration() documentation = Documentation(browser=browser, command=self.command) try: @@ -93,7 +121,7 @@ def run_command(self): click.echo(SUCCESS_MESSAGE) -@click.command(name="docs", help=HELP_TEXT, cls=DocsCommand) +@click.command(name="docs", help=HELP_TEXT, cls=DocsCommand().create_command()) @common_options @pass_context @track_command From d70ac6d571438118a99a555423e76d6e0fd3ad93 Mon Sep 17 00:00:00 2001 From: Daniel Mil Date: Fri, 17 Mar 2023 12:29:49 -0700 Subject: [PATCH 03/15] Format help text --- samcli/commands/docs/command.py | 74 +++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 12 deletions(-) diff --git a/samcli/commands/docs/command.py b/samcli/commands/docs/command.py index 7b8980dc33..a24ab41d34 100644 --- a/samcli/commands/docs/command.py +++ b/samcli/commands/docs/command.py @@ -6,9 +6,11 @@ from typing import List, Optional import click -from click import Command, Context +from click import Command, Context, style +from samcli.cli.formatters import RootCommandHelpTextFormatter from samcli.cli.main import common_options, pass_context, print_cmdline_args +from samcli.cli.row_modifiers import BaseLineRowModifier, RowDefinition from samcli.commands._utils.command_exception_handler import command_exception_handler from samcli.commands.exceptions import UserException from samcli.lib.docs.browser_configuration import BrowserConfiguration, BrowserConfigurationError @@ -16,12 +18,17 @@ from samcli.lib.telemetry.metric import track_command from samcli.lib.utils.version_checker import check_newer_version -HELP_TEXT = """Launch the AWS SAM CLI documentation in a browser! This command will - show information about setting up credentials, the - AWS SAM CLI lifecycle and other useful details. +COMMAND_NAME = "docs" +HELP_TEXT = "NEW! Open the documentation in a browser." +DESCRIPTION = """ + Launch the AWS SAM CLI documentation in a browser! This command will + show information about setting up credentials, the + AWS SAM CLI lifecycle and other useful details. + + The command also be run with sub-commands to open specific pages. """ -SUCCESS_MESSAGE = "Documentation page opened in a browser" +SUCCESS_MESSAGE = "Documentation page opened in a browser. These other sub-commands are also invokable." ERROR_MESSAGE = "Failed to open a web browser. Use the following link to navigate to the documentation page: {URL}" @@ -31,10 +38,49 @@ class InvalidDocsCommandException(UserException): """ +class DocsCommandHelpTextFormatter(RootCommandHelpTextFormatter): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.left_justification_length = self.width // 2 - self.indent_increment + self.modifiers = [BaseLineRowModifier()] + + class DocsBaseCommand(click.Command): + class CustomFormatterContext(Context): + formatter_class = DocsCommandHelpTextFormatter + + context_class = CustomFormatterContext + def __init__(self, *args, **kwargs): - command_callback = DocsCommand().command_callback - super().__init__(name="docs", callback=command_callback) + self.docs_command = DocsCommand() + command_callback = self.docs_command.command_callback + super().__init__(name=COMMAND_NAME, help=HELP_TEXT, callback=command_callback) + + @staticmethod + def format_description(formatter: DocsCommandHelpTextFormatter): + with formatter.indented_section(name="Description", extra_indents=1): + formatter.write_rd( + [ + RowDefinition( + text="", + name=DESCRIPTION + + style("\n This command does not require access to AWS credentials.", bold=True), + ), + ], + ) + + def format_sub_commands(self, formatter: DocsCommandHelpTextFormatter): + with formatter.indented_section(name="Commands", extra_indents=1): + formatter.write_rd( + [ + RowDefinition(self.docs_command.base_command + " " + command) + for command in self.docs_command.all_commands + ] + ) + + def format_options(self, ctx: Context, formatter: DocsCommandHelpTextFormatter): + DocsBaseCommand.format_description(formatter) + self.format_sub_commands(formatter) class DocsSubcommand(click.MultiCommand): @@ -62,8 +108,8 @@ def get_command(self, ctx: Context, cmd_name: str) -> Command: """ next_command = self.command.pop(0) - if not len(self.command): - return click.Command( + if not self.command: + return DocsBaseCommand( name=next_command, short_help=f"Documentation for {self.command_string}", callback=self.command_callback, @@ -71,7 +117,7 @@ def get_command(self, ctx: Context, cmd_name: str) -> Command: return DocsSubcommand(command=self.command) def list_commands(self, ctx: Context): - return list(Documentation.load().keys()) + return self.docs_command.all_commands class DocsCommand: @@ -92,6 +138,10 @@ def sub_command_string(self): def sub_commands(self): return self._filter_arguments(sys.argv[2:]) + @property + def base_command(self): + return f"sam {COMMAND_NAME}" + @staticmethod def _filter_arguments(commands): return list(filter(lambda arg: not arg.startswith("-"), commands)) @@ -106,6 +156,7 @@ class CommandImplementation: def __init__(self, command: str): self.command = command + @common_options def run_command(self): """ Run the necessary logic for the `sam docs` command @@ -121,8 +172,7 @@ def run_command(self): click.echo(SUCCESS_MESSAGE) -@click.command(name="docs", help=HELP_TEXT, cls=DocsCommand().create_command()) -@common_options +@click.command(name=COMMAND_NAME, help=HELP_TEXT, cls=DocsCommand().create_command()) @pass_context @track_command @check_newer_version From acff1e2a29358d2e6c17891ecddad72c0283b07a Mon Sep 17 00:00:00 2001 From: Daniel Mil Date: Fri, 17 Mar 2023 14:50:06 -0700 Subject: [PATCH 04/15] Format logic --- samcli/commands/docs/command.py | 173 ++---------------------- samcli/commands/docs/command_context.py | 71 ++++++++++ samcli/commands/docs/core/command.py | 91 +++++++++++++ samcli/commands/docs/core/formatter.py | 9 ++ samcli/commands/docs/exceptions.py | 7 + 5 files changed, 187 insertions(+), 164 deletions(-) create mode 100644 samcli/commands/docs/command_context.py create mode 100644 samcli/commands/docs/core/command.py create mode 100644 samcli/commands/docs/core/formatter.py create mode 100644 samcli/commands/docs/exceptions.py diff --git a/samcli/commands/docs/command.py b/samcli/commands/docs/command.py index a24ab41d34..e4d2a83b7f 100644 --- a/samcli/commands/docs/command.py +++ b/samcli/commands/docs/command.py @@ -1,178 +1,23 @@ """ CLI command for "docs" command """ -import functools -import sys -from typing import List, Optional +from click import command -import click -from click import Command, Context, style - -from samcli.cli.formatters import RootCommandHelpTextFormatter -from samcli.cli.main import common_options, pass_context, print_cmdline_args -from samcli.cli.row_modifiers import BaseLineRowModifier, RowDefinition +from samcli.cli.main import pass_context, print_cmdline_args from samcli.commands._utils.command_exception_handler import command_exception_handler -from samcli.commands.exceptions import UserException -from samcli.lib.docs.browser_configuration import BrowserConfiguration, BrowserConfigurationError -from samcli.lib.docs.documentation import Documentation +from samcli.commands.docs.command_context import COMMAND_NAME, DocsCommandContext +from samcli.commands.docs.core.command import DocsSubcommand, DocsBaseCommand from samcli.lib.telemetry.metric import track_command from samcli.lib.utils.version_checker import check_newer_version -COMMAND_NAME = "docs" -HELP_TEXT = "NEW! Open the documentation in a browser." -DESCRIPTION = """ - Launch the AWS SAM CLI documentation in a browser! This command will - show information about setting up credentials, the - AWS SAM CLI lifecycle and other useful details. - - The command also be run with sub-commands to open specific pages. -""" - -SUCCESS_MESSAGE = "Documentation page opened in a browser. These other sub-commands are also invokable." -ERROR_MESSAGE = "Failed to open a web browser. Use the following link to navigate to the documentation page: {URL}" - - -class InvalidDocsCommandException(UserException): - """ - Exception when the docs command fails - """ - - -class DocsCommandHelpTextFormatter(RootCommandHelpTextFormatter): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.left_justification_length = self.width // 2 - self.indent_increment - self.modifiers = [BaseLineRowModifier()] - - -class DocsBaseCommand(click.Command): - class CustomFormatterContext(Context): - formatter_class = DocsCommandHelpTextFormatter - - context_class = CustomFormatterContext - - def __init__(self, *args, **kwargs): - self.docs_command = DocsCommand() - command_callback = self.docs_command.command_callback - super().__init__(name=COMMAND_NAME, help=HELP_TEXT, callback=command_callback) - - @staticmethod - def format_description(formatter: DocsCommandHelpTextFormatter): - with formatter.indented_section(name="Description", extra_indents=1): - formatter.write_rd( - [ - RowDefinition( - text="", - name=DESCRIPTION - + style("\n This command does not require access to AWS credentials.", bold=True), - ), - ], - ) - - def format_sub_commands(self, formatter: DocsCommandHelpTextFormatter): - with formatter.indented_section(name="Commands", extra_indents=1): - formatter.write_rd( - [ - RowDefinition(self.docs_command.base_command + " " + command) - for command in self.docs_command.all_commands - ] - ) - - def format_options(self, ctx: Context, formatter: DocsCommandHelpTextFormatter): - DocsBaseCommand.format_description(formatter) - self.format_sub_commands(formatter) - - -class DocsSubcommand(click.MultiCommand): - def __init__(self, command: Optional[List[str]] = None, *args, **kwargs): - super().__init__(*args, **kwargs) - self.docs_command = DocsCommand() - self.command = command or self.docs_command.sub_commands - self.command_string = self.docs_command.sub_command_string - self.command_callback = self.docs_command.command_callback - - def get_command(self, ctx: Context, cmd_name: str) -> Command: - """ - Overriding the get_command method from the parent class. - - This method recursively gets creates sub-commands until - it reaches the leaf command, then it returns that as a click command. - - Parameters - ---------- - ctx - cmd_name - - Returns - ------- - - """ - next_command = self.command.pop(0) - if not self.command: - return DocsBaseCommand( - name=next_command, - short_help=f"Documentation for {self.command_string}", - callback=self.command_callback, - ) - return DocsSubcommand(command=self.command) - - def list_commands(self, ctx: Context): - return self.docs_command.all_commands - - -class DocsCommand: - @property - def command_callback(self): - impl = CommandImplementation(command=self.sub_command_string) - return functools.partial(impl.run_command) - - @property - def all_commands(self): - return list(Documentation.load().keys()) - - @property - def sub_command_string(self): - return " ".join(self.sub_commands) - - @property - def sub_commands(self): - return self._filter_arguments(sys.argv[2:]) - - @property - def base_command(self): - return f"sam {COMMAND_NAME}" - - @staticmethod - def _filter_arguments(commands): - return list(filter(lambda arg: not arg.startswith("-"), commands)) - - def create_command(self): - if self.sub_commands: - return DocsSubcommand - return DocsBaseCommand - - -class CommandImplementation: - def __init__(self, command: str): - self.command = command - @common_options - def run_command(self): - """ - Run the necessary logic for the `sam docs` command - """ - # TODO: Make sure the docs page exists in the list of docs pages - browser = BrowserConfiguration() - documentation = Documentation(browser=browser, command=self.command) - try: - documentation.open_docs() - except BrowserConfigurationError: - click.echo(ERROR_MESSAGE.format(URL=documentation.url)) - else: - click.echo(SUCCESS_MESSAGE) +def create_command(): + if DocsCommandContext().sub_commands: + return DocsSubcommand + return DocsBaseCommand -@click.command(name=COMMAND_NAME, help=HELP_TEXT, cls=DocsCommand().create_command()) +@command(name=COMMAND_NAME, cls=create_command()) @pass_context @track_command @check_newer_version diff --git a/samcli/commands/docs/command_context.py b/samcli/commands/docs/command_context.py new file mode 100644 index 0000000000..f5c4113aa6 --- /dev/null +++ b/samcli/commands/docs/command_context.py @@ -0,0 +1,71 @@ +import functools +import os +import sys + +from click import echo + +from samcli.cli.main import common_options +from samcli.commands.docs.exceptions import InvalidDocsCommandException +from samcli.lib.docs.browser_configuration import BrowserConfiguration, BrowserConfigurationError +from samcli.lib.docs.documentation import Documentation + +COMMAND_NAME = "docs" + +SUCCESS_MESSAGE = "Documentation page opened in a browser. These other sub-commands are also invokable." +ERROR_MESSAGE = "Failed to open a web browser. Use the following link to navigate to the documentation page: {URL}" + + +class DocsCommandContext: + def get_complete_command_paths(self): + return [self.base_command + " " + command for command in self.all_commands] + + @property + def command_callback(self): + impl = CommandImplementation(command=self.sub_command_string) + return functools.partial(impl.run_command) + + @property + def all_commands(self): + return list(Documentation.load().keys()) + + @property + def sub_command_string(self): + return " ".join(self.sub_commands) + + @property + def sub_commands(self): + return self._filter_arguments(sys.argv[2:]) + + @property + def base_command(self): + return f"sam {COMMAND_NAME}" + + @staticmethod + def _filter_arguments(commands): + return list(filter(lambda arg: not arg.startswith("-"), commands)) + + +class CommandImplementation: + def __init__(self, command: str): + self.command = command + self.docs_command = DocsCommandContext() + + @common_options + def run_command(self): + """ + Run the necessary logic for the `sam docs` command + """ + if self.docs_command.sub_commands and self.command not in self.docs_command.all_commands: + raise InvalidDocsCommandException( + f"Command not found. Try using one of the following available commands:{os.linesep}" + f"{os.linesep.join([command for command in self.docs_command.get_complete_command_paths()])}" + ) + browser = BrowserConfiguration() + documentation = Documentation(browser=browser, command=self.command) + try: + documentation.open_docs() + except BrowserConfigurationError: + echo(ERROR_MESSAGE.format(URL=documentation.url)) + else: + echo(SUCCESS_MESSAGE) + diff --git a/samcli/commands/docs/core/command.py b/samcli/commands/docs/core/command.py new file mode 100644 index 0000000000..70034de5ba --- /dev/null +++ b/samcli/commands/docs/core/command.py @@ -0,0 +1,91 @@ +from typing import List, Optional + +from click import Command, Context, MultiCommand, style + +from samcli.cli.row_modifiers import RowDefinition +from samcli.commands.docs.command_context import DocsCommandContext, COMMAND_NAME +from samcli.commands.docs.core.formatter import DocsCommandHelpTextFormatter + +HELP_TEXT = "NEW! Open the documentation in a browser." +DESCRIPTION = """ + Launch the AWS SAM CLI documentation in a browser! This command will + show information about setting up credentials, the + AWS SAM CLI lifecycle and other useful details. + + The command also be run with sub-commands to open specific pages. +""" + + +class DocsBaseCommand(Command): + class CustomFormatterContext(Context): + formatter_class = DocsCommandHelpTextFormatter + + context_class = CustomFormatterContext + + def __init__(self, *args, **kwargs): + self.docs_command = DocsCommandContext() + command_callback = self.docs_command.command_callback + super().__init__(name=COMMAND_NAME, help=HELP_TEXT, callback=command_callback) + + @staticmethod + def format_description(formatter: DocsCommandHelpTextFormatter): + with formatter.indented_section(name="Description", extra_indents=1): + formatter.write_rd( + [ + RowDefinition( + text="", + name=DESCRIPTION + + style("\n This command does not require access to AWS credentials.", bold=True), + ), + ], + ) + + def format_sub_commands(self, formatter: DocsCommandHelpTextFormatter): + with formatter.indented_section(name="Commands", extra_indents=1): + formatter.write_rd( + [ + RowDefinition(self.docs_command.base_command + " " + command) + for command in self.docs_command.all_commands + ] + ) + + def format_options(self, ctx: Context, formatter: DocsCommandHelpTextFormatter): + DocsBaseCommand.format_description(formatter) + self.format_sub_commands(formatter) + + +class DocsSubcommand(MultiCommand): + def __init__(self, command: Optional[List[str]] = None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.docs_command = DocsCommandContext() + self.command = command or self.docs_command.sub_commands + self.command_string = self.docs_command.sub_command_string + self.command_callback = self.docs_command.command_callback + + def get_command(self, ctx: Context, cmd_name: str) -> Command: + """ + Overriding the get_command method from the parent class. + + This method recursively gets creates sub-commands until + it reaches the leaf command, then it returns that as a click command. + + Parameters + ---------- + ctx + cmd_name + + Returns + ------- + + """ + next_command = self.command.pop(0) + if not self.command: + return DocsBaseCommand( + name=next_command, + short_help=f"Documentation for {self.command_string}", + callback=self.command_callback, + ) + return DocsSubcommand(command=self.command) + + def list_commands(self, ctx: Context): + return self.docs_command.all_commands diff --git a/samcli/commands/docs/core/formatter.py b/samcli/commands/docs/core/formatter.py new file mode 100644 index 0000000000..c5eae2f575 --- /dev/null +++ b/samcli/commands/docs/core/formatter.py @@ -0,0 +1,9 @@ +from samcli.cli.formatters import RootCommandHelpTextFormatter +from samcli.cli.row_modifiers import BaseLineRowModifier + + +class DocsCommandHelpTextFormatter(RootCommandHelpTextFormatter): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.left_justification_length = self.width // 2 - self.indent_increment + self.modifiers = [BaseLineRowModifier()] diff --git a/samcli/commands/docs/exceptions.py b/samcli/commands/docs/exceptions.py new file mode 100644 index 0000000000..15bd521c81 --- /dev/null +++ b/samcli/commands/docs/exceptions.py @@ -0,0 +1,7 @@ +from samcli.commands.exceptions import UserException + + +class InvalidDocsCommandException(UserException): + """ + Exception when the docs command fails + """ From b5e7369059a9f74090cbe9b7cdb1b0753a44b9bf Mon Sep 17 00:00:00 2001 From: Daniel Mil Date: Mon, 20 Mar 2023 14:22:02 -0700 Subject: [PATCH 05/15] Add docstrings, type hints and remaining docs pages --- samcli/commands/docs/command.py | 23 +++++---- samcli/commands/docs/command_context.py | 63 ++++++++++++++++++++---- samcli/commands/docs/core/command.py | 57 +++++++++++++++++++-- samcli/commands/docs/core/formatter.py | 3 ++ samcli/lib/docs/documentation.py | 26 ++++++++-- samcli/lib/docs/documentation_links.json | 19 ++++++- 6 files changed, 163 insertions(+), 28 deletions(-) diff --git a/samcli/commands/docs/command.py b/samcli/commands/docs/command.py index e4d2a83b7f..940237b478 100644 --- a/samcli/commands/docs/command.py +++ b/samcli/commands/docs/command.py @@ -1,17 +1,25 @@ """ CLI command for "docs" command """ -from click import command +from typing import Type -from samcli.cli.main import pass_context, print_cmdline_args +from click import Command, command + +from samcli.cli.main import pass_context from samcli.commands._utils.command_exception_handler import command_exception_handler from samcli.commands.docs.command_context import COMMAND_NAME, DocsCommandContext -from samcli.commands.docs.core.command import DocsSubcommand, DocsBaseCommand -from samcli.lib.telemetry.metric import track_command -from samcli.lib.utils.version_checker import check_newer_version +from samcli.commands.docs.core.command import DocsBaseCommand, DocsSubcommand -def create_command(): +def create_command() -> Type[Command]: + """ + Factory method for creating a Docs command + Returns + ------- + Type[Command] + Sub-command class if the command line args include + sub-commands, otherwise returns the base command class + """ if DocsCommandContext().sub_commands: return DocsSubcommand return DocsBaseCommand @@ -19,9 +27,6 @@ def create_command(): @command(name=COMMAND_NAME, cls=create_command()) @pass_context -@track_command -@check_newer_version -@print_cmdline_args @command_exception_handler def cli(ctx): """ diff --git a/samcli/commands/docs/command_context.py b/samcli/commands/docs/command_context.py index f5c4113aa6..e527ba6a2e 100644 --- a/samcli/commands/docs/command_context.py +++ b/samcli/commands/docs/command_context.py @@ -1,47 +1,85 @@ import functools import os import sys +from typing import Callable, List from click import echo -from samcli.cli.main import common_options +from samcli.cli.main import common_options, print_cmdline_args from samcli.commands.docs.exceptions import InvalidDocsCommandException from samcli.lib.docs.browser_configuration import BrowserConfiguration, BrowserConfigurationError from samcli.lib.docs.documentation import Documentation +from samcli.lib.telemetry.metric import track_command COMMAND_NAME = "docs" -SUCCESS_MESSAGE = "Documentation page opened in a browser. These other sub-commands are also invokable." +SUCCESS_MESSAGE = "Documentation page opened in a browser." ERROR_MESSAGE = "Failed to open a web browser. Use the following link to navigate to the documentation page: {URL}" class DocsCommandContext: - def get_complete_command_paths(self): + def get_complete_command_paths(self) -> List[str]: + """ + Get a list of strings representing the fully qualified commands invokable by sam docs + + Returns + ------- + List[str] + A string list of commands including the base command + """ return [self.base_command + " " + command for command in self.all_commands] @property - def command_callback(self): + def command_callback(self) -> Callable[[str], None]: + """ + Returns the callback function as a callable with the sub command string + """ impl = CommandImplementation(command=self.sub_command_string) return functools.partial(impl.run_command) @property - def all_commands(self): + def all_commands(self) -> List[str]: + """ + Returns all the commands from the commands list in the docs config + """ return list(Documentation.load().keys()) @property - def sub_command_string(self): + def sub_command_string(self) -> str: + """ + Returns a string representation of the sub-commands + """ return " ".join(self.sub_commands) @property - def sub_commands(self): + def sub_commands(self) -> List[str]: + """ + Returns the filtered command line arguments after "sam docs" + """ return self._filter_arguments(sys.argv[2:]) @property - def base_command(self): + def base_command(self) -> str: + """ + Returns a string representation of the base command (e.g "sam docs") + """ return f"sam {COMMAND_NAME}" @staticmethod - def _filter_arguments(commands): + def _filter_arguments(commands: List[str]) -> List[str]: + """ + Take a list of command line arguments and filter out all flags + + Parameters + ---------- + commands: List[str] + The command line arguments + + Returns + ------- + List of strings after filtering it all flags + + """ return list(filter(lambda arg: not arg.startswith("-"), commands)) @@ -50,10 +88,16 @@ def __init__(self, command: str): self.command = command self.docs_command = DocsCommandContext() + @track_command + @print_cmdline_args @common_options def run_command(self): """ Run the necessary logic for the `sam docs` command + + Raises + ------ + InvalidDocsCommandException """ if self.docs_command.sub_commands and self.command not in self.docs_command.all_commands: raise InvalidDocsCommandException( @@ -68,4 +112,3 @@ def run_command(self): echo(ERROR_MESSAGE.format(URL=documentation.url)) else: echo(SUCCESS_MESSAGE) - diff --git a/samcli/commands/docs/core/command.py b/samcli/commands/docs/core/command.py index 70034de5ba..335b2cb0c3 100644 --- a/samcli/commands/docs/core/command.py +++ b/samcli/commands/docs/core/command.py @@ -1,9 +1,12 @@ +""" +Module contains classes for creating the docs command from click +""" from typing import List, Optional from click import Command, Context, MultiCommand, style from samcli.cli.row_modifiers import RowDefinition -from samcli.commands.docs.command_context import DocsCommandContext, COMMAND_NAME +from samcli.commands.docs.command_context import COMMAND_NAME, DocsCommandContext from samcli.commands.docs.core.formatter import DocsCommandHelpTextFormatter HELP_TEXT = "NEW! Open the documentation in a browser." @@ -29,6 +32,14 @@ def __init__(self, *args, **kwargs): @staticmethod def format_description(formatter: DocsCommandHelpTextFormatter): + """ + Formats the description of the help text for the docs command. + + Parameters + ---------- + formatter: DocsCommandHelpTextFormatter + A formatter instance to use for formatting the help text + """ with formatter.indented_section(name="Description", extra_indents=1): formatter.write_rd( [ @@ -41,6 +52,14 @@ def format_description(formatter: DocsCommandHelpTextFormatter): ) def format_sub_commands(self, formatter: DocsCommandHelpTextFormatter): + """ + Formats the sub-commands of the help text for the docs command. + + Parameters + ---------- + formatter: DocsCommandHelpTextFormatter + A formatter instance to use for formatting the help text + """ with formatter.indented_section(name="Commands", extra_indents=1): formatter.write_rd( [ @@ -50,6 +69,17 @@ def format_sub_commands(self, formatter: DocsCommandHelpTextFormatter): ) def format_options(self, ctx: Context, formatter: DocsCommandHelpTextFormatter): + """ + Overrides the format_options method from the parent class to update + the help text formatting in a consistent method for the AWS SAM CLI + + Parameters + ---------- + ctx: Context + The click command context + formatter: DocsCommandHelpTextFormatter + A formatter instance to use for formatting the help text + """ DocsBaseCommand.format_description(formatter) self.format_sub_commands(formatter) @@ -71,11 +101,16 @@ def get_command(self, ctx: Context, cmd_name: str) -> Command: Parameters ---------- - ctx - cmd_name + ctx: Context + The click command context + cmd_name: str + Name of the next command to be added as a sub-command or the leaf command Returns ------- + Command + Returns either a sub-command to be recursively added to the command tree, + or the leaf command to be invoked by the command handler """ next_command = self.command.pop(0) @@ -87,5 +122,19 @@ def get_command(self, ctx: Context, cmd_name: str) -> Command: ) return DocsSubcommand(command=self.command) - def list_commands(self, ctx: Context): + def list_commands(self, ctx: Context) -> List[str]: + """ + Overrides the list_command method from the parent class. + Used for the Command class to understand all possible sub-commands. + + Parameters + ---------- + ctx: Context + The click command context + + Returns + ------- + List[str] + List of strings representing sub-commands callable by the docs command + """ return self.docs_command.all_commands diff --git a/samcli/commands/docs/core/formatter.py b/samcli/commands/docs/core/formatter.py index c5eae2f575..fc2d265fcf 100644 --- a/samcli/commands/docs/core/formatter.py +++ b/samcli/commands/docs/core/formatter.py @@ -1,3 +1,6 @@ +""" +Base formatter for the docs command help text +""" from samcli.cli.formatters import RootCommandHelpTextFormatter from samcli.cli.row_modifiers import BaseLineRowModifier diff --git a/samcli/lib/docs/documentation.py b/samcli/lib/docs/documentation.py index 8801bf6f8d..e42ae4a41c 100644 --- a/samcli/lib/docs/documentation.py +++ b/samcli/lib/docs/documentation.py @@ -4,6 +4,7 @@ import json import logging from pathlib import Path +from typing import Any, Dict from samcli.lib.docs.browser_configuration import BrowserConfiguration @@ -19,7 +20,10 @@ def __init__(self, browser: BrowserConfiguration, command: str): self.command = command @property - def url(self): + def url(self) -> str: + """ + Returns the url to be opened + """ return self.get_docs_link_for_command() def open_docs(self): @@ -33,10 +37,26 @@ def open_docs(self): LOG.debug(f"Launching {self.url} in a browser.") self.browser.launch(self.url) - def get_docs_link_for_command(self): + def get_docs_link_for_command(self) -> str: + """ + Get the documentation URL from a specific command + + Returns + ------- + str + String representing the link to be opened + """ return Documentation.load().get(self.command, LANDING_PAGE) @staticmethod - def load() -> dict: + def load() -> Dict[str, Any]: + """ + Opens the configuration file and returns the contents + + Returns + ------- + Dict[str, Any] + A dictionary containing commands and their corresponding docs URLs + """ with open(Path(__file__).parent / DOCS_CONFIG_FILE) as f: return json.load(f) diff --git a/samcli/lib/docs/documentation_links.json b/samcli/lib/docs/documentation_links.json index ee3028ec91..dd88b77482 100644 --- a/samcli/lib/docs/documentation_links.json +++ b/samcli/lib/docs/documentation_links.json @@ -1,6 +1,21 @@ { "config": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html", - "build": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-using-build.html", + "build": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-build.htmll", "local": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-test-and-debug.html", - "local invoke": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-using-invoke.html" + "local invoke": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-using-invoke.html", + "local start-api": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-local-start-api.html", + "local start-lambda": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-local-start-lambda.html", + "list": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-list.html", + "list stack-outputs": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-list-stack-outputs.html", + "list endpoints": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-list-endpoints.html", + "list resources": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-list-resources.html", + "deploy": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-delete.html", + "package": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-package.html", + "delete": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-delete.html", + "sync": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-sync.html", + "publish": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-publish.html", + "validate": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-validate.html", + "init": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-init.html", + "logs": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-logs.html", + "traces": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-traces.html" } \ No newline at end of file From a5845c22abba6951175c23107683e031991e3a2f Mon Sep 17 00:00:00 2001 From: Daniel Mil Date: Mon, 20 Mar 2023 15:18:13 -0700 Subject: [PATCH 06/15] Fix existing tests --- samcli/commands/docs/command.py | 4 +- samcli/commands/docs/core/__init__.py | 0 samcli/commands/docs/core/command.py | 6 +-- samcli/lib/docs/documentation.py | 2 +- samcli/lib/docs/documentation_links.json | 4 +- tests/integration/docs/docs_integ_base.py | 10 ++++- tests/integration/docs/test_docs_command.py | 39 ++++++++++++++++--- tests/unit/commands/docs/test_command.py | 19 --------- tests/unit/commands/docs/test_docs_context.py | 34 +--------------- tests/unit/lib/docs/test_documentation.py | 7 +++- 10 files changed, 56 insertions(+), 69 deletions(-) create mode 100644 samcli/commands/docs/core/__init__.py delete mode 100644 tests/unit/commands/docs/test_command.py diff --git a/samcli/commands/docs/command.py b/samcli/commands/docs/command.py index 940237b478..e42bef4448 100644 --- a/samcli/commands/docs/command.py +++ b/samcli/commands/docs/command.py @@ -8,7 +8,7 @@ from samcli.cli.main import pass_context from samcli.commands._utils.command_exception_handler import command_exception_handler from samcli.commands.docs.command_context import COMMAND_NAME, DocsCommandContext -from samcli.commands.docs.core.command import DocsBaseCommand, DocsSubcommand +from samcli.commands.docs.core.command import DocsBaseCommand, DocsSubCommand def create_command() -> Type[Command]: @@ -21,7 +21,7 @@ def create_command() -> Type[Command]: sub-commands, otherwise returns the base command class """ if DocsCommandContext().sub_commands: - return DocsSubcommand + return DocsSubCommand return DocsBaseCommand diff --git a/samcli/commands/docs/core/__init__.py b/samcli/commands/docs/core/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/samcli/commands/docs/core/command.py b/samcli/commands/docs/core/command.py index 335b2cb0c3..dd86493839 100644 --- a/samcli/commands/docs/core/command.py +++ b/samcli/commands/docs/core/command.py @@ -68,7 +68,7 @@ def format_sub_commands(self, formatter: DocsCommandHelpTextFormatter): ] ) - def format_options(self, ctx: Context, formatter: DocsCommandHelpTextFormatter): + def format_options(self, ctx: Context, formatter: DocsCommandHelpTextFormatter): # type:ignore """ Overrides the format_options method from the parent class to update the help text formatting in a consistent method for the AWS SAM CLI @@ -84,7 +84,7 @@ def format_options(self, ctx: Context, formatter: DocsCommandHelpTextFormatter): self.format_sub_commands(formatter) -class DocsSubcommand(MultiCommand): +class DocsSubCommand(MultiCommand): def __init__(self, command: Optional[List[str]] = None, *args, **kwargs): super().__init__(*args, **kwargs) self.docs_command = DocsCommandContext() @@ -120,7 +120,7 @@ def get_command(self, ctx: Context, cmd_name: str) -> Command: short_help=f"Documentation for {self.command_string}", callback=self.command_callback, ) - return DocsSubcommand(command=self.command) + return DocsSubCommand(command=self.command) def list_commands(self, ctx: Context) -> List[str]: """ diff --git a/samcli/lib/docs/documentation.py b/samcli/lib/docs/documentation.py index e42ae4a41c..b126454f8b 100644 --- a/samcli/lib/docs/documentation.py +++ b/samcli/lib/docs/documentation.py @@ -59,4 +59,4 @@ def load() -> Dict[str, Any]: A dictionary containing commands and their corresponding docs URLs """ with open(Path(__file__).parent / DOCS_CONFIG_FILE) as f: - return json.load(f) + return dict(json.load(f)) diff --git a/samcli/lib/docs/documentation_links.json b/samcli/lib/docs/documentation_links.json index dd88b77482..9323e720ae 100644 --- a/samcli/lib/docs/documentation_links.json +++ b/samcli/lib/docs/documentation_links.json @@ -1,8 +1,8 @@ { "config": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html", - "build": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-build.htmll", + "build": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-build.html", "local": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-test-and-debug.html", - "local invoke": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-using-invoke.html", + "local invoke": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-local-invoke.html", "local start-api": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-local-start-api.html", "local start-lambda": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-local-start-lambda.html", "list": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-list.html", diff --git a/tests/integration/docs/docs_integ_base.py b/tests/integration/docs/docs_integ_base.py index e8b9e92027..7d3823ad39 100644 --- a/tests/integration/docs/docs_integ_base.py +++ b/tests/integration/docs/docs_integ_base.py @@ -1,3 +1,4 @@ +from typing import List, Optional from unittest import TestCase from tests.testing_utils import get_sam_command @@ -5,5 +6,10 @@ class DocsIntegBase(TestCase): @staticmethod - def get_docs_command_list(): - return [get_sam_command(), "docs"] + def get_docs_command_list(sub_commands: Optional[List[str]] = None): + command = [get_sam_command(), "docs"] + + if sub_commands: + command += sub_commands + + return command diff --git a/tests/integration/docs/test_docs_command.py b/tests/integration/docs/test_docs_command.py index e3418592dd..842ff401a8 100644 --- a/tests/integration/docs/test_docs_command.py +++ b/tests/integration/docs/test_docs_command.py @@ -1,17 +1,44 @@ -from samcli.commands.docs.docs_context import DocsContext, SUCCESS_MESSAGE, ERROR_MESSAGE +import os + +from parameterized import parameterized + +from samcli.commands.docs.command_context import SUCCESS_MESSAGE, ERROR_MESSAGE, DocsCommandContext +from samcli.lib.docs.documentation import Documentation, LANDING_PAGE from tests.integration.docs.docs_integ_base import DocsIntegBase -from tests.testing_utils import run_command, RUNNING_ON_CI +from tests.testing_utils import run_command + +COMMAND_URL_PAIR = [(command, url) for command, url in Documentation.load().items()] class TestDocsCommand(DocsIntegBase): - def test_docs_command(self): + @parameterized.expand(COMMAND_URL_PAIR) + def test_docs_command(self, command, url): + sub_commands = command.split(" ") + command_list = self.get_docs_command_list(sub_commands=sub_commands) + command_result = run_command(command_list) + stdout = command_result.stdout.decode("utf-8").strip() + self.assertEqual(command_result.process.returncode, 0) + self._assert_valid_response(stdout, url) + + def test_base_command(self): command_list = self.get_docs_command_list() command_result = run_command(command_list) stdout = command_result.stdout.decode("utf-8").strip() self.assertEqual(command_result.process.returncode, 0) - self._assert_valid_response(stdout) + self._assert_valid_response(stdout, LANDING_PAGE) + + def test_invalid_command(self): + command_list = self.get_docs_command_list(sub_commands=["wrong", "command"]) + command_result = run_command(command_list) + stderr = command_result.stderr.decode("utf-8").strip() + self.assertEqual(command_result.process.returncode, 1) + self.assertEqual( + f"Error: Command not found. Try using one of the following available commands:{os.linesep}" + f"{os.linesep.join([command for command in DocsCommandContext().get_complete_command_paths()])}", + stderr, + ) - def _assert_valid_response(self, stdout): + def _assert_valid_response(self, stdout, url): # We don't know if the machine this runs on will have a browser, # so we're just testing to ensure we get one of two valid command outputs - return self.assertTrue(SUCCESS_MESSAGE in stdout or ERROR_MESSAGE.format(URL=DocsContext.URL) in stdout) + return self.assertTrue(SUCCESS_MESSAGE in stdout or ERROR_MESSAGE.format(URL=url) in stdout) diff --git a/tests/unit/commands/docs/test_command.py b/tests/unit/commands/docs/test_command.py deleted file mode 100644 index 47eab19ddb..0000000000 --- a/tests/unit/commands/docs/test_command.py +++ /dev/null @@ -1,19 +0,0 @@ -from unittest import TestCase -from unittest.mock import Mock, patch - -from samcli.commands.docs.command import do_cli - - -class TestDocsCliCommand(TestCase): - @patch("samcli.commands.docs.command.click") - @patch("samcli.commands.docs.docs_context.DocsContext") - def test_all_args(self, mock_docs_context, mock_docs_click): - context_mock = Mock() - mock_docs_context.return_value.__enter__.return_value = context_mock - - do_cli() - - mock_docs_context.assert_called_with() - - context_mock.run.assert_called_with() - self.assertEqual(context_mock.run.call_count, 1) diff --git a/tests/unit/commands/docs/test_docs_context.py b/tests/unit/commands/docs/test_docs_context.py index f46bd74c9e..4ec751ad83 100644 --- a/tests/unit/commands/docs/test_docs_context.py +++ b/tests/unit/commands/docs/test_docs_context.py @@ -1,39 +1,9 @@ from unittest import TestCase from unittest.mock import patch, Mock -from samcli.commands.docs.docs_context import DocsContext, ERROR_MESSAGE, SUCCESS_MESSAGE +from samcli.commands.docs.command_context import DocsCommandContext, ERROR_MESSAGE, SUCCESS_MESSAGE from samcli.lib.docs.browser_configuration import BrowserConfigurationError class TestDocsContext(TestCase): - def test_delete_context_enter(self): - with DocsContext() as docs_context: - self.assertIsInstance(docs_context, DocsContext) - - @patch("samcli.commands.docs.docs_context.echo") - @patch("samcli.commands.docs.docs_context.BrowserConfiguration") - @patch("samcli.commands.docs.docs_context.Documentation") - def test_run_command(self, mock_documentation, mock_browser_config, mock_echo): - mock_browser = Mock() - mock_documentation_object = Mock() - mock_browser_config.return_value = mock_browser - mock_documentation.return_value = mock_documentation_object - with DocsContext() as docs_context: - docs_context.run() - mock_browser_config.assert_called_once() - mock_documentation.assert_called_once_with(browser=mock_browser, url=DocsContext.URL) - mock_documentation_object.open_docs.assert_called_once() - mock_echo.assert_called_once_with(SUCCESS_MESSAGE) - - @patch("samcli.commands.docs.docs_context.echo") - @patch("samcli.commands.docs.docs_context.BrowserConfiguration") - @patch("samcli.commands.docs.docs_context.Documentation") - def test_run_command_browser_exception(self, mock_documentation, mock_browser_config, mock_echo): - mock_browser = Mock() - mock_documentation_object = Mock() - mock_documentation_object.open_docs.side_effect = BrowserConfigurationError - mock_browser_config.return_value = mock_browser - mock_documentation.return_value = mock_documentation_object - with DocsContext() as docs_context: - docs_context.run() - mock_echo.assert_called_once_with(ERROR_MESSAGE.format(URL=DocsContext.URL)) + """""" diff --git a/tests/unit/lib/docs/test_documentation.py b/tests/unit/lib/docs/test_documentation.py index de53306608..8a37e1222a 100644 --- a/tests/unit/lib/docs/test_documentation.py +++ b/tests/unit/lib/docs/test_documentation.py @@ -6,8 +6,11 @@ class TestDocumentation(TestCase): def test_open_docs(self): - url = "https://sam-is-the-best.com" + url = ( + "https://docs.aws.amazon.com/serverless-application-model/" + "latest/developerguide/sam-cli-command-reference-sam-list.html" + ) mock_browser = Mock() - documentation = Documentation(browser=mock_browser, url=url) + documentation = Documentation(browser=mock_browser, command="list") documentation.open_docs() mock_browser.launch.assert_called_with(url) From f3f9e46140728eb0bf8234bc471cb66384dfbaf0 Mon Sep 17 00:00:00 2001 From: Daniel Mil Date: Mon, 20 Mar 2023 15:28:22 -0700 Subject: [PATCH 07/15] Fix integration test case --- tests/integration/docs/test_docs_command.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/integration/docs/test_docs_command.py b/tests/integration/docs/test_docs_command.py index 842ff401a8..1c1149306a 100644 --- a/tests/integration/docs/test_docs_command.py +++ b/tests/integration/docs/test_docs_command.py @@ -33,8 +33,10 @@ def test_invalid_command(self): stderr = command_result.stderr.decode("utf-8").strip() self.assertEqual(command_result.process.returncode, 1) self.assertEqual( - f"Error: Command not found. Try using one of the following available commands:{os.linesep}" - f"{os.linesep.join([command for command in DocsCommandContext().get_complete_command_paths()])}", + ( + f"Error: Command not found. Try using one of the following available commands:{os.linesep}" + f"{os.linesep.join([command for command in DocsCommandContext().get_complete_command_paths()])}" + ), stderr, ) From d2b0eb2fa3aa07d0fca6f54505e957ce855e7f0b Mon Sep 17 00:00:00 2001 From: Daniel Mil Date: Mon, 20 Mar 2023 15:32:51 -0700 Subject: [PATCH 08/15] Fix integration test case --- tests/integration/docs/test_docs_command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/docs/test_docs_command.py b/tests/integration/docs/test_docs_command.py index 1c1149306a..7a4525a90c 100644 --- a/tests/integration/docs/test_docs_command.py +++ b/tests/integration/docs/test_docs_command.py @@ -32,7 +32,7 @@ def test_invalid_command(self): command_result = run_command(command_list) stderr = command_result.stderr.decode("utf-8").strip() self.assertEqual(command_result.process.returncode, 1) - self.assertEqual( + self.assertIn( ( f"Error: Command not found. Try using one of the following available commands:{os.linesep}" f"{os.linesep.join([command for command in DocsCommandContext().get_complete_command_paths()])}" From e61024bafdd68ac48c8a144cb65f76a2109b50b5 Mon Sep 17 00:00:00 2001 From: Daniel Mil Date: Mon, 20 Mar 2023 17:49:03 -0700 Subject: [PATCH 09/15] Add unit tests --- tests/unit/commands/docs/core/__init__.py | 0 tests/unit/commands/docs/core/test_command.py | 50 +++++++++++++++++++ .../unit/commands/docs/core/test_formatter.py | 12 +++++ tests/unit/lib/docs/test_documentation.py | 30 ++++++++++- 4 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 tests/unit/commands/docs/core/__init__.py create mode 100644 tests/unit/commands/docs/core/test_command.py create mode 100644 tests/unit/commands/docs/core/test_formatter.py diff --git a/tests/unit/commands/docs/core/__init__.py b/tests/unit/commands/docs/core/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/commands/docs/core/test_command.py b/tests/unit/commands/docs/core/test_command.py new file mode 100644 index 0000000000..3def7e0dd1 --- /dev/null +++ b/tests/unit/commands/docs/core/test_command.py @@ -0,0 +1,50 @@ +from unittest import TestCase +from unittest.mock import Mock + +from samcli.commands.docs.command_context import DocsCommandContext +from samcli.commands.docs.core.command import DocsBaseCommand, DESCRIPTION, DocsSubCommand +from tests.unit.cli.test_command import MockFormatter + + +class TestDocsBaseCommand(TestCase): + def test_formatter(self): + ctx = Mock() + ctx.command_path = "sam docs" + formatter = MockFormatter(scrub_text=True) + cmd = DocsBaseCommand(name="sync", requires_credentials=True, description=DESCRIPTION) + cmd.format_options(ctx, formatter) + formatting_result = formatter.data + self.assertEqual(len(formatting_result), 2) + description = formatting_result.get("Description", {}) + commands = formatting_result.get("Commands", {}) + self.assertEqual(len(description), 1) + self.assertIn( + ( + "Launch the AWS SAM CLI documentation in a browser! " + "This command will\n show information about setting up credentials, " + "the\n AWS SAM CLI lifecycle and other useful details. \n\n " + "The command also be run with sub-commands to open specific pages." + ), + description[0][0], + ) + self.assertEqual(len(commands), 19) + all_commands = set(DocsCommandContext().get_complete_command_paths()) + formatter_commands = set([command[0] for command in commands]) + self.assertEqual(all_commands, formatter_commands) + + +class TestDocsSubCommand(TestCase): + def test_get_command_with_sub_commands(self): + command = ["local", "invoke"] + sub_command = DocsSubCommand(command=command) + resolved_command = sub_command.get_command(ctx=None, cmd_name="local") + self.assertTrue(isinstance(resolved_command, DocsSubCommand)) + + def test_get_command_with_base_command(self): + command = ["local"] + sub_command = DocsSubCommand(command=command) + resolved_command = sub_command.get_command(ctx=None, cmd_name="local") + self.assertTrue(isinstance(resolved_command, DocsBaseCommand)) + + def test_list_commands(self): + self.assertEqual(DocsCommandContext().all_commands, DocsSubCommand().list_commands(ctx=None)) diff --git a/tests/unit/commands/docs/core/test_formatter.py b/tests/unit/commands/docs/core/test_formatter.py new file mode 100644 index 0000000000..632f4d3f47 --- /dev/null +++ b/tests/unit/commands/docs/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.docs.core.formatter import DocsCommandHelpTextFormatter + + +class TestDocsCommandHelpTextFormatter(TestCase): + def test_docs_formatter(self): + formatter = DocsCommandHelpTextFormatter() + self.assertTrue(formatter.left_justification_length <= get_terminal_size().columns // 2) + self.assertIsInstance(formatter.modifiers[0], BaseLineRowModifier) diff --git a/tests/unit/lib/docs/test_documentation.py b/tests/unit/lib/docs/test_documentation.py index 8a37e1222a..e98686536f 100644 --- a/tests/unit/lib/docs/test_documentation.py +++ b/tests/unit/lib/docs/test_documentation.py @@ -1,7 +1,7 @@ from unittest import TestCase -from unittest.mock import Mock +from unittest.mock import Mock, patch, mock_open -from samcli.lib.docs.documentation import Documentation +from samcli.lib.docs.documentation import Documentation, LANDING_PAGE class TestDocumentation(TestCase): @@ -14,3 +14,29 @@ def test_open_docs(self): documentation = Documentation(browser=mock_browser, command="list") documentation.open_docs() mock_browser.launch.assert_called_with(url) + + @patch("samcli.lib.docs.documentation.Documentation.load") + def test_get_docs_link_for_command(self, mock_load): + mock_load.return_value = {"command": "link"} + mock_browser = Mock() + documentation = Documentation(browser=mock_browser, command="command") + url = documentation.get_docs_link_for_command() + self.assertEqual(url, "link") + + @patch("samcli.lib.docs.documentation.Documentation.load") + def test_get_default_docs_link_for_command(self, mock_load): + mock_load.return_value = {"command": "link"} + mock_browser = Mock() + documentation = Documentation(browser=mock_browser, command="command-invalid") + url = documentation.get_docs_link_for_command() + self.assertEqual(url, LANDING_PAGE) + + @patch("samcli.lib.docs.documentation.json") + def test_load(self, mock_json): + mock_file = Mock() + mock_browser = Mock() + documentation = Documentation(browser=mock_browser, command="command") + with patch("builtins.open", mock_open()) as mock_open_func: + documentation.load() + mock_open_func.assert_called_once() + mock_json.load.assert_called_once() From 8abbf8c0ef4106c0f0401de6d4470ced90c7e304 Mon Sep 17 00:00:00 2001 From: Daniel Mil Date: Tue, 21 Mar 2023 11:04:54 -0700 Subject: [PATCH 10/15] Remaining tests --- tests/integration/docs/test_docs_command.py | 5 +- tests/unit/commands/docs/test_docs_context.py | 104 +++++++++++++++++- 2 files changed, 101 insertions(+), 8 deletions(-) diff --git a/tests/integration/docs/test_docs_command.py b/tests/integration/docs/test_docs_command.py index 7a4525a90c..5dac0711ed 100644 --- a/tests/integration/docs/test_docs_command.py +++ b/tests/integration/docs/test_docs_command.py @@ -33,10 +33,7 @@ def test_invalid_command(self): stderr = command_result.stderr.decode("utf-8").strip() self.assertEqual(command_result.process.returncode, 1) self.assertIn( - ( - f"Error: Command not found. Try using one of the following available commands:{os.linesep}" - f"{os.linesep.join([command for command in DocsCommandContext().get_complete_command_paths()])}" - ), + f"Error: Command not found. Try using one of the following available commands:", stderr, ) diff --git a/tests/unit/commands/docs/test_docs_context.py b/tests/unit/commands/docs/test_docs_context.py index 4ec751ad83..160512f199 100644 --- a/tests/unit/commands/docs/test_docs_context.py +++ b/tests/unit/commands/docs/test_docs_context.py @@ -1,9 +1,105 @@ from unittest import TestCase -from unittest.mock import patch, Mock +from unittest.mock import patch, Mock, PropertyMock -from samcli.commands.docs.command_context import DocsCommandContext, ERROR_MESSAGE, SUCCESS_MESSAGE +from parameterized import parameterized + +from samcli.commands.docs.command_context import ( + DocsCommandContext, + ERROR_MESSAGE, + SUCCESS_MESSAGE, + CommandImplementation, +) +from samcli.commands.docs.exceptions import InvalidDocsCommandException from samcli.lib.docs.browser_configuration import BrowserConfigurationError +SUPPORTED_COMMANDS = [ + "config", + "build", + "local", + "local invoke", + "local start-api", + "local start-lambda", + "list", + "list stack-outputs", + "list endpoints", + "list resources", + "deploy", + "package", + "delete", + "sync", + "publish", + "validate", + "init", + "logs", + "traces", +] + + +class TestDocsCommandContext(TestCase): + @patch("samcli.commands.docs.command_context.sys.argv", ["sam", "docs", "local", "invoke"]) + def test_properties(self) -> None: + docs_command_context = DocsCommandContext() + self.assertEqual(docs_command_context.base_command, "sam docs") + self.assertEqual(docs_command_context.sub_commands, ["local", "invoke"]) + self.assertEqual(docs_command_context.sub_command_string, "local invoke") + self.assertEqual(docs_command_context.all_commands, SUPPORTED_COMMANDS) + + def test_get_complete_command_paths(self): + docs_command_context = DocsCommandContext() + with patch( + "samcli.commands.docs.command_context.DocsCommandContext.all_commands", new_callable=PropertyMock + ) as mock_all_commands: + mock_all_commands.return_value = ["config", "build", "local invoke"] + command_paths = docs_command_context.get_complete_command_paths() + self.assertEqual(command_paths, ["sam docs config", "sam docs build", "sam docs local invoke"]) + + @parameterized.expand( + [ + (["local", "invoke", "--help"], ["local", "invoke"]), + (["local", "invoke", "-h"], ["local", "invoke"]), + (["build", "--random-args"], ["build"]), + ] + ) + def test_filter_arguments(self, commands, expected): + output = DocsCommandContext._filter_arguments(commands) + self.assertEqual(output, expected) + + +class TestCommandImplementation(TestCase): + @patch("samcli.commands.docs.command_context.echo") + @patch("samcli.commands.docs.command_context.Documentation.open_docs") + def test_run_command(self, mock_open_docs, mock_echo): + command_implementation = CommandImplementation(command="build") + command_implementation.run_command() + mock_open_docs.assert_called_once() + mock_echo.assert_called_once_with(SUCCESS_MESSAGE) + + @patch("samcli.commands.docs.command_context.echo") + @patch("samcli.commands.docs.command_context.Documentation.open_docs") + def test_run_command_invalid_command(self, mock_open_docs, mock_echo): + with patch( + "samcli.commands.docs.command_context.DocsCommandContext.sub_commands", new_callable=PropertyMock + ) as mock_sub_commands: + with self.assertRaises(InvalidDocsCommandException): + mock_sub_commands.return_value = True + command_implementation = CommandImplementation(command="not-a-command") + command_implementation.run_command() + mock_open_docs.assert_not_called() + mock_echo.assert_not_called() -class TestDocsContext(TestCase): - """""" + @patch("samcli.commands.docs.command_context.echo") + @patch("samcli.commands.docs.command_context.Documentation") + @patch("samcli.commands.docs.command_context.BrowserConfiguration") + def test_run_command_no_browser(self, mock_browser_config, mock_documentation, mock_echo): + mock_browser = Mock() + mock_documentation_object = Mock() + mock_documentation_object.open_docs.side_effect = BrowserConfigurationError + mock_documentation_object.url = "some-url" + mock_documentation_object.sub_commands = [] + mock_browser_config.return_value = mock_browser + mock_documentation.return_value = mock_documentation_object + command_implementation = CommandImplementation(command="build") + command_implementation.docs_command = Mock() + command_implementation.docs_command.sub_commands = [] + command_implementation.run_command() + mock_echo.assert_called_once_with(ERROR_MESSAGE.format(URL="some-url")) From b01e831efad4480d7dfa7621c07e22178f6558bc Mon Sep 17 00:00:00 2001 From: Daniel Mil Date: Wed, 22 Mar 2023 09:37:12 -0700 Subject: [PATCH 11/15] Add docstrings to constructors --- samcli/commands/docs/command_context.py | 8 ++++++++ samcli/commands/docs/core/command.py | 11 +++++++++++ samcli/commands/docs/core/formatter.py | 3 +++ samcli/lib/docs/documentation.py | 9 +++++++++ 4 files changed, 31 insertions(+) diff --git a/samcli/commands/docs/command_context.py b/samcli/commands/docs/command_context.py index e527ba6a2e..42621b5993 100644 --- a/samcli/commands/docs/command_context.py +++ b/samcli/commands/docs/command_context.py @@ -85,6 +85,14 @@ def _filter_arguments(commands: List[str]) -> List[str]: class CommandImplementation: def __init__(self, command: str): + """ + Constructor used for instantiating a command implementation object + + Parameters + ---------- + command: str + Name of the command that is being executed + """ self.command = command self.docs_command = DocsCommandContext() diff --git a/samcli/commands/docs/core/command.py b/samcli/commands/docs/core/command.py index dd86493839..85522b8018 100644 --- a/samcli/commands/docs/core/command.py +++ b/samcli/commands/docs/core/command.py @@ -26,6 +26,9 @@ class CustomFormatterContext(Context): context_class = CustomFormatterContext def __init__(self, *args, **kwargs): + """ + Constructor for instantiating a base command for the docs command + """ self.docs_command = DocsCommandContext() command_callback = self.docs_command.command_callback super().__init__(name=COMMAND_NAME, help=HELP_TEXT, callback=command_callback) @@ -86,6 +89,14 @@ def format_options(self, ctx: Context, formatter: DocsCommandHelpTextFormatter): class DocsSubCommand(MultiCommand): def __init__(self, command: Optional[List[str]] = None, *args, **kwargs): + """ + Constructor for instantiating a sub-command for the docs command + + Parameters + ---------- + command: Optional[List[str]] + Optional list of strings representing the fully resolved command name (e.g. ["docs", "local", "invoke"]) + """ super().__init__(*args, **kwargs) self.docs_command = DocsCommandContext() self.command = command or self.docs_command.sub_commands diff --git a/samcli/commands/docs/core/formatter.py b/samcli/commands/docs/core/formatter.py index fc2d265fcf..e11a168def 100644 --- a/samcli/commands/docs/core/formatter.py +++ b/samcli/commands/docs/core/formatter.py @@ -7,6 +7,9 @@ class DocsCommandHelpTextFormatter(RootCommandHelpTextFormatter): def __init__(self, *args, **kwargs): + """ + Constructor for instantiating a formatter object used for formatting help text + """ super().__init__(*args, **kwargs) self.left_justification_length = self.width // 2 - self.indent_increment self.modifiers = [BaseLineRowModifier()] diff --git a/samcli/lib/docs/documentation.py b/samcli/lib/docs/documentation.py index b126454f8b..972ba2f58f 100644 --- a/samcli/lib/docs/documentation.py +++ b/samcli/lib/docs/documentation.py @@ -16,6 +16,15 @@ class Documentation: def __init__(self, browser: BrowserConfiguration, command: str): + """ + Constructor for instantiating a Documentation object + Parameters + ---------- + browser: BrowserConfiguration + Configuration for a browser object used to launch docs pages + command: str + String name of the command for which to find documentation + """ self.browser = browser self.command = command From f8bcf178fb717d5098ecb7099de992f0d7510875 Mon Sep 17 00:00:00 2001 From: Daniel Mil Date: Thu, 23 Mar 2023 15:31:00 -0700 Subject: [PATCH 12/15] Address comments --- samcli/commands/docs/command_context.py | 8 ++++++-- samcli/commands/docs/core/command.py | 6 ++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/samcli/commands/docs/command_context.py b/samcli/commands/docs/command_context.py index 42621b5993..b3c3c53f1f 100644 --- a/samcli/commands/docs/command_context.py +++ b/samcli/commands/docs/command_context.py @@ -3,6 +3,7 @@ import sys from typing import Callable, List +import click from click import echo from samcli.cli.main import common_options, print_cmdline_args @@ -62,8 +63,11 @@ def sub_commands(self) -> List[str]: def base_command(self) -> str: """ Returns a string representation of the base command (e.g "sam docs") + + click.get_current_context().command_path returns the entire command by the time it + gets to the leaf node. We just want "sam docs" so we extract it from that string """ - return f"sam {COMMAND_NAME}" + return " ".join((click.get_current_context().command_path.split(" "))[:2]) @staticmethod def _filter_arguments(commands: List[str]) -> List[str]: @@ -117,6 +121,6 @@ def run_command(self): try: documentation.open_docs() except BrowserConfigurationError: - echo(ERROR_MESSAGE.format(URL=documentation.url)) + echo(ERROR_MESSAGE.format(URL=documentation.url), err=True) else: echo(SUCCESS_MESSAGE) diff --git a/samcli/commands/docs/core/command.py b/samcli/commands/docs/core/command.py index 85522b8018..adea49f905 100644 --- a/samcli/commands/docs/core/command.py +++ b/samcli/commands/docs/core/command.py @@ -3,6 +3,7 @@ """ from typing import List, Optional +import os from click import Command, Context, MultiCommand, style from samcli.cli.row_modifiers import RowDefinition @@ -49,7 +50,7 @@ def format_description(formatter: DocsCommandHelpTextFormatter): RowDefinition( text="", name=DESCRIPTION - + style("\n This command does not require access to AWS credentials.", bold=True), + + style(f"{os.linesep} This command does not require access to AWS credentials.", bold=True), ), ], ) @@ -68,7 +69,8 @@ def format_sub_commands(self, formatter: DocsCommandHelpTextFormatter): [ RowDefinition(self.docs_command.base_command + " " + command) for command in self.docs_command.all_commands - ] + ], + col_max=50 ) def format_options(self, ctx: Context, formatter: DocsCommandHelpTextFormatter): # type:ignore From a30660e44d9e1bf1ad4f182e08addaa76300ea19 Mon Sep 17 00:00:00 2001 From: Daniel Mil Date: Thu, 23 Mar 2023 16:01:37 -0700 Subject: [PATCH 13/15] Black reformat --- samcli/commands/docs/core/command.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/samcli/commands/docs/core/command.py b/samcli/commands/docs/core/command.py index adea49f905..a22b98e42e 100644 --- a/samcli/commands/docs/core/command.py +++ b/samcli/commands/docs/core/command.py @@ -1,9 +1,9 @@ """ Module contains classes for creating the docs command from click """ +import os from typing import List, Optional -import os from click import Command, Context, MultiCommand, style from samcli.cli.row_modifiers import RowDefinition @@ -70,7 +70,7 @@ def format_sub_commands(self, formatter: DocsCommandHelpTextFormatter): RowDefinition(self.docs_command.base_command + " " + command) for command in self.docs_command.all_commands ], - col_max=50 + col_max=50, ) def format_options(self, ctx: Context, formatter: DocsCommandHelpTextFormatter): # type:ignore From a3ded5073f155cb6f4ce4be0a982fa6cf6e60176 Mon Sep 17 00:00:00 2001 From: Daniel Mil Date: Thu, 23 Mar 2023 16:21:09 -0700 Subject: [PATCH 14/15] Change integration tests to use stderr --- tests/integration/docs/test_docs_command.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/integration/docs/test_docs_command.py b/tests/integration/docs/test_docs_command.py index 5dac0711ed..2d1191a136 100644 --- a/tests/integration/docs/test_docs_command.py +++ b/tests/integration/docs/test_docs_command.py @@ -17,8 +17,9 @@ def test_docs_command(self, command, url): command_list = self.get_docs_command_list(sub_commands=sub_commands) command_result = run_command(command_list) stdout = command_result.stdout.decode("utf-8").strip() + stderr = command_result.stderr.decode("utf-8").strip() self.assertEqual(command_result.process.returncode, 0) - self._assert_valid_response(stdout, url) + self._assert_valid_response(stdout, stderr, url) def test_base_command(self): command_list = self.get_docs_command_list() @@ -37,7 +38,7 @@ def test_invalid_command(self): stderr, ) - def _assert_valid_response(self, stdout, url): + def _assert_valid_response(self, stdout, stderr, url): # We don't know if the machine this runs on will have a browser, # so we're just testing to ensure we get one of two valid command outputs - return self.assertTrue(SUCCESS_MESSAGE in stdout or ERROR_MESSAGE.format(URL=url) in stdout) + return self.assertTrue(SUCCESS_MESSAGE in stdout or ERROR_MESSAGE.format(URL=url) in stderr) From 5ee9dcbdad0160795ba6a81c847cf4800f9154f7 Mon Sep 17 00:00:00 2001 From: Daniel Mil Date: Fri, 24 Mar 2023 17:54:49 -0700 Subject: [PATCH 15/15] Fix test cases --- samcli/commands/docs/command_context.py | 3 +-- tests/integration/docs/test_docs_command.py | 3 ++- tests/unit/cli/test_command.py | 2 +- tests/unit/commands/docs/test_docs_context.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/samcli/commands/docs/command_context.py b/samcli/commands/docs/command_context.py index b3c3c53f1f..7d968be874 100644 --- a/samcli/commands/docs/command_context.py +++ b/samcli/commands/docs/command_context.py @@ -3,7 +3,6 @@ import sys from typing import Callable, List -import click from click import echo from samcli.cli.main import common_options, print_cmdline_args @@ -67,7 +66,7 @@ def base_command(self) -> str: click.get_current_context().command_path returns the entire command by the time it gets to the leaf node. We just want "sam docs" so we extract it from that string """ - return " ".join((click.get_current_context().command_path.split(" "))[:2]) + return f"sam {COMMAND_NAME}" @staticmethod def _filter_arguments(commands: List[str]) -> List[str]: diff --git a/tests/integration/docs/test_docs_command.py b/tests/integration/docs/test_docs_command.py index 2d1191a136..8eb1b1648a 100644 --- a/tests/integration/docs/test_docs_command.py +++ b/tests/integration/docs/test_docs_command.py @@ -25,8 +25,9 @@ def test_base_command(self): command_list = self.get_docs_command_list() command_result = run_command(command_list) stdout = command_result.stdout.decode("utf-8").strip() + stderr = command_result.stderr.decode("utf-8").strip() self.assertEqual(command_result.process.returncode, 0) - self._assert_valid_response(stdout, LANDING_PAGE) + self._assert_valid_response(stdout, stderr, LANDING_PAGE) def test_invalid_command(self): command_list = self.get_docs_command_list(sub_commands=["wrong", "command"]) diff --git a/tests/unit/cli/test_command.py b/tests/unit/cli/test_command.py index 5386842512..d64be4e444 100644 --- a/tests/unit/cli/test_command.py +++ b/tests/unit/cli/test_command.py @@ -49,7 +49,7 @@ def indented_section(self, name, extra_indents=0): finally: pass - def write_rd(self, rows): + def write_rd(self, rows, col_max=None): self.data[list(self.data.keys())[-1]] = [(row.name, "" if self.scrub_text else row.text) for row in rows] diff --git a/tests/unit/commands/docs/test_docs_context.py b/tests/unit/commands/docs/test_docs_context.py index 160512f199..5e4787bd9c 100644 --- a/tests/unit/commands/docs/test_docs_context.py +++ b/tests/unit/commands/docs/test_docs_context.py @@ -102,4 +102,4 @@ def test_run_command_no_browser(self, mock_browser_config, mock_documentation, m command_implementation.docs_command = Mock() command_implementation.docs_command.sub_commands = [] command_implementation.run_command() - mock_echo.assert_called_once_with(ERROR_MESSAGE.format(URL="some-url")) + mock_echo.assert_called_once_with(ERROR_MESSAGE.format(URL="some-url"), err=True)