Birria: simple cli argument parsing
Declare your "Recipe" with type annotations like dataclass
, then "serve"
the arguments. For more information about type annotations:
Quickstart
Install using pip:
pip install birria
In your source code.
from birria import cook, serve
@cook
class Recipe:
first: int
second: float
third: str
fourth: str = None
fifth: List[int] = None
sixth: bool
dish = serve(Recipe)
print(f"First: {dish.first}")
print(f"Second: {dish.second}")
print(f"Third: {dish.third}")
print(f"Fourth: {dish.fourth}")
print(f"Fifth: {dish.fifth}")
print(f"Sixth: {dish.sixth})
Then, from the shell:
> python myprog.py 1 2.0 three -fourth four -fifth 1 2 3 4 5 -sixth
> First: 1
> Second: 2.0
> Third: three
> Fourth: four
> Fifth: [1, 2, 3, 4, 5]
> Sixth: True
Why?
Because i was making some birria simultaneously, and I'm a hunger-driven creature.
Why for real?
Take the example from quickstart, to do that with argparse
,
import argparse
parser = ArgumentParser()
parser.add_argument("first", type=int)
parser.add_argument("second", type=float)
parser.add_argument("third", type=str)
parser.add_argument("-fourth", type=str, default=None)
parser.add_argument("-fifth", type=int, nargs="*", default=None)
parser.add_argument("-sixth", action="store_true")
args = parser.parse_args()
...
Why is this worse? Well it's not really worse, but it's more verbose,
and one of the main motivations for birria
was to simplify things. Admittedly,
that means less power and complicated features than those supported by argparse
,
(all those fancy actions like "store_true", "append_const", etc.), but IMO, most
of the time, those things are overkill, and simplicity is generally better.
In addition, another example: say you want to reuse a group of arguments across multiple
programs, with argparse
, this is what you generally do:
parent_parser = argparse.ArgumentParser(add_help=False)
parent_parser.add_argument("-base", action="store_true")
...
app_parser = argparse.ArgumentParser(parents=[parent_parser])
...
another_parser = argparse.ArgumentParser(parents=[parent_parser])
...
With birria
, you can just write a base recipe and extend it
@cook
class BaseRecipe:
base: bool
...
@cook
class FancyRecipe(BaseRecipe):
extra: List[int]
More use cases
Here are some more common use cases (more will probably will be added).
Declare required and optional arguments in arbitrary order
@cook
class Recipe:
first_opt: int = 0
first_req: str
second_req: float
second_opt: str = "some lame string"
third_opt: List[float] = None
fourth_opt: bool
Note that a bool field/ingredient is always optional (like a flag).
Reverse bool flag
@cook
class Recipe:
b1: bool
b2: bool = True
> python myprog.py -b1 -b2
> b1: True, b2: False
This is useful to mimic the "store_true" and "store_false" behaviours from argparse
. The
parser essentially reverses the default boolean value of the field it finds a corresponding
flag. If the default value is anything else than a boolean,
when the corresponding flag is found, the parsed value is True
.
Lists
from birria import cook, ingredient, serve
@cook
class Recipe:
l1: List[float] = None
l2: List[int] = ingredient(default_factory: lambda: [1, 2, 3])
> python myprog.py -l1 0.5 0.121 3.142 -l2 4 5 6
> l1: [0.5, 0.121, 3.142]
> l2: [1, 2, 3, 4, 5, 6]
Note that you have to call ingredient()
with the default_factory
parameter to specify
a list as the default value. This is because Python stores default member attributes
as class attributes, so multiple instances of the same class will share the same variable,
like in this example:
class A:
def_list = []
def append(self, elem):
self.def_list.append(elem)
a1 = A()
a2 = A()
a2.append(1)
a2.append(2)
assert a1.def_list == [1, 2]
assert a2.def_list == [1, 2]
assert a1.def_list is a2.def_list
This is not desirable for our use case as we want each instances to have its own list
attributes. Using a factory to initialize a default list solves this problem. This is
basically what dataclass
does.
Another thing to note is this appending behaviour is only applied if the field/ingredient
is annotated as a list type (see limitations for more details), AND the default
value is a list. If the default value is not a list, the resulting value will be a
new list containing the parsed corrsponding items.
You can have as many list type ingredients as you want if you declare them as optional.
If a non-optional ingredient is annotated as a list, then only one non optional ingredient
is allowed, and the ordering of the arguments on the cli becomes "strict": items for the
non-optional ingredient must come first, then followed by any optional items. Otherwise,
the parser can't tell which item belongs to which ingredient.
@cook
class Recipe:
req_list: List[int]
flag: bool
opt_list: List[str]
> python myprog.py 1 2 3 -flag -opt-list some more strings
> python myprog.py -flag -opt-list some more strings 1 2 3
Long names
For longer ingredient names, snake-casing is recommended, as the parser accepts
both the snake-cased names and dash-separated names
python myprog.py -some-very-long-name-for-a-flag
python myprog.py -some_very_long_name_for_a_flag
Limitations
Here are current limitations of birria
. Some of them are by design and therefore
will likely never be "fixed".
Optional and non-optional argument mixing
By design, birria
doesn't allow mixed ordering of optional and non-optional
arguments on the cli. However, ordering of the optional argument can be arbitrary.
> python myprog.py 1 2 3 -p 1 -b -c some lame string
> python -b -p 1 -c some lame string 1 2 3
> python myprog.py -b 1 2 3 -p 1 -c some lame string
Supported types
birria
currently only supports these types:
int, float, str, bool, list, List, List[str], List[int], List[float]
Note that list
and List
will be treated as List[str]
.
Prefixes
By default, birria
only accepts -
as the prefix for option strings, other supported
characters are: ['+', '/']
. More will likely be added in the future.
You can specify what option string to accept through the parameters prefixes
and extra_prefixes
of serve
dish = serve(Recipe, prefixes=['+'], extra_prefixes=['/'])
The resulting prefix list will be all the characters in prefixes
and extra_prefixes
.
Details
cook decorator
The cook
decorator is heavily inspired by dataclass
(I basically stole the code), but it
is a very simplified and limited version of a dataclass, and not meant to be used in the same way.
It's also written to be easy way to declare required and optional arguments in arbitrary
order, even with inheritence, so its behaviour in that aspect is not at all like a dataclass.
Consider this example.
@cook
class BaseRecipe:
req_int: int
opt_int: int = None
@cook
class FancyRecipe:
req_float: float
opt_list: list = None
def __init__(self, req_int: int, req_float: float, opt_int: int = None, opt_list: list = None):
...
Using dataclass
, this would fail, for very good reasons that I won't get into here. However,
because cook
is only meant to turn a class into a list of declarations, and the resulting
class instances are only meant to be used as dumb containers, this behaviour is fine. Behind
the scenes, just like dataclass
, cook
looks through all the class's base classes in
reverse MRO, and remembers the fields of those decorated by cook
. Finally, it looks
through the fields of the current class, and sorts all the fields into "optional" and
"required" buckets. Then __init__
is generated with "correct" argument ordering. In
addition, fields with the same names from the base classes will still be overridden by
the derived class.
Parser behaviour
The parser is basically just the function serve
. The first step is to validate the "recipe".
@cook
class GoodRecipe1:
a: int
b: float
c: str
d: List[int] = None
@cook
class BadRecipe1:
a: int
b: SomeUnsupportType
@cook
class BadRecipe2:
a: List[int]
b: str
@cook
class GoodRecipe2:
a: List[int]
b: List[int] = None
c: str = None
The next phase is the actual parsing. Values from the cli is passed to the parser through
sys.argv
as a list. Regardless of whether there are optional ingredients in the recipe,
the parser parses the required items first. Because intermixing of optional and required
items are not allowed, in all cases except when the required ingredient is a list, the
parser can always figure out where the required values are. The "algorithm" for parsing is:
- If the recipe doesn't define any optional ingredient, parse the whole argument list for
required items.
- Likewise, if there are no required ingredient, parse the whole list for optional items.
- If there are optional ingredients, build regex to scan where option strings are in the list.
- If the first option string is the first item, required items are at the end of the list.
- Otherwise, the required items start at the beginning of the list.
- If no option string is found in the list, just parse the whole list for required items.
- Parse the required items.
- Parse the remaining list for optional items.
> python myprog.py 1 2.0 three
> python myprog.py -d 0 1 2 3 4 5 1 2.0 three
> python myprog.py 1 2.0 three -d 0 1 2 3 4 5
> python 1 2 3 -c hello -b 0 -1 -2
> python 1 2 3 -b 0 -1 -2 -c hello
In the case where there's a required ingredient that's a list, the parser assumes that
the first items are items belonging to that required ingredient. Items will be gathered
into that list until an option string is matched. So if you're using a list as a required
ingredient, make sure that values for it come before all the optional items.
@cook
class Recipe:
a: List[str]
b: bool
c: List[int] = None
> python myprog.py some lame string -b -c 1 2 3
> python myprog.py -b -c 1 2 3 some lame string