feat: Add new gcloud commands, API clients, and third-party libraries across various services.

This commit is contained in:
2026-01-01 20:26:35 +01:00
parent 5e23cbece0
commit a19e592eb7
25221 changed files with 8324611 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
# -*- coding: utf-8 -*- #
# Copyright 2017 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.
"""Help search filter rewrite.
Converts Cloud SDK filter expressions to nested terms prefixed by AND and OR
operators.
Usage:
from googlecloudsdk.command_lib.search_help import filter_rewrite
_, terms = filter_rewrite.SearchTerms().Rewrite(expression_string)
Examples:
"a b OR c" =>
[
"AND",
{
"a": null
},
[
"OR",
{
"b": null
},
{
"c": null
}
]
]
"flag:a release:alpha" =>
[
"AND",
{
"a": "flag"
},
{
"alpha": "release"
}
]
"""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
from googlecloudsdk.core import exceptions
from googlecloudsdk.core.resource import resource_expr_rewrite
class Error(exceptions.Error):
"""Exceptions for this module."""
class OperatorNotSupportedError(Error):
"""Operator not supported."""
class SearchTerms(resource_expr_rewrite.Backend):
"""A resource filter backend that produces help search terms."""
def RewriteTerm(self, key, op, operand, key_type):
"""Rewrites <key op operand>."""
del key_type # unused in RewriteTerm
if op != ':':
raise OperatorNotSupportedError(
'The [{}] operator is not supported.'.format(op))
return [{operand: key}]
def RewriteGlobal(self, call):
"""Rewrites global restriction <call>."""
return [{call.term: None}]
@staticmethod
def _SimplifyLogical(op, left, right):
"""Simplifies the binary logical operator subexpression 'left op right'.
Adjacent nested terms with the same 'AND' or 'OR' binary logical operator
are flattened.
For example,
['AND', {'a': None}, ['AND', {'b': None}, {'c', None}]]
simplifies to
['AND', {'a': None}, {'b': None}, {'c', None}]
Args:
op: The subexpression binary op, either 'AND' or 'OR'.
left: The left expression. Could be a term, 'AND' or 'OR' subexpression.
right: The right expression. Could be a term, 'AND' or 'OR' subexpression.
Returns:
The simplified binary logical operator subexpression.
"""
inv = 'AND' if op == 'OR' else 'OR'
if left[0] == op:
if right[0] == inv:
return left + [right]
if right[0] == op:
right = right[1:]
return left + right
if left[0] == inv:
if right[0] in [op, inv]:
return [op, left, right]
return [op, left] + right
if right[0] == inv:
return [op] + left + [right]
if right[0] == op:
right = right[1:]
return [op] + left + right
def RewriteAND(self, left, right):
"""Rewrites <left AND right>."""
return self._SimplifyLogical('AND', left, right)
def RewriteOR(self, left, right):
"""Rewrites <left OR right>."""
return self._SimplifyLogical('OR', left, right)
def RewriteNOT(self, expression):
"""Rewrites <NOT expression>."""
raise OperatorNotSupportedError(
'The [{}] operator is not supported.'.format('NOT'))

View File

