From 546187a8af4a0afb383a2a7852241d009ec73e88 Mon Sep 17 00:00:00 2001 From: Toshio Kuratomi Date: Sun, 13 Aug 2017 19:12:43 -0700 Subject: [PATCH] Revamp the plugin_formatter doc generator * Use a template to generate the category lists * Refactor so that we first extract all of the data that we need to build the docs and then give that data to the templates to build with * Add docs page listing modules ordered by support level --- docs/bin/plugin_formatter.py | 487 +++++++++--------- docs/docsite/rst/modules_support.rst | 13 + .../templates/list_of_CATEGORY_modules.rst.j2 | 27 + docs/templates/modules_by_category.rst.j2 | 9 + docs/templates/modules_by_support.rst.j2 | 15 + 5 files changed, 317 insertions(+), 234 deletions(-) create mode 100644 docs/templates/list_of_CATEGORY_modules.rst.j2 create mode 100644 docs/templates/modules_by_category.rst.j2 create mode 100644 docs/templates/modules_by_support.rst.j2 diff --git a/docs/bin/plugin_formatter.py b/docs/bin/plugin_formatter.py index b858046b86..58092bd49e 100755 --- a/docs/bin/plugin_formatter.py +++ b/docs/bin/plugin_formatter.py @@ -1,6 +1,7 @@ #!/usr/bin/env python # (c) 2012, Jan-Piet Mens # (c) 2012-2014, Michael DeHaan and others +# (c) 2017 Ansible Project # # This file is part of Ansible # @@ -17,10 +18,10 @@ # You should have received a copy of the GNU General Public License # along with Ansible. If not, see . -from __future__ import print_function +from __future__ import absolute_import, division, print_function __metaclass__ = type -import cgi + import datetime import glob import optparse @@ -28,9 +29,17 @@ import os import re import sys import warnings -import yaml - from collections import defaultdict +try: + from html import escape as html_escape +except ImportError: + # Python-3.2 or later + import cgi + + def html_escape(text, quote=True): + return cgi.escape(text, quote) + +import yaml from jinja2 import Environment, FileSystemLoader from six import iteritems @@ -38,6 +47,7 @@ from ansible.errors import AnsibleError from ansible.module_utils._text import to_bytes from ansible.utils import plugin_docs + ##################################################################################### # constants and paths @@ -82,7 +92,7 @@ def rst_ify(text): def html_ify(text): ''' convert symbols like I(this is in italics) to valid HTML ''' - t = cgi.escape(text) + t = html_escape(text) t = _ITALIC.sub("" + r"\1" + "", t) t = _BOLD.sub("" + r"\1" + "", t) t = _MODULE.sub("" + r"\1" + "", t) @@ -104,25 +114,48 @@ def rst_xline(width, char="="): return char * width -def write_data(text, options, outputname, module): +def write_data(text, output_dir, outputname, module=None): ''' dumps module output to a file or the screen, as requested ''' - if options.output_dir is not None: - fname = os.path.join(options.output_dir, outputname % module) + if output_dir is not None: + if module: + outputname = outputname % module + fname = os.path.join(output_dir, outputname) fname = fname.replace(".py", "") - f = open(fname, 'wb') - f.write(to_bytes(text)) - f.close() + with open(fname, 'wb') as f: + f.write(to_bytes(text)) else: print(text) -def list_modules(module_dir, depth=0, limit_to_modules=None): - ''' returns a hash of categories, each category being a hash of module names to file paths ''' +def get_module_info(module_dir, limit_to_modules=None, verbose=False): + ''' + Returns information about modules and the categories that they belong to + + :arg module_dir: file system path to the top of the modules directory + :kwarg limit_to_modules: If given, this is a list of module names to + generate information for. All other modules will be ignored. + :returns: Tuple of two dicts containing module_info, categories, and + aliases and a set listing deprecated modules: + + :module_info: mapping of module names to information about them. The fields of the dict are: + + :path: filesystem path to the module + :deprecated: boolean. True means the module is deprecated otherwise not. + :aliases: set of aliases to this module name + :metadata: The modules metadata (as recorded in the module) + :doc: The documentation structure for the module + :examples: The module's examples + :returndocs: The module's returndocs + + :categories: maps category names to a dict. The dict contains at + least one key, '_modules' which contains a list of module names in + that category. Any other keys in the dict are subcategories with + the same structure. + ''' categories = dict() - module_info = dict() - aliases = defaultdict(set) + module_info = defaultdict(dict) # * windows powershell modules have documentation stubs in python docstring # format (they are not executed) so skip the ps1 format files @@ -135,41 +168,72 @@ def list_modules(module_dir, depth=0, limit_to_modules=None): ) for module_path in files: + # Do not list __init__.py files if module_path.endswith('__init__.py'): continue - category = categories - mod_path_only = module_path - # Start at the second directory because we don't want the "vendor" - - mod_path_only = os.path.dirname(module_path[len(module_dir):]) - - # directories (core, extras) - for new_cat in mod_path_only.split('/')[1:]: - if new_cat not in category: - category[new_cat] = dict() - category = category[new_cat] + # Do not list blacklisted modules module = os.path.splitext(os.path.basename(module_path))[0] if module in plugin_docs.BLACKLIST['MODULE']: - # Do not list blacklisted modules - continue - if module.startswith("_") and os.path.islink(module_path): - source = os.path.splitext(os.path.basename(os.path.realpath(module_path)))[0] - module = module.replace("_", "", 1) - aliases[source].add(module) continue # If requested, limit module documentation building only to passed-in # modules. - if limit_to_modules is None or module.lower() in limit_to_modules: - category[module] = module_path - module_info[module] = module_path + if limit_to_modules is not None and module.lower() in limit_to_modules: + continue + + deprecated = False + if module.startswith("_"): + if os.path.islink(module_path): + # Handle aliases + source = os.path.splitext(os.path.basename(os.path.realpath(module_path)))[0] + module = module.replace("_", "", 1) + aliases = module_info[source].get('aliases', set()) + aliases.add(module) + # In case we just created this via get()'s fallback + module_info[source]['aliases'] = aliases + continue + else: + # Handle deprecations + module = module.replace("_", "", 1) + deprecated = True + + # + # Regular module to process + # + + category = categories + + # Start at the second directory because we don't want the "vendor" + mod_path_only = os.path.dirname(module_path[len(module_dir):]) + + # build up the categories that this module belongs to + for new_cat in mod_path_only.split('/')[1:]: + if new_cat not in category: + category[new_cat] = dict() + category[new_cat]['_modules'] = [] + category = category[new_cat] + + category['_modules'].append(module) + + # use ansible core library to parse out doc metadata YAML and plaintext examples + doc, examples, returndocs, metadata = plugin_docs.get_docstring(module_path, verbose=verbose) + + # save all the information + module_info[module] = {'path': module_path, + 'deprecated': deprecated, + 'aliases': set(), + 'metadata': metadata, + 'doc': doc, + 'examples': examples, + 'returndocs': returndocs, + } # keep module tests out of becoming module docs if 'test' in categories: del categories['test'] - return module_info, categories, aliases + return module_info, categories def generate_parser(): @@ -202,17 +266,21 @@ def jinja2_environment(template_dir, typ): trim_blocks=True) env.globals['xline'] = rst_xline + templates = {} if typ == 'rst': env.filters['convert_symbols_to_format'] = rst_ify env.filters['html_ify'] = html_ify env.filters['fmt'] = rst_fmt env.filters['xline'] = rst_xline - template = env.get_template('plugin.rst.j2') + templates['plugin'] = env.get_template('plugin.rst.j2') + templates['category_list'] = env.get_template('modules_by_category.rst.j2') + templates['support_list'] = env.get_template('modules_by_support.rst.j2') + templates['list_of_CATEGORY_modules'] = env.get_template('list_of_CATEGORY_modules.rst.j2') outputname = "%s_module.rst" else: raise Exception("unknown module format type: %s" % typ) - return env, template, outputname + return templates, outputname def too_old(added): @@ -225,196 +293,156 @@ def too_old(added): except ValueError as e: warnings.warn("Could not parse %s: %s" % (added, str(e))) return False - return (added_float < TO_OLD_TO_BE_NOTABLE) + return added_float < TO_OLD_TO_BE_NOTABLE -def process_module(module, options, env, template, outputname, module_map, aliases): +def process_modules(module_map, templates, outputname, output_dir, ansible_version): + for module in module_map: + print("rendering: %s" % module) - fname = module_map[module] - if isinstance(fname, dict): - return "SKIPPED" + fname = module_map[module]['path'] - basename = os.path.basename(fname) - deprecated = False + # crash if module is missing documentation and not explicitly hidden from docs index + if module_map[module]['doc'] is None: + sys.exit("*** ERROR: MODULE MISSING DOCUMENTATION: %s, %s ***\n" % (fname, module)) - # ignore files with extensions - if not basename.endswith(".py"): - return - elif module.startswith("_"): - if os.path.islink(fname): - return # ignore, its an alias - deprecated = True - module = module.replace("_", "", 1) + # Going to reference this heavily so make a short name to reference it by + doc = module_map[module]['doc'] - print("rendering: %s" % module) + if module_map[module]['deprecated'] and 'deprecated' not in doc: + sys.exit("*** ERROR: DEPRECATED MODULE MISSING 'deprecated' DOCUMENTATION: %s, %s ***\n" % (fname, module)) - # use ansible core library to parse out doc metadata YAML and plaintext examples - doc, examples, returndocs, metadata = plugin_docs.get_docstring(fname, verbose=options.verbose) + if 'version_added' not in doc: + sys.exit("*** ERROR: missing version_added in: %s ***\n" % module) - # crash if module is missing documentation and not explicitly hidden from docs index - if doc is None: - sys.exit("*** ERROR: MODULE MISSING DOCUMENTATION: %s, %s ***\n" % (fname, module)) + # + # The present template gets everything from doc so we spend most of this + # function moving data into doc for the template to reference + # - if deprecated and 'deprecated' not in doc: - sys.exit("*** ERROR: DEPRECATED MODULE MISSING 'deprecated' DOCUMENTATION: %s, %s ***\n" % (fname, module)) + if module_map[module]['aliases']: + doc['aliases'] = module_map[module]['aliases'] - if module in aliases: - doc['aliases'] = aliases[module] - - all_keys = [] - - if 'version_added' not in doc: - sys.exit("*** ERROR: missing version_added in: %s ***\n" % module) - - added = 0 - if doc['version_added'] == 'historical': - del doc['version_added'] - else: - added = doc['version_added'] - - # don't show version added information if it's too old to be called out - if too_old(added): - del doc['version_added'] - - if 'options' in doc and doc['options']: - for (k, v) in iteritems(doc['options']): - # don't show version added information if it's too old to be called out - if 'version_added' in doc['options'][k] and too_old(doc['options'][k]['version_added']): - del doc['options'][k]['version_added'] - if 'description' not in doc['options'][k]: - raise AnsibleError("Missing required description for option %s in %s " % (k, module)) - - required_value = doc['options'][k].get('required', False) - if not isinstance(required_value, bool): - raise AnsibleError("Invalid required value '%s' for option '%s' in '%s' (must be truthy)" % (required_value, k, module)) - if not isinstance(doc['options'][k]['description'], list): - doc['options'][k]['description'] = [doc['options'][k]['description']] - - all_keys.append(k) - - all_keys = sorted(all_keys) - - doc['option_keys'] = all_keys - doc['filename'] = fname - doc['docuri'] = doc['module'].replace('_', '-') - doc['now_date'] = datetime.date.today().strftime('%Y-%m-%d') - doc['ansible_version'] = options.ansible_version - doc['plainexamples'] = examples # plain text - doc['metadata'] = metadata - - if returndocs: - try: - doc['returndocs'] = yaml.safe_load(returndocs) - except: - print("could not load yaml: %s" % returndocs) - raise - else: - doc['returndocs'] = None - - # here is where we build the table of contents... - - try: - text = template.render(doc) - except Exception as e: - raise AnsibleError("Failed to render doc for %s: %s" % (fname, str(e))) - write_data(text, options, outputname, module) - return doc['short_description'] - - -def print_modules(module, category_file, deprecated, options, env, template, outputname, module_map, aliases): - modstring = module - if modstring.startswith('_'): - modstring = module[1:] - modname = modstring - if module in deprecated: - modstring = to_bytes(modstring) + DEPRECATED - - category_file.write(b" %s - %s <%s_module>\n" % (to_bytes(modstring), to_bytes(rst_ify(module_map[module][1])), to_bytes(modname))) - - -def process_category(category, categories, options, env, template, outputname): - - # FIXME: - # We no longer conceptually deal with a mapping of category names to - # modules to file paths. Instead we want several different records: - # (1) Mapping of module names to file paths (what's presently used - # as categories['all'] - # (2) Mapping of category names to lists of module names (what you'd - # presently get from categories[category_name][subcategory_name].keys() - # (3) aliases (what's presently in categories['_aliases'] - # - # list_modules() now returns those. Need to refactor this function and - # main to work with them. - - module_map = categories[category] - module_info = categories['all'] - - aliases = {} - if '_aliases' in categories: - aliases = categories['_aliases'] - - category_file_path = os.path.join(options.output_dir, "list_of_%s_modules.rst" % category) - category_file = open(category_file_path, "wb") - print("*** recording category %s in %s ***" % (category, category_file_path)) - - # start a new category file - - category = category.replace("_", " ") - category = category.title() - - modules = [] - deprecated = [] - for module in module_map.keys(): - if isinstance(module_map[module], dict): - for mod in (m for m in module_map[module].keys() if m in module_info): - if mod.startswith("_"): - deprecated.append(mod) + # don't show version added information if it's too old to be called out + added = 0 + if doc['version_added'] == 'historical': + del doc['version_added'] else: - if module not in module_info: - continue - if module.startswith("_"): - deprecated.append(module) - modules.append(module) + added = doc['version_added'] - modules.sort(key=lambda k: k[1:] if k.startswith('_') else k) + # Strip old version_added for the module + if too_old(added): + del doc['version_added'] - category_header = b"%s Modules" % (to_bytes(category.title())) - underscores = b"`" * len(category_header) + option_names = [] - category_file.write(b"""\ -%s -%s + if 'options' in doc and doc['options']: + for (k, v) in iteritems(doc['options']): + # Error out if there's no description + if 'description' not in doc['options'][k]: + raise AnsibleError("Missing required description for option %s in %s " % (k, module)) -.. toctree:: :maxdepth: 1 + # Error out if required isn't a boolean (people have been putting + # information on when something is required in here. Those need + # to go in the description instead). + required_value = doc['options'][k].get('required', False) + if not isinstance(required_value, bool): + raise AnsibleError("Invalid required value '%s' for option '%s' in '%s' (must be truthy)" % (required_value, k, module)) -""" % (category_header, underscores)) - sections = [] - for module in modules: - if module in module_map and isinstance(module_map[module], dict): - sections.append(module) - continue + # Strip old version_added information for options + if 'version_added' in doc['options'][k] and too_old(doc['options'][k]['version_added']): + del doc['options'][k]['version_added'] + + # Make sure description is a list of lines for later formatting + if not isinstance(doc['options'][k]['description'], list): + doc['options'][k]['description'] = [doc['options'][k]['description']] + + option_names.append(k) + + option_names.sort() + + doc['option_keys'] = option_names + doc['filename'] = fname + doc['docuri'] = doc['module'].replace('_', '-') + doc['now_date'] = datetime.date.today().strftime('%Y-%m-%d') + doc['ansible_version'] = ansible_version + doc['plainexamples'] = module_map[module]['examples'] # plain text + doc['metadata'] = module_map[module]['metadata'] + + if module_map[module]['returndocs']: + try: + doc['returndocs'] = yaml.safe_load(module_map[module]['returndocs']) + except: + print("could not load yaml: %s" % module_map[module]['returndocs']) + raise else: - print_modules(module, category_file, deprecated, options, env, template, outputname, module_info, aliases) + doc['returndocs'] = None - sections.sort() - for section in sections: - category_file.write(b"\n%s\n%s\n\n" % (to_bytes(section.replace("_", " ").title()), b'-' * len(section))) - category_file.write(b".. toctree:: :maxdepth: 1\n\n") + text = templates['plugin'].render(doc) - section_modules = list(module_map[section].keys()) - section_modules.sort(key=lambda k: k[1:] if k.startswith('_') else k) - # for module in module_map[section]: - for module in (m for m in section_modules if m in module_info): - print_modules(module, category_file, deprecated, options, env, template, outputname, module_info, aliases) + write_data(text, output_dir, outputname, module) - category_file.write(b"""\n\n -.. note:: - - %s: This marks a module as deprecated, which means a module is kept for backwards compatibility but usage is discouraged. - The module documentation details page may explain more about this rationale. -""" % DEPRECATED) - category_file.close() - # TODO: end a new category file +def process_categories(mod_info, categories, templates, output_dir, output_name): + for category in sorted(categories.keys()): + module_map = categories[category] + category_filename = output_name % category + + print("*** recording category %s in %s ***" % (category, category_filename)) + + # start a new category file + + category = category.replace("_", " ") + category = category.title() + + subcategories = dict((k, v) for k, v in module_map.items() if k != '_modules') + template_data = {'title': category, + 'category': module_map, + 'subcategories': subcategories, + 'module_info': mod_info, + } + + text = templates['list_of_CATEGORY_modules'].render(template_data) + write_data(text, output_dir, category_filename) + + +def process_support_levels(mod_info, templates, output_dir): + supported_by = {'Ansible Core Team': {'slug': 'core_supported', + 'modules': [], + 'output': 'core_maintained.rst'}, + 'Ansible Network Team': {'slug': 'network_supported', + 'modules': [], + 'output': 'network_maintained.rst'}, + 'Ansible Partners': {'slug': 'partner_supported', + 'modules': [], + 'output': 'partner_maintained.rst'}, + 'Ansible Community': {'slug': 'community_supported', + 'modules': [], + 'output': 'community_maintained.rst'}, + } + + # Separate the modules by support_level + for module, info in mod_info.items(): + if info['metadata']['supported_by'] == 'core': + supported_by['Ansible Core Team']['modules'].append(module) + elif info['metadata']['supported_by'] == 'network': + supported_by['Ansible Network Team']['modules'].append(module) + elif info['metadata']['supported_by'] == 'certified': + supported_by['Ansible Partners']['modules'].append(module) + elif info['metadata']['supported_by'] == 'community': + supported_by['Ansible Community']['modules'].append(module) + else: + raise AnsibleError('Unknown supported_by value: %s' % info['metadata']['supported_by']) + + # Render the module lists + for maintainers, data in supported_by.items(): + template_data = {'maintainers': maintainers, + 'modules': data['modules'], + 'slug': data['slug'], + 'module_info': mod_info, + } + text = templates['support_list'].render(template_data) + write_data(text, output_dir, data['output']) def validate_options(options): @@ -435,43 +463,34 @@ def main(): (options, args) = p.parse_args() validate_options(options) - env, template, outputname = jinja2_environment(options.template_dir, options.type) + templates, outputname = jinja2_environment(options.template_dir, options.type) # Convert passed-in limit_to_modules to None or list of modules. if options.limit_to_modules is not None: options.limit_to_modules = [s.lower() for s in options.limit_to_modules.split(",")] - mod_info, categories, aliases = list_modules(options.module_dir, limit_to_modules=options.limit_to_modules) - categories['all'] = mod_info - categories['_aliases'] = aliases - category_names = [c for c in categories.keys() if not c.startswith('_')] - category_names.sort() + mod_info, categories = get_module_info(options.module_dir, limit_to_modules=options.limit_to_modules, + verbose=options.verbose) + + categories['all'] = {'_modules': mod_info.keys()} + + # Transform the data + if options.type == 'rst': + for record in mod_info.values(): + record['doc']['short_description'] = rst_ify(record['doc']['short_description']) # Write master category list - category_list_path = os.path.join(options.output_dir, "modules_by_category.rst") - with open(category_list_path, "wb") as category_list_file: - category_list_file.write(b"Module Index\n") - category_list_file.write(b"============\n") - category_list_file.write(b"\n\n") - category_list_file.write(b".. toctree::\n") - category_list_file.write(b" :maxdepth: 1\n\n") + category_list_text = templates['category_list'].render(categories=sorted(categories.keys())) + write_data(category_list_text, options.output_dir, 'modules_by_category.rst') - for category in category_names: - category_list_file.write(b" list_of_%s_modules\n" % to_bytes(category)) + # Render all the individual module pages + process_modules(mod_info, templates, outputname, options.output_dir, options.ansible_version) - # Import all the docs into memory - module_map = mod_info.copy() + # Render all the categories for modules + process_categories(mod_info, categories, templates, options.output_dir, "list_of_%s_modules.rst") - for modname in module_map: - result = process_module(modname, options, env, template, outputname, module_map, aliases) - if result == 'SKIPPED': - del categories['all'][modname] - else: - categories['all'][modname] = (categories['all'][modname], result) - - # Render all the docs to rst via category pages - for category in category_names: - process_category(category, categories, options, env, template, outputname) + # Render all the categories for modules + process_support_levels(mod_info, templates, options.output_dir) if __name__ == '__main__': diff --git a/docs/docsite/rst/modules_support.rst b/docs/docsite/rst/modules_support.rst index 951098edd7..71f71a7ead 100644 --- a/docs/docsite/rst/modules_support.rst +++ b/docs/docsite/rst/modules_support.rst @@ -21,12 +21,18 @@ Core These are modules that the Ansible Core Team maintains and will always ship with Ansible itself. They will also receive slightly higher priority for all requests. Non-core modules are still fully usable. +.. seealso:: + List of :ref:`Core Supported` modules + Network ``````` These modules are supported by the Ansible Network Team in a relationship similar to how the Ansible Core Team maintains the Core modules. +.. seealso:: + List of :ref:`Network Supported` modules + Certified ````````` @@ -37,6 +43,9 @@ Also, it is strongly recommended (but not presently required) for these types of These modules are currently shipped with Ansible, but might be shipped separately in the future. +.. seealso:: + List of :ref:`Certified` modules + Community ````````` These modules **are not** supported by Core Committers or by companies/partners associated to the module. They are maintained by the community. @@ -45,6 +54,10 @@ They are still fully usable, but the response rate to issues is purely up to the These modules are currently shipped with Ansible, but will most likely be shipped separately in the future. +.. seealso:: + List of Core Supported modules + List of :ref:`Community Supported` modules + .. seealso:: diff --git a/docs/templates/list_of_CATEGORY_modules.rst.j2 b/docs/templates/list_of_CATEGORY_modules.rst.j2 new file mode 100644 index 0000000000..d54564a057 --- /dev/null +++ b/docs/templates/list_of_CATEGORY_modules.rst.j2 @@ -0,0 +1,27 @@ +@{ title }@ Modules +@{ '`' * title | length }@```````` + +.. toctree:: :maxdepth: 1 +{% if category['_modules'] %} + +{% for module in category['_modules'] | sort %} + @{ module }@{% if module_info[module]['deprecated'] %} **(D)**{% endif%} - @{ module_info[module]['doc']['short_description'] }@ <@{ module }@_module> +{% endfor %} +{% endif %} + +{% for name, info in subcategories.items() | sort %} +@{ name.title() }@ +@{ '-' * name | length }@ + +.. toctree:: :maxdepth: 1 + +{% for module in info['_modules'] | sort %} + @{ module }@{% if module_info[module]['deprecated'] %}**(D)**{% endif%} - @{ module_info[module]['doc']['short_description'] }@ <@{ module }@_module> +{% endfor %} + +{% endfor %} + +.. note:: + - **(D)**: This marks a module as deprecated, which means a module is kept for backwards compatibility but usage is discouraged. + The module documentation details page may explain more about this rationale. + diff --git a/docs/templates/modules_by_category.rst.j2 b/docs/templates/modules_by_category.rst.j2 new file mode 100644 index 0000000000..4034ec36a1 --- /dev/null +++ b/docs/templates/modules_by_category.rst.j2 @@ -0,0 +1,9 @@ +Module Index +============ + + +.. toctree:: :maxdepth: 1 + +{% for name in categories %} + list_of_@{ name }@_modules +{% endfor %} diff --git a/docs/templates/modules_by_support.rst.j2 b/docs/templates/modules_by_support.rst.j2 new file mode 100644 index 0000000000..2a155b0fe1 --- /dev/null +++ b/docs/templates/modules_by_support.rst.j2 @@ -0,0 +1,15 @@ +.. _@{ slug }@: + +Modules Maintained by the @{ maintainers }@ +``````````````````````````@{ '`' * maintainers | length }@ + +.. toctree:: :maxdepth: 1 + +{% for module in modules | sort %} + @{ module }@{% if module_info[module]['deprecated'] %}**(D)**{% endif %} - @{ module_info[module]['doc']['short_description'] }@ <@{ module }@_module> +{% endfor %} + +.. note:: + - **(D)**: This marks a module as deprecated, which means a module is kept for backwards compatibility but usage is discouraged. + The module documentation details page may explain more about this rationale. +