Add JSON output option to ansible-doc (#58209)

* allow json from ansible-doc

* save the var

* try to yaml load

* let examples stay as text blob
This commit is contained in:
Brian Coca 2019-07-03 17:52:20 -04:00 committed by GitHub
parent b4f4cb9b87
commit 9808ffecc7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 152 additions and 131 deletions

View file

@ -0,0 +1,2 @@
minor_changes:
- Allow ansible-doc to return JSON as output.

View file

@ -32,6 +32,10 @@ from ansible.utils.plugin_docs import BLACKLIST, get_docstring
display = Display() display = Display()
def jdump(text):
display.display(json.dumps(text, sort_keys=True, indent=4))
class DocCLI(CLI): class DocCLI(CLI):
''' displays information on modules installed in Ansible libraries. ''' displays information on modules installed in Ansible libraries.
It displays a terse listing of plugins and their short descriptions, It displays a terse listing of plugins and their short descriptions,
@ -60,6 +64,8 @@ class DocCLI(CLI):
help='Choose which plugin type (defaults to "module"). ' help='Choose which plugin type (defaults to "module"). '
'Available plugin types are : {0}'.format(C.DOCUMENTABLE_PLUGINS), 'Available plugin types are : {0}'.format(C.DOCUMENTABLE_PLUGINS),
choices=C.DOCUMENTABLE_PLUGINS) choices=C.DOCUMENTABLE_PLUGINS)
self.parser.add_argument("-j", "--json", action="store_true", default=False, dest='json_format',
help='Change output into json format.')
exclusive = self.parser.add_mutually_exclusive_group() exclusive = self.parser.add_mutually_exclusive_group()
exclusive.add_argument("-F", "--list_files", action="store_true", default=False, dest="list_files", exclusive.add_argument("-F", "--list_files", action="store_true", default=False, dest="list_files",
@ -68,7 +74,7 @@ class DocCLI(CLI):
help='List available plugins') help='List available plugins')
exclusive.add_argument("-s", "--snippet", action="store_true", default=False, dest='show_snippet', exclusive.add_argument("-s", "--snippet", action="store_true", default=False, dest='show_snippet',
help='Show playbook snippet for specified plugin(s)') help='Show playbook snippet for specified plugin(s)')
exclusive.add_argument("-j", "--json", action="store_true", default=False, dest='json_dump', exclusive.add_argument("--metadata-dump", action="store_true", default=False, dest='dump',
help='**For internal testing only** Dump json metadata for all plugins.') help='**For internal testing only** Dump json metadata for all plugins.')
def post_process_args(self, options): def post_process_args(self, options):
@ -84,6 +90,8 @@ class DocCLI(CLI):
plugin_type = context.CLIARGS['type'] plugin_type = context.CLIARGS['type']
do_json = context.CLIARGS['json_format']
if plugin_type in C.DOCUMENTABLE_PLUGINS: if plugin_type in C.DOCUMENTABLE_PLUGINS:
loader = getattr(plugin_loader, '%s_loader' % plugin_type) loader = getattr(plugin_loader, '%s_loader' % plugin_type)
else: else:
@ -109,21 +117,55 @@ class DocCLI(CLI):
for path in paths: for path in paths:
self.plugin_list.update(DocCLI.find_plugins(path, plugin_type)) self.plugin_list.update(DocCLI.find_plugins(path, plugin_type))
list_text = self.get_plugin_list_filenames(loader) plugins = self._get_plugin_list_filenames(loader)
DocCLI.pager(list_text) if do_json:
return 0 jdump(plugins)
else:
# format for user
displace = max(len(x) for x in self.plugin_list)
linelimit = display.columns - displace - 5
text = []
# list plugins for type for plugin in plugins.keys():
if context.CLIARGS['list_dir']: filename = plugins[plugin]
text.append("%-*s %-*.*s" % (displace, plugin, linelimit, len(filename), filename))
DocCLI.pager("\n".join(text))
# list file plugins for type (does not read docs, very fast)
elif context.CLIARGS['list_dir']:
paths = loader._get_paths() paths = loader._get_paths()
for path in paths: for path in paths:
self.plugin_list.update(DocCLI.find_plugins(path, plugin_type)) self.plugin_list.update(DocCLI.find_plugins(path, plugin_type))
DocCLI.pager(self.get_plugin_list_text(loader)) descs = self._get_plugin_list_descriptions(loader)
return 0 if do_json:
jdump(descs)
else:
displace = max(len(x) for x in self.plugin_list)
linelimit = display.columns - displace - 5
text = []
deprecated = []
for plugin in descs.keys():
desc = DocCLI.tty_ify(descs[plugin])
if len(desc) > linelimit:
desc = desc[:linelimit] + '...'
if plugin.startswith('_'): # Handle deprecated
deprecated.append("%-*s %-*.*s" % (displace, plugin[1:], linelimit, len(desc), desc))
else:
text.append("%-*s %-*.*s" % (displace, plugin, linelimit, len(desc), desc))
if len(deprecated) > 0:
text.append("\nDEPRECATED:")
text.extend(deprecated)
DocCLI.pager("\n".join(text))
# dump plugin desc/metadata as JSON # dump plugin desc/metadata as JSON
if context.CLIARGS['json_dump']: elif context.CLIARGS['dump']:
plugin_data = {} plugin_data = {}
plugin_names = DocCLI.get_all_plugins_of_type(plugin_type) plugin_names = DocCLI.get_all_plugins_of_type(plugin_type)
for plugin_name in plugin_names: for plugin_name in plugin_names:
@ -131,23 +173,35 @@ class DocCLI(CLI):
if plugin_info is not None: if plugin_info is not None:
plugin_data[plugin_name] = plugin_info plugin_data[plugin_name] = plugin_info
DocCLI.pager(json.dumps(plugin_data, sort_keys=True, indent=4)) jdump(plugin_data)
return 0 else:
# display specific plugin docs
if len(context.CLIARGS['args']) == 0:
raise AnsibleOptionsError("Incorrect options passed")
if len(context.CLIARGS['args']) == 0: # process command line list
raise AnsibleOptionsError("Incorrect options passed") if do_json:
dump = {}
for plugin in context.CLIARGS['args']:
doc, plainexamples, returndocs, metadata = DocCLI._get_plugin_doc(plugin, loader, plugin_type, search_paths)
try:
returndocs = yaml.load(returndocs)
except Exception:
pass
if doc:
dump[plugin] = {'doc': doc, 'examples': plainexamples, 'return': returndocs, 'metadata': metadata}
jdump(dump)
else:
text = ''
for plugin in context.CLIARGS['args']:
textret = DocCLI.format_plugin_doc(plugin, loader, plugin_type, search_paths)
# process command line list if textret:
text = '' text += textret
for plugin in context.CLIARGS['args']:
textret = DocCLI.format_plugin_doc(plugin, loader, plugin_type, search_paths)
if textret: if text:
text += textret DocCLI.pager(text)
if text:
DocCLI.pager(text)
return 0 return 0
@ -207,9 +261,9 @@ class DocCLI(CLI):
return clean_ns return clean_ns
@staticmethod @staticmethod
def format_plugin_doc(plugin, loader, plugin_type, search_paths): def _get_plugin_doc(plugin, loader, plugin_type, search_paths):
text = ''
doc = plainexamples = returndocs = metadata = {}
try: try:
# if the plugin lives in a non-python file (eg, win_X.ps1), require the corresponding python file for docs # if the plugin lives in a non-python file (eg, win_X.ps1), require the corresponding python file for docs
filename = loader.find_plugin(plugin, mod_type='.py', ignore_deprecated=True, check_aliases=True) filename = loader.find_plugin(plugin, mod_type='.py', ignore_deprecated=True, check_aliases=True)
@ -217,56 +271,47 @@ class DocCLI(CLI):
display.warning("%s %s not found in:\n%s\n" % (plugin_type, plugin, search_paths)) display.warning("%s %s not found in:\n%s\n" % (plugin_type, plugin, search_paths))
return return
if any(filename.endswith(x) for x in C.BLACKLIST_EXTS): if not any(filename.endswith(x) for x in C.BLACKLIST_EXTS):
return doc, plainexamples, returndocs, metadata = get_docstring(filename, fragment_loader, verbose=(context.CLIARGS['verbosity'] > 0))
try:
doc, plainexamples, returndocs, metadata = get_docstring(filename, fragment_loader,
verbose=(context.CLIARGS['verbosity'] > 0))
except Exception:
display.vvv(traceback.format_exc())
display.error(
"%s %s has a documentation error formatting or is missing documentation." % (plugin_type, plugin),
wrap_text=False)
return
if doc is not None:
# assign from other sections
doc['plainexamples'] = plainexamples
doc['returndocs'] = returndocs
doc['metadata'] = metadata
# generate extra data
if plugin_type == 'module':
# is there corresponding action plugin?
if plugin in action_loader:
doc['action'] = True
else:
doc['action'] = False
doc['filename'] = filename doc['filename'] = filename
doc['now_date'] = datetime.date.today().strftime('%Y-%m-%d')
if 'docuri' in doc:
doc['docuri'] = doc[plugin_type].replace('_', '-')
if context.CLIARGS['show_snippet'] and plugin_type == 'module':
text += DocCLI.get_snippet_text(doc)
else:
text += DocCLI.get_man_text(doc)
return text
else:
if 'removed' in metadata['status']:
display.warning("%s %s has been removed\n" % (plugin_type, plugin))
return
# this typically means we couldn't even parse the docstring, not just that the YAML is busted,
# probably a quoting issue.
raise AnsibleError("Parsing produced an empty object.")
except Exception as e: except Exception as e:
display.vvv(traceback.format_exc()) display.vvv(traceback.format_exc())
raise AnsibleError( raise AnsibleError("%s %s missing documentation (or could not parse documentation): %s\n" % (plugin_type, plugin, to_native(e)))
"%s %s missing documentation (or could not parse documentation): %s\n" % (plugin_type, plugin, to_native(e))) return doc, plainexamples, returndocs, metadata
@staticmethod
def format_plugin_doc(plugin, loader, plugin_type, search_paths):
text = ''
doc, plainexamples, returndocs, metadata = DocCLI._get_plugin_doc(plugin, loader, plugin_type, search_paths)
if doc is not None:
# assign from other sections
doc['plainexamples'] = plainexamples
doc['returndocs'] = returndocs
doc['metadata'] = metadata
# generate extra data
if plugin_type == 'module':
# is there corresponding action plugin?
if plugin in action_loader:
doc['action'] = True
else:
doc['action'] = False
doc['now_date'] = datetime.date.today().strftime('%Y-%m-%d')
if 'docuri' in doc:
doc['docuri'] = doc[plugin_type].replace('_', '-')
if context.CLIARGS['show_snippet'] and plugin_type == 'module':
text += DocCLI.get_snippet_text(doc)
else:
text += DocCLI.get_man_text(doc)
elif 'removed' in metadata['status']:
display.warning("%s %s has been removed\n" % (plugin_type, plugin))
return text
@staticmethod @staticmethod
def find_plugins(path, ptype): def find_plugins(path, ptype):
@ -311,12 +356,39 @@ class DocCLI(CLI):
return plugin_list return plugin_list
def get_plugin_list_text(self, loader): def _get_plugin_list_descriptions(self, loader):
columns = display.columns
displace = max(len(x) for x in self.plugin_list) descs = {}
linelimit = columns - displace - 5 plugins = self._get_plugin_list_filenames(loader)
text = [] for plugin in plugins.keys():
deprecated = []
filename = plugins[plugin]
doc = None
try:
doc = read_docstub(filename)
except Exception:
display.warning("%s has a documentation formatting error" % plugin)
continue
if not doc or not isinstance(doc, dict):
with open(filename) as f:
metadata = extract_metadata(module_data=f.read())
if metadata[0]:
if 'removed' not in metadata[0].get('status', []):
display.warning("%s parsing did not produce documentation." % plugin)
else:
continue
desc = 'UNDOCUMENTED'
else:
desc = doc.get('short_description', 'INVALID SHORT DESCRIPTION').strip()
descs[plugin] = desc
return descs
def _get_plugin_list_filenames(self, loader):
pfiles = {}
for plugin in sorted(self.plugin_list): for plugin in sorted(self.plugin_list):
try: try:
@ -330,65 +402,12 @@ class DocCLI(CLI):
if os.path.isdir(filename): if os.path.isdir(filename):
continue continue
doc = None pfiles[plugin] = filename
try:
doc = read_docstub(filename)
except Exception:
display.warning("%s has a documentation formatting error" % plugin)
continue
if not doc or not isinstance(doc, dict):
with open(filename) as f:
metadata = extract_metadata(module_data=f.read())
if metadata[0]:
if 'removed' not in metadata[0].get('status', []):
display.warning("%s parsing did not produce documentation." % plugin)
else:
continue
desc = 'UNDOCUMENTED'
else:
desc = DocCLI.tty_ify(doc.get('short_description', 'INVALID SHORT DESCRIPTION').strip())
if len(desc) > linelimit:
desc = desc[:linelimit] + '...'
if plugin.startswith('_'): # Handle deprecated
deprecated.append("%-*s %-*.*s" % (displace, plugin[1:], linelimit, len(desc), desc))
else:
text.append("%-*s %-*.*s" % (displace, plugin, linelimit, len(desc), desc))
except Exception as e:
raise AnsibleError("Failed reading docs at %s: %s" % (plugin, to_native(e)), orig_exc=e)
if len(deprecated) > 0:
text.append("\nDEPRECATED:")
text.extend(deprecated)
return "\n".join(text)
def get_plugin_list_filenames(self, loader):
columns = display.columns
displace = max(len(x) for x in self.plugin_list)
linelimit = columns - displace - 5
text = []
for plugin in sorted(self.plugin_list):
try:
# if the module lives in a non-python file (eg, win_X.ps1), require the corresponding python file for docs
filename = loader.find_plugin(plugin, mod_type='.py', ignore_deprecated=True, check_aliases=True)
if filename is None:
continue
if filename.endswith(".ps1"):
continue
if os.path.isdir(filename):
continue
text.append("%-*s %-*.*s" % (displace, plugin, linelimit, len(filename), filename))
except Exception as e: except Exception as e:
raise AnsibleError("Failed reading docs at %s: %s" % (plugin, to_native(e)), orig_exc=e) raise AnsibleError("Failed reading docs at %s: %s" % (plugin, to_native(e)), orig_exc=e)
return "\n".join(text) return pfiles
@staticmethod @staticmethod
def print_paths(finder): def print_paths(finder):