diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cf7ba677..fd44b307 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,4 +33,4 @@ repos: language: system entry: mypy types: [python] - exclude: ^docs/ + exclude: ^(docs|test/roots)/ diff --git a/docs/changelog.rst b/docs/changelog.rst index 06c1971f..74f758c1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,34 @@ Change log ********** +0.5.0 +##### + +The following enhancements to the HTML output are described on the [Usage](https://sphinx-argparse.readthedocs.io/en/latest/usage.html) page. + +* Optional command index. +* Optional ``:index-groups:`` field to the directive for an command-by-group index. +* A ``full_subcommand_name`` option to print fully-qualified sub-command headings. + This option helps when more than one sub-command offers a ``create`` or ``list`` or other + repeated sub-command. +* Each command heading is a domain-specific link target. + You can link to commands and sub-commands with the ``:ref:`` role, but this + release adds support for the domain-specific role like + ``:commands:command:`sample-directive-opts A` ``. + The ``:commands:command:`` role supports linking from other projects through the + intersphinx extension. + +Changes + +* Previously, common headings such as **Positional Arguments** were subject to a + process that made them unique but adding a ``_repeatX`` suffix to the HREF target. + This release continues to support those HREF targets as secondary targets so that + bookmarks continue to work. + However, this release prefers using fully-qualified HREF targets like + ``sample-directive-opts-positional-arguments`` as the primary HREF so that customers + are less likely to witness the ``_repeatX`` link in URLs. + + 0.4.0 ##### diff --git a/docs/conf.py b/docs/conf.py index ef468cd0..d6032ee4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -2,6 +2,7 @@ extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.coverage', + 'sphinx.ext.intersphinx', 'sphinx.ext.viewcode', 'sphinxarg.ext', ] diff --git a/docs/usage.rst b/docs/usage.rst index c109a7a3..43335ddd 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -1,3 +1,4 @@ +=========== Basic usage =========== @@ -67,6 +68,11 @@ working dir. That's it. Directives will render positional arguments, options and sub-commands. +.. _about-subcommands: + +About Sub-Commands +================== + Sub-commands are limited to one level. But, you can always output help for subcommands separately:: .. argparse:: @@ -87,7 +93,7 @@ Nesting level is unlimited:: Other useful directives ------------------------ +======================= :nodefault: Do not show any default values. @@ -100,3 +106,122 @@ Other useful directives :nodescription: Do not parse the description, which can be useful if it contains text that could be incorrectly parse as reStructuredText. :passparser: This can be used if you don't have a function that returns an argument parser, but rather adds commands to it (`:func:` is then that function). + +:index-groups: This option is related to grouping related commands in an index. + + +Printing Fully Qualified Sub-Command Headings +============================================= + +By default, when a command has sub-commands, such as ``fancytool install`` shown in the +:ref:`about-subcommands` section, the heading for the sub-command does not include the command name. +For instance, the the heading is **install** rather than **fancytool install**. + +If you prefer to show the full command, **fancytool install**, then you can enable +the option in the ``conf.py`` for your project: + +.. code-block:: python + + sphinx_argparse_conf = { + "full_subcommand_name": True, + } + + +Indices +======= + +The extension supports two types of optional indices. +The first type of index is a simple index that provides a list of all the commands in the project by fully qualified name and a link to each command. +The second type of index enables you to group related commands into groups and then provide a list of the commands and a link to each command. +By default, no index is created. + +Simple Command Index +-------------------- + +To enable the simple command index, add the following to the project ``conf.py`` file: + +.. code-block:: python + + sphinx_argparse_conf = { + "build_commands_index": True, + "commands_index_in_toctree": True, + } + +The first option, ``build_commands_index``, instructs the extension to create the index. +For an HTML build, the index is created with the file name ``commands-index.html`` in the output directory. +You can reference the index from other files with the ``:ref:`commands-index``` markup. + +The second option, ``commands_index_in_toctree``, enables you to reference the the index in a ``toctree`` directive. +By default, you cannot reference indices generated by extensions in a ``toctree``. +When you enable this option, the extension creates a temporary file that is named ``commands-index.rst`` in the source directory of your project. +Sphinx locates the temporary file and that makes it possible to reference the file in the ``toctree``. +When the Sphinx build finishes, the extension removes the temporary file from the source directory. + +Commands by Group Index +----------------------- + +To enable the more complex index, add the following to the project ``conf.py`` file: + +.. code-block:: python + + sphinx_argparse_conf = { + "build_commands_by_group_index": True, + "commands_by_group_index_in_toctree": True, + } + +Add the ``:index-groups:`` option to the ``argparse`` directive in your documentation files. +Specify one or more groups that the command belongs to (comma-separated). + +.. code-block:: reStructuredText + + .. argparse:: + :filename: ../test/sample.py + :func: parser + :prog: sample + :index-groups: Basic Commands + +For an HTML build, the index is created with the file name ``commands-by-group.html`` in the output directory. +You can cross reference the index from other files with the ``:ref:`commands-by-group``` role. + +Like the simple index, the ``commands_by_group_index_in_toctree`` option enables you to reference the index in ``toctree`` directives. + +This index has two more options. + +.. code-block:: python + + sphinx_argparse_conf = { + "commands_by_group_index_in_toctree": True, + "commands_by_group_index_file_suffix": "by-service", + "commands_by_group_index_title": "Commands by Service", + } + +The ``commands_by_group_index_file_suffix`` option overrides the default index name of ``commands-by-group.html``. +The value ``commands-`` is concatenated with the value you specify. +In the preceding sample, the index file name is created as ``commands-by-service.html``. +If you specify this option, the default reference of ``:ref:`commands-by-group``` is overridden with the value that you create. + +The ``commands_by_group_index_title`` option overides the default first-level heading for the file. +The default heading is "Commands by Group". +The value you specify replaces the default value. + +Customizing the Indices +----------------------- + +By default, indices are created with the ``domainindex.html`` template. +If you want to customize the appearance of an index, copy the default ``domainindex.html`` file for your theme to the ``_templates`` directory in your project and modify it. + +If you want to customize both indices, but one template cannot accommodate both of them, you can create an additional index template, such as ``customindex.html``. +You can configure Sphinx to use the additional template for an index by modifying the ``conf.py`` for the project like the following example. + +.. code-block:: python + + def page_template(app: "Sphinx", pagename, templatename, context, doctree) -> str: + if pagename == "commands-by-group": + return "customindex.html" + else: + return templatename + + def setup(app: "Sphinx"): + app.connect('html-page-context', page_template) + +For more information, refer to the Sphinx documentation for :ref:`sphinx:templating` and the :doc:`sphinx:extdev/appapi`. diff --git a/pyproject.toml b/pyproject.toml index 9d3ff1a5..5580fe27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,10 +37,10 @@ classifiers = [ ] dependencies = [ "sphinx>=4.0.0", + "CommonMark>=0.9.1", ] dynamic = ["version"] - [project.optional-dependencies] markdown = [ "CommonMark>=0.5.6" @@ -98,6 +98,7 @@ multi_line_output = 3 [tool.mypy] files = ["sphinxarg", "test"] python_version = "3.10" +exclude = '''(?x)( ^test/roots | ^docs )''' [[tool.mypy.overrides]] module = [ diff --git a/sphinxarg/ext.py b/sphinxarg/ext.py index 1f0004c6..a471e9f9 100644 --- a/sphinxarg/ext.py +++ b/sphinxarg/ext.py @@ -1,22 +1,43 @@ from __future__ import annotations import importlib +import operator import os import shutil import sys from argparse import ArgumentParser +from typing import TYPE_CHECKING, cast from docutils import nodes from docutils.frontend import get_default_settings -from docutils.parsers.rst import Directive, Parser +from docutils.parsers.rst import Parser from docutils.parsers.rst.directives import flag, unchanged from docutils.statemachine import StringList +from sphinx.domains import Domain, Index, IndexEntry +from sphinx.errors import ExtensionError from sphinx.ext.autodoc import mock -from sphinx.util.docutils import new_document -from sphinx.util.nodes import nested_parse_with_titles +from sphinx.roles import XRefRole +from sphinx.util import logging +from sphinx.util.docutils import SphinxDirective, new_document +from sphinx.util.nodes import make_id, make_refnode, nested_parse_with_titles from sphinxarg import __version__ from sphinxarg.parser import parse_parser, parser_navigate +from sphinxarg.utils import command_pos_args, target_to_anchor_id + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + from pathlib import Path + + from docutils.nodes import Element + from sphinx.addnodes import pending_xref + from sphinx.application import Sphinx + from sphinx.builders import Builder + from sphinx.environment import BuildEnvironment + + _ObjectDescriptionTuple = tuple[str, str, str, str, str, int] + +logger = logging.getLogger(__name__) def map_nested_definitions(nested_content): @@ -87,173 +108,6 @@ def render_list(l, markdown_help, settings=None): return all_children -def _is_suppressed(item): - """Return whether item should not be printed.""" - if item is None: - return True - item = str(item).replace('"', '').replace("'", '') - return item == '==SUPPRESS==' - - -def print_action_groups( - data, - nested_content, - markdown_help=False, - settings=None, - id_prefix='', -): - """ - Process all 'action groups', which are also include 'Options' and 'Required - arguments'. A list of nodes is returned. - """ - definitions = map_nested_definitions(nested_content) - nodes_list = [] - if 'action_groups' in data: - for action_group in data['action_groups']: - # Every action group is composed of a section, holding - # a title, the description, and the option group (members) - title_as_id = action_group['title'].replace(' ', '-').lower() - section = nodes.section(ids=[f'{id_prefix}-{title_as_id}']) - section += nodes.title(action_group['title'], action_group['title']) - - desc = [] - if action_group['description']: - desc.append(action_group['description']) - # Replace/append/prepend content to the description according to nested content - subcontent = [] - if action_group['title'] in definitions: - classifier, s, subcontent = definitions[action_group['title']] - if classifier == '@replace': - desc = [s] - elif classifier == '@after': - desc.append(s) - elif classifier == '@before': - desc.insert(0, s) - elif classifier == '@skip': - continue - if len(subcontent) > 0: - for k, v in map_nested_definitions(subcontent).items(): - definitions[k] = v - # Render appropriately - for element in render_list(desc, markdown_help): - section += element - - local_definitions = definitions - if len(subcontent) > 0: - local_definitions = dict(definitions.items()) - for k, v in map_nested_definitions(subcontent).items(): - local_definitions[k] = v - - items = [] - # Iterate over action group members - for entry in action_group['options']: - # Members will include: - # default The default value. This may be ==SUPPRESS== - # name A list of option names (e.g., ['-h', '--help'] - # help The help message string - # There may also be a 'choices' member. - # Build the help text - arg = [] - if 'choices' in entry: - arg.append( - f"Possible choices: {', '.join(str(c) for c in entry['choices'])}\n" - ) - if 'help' in entry: - arg.append(entry['help']) - if not _is_suppressed(entry['default']): - # Put the default value in a literal block, - # but escape backticks already in the string - default_str = str(entry['default']).replace('`', r'\`') - arg.append(f'Default: ``{default_str}``') - - # Handle nested content, the term used in the dict - # has the comma removed for simplicity - desc = arg - term = ' '.join(entry['name']) - if term in local_definitions: - classifier, s, subcontent = local_definitions[term] - if classifier == '@replace': - desc = [s] - elif classifier == '@after': - desc.append(s) - elif classifier == '@before': - desc.insert(0, s) - term = ', '.join(entry['name']) - - n = nodes.option_list_item( - '', - nodes.option_group('', nodes.option_string(text=term)), - nodes.description('', *render_list(desc, markdown_help, settings)), - ) - items.append(n) - - section += nodes.option_list('', *items) - nodes_list.append(section) - - return nodes_list - - -def print_subcommands(data, nested_content, markdown_help=False, settings=None): # noqa: N803 - """ - Each subcommand is a dictionary with the following keys: - - ['usage', 'action_groups', 'bare_usage', 'name', 'help'] - - In essence, this is all tossed in a new section with the title 'name'. - Apparently there can also be a 'description' entry. - """ - - definitions = map_nested_definitions(nested_content) - items = [] - if 'children' in data: - subcommands = nodes.section(ids=['Sub-commands']) - subcommands += nodes.title('Sub-commands', 'Sub-commands') - - for child in data['children']: - sec = nodes.section(ids=[child['name']]) - sec += nodes.title(child['name'], child['name']) - - if 'description' in child and child['description']: - desc = [child['description']] - elif child['help']: - desc = [child['help']] - else: - desc = ['Undocumented'] - - # Handle nested content - subcontent = [] - if child['name'] in definitions: - classifier, s, subcontent = definitions[child['name']] - if classifier == '@replace': - desc = [s] - elif classifier == '@after': - desc.append(s) - elif classifier == '@before': - desc.insert(0, s) - - for element in render_list(desc, markdown_help): - sec += element - sec += nodes.literal_block(text=child['bare_usage']) - for x in print_action_groups( - child, nested_content + subcontent, markdown_help, settings=settings - ): - sec += x - - for x in print_subcommands( - child, nested_content + subcontent, markdown_help, settings=settings - ): - sec += x - - if 'epilog' in child and child['epilog']: - for element in render_list([child['epilog']], markdown_help): - sec += element - - subcommands += sec - items.append(subcommands) - - return items - - def ensure_unique_ids(items): """ If action groups are repeated, then links in the table of contents will @@ -279,8 +133,9 @@ def ensure_unique_ids(items): n['ids'] = ids -class ArgParseDirective(Directive): +class ArgParseDirective(SphinxDirective): has_content = True + required_arguments = 0 option_spec = { 'module': unchanged, 'func': unchanged, @@ -297,7 +152,9 @@ class ArgParseDirective(Directive): 'nodescription': unchanged, 'markdown': flag, 'markdownhelp': flag, + 'index-groups': unchanged, } + index_groups: Sequence[str] = () def _construct_manpage_specific_structure(self, parser_info): """ @@ -420,7 +277,7 @@ def _format_optional_arguments(self, parser_info): opt_items = [] for name in opt['name']: option_declaration = [nodes.option_string(text=name)] - if not _is_suppressed(opt['default']): + if not self._is_suppressed(opt['default']): option_declaration += nodes.option_argument( '', text='=' + str(opt['default']) ) @@ -484,6 +341,208 @@ def _open_filename(self): # raise exception raise FileNotFoundError(self.options['filename']) + def _print_subcommands(self, data, nested_content, markdown_help=False, settings=None): + """ + Each subcommand is a dictionary with the following keys: + + ['usage', 'action_groups', 'bare_usage', 'name', 'help'] + + In essence, this is all tossed in a new section with the title 'name'. + Apparently there can also be a 'description' entry. + """ + + definitions = map_nested_definitions(nested_content) + items = [] + full_subcommand_name_true = ( + ('full_subcommand_name', True) in self.config.sphinx_argparse_conf.items() + ) + domain = cast(ArgParseDomain, self.env.domains[ArgParseDomain.name]) + + if 'children' in data: + full_command = command_pos_args(data) + node_id = make_id( + self.env, self.state.document, '', full_command + '-sub-commands' + ) + target = nodes.target('', '', ids=[node_id]) + self.set_source_info(target) + self.state.document.note_explicit_target(target) + + subcommands = nodes.section(ids=['Sub-commands']) + subcommands += nodes.title('Sub-commands', 'Sub-commands') + + for child in data['children']: + full_command = command_pos_args(child) + node_id = make_id(self.env, self.state.document, '', full_command) + target = nodes.target('', '', ids=[node_id]) + self.set_source_info(target) + self.state.document.note_explicit_target(target) + + sec = nodes.section(ids=[node_id, child['name']]) + if full_subcommand_name_true: + title = nodes.title(full_command, full_command) + else: + title = nodes.title(child['name'], child['name']) + sec += title + + domain.add_argparse_command(child, node_id, self.index_groups) + + if 'description' in child and child['description']: + desc = [child['description']] + elif child['help']: + desc = [child['help']] + else: + desc = ['Undocumented'] + + # Handle nested content + subcontent = [] + if child['name'] in definitions: + classifier, s, subcontent = definitions[child['name']] + if classifier == '@replace': + desc = [s] + elif classifier == '@after': + desc.append(s) + elif classifier == '@before': + desc.insert(0, s) + + for element in render_list(desc, markdown_help): + sec += element + sec += nodes.literal_block(text=child['bare_usage']) + for x in self._print_action_groups( + child, nested_content + subcontent, markdown_help, settings=settings + ): + sec += x + + for x in self._print_subcommands( + child, nested_content + subcontent, markdown_help, settings=settings + ): + sec += x + + if 'epilog' in child and child['epilog']: + for element in render_list([child['epilog']], markdown_help): + sec += element + + subcommands += sec + items.append(subcommands) + + return items + + def _print_action_groups( + self, + data, + nested_content, + markdown_help=False, + settings=None, + id_prefix='', + ): + """ + Process all 'action groups', which are also include 'Options' and 'Required + arguments'. A list of nodes is returned. + """ + definitions = map_nested_definitions(nested_content) + nodes_list = [] + if 'action_groups' in data: + for action_group in data['action_groups']: + # Every action group is composed of a section, holding + # a title, the description, and the option group (members) + title_as_id = action_group['title'].replace(' ', '-').lower() + full_command = command_pos_args(data) + node_id = make_id( + self.env, + self.state.document, + '', + full_command + '-' + title_as_id, + ) + target = nodes.target('', '', ids=[node_id]) + self.set_source_info(target) + self.state.document.note_explicit_target(target) + + section = nodes.section(ids=[node_id, f'{id_prefix}-{title_as_id}']) + section += nodes.title(action_group['title'], action_group['title']) + + desc = [] + if action_group['description']: + desc.append(action_group['description']) + # Replace/append/prepend content to the description according to nested content + subcontent = [] + if action_group['title'] in definitions: + classifier, s, subcontent = definitions[action_group['title']] + if classifier == '@replace': + desc = [s] + elif classifier == '@after': + desc.append(s) + elif classifier == '@before': + desc.insert(0, s) + elif classifier == '@skip': + continue + if len(subcontent) > 0: + for k, v in map_nested_definitions(subcontent).items(): + definitions[k] = v + # Render appropriately + for element in render_list(desc, markdown_help): + section += element + + local_definitions = definitions + if len(subcontent) > 0: + local_definitions = dict(definitions.items()) + for k, v in map_nested_definitions(subcontent).items(): + local_definitions[k] = v + + items = [] + # Iterate over action group members + for entry in action_group['options']: + # Members will include: + # default The default value. This may be ==SUPPRESS== + # name A list of option names (e.g., ['-h', '--help'] + # help The help message string + # There may also be a 'choices' member. + # Build the help text + arg = [] + if 'choices' in entry: + arg.append( + f"Possible choices: {', '.join(map(str, entry['choices']))}\n" + ) + if 'help' in entry: + arg.append(entry['help']) + if not self._is_suppressed(entry['default']): + # Put the default value in a literal block, + # but escape backticks already in the string + default_str = str(entry['default']).replace('`', r'\`') + arg.append(f'Default: ``{default_str}``') + + # Handle nested content, the term used in the dict + # has the comma removed for simplicity + desc = arg + term = ' '.join(entry['name']) + if term in local_definitions: + classifier, s, subcontent = local_definitions[term] + if classifier == '@replace': + desc = [s] + elif classifier == '@after': + desc.append(s) + elif classifier == '@before': + desc.insert(0, s) + term = ', '.join(entry['name']) + + n = nodes.option_list_item( + '', + nodes.option_group('', nodes.option_string(text=term)), + nodes.description('', *render_list(desc, markdown_help, settings)), + ) + items.append(n) + + section += nodes.option_list('', *items) + nodes_list.append(section) + + return nodes_list + + @staticmethod + def _is_suppressed(item: str | None) -> bool: + """Return whether item should not be printed.""" + if item is None: + return True + item = str(item).replace('"', '').replace("'", '') + return item == '==SUPPRESS==' + def run(self): if 'module' in self.options and 'func' in self.options: module_name = self.options['module'] @@ -568,9 +627,25 @@ def run(self): items.extend(render_list([result['description']], True)) else: items.append(self._nested_parse_paragraph(result['description'])) + + if 'index-groups' in self.options: + self.index_groups = list(map(str.strip, self.options['index-groups'].split(', '))) + else: + self.index_groups = [] + + full_command = command_pos_args(result) + node_id = make_id(self.env, self.state.document, '', full_command) + target = nodes.target('', '', ids=[node_id]) + items.append(target) + self.set_source_info(target) + self.state.document.note_explicit_target(target) + + domain = cast(ArgParseDomain, self.env.get_domain(ArgParseDomain.name)) + domain.add_argparse_command(result, node_id, self.index_groups) + items.append(nodes.literal_block(text=result['usage'])) items.extend( - print_action_groups( + self._print_action_groups( result, nested_content, markdown_help, @@ -580,7 +655,7 @@ def run(self): ) if 'nosubcommands' not in self.options: items.extend( - print_subcommands( + self._print_subcommands( result, nested_content, markdown_help, @@ -596,9 +671,176 @@ def run(self): return items -def setup(app): +class CommandsIndex(Index): + name = 'index' + localname = 'Commands Index' + + def generate( + self, docnames: Iterable[str] | None = None + ) -> tuple[list[tuple[str, list[IndexEntry]]], bool]: + content: dict[str, list[IndexEntry]] = {} + commands: list[_ObjectDescriptionTuple] + commands = sorted(self.domain.get_objects(), key=operator.itemgetter(0)) + for cmd, dispname, _typ, docname, anchor, priority in commands: + inx_entry = IndexEntry(cmd, priority, docname, anchor, docname, '', dispname) + content.setdefault(cmd[0].lower(), []).append(inx_entry) + return sorted(content.items()), True + + +class CommandsByGroupIndex(Index): + name = 'by-group' + localname = 'Commands by Group' + + def generate( + self, docnames: Iterable[str] | None = None + ) -> tuple[list[tuple[str, list[IndexEntry]]], bool]: + content: dict[str, list[IndexEntry]] = {} + commands_by_group: dict[str, list[_ObjectDescriptionTuple]] + commands_by_group = self.domain.data['commands-by-group'] + for group in sorted(commands_by_group): + commands = sorted(commands_by_group[group], key=operator.itemgetter(0)) + for cmd, dispname, _typ, docname, anchor, priority in commands: + idx_entry = IndexEntry(cmd, priority, docname, anchor, docname, '', dispname) + content.setdefault(group, []).append(idx_entry) + return sorted(content.items()), True + + +class ArgParseDomain(Domain): + name = 'commands' + label = 'commands-label' + + roles = { + 'command': XRefRole(), + } + indices = [] + initial_data: dict[ + str, list[_ObjectDescriptionTuple] | dict[str, list[_ObjectDescriptionTuple]] + ] = { + 'commands': [], + 'commands-by-group': {}, + } + + # Keep a list of the temporary index files that are created in the + # source directory. The files are created if the command_xxx_in_toctree + # option is set to True. + temporary_index_files: list[Path] = [] + + def get_full_qualified_name(self, node: Element) -> str: + return str(node.arguments[0]) + + def get_objects(self) -> Iterable[_ObjectDescriptionTuple]: + yield from self.data['commands'] + + def resolve_xref( + self, + env: BuildEnvironment, + fromdocname: str, + builder: Builder, + typ: str, + target: str, + node: pending_xref, + contnode: Element, + ) -> Element | None: + anchor_id = target_to_anchor_id(target) + match = [ + (docname, anchor) + for _cmd, _sig, _type, docname, anchor, _prio in self.get_objects() + if anchor_id == anchor + ] + + if len(match) > 0: + todocname = match[0][0] + targ = match[0][1] + + return make_refnode(builder, fromdocname, todocname, targ, contnode, targ) + else: + msg = f'Error, no command xref target from {fromdocname}:{target}' + logger.warning(msg) + return None + + def add_argparse_command(self, result: dict, anchor: str, groups: Sequence[str] = ()): + """Add an argparse command to the domain.""" + full_command = command_pos_args(result) + desc = result.get('description', 'No description.') + idx_entry = (full_command, desc, 'command', self.env.docname, anchor, 0) + self.data['commands'].append(idx_entry) + + # A likely duplicate list of index entries is kept for the grouping. + # A separate list is kept to avoid the edge case that a command is used + # once as part of a group (with index_groups) and another time without the + # option. + commands_by_group = self.data['commands-by-group'] + for group in groups: + commands_by_group.setdefault(group, []).append(idx_entry) + + +def _delete_temporary_files(app: Sphinx, _err) -> None: + assert app.env is not None + domain = cast(ArgParseDomain, app.env.domains[ArgParseDomain.name]) + for fpath in domain.temporary_index_files: + fpath.unlink(missing_ok=True) + + +def _create_temporary_dummy_file( + app: Sphinx, domain: Domain, docname: str, title: str +) -> None: + dummy_file = app.srcdir / docname + if dummy_file.exists(): + msg = ( + f'The Sphinx project cannot include a file named ' + f'"{docname}" in the source directory.' + ) + raise ExtensionError(msg) + + underline = len(title) * '=' + content = '\n'.join(( + f'{title}', + f'{underline}', + '', + 'Temporary file that is replaced with an index from the sphinxarg extension.', + f'Creating this temporary file enables you to add {docname} to the toctree.', + )) + dummy_file.write_text(content, encoding='utf-8') + domain = cast(ArgParseDomain, domain) + domain.temporary_index_files.append(dummy_file) + + +def configure_ext(app: Sphinx) -> None: + conf = app.config.sphinx_argparse_conf + domain = cast(ArgParseDomain, app.env.domains[ArgParseDomain.name]) + build_index = False + build_by_group_index = False + if 'commands_by_group_index_file_suffix' in conf: + build_by_group_index = True + CommandsByGroupIndex.name = conf.get('commands_by_group_index_file_suffix') + if 'commands_by_group_index_title' in conf: + build_by_group_index = True + CommandsByGroupIndex.localname = conf.get('commands_by_group_index_title') + if ('commands_index_in_toctree', True) in conf.items(): + build_index = True + docname = f'{ArgParseDomain.name}-{CommandsIndex.name}.rst' + _create_temporary_dummy_file(app, domain, docname, CommandsIndex.localname) + if ('commands_by_group_index_in_toctree', True) in conf.items(): + build_by_group_index = True + docname = f'{ArgParseDomain.name}-{CommandsByGroupIndex.name}.rst' + _create_temporary_dummy_file(app, domain, docname, CommandsByGroupIndex.localname) + + if build_index or ('build_commands_index', True) in conf.items(): + domain.indices.append(CommandsIndex) + if build_by_group_index or ('build_commands_by_group_index', True) in conf.items(): + domain.indices.append(CommandsByGroupIndex) + + # Call setup so that :ref:`commands-...` are link targets. + domain.setup() + + +def setup(app: Sphinx): app.setup_extension('sphinx.ext.autodoc') + app.add_domain(ArgParseDomain) app.add_directive('argparse', ArgParseDirective) + app.add_config_value('sphinx_argparse_conf', {}, 'html', types={dict}) + app.connect('builder-inited', configure_ext) + app.connect('build-finished', _delete_temporary_files) return { 'version': __version__, 'parallel_read_safe': True, diff --git a/sphinxarg/parser.py b/sphinxarg/parser.py index c3bd32d8..2a1f95fa 100644 --- a/sphinxarg/parser.py +++ b/sphinxarg/parser.py @@ -92,7 +92,13 @@ def parse_parser(parser, data=None, **kwargs): 'help': helps.get(name, ''), 'usage': subaction.format_usage().strip(), 'bare_usage': _format_usage_without_prefix(subaction), + 'parent': { + 'name': '' if 'name' not in data else data['name'], + 'prog': '' if 'prog' not in data else data['prog'], + }, } + if 'parent' in data: + subdata['parent'].update({'parent': data['parent']}) if subalias: subdata['identifier'] = name parse_parser(subaction, subdata, **kwargs) diff --git a/sphinxarg/utils.py b/sphinxarg/utils.py new file mode 100644 index 00000000..3cf7e063 --- /dev/null +++ b/sphinxarg/utils.py @@ -0,0 +1,55 @@ +def command_pos_args(result: dict) -> str: + """Returns the command up to the positional arg a string + that is suitable for the text in the command index. + + >>> x, y, z = {}, {}, {} + >>> x['prog']='simple-command' + >>> command_pos_args(x) + 'simple-command' + + >>> y['name']='A' + >>> y['parent']=x + >>> command_pos_args(y) + 'simple-command A' + + >>> z['name']='zz' + >>> z['parent']=y + >>> command_pos_args(z) + 'simple-command A zz' + + >>> command_pos_args("blah") + '' + """ + ret = '' + + if 'name' in result and result['name'] != '': + ret += f"{result['name']}" + elif 'prog' in result and result['prog'] != '': + ret += f"{result['prog']}" + + if 'parent' in result: + ret = command_pos_args(result['parent']) + ' ' + ret + + return ret + + +def target_to_anchor_id(target: str) -> str: + """Returns the a string with the spaces replaced + with dashes so the string can be found in the + command xref targets. + + >>> cmd='simple-command A' + >>> target_to_anchor_id(cmd) + 'simple-command-A' + """ + if len(target) < 1: + msg = 'Supplied target string is less than one character long.' + raise ValueError(msg) + + return target.replace(' ', '-') + + +if __name__ == '__main__': + import doctest + + doctest.testmod() diff --git a/test/__pycache__/test_commands_by_group_index.cpython-39-pytest-7.2.0.pyc.484121 b/test/__pycache__/test_commands_by_group_index.cpython-39-pytest-7.2.0.pyc.484121 new file mode 100644 index 00000000..e69de29b diff --git a/test/conftest.py b/test/conftest.py index 934edae6..1872ee3a 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,6 +1,5 @@ """Test HTML output the same way that Sphinx does in test_build_html.py.""" -from itertools import chain, cycle from pathlib import Path import pytest @@ -67,7 +66,3 @@ def parse(fname): yield parse etree_cache.clear() - - -def flat_dict(d): - return chain.from_iterable([zip(cycle([fname]), values) for fname, values in d.items()]) diff --git a/test/roots/test-argparse-directive/conf.py b/test/roots/test-argparse-directive/conf.py new file mode 100644 index 00000000..d08cd8eb --- /dev/null +++ b/test/roots/test-argparse-directive/conf.py @@ -0,0 +1 @@ +extensions = ['sphinxarg.ext'] diff --git a/test/roots/test-argparse-directive/index.rst b/test/roots/test-argparse-directive/index.rst new file mode 100644 index 00000000..ee2cfa66 --- /dev/null +++ b/test/roots/test-argparse-directive/index.rst @@ -0,0 +1,8 @@ +Fails to parse +============== + +.. argparse:: + :filename: test/sample-directive-opts.py + :prog: sample-directive-opts + :func: get_parser + :index-groups: Needs; Commas diff --git a/test/roots/test-command-by-group-index/conf.py b/test/roots/test-command-by-group-index/conf.py new file mode 100644 index 00000000..ae6fc22a --- /dev/null +++ b/test/roots/test-command-by-group-index/conf.py @@ -0,0 +1,4 @@ +extensions = ['sphinxarg.ext'] +sphinx_argparse_conf = { + 'commands_by_group_index_in_toctree': True, +} diff --git a/test/roots/test-command-by-group-index/index.rst b/test/roots/test-command-by-group-index/index.rst new file mode 100644 index 00000000..0c7757fe --- /dev/null +++ b/test/roots/test-command-by-group-index/index.rst @@ -0,0 +1,9 @@ +Test Directive Options +====================== + +.. toctree:: + + sample + subcommand-a + subcommand-b + commands-by-group diff --git a/test/roots/test-command-by-group-index/sample.rst b/test/roots/test-command-by-group-index/sample.rst new file mode 100644 index 00000000..a84877bd --- /dev/null +++ b/test/roots/test-command-by-group-index/sample.rst @@ -0,0 +1,9 @@ +Sample +====== + +.. argparse:: + :filename: test/sample-directive-opts.py + :prog: sample-directive-opts + :func: get_parser + :nosubcommands: + :index-groups: spam on a stick, ham in a cone diff --git a/test/roots/test-command-by-group-index/subcommand-a.rst b/test/roots/test-command-by-group-index/subcommand-a.rst new file mode 100644 index 00000000..d5959a8d --- /dev/null +++ b/test/roots/test-command-by-group-index/subcommand-a.rst @@ -0,0 +1,9 @@ +Command A +========= + +.. argparse:: + :filename: test/sample-directive-opts.py + :prog: sample-directive-opts + :func: get_parser + :path: A + :index-groups: spam on a stick diff --git a/test/roots/test-command-by-group-index/subcommand-b.rst b/test/roots/test-command-by-group-index/subcommand-b.rst new file mode 100644 index 00000000..1817a39a --- /dev/null +++ b/test/roots/test-command-by-group-index/subcommand-b.rst @@ -0,0 +1,9 @@ +Command B +========= + +.. argparse:: + :filename: test/sample-directive-opts.py + :prog: sample-directive-opts + :func: get_parser + :path: B + :index-groups: ham in a cone diff --git a/test/roots/test-command-index/conf.py b/test/roots/test-command-index/conf.py new file mode 100644 index 00000000..6f887515 --- /dev/null +++ b/test/roots/test-command-index/conf.py @@ -0,0 +1,5 @@ +extensions = ['sphinxarg.ext'] +sphinx_argparse_conf = { + 'build_commands_index': True, + 'commands_index_in_toctree': True, +} diff --git a/test/roots/test-command-index/index.rst b/test/roots/test-command-index/index.rst new file mode 100644 index 00000000..a89b0219 --- /dev/null +++ b/test/roots/test-command-index/index.rst @@ -0,0 +1,9 @@ +Test Directive Options +====================== + +.. toctree:: + + sample + subcommand-a + subcommand-b + commands-index diff --git a/test/roots/test-command-index/sample.rst b/test/roots/test-command-index/sample.rst new file mode 100644 index 00000000..d7eb90b6 --- /dev/null +++ b/test/roots/test-command-index/sample.rst @@ -0,0 +1,7 @@ +Sample +====== + +.. argparse:: + :filename: test/sample-directive-opts.py + :prog: sample-directive-opts + :func: get_parser diff --git a/test/roots/test-command-index/subcommand-a.rst b/test/roots/test-command-index/subcommand-a.rst new file mode 100644 index 00000000..4cbbf448 --- /dev/null +++ b/test/roots/test-command-index/subcommand-a.rst @@ -0,0 +1,8 @@ +Command A +========= + +.. argparse:: + :filename: test/sample-directive-opts.py + :prog: sample-directive-opts + :func: get_parser + :path: A diff --git a/test/roots/test-command-index/subcommand-b.rst b/test/roots/test-command-index/subcommand-b.rst new file mode 100644 index 00000000..53625f26 --- /dev/null +++ b/test/roots/test-command-index/subcommand-b.rst @@ -0,0 +1,8 @@ +Command B +========= + +.. argparse:: + :filename: test/sample-directive-opts.py + :prog: sample-directive-opts + :func: get_parser + :path: B diff --git a/test/roots/test-conf-opts-html/conf.py b/test/roots/test-conf-opts-html/conf.py new file mode 100644 index 00000000..d08cd8eb --- /dev/null +++ b/test/roots/test-conf-opts-html/conf.py @@ -0,0 +1 @@ +extensions = ['sphinxarg.ext'] diff --git a/test/roots/test-conf-opts-html/index.rst b/test/roots/test-conf-opts-html/index.rst new file mode 100644 index 00000000..d7eb90b6 --- /dev/null +++ b/test/roots/test-conf-opts-html/index.rst @@ -0,0 +1,7 @@ +Sample +====== + +.. argparse:: + :filename: test/sample-directive-opts.py + :prog: sample-directive-opts + :func: get_parser diff --git a/test/roots/test-conf-opts-html/subcommand-a.rst b/test/roots/test-conf-opts-html/subcommand-a.rst new file mode 100644 index 00000000..4cbbf448 --- /dev/null +++ b/test/roots/test-conf-opts-html/subcommand-a.rst @@ -0,0 +1,8 @@ +Command A +========= + +.. argparse:: + :filename: test/sample-directive-opts.py + :prog: sample-directive-opts + :func: get_parser + :path: A diff --git a/test/roots/test-default-html/index.rst b/test/roots/test-default-html/index.rst index 7e88d019..636e7f01 100644 --- a/test/roots/test-default-html/index.rst +++ b/test/roots/test-default-html/index.rst @@ -5,3 +5,9 @@ Sample :filename: test/sample-directive-opts.py :prog: sample-directive-opts :func: get_parser + + +Link check +********** + +Add a link to :commands:command:`sample-directive-opts A`. diff --git a/test/test_argparse_directive.py b/test/test_argparse_directive.py new file mode 100644 index 00000000..f12304ea --- /dev/null +++ b/test/test_argparse_directive.py @@ -0,0 +1,7 @@ +import pytest + + +@pytest.mark.sphinx('html', testroot='argparse-directive') +def test_bad_index_groups(app, status, warning): + app.build() + assert 'failed to parse index-groups as a list' in warning.getvalue() diff --git a/test/test_commands_by_group_index.py b/test/test_commands_by_group_index.py new file mode 100644 index 00000000..d4b9f25e --- /dev/null +++ b/test/test_commands_by_group_index.py @@ -0,0 +1,121 @@ +import os +from pathlib import Path + +import pytest + +from sphinxarg.ext import CommandsByGroupIndex +from test.utils.xpath import check_xpath + + +@pytest.mark.parametrize( + ('fname', 'expect'), + [ + ( + 'index.html', + (".//div[@role='navigation']//a[@class='reference internal']", 'Sample'), + ), + ( + 'index.html', + (".//div[@role='navigation']//a[@class='reference internal']", 'Command A'), + ), + ( + 'index.html', + (".//div[@role='navigation']//a[@class='reference internal']", 'Command B'), + ), + ( + 'index.html', + ( + ".//div[@role='navigation']//a[@class='reference internal']", + 'Commands by Group', + ), + ), + ('commands-by-group.html', ('.//h1', 'Commands by Group')), + ('commands-by-group.html', ('.//tr/td[2]/strong', 'ham in a cone')), + ( + 'commands-by-group.html', + ( + ".//tr[td[2]/strong/text()='ham in a cone']/following-sibling::tr[1]/td[2]/a/code", # NoQA: E501 + 'sample-directive-opts', + ), + ), + ( + 'commands-by-group.html', + ( + ".//tr[td[2]/strong/text()='ham in a cone']/following-sibling::tr[2]/td[2]/a/code", # NoQA: E501 + 'sample-directive-opts B', + ), + ), + ('commands-by-group.html', ('.//tr/td[2]/strong', 'spam')), + ( + 'commands-by-group.html', + ( + ".//tr[td[2]/strong/text()='spam on a stick']/following-sibling::tr[1]/td[2]/a/code", # NoQA: E501 + 'sample-directive-opts', + ), + ), + ( + 'commands-by-group.html', + ( + ".//tr[td[2]/strong/text()='spam on a stick']/following-sibling::tr[2]/td[2]/a/code", # NoQA: E501 + 'sample-directive-opts A', + ), + ), + ( + 'commands-by-group.html', + ( + './/tr/td[2]/em', + '(other)', + False, + ), + ), # Other does not have index-groups set at all and is not present. + ], +) +@pytest.mark.sphinx('html', testroot='command-by-group-index') +def test_commands_by_group_index_html(app, cached_etree_parse, fname, expect): + app.build() + check_xpath(cached_etree_parse(app.outdir / fname), fname, *expect) + + +@pytest.mark.parametrize( + ('fname', 'expect'), + [ + ( + 'index.html', + ( + ".//div[@role='navigation']//a[@class='reference internal']", + 'Commands grouped by SomeName', + ), + ), + ('commands-groupedby-somename.html', ('.//h1', 'Commands grouped by SomeName')), + ('commands-groupedby-somename.html', ('.//h1', 'Commands by Group', False)), + ], +) +@pytest.mark.sphinx( + 'html', + testroot='command-by-group-index', + confoverrides={ + 'sphinx_argparse_conf': { + 'commands_by_group_index_title': 'Commands grouped by SomeName', + 'commands_by_group_index_file_suffix': 'groupedby-somename', + 'commands_by_group_index_in_toctree': True, + } + }, +) +def test_by_group_index_overrides_html(app, cached_etree_parse, fname, expect): + def update_toctree(app): + indexfile = Path(app.srcdir) / 'index.rst' + content = indexfile.read_text(encoding='utf8') + # replace the toctree entry + content = content.replace('commands-by-group', 'commands-groupedby-somename') + indexfile.write_text(content) + + update_toctree(app) + + app.build(force_all=True) + check_xpath(cached_etree_parse(app.outdir / fname), fname, *expect) + + +@pytest.mark.sphinx('html', testroot='command-by-group-index') +def test_by_group_index_overrides_files_html(app): + assert os.path.exists(app.outdir / (CommandsByGroupIndex.name + '.html')) is False + assert os.path.exists(app.outdir / 'commands-groupedby-somename.html') is True diff --git a/test/test_commands_index.py b/test/test_commands_index.py new file mode 100644 index 00000000..11b8befa --- /dev/null +++ b/test/test_commands_index.py @@ -0,0 +1,28 @@ +import pytest + +from test.utils.xpath import check_xpath + + +@pytest.mark.parametrize( + ('fname', 'expect'), + [ + ('subcommand-a.html', ('.//h1', 'Sample', False)), + ('subcommand-a.html', ('.//h1', 'Command A')), + ('subcommand-b.html', ('.//h1', 'Sample', False)), + ('subcommand-b.html', ('.//h1', 'Command B')), + ('commands-index.html', ('.//h1', 'Commands Index')), + ('commands-index.html', ('.//tr/td[2]/a/code', 'sample-directive-opts')), + ('commands-index.html', ('.//tr/td[3]/em', 'Support SphinxArgParse HTML testing')), + ( + 'commands-index.html', + ( + ".//tr[td[2]/a/code/text()='sample-directive-opts']/td[3]/em", + 'Support SphinxArgParse HTML testing', + ), + ), + ], +) +@pytest.mark.sphinx('html', testroot='command-index') +def test_commands_index_html(app, cached_etree_parse, fname, expect): + app.build() + check_xpath(cached_etree_parse(app.outdir / fname), fname, *expect) diff --git a/test/test_conf_options_html.py b/test/test_conf_options_html.py new file mode 100644 index 00000000..51e4cbaa --- /dev/null +++ b/test/test_conf_options_html.py @@ -0,0 +1,29 @@ +"""Test the HTML builder with sphinx-argparse conf options and check output against XPath.""" + +import pytest + +from test.utils.xpath import check_xpath + + +@pytest.mark.parametrize( + ('fname', 'expect'), + [ + ('index.html', ('.//h1', 'Sample')), + ('index.html', ('.//h2', 'Sub-commands')), + ('index.html', ('.//h3', 'sample-directive-opts A')), # By default, just "A". + ('index.html', ('.//h3', 'sample-directive-opts B')), + ], +) +@pytest.mark.sphinx( + 'html', + testroot='conf-opts-html', + confoverrides={ + 'sphinx_argparse_conf': { + 'full_subcommand_name': True, + } + }, +) +def test_full_subcomand_name_html(app, cached_etree_parse, fname, expect): + app.build() + print(app.outdir / fname) + check_xpath(cached_etree_parse(app.outdir / fname), fname, *expect) diff --git a/test/test_default_html.py b/test/test_default_html.py index e5acd7f7..b9325c0e 100644 --- a/test/test_default_html.py +++ b/test/test_default_html.py @@ -1,49 +1,11 @@ """Test the HTML builder and check output against XPath.""" -import re +import posixpath import pytest +from sphinx.util.inventory import InventoryFile - -def check_xpath(etree, fname, path, check, be_found=True): - nodes = list(etree.xpath(path)) - if check is None: - assert nodes == [], f'found any nodes matching xpath {path!r} in file {fname}' - return - else: - assert nodes != [], f'did not find any node matching xpath {path!r} in file {fname}' - if callable(check): - check(nodes) - elif not check: - # only check for node presence - pass - else: - - def get_text(node): - if node.text is not None: - # the node has only one text - return node.text - else: - # the node has tags and text; gather texts just under the node - return ''.join(n.tail or '' for n in node) - - rex = re.compile(check) - if be_found: - if any(rex.search(get_text(node)) for node in nodes): - return - msg = ( - f'{check!r} not found in any node matching path {path} in {fname}: ' - f'{[node.text for node in nodes]!r}' - ) - else: - if all(not rex.search(get_text(node)) for node in nodes): - return - msg = ( - f'Found {check!r} in a node matching path {path} in {fname}: ' - f'{[node.text for node in nodes]!r}' - ) - - raise AssertionError(msg) +from test.utils.xpath import check_xpath @pytest.mark.parametrize( @@ -56,12 +18,21 @@ def get_text(node): ('.//h1', 'blah-blah', False), (".//div[@class='highlight']//span", 'usage'), ('.//h2', 'Positional Arguments'), - (".//section[@id='positional-arguments']", ''), - (".//section[@id='positional-arguments']/dl/dt[1]/kbd", 'foo2 metavar'), - (".//section[@id='named-arguments']", ''), - (".//section[@id='named-arguments']/dl/dt[1]/kbd", '--foo'), - (".//section[@id='bar-options']", ''), - (".//section[@id='bar-options']/dl/dt[1]/kbd", '--bar'), + (".//section[@id='sample-directive-opts-positional-arguments']", ''), + (".//section/span[@id='positional-arguments']", ''), + ( + ".//section[@id='sample-directive-opts-positional-arguments']/dl/dt[1]/kbd", + 'foo2 metavar', + ), + (".//section[@id='sample-directive-opts-named-arguments']", ''), + (".//section/span[@id='named-arguments']", ''), + ( + ".//section[@id='sample-directive-opts-named-arguments']/dl/dt[1]/kbd", + '--foo', + ), + (".//section[@id='sample-directive-opts-bar-options']", ''), + (".//section[@id='sample-directive-opts-bar-options']/dl/dt[1]/kbd", '--bar'), + (".//section[@id='link-check']/p[1]/a[@href='#sample-directive-opts-A']", ''), ], ), ( @@ -71,8 +42,12 @@ def get_text(node): ('.//h1', 'Command A'), (".//div[@class='highlight']//span", 'usage'), ('.//h2', 'Positional Arguments'), - (".//section[@id='positional-arguments']", ''), - (".//section[@id='positional-arguments']/dl/dt[1]/kbd", 'baz'), + (".//section[@id='sample-directive-opts-A-positional-arguments']", ''), + (".//section/span[@id='positional-arguments']", ''), + ( + ".//section[@id='sample-directive-opts-A-positional-arguments']/dl/dt[1]/kbd", + 'baz', + ), ], ), ( @@ -103,3 +78,41 @@ def test_default_html(app, cached_etree_parse, fname, expect_list): print(app.outdir / fname) for expect in expect_list: check_xpath(cached_etree_parse(app.outdir / fname), fname, *expect) + + +@pytest.mark.sphinx('html', testroot='default-html') +def test_index_is_optional(app, cached_etree_parse): + app.build() + index_file = app.outdir / 'index.html' + assert index_file.exists() is True # Confirm that the build occurred. + + command_index_file = app.outdir / 'commands-index.html' + assert command_index_file.exists() is False + + +@pytest.mark.sphinx('html', testroot='default-html') +def test_object_inventory(app, cached_etree_parse): + app.build() + inventory_file = app.outdir / 'objects.inv' + assert inventory_file.exists() is True + + with inventory_file.open('rb') as f: + inv = InventoryFile.load(f, 'test/path', posixpath.join) + + assert 'sample-directive-opts' in inv.get('commands:command') + assert ( + 'test/path/index.html#sample-directive-opts' + == inv['commands:command']['sample-directive-opts'][2] + ) + + assert 'sample-directive-opts A' in inv.get('commands:command') + assert ( + 'test/path/subcommand-a.html#sample-directive-opts-A' + == inv['commands:command']['sample-directive-opts A'][2] + ) + + assert 'sample-directive-opts B' in inv.get('commands:command') + assert ( + 'test/path/index.html#sample-directive-opts-B' + == inv['commands:command']['sample-directive-opts B'][2] + ) diff --git a/test/test_parser.py b/test/test_parser.py index 3d41a2d9..c6319097 100755 --- a/test/test_parser.py +++ b/test/test_parser.py @@ -138,6 +138,10 @@ def test_parse_nested(): ], }, ], + 'parent': { + 'name': '', + 'prog': 'under-test', + }, } ] @@ -185,6 +189,10 @@ def test_parse_nested_with_alias(): ], }, ], + 'parent': { + 'name': '', + 'prog': 'under-test', + }, } ] @@ -205,6 +213,10 @@ def test_aliased_traversal(): 'usage': 'usage: under-test level1 [-h]', 'name': 'level1 (l1)', 'identifier': 'level1', + 'parent': { + 'name': '', + 'prog': 'under-test', + }, } @@ -232,6 +244,19 @@ def test_parse_nested_traversal(): {'name': ['bar'], 'help': '', 'default': None}, ] + assert data3['parent'] == { + 'name': 'level2', + 'prog': '', + 'parent': { + 'name': 'level1', + 'prog': '', + 'parent': { + 'name': '', + 'prog': 'under-test', + }, + }, + } + data2 = parser_navigate(data, 'level1 level2') assert data2['children'] == [ { @@ -249,10 +274,20 @@ def test_parse_nested_traversal(): ], } ], + 'parent': { + 'name': 'level2', + 'prog': '', + 'parent': { + 'name': 'level1', + 'prog': '', + 'parent': {'name': '', 'prog': 'under-test'}, + }, + }, } ] assert data == parser_navigate(data, '') + assert 'parent' not in data def test_fill_in_default_prog(): @@ -387,6 +422,10 @@ def test_action_groups_with_subcommands(): 'bare_usage': 'foo A [-h] baz', 'name': 'A', 'help': 'A subparser', + 'parent': { + 'name': '', + 'prog': 'foo', + }, }, { 'usage': 'usage: foo B [-h] [--barg {X,Y,Z}]', @@ -407,5 +446,9 @@ def test_action_groups_with_subcommands(): 'bare_usage': 'foo B [-h] [--barg {X,Y,Z}]', 'name': 'B', 'help': 'B subparser', + 'parent': { + 'name': '', + 'prog': 'foo', + }, }, ] diff --git a/test/utils/__init__.py b/test/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/utils/xpath.py b/test/utils/xpath.py new file mode 100644 index 00000000..e7f3ce48 --- /dev/null +++ b/test/utils/xpath.py @@ -0,0 +1,42 @@ +import re + + +def check_xpath(etree, fname, path, check, be_found=True): + nodes = list(etree.xpath(path)) + if check is None: + assert nodes == [], f'found any nodes matching xpath {path!r} in file {fname}' + return + else: + assert nodes != [], f'did not find any node matching xpath {path!r} in file {fname}' + if callable(check): + check(nodes) + elif not check: + # only check for node presence + pass + else: + + def get_text(node): + if node.text is not None: + # the node has only one text + return node.text + else: + # the node has tags and text; gather texts just under the node + return ''.join(n.tail or '' for n in node) + + rex = re.compile(check) + if be_found: + if any(rex.search(get_text(node)) for node in nodes): + return + msg = ( + f'{check!r} not found in any node matching path {path} in {fname}: ' + f'{[node.text for node in nodes]!r}' + ) + else: + if all(not rex.search(get_text(node)) for node in nodes): + return + msg = ( + f'Found {check!r} in a node matching path {path} in {fname}: ' + f'{[node.text for node in nodes]!r}' + ) + + raise AssertionError(msg)