Skip to main content

No project description provided

Project description

ponderosa: ergonomic subcommand handling built on argparse

PyPI - Version Tests codecov License: 3-Clause BSD PyPI - Python Version Static Badge

Ponderosa extends the Python standard library's argparse in an effort to make dealing with deeply nested subcommand trees less ponderous. I've tried out many different command line parsing libraries over the years, but none of them have quite scratched the itch for this use case. Ponderosa gets rid of those giant blocks of add_subparsers nastiness without entirely reinventing the wheel at the lower level of parsing the arguments themselves.

Basic Usage

ponderosa is primarily interacted with via the CmdTree class. This class keeps track of the parser tree, adds subparsers when needed, exposes methods for finding parsers and traversing the subcommand tree, and provides some convenience functions for printing out the subcommand tree in an orderly way. To convert a function into a subcommand, we use the CmdTree.register decorator with the fully qualified subcommand name. The returned object can then register its arguments with its .args decorator.

from argparse import Namespace
from ponderosa import ArgParser, CmdTree
# ArgParser is just Union[argparse.ArgumentParser, argparse._ArgumentGroup]

commands = CmdTree(description='Ponderosa Basics')

@commands.register('basics', help='Easy as pie 🥧')
def basics_cmd(args: Namespace):
    print('Ponderosa 🌲')
    if args.show:
        commands.print_help()

@basics_cmd.args()
def _(parser: ArgParser):
    parser.add_argument('--show', action='store_true', default=False)

@commands.register('basics', 'deeply', 'nested', help='A deeply nested command')
def deeply_nested_cmd(args: Namespace):
    print(f'Deeply nested command! Args: {args}')

@commands.register('basics', 'deeply', 'also-nested', help='Another deeply nested command')
def deeply_nested_cmd(args: Namespace):
    print(f'Another deeply nested command! Args: {args}')

@deeply_nested_cmd.args()
def _(parser: ArgParser):
    parser.add_argument('--deep', action='store_true', default=False)

if __name__ == '__main__':
    commands.run()
$ python examples/basics.py basics --show
Ponderosa 🌲
usage: basics.py [-h] {basics} ...

Subcommands:
  basics: Easy as pie 🥧
    deeply: 
      nested: A deeply nested command
      also-nested: Another deeply nested command

$ python examples/basics.py basics deeply nested -h
usage: basics.py basics deeply nested [-h] [--deep]

options:
  -h, --help  show this help message and exit
  --deep

Registering Subcommands

The register decorator takes care of the boilerplate of adding intermediate subparsers to your subcommand tree. Given a fully qualified subcommand string, for example grandparent parent child, the CmdTree will search for the first subparser matching the root name (in this case, grandparent), and then find all the intermediary subparsers; any intermediaries that do not yet exist will be instantiated, including the root subparser if necessary.

>>> from argparse import Namespace
>>> from ponderosa import CmdTree
>>> 
>>> commands = CmdTree()
>>> 
>>> @commands.register('grandparent', 'parent', 'child')
... def _(args: Namespace):
...     print('Hello from child!')
... 
>>> commands.run(['grandparent', 'parent', 'child'])
Hello from child!
0

All arguments passed to register after the subcommand string are forwarded on to add_parser:

@commands.register('subcomand',
                   help='A useful subcommand',
                   aliases=['sub'],
                   description='A long description of the subcommand...')
def _(args):
    pass

Add Postprocessors

Sometimes you want to add some postprocessing to your arguments that can only be done after parsing has already occurred - for example, validating one of your arguments might depend on opening a database connection. You can register postprocessors on your argument groups to handle this:

#!/usr/bin/env python3

from argparse import Namespace
from ponderosa import arggroup, ArgParser, CmdTree

commands = CmdTree()

@arggroup('Foobar')
def foobar_args(group: ArgParser):
    group.add_argument('--foo', type=str)
    group.add_argument('--bar', type=int)
    
@foobar_args.apply()
@commands.register('foobar')
def foobar_cmd(args: Namespace) -> int:
    print(f'Handling subcommand with args: {args}')
    return 0
    
@foobar_args.postprocessor()
def foobar_postprocessor(args: Namespace):
    print(f'Postprocessing args: {args}')

if __name__ == '__main__':    
    commands.run()

Running the example gives, roughly:

$ python examples/postprocessor.py foobar --bar 1 --foo bar      
Postprocessing args: Namespace(func=<function foobar_cmd at 0x7bc1ba0b1800>, foo='bar', bar=1)
Handling subcommand with args: Namespace(func=<function foobar_cmd at 0x7bc1ba0b1800>, foo='bar', bar=1)

We can of course register multiple postprocessors, and do so on the result of a SubCmd.args. By default, the postprocessors will be executed in the order they are registered:

#!/usr/bin/env python3

from argparse import Namespace
from ponderosa import ArgParser, CmdTree

commands = CmdTree()

@commands.register('foobar')
def foobar_cmd(args: Namespace) -> int:
    print(f'Handling subcommand with args: {args}')
    return 0

@foobar_cmd.args()
def foobar_args(group: ArgParser):
    group.add_argument('--foo', type=str)
    group.add_argument('--bar', type=int)
    
