Tutorial#

We’ve already seen many of the concepts that power in the Highlights section. This tutorial will address these concepts one at a time and expose the reader to some nuances of usage.

An example from argparse#

Many of you are already familiar with argparse. You may even recognize this example from the argparse docs:

import argparse
parser = argparse.ArgumentParser(description="calculate X to the power of Y")
group = parser.add_mutually_exclusive_group()
group.add_argument("-v", "--verbose", action="store_true")
group.add_argument("-q", "--quiet", action="store_true")
parser.add_argument("x", type=int, help="the base")
parser.add_argument("y", type=int, help="the exponent")
args = parser.parse_args()

Here is one way to express this logic in :

>>> from dollar_lambda import command, flag
>>> @command(
...     parsers=dict(kwargs=(flag("verbose") | flag("quiet")).optional()),
...     help=dict(x="the base", y="the exponent"),
... )
... def main(x: int, y: int, **kwargs):
...     print(dict(x=x, y=y, **kwargs))

Here is the help text for this parser:

>>> main("-h")
usage: -x X -y Y [--verbose | --quiet]
x: the base
y: the exponent

As indicated, this succeeds given --verbose

>>> main("-x", "1", "-y", "2", "--verbose")
{'x': 1, 'y': 2, 'verbose': True}

or --quiet

>>> main("-x", "1", "-y", "2", "--quiet")
{'x': 1, 'y': 2, 'quiet': True}

or neither

>>> main("-x", "1", "-y", "2")
{'x': 1, 'y': 2}

Note

Ordinarily , we would not feed main any arguments, and it would get them from the command line:

>>> import sys
>>> sys.argv[1:] = ["-x", "1", "-y", "2"] # simulate command line input
>>> parsers.TESTING = False # unnecessary outside doctests
>>> main()
{'x': 1, 'y': 2}
>>> parsers.TESTING = True

Equivalent in lower-level syntax#

To better understand what is going on here, let’s remove the syntactic sugar:

>>> from dollar_lambda import nonpositional, option
>>> p = nonpositional(
...     (flag("verbose") | flag("quiet")).optional(),
...     option("x", type=int, help="the base"),
...     option("y", type=int, help="the exponent"),
... )
...
>>> def main(x, y, **kwargs):
...     return dict(x=x, y=y, **kwargs)
...
>>> main(**p.parse_args("-x", "1", "-y", "2", "--verbose"))
{'x': 1, 'y': 2, 'verbose': True}
>>> main(**p.parse_args("-x", "1", "-y", "2", "--quiet"))
{'x': 1, 'y': 2, 'quiet': True}
>>> main(**p.parse_args("-x", "1", "-y", "2"))
{'x': 1, 'y': 2}

Now let’s walk through this step by step.

High-Level Parsers#

In the de-sugared implementation there are two different parser constructors: flag, which binds a boolean value to a variable, and option, which binds an arbitrary value to a variable.

flag#

>>> p = flag("verbose")
>>> p.parse_args("--verbose")
{'verbose': True}

By default flag fails when it does not receive expected input:

>>> p.parse_args()
usage: --verbose
The following arguments are required: --verbose

Alternately, you can set a default value:

>>> flag("verbose", default=False).parse_args()
{'verbose': False}

option#

option is similar but takes an argument: By default, option, expects a single - for single-character variable names (as in -x), as opposed to -- for longer names (as in --xenophon):

>>> option("x").parse_args("-x", "1")
{'x': '1'}
>>> option("xenophon").parse_args("-xenophon", "1")
{'xenophon': '1'}

Use the type argument to convert the input to a different type:

>>> option("x", type=int).parse_args("-x", "1") # converts "1" to an int
{'x': 1}

Parser Combinators#

Parser combinators are functions that combine multiple parsers into new, more complex parsers. Our example uses two such functions: nonpositional and |.

|#

The | operator is used for alternatives. Specifically, it will try the first parser, and if that fails, try the second:

