Skip to content

Instantly share code, notes, and snippets.

@amarao
Last active September 23, 2024 02:22
Show Gist options
  • Save amarao/36327a6f77b86b90c2bca72ba03c9d3a to your computer and use it in GitHub Desktop.
Save amarao/36327a6f77b86b90c2bca72ba03c9d3a to your computer and use it in GitHub Desktop.
Example of argparse with subparsers for python
#!/usr/bin/env python
import argparse
def main(command_line=None):
parser = argparse.ArgumentParser('Blame Praise app')
parser.add_argument(
'--debug',
action='store_true',
help='Print debug info'
)
subparsers = parser.add_subparsers(dest='command')
blame = subparsers.add_parser('blame', help='blame people')
blame.add_argument(
'--dry-run',
help='do not blame, just pretend',
action='store_true'
)
blame.add_argument('name', nargs='+', help='name(s) to blame')
praise = subparsers.add_parser('praise', help='praise someone')
praise.add_argument('name', help='name of person to praise')
praise.add_argument(
'reason',
help='what to praise for (optional)',
default="no reason",
nargs='?'
)
args = parser.parse_args(command_line)
if args.debug:
print("debug: " + str(args))
if args.command == 'blame':
if args.dry_run:
print("Not for real")
print("blaming " + ", ".join(args.name))
elif args.command == 'praise':
print('praising ' + args.name + ' for ' + args.reason)
if __name__ == '__main__':
main()
@andreburto
Copy link

Thank you! This example was amazing.

@dmailman01
Copy link

This is great - was trying to figure out a way to make a general command-line tool for work and subparser commands fit what I was wanting to do.

Question though - command_line is defined in the function arguments? I'm unsure how this actually works so I'm going to write as I try to think through it. The main() function argument is =None, but this is setting its default value right? And command_line is actually getting set to the arguments given on the command line? How does that work?

Thanks in advance! I just want to solidify my understanding here as much as possible.

@luhahn
Copy link

luhahn commented Jun 9, 2022

There might be a more elegant solution for subparsers by using set_defaults to directly run the desired function:

import argparse

class Test:
  def __init__(self) -> None:
    pass

  def foo(self, args):
    print("foo")

  def bar(self, args):
    print("bar {}".format(args.context))

def main():
  test = Test()

  parser = argparse.ArgumentParser(description='Foo Bar')
  subparsers = parser.add_subparsers(dest='command', help='Commands to run', required=True)

  foo = subparsers.add_parser('foo', help='foo some Foos')
  foo.set_defaults(func=test.foo)

  bar = subparsers.add_parser('bar', help='bar some Bars')
  bar.add_argument('context', help='context for bar')
  bar.set_defaults(func=test.bar)

  args = parser.parse_args()
  args.func(args)

if __name__ == '__main__':
  main()

edited to include @Cinndy s comment.
Also added required=True to subparser, since there is a known python3 argparse bug, which gives a misleading error message, if you don't provide any arguments.

Traceback (most recent call last):
  File "/home/rdo/workspace/repos/test.py", line 30, in <module>
    main()
  File "/home/rdo/workspace/repos/test.py", line 27, in main
    args.func(args)
AttributeError: 'Namespace' object has no attribute 'func'

@Cinndy
Copy link

Cinndy commented Jul 11, 2022

@luhahn this is great, thank you! One small thing: should the foo definition be foo(self, args)? Thanks again!

@luhahn
Copy link

luhahn commented Jul 13, 2022

@Cinndy indeed, i missed that :)

@fireattack
Copy link

fireattack commented Jul 19, 2022

@luhahn a small thing I don't quite like about this pattern is that it makes all the actual arguments hidden inside Namespace args instead being explicit. Which also makes it kinda difficult to "retrofit" argparse into a script that has batch of utility functions.

I prefer to keep my actual functions in format similar def func(url, headers=None).

This is what I come up with, I know it's not as pretty but works for me. Any input would be appreciated.

import argparse

def minus_one(x):
  print(int(x) - 1)

def time(x, y):
  print(int(x) * int(y))

