/blog/

2023 1120 Improving argparse documentation generation

cog is a neat little library for inserting text into files. It’s small, easy to read, and dependency-free. You might want to use it for automatically inserting help text in a project readme.

In a previous post, I discovered how to recursively generate Argparse help for pdoc. Today I discovered that this is also useful for cog, and that there’s a better way to wrap the text.

The result still recursively formats help for every subcommand (and sub-sub-command, and so forth), and is still useful with pdoc too (see the older post for how that works).

Better text wrapping

In the last post, I was wrapping text manually with textwrap. However, we can use a formatter_class to do this for us. This has the benefit of somewhat shorter code, but more importantly, it handles long lines better. Argparse can indent more intelligently than the textwrap approach by indenting to a tab stop and only breaking on words when possible.

Here’s an example of the result:

options:
  -h, --help           show this help message and exit
  --verbose            Increase verbosity of output
  --longhelp LONGHELP  This is a very long help text. This is a very long help
                       text. This is a very long help text. This is a very long
                       help text. This is a very long help text. This is a very
                       long help text. This is a very long help text. This is a
                       very long help text. This is a very long help text. This
                       is a very long help text.

And here’s the code:

def get_argparse_help_string(
    name: str, parser: argparse.ArgumentParser, wrap: int = 80
) -> str:
    """Generate a docstring for an argparse parser that shows the help for the parser and all subparsers, recursively.

    Based on an idea from <https://github.com/pdoc3/pdoc/issues/89>

    Arguments:
    * `name`: The name of the program
    * `parser`: The parser
    * `wrap`: The number of characters to wrap the help text to (0 to disable)
    """

    def help_formatter(prog):
        return argparse.HelpFormatter(prog, width=wrap)

    def get_parser_help_recursive(
        parser: argparse.ArgumentParser, cmd: str = "", root: bool = True
    ):
        docstring = ""
        if not root:
            docstring += "\n" + "_" * 72 + "\n\n"
        docstring += f"> {cmd} --help\n"
        parser.formatter_class = help_formatter
        docstring += parser.format_help()

        for action in parser._actions:
            if isinstance(action, argparse._SubParsersAction):
                for subcmd, subparser in action.choices.items():
                    docstring += get_parser_help_recursive(
                        subparser, f"{cmd} {subcmd}", root=False
                    )
        return docstring

    docstring = get_parser_help_recursive(parser, name)
    return docstring

Use with cog

We’ll adapt our example project from the previous post.

In this context, we need the project to be installed with pip install -e into the same venv that contains cog, so that cog can import it and run its make_parser() function. The best way to handle this is to make a real Python package, and include cogapp as an optional dependency. That means we need a few more files this time.

Here’s our directory layout:

  • exampleapp/: Our package’s directory
    • pyproject.toml: A Python project file; this might be setup.py or whatever instead
    • readme.md: The readme, where we want to include autogenerated help
    • src/exampleapp/: The package’s source code
      • __init__.py: Our Python package

And here are the file contents:

pyproject.toml

Note that we include cogapp in our list of optional development dependencies. This will automatically install it for us when we do pip install -e .[development] in a moment.

[project]
name = "exampleapp"
license = { text = "WTFPL" }
readme = "readme.md"
version = "0.0.1"

[project.scripts]
exampleapp = "exampleapp:main"

[project.optional-dependencies]
development = [
    "cogapp",
]

