Package: birria
Birria: simple cli argument parsingDeclare your "Recipe" with type annotations likedataclass, then "serve"
the arguments. For more information about type annotations:PEP484PEP544QuickstartInstall using pip:pipinstallbirriaIn your source code.frombirriaimportcook,serve@cookclassRecipe:first:intsecond:floatthird:strfourth:str=Nonefifth:List[int]=Nonesixth:booldish=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:>pythonmyprog.py12.0three-fourthfour-fifth12345-sixth
>First:1>Second:2.0
>Third:three
>Fourth:four
>Fifth:[1,2,3,4,5]>Sixth:TrueWhy?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 withargparse,importargparseparser=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()# print out the parsed values...Why is this worse? Well it's not really worse, but it's  more verbose,
and one of the main motivations forbirriawas to simplify things. Admittedly,
that means less power and complicated features than those supported byargparse,
(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, withargparse, this is what you generally do:parent_parser=argparse.ArgumentParser(add_help=False)# add your common arguments down hereparent_parser.add_argument("-base",action="store_true")...app_parser=argparse.ArgumentParser(parents=[parent_parser])# again, add your arguments...another_parser=argparse.ArgumentParser(parents=[parent_parser])# again, add your arguments...Withbirria, you can just write a base recipe and extend it@cookclassBaseRecipe:# common arguments herebase:bool...@cookclassFancyRecipe(BaseRecipe):# specific arguments here# you can also override# the arguments in the base# class# this overrides the default# value of base (False)# base: bool = Trueextra:List[int]More use casesHere are some more common use cases (more will probably will be added).Declare required and optional arguments in arbitrary order@cookclassRecipe:first_opt:int=0first_req:strsecond_req:floatsecond_opt:str="some lame string"third_opt:List[float]=Nonefourth_opt:boolNote that a bool field/ingredient is always optional (like a flag).Reverse bool flag@cookclassRecipe:b1:boolb2:bool=True# print out parsed arguments>pythonmyprog.py-b1-b2>b1:True,b2:FalseThis is useful to mimic the "store_true" and "store_false" behaviours fromargparse. 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 isTrue.Listsfrombirriaimportcook,ingredient,serve@cookclassRecipe:l1:List[float]=Nonel2:List[int]=ingredient(default_factory:lambda:[1,2,3])# print out parsed arguments>pythonmyprog.py-l10.50.1213.142-l2456>l1:[0.5,0.121,3.142]>l2:[1,2,3,4,5,6]Note that you have to callingredient()with thedefault_factoryparameter 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:classA:def_list=[]defappend(self,elem):self.def_list.append(elem)a1=A()a2=A()a2.append(1)a2.append(2)asserta1.def_list==[1,2]# trueasserta2.def_list==[1,2]# also trueasserta1.def_listisa2.def_list# big true trueThis 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 whatdataclassdoes.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.@cookclassRecipe:req_list:List[int]flag:boolopt_list:List[str]>pythonmyprog.py123-flag-opt-listsomemorestrings# good>pythonmyprog.py-flag-opt-listsomemorestrings123# badLong namesFor longer ingredient names, snake-casing is recommended, as the parser accepts
both the snake-cased names and dash-separated namespythonmyprog.py-some-very-long-name-for-a-flag# goodpythonmyprog.py-some_very_long_name_for_a_flag# also goodLimitationsHere are current limitations ofbirria. Some of them are by design and therefore
will likely never be "fixed".Optional and non-optional argument mixingBy design,birriadoesn't allow mixed ordering of optional and non-optional
arguments on the cli. However, ordering of the optional argument can be arbitrary.>pythonmyprog.py123-p1-b-csomelamestring# good>python-b-p1-csomelamestring123# good>pythonmyprog.py-b123-p1-csomelamestring# badSupported typesbirriacurrently only supports these types:int, float, str, bool, list, List, List[str], List[int], List[float]Note thatlistandListwill be treated asList[str].PrefixesBy default,birriaonly 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 parametersprefixesandextra_prefixesofservedish=serve(Recipe,prefixes=['+'],extra_prefixes=['/'])The resulting prefix list will be all the characters inprefixesandextra_prefixes.Detailscook decoratorThecookdecorator is heavily inspired bydataclass(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.@cookclassBaseRecipe:req_int:intopt_int:int=None@cookclassFancyRecipe:req_float:floatopt_list:list=None# this is the generated __init__def__init__(self,req_int:int,req_float:float,opt_int:int=None,opt_list:list=None):...# __repr__ and __eq__ are also generated for# easy printing and debugging# FancyRecipe will have# req_int:      int =>      no default value# req_float:    float =>    no default value# opt_int:      int =>      defaults to None# opt_list:     list =>     defaults to NoneUsingdataclass, this would fail, for very good reasons that I won't get into here. However,
becausecookis 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 likedataclass,cooklooks through all the class's base classes in
reverse MRO, and remembers the fields of those decorated bycook. 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 behaviourThe parser is basically just the functionserve. The first step is to validate the "recipe".@cookclassGoodRecipe1:a:intb:floatc:strd:List[int]=None@cookclassBadRecipe1:a:intb:SomeUnsupportType# will raise TypeError# if a required ingredient is a list,# only one required ingredient is allowed# so this is bad@cookclassBadRecipe2:a:List[int]b:str# but this is good@cookclassGoodRecipe2:a:List[int]b:List[int]=Nonec:str=NoneThe next phase is the actual parsing. Values from the cli is passed to the parser throughsys.argvas 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.# from GoodRecipe1# 3 required scalar items# 1 optional list items# no option string found, parse whole list for required items>pythonmyprog.py12.0three# option string first item, parse the last 3 items of the list# for required items, then the remaning list>pythonmyprog.py-d01234512.0three# option string not first item, parse the first 3 items of the list# for required items, then the remaining list>pythonmyprog.py12.0three-d012345# d = [0, 1, 2, 3, 4, 5]# a = 1# b = 2.0# c = "three"# from GoodRecipe2# arbitrary order of optional values>python123-chello-b0-1-2# or>python123-b0-1-2-chelloIn 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.@cookclassRecipe:a:List[str]b:boolc:List[int]=None# gather items into list for a until "-b" or "-c",# whichever comes first>pythonmyprog.pysomelamestring-b-c123# a = ["some", "lame", "string]# b = True# c = [1, 2, 3]# sees -b and stops immediately,# so empty list for a, but a is required# so prints out error and exits>pythonmyprog.py-b-c123somelamestring