@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*- #
# Copyright 2017 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.
"""Stores lookup keys for help search table."""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
from googlecloudsdk.calliope import cli_tree
ATTR = cli_tree.LOOKUP_ATTR
CAPSULE = cli_tree.LOOKUP_CAPSULE
CHOICES = cli_tree.LOOKUP_CHOICES
HIDDEN_CHOICES = cli_tree.LOOKUP_HIDDEN_CHOICES
COMMANDS = cli_tree.LOOKUP_COMMANDS
DEFAULT = cli_tree.LOOKUP_DEFAULT
DESCRIPTION = cli_tree.LOOKUP_DESCRIPTION
FLAGS = cli_tree.LOOKUP_FLAGS
IS_GLOBAL = cli_tree.LOOKUP_IS_GLOBAL
IS_HIDDEN = cli_tree.LOOKUP_IS_HIDDEN
NAME = cli_tree.LOOKUP_NAME
PATH = cli_tree.LOOKUP_PATH
POSITIONALS = cli_tree.LOOKUP_POSITIONALS
RELEASE = cli_tree.LOOKUP_RELEASE
SECTIONS = cli_tree.LOOKUP_SECTIONS
FLAG = 'flag'
COMMAND = 'command'
GENERATED = 'generated'
MARKDOWN = 'markdown'
POSITIONAL = 'positional'
SUBSECTIONS = 'subsections'
SUMMARY = 'summary'
TEXT = 'text'
RESULTS = 'results'
RELEVANCE = 'relevance'
DOT = '.'
# Part of command[RELEASE]
ALPHA = 'ALPHA'
BETA = 'BETA'
GA = 'GA'
# Part of command[PATH]
ALPHA_PATH = 'alpha'
BETA_PATH = 'beta'

View File

@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*- #
# Copyright 2018 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.
"""Contains a class to rate commands based on relevance."""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
from googlecloudsdk.command_lib.help_search import lookup
class CommandRater(object):
"""A class to rate the results of searching a command."""
# The below multipliers reflect heuristics for how "important" a term is
# in a command based on where it's found.
_COMMAND_NAME_MULTIPLIER = 1.0 # command name
_ARG_NAME_MULTIPLIER = 0.5 # arg name (positional or flag)
_PATH_MULTIPLIER = 0.5 # the command path
_DEFAULT_MULTIPLIER = 0.25 # anything not controlled by other multipliers.
# This multiplier controls how much a command is penalized for not containing
# a search term.
_NOT_FOUND_MULTIPLIER = 0.1
def __init__(self, results, command):
"""Create a CommandRater.
Args:
results: googlecloudsdk.command_lib.search_help.search_util
.CommandSearchResult, class that holds results.
command: dict, a json representation of a command.
"""
self._command = command
self._terms = results.AllTerms()
self._results = results
def Rate(self):
"""Produce a simple relevance rating for a set of command search results.
Returns a float in the range (0, 1]. For each term that's found, the rating
is multiplied by a number reflecting how "important" its location is, with
command name being the most and flag or positional names being the second
most important, as well as by how many of the search terms were found.
Commands are also penalized if duplicate results in a higher release track
were found.
Returns:
rating: float, the rating of the results.
"""
rating = 1.0
rating *= self._RateForLocation()
rating *= self._RateForTermsFound()
return rating
def _RateForLocation(self):
"""Get a rating based on locations of results."""
rating = 1.0
locations = self._results.FoundTermsMap().values()
for location in locations:
if location == lookup.NAME:
rating *= self._COMMAND_NAME_MULTIPLIER
elif location == lookup.PATH:
rating *= self._PATH_MULTIPLIER
elif (location.split(lookup.DOT)[0] in [lookup.FLAGS, lookup.POSITIONALS]
and location.split(lookup.DOT)[-1] == lookup.NAME):
rating *= self._ARG_NAME_MULTIPLIER
else:
rating *= self._DEFAULT_MULTIPLIER
return rating
def _RateForTermsFound(self):
"""Get a rating based on how many of the searched terms were found."""
rating = 1.0
results = self._results.FoundTermsMap()
for term in self._terms:
if term not in results:
rating *= self._NOT_FOUND_MULTIPLIER
return rating
class CumulativeRater(object):
"""Rates all found commands for relevance."""
def __init__(self):
"""Creates a cumulative rater.
"""
self._found_commands_and_results = []
def AddFoundCommand(self, command, result):
"""Add a command that is a result.
Args:
command: dict, a json representation of a command. MUST already be updated
with the search results.
result: search_util.CommandSearchResults, the results object that goes
with this command.
"""
self._found_commands_and_results.append((command, result))
def RateAll(self):
"""Adds rating to every command found."""
for command, results in self._found_commands_and_results:
rating = CommandRater(results, command).Rate()
command[lookup.RELEVANCE] = rating