def main():
  parser = argparse.ArgumentParser(description='Foo Bar')
  subparsers = parser.add_subparsers(dest='command', help='Commands to run', required=True)

  parser_minus_one = subparsers.add_parser('minusone')
  parser_minus_one.add_argument('x', help='X')
  parser_minus_one.set_defaults(func=minus_one)

  parser_time = subparsers.add_parser('time', help='X times Y')
  parser_time.add_argument('x', help='X')
  parser_time.add_argument('y', help='Y')
  parser_time.set_defaults(func=time)

  args = parser.parse_args()
  args_ = vars(args).copy()
  args_.pop('command', None)
  args_.pop('func', None)
  args.func(**args_)

if __name__ == '__main__':
  main()

@nuxwin
Copy link

nuxwin commented Sep 9, 2024

@luhahn a small thing I don't quite like about this pattern is that it makes all the actual arguments hidden inside Namespace args instead being explicit. Which also makes it kinda difficult to "retrofit" argparse into a script that has batch of utility functions.

I prefer to keep my actual functions in format similar def func(url, headers=None).

This is what I come up with, I know it's not as pretty but works for me. Any input would be appreciated.

import argparse

def minus_one(x):
  print(int(x) - 1)

def time(x, y):
  print(int(x) * int(y))

def main():
  parser = argparse.ArgumentParser(description='Foo Bar')
  subparsers = parser.add_subparsers(dest='command', help='Commands to run', required=True)

  parser_minus_one = subparsers.add_parser('minusone')
  parser_minus_one.add_argument('x', help='X')
  parser_minus_one.set_defaults(func=minus_one)

  parser_time = subparsers.add_parser('time', help='X times Y')
  parser_time.add_argument('x', help='X')
  parser_time.add_argument('y', help='Y')
  parser_time.set_defaults(func=time)

  args = parser.parse_args()
  args_ = vars(args).copy()
  args_.pop('command', None)
  args_.pop('func', None)
  args.func(**args_)

if __name__ == '__main__':
  main()

Good evening @fireattack

Maybe something more elegant like:

import argparse
import inspect

from beartype.typing import Callable, Any


def minus_one(x: int) -> None:
    """
    Print the result of subtracting one from x.

    Args:
        x (int): The number to subtract

    Returns:
        None
    """
    print(int(x) - 1)


def time(x: int, y: int) -> None:
    """
    Print the product of x and y.

    Args:
        x (int): The first number
        y (int): The second number

    Returns:
        None
    """
    print(int(x) * int(y))


def call_function_with_args(func: Callable, args: argparse.Namespace) -> Any:
    """
    Call the function with the arguments extracted from the argparse.Namespace object.

    Args:
        func: The function to call.
        args: The argparse.Namespace object containing the arguments.

    Returns:
        Any: The result of the function call.

    Author:
        Laurent DECLERCQ, AGON PARTNERS INNOVATION <[email protected]>
    """
    # Let's inspect the signature of the function so that we can call it with the correct arguments.
    # We make use of the inspect module to get the function signature.
    signature = inspect.signature(func)

    # Get the parameters of the function using a dictionary comprehension.
    # Note: Could be enhanced to handle edge cases (default values, *args, **kwargs, etc.)
    args = {parameter: getattr(args, parameter) for parameter in signature.parameters}

    # Type cast the arguments to the correct type according to the function signature. We use the annotation of the
    # parameter to cast the argument. If the annotation is empty, we keep the argument as is. We only process the
    # arguments that are in the function signature.
    args = {
        parameter: (
            signature.parameters[parameter].annotation(args[parameter])
            if signature.parameters[parameter].annotation is not inspect.Parameter.empty else args[parameter]
        ) for parameter in args if parameter in signature.parameters
    }

    # Call the function with the arguments and return the result if any.
    return func(**arguments)


def cli():
    parser = argparse.ArgumentParser(description='Foo Bar')
    subparsers = parser.add_subparsers(dest='command', help='Commands to run', required=True)

    # Define the minusone sub-command.
    parser_minus_one = subparsers.add_parser('minusone')
    parser_minus_one.add_argument('x', help='X')
    parser_minus_one.set_defaults(func=minus_one)

    # Define the time sub-command.
    parser_time = subparsers.add_parser('time', help='X times Y')
    parser_time.add_argument('x', help='X')
    parser_time.add_argument('y', help='Y')
    parser_time.set_defaults(func=time)

    # Parse the arguments from the command line.
    args = parser.parse_args()

    # Call the function with the arguments.
    call_function_with_args(args.func, args)


if __name__ == '__main__':
    cli()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment