diff --git a/samcli/commands/docs/command.py b/samcli/commands/docs/command.py index ddea9472cb..e42bef4448 100644 --- a/samcli/commands/docs/command.py +++ b/samcli/commands/docs/command.py @@ -1,39 +1,34 @@ """ CLI command for "docs" command """ -import click +from typing import Type -from samcli.cli.main import common_options, print_cmdline_args -from samcli.commands._utils.command_exception_handler import command_exception_handler -from samcli.lib.telemetry.metric import track_command -from samcli.lib.utils.version_checker import check_newer_version +from click import Command, command -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. -""" +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 -@click.command("docs", help=HELP_TEXT) -@common_options -@track_command -@check_newer_version -@print_cmdline_args -@command_exception_handler -def cli(): +def create_command() -> Type[Command]: """ - `sam docs` command entry point + 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 - # All logic must be implemented in the ``do_cli`` method. This helps with easy unit testing - do_cli() # pragma: no cover - -def do_cli(): +@command(name=COMMAND_NAME, cls=create_command()) +@pass_context +@command_exception_handler +def cli(ctx): """ - Implementation of the ``cli`` method + `sam docs` command entry point """ - from samcli.commands.docs.docs_context import DocsContext - - with DocsContext() as docs_context: - docs_context.run() diff --git a/samcli/commands/docs/command_context.py b/samcli/commands/docs/command_context.py new file mode 100644 index 0000000000..7d968be874 --- /dev/null +++ b/samcli/commands/docs/command_context.py @@ -0,0 +1,125 @@ +import functools +import os +import sys +from typing import Callable, List + +from click import echo + +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." +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) -> 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) -> 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) -> 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) -> str: + """ + Returns a string representation of the sub-commands + """ + return " ".join(self.sub_commands) + + @property + 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) -> 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}" + + @staticmethod + 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)) + + +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() + + @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( + 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), err=True) + else: + echo(SUCCESS_MESSAGE) 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 new file mode 100644 index 0000000000..a22b98e42e --- /dev/null +++ b/samcli/commands/docs/core/command.py @@ -0,0 +1,153 @@ +""" +Module contains classes for creating the docs command from click +""" +import os +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 COMMAND_NAME, DocsCommandContext +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): + """ + 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) + + @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( + [ + RowDefinition( + text="", + name=DESCRIPTION + + style(f"{os.linesep} This command does not require access to AWS credentials.", bold=True), + ), + ], + ) + + 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( + [ + 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 + """ + 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) + + +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 + 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: 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) + 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) -> 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 new file mode 100644 index 0000000000..e11a168def --- /dev/null +++ b/samcli/commands/docs/core/formatter.py @@ -0,0 +1,15 @@ +""" +Base formatter for the docs command help text +""" +from samcli.cli.formatters import RootCommandHelpTextFormatter +from samcli.cli.row_modifiers import BaseLineRowModifier + + +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/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/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 + """ diff --git a/samcli/lib/docs/documentation.py b/samcli/lib/docs/documentation.py index 72037ce21d..972ba2f58f 100644 --- a/samcli/lib/docs/documentation.py +++ b/samcli/lib/docs/documentation.py @@ -1,17 +1,39 @@ """ Library housing the logic for handling AWS SAM CLI documentation pages """ +import json import logging +from pathlib import Path +from typing import Any, Dict 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): + """ + 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.url = url + self.command = command + + @property + def url(self) -> str: + """ + Returns the url to be opened + """ + return self.get_docs_link_for_command() def open_docs(self): """ @@ -23,3 +45,27 @@ 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) -> 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[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 dict(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..9323e720ae --- /dev/null +++ b/samcli/lib/docs/documentation_links.json @@ -0,0 +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/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/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", + "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 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..8eb1b1648a 100644 --- a/tests/integration/docs/test_docs_command.py +++ b/tests/integration/docs/test_docs_command.py @@ -1,17 +1,45 @@ -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() + stderr = command_result.stderr.decode("utf-8").strip() + self.assertEqual(command_result.process.returncode, 0) + self._assert_valid_response(stdout, stderr, 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() + stderr = command_result.stderr.decode("utf-8").strip() self.assertEqual(command_result.process.returncode, 0) - self._assert_valid_response(stdout) + self._assert_valid_response(stdout, stderr, 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.assertIn( + f"Error: Command not found. Try using one of the following available commands:", + stderr, + ) - def _assert_valid_response(self, stdout): + 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=DocsContext.URL) in stdout) + return self.assertTrue(SUCCESS_MESSAGE in stdout or ERROR_MESSAGE.format(URL=url) in stderr) 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/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/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..5e4787bd9c 100644 --- a/tests/unit/commands/docs/test_docs_context.py +++ b/tests/unit/commands/docs/test_docs_context.py @@ -1,39 +1,105 @@ from unittest import TestCase -from unittest.mock import patch, Mock +from unittest.mock import patch, Mock, PropertyMock -from samcli.commands.docs.docs_context import DocsContext, 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 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): +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() + + @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 - with DocsContext() as docs_context: - docs_context.run() - mock_echo.assert_called_once_with(ERROR_MESSAGE.format(URL=DocsContext.URL)) + 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"), err=True) diff --git a/tests/unit/lib/docs/test_documentation.py b/tests/unit/lib/docs/test_documentation.py index de53306608..e98686536f 100644 --- a/tests/unit/lib/docs/test_documentation.py +++ b/tests/unit/lib/docs/test_documentation.py @@ -1,13 +1,42 @@ 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): 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) + + @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()