View File

@@ -0,0 +1,140 @@
# -*- coding: utf-8 -*- #
# Copyright 2017 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.
"""gcloud search-help command resources."""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
from googlecloudsdk.calliope import cli_tree
from googlecloudsdk.command_lib.help_search import lookup
from googlecloudsdk.command_lib.help_search import rater
from googlecloudsdk.command_lib.help_search import search_util
from six.moves import zip
def RunSearch(terms, cli):
"""Runs search-help by opening and reading help table, finding commands.
Args:
terms: [str], list of strings that must be found in the command.
cli: the Calliope CLI object
Returns:
a list of json objects representing gcloud commands.
"""
parent = cli_tree.Load(cli=cli, one_time_use_ok=True)
searcher = Searcher(parent, terms)
return searcher.Search()
class Searcher(object):
"""Class to run help search."""
def __init__(self, parent, terms):
self.parent = parent
self.terms = terms
self._rater = rater.CumulativeRater()
def Search(self):
"""Run a search and return a list of processed matching commands.
The search walks the command tree and returns a list of matching commands.
The commands are modified so that child commands in command groups are
replaced with just a list of their names, and include summaries and
"relevance" ratings as well.
Commands match if at least one of the searcher's terms is found in the
command.
Filters out duplicates with lower tracks.
Returns:
[dict], a list of the matching commands in json form.
"""
found_commands = self._WalkTree(self.parent, [])
# Sorts by track, i.e. Ga -> Beta -> Alpha.
found_commands.sort(key=lambda e: e['release'], reverse=True)
de_duped_commands = []
unique_results_tracking_list = []
for command in found_commands:
command_path = _GetCommandPathWithoutTrackPrefix(command)
unique_combo = (command_path, command['results'])
if unique_combo not in unique_results_tracking_list:
unique_results_tracking_list.append(unique_combo)
de_duped_commands.append(command)
self._rater.RateAll()
return de_duped_commands
def _WalkTree(self, current_parent, found_commands):
"""Recursively walks command tree, checking for matches.
If a command matches, it is postprocessed and added to found_commands.
Args:
current_parent: dict, a json representation of a CLI command.
found_commands: [dict], a list of matching commands.
Returns:
[dict], a list of commands that have matched so far.
"""
result = self._PossiblyGetResult(current_parent)
if result:
found_commands.append(result)
for child_command in current_parent.get(lookup.COMMANDS, {}).values():
found_commands = self._WalkTree(child_command, found_commands)
return found_commands
def _PossiblyGetResult(self, command):
"""Helper function to determine whether a command contains all terms.
Returns a copy of the command or command group with modifications to the
'commands' field and an added 'summary' field if the command matches
the searcher's search terms.
Args:
command: dict, a json representation of a command.
Returns:
a modified copy of the command if the command is a result, otherwise None.
"""
locations = [search_util.LocateTerm(command, term) for term in self.terms]
if any(locations):
results = search_util.CommandSearchResults(
dict(zip(self.terms, locations)))
new_command = search_util.ProcessResult(command, results)
self._rater.AddFoundCommand(new_command, results)
return new_command
def _GetCommandPathWithoutTrackPrefix(command):
"""Helper to get the path of a command without a track prefix.
Args:
command: dict, json representation of a command.
Returns:
a ' '-separated string representation of a command path without any
track prefixes.
"""
return ' '.join(
[segment for segment in command[lookup.PATH]
if segment not in [lookup.ALPHA_PATH, lookup.BETA_PATH]])

View File

