Undeniably Python is quite idiot-friendly language that you don’t need too much learning to get something done. A lot of code out there to copy and the concise language helped too. While writing a Python function is easy, it seems quite a lot of boilerplates needed additionally to make a command line tool. But it turns out, with a good coding style, something can be done.

Python is object-oriented language. Everything in Python is an object, including functions. Since long time ago, we can extract the details of a function including its name, the arguments, and which argument has default value assigned.

In the newer Python, we can add type hints to variables, as well as arguments to functions. While it is a hint, which does not affect the language execution, we can sort-of declaring what type we expect for an argument of a function.

Also there are docstrings. It is the string put immediately inside the function which for consumption of documentation generation tools. I prefer to use the “Google style” docstring for saving screen real estate. The “numpy style” docstring, however, is more verbose and more robust.

Assume we have a function declared with type hints, and carefully crafted docstring, like the following:

def quadratic(a: float, b: float, c: float, x: float=None, give_roots: bool=False):
    """Evaluating a quadratic polynomial and find its root
    a : float
        Coefficient of x^2 term
    b : float
        Coefficient of x term
    c : float
        Coefficient of constant term
    x : float, optional
        Value that the quadratic polynomial should evaluate on
    give_roots : bool, optional
        If True, roots of the polynomial will be returned as well
    A dictionary containing the value of the polynomial evaluated (if provided)
    as well as the roots
    ret = {}
    # evaluate polynomial
    if x:
        ret["value"] = ((a*x) + b)*x + c
    # find roots
    if give_roots:
        det = b*b - 4*a*c
        first = -b / (2*a)
        if det < 0:
            second = cmath.sqrt(det) / (2*a)
            second = math.sqrt(det) / (2*a)
        ret["roots"] = [first + second, first - second]
    return ret

And we want to be able to make a script that can be called using:

python quad.py -a 3 -b -2 -c 5 -x 0 --give_roots true

This is possible to be done automatically. First, based on the function, we can read what are the arguments using func.__code__.co_varnames and whether the argument has any default assigned using func.__defaults__. Also we can read the type hints using typing.get_type_hints(func). The docstring is a bit difficult because you need to parse its content. If its is nicely written following some style, the docstring_parser module is available for help.

Now what’s left is how we can build the add_argument() calls from an ArgumentParser object. Roughly it is about these:

  • the short form argument, e.g., -g
  • the long form argument, e.g., --get_roots
  • the help text for the argument
  • any defaults for it
  • is it a required argument
  • the type of the argument (so argparse will automatically give us the correct type instead of strings)

We can make the long form the same as the functions argument: Usually the long form accepts wider range of identifiers such as containing a dash but any Python identifier can be an argument well. The short form can be derived from the long one.

The help text is not part of the function but we can infer that from docstring. The docstring_parser module will produce an object for the docstring parsed, and we can check if the variable is mentioned in it.

Telling if the argument has default need to be inferred from func.__defaults__ if it is a non-keyword-only arguments or func.__kwdefaults__ for keyword-only arguments. If a function takes an argument and no default is assigned, it is a required argument.

The tricky part is the types of the arguments. Normally, the type hint will give you the type (e.g., when we specifies a: float). But when we have x: float=None, we are not going to see float as the type for x but typing.Optional[float] instead. We need to extract float from such, which we can get it from typing.get_args().

The full code is as in gist, below. Note that it does not catch all corner cases, such as more complex type hints (e.g., list of floats).