>>> p = flag("verbose") | flag("quiet")
>>> p.parse_args("--quiet") # flag("verbose") fails
{'quiet': True}
>>> p.parse_args("--verbose") # flag("verbose") succeeds
{'verbose': True}

By default one of the two flags would be required to prevent failure:

>>> p.parse_args() # neither flag is provided so this fails usage:
usage: [--verbose | --quiet]
The following arguments are required: --verbose

We can permit the omission of both flags by using optional, as we saw earlier, or we can supply a default value:

>>> (flag("verbose") | flag("quiet")).optional().parse_args() # flags fail, but that's ok
{}
>>> (flag("verbose") | flag("quiet", default=False)).parse_args()
{'quiet': False}

In the second example, flag("verbose") fails but flag("quiet", default=False) succeeds.

Note

Unlike logical “or” but like Python or, the

| operator is not commutative:

>>> from dollar_lambda import argument
>>> (flag("verbose") | argument("x")).parse_args("--verbose")
{'verbose': True}

argument binds to positional arguments. If it comes first, it will think that "--verbose" is the expression that we want to bind to x:

>>> from dollar_lambda import argument
>>> (argument("x") | flag("verbose")).parse_args("--verbose")
{'x': '--verbose'}

nonpositional and +#

nonpositional takes a sequence of parsers as arguments and attempts all permutations of them, returning the first permutations that is successful:

>>> p = nonpositional(flag("verbose"), flag("quiet"))
>>> p.parse_args("--verbose", "--quiet")
{'verbose': True, 'quiet': True}
>>> p.parse_args("--quiet", "--verbose") # reverse order also works
{'quiet': True, 'verbose': True}

For just two parsers you can use + instead of nonpositional:

>>> p = flag("verbose") + flag("quiet")
>>> p.parse_args("--verbose", "--quiet")
{'verbose': True, 'quiet': True}
>>> p.parse_args("--quiet", "--verbose") # reverse order also works
{'quiet': True, 'verbose': True}

This will not cover all permutations for more than two parsers:

>>> p = flag("verbose") + flag("quiet") + option("x")
>>> p.parse_args("--verbose", "-x", "1", "--quiet")
usage: --verbose --quiet -x X
Expected '--quiet'. Got '-x'

To see why note the implicit parentheses:

>>> p = (flag("verbose") + flag("quiet")) + option("x")

In order to cover the case where -x comes between --verbose and --quiet, use nonpositional

>>> p = nonpositional(flag("verbose"), flag("quiet"), option("x"))
>>> p.parse_args("--verbose", "-x", "1", "--quiet") # works
{'verbose': True, 'x': '1', 'quiet': True}

Putting it all together#

Let’s recall the original example without the syntactic sugar:

>>> p = nonpositional(
...     (flag("verbose") | flag("quiet")).optional(),
...     option("x", type=int, help="the base"),
...     option("y", type=int, help="the exponent"),
... )
>>> def main(x, y, verbose=False, quiet=False):
...     print(dict(x=x, y=y, verbose=verbose, quiet=quiet))

As we’ve seen, (flag("verbose") | flag("quiet")).optional() succeeds on either --verbose or --quiet or neither.

option("x", type=int) succeeds on -x X, where X is some integer, binding that integer to the variable "x". Similarly for option("y", type=int).

nonpositional takes the three parsers:

  • (flag("verbose") | flag("quiet")).optional()

  • option("x", type=int)

  • option("y", type=int)

and applies them in every order, until some order succeeds.

Applying the syntactic sugar:

>>> @command(
...     parsers=dict(kwargs=(flag("verbose") | flag("quiet")).optional()),
...     help=dict(x="the base", y="the exponent"),
... )
...
... def main(x: int, y: int, **kwargs):
...     pass # do work

Here the parsers argument reserves a function argument (in this case, kwargs) for a custom parser (in this case, (flag("verbose") | flag("quiet")).optional()) using our lower-level syntax. The help argument assigns help text to the arguments (in this case x and y).