From e40513834a0a98703984e498bbd99027fa20ab2a Mon Sep 17 00:00:00 2001 From: Mike McKiernan Date: Sun, 13 Nov 2022 15:40:33 -0500 Subject: [PATCH 1/4] Updates for 0.5.0 - Print fully-qualified subcommand name in title - Support commands:command role for intersphinx - Prefer fully-qualified HREF targets Use targets like "#blah-sub-commands" as the primary target and move historic targets like "#Sub-commands" to secondary targets. Preserve the older HREF, `sub-commands`, as a secondary target. In the HTML, this becomes a span just below the section element so that bookmarks continue to work even after adopting the update from this commit. --- .pre-commit-config.yaml | 2 +- docs/changelog.rst | 28 + docs/conf.py | 1 + docs/usage.rst | 127 +++- pyproject.toml | 3 +- sphinxarg/ext.py | 540 ++++++++++++------ sphinxarg/parser.py | 6 + sphinxarg/utils.py | 54 ++ ...p_index.cpython-39-pytest-7.2.0.pyc.484121 | 0 test/roots/test-argparse-directive/conf.py | 1 + test/roots/test-argparse-directive/index.rst | 8 + .../roots/test-command-by-group-index/conf.py | 4 + .../test-command-by-group-index/index.rst | 9 + .../test-command-by-group-index/sample.rst | 9 + .../subcommand-a.rst | 9 + .../subcommand-b.rst | 9 + test/roots/test-command-index/conf.py | 5 + test/roots/test-command-index/index.rst | 9 + test/roots/test-command-index/sample.rst | 7 + .../roots/test-command-index/subcommand-a.rst | 8 + .../roots/test-command-index/subcommand-b.rst | 8 + test/roots/test-conf-opts-html/conf.py | 1 + test/roots/test-conf-opts-html/index.rst | 7 + .../test-conf-opts-html/subcommand-a.rst | 8 + test/roots/test-default-html/index.rst | 6 + test/test_argparse_directive.py | 7 + test/test_commands_by_group_index.py | 82 +++ test/test_commands_index.py | 30 + test/test_conf_options_html.py | 33 ++ test/test_default_html.py | 52 +- test/test_parser.py | 35 ++ 31 files changed, 924 insertions(+), 184 deletions(-) create mode 100644 sphinxarg/utils.py create mode 100644 test/__pycache__/test_commands_by_group_index.cpython-39-pytest-7.2.0.pyc.484121 create mode 100644 test/roots/test-argparse-directive/conf.py create mode 100644 test/roots/test-argparse-directive/index.rst create mode 100644 test/roots/test-command-by-group-index/conf.py create mode 100644 test/roots/test-command-by-group-index/index.rst create mode 100644 test/roots/test-command-by-group-index/sample.rst create mode 100644 test/roots/test-command-by-group-index/subcommand-a.rst create mode 100644 test/roots/test-command-by-group-index/subcommand-b.rst create mode 100644 test/roots/test-command-index/conf.py create mode 100644 test/roots/test-command-index/index.rst create mode 100644 test/roots/test-command-index/sample.rst create mode 100644 test/roots/test-command-index/subcommand-a.rst create mode 100644 test/roots/test-command-index/subcommand-b.rst create mode 100644 test/roots/test-conf-opts-html/conf.py create mode 100644 test/roots/test-conf-opts-html/index.rst create mode 100644 test/roots/test-conf-opts-html/subcommand-a.rst create mode 100644 test/test_argparse_directive.py create mode 100644 test/test_commands_by_group_index.py create mode 100644 test/test_commands_index.py create mode 100644 test/test_conf_options_html.py 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..3e8ce283 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 ``:idxgroups:`` 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..17d40ed8 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). + +:idxgroups: 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 ``:idxgroups:`` option to the ``argparse`` directive in your documentation files. +Specify one or more groups that the command belongs to. + +.. code-block:: reStructuredText + + .. argparse:: + :filename: ../test/sample.py + :func: parser + :prog: sample + :idxgroups: ["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..ba30b444 100644 --- a/sphinxarg/ext.py +++ b/sphinxarg/ext.py @@ -1,22 +1,38 @@ from __future__ import annotations import importlib +import ast import os import shutil import sys from argparse import ArgumentParser +from collections import defaultdict +from typing import Dict, List, Optional, Union, cast from docutils import nodes from docutils.frontend import get_default_settings -from docutils.parsers.rst import Directive, Parser +from docutils.nodes import Element +from docutils.parsers.rst import Parser from docutils.parsers.rst.directives import flag, unchanged from docutils.statemachine import StringList from sphinx.ext.autodoc import mock from sphinx.util.docutils import new_document -from sphinx.util.nodes import nested_parse_with_titles +from sphinx.addnodes import pending_xref +from sphinx.application import Sphinx +from sphinx.builders import Builder +from sphinx.domains import Domain, Index +from sphinx.environment import BuildEnvironment +from sphinx.errors import ExtensionError +from sphinx.roles import XRefRole +from sphinx.util import logging +from sphinx.util.docutils import SphinxDirective +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 + +logger = logging.getLogger(__name__) def map_nested_definitions(nested_content): @@ -87,173 +103,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 +128,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 +147,10 @@ class ArgParseDirective(Directive): 'nodescription': unchanged, 'markdown': flag, 'markdownhelp': flag, + 'idxgroups': unchanged, } + domain: Optional[Domain] = None + idxgroups: Optional[List[str]] = None def _construct_manpage_specific_structure(self, parser_info): """ @@ -484,7 +337,182 @@ 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 = [] + env = self.state.document.settings.env + conf = env.config.sphinx_argparse_conf + domain = cast(SphinxArgParseDomain, env.domains[SphinxArgParseDomain.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=[node_id, "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) in conf.items(): + title = nodes.title(full_command, full_command) + else: + title = nodes.title(child['name'], child['name']) + sec += title + + domain.add_command(child, node_id, self.idxgroups) + + 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): + """ + 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 comprised of a section, holding a title, the description, and the option group (members) + full_command = command_pos_args(data) + node_id = make_id(self.env, self.state.document, '', full_command + "-" + action_group['title'].replace(' ', '-').lower()) + target = nodes.target('', '', ids=[node_id]) + self.set_source_info(target) + self.state.document.note_explicit_target(target) + + section = nodes.section(ids=[node_id, action_group['title'].replace(' ', '-').lower()]) + 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 = {k: v for k, v in 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 entry['default'] is not None and entry['default'] not in [ + '"==SUPPRESS=="', + '==SUPPRESS==', + ]: + if entry['default'] == '': + arg.append('Default: ""') + else: + arg.append(f"Default: {entry['default']}") + + # 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 run(self): + self.domain = cast(SphinxArgParseDomain, self.env.get_domain(SphinxArgParseDomain.name)) + if 'module' in self.options and 'func' in self.options: module_name = self.options['module'] attr_name = self.options['func'] @@ -568,9 +596,31 @@ def run(self): items.extend(render_list([result['description']], True)) else: items.append(self._nested_parse_paragraph(result['description'])) + + if 'idxgroups' in self.options: + try: + self.idxgroups = ast.literal_eval(self.options['idxgroups']) + except (SyntaxError, ValueError): + message = f"""Error in "{self.name}". In file "{self.env.doc2path(self.env.docname, False)}" + failed to parse idxgroups as a list: "{self.state_machine.line.strip()}". + """ + raise self.error(message) + self.idxgroups = [x.strip() for x in self.idxgroups] + else: + self.idxgroups = [] + + 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) + + self.domain.add_command(result, node_id, self.idxgroups) + items.append(nodes.literal_block(text=result['usage'])) items.extend( - print_action_groups( + self._print_action_groups( result, nested_content, markdown_help, @@ -580,7 +630,7 @@ def run(self): ) if 'nosubcommands' not in self.options: items.extend( - print_subcommands( + self._print_subcommands( result, nested_content, markdown_help, @@ -596,9 +646,153 @@ def run(self): return items -def setup(app): +class CommandsIndex(Index): + name = 'index' + localname = 'Commands Index' + + def generate(self, docnames=None): + content = defaultdict(list) + + commands = self.domain.get_objects() + commands = sorted(commands, key=lambda command: command[0]) + + for cmd, dispname, _typ, docname, anchor, priority in commands: + content[cmd[0].lower()].append((cmd, priority, docname, anchor, docname, '', dispname)) + + content = sorted(content.items()) + return content, True + + +class CommandsByGroupIndex(Index): + name = 'by-group' + localname = 'Commands by Group' + + def generate(self, docnames=None): + content = defaultdict(list) + + bygroups = self.domain.data['commands-by-group'] + + for group in sorted(bygroups): + commands = sorted(bygroups[group], key=lambda command: command[0]) + for cmd, dispname, _typ, docname, anchor, priority in commands: + content[group].append((cmd, priority, docname, anchor, docname, '', dispname)) + + content = sorted(content.items()) + return content, True + + +class SphinxArgParseDomain(Domain): + name = 'commands' + label = 'commands-label' + + roles = {'command': XRefRole()} + indices = {} # type: ignore + initial_data: Dict[str, Union[List, Dict]] = { + 'commands': [], + 'commands-by-group': defaultdict(list), + } + + # 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[str] = [] + + def get_full_qualified_name(self, node): + return f'{node.arguments[0]}' + + def get_objects(self): + yield from self.data['commands'] + + def resolve_xref(self, env: BuildEnvironment, fromdocname: str, builder: Builder, typ: str, target: str, node: pending_xref, contnode: Element): + 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: + logger.warning(f'Error, no command xref target from {fromdocname}:{target}') + return None + + def add_command(self, result: Dict, anchor: str, groups: List[str] = None): + """Add an argparse command to the domain.""" + full_command = command_pos_args(result) + desc = "No description." + if 'description' in result: + desc = result['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 idxgroups) and another time without the + # option. + for group in groups or []: + self.data['commands-by-group'][group].append(idx_entry) + + +def delete_dummy_file(app: Sphinx, _) -> None: + assert app.env is not None + domain = cast(SphinxArgParseDomain, app.env.domains[SphinxArgParseDomain.name]) + for fpath in domain.temporary_index_files: + if os.path.exists(fpath): + os.unlink(fpath) + + +def create_temp_dummy_file(app: Sphinx, domain: Domain, docname: str, title: str) -> None: + dummy_file = os.path.join(app.srcdir, docname) + domain = cast(SphinxArgParseDomain, domain) + if os.path.exists(dummy_file): + raise ExtensionError(f'The Sphinx project cannot include a file named "{docname}" in the source directory.') + with open(dummy_file, "w") as f: + f.write(f"{title}\n") + f.write(f"{len(title) * '='}\n") + f.write("\n") + f.write("Temporary file that is replaced with an index from the sphinxarg extension.\n") + f.write(f"Creating this temporary file enables you to add {docname} to the toctree.\n") + domain.temporary_index_files.append(dummy_file) + + +def configure_ext(app: Sphinx) -> None: + conf = app.config.sphinx_argparse_conf + assert app.env is not None + domain = cast(SphinxArgParseDomain, app.env.domains[SphinxArgParseDomain.name]) + by_group_index = CommandsByGroupIndex + build_index = False + build_by_group_index = False + if 'commands_by_group_index_file_suffix' in conf: + build_by_group_index = True + by_group_index.name = conf.get('commands_by_group_index_file_suffix') + if 'commands_by_group_index_title' in conf: + build_by_group_index = True + by_group_index.localname = conf.get('commands_by_group_index_title') + if ('commands_index_in_toctree', True) in conf.items(): + build_index = True + docname = f"{SphinxArgParseDomain.name}-{CommandsIndex.name}.rst" + create_temp_dummy_file(app, domain, docname, f"{CommandsIndex.localname}") + if ('commands_by_group_index_in_toctree', True) in conf.items(): + build_by_group_index = True + docname = f"{SphinxArgParseDomain.name}-{by_group_index.name}.rst" + create_temp_dummy_file(app, domain, docname, f"{by_group_index.localname}") + + if build_index or ('build_commands_index', True) in conf.items(): + domain.indices.append(CommandsIndex) # type: ignore + if build_by_group_index or ('build_commands_by_group_index', True) in conf.items(): + domain.indices.append(by_group_index) # type: ignore + + # Call setup so that :ref:`commands-...` are link targets. + domain.setup() + + +def setup(app: Sphinx): app.setup_extension('sphinx.ext.autodoc') + app.add_domain(SphinxArgParseDomain) app.add_directive('argparse', ArgParseDirective) + app.add_config_value('sphinx_argparse_conf', {}, 'html', Dict) + app.connect('builder-inited', configure_ext) + app.connect('build-finished', delete_dummy_file) 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..15353f28 --- /dev/null +++ b/sphinxarg/utils.py @@ -0,0 +1,54 @@ +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: + raise ValueError('Supplied target string is less than one character long.') + + 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/roots/test-argparse-directive/conf.py b/test/roots/test-argparse-directive/conf.py new file mode 100644 index 00000000..8e04c207 --- /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..05881bc7 --- /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 + :idxgroups: "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..e20e8450 --- /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..5f1b1338 --- /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: + :idxgroups: ["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..9f290cce --- /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 + :idxgroups: ["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..2d5e4ab2 --- /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 + :idxgroups: ["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..211f02b3 --- /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..8e04c207 --- /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..c7bcba96 --- /dev/null +++ b/test/test_argparse_directive.py @@ -0,0 +1,7 @@ +import pytest + + +@pytest.mark.sphinx('html', testroot='argparse-directive') +def test_bad_idxgroups(app, status, warning): + app.build() + assert 'failed to parse idxgroups 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..6eb1c3b6 --- /dev/null +++ b/test/test_commands_by_group_index.py @@ -0,0 +1,82 @@ +import os +from pathlib import Path + +import pytest + +from sphinxarg.ext import CommandsByGroupIndex + +from .conftest import check_xpath, flat_dict + + +@pytest.mark.parametrize( + "fname,expect", + flat_dict( + { + 'index.html': [ + (".//div[@role='navigation']//a[@class='reference internal']", 'Sample'), + (".//div[@role='navigation']//a[@class='reference internal']", 'Command A'), + (".//div[@role='navigation']//a[@class='reference internal']", 'Command B'), + (".//div[@role='navigation']//a[@class='reference internal']", 'Commands by Group'), + ], + 'commands-by-group.html': [ + (".//h1", 'Commands by Group'), + (".//tr/td[2]/strong", 'ham in a cone'), + (".//tr[td[2]/strong/text()='ham in a cone']/following-sibling::tr[1]/td[2]/a/code", 'sample-directive-opts'), + (".//tr[td[2]/strong/text()='ham in a cone']/following-sibling::tr[2]/td[2]/a/code", 'sample-directive-opts B'), + (".//tr/td[2]/strong", 'spam'), + (".//tr[td[2]/strong/text()='spam on a stick']/following-sibling::tr[1]/td[2]/a/code", 'sample-directive-opts'), + (".//tr[td[2]/strong/text()='spam on a stick']/following-sibling::tr[2]/td[2]/a/code", 'sample-directive-opts A'), + (".//tr/td[2]/em", '(other)', False), # Other does not have idxgroups 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", + flat_dict( + { + 'index.html': [ + (".//div[@role='navigation']//a[@class='reference internal']", 'Commands grouped by SomeName'), + ], + 'commands-groupedby-somename.html': [ + (".//h1", 'Commands grouped by SomeName'), + (".//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..cd86803d --- /dev/null +++ b/test/test_commands_index.py @@ -0,0 +1,30 @@ +import pytest + +from .conftest import check_xpath, flat_dict + + +@pytest.mark.parametrize( + "fname,expect", + flat_dict( + { + 'subcommand-a.html': [ + (".//h1", 'Sample', False), + (".//h1", 'Command A'), + ], + 'subcommand-b.html': [ + (".//h1", 'Sample', False), + (".//h1", 'Command B'), + ], + 'commands-index.html': [ + (".//h1", 'Commands Index'), + (".//tr/td[2]/a/code", 'sample-directive-opts'), + (".//tr/td[3]/em", 'Support SphinxArgParse HTML testing'), + (".//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..e4f31dd1 --- /dev/null +++ b/test/test_conf_options_html.py @@ -0,0 +1,33 @@ +"""Test the HTML builder with sphinx-argparse conf options and check output against XPath.""" + +import pytest + +from .conftest import check_xpath, flat_dict + + +@pytest.mark.parametrize( + "fname,expect", + flat_dict( + { + 'index.html': [ + (".//h1", 'Sample'), + (".//h2", 'Sub-commands'), + (".//h3", 'sample-directive-opts A'), # By default, just "A". + (".//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..9bc158ae 100644 --- a/test/test_default_html.py +++ b/test/test_default_html.py @@ -1,8 +1,11 @@ """Test the HTML builder and check output against XPath.""" import re +import posixpath import pytest +from sphinx.ext.intersphinx import INVENTORY_FILENAME +from sphinx.util.inventory import Inventory, InventoryFile def check_xpath(etree, fname, path, check, be_found=True): @@ -56,12 +59,15 @@ 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 +77,9 @@ 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 +110,32 @@ 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 / INVENTORY_FILENAME + assert inventory_file.exists() is True + + with inventory_file.open('rb') as f: + inv: Inventory = 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..963c4696 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,12 @@ 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 +414,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 +438,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', + }, }, ] From 8831d4861b8b3afae8e7a9fade9321c9215d4823 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Mon, 15 Jul 2024 17:15:35 +0100 Subject: [PATCH 2/4] Ruff --- sphinxarg/ext.py | 189 ++++++++++++------ sphinxarg/utils.py | 7 +- test/roots/test-argparse-directive/conf.py | 2 +- .../roots/test-command-by-group-index/conf.py | 4 +- test/roots/test-command-index/conf.py | 6 +- test/roots/test-conf-opts-html/conf.py | 2 +- test/test_commands_by_group_index.py | 94 +++++---- test/test_commands_index.py | 39 ++-- test/test_conf_options_html.py | 22 +- test/test_default_html.py | 36 +++- test/test_parser.py | 10 +- 11 files changed, 259 insertions(+), 152 deletions(-) diff --git a/sphinxarg/ext.py b/sphinxarg/ext.py index ba30b444..298818f9 100644 --- a/sphinxarg/ext.py +++ b/sphinxarg/ext.py @@ -1,37 +1,39 @@ from __future__ import annotations -import importlib import ast +import importlib +import operator import os import shutil import sys from argparse import ArgumentParser from collections import defaultdict -from typing import Dict, List, Optional, Union, cast +from typing import TYPE_CHECKING, cast from docutils import nodes from docutils.frontend import get_default_settings -from docutils.nodes import Element from docutils.parsers.rst import Parser from docutils.parsers.rst.directives import flag, unchanged from docutils.statemachine import StringList -from sphinx.ext.autodoc import mock -from sphinx.util.docutils import new_document -from sphinx.addnodes import pending_xref -from sphinx.application import Sphinx -from sphinx.builders import Builder from sphinx.domains import Domain, Index -from sphinx.environment import BuildEnvironment from sphinx.errors import ExtensionError +from sphinx.ext.autodoc import mock from sphinx.roles import XRefRole from sphinx.util import logging -from sphinx.util.docutils import SphinxDirective +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 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 + logger = logging.getLogger(__name__) @@ -149,8 +151,8 @@ class ArgParseDirective(SphinxDirective): 'markdownhelp': flag, 'idxgroups': unchanged, } - domain: Optional[Domain] = None - idxgroups: Optional[List[str]] = None + domain: Domain | None = None + idxgroups: list[str] | None = None def _construct_manpage_specific_structure(self, parser_info): """ @@ -273,7 +275,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']) ) @@ -355,12 +357,14 @@ def _print_subcommands(self, data, nested_content, markdown_help=False, settings if 'children' in data: full_command = command_pos_args(data) - node_id = make_id(self.env, self.state.document, '', full_command + "-sub-commands") + 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=[node_id, "Sub-commands"]) + subcommands = nodes.section(ids=['Sub-commands']) subcommands += nodes.title('Sub-commands', 'Sub-commands') for child in data['children']: @@ -400,10 +404,14 @@ def _print_subcommands(self, data, nested_content, markdown_help=False, settings 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): + 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): + for x in self._print_subcommands( + child, nested_content + subcontent, markdown_help, settings=settings + ): sec += x if 'epilog' in child and child['epilog']: @@ -415,7 +423,14 @@ def _print_subcommands(self, data, nested_content, markdown_help=False, settings return items - def _print_action_groups(self, data, nested_content, markdown_help=False, settings=None): + 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. @@ -424,14 +439,21 @@ def _print_action_groups(self, data, nested_content, markdown_help=False, settin nodes_list = [] if 'action_groups' in data: for action_group in data['action_groups']: - # Every action group is comprised of a section, holding a title, the description, and the option group (members) + # Every action group is composed of a section, holding + # a title, the description, and the option group (members) full_command = command_pos_args(data) - node_id = make_id(self.env, self.state.document, '', full_command + "-" + action_group['title'].replace(' ', '-').lower()) + node_id = make_id( + self.env, + self.state.document, + '', + full_command + '-' + action_group['title'].replace(' ', '-').lower(), + ) target = nodes.target('', '', ids=[node_id]) self.set_source_info(target) self.state.document.note_explicit_target(target) - section = nodes.section(ids=[node_id, action_group['title'].replace(' ', '-').lower()]) + title_as_id = action_group['title'].replace(' ', '-').lower() + section = nodes.section(ids=[node_id, f'{id_prefix}-{title_as_id}']) section += nodes.title(action_group['title'], action_group['title']) desc = [] @@ -458,7 +480,7 @@ def _print_action_groups(self, data, nested_content, markdown_help=False, settin local_definitions = definitions if len(subcontent) > 0: - local_definitions = {k: v for k, v in definitions.items()} + local_definitions = dict(definitions.items()) for k, v in map_nested_definitions(subcontent).items(): local_definitions[k] = v @@ -473,19 +495,19 @@ def _print_action_groups(self, data, nested_content, markdown_help=False, settin # Build the help text arg = [] if 'choices' in entry: - arg.append(f"Possible choices: {', '.join(str(c) for c in entry['choices'])}\n") + arg.append( + f"Possible choices: {', '.join(map(str, entry['choices']))}\n" + ) if 'help' in entry: arg.append(entry['help']) - if entry['default'] is not None and entry['default'] not in [ - '"==SUPPRESS=="', - '==SUPPRESS==', - ]: - if entry['default'] == '': - arg.append('Default: ""') - else: - arg.append(f"Default: {entry['default']}") - - # Handle nested content, the term used in the dict has the comma removed for simplicity + 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: @@ -510,8 +532,18 @@ def _print_action_groups(self, data, nested_content, markdown_help=False, settin 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): - self.domain = cast(SphinxArgParseDomain, self.env.get_domain(SphinxArgParseDomain.name)) + self.domain = cast( + SphinxArgParseDomain, self.env.get_domain(SphinxArgParseDomain.name) + ) if 'module' in self.options and 'func' in self.options: module_name = self.options['module'] @@ -600,11 +632,14 @@ def run(self): if 'idxgroups' in self.options: try: self.idxgroups = ast.literal_eval(self.options['idxgroups']) - except (SyntaxError, ValueError): - message = f"""Error in "{self.name}". In file "{self.env.doc2path(self.env.docname, False)}" - failed to parse idxgroups as a list: "{self.state_machine.line.strip()}". - """ - raise self.error(message) + except (SyntaxError, ValueError) as exc: + message = ( + f'Error in "{self.name}". ' + f'In file "{self.env.doc2path(self.env.docname, False)}" ' + 'failed to parse idxgroups as a list: ' + f'"{self.state_machine.line.strip()}".' + ) + raise self.error(message) from exc self.idxgroups = [x.strip() for x in self.idxgroups] else: self.idxgroups = [] @@ -654,10 +689,18 @@ def generate(self, docnames=None): content = defaultdict(list) commands = self.domain.get_objects() - commands = sorted(commands, key=lambda command: command[0]) + commands = sorted(commands, key=operator.itemgetter(0)) for cmd, dispname, _typ, docname, anchor, priority in commands: - content[cmd[0].lower()].append((cmd, priority, docname, anchor, docname, '', dispname)) + content[cmd[0].lower()].append(( + cmd, + priority, + docname, + anchor, + docname, + '', + dispname, + )) content = sorted(content.items()) return content, True @@ -673,7 +716,7 @@ def generate(self, docnames=None): bygroups = self.domain.data['commands-by-group'] for group in sorted(bygroups): - commands = sorted(bygroups[group], key=lambda command: command[0]) + commands = sorted(bygroups[group], key=operator.itemgetter(0)) for cmd, dispname, _typ, docname, anchor, priority in commands: content[group].append((cmd, priority, docname, anchor, docname, '', dispname)) @@ -686,8 +729,8 @@ class SphinxArgParseDomain(Domain): label = 'commands-label' roles = {'command': XRefRole()} - indices = {} # type: ignore - initial_data: Dict[str, Union[List, Dict]] = { + indices = {} + initial_data: dict[str, list | dict] = { 'commands': [], 'commands-by-group': defaultdict(list), } @@ -695,7 +738,7 @@ class SphinxArgParseDomain(Domain): # 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[str] = [] + temporary_index_files: list[str] = [] def get_full_qualified_name(self, node): return f'{node.arguments[0]}' @@ -703,9 +746,22 @@ def get_full_qualified_name(self, node): def get_objects(self): yield from self.data['commands'] - def resolve_xref(self, env: BuildEnvironment, fromdocname: str, builder: Builder, typ: str, target: str, node: pending_xref, contnode: Element): + def resolve_xref( + self, + env: BuildEnvironment, + fromdocname: str, + builder: Builder, + typ: str, + target: str, + node: pending_xref, + contnode: Element, + ): 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] + 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] @@ -713,13 +769,14 @@ def resolve_xref(self, env: BuildEnvironment, fromdocname: str, builder: Builder return make_refnode(builder, fromdocname, todocname, targ, contnode, targ) else: - logger.warning(f'Error, no command xref target from {fromdocname}:{target}') + msg = f'Error, no command xref target from {fromdocname}:{target}' + logger.warning(msg) return None - def add_command(self, result: Dict, anchor: str, groups: List[str] = None): + def add_command(self, result: dict, anchor: str, groups: list[str] = None): """Add an argparse command to the domain.""" full_command = command_pos_args(result) - desc = "No description." + desc = 'No description.' if 'description' in result: desc = result['description'] idx_entry = (full_command, desc, 'command', self.env.docname, anchor, 0) @@ -745,13 +802,19 @@ def create_temp_dummy_file(app: Sphinx, domain: Domain, docname: str, title: str dummy_file = os.path.join(app.srcdir, docname) domain = cast(SphinxArgParseDomain, domain) if os.path.exists(dummy_file): - raise ExtensionError(f'The Sphinx project cannot include a file named "{docname}" in the source directory.') - with open(dummy_file, "w") as f: - f.write(f"{title}\n") + msg = ( + f'The Sphinx project cannot include a file named ' + f'"{docname}" in the source directory.' + ) + raise ExtensionError(msg) + with open(dummy_file, 'w') as f: + f.write(f'{title}\n') f.write(f"{len(title) * '='}\n") - f.write("\n") - f.write("Temporary file that is replaced with an index from the sphinxarg extension.\n") - f.write(f"Creating this temporary file enables you to add {docname} to the toctree.\n") + f.write('\n') + f.write( + 'Temporary file that is replaced with an index from the sphinxarg extension.\n' + ) + f.write(f'Creating this temporary file enables you to add {docname} to the toctree.\n') domain.temporary_index_files.append(dummy_file) @@ -770,17 +833,17 @@ def configure_ext(app: Sphinx) -> None: by_group_index.localname = conf.get('commands_by_group_index_title') if ('commands_index_in_toctree', True) in conf.items(): build_index = True - docname = f"{SphinxArgParseDomain.name}-{CommandsIndex.name}.rst" - create_temp_dummy_file(app, domain, docname, f"{CommandsIndex.localname}") + docname = f'{SphinxArgParseDomain.name}-{CommandsIndex.name}.rst' + create_temp_dummy_file(app, domain, docname, f'{CommandsIndex.localname}') if ('commands_by_group_index_in_toctree', True) in conf.items(): build_by_group_index = True - docname = f"{SphinxArgParseDomain.name}-{by_group_index.name}.rst" - create_temp_dummy_file(app, domain, docname, f"{by_group_index.localname}") + docname = f'{SphinxArgParseDomain.name}-{by_group_index.name}.rst' + create_temp_dummy_file(app, domain, docname, f'{by_group_index.localname}') if build_index or ('build_commands_index', True) in conf.items(): - domain.indices.append(CommandsIndex) # type: ignore + domain.indices.append(CommandsIndex) if build_by_group_index or ('build_commands_by_group_index', True) in conf.items(): - domain.indices.append(by_group_index) # type: ignore + domain.indices.append(by_group_index) # Call setup so that :ref:`commands-...` are link targets. domain.setup() @@ -790,7 +853,7 @@ def setup(app: Sphinx): app.setup_extension('sphinx.ext.autodoc') app.add_domain(SphinxArgParseDomain) app.add_directive('argparse', ArgParseDirective) - app.add_config_value('sphinx_argparse_conf', {}, 'html', Dict) + app.add_config_value('sphinx_argparse_conf', {}, 'html', dict) app.connect('builder-inited', configure_ext) app.connect('build-finished', delete_dummy_file) return { diff --git a/sphinxarg/utils.py b/sphinxarg/utils.py index 15353f28..3cf7e063 100644 --- a/sphinxarg/utils.py +++ b/sphinxarg/utils.py @@ -20,7 +20,7 @@ def command_pos_args(result: dict) -> str: >>> command_pos_args("blah") '' """ - ret = "" + ret = '' if 'name' in result and result['name'] != '': ret += f"{result['name']}" @@ -43,12 +43,13 @@ def target_to_anchor_id(target: str) -> str: 'simple-command-A' """ if len(target) < 1: - raise ValueError('Supplied target string is less than one character long.') + msg = 'Supplied target string is less than one character long.' + raise ValueError(msg) return target.replace(' ', '-') -if __name__ == "__main__": +if __name__ == '__main__': import doctest doctest.testmod() diff --git a/test/roots/test-argparse-directive/conf.py b/test/roots/test-argparse-directive/conf.py index 8e04c207..d08cd8eb 100644 --- a/test/roots/test-argparse-directive/conf.py +++ b/test/roots/test-argparse-directive/conf.py @@ -1 +1 @@ -extensions = ["sphinxarg.ext"] +extensions = ['sphinxarg.ext'] diff --git a/test/roots/test-command-by-group-index/conf.py b/test/roots/test-command-by-group-index/conf.py index e20e8450..ae6fc22a 100644 --- a/test/roots/test-command-by-group-index/conf.py +++ b/test/roots/test-command-by-group-index/conf.py @@ -1,4 +1,4 @@ -extensions = ["sphinxarg.ext"] +extensions = ['sphinxarg.ext'] sphinx_argparse_conf = { - "commands_by_group_index_in_toctree": True, + 'commands_by_group_index_in_toctree': True, } diff --git a/test/roots/test-command-index/conf.py b/test/roots/test-command-index/conf.py index 211f02b3..6f887515 100644 --- a/test/roots/test-command-index/conf.py +++ b/test/roots/test-command-index/conf.py @@ -1,5 +1,5 @@ -extensions = ["sphinxarg.ext"] +extensions = ['sphinxarg.ext'] sphinx_argparse_conf = { - "build_commands_index": True, - "commands_index_in_toctree": True, + 'build_commands_index': True, + 'commands_index_in_toctree': True, } diff --git a/test/roots/test-conf-opts-html/conf.py b/test/roots/test-conf-opts-html/conf.py index 8e04c207..d08cd8eb 100644 --- a/test/roots/test-conf-opts-html/conf.py +++ b/test/roots/test-conf-opts-html/conf.py @@ -1 +1 @@ -extensions = ["sphinxarg.ext"] +extensions = ['sphinxarg.ext'] diff --git a/test/test_commands_by_group_index.py b/test/test_commands_by_group_index.py index 6eb1c3b6..9a8e30b4 100644 --- a/test/test_commands_by_group_index.py +++ b/test/test_commands_by_group_index.py @@ -9,27 +9,44 @@ @pytest.mark.parametrize( - "fname,expect", - flat_dict( - { - 'index.html': [ - (".//div[@role='navigation']//a[@class='reference internal']", 'Sample'), - (".//div[@role='navigation']//a[@class='reference internal']", 'Command A'), - (".//div[@role='navigation']//a[@class='reference internal']", 'Command B'), - (".//div[@role='navigation']//a[@class='reference internal']", 'Commands by Group'), - ], - 'commands-by-group.html': [ - (".//h1", 'Commands by Group'), - (".//tr/td[2]/strong", 'ham in a cone'), - (".//tr[td[2]/strong/text()='ham in a cone']/following-sibling::tr[1]/td[2]/a/code", 'sample-directive-opts'), - (".//tr[td[2]/strong/text()='ham in a cone']/following-sibling::tr[2]/td[2]/a/code", 'sample-directive-opts B'), - (".//tr/td[2]/strong", 'spam'), - (".//tr[td[2]/strong/text()='spam on a stick']/following-sibling::tr[1]/td[2]/a/code", 'sample-directive-opts'), - (".//tr[td[2]/strong/text()='spam on a stick']/following-sibling::tr[2]/td[2]/a/code", 'sample-directive-opts A'), - (".//tr/td[2]/em", '(other)', False), # Other does not have idxgroups set at all and is not present. - ], - } - ), + ('fname', 'expect'), + flat_dict({ + 'index.html': [ + (".//div[@role='navigation']//a[@class='reference internal']", 'Sample'), + (".//div[@role='navigation']//a[@class='reference internal']", 'Command A'), + (".//div[@role='navigation']//a[@class='reference internal']", 'Command B'), + ( + ".//div[@role='navigation']//a[@class='reference internal']", + 'Commands by Group', + ), + ], + 'commands-by-group.html': [ + ('.//h1', 'Commands by Group'), + ('.//tr/td[2]/strong', 'ham in a cone'), + ( + ".//tr[td[2]/strong/text()='ham in a cone']/following-sibling::tr[1]/td[2]/a/code", # NoQA: E501 + 'sample-directive-opts', + ), + ( + ".//tr[td[2]/strong/text()='ham in a cone']/following-sibling::tr[2]/td[2]/a/code", # NoQA: E501 + 'sample-directive-opts B', + ), + ('.//tr/td[2]/strong', 'spam'), + ( + ".//tr[td[2]/strong/text()='spam on a stick']/following-sibling::tr[1]/td[2]/a/code", # NoQA: E501 + 'sample-directive-opts', + ), + ( + ".//tr[td[2]/strong/text()='spam on a stick']/following-sibling::tr[2]/td[2]/a/code", # NoQA: E501 + 'sample-directive-opts A', + ), + ( + './/tr/td[2]/em', + '(other)', + False, + ), # Other does not have idxgroups 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): @@ -38,27 +55,28 @@ def test_commands_by_group_index_html(app, cached_etree_parse, fname, expect): @pytest.mark.parametrize( - "fname,expect", - flat_dict( - { - 'index.html': [ - (".//div[@role='navigation']//a[@class='reference internal']", 'Commands grouped by SomeName'), - ], - 'commands-groupedby-somename.html': [ - (".//h1", 'Commands grouped by SomeName'), - (".//h1", 'Commands by Group', False), - ], - } - ), + ('fname', 'expect'), + flat_dict({ + 'index.html': [ + ( + ".//div[@role='navigation']//a[@class='reference internal']", + 'Commands grouped by SomeName', + ), + ], + 'commands-groupedby-somename.html': [ + ('.//h1', 'Commands grouped by SomeName'), + ('.//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, + 'commands_by_group_index_title': 'Commands grouped by SomeName', + 'commands_by_group_index_file_suffix': 'groupedby-somename', + 'commands_by_group_index_in_toctree': True, } }, ) @@ -78,5 +96,5 @@ def update_toctree(app): @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 + 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 index cd86803d..7b87f8a7 100644 --- a/test/test_commands_index.py +++ b/test/test_commands_index.py @@ -4,25 +4,26 @@ @pytest.mark.parametrize( - "fname,expect", - flat_dict( - { - 'subcommand-a.html': [ - (".//h1", 'Sample', False), - (".//h1", 'Command A'), - ], - 'subcommand-b.html': [ - (".//h1", 'Sample', False), - (".//h1", 'Command B'), - ], - 'commands-index.html': [ - (".//h1", 'Commands Index'), - (".//tr/td[2]/a/code", 'sample-directive-opts'), - (".//tr/td[3]/em", 'Support SphinxArgParse HTML testing'), - (".//tr[td[2]/a/code/text()='sample-directive-opts']/td[3]/em", 'Support SphinxArgParse HTML testing'), - ], - } - ), + ('fname', 'expect'), + flat_dict({ + 'subcommand-a.html': [ + ('.//h1', 'Sample', False), + ('.//h1', 'Command A'), + ], + 'subcommand-b.html': [ + ('.//h1', 'Sample', False), + ('.//h1', 'Command B'), + ], + 'commands-index.html': [ + ('.//h1', 'Commands Index'), + ('.//tr/td[2]/a/code', 'sample-directive-opts'), + ('.//tr/td[3]/em', 'Support SphinxArgParse HTML testing'), + ( + ".//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): diff --git a/test/test_conf_options_html.py b/test/test_conf_options_html.py index e4f31dd1..d7d29084 100644 --- a/test/test_conf_options_html.py +++ b/test/test_conf_options_html.py @@ -6,24 +6,22 @@ @pytest.mark.parametrize( - "fname,expect", - flat_dict( - { - 'index.html': [ - (".//h1", 'Sample'), - (".//h2", 'Sub-commands'), - (".//h3", 'sample-directive-opts A'), # By default, just "A". - (".//h3", 'sample-directive-opts B'), - ], - } - ), + ('fname', 'expect'), + flat_dict({ + 'index.html': [ + ('.//h1', 'Sample'), + ('.//h2', 'Sub-commands'), + ('.//h3', 'sample-directive-opts A'), # By default, just "A". + ('.//h3', 'sample-directive-opts B'), + ], + }), ) @pytest.mark.sphinx( 'html', testroot='conf-opts-html', confoverrides={ 'sphinx_argparse_conf': { - "full_subcommand_name": True, + 'full_subcommand_name': True, } }, ) diff --git a/test/test_default_html.py b/test/test_default_html.py index 9bc158ae..f7fc364f 100644 --- a/test/test_default_html.py +++ b/test/test_default_html.py @@ -1,7 +1,7 @@ """Test the HTML builder and check output against XPath.""" -import re import posixpath +import re import pytest from sphinx.ext.intersphinx import INVENTORY_FILENAME @@ -61,10 +61,16 @@ def get_text(node): ('.//h2', 'Positional Arguments'), (".//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-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-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']", ''), @@ -79,7 +85,10 @@ def get_text(node): ('.//h2', 'Positional Arguments'), (".//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'), + ( + ".//section[@id='sample-directive-opts-A-positional-arguments']/dl/dt[1]/kbd", + 'baz', + ), ], ), ( @@ -115,10 +124,10 @@ def test_default_html(app, cached_etree_parse, fname, expect_list): @pytest.mark.sphinx('html', testroot='default-html') def test_index_is_optional(app, cached_etree_parse): app.build() - index_file = app.outdir / "index.html" + 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" + command_index_file = app.outdir / 'commands-index.html' assert command_index_file.exists() is False @@ -132,10 +141,19 @@ def test_object_inventory(app, cached_etree_parse): inv: Inventory = 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 ( + '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 ( + '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] + 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 963c4696..c6319097 100755 --- a/test/test_parser.py +++ b/test/test_parser.py @@ -274,7 +274,15 @@ def test_parse_nested_traversal(): ], } ], - 'parent': {'name': 'level2', 'prog': '', 'parent': {'name': 'level1', 'prog': '', 'parent': {'name': '', 'prog': 'under-test'}}}, + 'parent': { + 'name': 'level2', + 'prog': '', + 'parent': { + 'name': 'level1', + 'prog': '', + 'parent': {'name': '', 'prog': 'under-test'}, + }, + }, } ] From 1fda2b25c5cfbea1363af8be93086439bd1366ca Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Mon, 15 Jul 2024 17:33:27 +0100 Subject: [PATCH 3/4] Mypy --- sphinxarg/ext.py | 14 ++++--- test/conftest.py | 5 --- test/test_commands_by_group_index.py | 61 +++++++++++++++++++--------- test/test_commands_index.py | 29 ++++++------- test/test_conf_options_html.py | 16 ++++---- test/test_default_html.py | 45 +------------------- test/utils/__init__.py | 0 test/utils/xpath.py | 42 +++++++++++++++++++ 8 files changed, 114 insertions(+), 98 deletions(-) create mode 100644 test/utils/__init__.py create mode 100644 test/utils/xpath.py diff --git a/sphinxarg/ext.py b/sphinxarg/ext.py index 298818f9..a3f8f16f 100644 --- a/sphinxarg/ext.py +++ b/sphinxarg/ext.py @@ -28,6 +28,8 @@ from sphinxarg.utils import command_pos_args, target_to_anchor_id if TYPE_CHECKING: + from collections.abc import Sequence + from docutils.nodes import Element from sphinx.addnodes import pending_xref from sphinx.application import Sphinx @@ -152,7 +154,7 @@ class ArgParseDirective(SphinxDirective): 'idxgroups': unchanged, } domain: Domain | None = None - idxgroups: list[str] | None = None + idxgroups: Sequence[str] = () def _construct_manpage_specific_structure(self, parser_info): """ @@ -728,8 +730,10 @@ class SphinxArgParseDomain(Domain): name = 'commands' label = 'commands-label' - roles = {'command': XRefRole()} - indices = {} + roles = { + 'command': XRefRole(), + } + indices = [] initial_data: dict[str, list | dict] = { 'commands': [], 'commands-by-group': defaultdict(list), @@ -773,7 +777,7 @@ def resolve_xref( logger.warning(msg) return None - def add_command(self, result: dict, anchor: str, groups: list[str] = None): + def add_command(self, result: dict, anchor: str, groups: Sequence[str] = ()): """Add an argparse command to the domain.""" full_command = command_pos_args(result) desc = 'No description.' @@ -786,7 +790,7 @@ def add_command(self, result: dict, anchor: str, groups: list[str] = None): # A separate list is kept to avoid the edge case that a command is used # once as part of a group (with idxgroups) and another time without the # option. - for group in groups or []: + for group in groups: self.data['commands-by-group'][group].append(idx_entry) 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/test_commands_by_group_index.py b/test/test_commands_by_group_index.py index 9a8e30b4..a004b0ed 100644 --- a/test/test_commands_by_group_index.py +++ b/test/test_commands_by_group_index.py @@ -4,49 +4,71 @@ import pytest from sphinxarg.ext import CommandsByGroupIndex - -from .conftest import check_xpath, flat_dict +from test.utils.xpath import check_xpath @pytest.mark.parametrize( ('fname', 'expect'), - flat_dict({ - 'index.html': [ + [ + ( + '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'), - ('.//tr/td[2]/strong', 'ham in a cone'), + ), + ('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', ), - ('.//tr/td[2]/strong', 'spam'), + ), + ('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 idxgroups set at all and is not present. - ], - }), + ), + ), # Other does not have idxgroups 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): @@ -56,18 +78,17 @@ def test_commands_by_group_index_html(app, cached_etree_parse, fname, expect): @pytest.mark.parametrize( ('fname', 'expect'), - flat_dict({ - 'index.html': [ + [ + ( + 'index.html', ( ".//div[@role='navigation']//a[@class='reference internal']", 'Commands grouped by SomeName', ), - ], - 'commands-groupedby-somename.html': [ - ('.//h1', 'Commands grouped by SomeName'), - ('.//h1', 'Commands by Group', False), - ], - }), + ), + ('commands-groupedby-somename.html', ('.//h1', 'Commands grouped by SomeName')), + ('commands-groupedby-somename.html', ('.//h1', 'Commands by Group', False)), + ], ) @pytest.mark.sphinx( 'html', diff --git a/test/test_commands_index.py b/test/test_commands_index.py index 7b87f8a7..11b8befa 100644 --- a/test/test_commands_index.py +++ b/test/test_commands_index.py @@ -1,29 +1,26 @@ import pytest -from .conftest import check_xpath, flat_dict +from test.utils.xpath import check_xpath @pytest.mark.parametrize( ('fname', 'expect'), - flat_dict({ - 'subcommand-a.html': [ - ('.//h1', 'Sample', False), - ('.//h1', 'Command A'), - ], - 'subcommand-b.html': [ - ('.//h1', 'Sample', False), - ('.//h1', 'Command B'), - ], - 'commands-index.html': [ - ('.//h1', 'Commands Index'), - ('.//tr/td[2]/a/code', 'sample-directive-opts'), - ('.//tr/td[3]/em', 'Support SphinxArgParse HTML testing'), + [ + ('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): diff --git a/test/test_conf_options_html.py b/test/test_conf_options_html.py index d7d29084..51e4cbaa 100644 --- a/test/test_conf_options_html.py +++ b/test/test_conf_options_html.py @@ -2,19 +2,17 @@ import pytest -from .conftest import check_xpath, flat_dict +from test.utils.xpath import check_xpath @pytest.mark.parametrize( ('fname', 'expect'), - flat_dict({ - 'index.html': [ - ('.//h1', 'Sample'), - ('.//h2', 'Sub-commands'), - ('.//h3', 'sample-directive-opts A'), # By default, just "A". - ('.//h3', 'sample-directive-opts B'), - ], - }), + [ + ('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', diff --git a/test/test_default_html.py b/test/test_default_html.py index f7fc364f..57cfcb29 100644 --- a/test/test_default_html.py +++ b/test/test_default_html.py @@ -1,52 +1,11 @@ """Test the HTML builder and check output against XPath.""" import posixpath -import re import pytest -from sphinx.ext.intersphinx import INVENTORY_FILENAME from sphinx.util.inventory import Inventory, 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( @@ -134,7 +93,7 @@ def test_index_is_optional(app, cached_etree_parse): @pytest.mark.sphinx('html', testroot='default-html') def test_object_inventory(app, cached_etree_parse): app.build() - inventory_file = app.outdir / INVENTORY_FILENAME + inventory_file = app.outdir / 'objects.inv' assert inventory_file.exists() is True with inventory_file.open('rb') as f: 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) From 5819633bcb32ecf5a43bdf051f54c5f5a97e7508 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Mon, 15 Jul 2024 18:21:20 +0100 Subject: [PATCH 4/4] typing and other improvements --- docs/changelog.rst | 2 +- docs/usage.rst | 8 +- sphinxarg/ext.py | 189 ++++++++---------- test/roots/test-argparse-directive/index.rst | 2 +- .../test-command-by-group-index/sample.rst | 2 +- .../subcommand-a.rst | 2 +- .../subcommand-b.rst | 2 +- test/test_argparse_directive.py | 4 +- test/test_commands_by_group_index.py | 2 +- test/test_default_html.py | 4 +- 10 files changed, 99 insertions(+), 118 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 3e8ce283..74f758c1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,7 +8,7 @@ Change log 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 ``:idxgroups:`` field to the directive for an command-by-group 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. diff --git a/docs/usage.rst b/docs/usage.rst index 17d40ed8..43335ddd 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -107,7 +107,7 @@ Other useful directives :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). -:idxgroups: This option is related to grouping related commands in an index. +:index-groups: This option is related to grouping related commands in an index. Printing Fully Qualified Sub-Command Headings @@ -169,8 +169,8 @@ To enable the more complex index, add the following to the project ``conf.py`` f "commands_by_group_index_in_toctree": True, } -Add the ``:idxgroups:`` option to the ``argparse`` directive in your documentation files. -Specify one or more groups that the command belongs to. +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 @@ -178,7 +178,7 @@ Specify one or more groups that the command belongs to. :filename: ../test/sample.py :func: parser :prog: sample - :idxgroups: ["Basic Commands"] + :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. diff --git a/sphinxarg/ext.py b/sphinxarg/ext.py index a3f8f16f..a471e9f9 100644 --- a/sphinxarg/ext.py +++ b/sphinxarg/ext.py @@ -1,13 +1,11 @@ from __future__ import annotations -import ast import importlib import operator import os import shutil import sys from argparse import ArgumentParser -from collections import defaultdict from typing import TYPE_CHECKING, cast from docutils import nodes @@ -15,7 +13,7 @@ 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 +from sphinx.domains import Domain, Index, IndexEntry from sphinx.errors import ExtensionError from sphinx.ext.autodoc import mock from sphinx.roles import XRefRole @@ -28,7 +26,8 @@ from sphinxarg.utils import command_pos_args, target_to_anchor_id if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Iterable, Sequence + from pathlib import Path from docutils.nodes import Element from sphinx.addnodes import pending_xref @@ -36,6 +35,8 @@ from sphinx.builders import Builder from sphinx.environment import BuildEnvironment + _ObjectDescriptionTuple = tuple[str, str, str, str, str, int] + logger = logging.getLogger(__name__) @@ -151,10 +152,9 @@ class ArgParseDirective(SphinxDirective): 'nodescription': unchanged, 'markdown': flag, 'markdownhelp': flag, - 'idxgroups': unchanged, + 'index-groups': unchanged, } - domain: Domain | None = None - idxgroups: Sequence[str] = () + index_groups: Sequence[str] = () def _construct_manpage_specific_structure(self, parser_info): """ @@ -353,9 +353,10 @@ def _print_subcommands(self, data, nested_content, markdown_help=False, settings definitions = map_nested_definitions(nested_content) items = [] - env = self.state.document.settings.env - conf = env.config.sphinx_argparse_conf - domain = cast(SphinxArgParseDomain, env.domains[SphinxArgParseDomain.name]) + 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) @@ -377,13 +378,13 @@ def _print_subcommands(self, data, nested_content, markdown_help=False, settings self.state.document.note_explicit_target(target) sec = nodes.section(ids=[node_id, child['name']]) - if ('full_subcommand_name', True) in conf.items(): + if full_subcommand_name_true: title = nodes.title(full_command, full_command) else: title = nodes.title(child['name'], child['name']) sec += title - domain.add_command(child, node_id, self.idxgroups) + domain.add_argparse_command(child, node_id, self.index_groups) if 'description' in child and child['description']: desc = [child['description']] @@ -443,18 +444,18 @@ def _print_action_groups( 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 + '-' + action_group['title'].replace(' ', '-').lower(), + full_command + '-' + title_as_id, ) target = nodes.target('', '', ids=[node_id]) self.set_source_info(target) self.state.document.note_explicit_target(target) - title_as_id = action_group['title'].replace(' ', '-').lower() section = nodes.section(ids=[node_id, f'{id_prefix}-{title_as_id}']) section += nodes.title(action_group['title'], action_group['title']) @@ -543,10 +544,6 @@ def _is_suppressed(item: str | None) -> bool: return item == '==SUPPRESS==' def run(self): - self.domain = cast( - SphinxArgParseDomain, self.env.get_domain(SphinxArgParseDomain.name) - ) - if 'module' in self.options and 'func' in self.options: module_name = self.options['module'] attr_name = self.options['func'] @@ -631,20 +628,10 @@ def run(self): else: items.append(self._nested_parse_paragraph(result['description'])) - if 'idxgroups' in self.options: - try: - self.idxgroups = ast.literal_eval(self.options['idxgroups']) - except (SyntaxError, ValueError) as exc: - message = ( - f'Error in "{self.name}". ' - f'In file "{self.env.doc2path(self.env.docname, False)}" ' - 'failed to parse idxgroups as a list: ' - f'"{self.state_machine.line.strip()}".' - ) - raise self.error(message) from exc - self.idxgroups = [x.strip() for x in self.idxgroups] + if 'index-groups' in self.options: + self.index_groups = list(map(str.strip, self.options['index-groups'].split(', '))) else: - self.idxgroups = [] + self.index_groups = [] full_command = command_pos_args(result) node_id = make_id(self.env, self.state.document, '', full_command) @@ -653,7 +640,8 @@ def run(self): self.set_source_info(target) self.state.document.note_explicit_target(target) - self.domain.add_command(result, node_id, self.idxgroups) + 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( @@ -687,46 +675,37 @@ class CommandsIndex(Index): name = 'index' localname = 'Commands Index' - def generate(self, docnames=None): - content = defaultdict(list) - - commands = self.domain.get_objects() - commands = sorted(commands, key=operator.itemgetter(0)) - + 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: - content[cmd[0].lower()].append(( - cmd, - priority, - docname, - anchor, - docname, - '', - dispname, - )) - - content = sorted(content.items()) - return content, True + 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=None): - content = defaultdict(list) - - bygroups = self.domain.data['commands-by-group'] - - for group in sorted(bygroups): - commands = sorted(bygroups[group], key=operator.itemgetter(0)) + 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: - content[group].append((cmd, priority, docname, anchor, docname, '', dispname)) - - content = sorted(content.items()) - return content, True + idx_entry = IndexEntry(cmd, priority, docname, anchor, docname, '', dispname) + content.setdefault(group, []).append(idx_entry) + return sorted(content.items()), True -class SphinxArgParseDomain(Domain): +class ArgParseDomain(Domain): name = 'commands' label = 'commands-label' @@ -734,20 +713,22 @@ class SphinxArgParseDomain(Domain): 'command': XRefRole(), } indices = [] - initial_data: dict[str, list | dict] = { + initial_data: dict[ + str, list[_ObjectDescriptionTuple] | dict[str, list[_ObjectDescriptionTuple]] + ] = { 'commands': [], - 'commands-by-group': defaultdict(list), + '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[str] = [] + temporary_index_files: list[Path] = [] - def get_full_qualified_name(self, node): - return f'{node.arguments[0]}' + def get_full_qualified_name(self, node: Element) -> str: + return str(node.arguments[0]) - def get_objects(self): + def get_objects(self) -> Iterable[_ObjectDescriptionTuple]: yield from self.data['commands'] def resolve_xref( @@ -759,7 +740,7 @@ def resolve_xref( target: str, node: pending_xref, contnode: Element, - ): + ) -> Element | None: anchor_id = target_to_anchor_id(target) match = [ (docname, anchor) @@ -777,77 +758,77 @@ def resolve_xref( logger.warning(msg) return None - def add_command(self, result: dict, anchor: str, groups: Sequence[str] = ()): + 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 = 'No description.' - if 'description' in result: - desc = result['description'] + 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 idxgroups) and another time without the + # 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: - self.data['commands-by-group'][group].append(idx_entry) + commands_by_group.setdefault(group, []).append(idx_entry) -def delete_dummy_file(app: Sphinx, _) -> None: +def _delete_temporary_files(app: Sphinx, _err) -> None: assert app.env is not None - domain = cast(SphinxArgParseDomain, app.env.domains[SphinxArgParseDomain.name]) + domain = cast(ArgParseDomain, app.env.domains[ArgParseDomain.name]) for fpath in domain.temporary_index_files: - if os.path.exists(fpath): - os.unlink(fpath) + fpath.unlink(missing_ok=True) -def create_temp_dummy_file(app: Sphinx, domain: Domain, docname: str, title: str) -> None: - dummy_file = os.path.join(app.srcdir, docname) - domain = cast(SphinxArgParseDomain, domain) - if os.path.exists(dummy_file): +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) - with open(dummy_file, 'w') as f: - f.write(f'{title}\n') - f.write(f"{len(title) * '='}\n") - f.write('\n') - f.write( - 'Temporary file that is replaced with an index from the sphinxarg extension.\n' - ) - f.write(f'Creating this temporary file enables you to add {docname} to the toctree.\n') + + 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 - assert app.env is not None - domain = cast(SphinxArgParseDomain, app.env.domains[SphinxArgParseDomain.name]) - by_group_index = CommandsByGroupIndex + 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 - by_group_index.name = conf.get('commands_by_group_index_file_suffix') + CommandsByGroupIndex.name = conf.get('commands_by_group_index_file_suffix') if 'commands_by_group_index_title' in conf: build_by_group_index = True - by_group_index.localname = conf.get('commands_by_group_index_title') + CommandsByGroupIndex.localname = conf.get('commands_by_group_index_title') if ('commands_index_in_toctree', True) in conf.items(): build_index = True - docname = f'{SphinxArgParseDomain.name}-{CommandsIndex.name}.rst' - create_temp_dummy_file(app, domain, docname, f'{CommandsIndex.localname}') + 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'{SphinxArgParseDomain.name}-{by_group_index.name}.rst' - create_temp_dummy_file(app, domain, docname, f'{by_group_index.localname}') + 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(by_group_index) + domain.indices.append(CommandsByGroupIndex) # Call setup so that :ref:`commands-...` are link targets. domain.setup() @@ -855,11 +836,11 @@ def configure_ext(app: Sphinx) -> None: def setup(app: Sphinx): app.setup_extension('sphinx.ext.autodoc') - app.add_domain(SphinxArgParseDomain) + app.add_domain(ArgParseDomain) app.add_directive('argparse', ArgParseDirective) - app.add_config_value('sphinx_argparse_conf', {}, 'html', dict) + app.add_config_value('sphinx_argparse_conf', {}, 'html', types={dict}) app.connect('builder-inited', configure_ext) - app.connect('build-finished', delete_dummy_file) + app.connect('build-finished', _delete_temporary_files) return { 'version': __version__, 'parallel_read_safe': True, diff --git a/test/roots/test-argparse-directive/index.rst b/test/roots/test-argparse-directive/index.rst index 05881bc7..ee2cfa66 100644 --- a/test/roots/test-argparse-directive/index.rst +++ b/test/roots/test-argparse-directive/index.rst @@ -5,4 +5,4 @@ Fails to parse :filename: test/sample-directive-opts.py :prog: sample-directive-opts :func: get_parser - :idxgroups: "Needs"; "Commas" + :index-groups: Needs; Commas diff --git a/test/roots/test-command-by-group-index/sample.rst b/test/roots/test-command-by-group-index/sample.rst index 5f1b1338..a84877bd 100644 --- a/test/roots/test-command-by-group-index/sample.rst +++ b/test/roots/test-command-by-group-index/sample.rst @@ -6,4 +6,4 @@ Sample :prog: sample-directive-opts :func: get_parser :nosubcommands: - :idxgroups: ["spam on a stick", "ham in a cone"] + :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 index 9f290cce..d5959a8d 100644 --- a/test/roots/test-command-by-group-index/subcommand-a.rst +++ b/test/roots/test-command-by-group-index/subcommand-a.rst @@ -6,4 +6,4 @@ Command A :prog: sample-directive-opts :func: get_parser :path: A - :idxgroups: ["spam on a stick"] + :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 index 2d5e4ab2..1817a39a 100644 --- a/test/roots/test-command-by-group-index/subcommand-b.rst +++ b/test/roots/test-command-by-group-index/subcommand-b.rst @@ -6,4 +6,4 @@ Command B :prog: sample-directive-opts :func: get_parser :path: B - :idxgroups: ["ham in a cone"] + :index-groups: ham in a cone diff --git a/test/test_argparse_directive.py b/test/test_argparse_directive.py index c7bcba96..f12304ea 100644 --- a/test/test_argparse_directive.py +++ b/test/test_argparse_directive.py @@ -2,6 +2,6 @@ @pytest.mark.sphinx('html', testroot='argparse-directive') -def test_bad_idxgroups(app, status, warning): +def test_bad_index_groups(app, status, warning): app.build() - assert 'failed to parse idxgroups as a list' in warning.getvalue() + 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 index a004b0ed..d4b9f25e 100644 --- a/test/test_commands_by_group_index.py +++ b/test/test_commands_by_group_index.py @@ -67,7 +67,7 @@ '(other)', False, ), - ), # Other does not have idxgroups set at all and is not present. + ), # Other does not have index-groups set at all and is not present. ], ) @pytest.mark.sphinx('html', testroot='command-by-group-index') diff --git a/test/test_default_html.py b/test/test_default_html.py index 57cfcb29..b9325c0e 100644 --- a/test/test_default_html.py +++ b/test/test_default_html.py @@ -3,7 +3,7 @@ import posixpath import pytest -from sphinx.util.inventory import Inventory, InventoryFile +from sphinx.util.inventory import InventoryFile from test.utils.xpath import check_xpath @@ -97,7 +97,7 @@ def test_object_inventory(app, cached_etree_parse): assert inventory_file.exists() is True with inventory_file.open('rb') as f: - inv: Inventory = InventoryFile.load(f, 'test/path', posixpath.join) + inv = InventoryFile.load(f, 'test/path', posixpath.join) assert 'sample-directive-opts' in inv.get('commands:command') assert (