@@ -0,0 +1,625 @@
# -*- coding: utf-8 -*- #
# Copyright 2017 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.
"""utils for search-help command resources."""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
import copy
import io
import re
from googlecloudsdk.command_lib.help_search import lookup
from googlecloudsdk.core.document_renderers import render_document
import six
from six.moves import filter
DEFAULT_SNIPPET_LENGTH = 200
DOT = '.'
SUMMARY_PRIORITIES = {
lookup.NAME: 0,
lookup.CAPSULE: 1,
lookup.SECTIONS: 2,
lookup.POSITIONALS: 3,
lookup.FLAGS: 4,
lookup.COMMANDS: 5,
lookup.PATH: 6}
class TextSlice(object):
"""Small class for working with pieces of text."""
def __init__(self, start, end):
self.start = start
self.end = end
def Overlaps(self, other):
if other.start < self.start:
return other.overlaps(self)
return self.end >= other.start
def Merge(self, other):
if not self.Overlaps(other):
msg = ('Cannot merge text slices [{}:{}] and [{}:{}]: '
'Do not overlap.'.format(
self.start, self.end, other.start, other.end))
raise ValueError(msg)
self.start = min(self.start, other.start)
self.end = max(self.end, other.end)
def AsSlice(self):
return slice(self.start, self.end, 1)
def _GetStartAndEnd(match, cut_points, length_per_snippet):
"""Helper function to get start and end of single snippet that matches text.
Gets a snippet of length length_per_snippet with the match object
in the middle.
Cuts at the first cut point (if available, else cuts at any char)
within 1/2 the length of the start of the match object.
Then cuts at the last cut point within
the desired length (if available, else cuts at any point).
Then moves start back if there is extra room at the beginning.
Args:
match: re.match object.
cut_points: [int], indices of each cut char, plus start and
end index of full string. Must be sorted.
(The characters at cut_points are skipped.)
length_per_snippet: int, max length of snippet to be returned
Returns:
(int, int) 2-tuple with start and end index of the snippet
"""
max_length = cut_points[-1] if cut_points else 0
match_start = match.start() if match else 0
match_end = match.end() if match else 0
# Get start cut point.
start = 0
if match_start > .5 * length_per_snippet:
# Get first point within 1/2 * length_per_snippet chars of term.
for c in cut_points:
if c >= match_start - (.5 * length_per_snippet) and c < match_start:
start = c + 1
break # The cut points are already sorted, so first = min.
# If no cut points, just start 1/2 the desired length back or at 0.
start = int(max(match_start - (.5 * length_per_snippet), start))
# Get end cut point.
# Must be after term but within desired distance of start.
end = match_end
# Look for last cut point in this interval
for c in cut_points:
if end < c <= start + length_per_snippet:
end = c
elif c > start + length_per_snippet:
break # the list was sorted, so last = max.
# If no cut points, just cut at the exact desired length or at the end,
# whichever comes first.
if end == match_end:
end = max(min(max_length, start + length_per_snippet), end)
# If cutting at the end, update start so we get the maximum length snippet.
# Look for the first cut point within length_of_snippet of the end.
if end == max_length:
for c in cut_points:
if end - c <= (length_per_snippet + 1) and c < start:
start = c + 1
break
return TextSlice(start, end)
def _BuildExcerpt(text, snips):
"""Helper function to build excerpt using (start, end) tuples.
Returns a string that combines substrings of the text (text[start:end]),
joins them with ellipses
Args:
text: the text to excerpt from.
snips: [(int, int)] list of 2-tuples representing start and end places
to cut text.
Returns:
str, the excerpt.
"""
snippet = '...'.join([text[snip.AsSlice()] for snip in snips])
if snips:
if snips[0].start != 0:
snippet = '...' + snippet
if snips[-1].end != len(text):
snippet += '...'
return snippet
def _Snip(text, length_per_snippet, terms):
"""Create snippet of text, containing given terms if present.
The max length of the snippet is the number of terms times the given length.
This is to prevent a long list of terms from resulting in nonsensically
short sub-strings. Each substring is up to length given, joined by '...'
Args:
text: str, the part of help text to cut. Should be only ASCII characters.
length_per_snippet: int, the length of the substrings to create containing
each term.
terms: [str], the terms to include.
Returns:
str, a summary excerpt including the terms, with all consecutive whitespace
including newlines reduced to a single ' '.
"""
text = re.sub(r'\s+', ' ', text)
if len(text) <= length_per_snippet:
return text
cut_points = ([0] + [r.start() for r in re.finditer(r'\s', text)] +
[len(text)])
if not terms:
return _BuildExcerpt(
text,
[_GetStartAndEnd(None, cut_points, length_per_snippet)])
unsorted_matches = [re.search(term, text, re.IGNORECASE) for term in terms]
matches = sorted(filter(bool, unsorted_matches),
key=lambda x: x.start())
snips = [] # list of TextSlice objects.
for match in matches:
# Don't get a new excerpt if the word is already in the excerpted part.
if not (snips and
snips[-1].start < match.start() and snips[-1].end > match.end()):
next_slice = _GetStartAndEnd(match, cut_points, length_per_snippet)
# Combine if overlaps with previous snippet.
if snips:
latest = snips[-1]
if latest.Overlaps(next_slice):
latest.Merge(next_slice)
else:
snips.append(next_slice)
else:
snips.append(next_slice)
# If no terms were found, just cut from beginning.
if not snips:
snips = [_GetStartAndEnd(None, cut_points, length_per_snippet)]
return _BuildExcerpt(text, snips)
def _FormatHeader(header):
"""Helper function to reformat header string in markdown."""
if header == lookup.CAPSULE:
return None
return '# {}'.format(header.upper())
def _FormatItem(item):
"""Helper function to reformat string as markdown list item: {STRING}::."""
return '{}::'.format(item)
def _SummaryPriority(x):
# Ensure the summary is built in the right order.
return SUMMARY_PRIORITIES.get(x[0], len(SUMMARY_PRIORITIES))
class SummaryBuilder(object):
"""Class that builds a summary of certain attributes of a command.
This will summarize a json representation of a command using
cloud SDK-style markdown (but with no text wrapping) by taking snippets
of the given locations in a command.
If a lookup is given from terms to where they appear, then the snippets will
include the relevant terms. Occurrences of search terms will be stylized.
Uses a small amount of simple Cloud SDK markdown.
1) To get a summary with just the brief help:
SummaryBuilder(command, {'alligator': 'capsule'}).GetSummary()
[no heading]
{excerpt of command['capsule'] with first appearance of 'alligator'}
2) To get a summary with a section (can be first-level or inside 'sections',
which is the same as detailed_help):
SummaryBuilder(command, {'': 'sections.SECTION_NAME'}).GetSummary()
# SECTION_NAME
{excerpt of 'SECTION_NAME' section of detailed help. If it is a list
it will be joined by ', '.}
3) To get a summary with a specific positional arg:
SummaryBuilder(command, {'crocodile': 'positionals.myarg.name'}).GetSummary()
# POSITIONALS
myarg::
{excerpt of 'myarg' positional help containing 'crocodile'}
4) To get a summary with specific flags, possibly including choices/defaults:
SummaryBuilder.GetSummary(command,
{'a': 'flags.--my-flag.choices',
'b': 'flags.--my-other-flag.default'})
# FLAGS
myflag::
{excerpt of help} Choices: {comma-separated list of flag choices}
myotherflag::
{excerpt of help} Default: {flag default}
Attributes:
command: dict, a json representation of a command.
found_terms_map: dict, mapping of terms to the locations where they are
found, equivalent to the return value of
CommandSearchResults.FoundTermsMap(). This map is found under "results"
in the command resource returned by help-search. Locations have segments
separated by dots, such as sections.DESCRIPTION. If the first segment is
"flags" or "positionals", there must be three segments.
length_per_snippet: int, length of desired substrings to get from text.
"""
_INVALID_LOCATION_MESSAGE = (
'Attempted to look up a location [{}] that was not found or invalid.')
_IMPRECISE_LOCATION_MESSAGE = (
'Expected location with three segments, received [{}]')
def __init__(self, command, found_terms_map, length_per_snippet=200):
"""Create the class."""
self.command = command
self.found_terms_map = found_terms_map
self.length_per_snippet = length_per_snippet
self._lines = []
def _AddFlagToSummary(self, location, terms):
"""Adds flag summary, given location such as ['flags']['--myflag']."""
flags = self.command.get(location[0], {})
line = ''
assert len(location) > 2, self._IMPRECISE_LOCATION_MESSAGE.format(
DOT.join(location))
# Add flag name and description of flag if not added yet.
flag = flags.get(location[1])
assert flag and not flag[lookup.IS_HIDDEN], (
self._INVALID_LOCATION_MESSAGE.format(DOT.join(location)))
if _FormatHeader(lookup.FLAGS) not in self._lines:
self._lines.append(_FormatHeader(lookup.FLAGS))
if _FormatItem(location[1]) not in self._lines:
self._lines.append(_FormatItem(location[1]))
desc_line = flag.get(lookup.DESCRIPTION, '')
desc_line = _Snip(desc_line, self.length_per_snippet, terms)
assert desc_line, self._INVALID_LOCATION_MESSAGE.format(
DOT.join(location))
line = desc_line
# Add default if needed.
if location[2] == lookup.DEFAULT:
default = flags.get(location[1]).get(lookup.DEFAULT)
if default:
if line not in self._lines:
self._lines.append(line)
if isinstance(default, dict):
default = ', '.join([x for x in sorted(default.keys())])
elif isinstance(default, list):
default = ', '.join([x for x in default])
line = 'Default: {}.'.format(default)
else:
# The other three sub-locations for flags are covered by adding the
# snippet of the description.
valid_subattributes = [lookup.NAME, lookup.DESCRIPTION, lookup.CHOICES]
assert location[2] in valid_subattributes, (
self._INVALID_LOCATION_MESSAGE.format(DOT.join(location)))
if line:
self._lines.append(line)
def _AddPositionalToSummary(self, location, terms):
"""Adds summary of arg, given location such as ['positionals']['myarg']."""
positionals = self.command.get(lookup.POSITIONALS)
line = ''
assert len(location) > 2, self._IMPRECISE_LOCATION_MESSAGE.format(DOT.join(
location))
positionals = [p for p in positionals if p[lookup.NAME] == location[1]]
assert positionals, self._INVALID_LOCATION_MESSAGE.format(
DOT.join(location))
if _FormatHeader(lookup.POSITIONALS) not in self._lines:
self._lines.append(_FormatHeader(lookup.POSITIONALS))
self._lines.append(_FormatItem(location[1]))
positional = positionals[0]
line = positional.get(lookup.DESCRIPTION, '')
line = _Snip(line, self.length_per_snippet, terms)
if line:
self._lines.append(line)
def _AddGenericSectionToSummary(self, location, terms):
"""Helper function for adding sections in the form ['loc1','loc2',...]."""
section = self.command
for loc in location:
section = section.get(loc, {})
if isinstance(section, str):
line = section
# if dict or list, use commas to join keys or items, respectively.
elif isinstance(section, list):
line = ', '.join(sorted(section))
elif isinstance(section, dict):
line = ', '.join(sorted(section.keys()))
else:
line = six.text_type(section)
assert line, self._INVALID_LOCATION_MESSAGE.format(DOT.join(location))
header = _FormatHeader(location[-1])
if header:
self._lines.append(header)
loc = '.'.join(location)
self._lines.append(
_Snip(line, self.length_per_snippet, terms))
def GetSummary(self):
"""Builds a summary.
Returns:
str, a markdown summary
"""
all_locations = set(self.found_terms_map.values())
if lookup.CAPSULE not in all_locations:
all_locations.add(lookup.CAPSULE)
def _Equivalent(location, other_location):
"""Returns True if both locations correspond to same summary section."""
if location == other_location:
return True
if len(location) != len(other_location):
return False
if location[:-1] != other_location[:-1]:
return False
equivalent = [lookup.NAME, lookup.CHOICES, lookup.DESCRIPTION]
if location[-1] in equivalent and other_location[-1] in equivalent:
return True
return False
# Sort alphabetically first to make sure everything is alphabetical within
# the same priority.
for full_location in sorted(sorted(all_locations), key=_SummaryPriority):
location = full_location.split(DOT)
terms = {t for t, l in six.iteritems(self.found_terms_map)
if _Equivalent(l.split(DOT), location) and t}
if location[0] == lookup.FLAGS:
self._AddFlagToSummary(location, terms)
elif location[0] == lookup.POSITIONALS:
self._AddPositionalToSummary(location, terms)
# path and name are ignored.
elif lookup.PATH in location or lookup.NAME in location:
continue
else:
self._AddGenericSectionToSummary(location, terms)
summary = '\n'.join(self._lines)
return Highlight(summary, self.found_terms_map.keys())
def GetSummary(command, found_terms_map,
length_per_snippet=DEFAULT_SNIPPET_LENGTH):
"""Gets a summary of certain attributes of a command."""
return SummaryBuilder(
command, found_terms_map, length_per_snippet).GetSummary()
def _Stylize(s):
"""Stylize a given string. Currently done by converting to upper-case."""
return s.upper()
def Highlight(text, terms, stylize=None):
"""Stylize desired terms in a string.
Returns a copy of the original string with all substrings matching the given
terms (with case-insensitive matching) stylized.
Args:
text: str, the original text to be highlighted.
terms: [str], a list of terms to be matched.
stylize: callable, the function to use to stylize the terms.
Returns:
str, the highlighted text.
"""
if stylize is None:
stylize = _Stylize
for term in filter(bool, terms):
# Find all occurrences of term and stylize them.
matches = re.finditer(term, text, re.IGNORECASE)
match_strings = set([text[match.start():match.end()] for match in matches])
for match_string in match_strings:
text = text.replace(match_string, stylize(match_string))
return text
def ProcessResult(command, results):
"""Helper function to create help text resource for listing results.
Args:
command: dict, json representation of command.
results: CommandSearchResults, result of searching for each term.
Returns:
A modified copy of the json command with a summary, and with the dict
of subcommands replaced with just a list of available subcommands.
"""
new_command = copy.deepcopy(command)
if lookup.COMMANDS in six.iterkeys(new_command):
new_command[lookup.COMMANDS] = sorted([
c[lookup.NAME]
for c in new_command[lookup.COMMANDS].values()
if not c[lookup.IS_HIDDEN]
])
new_command[lookup.RESULTS] = results.FoundTermsMap()
return new_command
def LocateTerm(command, term):
"""Helper function to get first location of term in a json command.
Locations are considered in this order: 'name', 'capsule',
'sections', 'positionals', 'flags', 'commands', 'path'. Returns a dot-
separated lookup for the location e.g. 'sections.description' or
empty string if not found.
Args:
command: dict, json representation of command.
term: str, the term to search.
Returns:
str, lookup for where to find the term when building summary of command.
"""
# Skip hidden commands.
if command[lookup.IS_HIDDEN]:
return ''
# Look in name/path
regexp = re.compile(re.escape(term), re.IGNORECASE)
if regexp.search(command[lookup.NAME]):
return lookup.NAME
if regexp.search(' '.join(command[lookup.PATH] + [lookup.NAME])):
return lookup.PATH
def _Flags(command):
return {flag_name: flag for (flag_name, flag)
in six.iteritems(command[lookup.FLAGS])
if not flag[lookup.IS_HIDDEN] and not flag[lookup.IS_GLOBAL]}
# Look in flag and positional names
for flag_name, flag in sorted(six.iteritems(_Flags(command))):
if regexp.search(flag_name):
return DOT.join([lookup.FLAGS, flag[lookup.NAME], lookup.NAME])
for positional in command[lookup.POSITIONALS]:
if regexp.search(positional[lookup.NAME]):
return DOT.join([lookup.POSITIONALS, positional[lookup.NAME],
lookup.NAME])
# Look in other help sections
if regexp.search(command[lookup.CAPSULE]):
return lookup.CAPSULE
for section_name, section_desc in sorted(
six.iteritems(command[lookup.SECTIONS])):
if regexp.search(section_desc):
return DOT.join([lookup.SECTIONS, section_name])
# Look in flag sections
for flag_name, flag in sorted(six.iteritems(_Flags(command))):
hidden_choices = flag.get(lookup.ATTR, {}).get(lookup.HIDDEN_CHOICES, [])
choices = [
c for c in flag.get(lookup.CHOICES, []) if c not in hidden_choices]
if regexp.search(six.text_type(choices)):
return DOT.join([lookup.FLAGS, flag[lookup.NAME], lookup.CHOICES])
for sub_attribute in [lookup.DESCRIPTION, lookup.DEFAULT]:
if regexp.search(six.text_type(flag.get(sub_attribute, ''))):
return DOT.join([lookup.FLAGS, flag[lookup.NAME], sub_attribute])
# Look in positionals
for positional in command[lookup.POSITIONALS]:
if regexp.search(positional[lookup.DESCRIPTION]):
return DOT.join([lookup.POSITIONALS, positional[lookup.NAME],
positional[lookup.DESCRIPTION]])
# Look in subcommands & path
if regexp.search(
six.text_type([n for n, c in six.iteritems(command[lookup.COMMANDS])
if not c[lookup.IS_HIDDEN]])):
return lookup.COMMANDS
return ''
def SummaryTransform(r):
"""A resource transform function to summarize a command search result.
Uses the "results" attribute of the command to build a summary that includes
snippets of the help text of the command that include the searched terms.
Occurrences of the search term will be stylized.
Args:
r: a json representation of a command.
Returns:
str, a summary of the command.
"""
summary = GetSummary(r, r[lookup.RESULTS])
md = io.StringIO(summary)
rendered_summary = io.StringIO()
# Render summary as markdown, ignoring console width.
render_document.RenderDocument('text',
md,
out=rendered_summary,
# Increase length in case of indentation.
width=len(summary) * 2)
final_summary = '\n'.join(
[l.lstrip() for l in rendered_summary.getvalue().splitlines()
if l.lstrip()])
return final_summary
def PathTransform(r):
"""A resource transform to get the command path with search terms stylized.
Uses the "results" attribute of the command to determine which terms to
stylize and the "path" attribute of the command to get the command path.
Args:
r: a json representation of a command.
Returns:
str, the path of the command with search terms stylized.
"""
results = r[lookup.RESULTS]
path = ' '.join(r[lookup.PATH])
return Highlight(path, results.keys())
class CommandSearchResults(object):
"""Class to hold the results of a search."""
def __init__(self, results_data):
"""Create a CommandSearchResults object.
Args:
results_data: {str: str}, a dictionary from terms to the locations where
they were found. Empty string values in the dict represent terms that
were searched but not found. Locations should be formatted as
dot-separated strings representing the location in the command (as
created by LocateTerms above).
"""
self._results_data = results_data
def AllTerms(self):
"""Gets a list of all terms that were searched."""
return self._results_data.keys()
def FoundTermsMap(self):
"""Gets a map from all terms that were found to their locations."""
return {k: v for (k, v) in six.iteritems(self._results_data) if v}
_TRANSFORMS = {
'summary': SummaryTransform,
'commandpath': PathTransform
}
def GetTransforms():
return _TRANSFORMS