diff --git a/awscli/__init__.py b/awscli/__init__.py index eca17721f5f3..6e55a60437e0 100644 --- a/awscli/__init__.py +++ b/awscli/__init__.py @@ -104,3 +104,5 @@ def find_spec(self, fullname, path, target=None): TopLevelImportAliasFinder.add_alias_finder(sys.meta_path) + +_DEFAULT_BASE_REMOTE_URL = f"https://awscli.amazonaws.com/v2/documentation/api/{__version__}" # noqa diff --git a/awscli/clidriver.py b/awscli/clidriver.py index 261a472fbae2..df08083f8031 100644 --- a/awscli/clidriver.py +++ b/awscli/clidriver.py @@ -150,7 +150,7 @@ def _get_linux_distribution(): linux_distribution = distro.id() version = distro.major_version() if version: - linux_distribution += '.%s' % version + linux_distribution += f'.{version}' except Exception: pass return linux_distribution @@ -270,6 +270,9 @@ def _update_config_chain(self): config_store.set_config_provider( 'cli_auto_prompt', self._construct_cli_auto_prompt_chain() ) + config_store.set_config_provider( + 'cli_help_output', self._construct_cli_help_output_chain() + ) def _construct_cli_region_chain(self): providers = [ @@ -308,6 +311,16 @@ def _construct_cli_output_chain(self): ] return ChainProvider(providers=providers) + def _construct_cli_help_output_chain(self): + providers = [ + ScopedConfigProvider( + config_var_name='cli_help_output', + session=self.session, + ), + ConstantProvider(value='terminal'), + ] + return ChainProvider(providers=providers) + def _construct_cli_pager_chain(self): providers = [ EnvironmentProvider( @@ -665,7 +678,7 @@ def _create_command_table(self): operation_caller=CLIOperationCaller(self.session), ) self.session.emit( - 'building-command-table.%s' % self._name, + f'building-command-table.{self._name}', command_table=command_table, session=self.session, command_object=self, @@ -773,7 +786,7 @@ def _build_subcommand_table(self): subcommand_table = OrderedDict() full_name = '_'.join([c.name for c in self.lineage]) self._session.emit( - 'building-command-table.%s' % full_name, + f'building-command-table.{full_name}', command_table=subcommand_table, session=self._session, command_object=self, @@ -803,10 +816,7 @@ def _parse_potential_subcommand(self, args, subcommand_table): def __call__(self, args, parsed_globals): # Once we know we're trying to call a particular operation # of a service we can go ahead and load the parameters. - event = 'before-building-argument-table-parser.%s.%s' % ( - self._parent_name, - self._name, - ) + event = f'before-building-argument-table-parser.{self._parent_name}.{self._name}' self._emit( event, argument_table=self.arg_table, @@ -832,16 +842,16 @@ def __call__(self, args, parsed_globals): remaining.append(parsed_args.help) if remaining: raise UnknownArgumentError( - "Unknown options: %s" % ', '.join(remaining) + f"Unknown options: {', '.join(remaining)}" ) - event = 'operation-args-parsed.%s.%s' % (self._parent_name, self._name) + event = f'operation-args-parsed.{self._parent_name}.{self._name}' self._emit( event, parsed_args=parsed_args, parsed_globals=parsed_globals ) call_parameters = self._build_call_parameters( parsed_args, self.arg_table ) - event = 'calling-command.%s.%s' % (self._parent_name, self._name) + event = f'calling-command.{self._parent_name}.{self._name}' override = self._emit_first_non_none_response( event, call_parameters=call_parameters, @@ -941,7 +951,7 @@ def _create_argument_table(self): arg_object.add_to_arg_table(argument_table) LOG.debug(argument_table) self._emit( - 'building-argument-table.%s.%s' % (self._parent_name, self._name), + f'building-argument-table.{self._parent_name}.{self._name}', operation_model=self._operation_model, session=self._session, command=self, diff --git a/awscli/customizations/commands.py b/awscli/customizations/commands.py index c2c5f915e295..b243bde75199 100644 --- a/awscli/customizations/commands.py +++ b/awscli/customizations/commands.py @@ -138,9 +138,7 @@ def __call__(self, args, parsed_globals): # an arg parser and parse them. self._subcommand_table = self._build_subcommand_table() self._arg_table = self._build_arg_table() - event = 'before-building-argument-table-parser.%s' % ".".join( - self.lineage_names - ) + event = f'before-building-argument-table-parser.{".".join(self.lineage_names)}' self._session.emit( event, argument_table=self._arg_table, @@ -177,7 +175,7 @@ def __call__(self, args, parsed_globals): # a chance to process and override its value. if self._should_allow_plugins_override(cli_argument, value): override = self._session.emit_first_non_none_response( - 'process-cli-arg.%s.%s' % ('custom', self.name), + f'process-cli-arg.custom.{self.name}', cli_argument=cli_argument, value=value, operation=None, @@ -204,7 +202,7 @@ def __call__(self, args, parsed_globals): # function for this top level command. if remaining: raise ParamValidationError( - "Unknown options: %s" % ','.join(remaining) + f"Unknown options: {','.join(remaining)}" ) rc = self._run_main(parsed_args, parsed_globals) if rc is None: @@ -212,7 +210,7 @@ def __call__(self, args, parsed_globals): else: return rc - def _validate_value_against_schema(self, model, value): + def _validate_value_against_schema(self, model, value): # noqa: F811 validate_parameters(value, model) def _should_allow_plugins_override(self, param, value): @@ -239,7 +237,7 @@ def _build_subcommand_table(self): subcommand_table[subcommand_name] = subcommand_class(self._session) name = '_'.join([c.name for c in self.lineage]) self._session.emit( - 'building-command-table.%s' % name, + f'building-command-table.{name}', command_table=subcommand_table, session=self._session, command_object=self, @@ -277,7 +275,7 @@ def _build_arg_table(self): arg_table = OrderedDict() name = '_'.join([c.name for c in self.lineage]) self._session.emit( - 'building-arg-table.%s' % name, arg_table=self.ARG_TABLE + f'building-arg-table.{name}', arg_table=self.ARG_TABLE ) for arg_data in self.ARG_TABLE: # If a custom schema was passed in, create the argument_model @@ -328,9 +326,9 @@ def lineage(self, value): def _raise_usage_error(self): lineage = ' '.join([c.name for c in self.lineage]) error_msg = ( - "usage: aws [options] %s " + f"usage: aws [options] {lineage} " "[parameters]\naws: error: too few arguments" - ) % lineage + ) raise ParamValidationError(error_msg) def _add_customization_to_user_agent(self): @@ -348,9 +346,7 @@ def __init__( arg_table, event_handler_class=None, ): - super(BasicHelp, self).__init__( - session, command_object, command_table, arg_table - ) + super().__init__(session, command_object, command_table, arg_table) # This is defined in HelpCommand so we're matching the # casing here. if event_handler_class is None: @@ -383,6 +379,16 @@ def examples(self): def event_class(self): return '.'.join(self.obj.lineage_names) + @property + def url(self): + # If the operation command has a subcommand table with commands + # in it, treat it as a service command as opposed to an operation + # command. This is to support things like a customization command + # that rely on the `BasicHelp` object. + if len(self.command_table) > 0: + return f"{self._base_remote_url}/reference/{self.event_class.replace('.', '/')}/index.html" + return f"{self._base_remote_url}/reference/{self.event_class.replace('.', '/')}.html" + def _get_doc_contents(self, attr_name): value = getattr(self, attr_name) if isinstance(value, BasicCommand.FROM_FILE): @@ -408,13 +414,18 @@ def __call__(self, args, parsed_globals): # We pass ourselves along so that we can, in turn, get passed # to all event handlers. docevents.generate_events(self.session, self) - self.renderer.render(self.doc.getvalue()) + + if self._help_output_format == 'url': + self.renderer.render(self.url.encode()) + else: + self.renderer.render(self.doc.getvalue()) + instance.unregister() class BasicDocHandler(OperationDocumentEventHandler): def __init__(self, help_command): - super(BasicDocHandler, self).__init__(help_command) + super().__init__(help_command) self.doc = help_command.doc def doc_description(self, help_command, **kwargs): @@ -424,9 +435,7 @@ def doc_description(self, help_command, **kwargs): def doc_synopsis_start(self, help_command, **kwargs): if not help_command.synopsis: - super(BasicDocHandler, self).doc_synopsis_start( - help_command=help_command, **kwargs - ) + super().doc_synopsis_start(help_command=help_command, **kwargs) else: self.doc.style.h2('Synopsis') self.doc.style.start_codeblock() @@ -447,14 +456,14 @@ def doc_synopsis_option(self, arg_name, help_command, **kwargs): ) self._documented_arg_groups.append(argument.group_name) elif argument.cli_type_name == 'boolean': - option_str = '%s' % argument.cli_name + option_str = f'{argument.cli_name}' elif argument.nargs == '+': - option_str = "%s [...]" % argument.cli_name + option_str = f"{argument.cli_name} [...]" else: - option_str = '%s ' % argument.cli_name + option_str = f'{argument.cli_name} ' if not (argument.required or argument.positional_arg): - option_str = '[%s]' % option_str - doc.writeln('%s' % option_str) + option_str = f'[{option_str}]' + doc.writeln(f'{option_str}') else: # A synopsis has been provided so we don't need to write @@ -463,9 +472,7 @@ def doc_synopsis_option(self, arg_name, help_command, **kwargs): def doc_synopsis_end(self, help_command, **kwargs): if not help_command.synopsis and not help_command.command_table: - super(BasicDocHandler, self).doc_synopsis_end( - help_command=help_command, **kwargs - ) + super().doc_synopsis_end(help_command=help_command, **kwargs) else: self.doc.style.end_codeblock() diff --git a/awscli/help.py b/awscli/help.py index 1c32fb421909..fa6a3099a41b 100644 --- a/awscli/help.py +++ b/awscli/help.py @@ -15,11 +15,20 @@ import platform import shlex import sys +import tempfile +import webbrowser from subprocess import PIPE, Popen +from botocore.exceptions import ProfileNotFound from docutils.core import publish_string -from docutils.writers import manpage +from docutils.writers import ( + html4css1, + manpage, +) +from awscli import ( + _DEFAULT_BASE_REMOTE_URL, +) from awscli.argparser import ArgTableArgParser from awscli.argprocess import ParamShorthandParser from awscli.bcdoc import docevents @@ -37,26 +46,39 @@ LOG = logging.getLogger('awscli.help') +REF_PATH = 'reference' +TUT_PATH = 'tutorial' +TOPIC_PATH = 'topic' + class ExecutableNotFoundError(Exception): def __init__(self, executable_name): - super(ExecutableNotFoundError, self).__init__( - 'Could not find executable named "%s"' % executable_name + super().__init__( + f'Could not find executable named "{executable_name}"' ) -def get_renderer(): +def get_renderer(help_output): """ Return the appropriate HelpRenderer implementation for the current platform. """ + if platform.system() == 'Windows': + if help_output == "browser": + return WindowsBrowserHelpRenderer() + elif help_output == "url": + return WindowsPagingHelpRenderer() return WindowsHelpRenderer() else: + if help_output == "browser": + return PosixBrowserHelpRenderer() + elif help_output == "url": + return PosixPagingHelpRenderer() return PosixHelpRenderer() -class PagingHelpRenderer: +class HelpRenderer: """ Interface for a help renderer. @@ -68,6 +90,34 @@ class PagingHelpRenderer: def __init__(self, output_stream=sys.stdout): self.output_stream = output_stream + def render(self, contents): + """ + Each implementation of HelpRenderer must implement this + render method. + """ + converted_content = self._convert_doc_content(contents) + self._send_output_to_destination(converted_content) + + def _send_output_to_destination(self, output): + """ + Each implementation of HelpRenderer must implement this + method. + """ + raise NotImplementedError + + def _popen(self, *args, **kwargs): + return Popen(*args, **kwargs) + + def _convert_doc_content(self, contents): + return contents + + +class PagingHelpRenderer(HelpRenderer): + """Interface for a help renderer. + + This sends output to the pager. + """ + PAGER = None _DEFAULT_DOCUTILS_SETTINGS_OVERRIDES = { # The default for line length limit in docutils is 10,000. However, @@ -88,13 +138,8 @@ def get_pager_cmdline(self): pager = os.environ['PAGER'] return shlex.split(pager) - def render(self, contents): - """ - Each implementation of HelpRenderer must implement this - render method. - """ - converted_content = self._convert_doc_content(contents) - self._send_output_to_pager(converted_content) + def _send_output_to_destination(self, output): + self._send_output_to_pager(output) def _send_output_to_pager(self, output): cmdline = self.get_pager_cmdline() @@ -102,14 +147,48 @@ def _send_output_to_pager(self, output): p = self._popen(cmdline, stdin=PIPE) p.communicate(input=output) - def _popen(self, *args, **kwargs): - return Popen(*args, **kwargs) - def _convert_doc_content(self, contents): - return contents +class BrowserHelpRenderer(HelpRenderer): + """ + Interface for a help renderer to a web browser. + + The renderer is responsible for displaying the help content on + a particular platform. + + """ + + def __init__(self, output_stream=sys.stdout): + self.output_stream = output_stream + + _DEFAULT_DOCUTILS_SETTINGS_OVERRIDES = { + # The default for line length limit in docutils is 10,000. However, + # currently in the documentation, it inlines all possible enums in + # the JSON syntax which exceeds this limit for some EC2 commands + # and prevents the manpages from being generated. + # This is a temporary fix to allow the manpages for these commands + # to be rendered. Long term, we should avoid enumerating over all + # enums inline for the JSON syntax snippets. + 'line_length_limit': 50_000 + } + + def _send_output_to_destination(self, output): + self._send_output_to_browser(output) + + def _send_output_to_browser(self, output): + html_file = tempfile.NamedTemporaryFile( + "wb", suffix=".html", delete=False + ) + html_file.write(output) + html_file.close() + + try: + print("Opening help file in the default browser.") + return webbrowser.open_new_tab(f'file://{html_file.name}') + except webbrowser.Error: + print('Failed to open browser:', file=sys.stderr) -class PosixHelpRenderer(PagingHelpRenderer): +class PosixPagingHelpRenderer(PagingHelpRenderer): """ Render help content on a Posix-like system. This includes Linux and MacOS X. @@ -117,30 +196,11 @@ class PosixHelpRenderer(PagingHelpRenderer): PAGER = 'less -R' - def _convert_doc_content(self, contents): - settings_overrides = self._DEFAULT_DOCUTILS_SETTINGS_OVERRIDES.copy() - settings_overrides["report_level"] = 3 - man_contents = publish_string( - contents, - writer=manpage.Writer(), - settings_overrides=self._DEFAULT_DOCUTILS_SETTINGS_OVERRIDES, - ) - if self._exists_on_path('groff'): - cmdline = ['groff', '-m', 'man', '-T', 'ascii'] - elif self._exists_on_path('mandoc'): - cmdline = ['mandoc', '-T', 'ascii'] - else: - raise ExecutableNotFoundError('groff or mandoc') - LOG.debug("Running command: %s", cmdline) - p3 = self._popen(cmdline, stdin=PIPE, stdout=PIPE, stderr=PIPE) - output = p3.communicate(input=man_contents)[0] - return output - def _send_output_to_pager(self, output): cmdline = self.get_pager_cmdline() if not self._exists_on_path(cmdline[0]): LOG.debug( - "Pager '%s' not found in PATH, printing raw help." % cmdline[0] + f"Pager '{cmdline[0]}' not found in PATH, printing raw help." ) self.output_stream.write(output.decode('utf-8') + "\n") self.output_stream.flush() @@ -172,18 +232,72 @@ def _exists_on_path(self, name): ) -class WindowsHelpRenderer(PagingHelpRenderer): - """Render help content on a Windows platform.""" +class PosixHelpRenderer(PosixPagingHelpRenderer): + """ + Render help content on a Posix-like system. This includes + Linux and MacOS X. + """ + + def _convert_doc_content(self, contents): + settings_overrides = self._DEFAULT_DOCUTILS_SETTINGS_OVERRIDES.copy() + settings_overrides["report_level"] = 3 + man_contents = publish_string( + contents, + writer=manpage.Writer(), + settings_overrides=self._DEFAULT_DOCUTILS_SETTINGS_OVERRIDES, + ) + if self._exists_on_path('groff'): + cmdline = ['groff', '-m', 'man', '-T', 'ascii'] + elif self._exists_on_path('mandoc'): + cmdline = ['mandoc', '-T', 'ascii'] + else: + raise ExecutableNotFoundError('groff or mandoc') + LOG.debug("Running command: %s", cmdline) + p3 = self._popen(cmdline, stdin=PIPE, stdout=PIPE, stderr=PIPE) + output = p3.communicate(input=man_contents)[0] + return output - PAGER = 'more' + +class PosixBrowserHelpRenderer(BrowserHelpRenderer): + """ + Render help content in a browser on a Posix-like system. This includes + Linux and MacOS X. + """ def _convert_doc_content(self, contents): - text_output = publish_string( + settings_overrides = self._DEFAULT_DOCUTILS_SETTINGS_OVERRIDES.copy() + settings_overrides["report_level"] = 3 + man_contents = publish_string( contents, - writer=TextWriter(), + writer=manpage.Writer(), settings_overrides=self._DEFAULT_DOCUTILS_SETTINGS_OVERRIDES, ) - return text_output + if self._exists_on_path('groff'): + cmdline = ['groff', '-m', 'man', '-T', 'html'] + elif self._exists_on_path('mandoc'): + cmdline = ['mandoc', '-T', 'html'] + else: + raise ExecutableNotFoundError('groff or mandoc') + LOG.debug("Running command: %s", cmdline) + p3 = self._popen(cmdline, stdin=PIPE, stdout=PIPE, stderr=PIPE) + output = p3.communicate(input=man_contents)[0] + return output + + def _exists_on_path(self, name): + # Since we're only dealing with POSIX systems, we can + # ignore things like PATHEXT. + return any( + [ + os.path.exists(os.path.join(p, name)) + for p in os.environ.get('PATH', '').split(os.pathsep) + ] + ) + + +class WindowsPagingHelpRenderer(PagingHelpRenderer): + """Render help content on a Windows platform.""" + + PAGER = 'more' def _popen(self, *args, **kwargs): # Also set the shell value to True. To get any of the @@ -192,6 +306,30 @@ def _popen(self, *args, **kwargs): return Popen(*args, **kwargs) +class WindowsHelpRenderer(WindowsPagingHelpRenderer): + """Render help content on a Windows platform.""" + + def _convert_doc_content(self, contents): + text_output = publish_string( + contents, + writer=TextWriter(), + settings_overrides=self._DEFAULT_DOCUTILS_SETTINGS_OVERRIDES, + ) + return text_output + + +class WindowsBrowserHelpRenderer(BrowserHelpRenderer): + """Render help content in the browser on a Windows platform.""" + + def _convert_doc_content(self, contents): + text_output = publish_string( + contents, + writer=html4css1.Writer(), + settings_overrides=self._DEFAULT_DOCUTILS_SETTINGS_OVERRIDES, + ) + return text_output + + class HelpCommand: """ HelpCommand Interface @@ -247,8 +385,17 @@ def __init__(self, session, obj, command_table, arg_table): self.arg_table = arg_table self._subcommand_table = {} self._related_items = [] - self.renderer = get_renderer() self.doc = ReSTDocument(target='man') + self._base_remote_url = _DEFAULT_BASE_REMOTE_URL + + try: + self._help_output_format = self.session.get_config_variable( + "cli_help_output" + ) + except ProfileNotFound: + self._help_output_format = None + + self.renderer = get_renderer(self._help_output_format) @property def event_class(self): @@ -300,7 +447,10 @@ def __call__(self, args, parsed_globals): # We pass ourselves along so that we can, in turn, get passed # to all event handlers. docevents.generate_events(self.session, self) - self.renderer.render(self.doc.getvalue()) + if self._help_output_format == 'url': + self.renderer.render(self.url.encode()) + else: + self.renderer.render(self.doc.getvalue()) instance.unregister() @@ -314,7 +464,13 @@ class ProviderHelpCommand(HelpCommand): EventHandlerClass = ProviderDocumentEventHandler def __init__( - self, session, command_table, arg_table, description, synopsis, usage + self, + session, + command_table, + arg_table, + description, + synopsis, + usage, ): HelpCommand.__init__(self, session, None, command_table, arg_table) self.description = description @@ -332,6 +488,10 @@ def event_class(self): def name(self): return 'aws' + @property + def url(self): + return f"{self._base_remote_url}/index.html" + @property def subcommand_table(self): if self._subcommand_table is None: @@ -368,9 +528,7 @@ class ServiceHelpCommand(HelpCommand): def __init__( self, session, obj, command_table, arg_table, name, event_class ): - super(ServiceHelpCommand, self).__init__( - session, obj, command_table, arg_table - ) + super().__init__(session, obj, command_table, arg_table) self._name = name self._event_class = event_class @@ -382,6 +540,10 @@ def event_class(self): def name(self): return self._name + @property + def url(self): + return f"{self._base_remote_url}/{REF_PATH}/{self.name}/index.html" + class OperationHelpCommand(HelpCommand): """Implements operation level help. @@ -407,12 +569,16 @@ def event_class(self): def name(self): return self._name + @property + def url(self): + return f"{self._base_remote_url}/reference/{self.event_class.replace('.', '/')}.html" + class TopicListerCommand(HelpCommand): EventHandlerClass = TopicListerDocumentEventHandler def __init__(self, session): - super(TopicListerCommand, self).__init__(session, None, {}, {}) + super().__init__(session, None, {}, {}) @property def event_class(self): @@ -422,12 +588,16 @@ def event_class(self): def name(self): return 'topics' + @property + def url(self): + return f"{self._base_remote_url}/{TOPIC_PATH}/index.html" + class TopicHelpCommand(HelpCommand): EventHandlerClass = TopicDocumentEventHandler def __init__(self, session, topic_name): - super(TopicHelpCommand, self).__init__(session, None, {}, {}) + super().__init__(session, None, {}, {}) self._topic_name = topic_name @property @@ -437,3 +607,7 @@ def event_class(self): @property def name(self): return self._topic_name + + @property + def url(self): + return f"{self._base_remote_url}/{TOPIC_PATH}/{self.name}.html" diff --git a/awscli/utils.py b/awscli/utils.py index d2c9a954dbec..a2b4dba5b713 100644 --- a/awscli/utils.py +++ b/awscli/utils.py @@ -230,7 +230,7 @@ def _split_with_quotes(value): try: parts = list(csv.reader(StringIO(value), escapechar='\\'))[0] except csv.Error: - raise ValueError("Bad csv value: %s" % value) + raise ValueError(f"Bad csv value: {value}") iter_parts = iter(parts) new_parts = [] for part in iter_parts: @@ -508,7 +508,7 @@ def _walk(self, shape, visitor, stack): if shape.name in stack: return stack.append(shape.name) - getattr(self, '_walk_%s' % shape.type_name, self._default_scalar_walk)( + getattr(self, f'_walk_{shape.type_name}', self._default_scalar_walk)( shape, visitor, stack ) stack.pop() diff --git a/tests/functional/docs/test_help_output.py b/tests/functional/docs/test_help_output.py index f6c9b004b195..4fd161fb5ef3 100644 --- a/tests/functional/docs/test_help_output.py +++ b/tests/functional/docs/test_help_output.py @@ -24,11 +24,73 @@ import os +import pytest + from awscli.alias import AliasLoader from awscli.compat import StringIO -from awscli.testutils import BaseAWSHelpOutputTest, FileCreator, mock +from awscli.testutils import ( + BaseAWSHelpOutputTest, + CapturedRenderer, + FileCreator, + mock, +) from tests import CLIRunner +COMMAND_ARGS_TEST_DATA = [ + { + 'command_args': ["ec2", "create-launch-template-version", "help"], + 'expected_url_suffix': "/reference/ec2/create-launch-template-version.html", + }, + { + 'command_args': ["help"], + 'expected_url_suffix': "/index.html", + }, + { + 'command_args': ["s3", "help"], + 'expected_url_suffix': "/reference/s3/index.html", + }, +] + + +def create_cases(): + for test_data in COMMAND_ARGS_TEST_DATA: + yield pytest.param(test_data, id="-".join(test_data['command_args'])) + + +def runner(config_file=None): + runner = CLIRunner() + + # Add the PATH to the environment variables so that that posix help + # renderers can find either the groff or mandoc executables required to + # render the help pages for posix environments + if "PATH" in os.environ: + runner.env["PATH"] = os.environ["PATH"] + + if config_file is not None: + runner.env['AWS_CONFIG_FILE'] = config_file + + return runner + + +@pytest.fixture +def runner_url(): + file_creator = FileCreator() + return runner( + file_creator.create_file( + 'config', '[default]\n' 'cli_help_output = url\n' + ) + ) + + +@pytest.fixture +def runner_browser(): + file_creator = FileCreator() + return runner( + file_creator.create_file( + 'config', '[default]\n' 'cli_help_output = browser\n' + ) + ) + class TestHelpOutput(BaseAWSHelpOutputTest): def test_output(self): @@ -217,7 +279,9 @@ def assert_command_does_not_exist(self, service, command): self.assertEqual(cr, 252) # We should see an error message complaining about # an invalid choice because the operation has been removed. - self.assertIn('argument operation: Found invalid choice', stderr.getvalue()) + self.assertIn( + 'argument operation: Found invalid choice', stderr.getvalue() + ) def test_ses_deprecated_commands(self): self.driver.main(['ses', 'help']) @@ -386,7 +450,7 @@ def test_can_doc_as_required(self): # This param is already marked as required, but to be # explicit this is repeated here to make it more clear. def doc_as_required(argument_table, **kwargs): - arg = argument_table['volume-arns'] + arg = argument_table['volume-arns'] # noqa: F841 self.driver.session.register( 'building-argument-table', doc_as_required @@ -444,18 +508,18 @@ def test_operation_help_command_has_note(self): class TestAliases(BaseAWSHelpOutputTest): def setUp(self): - super(TestAliases, self).setUp() + super().setUp() self.files = FileCreator() self.alias_file = self.files.create_file('alias', '[toplevel]\n') self.driver.alias_loader = AliasLoader(self.alias_file) def tearDown(self): - super(TestAliases, self).tearDown() + super().tearDown() self.files.remove_all() def add_alias(self, alias_name, alias_value): with open(self.alias_file, 'a+') as f: - f.write('%s = %s\n' % (alias_name, alias_value)) + f.write(f'{alias_name} = {alias_value}\n') def test_alias_not_in_main_help(self): self.add_alias('my-alias', 'ec2 describe-regions') @@ -470,6 +534,24 @@ def test_service_help_command_has_note(self): self.assert_contains('') +class TestUrlOutputHelp: + @pytest.mark.parametrize( + "test_case", + create_cases(), + ) + @mock.patch('awscli.help.get_renderer') + def test_docs_prints_url(self, mock_get_renderer, test_case, runner_url): + renderer = CapturedRenderer() + mock_get_renderer.return_value = renderer + + runner_url.run(test_case['command_args']) + assert ( + "https://awscli.amazonaws.com/v2/documentation/api/" + in renderer.rendered_contents + ) + assert test_case['expected_url_suffix'] in renderer.rendered_contents + + # Use this test class for "help" cases that require the default renderer # (i.e. renderer from get_render()) instead of a mocked version. class TestHelpOutputDefaultRenderer: @@ -485,3 +567,17 @@ def test_line_lengths_do_not_break_create_launch_template_version_cmd( result = runner.run(["ec2", "create-launch-template-version", "help"]) assert 'exceeds the line-length-limit' not in result.stderr + + +@pytest.mark.skip("Cross-test interaction with CLIRunner mocking os.environ") +class TestHelpOutputBrowserRenderer: + @pytest.mark.parametrize("test_case", create_cases()) + @mock.patch("awscli.help.webbrowser.open_new_tab") + def test_docs_opens_browser( + self, mock_open_new_tab, test_case, runner_browser + ): + runner_result = runner_browser.run(test_case['command_args']) + assert ( + "Opening help file in the default browser." in runner_result.stdout + ) + mock_open_new_tab.assert_called_once() diff --git a/tests/unit/customizations/test_commands.py b/tests/unit/customizations/test_commands.py index 33305f1b63b9..790871c07f8b 100644 --- a/tests/unit/customizations/test_commands.py +++ b/tests/unit/customizations/test_commands.py @@ -102,7 +102,7 @@ class TestBasicCommandHooks(unittest.TestCase): def setUp(self): self.session = FakeSession() self.session.get_config_variable = mock.Mock() - return_values = {'cli_auto_prompt': 'off'} + return_values = {'cli_auto_prompt': 'off', 'cli_help_output': None} self.session.get_config_variable.side_effect = return_values self.emitter = mock.Mock(wraps=HierarchicalEmitter()) self.session.emitter = self.emitter diff --git a/tests/unit/test_clidriver.py b/tests/unit/test_clidriver.py index a3f07e278dc2..a74312eca851 100644 --- a/tests/unit/test_clidriver.py +++ b/tests/unit/test_clidriver.py @@ -278,6 +278,7 @@ class TestCliDriver: def setup_method(self): self.session = FakeSession() self.session.set_config_variable('cli_auto_prompt', 'off') + self.session.set_config_variable('cli_help_output', None) self.driver = CLIDriver(session=self.session) def test_session_can_be_passed_in(self): @@ -412,6 +413,7 @@ class TestCliDriverHooks(unittest.TestCase): def setUp(self): self.session = FakeSession() self.session.set_config_variable('cli_auto_prompt', 'off') + self.session.set_config_variable('cli_help_output', None) self.emitter = mock.Mock() self.emitter.emit.return_value = [] self.stdout = StringIO() @@ -958,6 +960,7 @@ class TestServiceCommand(unittest.TestCase): def setUp(self): self.name = 'foo' self.session = FakeSession() + self.session.set_config_variable('cli_help_output', None) self.cmd = ServiceCommand(self.name, self.session) def test_can_access_subcommand_table(self): diff --git a/tests/unit/test_help.py b/tests/unit/test_help.py index d66e763f2ab6..1e84943565b3 100644 --- a/tests/unit/test_help.py +++ b/tests/unit/test_help.py @@ -15,16 +15,21 @@ import signal import sys +from awscli import ( + _DEFAULT_BASE_REMOTE_URL, +) from awscli.argparser import HELP_BLURB, ArgParseException from awscli.compat import StringIO from awscli.help import ( ExecutableNotFoundError, HelpCommand, PosixHelpRenderer, + PosixPagingHelpRenderer, ProviderHelpCommand, TopicHelpCommand, TopicListerCommand, WindowsHelpRenderer, + WindowsPagingHelpRenderer, ) from awscli.testutils import FileCreator, mock, skip_if_windows, unittest @@ -197,7 +202,7 @@ class TestHelpCommand(TestHelpCommandBase): """ def setUp(self): - super(TestHelpCommand, self).setUp() + super().setUp() self.doc_handler_mock = mock.Mock() self.subcommand_mock = mock.Mock() self.renderer = mock.Mock() @@ -232,7 +237,7 @@ def test_invalid_subcommand(self): class TestProviderHelpCommand(TestHelpCommandBase): def setUp(self): - super(TestProviderHelpCommand, self).setUp() + super().setUp() self.session.provider = None self.command_table = {} self.arg_table = {} @@ -261,7 +266,7 @@ def setUp(self): def tearDown(self): self.json_patch.stop() - super(TestProviderHelpCommand, self).tearDown() + super().tearDown() def test_related_items(self): self.assertEqual(self.cmd.related_items, ['aws help topics']) @@ -288,10 +293,14 @@ def test_subcommand_table(self): ) self.assertEqual(subcommand_table['topic-name-2'].name, 'topic-name-2') + def test_url(self): + self.assertIn(_DEFAULT_BASE_REMOTE_URL, self.cmd.url) + self.assertIn("/index.html", self.cmd.url) + class TestTopicListerCommand(TestHelpCommandBase): def setUp(self): - super(TestTopicListerCommand, self).setUp() + super().setUp() self.cmd = TopicListerCommand(self.session) def test_event_class(self): @@ -303,7 +312,7 @@ def test_name(self): class TestTopicHelpCommand(TestHelpCommandBase): def setUp(self): - super(TestTopicHelpCommand, self).setUp() + super().setUp() self.name = 'topic-name-1' self.cmd = TopicHelpCommand(self.session, self.name) @@ -312,3 +321,7 @@ def test_event_class(self): def test_name(self): self.assertEqual(self.cmd.name, self.name) + + def test_url(self): + self.assertIn(_DEFAULT_BASE_REMOTE_URL, self.cmd.url) + self.assertIn("/topic/topic-name-1.html", self.cmd.url)