File: //snap/google-cloud-cli/396/lib/googlecloudsdk/calliope/markdown.py
# -*- coding: utf-8 -*- #
# Copyright 2014 Google LLC. 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.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License 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.
"""The Calliope command help document markdown generator."""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
import abc
import io
import re
import textwrap
from googlecloudsdk.calliope import base
from googlecloudsdk.calliope import usage_text
from googlecloudsdk.core import properties
from googlecloudsdk.core.console import console_io
from googlecloudsdk.core.universe_descriptor import universe_descriptor
import six
_SPLIT = 78 # Split lines longer than this.
_SECTION_INDENT = 8 # Section or list within section indent.
_FIRST_INDENT = 2 # First line indent.
_SUBSEQUENT_INDENT = 6 # Subsequent line indent.
_SECOND_LINE_OFFSET = 2 # Used to create 2nd line indentation using markdown.
_GCLOUD_ROOT_SURFACES = frozenset([
'gcloud',
'gcloud alpha',
'gcloud beta',
'gcloud preview',
])
def _GetIndexFromCapsule(capsule):
"""Returns a help doc index line for a capsule line.
The capsule line is a formal imperative sentence, preceded by optional
(RELEASE-TRACK) or [TAG] tags, optionally with markdown attributes. The index
line has no tags, is not capitalized and has no period, period.
Args:
capsule: The capsule line to convert to an index line.
Returns:
The help doc index line for a capsule line.
"""
# Strip leading tags: <markdown>(TAG)<markdown> or <markdown>[TAG]<markdown>.
capsule = re.sub(r'(\*?[\[(][A-Z]+[\])]\*? +)*', '', capsule)
# Lower case first word if not an abbreviation.
match = re.match(r'([A-Z])([^A-Z].*)', capsule)
if match:
capsule = match.group(1).lower() + match.group(2)
# Strip trailing period.
return capsule.rstrip('.')
class ExampleCommandLineSplitter(object):
"""Example command line splitter.
Attributes:
max_index: int, The max index to check in line.
quote_char: str, The current quote char for quotes split across lines.
quote_index: int, The index of quote_char in line or 0 if in previous line.
"""
def __init__(self):
self._max_index = _SPLIT - _SECTION_INDENT - _FIRST_INDENT
self._quote_char = None
self._quote_index = 0
def _SplitInTwo(self, line):
"""Splits line into before and after, len(before) < self._max_index.
Args:
line: str, The line to split.
Returns:
(before, after)
The line split into two parts. <before> is a list of strings that forms
the first line of the split and <after> is a string containing the
remainder of the line to split. The display width of <before> is
< self._max_index. <before> contains the separator chars, including a
newline.
"""
punct_index = 0
quoted_space_index = 0
quoted_space_quote = None
space_index = 0
space_flag = False
i = 0
while i < self._max_index:
c = line[i]
i += 1
if c == self._quote_char:
self._quote_char = None
elif self._quote_char:
if c == ' ':
quoted_space_index = i - 1
quoted_space_quote = self._quote_char
elif c in ('"', "'"):
self._quote_char = c
self._quote_index = i
quoted_space_index = 0
elif c == '\\':
i += 1
elif i < self._max_index:
if c == ' ':
# Split before a flag instead of the next arg; it could be the flag
# value.
if line[i] == '-':
space_flag = True
space_index = i
elif space_flag:
space_flag = False
else:
space_index = i
elif c in (',', ';', '/', '|'):
punct_index = i
elif c == '=':
space_flag = False
separator = '\\\n'
indent = _FIRST_INDENT
if space_index:
split_index = space_index
indent = _SUBSEQUENT_INDENT
elif quoted_space_index:
split_index = quoted_space_index
if quoted_space_quote == "'":
separator = '\n'
else:
split_index += 1
elif punct_index:
split_index = punct_index
else:
split_index = self._max_index
if split_index <= self._quote_index:
self._quote_char = None
else:
self._quote_index = 0
self._max_index = _SPLIT - _SECTION_INDENT - indent
return [line[:split_index], separator, ' ' * indent], line[split_index:]
def Split(self, line):
"""Splits a long example command line by inserting newlines.
Args:
line: str, The command line to split.
Returns:
str, The command line with newlines inserted.
"""
lines = []
while len(line) > self._max_index:
before, line = self._SplitInTwo(line)
lines.extend(before)
lines.append(line)
return ''.join(lines)
def NormalizeExampleSection(doc):
"""Removes line breaks and extra spaces in example commands.
In command implementation, some example commands were manually broken into
multiple lines with or without "\". This function removes these line
breaks and let ExampleCommandLineSplitter to split the long commands
centrally.
This function will not change example commands in the following situations:
1. If the command is in a code block, surrounded with ```sh...```.
2. If the values are within a quote (single or double quote).
Args:
doc: str, help text to process.
Returns:
Modified help text.
"""
example_sec_until_next_sec = re.compile(
r'^## EXAMPLES\n(.+?)(\n+## )', flags=re.M | re.DOTALL
)
example_sec_until_end = re.compile(
r'^## EXAMPLES\n(.+)', flags=re.M | re.DOTALL
)
match_example_sec = example_sec_until_next_sec.search(doc)
match_example_sec_to_end = example_sec_until_end.search(doc)
# no EXAMPLES section
if not match_example_sec and not match_example_sec_to_end:
return doc
elif match_example_sec:
selected_match = match_example_sec
else:
selected_match = match_example_sec_to_end
doc_before_examples = doc[: selected_match.start(1)]
example_section = doc[selected_match.start(1) : selected_match.end(1)]
doc_after_example = doc[selected_match.end(1) :]
pat_example_line = re.compile(r'^ *(\$ .*)$', re.M)
pat_code_block = re.compile(r'^ *```sh(.+?```)', re.M | re.DOTALL)
pos = 0
res = ''
while True:
match_example_line = pat_example_line.search(example_section, pos)
match_code_block = pat_code_block.search(example_section, pos)
if not match_code_block and not match_example_line:
break
elif match_code_block and match_example_line:
# If it found an example command line and a code block, pick the one
# closer to the starting point.
if match_code_block.start(1) > match_example_line.start(1):
example, next_pos = UnifyExampleLine(
example_section, match_example_line.start(1)
)
res += example_section[pos : match_example_line.start(1)] + example
pos = next_pos
else:
res += example_section[pos : match_code_block.end(1)]
pos = match_code_block.end(1)
elif match_code_block:
res += example_section[pos : match_code_block.end(1)]
pos = match_code_block.end(1)
else:
example, next_pos = UnifyExampleLine(
example_section, match_example_line.start(1)
)
res += example_section[pos : match_example_line.start(1)] + example
pos = next_pos
return doc_before_examples + (res + example_section[pos:]) + doc_after_example
def UnifyExampleLine(example_doc, pos):
"""Returns the example command line at pos in one single line.
pos is the starting point of an example (starting with "$ ").
This function removes "\n" and "\" and redundant spaces in the example line.
The resulted example should be in one single line.
Args:
example_doc: str, Example section of the help text.
pos: int, Position to start. pos will be the starting position of an example
line.
Returns:
normalized example command, next starting position to search
"""
pat_match_next_command = re.compile(
r'\$\s+(.+?)(\n +\$\s+)', re.DOTALL
) # match consecutive commands.
pat_match_empty_line_after_command = re.compile(
r'\$\s+(.+?)(\n\s*\n|\n\+\n)', re.DOTALL
)
match_next_command = pat_match_next_command.match(example_doc, pos)
match_empty_line_after_command = pat_match_empty_line_after_command.match(
example_doc, pos
)
# reached to the end
if not match_next_command and not match_empty_line_after_command:
new_doc = example_doc.rstrip()
pat = re.compile(r'\$\s+(.+)', re.DOTALL)
match = pat.match(new_doc, pos)
example = match.group(1)
pat = re.compile(r'\\\n\s*')
example = pat.sub('', example) # remove extra \n and \ and spaces.
example = RemoveSpacesLineBreaksFromExample(example)
return '$ ' + example, len(new_doc)
elif match_next_command and match_empty_line_after_command:
if len(match_next_command.group(1)) > len(
match_empty_line_after_command.group(1)
):
selected_match = match_empty_line_after_command
else:
selected_match = match_next_command
else:
selected_match = (
match_next_command
if match_next_command
else match_empty_line_after_command
)
example = selected_match.group(1)
pat = re.compile(r'\\\n\s*')
example = pat.sub('', example)
example = RemoveSpacesLineBreaksFromExample(example)
next_pos = selected_match.end(1)
return '$ ' + example, next_pos
def _PrecedingBackslashCount(res):
index = len(res) - 1
while index >= 0 and res[index] == '\\':
index -= 1
return len(res) - index - 1
def RemoveSpacesLineBreaksFromExample(example):
"""Returns the example with redundant spaces and line breaks removed.
If a character sequence is quoted (either single or double quote), we will
not touch its value. Single quote is not allowed within single quote even
with a preceding backslash. Double quote is allowed in double quote with
preceding backslash though. If the spaces and line breaks are within quote,
they are not touched.
Args: example, str: Example line to process.
"""
res = []
example = example.strip()
pos = 0
while pos < len(example):
c = example[pos]
if c not in ['"', "'"]: # outside quote
if c == '\n':
c = ' ' # remove line break
if not (c == ' ' and res and res[-1] == ' '): # remove redundant spaces
res.append(c)
pos += 1
elif c == "'": # see single quote
res.append(c)
pos += 1
# proceed until seeing a closing single quote or exhausting example.
while pos < len(example) and example[pos] != "'":
res.append(example[pos])
pos += 1
if pos < len(example): # see closing single quote
res.append(example[pos])
pos += 1
else: # see double quote
res.append(example[pos])
pos += 1
# proceed until seeing a closing double quote or exhausting example.
while pos < len(example) and not (
example[pos] == '"' and _PrecedingBackslashCount(res) % 2 == 0
):
res.append(example[pos])
pos += 1
if pos < len(example): # see closing double quote
res.append(example[pos])
pos += 1
return ''.join(res)
class MarkdownGenerator(six.with_metaclass(abc.ABCMeta, object)):
"""Command help markdown document generator base class.
Attributes:
_buf: Output document stream.
_capsule: The one line description string.
_command_name: The dotted command name.
_command_path: The command path list.
_doc: The output markdown document string.
_docstring: The command docstring.
_file_name: The command path name (used to name documents).
_final_sections: The list of PrintFinalSections section names.
_is_hidden: The command is hidden.
_out: Output writer.
_printed_sections: The set of already printed sections.
_release_track: The calliope.base.ReleaseTrack.
"""
def __init__(self, command_path, release_track, is_hidden):
"""Constructor.
Args:
command_path: The command path list.
release_track: The base.ReleaseTrack of the command.
is_hidden: The command is hidden if True.
"""
self._command_path = command_path
self._command_name = ' '.join(self._command_path)
self._subcommands = None
self._subgroups = None
self._sort_top_level_args = None
self._top = self._command_path[0] if self._command_path else ''
self._buf = io.StringIO()
self._out = self._buf.write
self._capsule = ''
self._docstring = ''
self._final_sections = ['EXAMPLES', 'SEE ALSO']
self._arg_sections = None
self._sections = {}
self._file_name = '_'.join(self._command_path)
self._global_flags = set()
self._is_hidden = is_hidden
self._release_track = release_track
self._printed_sections = set()
@abc.abstractmethod
def IsValidSubPath(self, sub_command_path):
"""Determines if the given sub command path is valid from this node.
Args:
sub_command_path: [str], The pieces of the command path.
Returns:
True, if the given path parts exist under this command or group node.
False, if the sub path does not lead to a valid command or group.
"""
pass
@abc.abstractmethod
def GetArguments(self):
"""Returns the command arguments."""
pass
def FormatExample(self, cmd, args, with_args):
"""Creates a link to the command reference from a command example.
If with_args is False and the provided command includes args,
returns None.
Args:
cmd: [str], a command.
args: [str], args with the command.
with_args: bool, whether the example is valid if it has args.
Returns:
(str) a representation of the command with a link to the reference, plus
any args. | None, if the command isn't valid.
"""
if args and not with_args:
return None
ref = '/'.join(cmd)
command_link = 'link:' + ref + '[' + ' '.join(cmd) + ']'
if args:
command_link += ' ' + ' '.join(args)
return command_link
@property
def is_root(self):
"""Determine if this node should be treated as a "root" of the CLI tree.
The top element is the root, but we also treat any additional release tracks
as a root so that global flags are shown there as well.
Returns:
True if this node should be treated as a root, False otherwise.
"""
if len(self._command_path) == 1:
return True
elif len(self._command_path) == 2:
tracks = [t.prefix for t in base.ReleaseTrack.AllValues()]
if self._command_path[-1] in tracks:
return True
return False
@property
def is_group(self):
"""Returns True if this node is a command group."""
return bool(self._subgroups or self._subcommands)
@property
def sort_top_level_args(self):
"""Returns whether to sort the top level arguments in markdown docs."""
return self._sort_top_level_args
@property
def is_topic(self):
"""Returns True if this node is a topic command."""
if (
len(self._command_path) >= 3
and self._command_path[1] == self._release_track.prefix
):
command_index = 2
else:
command_index = 1
return (
len(self._command_path) >= (command_index + 1)
and self._command_path[command_index] == 'topic'
)
def _ExpandHelpText(self, text):
"""Expand command {...} references in text.
Args:
text: The text chunk to expand.
Returns:
The expanded help text.
"""
return console_io.LazyFormat(
text or '',
command=self._command_name,
man_name=self._file_name,
top_command=self._top,
parent_command=' '.join(self._command_path[:-1]),
grandparent_command=' '.join(self._command_path[:-2]),
index=self._capsule,
**self._sections,
)
def _SetArgSections(self):
"""Sets self._arg_sections in document order."""
if self._arg_sections is None:
self._arg_sections, self._global_flags = usage_text.GetArgSections(
self.GetArguments(),
self.is_root,
self.is_group,
self.sort_top_level_args,
)
def _SplitCommandFromArgs(self, cmd):
"""Splits cmd into command and args lists.
The command list part is a valid command and the args list part is the
trailing args.
Args:
cmd: [str], A command + args list.
Returns:
(command, args): The command and args lists.
"""
# The bare top level command always works.
if len(cmd) <= 1:
return cmd, []
# Skip the top level command name.
skip = 1
i = skip
while i <= len(cmd):
i += 1
if not self.IsValidSubPath(cmd[skip:i]):
i -= 1
break
return cmd[:i], cmd[i:]
def _UserInput(self, msg):
"""Returns msg with user input markdown.
Args:
msg: str, The user input string.
Returns:
The msg string with embedded user input markdown.
"""
return (
base.MARKDOWN_CODE
+ base.MARKDOWN_ITALIC
+ msg
+ base.MARKDOWN_ITALIC
+ base.MARKDOWN_CODE
)
def _ArgTypeName(self, arg):
"""Returns the argument type name for arg."""
return 'positional' if arg.is_positional else 'flag'
def _IsGcloudSurfaceCommand(self):
"""Returns True if the command is the gcloud command."""
return self._command_name in _GCLOUD_ROOT_SURFACES
def PrintSectionHeader(self, name, sep=True):
"""Prints the section header markdown for name.
Args:
name: str, The manpage section name.
sep: boolean, Add trailing newline.
"""
self._printed_sections.add(name)
self._out('\n\n## {name}\n'.format(name=name))
if sep:
self._out('\n')
def PrintUniverseInformationSection(self, disable_header=False):
"""Prints the command line information section.
The information section provides disclaimer information on whether a command
is available in a particular universe domain.
Args:
disable_header: Disable printing the section header if True.
"""
if properties.IsDefaultUniverse():
return
if not disable_header:
self.PrintSectionHeader('INFORMATION')
code = base.MARKDOWN_CODE
em = base.MARKDOWN_ITALIC
if self._command.IsUniverseCompatible() or self._IsGcloudSurfaceCommand():
info_body = (
f'{code}{self._command_name}{code} is supported in universe domain '
f'{em}{properties.GetUniverseDomain()}{em}; however, some of the '
'values used in the help text may not be available. Command examples '
'may not work as-is and may requires changes before execution.'
)
else:
info_body = (
f'{code}{self._command_name}{code} is not available in '
f'universe domain {em}{properties.GetUniverseDomain()}{em}.'
)
# print the informartion
self._out(info_body)
# print UNIVERSE ADDITIONAL INFO section
self.PrintSectionIfExists('UNIVERSE ADDITIONAL INFO')
def PrintNameSection(self, disable_header=False):
"""Prints the command line name section.
Args:
disable_header: Disable printing the section header if True.
"""
if not disable_header:
self.PrintSectionHeader('NAME')
self._out(
'{command} - {index}\n'.format(
command=self._command_name,
index=_GetIndexFromCapsule(self._capsule),
)
)
def PrintSynopsisSection(self, disable_header=False):
"""Prints the command line synopsis section.
Args:
disable_header: Disable printing the section header if True.
"""
if self.is_topic:
return
self._SetArgSections()
# MARKDOWN_CODE is the default SYNOPSIS font style.
code = base.MARKDOWN_CODE
em = base.MARKDOWN_ITALIC
if not disable_header:
self.PrintSectionHeader('SYNOPSIS')
self._out(
'{code}{command}{code}'.format(code=code, command=self._command_name)
)
if self._subcommands and self._subgroups:
self._out(' ' + em + 'GROUP' + em + ' | ' + em + 'COMMAND' + em)
elif self._subcommands:
self._out(' ' + em + 'COMMAND' + em)
elif self._subgroups:
self._out(' ' + em + 'GROUP' + em)
# Generate the arg usage string with flags in section order.
remainder_usage = []
for section in self._arg_sections:
self._out(' ')
self._out(
usage_text.GetArgUsage(
section.args,
markdown=True,
top=True,
remainder_usage=remainder_usage,
)
)
if self._global_flags:
self._out(' [' + em + self._top.upper() + '_WIDE_FLAG ...' + em + ']')
if remainder_usage:
self._out(' ')
self._out(' '.join(remainder_usage))
self._out('\n')
def _PrintArgDefinition(self, arg, depth=0, single=False):
"""Prints a positional or flag arg definition list item at depth."""
usage = usage_text.GetArgUsage(arg, definition=True, markdown=True)
if not usage:
return
self._out(
'\n{usage}{depth}\n'.format(
usage=usage, depth=':' * (depth + _SECOND_LINE_OFFSET)
)
)
if arg.is_required and depth and not single:
modal = (
'\n+\nThis {arg_type} argument must be specified if any of the other '
'arguments in this group are specified.'
).format(arg_type=self._ArgTypeName(arg))
else:
modal = ''
details = self.GetArgDetails(arg, depth=depth).replace('\n\n', '\n+\n')
self._out('\n{details}{modal}\n'.format(details=details, modal=modal))
def _PrintArgGroup(self, arg, depth=0, single=False):
"""Prints an arg group definition list at depth."""
args = (
sorted(arg.arguments, key=usage_text.GetArgSortKey)
if arg.sort_args
else arg.arguments
)
heading = []
if arg.help or arg.is_mutex or arg.is_required:
if arg.help:
heading.append(arg.help)
if arg.disable_default_heading:
pass
elif len(args) == 1 or args[0].is_required:
if arg.is_required:
heading.append('This must be specified.')
elif arg.is_mutex:
if arg.is_required:
heading.append('Exactly one of these must be specified:')
else:
heading.append('At most one of these can be specified:')
elif arg.is_required:
heading.append('At least one of these must be specified:')
if not arg.is_hidden and heading:
self._out(
'\n{0} {1}\n\n'.format(
':' * (depth + _SECOND_LINE_OFFSET), '\n+\n'.join(heading)
).replace('\n\n', '\n+\n'),
)
heading = None
depth += 1
for a in args:
if a.is_hidden:
continue
if a.is_group:
single = False
singleton = usage_text.GetSingleton(a)
if singleton:
if not a.help:
a = singleton
else:
single = True
if a.is_group:
self._PrintArgGroup(a, depth=depth, single=single)
else:
self._PrintArgDefinition(a, depth=depth, single=single)
def PrintPositionalDefinition(self, arg, depth=0):
self._out(
'\n{usage}{depth}\n'.format(
usage=usage_text.GetPositionalUsage(arg, markdown=True),
depth=':' * (depth + _SECOND_LINE_OFFSET),
)
)
self._out('\n{arghelp}\n'.format(arghelp=self.GetArgDetails(arg)))
def PrintFlagDefinition(self, flag, disable_header=False, depth=0):
"""Prints a flags definition list item.
Args:
flag: The flag object to display.
disable_header: Disable printing the section header if True.
depth: The indentation depth at which to print arg help text.
"""
if not disable_header:
self._out('\n')
self._out(
'{usage}{depth}\n'.format(
usage=usage_text.GetFlagUsage(flag, markdown=True),
depth=':' * (depth + _SECOND_LINE_OFFSET),
)
)
self._out('\n{arghelp}\n'.format(arghelp=self.GetArgDetails(flag)))
def PrintFlagSection(self, heading, arg, disable_header=False):
"""Prints a flag section.
Args:
heading: The flag section heading name.
arg: The flag args / group.
disable_header: Disable printing the section header if True.
"""
if not disable_header:
self.PrintSectionHeader(heading, sep=False)
self._PrintArgGroup(arg)
def PrintPositionalsAndFlagsSections(self, disable_header=False):
"""Prints the positionals and flags sections.
Args:
disable_header: Disable printing the section header if True.
"""
if self.is_topic:
return
self._SetArgSections()
# List the sections in order.
for section in self._arg_sections:
self.PrintFlagSection(
section.heading, section.args, disable_header=disable_header
)
if self._global_flags:
if not disable_header:
self.PrintSectionHeader(
'{} WIDE FLAGS'.format(self._top.upper()), sep=False
)
# NOTE: We need two newlines before 'Run' for a paragraph break.
self._out(
'\nThese flags are available to all commands: {}.'
'\n\nRun *$ {} help* for details.\n'.format(
', '.join(sorted(self._global_flags)), self._top
)
)
def PrintSubGroups(self, disable_header=False):
"""Prints the subgroup section if there are subgroups.
Args:
disable_header: Disable printing the section header if True.
"""
if self._subgroups:
self.PrintCommandSection(
'GROUP', self._subgroups, disable_header=disable_header
)
def PrintSubCommands(self, disable_header=False):
"""Prints the subcommand section if there are subcommands.
Args:
disable_header: Disable printing the section header if True.
"""
if self._subcommands:
if self.is_topic:
self.PrintCommandSection(
'TOPIC',
self._subcommands,
is_topic=True,
disable_header=disable_header,
)
else:
self.PrintCommandSection(
'COMMAND', self._subcommands, disable_header=disable_header
)
def PrintSectionIfExists(self, name, default=None, disable_header=False):
"""Print a section name if it exists.
Args:
name: str, The manpage section name.
default: str, Default help_stuff if section name is not defined.
disable_header: Disable printing the section header if True.
"""
if name in self._printed_sections:
return
help_stuff = self._sections.get(name, default)
if not help_stuff:
return
if callable(help_stuff):
help_message = help_stuff()
else:
help_message = help_stuff
if not disable_header:
self.PrintSectionHeader(name)
self._out(
'{message}\n'.format(message=textwrap.dedent(help_message).strip())
)
def PrintExtraSections(self, disable_header=False):
"""Print extra sections not in excluded_sections.
Extra sections are sections that have not been printed yet.
PrintSectionIfExists() skips sections that have already been printed.
Args:
disable_header: Disable printing the section header if True.
"""
excluded_sections = set(
self._final_sections + ['NOTES', 'UNIVERSE ADDITIONAL INFO']
)
for section in sorted(self._sections):
if section.isupper() and section not in excluded_sections:
self.PrintSectionIfExists(section, disable_header=disable_header)
def PrintFinalSections(self, disable_header=False):
"""Print the final sections in order.
Args:
disable_header: Disable printing the section header if True.
"""
for section in self._final_sections:
self.PrintSectionIfExists(section, disable_header=disable_header)
self.PrintNotesSection(disable_header=disable_header)
def PrintCommandSection(
self, name, subcommands, is_topic=False, disable_header=False
):
"""Prints a group or command section.
Args:
name: str, The section name singular form.
subcommands: dict, The subcommand dict.
is_topic: bool, True if this is a TOPIC subsection.
disable_header: Disable printing the section header if True.
"""
# Determine if the section has any content.
content = ''
for subcommand, help_info in sorted(six.iteritems(subcommands)):
if self._is_hidden or not help_info.is_hidden:
# If this group is already hidden, we can safely include hidden
# sub-items. Else, only include them if they are not hidden.
content += '\n*link:{ref}[{cmd}]*::\n\n{txt}\n'.format(
ref='/'.join(self._command_path + [subcommand]),
cmd=subcommand,
txt=help_info.help_text,
)
if content:
if not disable_header:
self.PrintSectionHeader(name + 'S')
if is_topic:
self._out('The supplementary help topics are:\n')
else:
self._out(
'{cmd} is one of the following:\n'.format(cmd=self._UserInput(name))
)
self._out(content)
def GetNotes(self):
"""Returns the explicit NOTES section contents."""
return self._sections.get('NOTES')
def PrintNotesSection(self, disable_header=False):
"""Prints the NOTES section if needed.
Args:
disable_header: Disable printing the section header if True.
"""
notes = self.GetNotes()
if notes:
if not disable_header:
self.PrintSectionHeader('NOTES')
if notes:
self._out(notes + '\n\n')
def GetArgDetails(self, arg, depth=0):
"""Returns the detailed help message for the given arg."""
if getattr(arg, 'detailed_help', None):
raise ValueError(
'{}: Use add_argument(help=...) instead of detailed_help="""{}""".'
.format(self._command_name, getattr(arg, 'detailed_help'))
)
return usage_text.GetArgDetails(arg, depth=depth)
def _ExpandFormatReferences(self, doc):
"""Expand {...} references in doc."""
doc = self._ExpandHelpText(doc)
doc = NormalizeExampleSection(doc)
# Split long $ ... example lines.
pat = re.compile(
r'^ *(\$ .{%d,})$' % (_SPLIT - _FIRST_INDENT - _SECTION_INDENT), re.M
)
pos = 0
rep = ''
while True:
match = pat.search(doc, pos)
if not match:
break
rep += doc[pos : match.start(1)] + ExampleCommandLineSplitter().Split(
doc[match.start(1) : match.end(1)]
)
pos = match.end(1)
if rep:
doc = rep + doc[pos:]
return doc
def _IsNotThisCommand(self, cmd):
# We should not include the link if it refers to the current page, per
# our research with screen readers. (See b/1723464.)
return '.'.join(cmd) != '.'.join(self._command_path)
def _LinkMarkdown(self, doc, pat, with_args=True):
"""Build a representation of a doc, finding all command examples.
Finds examples of both inline commands and commands on their own line.
Args:
doc: str, the doc to find examples in.
pat: the compiled regexp pattern to match against (the "command" match
group).
with_args: bool, whether the examples are valid if they also have args.
Returns:
(str) The final representation of the doc.
"""
pos = 0
rep = ''
while True:
match = pat.search(doc, pos)
if not match:
break
cmd, args = self._SplitCommandFromArgs(match.group('command').split(' '))
lnk = self.FormatExample(cmd, args, with_args=with_args)
if self._IsNotThisCommand(cmd) and lnk:
rep += doc[pos : match.start('command')] + lnk
else:
# Skip invalid commands.
rep += doc[pos : match.end('command')]
rep += doc[match.end('command') : match.end('end')]
pos = match.end('end')
if rep:
doc = rep + doc[pos:]
return doc
def InlineCommandExamplePattern(self):
"""Regex to search for inline command examples enclosed in ` or *.
Contains a 'command' group and an 'end' group which will be used
by the regexp search later.
Returns:
(str) the regex pattern, including a format string for the 'top'
command.
"""
# This pattern matches "([`*]){top} {arg}*\1" where {top}...{arg} is a
# known command. The negative lookbehind prefix prevents hyperlinks in
# SYNOPSIS sections and as the first line in a paragraph.
return (
r'(?<!\n\n)(?<!\*\(ALPHA\)\* )(?<!\*\(BETA\)\* )(?<!\*\(PREVIEW\)\* )'
r'([`*])(?P<command>{top}( [a-z][-a-z0-9]*)*)(?P<end>\1)'.format(
top=re.escape(self._top)
)
)
def _AddCommandLinkMarkdown(self, doc):
r"""Add ([`*])command ...\1 link markdown to doc."""
if not self._command_path:
return doc
pat = re.compile(self.InlineCommandExamplePattern())
doc = self._LinkMarkdown(doc, pat, with_args=False)
return doc
def CommandLineExamplePattern(self):
"""Regex to search for command examples starting with '$ '.
Contains a 'command' group and an 'end' group which will be used
by the regexp search later.
Returns:
(str) the regex pattern, including a format string for the 'top'
command.
"""
# This pattern matches "$ {top} {arg}*" where each arg is lower case and
# does not start with example-, my-, or sample-. This follows the style
# guide rule that user-supplied args to example commands contain upper case
# chars or start with example-, my-, or sample-. The trailing .? allows for
# an optional punctuation character before end of line. This handles cases
# like ``... run $ <top> foo bar.'' at the end of a sentence.
# The <end> group ends at the same place as the command group, without
# the punctuation or newlines.
return (
r'\$ (?P<end>(?P<command>{top}((?: (?!(example|my|sample)-)'
r'[a-z][-a-z0-9]*)*))).?[ `\n]'.format(top=re.escape(self._top))
)
def _AddCommandLineLinkMarkdown(self, doc):
"""Add $ command ... link markdown to doc."""
if not self._command_path:
return doc
pat = re.compile(self.CommandLineExamplePattern())
doc = self._LinkMarkdown(doc, pat, with_args=True)
return doc
def _AddManPageLinkMarkdown(self, doc):
"""Add <top> ...(1) man page link markdown to doc."""
if not self._command_path:
return doc
pat = re.compile(r'(\*?(' + self._top + r'(?:[-_ a-z])*)\*?)\(1\)')
pos = 0
rep = ''
while True:
match = pat.search(doc, pos)
if not match:
break
cmd = match.group(2).replace('_', ' ')
ref = cmd.replace(' ', '/')
lnk = '*link:' + ref + '[' + cmd + ']*'
rep += doc[pos : match.start(2)] + lnk
pos = match.end(1)
if rep:
doc = rep + doc[pos:]
return doc
def _FixAirQuotesMarkdown(self, doc):
"""Change ``.*[[:alnum:]]{2,}.*'' quotes => _UserInput(*) in doc."""
# Double ``air quotes'' on strings with no identifier chars or groups of
# singleton identifier chars are literal. All other double air quote forms
# are converted to unquoted strings with the _UserInput() font
# embellishment. This is a subjective choice for aesthetically pleasing
# renderings.
pat = re.compile(r"[^`](``([^`']*)'')")
pos = 0
rep = ''
for match in pat.finditer(doc):
if re.search(r'\w\w', match.group(2)):
quoted_string = self._UserInput(match.group(2))
else:
quoted_string = match.group(1)
rep += doc[pos : match.start(1)] + quoted_string
pos = match.end(1)
if rep:
doc = rep + doc[pos:]
return doc
def _IsUniverseCompatible(self):
return (
not properties.IsDefaultUniverse()
and not isinstance(self._command, dict)
and self._command.IsUniverseCompatible()
)
def _ReplaceGDULinksWithUniverseLinks(self, doc):
"""Replace static GDU Links with Universe Links."""
# Replace links only for other universes and
# command is available in the universe.
if self._IsUniverseCompatible():
doc = re.sub(
r'cloud.google.com',
universe_descriptor.GetUniverseDocumentDomain(),
doc,
)
return doc
def Edit(self, doc=None):
"""Applies edits to a copy of the generated markdown in doc.
The sub-edit method call order might be significant. This method allows
the combined edits to be tested without relying on the order.
Args:
doc: The markdown document string to edit, None for the output buffer.
Returns:
An edited copy of the generated markdown.
"""
if doc is None:
doc = self._buf.getvalue()
doc = self._ExpandFormatReferences(doc)
doc = self._AddCommandLineLinkMarkdown(doc)
doc = self._AddCommandLinkMarkdown(doc)
doc = self._AddManPageLinkMarkdown(doc)
doc = self._FixAirQuotesMarkdown(doc)
doc = self._ReplaceGDULinksWithUniverseLinks(doc)
return doc
def Generate(self):
"""Generates markdown for the command, group or topic, into a string.
Returns:
An edited copy of the generated markdown.
"""
self._out('# {0}(1)\n'.format(self._file_name.upper()))
# Disclaimer info will be printed only for other universes
self.PrintUniverseInformationSection()
self.PrintNameSection()
self.PrintSynopsisSection()
self.PrintSectionIfExists('DESCRIPTION')
self.PrintSectionIfExists('EXAMPLES')
self.PrintPositionalsAndFlagsSections()
self.PrintSubGroups()
self.PrintSubCommands()
self.PrintExtraSections()
self.PrintFinalSections()
return self.Edit()
class CommandMarkdownGenerator(MarkdownGenerator):
"""Command help markdown document generator.
Attributes:
_command: The CommandCommon instance for command.
_root_command: The root CLI command instance.
_subcommands: The dict of subcommand help indexed by subcommand name.
_subgroups: The dict of subgroup help indexed by subcommand name.
"""
def __init__(self, command):
"""Constructor.
Args:
command: A calliope._CommandCommon instance. Help is extracted from this
calliope command, group or topic.
"""
self._command = command
command.LoadAllSubElements()
# pylint: disable=protected-access
self._root_command = command._TopCLIElement()
super(CommandMarkdownGenerator, self).__init__(
command.GetPath(), command.ReleaseTrack(), command.IsHidden()
)
self._capsule = self._command.short_help
self._docstring = self._command.long_help
self._ExtractSectionsFromDocstring(self._docstring)
self._sections['description'] = self._sections.get('DESCRIPTION', '')
self._sections.update(getattr(self._command, 'detailed_help', {}))
self._subcommands = command.GetSubCommandHelps()
self._subgroups = command.GetSubGroupHelps()
self._sort_top_level_args = command.ai.sort_args
def _SetSectionHelp(self, name, lines):
"""Sets section name help composed of lines.
Args:
name: The section name.
lines: The list of lines in the section.
"""
# Strip leading empty lines.
while lines and not lines[0]:
lines = lines[1:]
# Strip trailing empty lines.
while lines and not lines[-1]:
lines = lines[:-1]
if lines:
self._sections[name] = '\n'.join(lines)
def _ExtractSectionsFromDocstring(self, docstring):
"""Extracts section help from the command docstring."""
name = 'DESCRIPTION'
lines = []
for line in textwrap.dedent(docstring).strip().splitlines():
# '## \n' is not section markdown.
if len(line) >= 4 and line.startswith('## '):
self._SetSectionHelp(name, lines)
name = line[3:]
lines = []
else:
lines.append(line)
self._SetSectionHelp(name, lines)
def IsValidSubPath(self, sub_command_path):
"""Returns True if the given sub command path is valid from this node."""
return self._root_command.IsValidSubPath(sub_command_path)
def GetArguments(self):
"""Returns the command arguments."""
return self._command.ai.arguments
def GetNotes(self):
"""Returns the explicit and auto-generated NOTES section contents."""
return self._command.GetNotesHelpSection(self._sections.get('NOTES'))
def Markdown(command):
"""Generates and returns the help markdown document for command.
Args:
command: The CommandCommon command instance.
Returns:
The markdown document string.
"""
return CommandMarkdownGenerator(command).Generate()