awscli/clidocs.py (637 lines of code) (raw):

# Copyright 2012-2013 Amazon.com, Inc. or its affiliates. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"). You # may not use this file except in compliance with the License. A copy of # the License is located at # # http://aws.amazon.com/apache2.0/ # # or in the "license" file accompanying this file. This file is # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF # ANY KIND, either express or implied. See the License for the specific # language governing permissions and limitations under the License. import logging import os import re from botocore.model import StringShape from botocore.utils import is_json_value_header from awscli import SCALAR_TYPES from awscli.argprocess import ParamShorthandDocGen from awscli.bcdoc.docevents import DOC_EVENTS from awscli.topictags import TopicTagDB from awscli.utils import ( find_service_and_method_in_event_name, is_document_type, is_streaming_blob_type, is_tagged_union_type, operation_uses_document_types, ) LOG = logging.getLogger(__name__) EXAMPLES_DIR = os.path.join( os.path.dirname(os.path.abspath(__file__)), 'examples' ) GLOBAL_OPTIONS_FILE = os.path.join(EXAMPLES_DIR, 'global_options.rst') GLOBAL_OPTIONS_SYNOPSIS_FILE = os.path.join( EXAMPLES_DIR, 'global_synopsis.rst' ) class CLIDocumentEventHandler: def __init__(self, help_command): self.help_command = help_command self.register(help_command.session, help_command.event_class) self._arg_groups = self._build_arg_table_groups(help_command) self._documented_arg_groups = [] def _build_arg_table_groups(self, help_command): arg_groups = {} for arg in help_command.arg_table.values(): if arg.group_name is not None: arg_groups.setdefault(arg.group_name, []).append(arg) return arg_groups def _get_argument_type_name(self, shape, default): if is_json_value_header(shape): return 'JSON' if is_document_type(shape): return 'document' if is_streaming_blob_type(shape): return 'streaming blob' if is_tagged_union_type(shape): return 'tagged union structure' return default def _map_handlers(self, session, event_class, mapfn): for event in DOC_EVENTS: event_handler_name = event.replace('-', '_') if hasattr(self, event_handler_name): event_handler = getattr(self, event_handler_name) format_string = DOC_EVENTS[event] num_args = len(format_string.split('.')) - 2 format_args = (event_class,) + ('*',) * num_args event_string = event + format_string % format_args unique_id = event_class + event_handler_name mapfn(event_string, event_handler, unique_id) def register(self, session, event_class): """ The default register iterates through all of the available document events and looks for a corresponding handler method defined in the object. If it's there, that handler method will be registered for the all events of that type for the specified ``event_class``. """ self._map_handlers(session, event_class, session.register) def unregister(self): """ The default unregister iterates through all of the available document events and looks for a corresponding handler method defined in the object. If it's there, that handler method will be unregistered for the all events of that type for the specified ``event_class``. """ self._map_handlers( self.help_command.session, self.help_command.event_class, self.help_command.session.unregister, ) # These are default doc handlers that apply in the general case. def doc_breadcrumbs(self, help_command, **kwargs): doc = help_command.doc if doc.target != 'man': cmd_names = help_command.event_class.split('.') doc.write('[ ') doc.write(':ref:`aws <cli:aws>`') full_cmd_list = ['aws'] for cmd in cmd_names[:-1]: doc.write(' . ') full_cmd_list.append(cmd) full_cmd_name = ' '.join(full_cmd_list) doc.write(f':ref:`{cmd} <cli:{full_cmd_name}>`') doc.write(' ]') def doc_title(self, help_command, **kwargs): doc = help_command.doc doc.style.new_paragraph() reference = help_command.event_class.replace('.', ' ') if reference != 'aws': reference = 'aws ' + reference doc.writeln(f'.. _cli:{reference}:') doc.style.h1(help_command.name) def doc_description(self, help_command, **kwargs): doc = help_command.doc doc.style.h2('Description') doc.include_doc_string(help_command.description) doc.style.new_paragraph() def doc_synopsis_start(self, help_command, **kwargs): self._documented_arg_groups = [] doc = help_command.doc doc.style.h2('Synopsis') doc.style.start_codeblock() doc.writeln(help_command.name) def doc_synopsis_option(self, arg_name, help_command, **kwargs): doc = help_command.doc argument = help_command.arg_table[arg_name] if argument.group_name in self._arg_groups: if argument.group_name in self._documented_arg_groups: # This arg is already documented so we can move on. return option_str = ' | '.join( a.cli_name for a in self._arg_groups[argument.group_name] ) self._documented_arg_groups.append(argument.group_name) elif argument.cli_name.startswith('--'): option_str = f'{argument.cli_name} <value>' else: option_str = f'<{argument.cli_name}>' if not ( argument.required or getattr(argument, '_DOCUMENT_AS_REQUIRED', False) ): option_str = f'[{option_str}]' doc.writeln(option_str) def doc_synopsis_end(self, help_command, **kwargs): doc = help_command.doc # Append synopsis for global options. doc.write_from_file(GLOBAL_OPTIONS_SYNOPSIS_FILE) doc.style.end_codeblock() # Reset the documented arg groups for other sections # that may document args (the detailed docs following # the synopsis). self._documented_arg_groups = [] def doc_options_start(self, help_command, **kwargs): doc = help_command.doc doc.style.h2('Options') if not help_command.arg_table: doc.write('*None*\n') def doc_option(self, arg_name, help_command, **kwargs): doc = help_command.doc argument = help_command.arg_table[arg_name] if argument.group_name in self._arg_groups: if argument.group_name in self._documented_arg_groups: # This arg is already documented so we can move on. return name = ' | '.join( f'``{a.cli_name}``' for a in self._arg_groups[argument.group_name] ) self._documented_arg_groups.append(argument.group_name) else: name = f'``{argument.cli_name}``' argument_type_name = self._get_argument_type_name( argument.argument_model, argument.cli_type_name ) doc.write(f'{name} ({argument_type_name})\n') doc.style.indent() doc.include_doc_string(argument.documentation) if is_streaming_blob_type(argument.argument_model): self._add_streaming_blob_note(doc) if is_tagged_union_type(argument.argument_model): self._add_tagged_union_note(argument.argument_model, doc) if hasattr(argument, 'argument_model'): self._document_enums(argument.argument_model, doc) self._document_nested_structure(argument.argument_model, doc) doc.style.dedent() doc.style.new_paragraph() def doc_global_option(self, help_command, **kwargs): doc = help_command.doc doc.style.h2('Global Options') doc.write_from_file(GLOBAL_OPTIONS_FILE) def doc_relateditems_start(self, help_command, **kwargs): if help_command.related_items: doc = help_command.doc doc.style.h2('See Also') def doc_relateditem(self, help_command, related_item, **kwargs): doc = help_command.doc doc.write('* ') doc.style.sphinx_reference_label( label=f'cli:{related_item}', text=related_item ) doc.write('\n') def _document_enums(self, model, doc): """Documents top-level parameter enums""" if isinstance(model, StringShape): if model.enum: doc.style.new_paragraph() doc.write('Possible values:') doc.style.start_ul() for enum in model.enum: doc.style.li(f'``{enum}``') doc.style.end_ul() def _document_nested_structure(self, model, doc): """Recursively documents parameters in nested structures""" member_type_name = getattr(model, 'type_name', None) if member_type_name == 'structure': for member_name, member_shape in model.members.items(): self._doc_member( doc, member_name, member_shape, stack=[model.name] ) elif member_type_name == 'list': self._doc_member(doc, '', model.member, stack=[model.name]) elif member_type_name == 'map': key_shape = model.key key_name = key_shape.serialization.get('name', 'key') self._doc_member(doc, key_name, key_shape, stack=[model.name]) value_shape = model.value value_name = value_shape.serialization.get('name', 'value') self._doc_member(doc, value_name, value_shape, stack=[model.name]) def _doc_member(self, doc, member_name, member_shape, stack): if member_shape.name in stack: # Document the recursion once, otherwise just # note the fact that it's recursive and return. if stack.count(member_shape.name) > 1: if member_shape.type_name == 'structure': doc.write('( ... recursive ... )') return stack.append(member_shape.name) try: self._do_doc_member(doc, member_name, member_shape, stack) finally: stack.pop() def _do_doc_member(self, doc, member_name, member_shape, stack): docs = member_shape.documentation type_name = self._get_argument_type_name( member_shape, member_shape.type_name ) if member_name: doc.write(f'{member_name} -> ({type_name})') else: doc.write(f'({type_name})') doc.style.indent() doc.style.new_paragraph() doc.include_doc_string(docs) if is_tagged_union_type(member_shape): self._add_tagged_union_note(member_shape, doc) doc.style.new_paragraph() member_type_name = member_shape.type_name if member_type_name == 'structure': for sub_name, sub_shape in member_shape.members.items(): self._doc_member(doc, sub_name, sub_shape, stack) elif member_type_name == 'map': key_shape = member_shape.key key_name = key_shape.serialization.get('name', 'key') self._doc_member(doc, key_name, key_shape, stack) value_shape = member_shape.value value_name = value_shape.serialization.get('name', 'value') self._doc_member(doc, value_name, value_shape, stack) elif member_type_name == 'list': self._doc_member(doc, '', member_shape.member, stack) doc.style.dedent() doc.style.new_paragraph() def _add_streaming_blob_note(self, doc): doc.style.start_note() msg = ( "This argument is of type: streaming blob. " "Its value must be the path to a file " "(e.g. ``path/to/file``) and must **not** " "be prefixed with ``file://`` or ``fileb://``" ) doc.writeln(msg) doc.style.end_note() def _add_tagged_union_note(self, shape, doc): doc.style.start_note() members_str = ", ".join(f'``{key}``' for key in shape.members.keys()) doc.writeln( "This is a Tagged Union structure. Only one of the " f"following top level keys can be set: {members_str}." ) doc.style.end_note() class ProviderDocumentEventHandler(CLIDocumentEventHandler): def doc_breadcrumbs(self, help_command, event_name, **kwargs): pass def doc_synopsis_start(self, help_command, **kwargs): doc = help_command.doc doc.style.h2('Synopsis') doc.style.codeblock(help_command.synopsis) doc.include_doc_string(help_command.help_usage) def doc_synopsis_option(self, arg_name, help_command, **kwargs): pass def doc_synopsis_end(self, help_command, **kwargs): doc = help_command.doc doc.style.new_paragraph() def doc_options_start(self, help_command, **kwargs): pass def doc_option(self, arg_name, help_command, **kwargs): pass def doc_subitems_start(self, help_command, **kwargs): doc = help_command.doc doc.style.h2('Available Services') doc.style.toctree() def doc_subitem(self, command_name, help_command, **kwargs): doc = help_command.doc doc.style.tocitem(command_name, file_name=f"{command_name}/index") class ServiceDocumentEventHandler(CLIDocumentEventHandler): # A service document has no synopsis. def doc_synopsis_start(self, help_command, **kwargs): pass def doc_synopsis_option(self, arg_name, help_command, **kwargs): pass def doc_synopsis_end(self, help_command, **kwargs): pass # A service document has no option section. def doc_options_start(self, help_command, **kwargs): pass def doc_option(self, arg_name, help_command, **kwargs): pass def doc_option_example(self, arg_name, help_command, **kwargs): pass def doc_options_end(self, help_command, **kwargs): pass def doc_global_option(self, help_command, **kwargs): pass def doc_description(self, help_command, **kwargs): doc = help_command.doc service_model = help_command.obj doc.style.h2('Description') # TODO: need a documentation attribute. doc.include_doc_string(service_model.documentation) def doc_subitems_start(self, help_command, **kwargs): doc = help_command.doc doc.style.h2('Available Commands') doc.style.toctree() def doc_subitem(self, command_name, help_command, **kwargs): doc = help_command.doc subcommand = help_command.command_table[command_name] subcommand_table = getattr(subcommand, 'subcommand_table', {}) # If the subcommand table has commands in it, # direct the subitem to the command's index because # it has more subcommands to be documented. if len(subcommand_table) > 0: doc.style.tocitem(command_name, file_name=f"{command_name}/index") else: doc.style.tocitem(command_name) class OperationDocumentEventHandler(CLIDocumentEventHandler): AWS_DOC_BASE = 'https://docs.aws.amazon.com/goto/WebAPI' def doc_description(self, help_command, **kwargs): doc = help_command.doc operation_model = help_command.obj doc.style.h2('Description') doc.include_doc_string(operation_model.documentation) self._add_webapi_crosslink(help_command) self._add_note_for_document_types_if_used(help_command) def _add_webapi_crosslink(self, help_command): doc = help_command.doc operation_model = help_command.obj service_model = operation_model.service_model service_uid = service_model.metadata.get('uid') if service_uid is None: # If there's no service_uid in the model, we can't # be certain if the generated cross link will work # so we don't generate any crosslink info. return doc.style.new_paragraph() doc.write("See also: ") link = f'{self.AWS_DOC_BASE}/{service_uid}/{operation_model.name}' doc.style.external_link(title="AWS API Documentation", link=link) doc.writeln('') def _add_note_for_document_types_if_used(self, help_command): if operation_uses_document_types(help_command.obj): help_command.doc.style.new_paragraph() help_command.doc.writeln( f'``{help_command.name}`` uses document type values. Document ' 'types follow the JSON data model where valid values are: ' 'strings, numbers, booleans, null, arrays, and objects. For ' 'command input, options and nested parameters that are labeled ' 'with the type ``document`` must be provided as JSON. ' 'Shorthand syntax does not support document types.' ) def _json_example_value_name( self, argument_model, include_enum_values=True ): # If include_enum_values is True, then the valid enum values # are included as the sample JSON value. if isinstance(argument_model, StringShape): if argument_model.enum and include_enum_values: choices = argument_model.enum return '|'.join(f'"{c}"' for c in choices) else: return '"string"' elif argument_model.type_name == 'boolean': return 'true|false' else: return argument_model.type_name def _json_example(self, doc, argument_model, stack): if argument_model.name in stack: # Document the recursion once, otherwise just # note the fact that it's recursive and return. if stack.count(argument_model.name) > 1: if argument_model.type_name == 'structure': doc.write('{ ... recursive ... }') return stack.append(argument_model.name) try: self._do_json_example(doc, argument_model, stack) finally: stack.pop() def _do_json_example(self, doc, argument_model, stack): if argument_model.type_name == 'list': doc.write('[') if argument_model.member.type_name in SCALAR_TYPES: example_name = self._json_example_value_name(argument_model.member) doc.write(f'{example_name}, ...') else: doc.style.indent() doc.style.new_line() self._json_example(doc, argument_model.member, stack) doc.style.new_line() doc.write('...') doc.style.dedent() doc.style.new_line() doc.write(']') elif argument_model.type_name == 'map': doc.write('{') doc.style.indent() key_string = self._json_example_value_name(argument_model.key) doc.write(f'{key_string}: ') if argument_model.value.type_name in SCALAR_TYPES: doc.write(self._json_example_value_name(argument_model.value)) else: doc.style.indent() self._json_example(doc, argument_model.value, stack) doc.style.dedent() doc.style.new_line() doc.write('...') doc.style.dedent() doc.write('}') elif argument_model.type_name == 'structure': if argument_model.is_document_type: self._doc_document_member(doc) else: self._doc_input_structure_members(doc, argument_model, stack) def _doc_document_member(self, doc): doc.write('{...}') def _doc_input_structure_members(self, doc, argument_model, stack): doc.write('{') doc.style.indent() doc.style.new_line() members = argument_model.members for i, member_name in enumerate(members): member_model = members[member_name] member_type_name = member_model.type_name if member_type_name in SCALAR_TYPES: example_name = self._json_example_value_name(member_model) doc.write(f'"{member_name}": {example_name}') elif member_type_name == 'structure': doc.write(f'"{member_name}": ') self._json_example(doc, member_model, stack) elif member_type_name == 'map': doc.write(f'"{member_name}": ') self._json_example(doc, member_model, stack) elif member_type_name == 'list': doc.write(f'"{member_name}": ') self._json_example(doc, member_model, stack) if i < len(members) - 1: doc.write(',') doc.style.new_line() doc.style.dedent() doc.style.new_line() doc.write('}') def doc_option_example(self, arg_name, help_command, event_name, **kwargs): service_id, operation_name = find_service_and_method_in_event_name( event_name ) doc = help_command.doc cli_argument = help_command.arg_table[arg_name] if cli_argument.group_name in self._arg_groups: if cli_argument.group_name in self._documented_arg_groups: # Args with group_names (boolean args) don't # need to generate example syntax. return argument_model = cli_argument.argument_model docgen = ParamShorthandDocGen() if docgen.supports_shorthand(cli_argument.argument_model): example_shorthand_syntax = docgen.generate_shorthand_example( cli_argument, service_id, operation_name ) if example_shorthand_syntax is None: # If the shorthand syntax returns a value of None, # this indicates to us that there is no example # needed for this param so we can immediately # return. return if example_shorthand_syntax: doc.style.new_paragraph() doc.write('Shorthand Syntax') doc.style.start_codeblock() for example_line in example_shorthand_syntax.splitlines(): doc.writeln(example_line) doc.style.end_codeblock() if ( argument_model is not None and argument_model.type_name == 'list' and argument_model.member.type_name in SCALAR_TYPES ): # A list of scalars is special. While you *can* use # JSON ( ["foo", "bar", "baz"] ), you can also just # use the argparse behavior of space separated lists. # "foo" "bar" "baz". In fact we don't even want to # document the JSON syntax in this case. member = argument_model.member doc.style.new_paragraph() doc.write('Syntax') doc.style.start_codeblock() example_type = self._json_example_value_name( member, include_enum_values=False ) doc.write(f'{example_type} {example_type} ...') if isinstance(member, StringShape) and member.enum: # If we have enum values, we can tell the user # exactly what valid values they can provide. self._write_valid_enums(doc, member.enum) doc.style.end_codeblock() doc.style.new_paragraph() elif cli_argument.cli_type_name not in SCALAR_TYPES: doc.style.new_paragraph() doc.write('JSON Syntax') doc.style.start_codeblock() self._json_example(doc, argument_model, stack=[]) doc.style.end_codeblock() doc.style.new_paragraph() def _write_valid_enums(self, doc, enum_values): doc.style.new_paragraph() doc.write("Where valid values are:\n") for value in enum_values: doc.write(f" {value}\n") doc.write("\n") def doc_output(self, help_command, event_name, **kwargs): doc = help_command.doc doc.style.h2('Output') operation_model = help_command.obj output_shape = operation_model.output_shape if output_shape is None or not output_shape.members: doc.write('None') else: for member_name, member_shape in output_shape.members.items(): self._doc_member(doc, member_name, member_shape, stack=[]) class TopicListerDocumentEventHandler(CLIDocumentEventHandler): DESCRIPTION = ( 'This is the AWS CLI Topic Guide. It gives access to a set ' 'of topics that provide a deeper understanding of the CLI. To access ' 'the list of topics from the command line, run ``aws help topics``. ' 'To access a specific topic from the command line, run ' '``aws help [topicname]``, where ``topicname`` is the name of the ' 'topic as it appears in the output from ``aws help topics``.' ) def __init__(self, help_command): self.help_command = help_command self.register(help_command.session, help_command.event_class) self._topic_tag_db = TopicTagDB() self._topic_tag_db.load_json_index() def doc_breadcrumbs(self, help_command, **kwargs): doc = help_command.doc if doc.target != 'man': doc.write('[ ') doc.style.sphinx_reference_label(label='cli:aws', text='aws') doc.write(' ]') def doc_title(self, help_command, **kwargs): doc = help_command.doc doc.style.new_paragraph() doc.style.link_target_definition( refname=f'cli:aws help {self.help_command.name}', link='' ) doc.style.h1('AWS CLI Topic Guide') def doc_description(self, help_command, **kwargs): doc = help_command.doc doc.style.h2('Description') doc.include_doc_string(self.DESCRIPTION) doc.style.new_paragraph() def doc_synopsis_start(self, help_command, **kwargs): pass def doc_synopsis_end(self, help_command, **kwargs): pass def doc_options_start(self, help_command, **kwargs): pass def doc_options_end(self, help_command, **kwargs): pass def doc_global_option(self, help_command, **kwargs): pass def doc_subitems_start(self, help_command, **kwargs): doc = help_command.doc doc.style.h2('Available Topics') categories = self._topic_tag_db.query('category') topic_names = self._topic_tag_db.get_all_topic_names() # Sort the categories category_names = sorted(categories.keys()) for category_name in category_names: doc.style.h3(category_name) doc.style.new_paragraph() # Write out the topic and a description for each topic under # each category. for topic_name in sorted(categories[category_name]): description = self._topic_tag_db.get_tag_single_value( topic_name, 'description' ) doc.write('* ') doc.style.sphinx_reference_label( label=f'cli:aws help {topic_name}', text=topic_name ) doc.write(f': {description}\n') # Add a hidden toctree to make sure everything is connected in # the document. doc.style.hidden_toctree() for topic_name in topic_names: doc.style.hidden_tocitem(topic_name) class TopicDocumentEventHandler(TopicListerDocumentEventHandler): def doc_breadcrumbs(self, help_command, **kwargs): doc = help_command.doc if doc.target != 'man': doc.write('[ ') doc.style.sphinx_reference_label(label='cli:aws', text='aws') doc.write(' . ') doc.style.sphinx_reference_label( label='cli:aws help topics', text='topics' ) doc.write(' ]') def doc_title(self, help_command, **kwargs): doc = help_command.doc doc.style.new_paragraph() doc.style.link_target_definition( refname=f'cli:aws help {self.help_command.name}', link='' ) title = self._topic_tag_db.get_tag_single_value( help_command.name, 'title' ) doc.style.h1(title) def doc_description(self, help_command, **kwargs): doc = help_command.doc topic_filename = os.path.join( self._topic_tag_db.topic_dir, f'{help_command.name}.rst' ) contents = self._remove_tags_from_content(topic_filename) doc.writeln(contents) doc.style.new_paragraph() def _remove_tags_from_content(self, filename): with open(filename) as f: lines = f.readlines() content_begin_index = 0 for i, line in enumerate(lines): # If a line is encountered that does not begin with the tag # end the search for tags and mark where tags end. if not self._line_has_tag(line): content_begin_index = i break # Join all of the non-tagged lines back together. return ''.join(lines[content_begin_index:]) def _line_has_tag(self, line): for tag in self._topic_tag_db.valid_tags: if line.startswith(f':{tag}:'): return True return False def doc_subitems_start(self, help_command, **kwargs): pass class GlobalOptionsDocumenter: """Documenter used to pre-generate global options docs.""" def __init__(self, help_command): self._help_command = help_command def _remove_multilines(self, s): return re.sub(r'\n+', '\n', s) def doc_global_options(self): help_command = self._help_command for arg in help_command.arg_table: argument = help_command.arg_table.get(arg) help_command.doc.writeln( f"``{argument.cli_name}`` ({argument.cli_type_name})" ) help_command.doc.style.indent() help_command.doc.style.new_paragraph() help_command.doc.include_doc_string(argument.documentation) if argument.choices: help_command.doc.style.start_ul() for choice in argument.choices: help_command.doc.style.li(choice) help_command.doc.style.end_ul() help_command.doc.style.dedent() help_command.doc.style.new_paragraph() global_options = help_command.doc.getvalue().decode('utf-8') return self._remove_multilines(global_options) def doc_global_synopsis(self): help_command = self._help_command for arg in help_command.arg_table: argument = help_command.arg_table.get(arg) if argument.cli_type_name == 'boolean': arg_synopsis = f"[{argument.cli_name}]" else: arg_synopsis = f"[{argument.cli_name} <value>]" help_command.doc.writeln(arg_synopsis) global_synopsis = help_command.doc.getvalue().decode('utf-8') return self._remove_multilines(global_synopsis)