[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
readme.md

Note the Cog placeholder in comments.

# Example project

This is an example readme for an example project.

## Command options

<!--[[[cog
#
# This section is generated with cog
# Run `cog -r readme.md` and it will overwrite the help output below with help generated from argparse.
#

import cog
from exampleapp import get_argparse_help_string, make_parser
cog.outl(get_argparse_help_string("exampleapp", make_parser(progname="exampleapp")))
]]]-->

<!--[[[end]]]-->
src/exampleapp/__init__.py

A very simply Python package that consists of just one file.

import argparse


def get_argparse_help_string(
    name: str, parser: argparse.ArgumentParser, wrap: int = 80
) -> str:
    """Generate a docstring for an argparse parser that shows the help for the parser and all subparsers, recursively.

    Based on an idea from <https://github.com/pdoc3/pdoc/issues/89>

    Arguments:
    * `name`: The name of the program
    * `parser`: The parser
    * `wrap`: The number of characters to wrap the help text to (0 to disable)
    """

    def help_formatter(prog):
        return argparse.HelpFormatter(prog, width=wrap)

    def get_parser_help_recursive(
        parser: argparse.ArgumentParser, cmd: str = "", root: bool = True
    ):
        docstring = ""
        if not root:
            docstring += "\n" + "_" * 72 + "\n\n"
        docstring += f"> {cmd} --help\n"
        parser.formatter_class = help_formatter
        docstring += parser.format_help()

        for action in parser._actions:
            if isinstance(action, argparse._SubParsersAction):
                for subcmd, subparser in action.choices.items():
                    docstring += get_parser_help_recursive(
                        subparser, f"{cmd} {subcmd}", root=False
                    )
        return docstring

    docstring = get_parser_help_recursive(parser, name)
    return docstring


def make_parser(progname=None) -> argparse.ArgumentParser:
    """Return the ArgumentParser for this program.

    progname: The name of the program; tries to guess if not provided.
    """
    parser = argparse.ArgumentParser(
        prog=progname, description="A program that does something."
    )
    parser.add_argument(
        "--verbose", action="store_true", help="Increase verbosity of output"
    )
    parser.add_argument(
        "--longhelp",
        help="This is a very long help text. " * 10,
    )
    subparsers = parser.add_subparsers()
    subparser = subparsers.add_parser("subcommand", help="A subcommand")
    subparser.add_argument("--subarg", help="An argument for the subcommand")
    return parser


def main():
    parser = make_parser()
    args = parser.parse_args()
    print("Hello from the exampleapp")
    print(args)

    # ... do whatever in your program

With those files in place, set up a virtual environment:

cd exampleapp
python3 -m venv
. venv/bin/activate
pip install -e '.[development]'
exampleapp
# should print "Hello from exampleapp" and an empty list of arguments

Now all we have to do is follow the instructions in the comment in readme.md:

cog -r readme.md

And the readme file has been transformed to include the help text!

# Example project

This is an example readme for an example project.

## Command options

<!--[[[cog
#
# This section is generated with cog
# Run `cog -r readme.md` and it will overwrite the help output below with help generated from argparse.
#

import cog
from exampleapp import get_argparse_help_string, make_parser
cog.outl(get_argparse_help_string("exampleapp", make_parser(progname="exampleapp")))
]]]-->
> exampleapp --help
usage: exampleapp [-h] [--verbose] [--longhelp LONGHELP] {subcommand} ...

A program that does something.

positional arguments:
  {subcommand}
    subcommand         A subcommand

options:
  -h, --help           show this help message and exit
  --verbose            Increase verbosity of output
  --longhelp LONGHELP  This is a very long help text. This is a very long help
                       text. This is a very long help text. This is a very long
                       help text. This is a very long help text. This is a very
                       long help text. This is a very long help text. This is a
                       very long help text. This is a very long help text. This
                       is a very long help text.

________________________________________________________________________

> exampleapp subcommand --help
usage: exampleapp subcommand [-h] [--subarg SUBARG]

options:
  -h, --help       show this help message and exit
  --subarg SUBARG  An argument for the subcommand

<!--[[[end]]]-->

A few difference from last time:

  • make_parser() has to be public now, because it will be called outside of this file.
  • make_parser() now takes a progname argument. This is normally not necessary for argparse programs, but argparse gets confused when run by cog and thinks that the program name is cog, so we have to allow an override.
  • Text wrapping is simpler per above.

Responses

Webmentions

Hosted on remote sites, and collected here via Webmention.io (thanks!).

Comments

Comments are hosted on this site and powered by Remark42 (thanks!).