@foobar_args.postprocessor()
def _(args: Namespace):
    print(f'First postprocessor: {args}')
    args.calculated = args.bar * 2

@foobar_args.postprocessor()
def _(args: Namespace):
    print(f'Second postprocessor: {args}')

if __name__ == '__main__':    
    commands.run()

Which gives:

$ python examples/multi_postprocessor.py foobar --foo bar --bar 1
SubCmd.args.wrapper: foobar
First postprocessor: Namespace(func=<function foobar_cmd at 0x751415cb1a80>, foo='bar', bar=1)
Second postprocessor: Namespace(func=<function foobar_cmd at 0x751415cb1a80>, foo='bar', bar=1, calculated=2)
Handling subcommand with args: Namespace(func=<function foobar_cmd at 0x751415cb1a80>, foo='bar', bar=1, calculated=2)

You can also provide a priority to your postprocessors if registration order is insufficient:

#!/usr/bin/env python3

from argparse import Namespace
from ponderosa import ArgParser, CmdTree

commands = CmdTree()

@commands.register('foobar')
def foobar_cmd(args: Namespace) -> int:
    print(f'Handling subcommand with args: {args}')
    return 0

@foobar_cmd.args()
def foobar_args(group: ArgParser):
    group.add_argument('--foo', type=str)
    group.add_argument('--bar', type=int)

@foobar_args.postprocessor()
def _(args: Namespace):
    print(f'Low priority: {args}')

# Usually, this function would run second, as it was defined second.
# It will run first due to its priority score.
@foobar_args.postprocessor(priority=100)
def _(args: Namespace):
    print(f'High priority: {args}')
    args.calculated = args.bar * 2

if __name__ == '__main__':    
    commands.run()

This time, we get:

$ python examples/priority_postprocessors.py foobar --bar 2 
High priority: Namespace(func=<function foobar_cmd at 0x7693e57b5bc0>, foo=None, bar=2)
Low priority: Namespace(func=<function foobar_cmd at 0x7693e57b5bc0>, foo=None, bar=2, calculated=4)
Handling subcommand with args: Namespace(func=<function foobar_cmd at 0x7693e57b5bc0>, foo=None, bar=2, calculated=4)

Async Support

Ponderosa supports async def command functions and postprocessors out of the box. There's nothing special to opt in to: just define your functions as async and ponderosa handles the rest.

Async Command Functions

When run() detects an async command function, it automatically manages the event loop via asyncio.run():

from argparse import Namespace
from ponderosa import ArgParser, CmdTree

commands = CmdTree()

@commands.register('fetch', help='Fetch data asynchronously')
async def fetch_cmd(args: Namespace) -> int:
    data = await some_async_operation(args.url)
    print(data)
    return 0

@fetch_cmd.args()
def _(parser: ArgParser):
    parser.add_argument('url')

if __name__ == '__main__':
    commands.run()

Async Postprocessors

Postprocessors can also be async. They execute sequentially in priority order, just like their synchronous counterparts, and can be freely mixed with sync postprocessors:

@foobar_args.postprocessor(priority=100)
async def validate_connection(args: Namespace):
    args.db = await connect_to_database(args.db_url)

@foobar_args.postprocessor(priority=0)
def set_defaults(args: Namespace):
    args.timeout = args.timeout or 30

Explicit Async Entry Points

If you're already inside an async context (e.g., embedding ponderosa in an async application), use async_run() or async_parse_args() directly:

import asyncio
from ponderosa import CmdTree

commands = CmdTree()

# ... register commands ...

async def main():
    retcode = await commands.async_run()

asyncio.run(main())

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ponderosa-0.6.0.tar.gz (12.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ponderosa-0.6.0-py3-none-any.whl (10.5 kB view details)

Uploaded Python 3

File details

Details for the file ponderosa-0.6.0.tar.gz.

File metadata

  • Download URL: ponderosa-0.6.0.tar.gz
  • Upload date:
  • Size: 12.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/2.3.3 CPython/3.12.1 Linux/6.17.0-1010-azure

File hashes

Hashes for ponderosa-0.6.0.tar.gz
Algorithm Hash digest
SHA256 f2b1a61a9b419db570bc83bc6771f6add705bf6c7c835b3f74963e3478eb0efb
MD5 ea9c553dd26eae69540cf250565b9815
BLAKE2b-256 5d44996b1fc528c861e482ec0bee15963b39395e25895300ff61f7ff0d4c50b5

See more details on using hashes here.

File details

Details for the file ponderosa-0.6.0-py3-none-any.whl.

File metadata

  • Download URL: ponderosa-0.6.0-py3-none-any.whl
  • Upload date:
  • Size: 10.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/2.3.3 CPython/3.12.1 Linux/6.17.0-1010-azure

File hashes

Hashes for ponderosa-0.6.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ca6f60f134e1d0964d8ebd2a074f6344c4e8b07176511f303ec2b5ebe9705416
MD5 2c7bfc593341fa999d1662f9f372bd91
BLAKE2b-256 3556e72d6c5707fa812e92471d5e63e56a6ba7de1dc286eea33bcfb6b955e528

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page