Command line argument parser, with types.
argtyped
: Command Line Argument Parser, with Typesargtyped
is an command line argument parser with that relies on type annotations. It is built onargparse
, the command line argument parser library built into
Python. Compared with argparse
, this library gives you:
argparse
in most cases.Since v0.4.0, argtyped
also supports parsing arguments defined with an attrs-class. See
Attrs Support for more details.
Install stable release from PyPI:
pip install argtyped
Or, install the latest commit from GitHub:
pip install git+https://github.com/huzecong/argtyped.git
With argtyped
, you can define command line arguments in a syntax similar totyping.NamedTuple
. The syntax is intuitive and can
be illustrated with an example:
from typing import Optional
from typing_extensions import Literal # or directly import from `typing` in Python 3.8+
from argtyped import Arguments, Switch
from argtyped import Enum, auto
class LoggingLevels(Enum):
Debug = auto()
Info = auto()
Warning = auto()
Error = auto()
Critical = auto()
class MyArguments(Arguments):
model_name: str # required argument of `str` type
hidden_size: int = 512 # `int` argument with default value of 512
activation: Literal["relu", "tanh", "sigmoid"] = "relu" # argument with limited choices
logging_level: LoggingLevels = LoggingLevels.Info # using `Enum` class as choices
use_dropout: Switch = True # switch argument, enable with "--use-dropout" and disable with "--no-use-dropout"
dropout_prob: Optional[float] = 0.5 # optional argument, "--dropout-prob=none" parses into `None`
args = MyArguments()
This is equivalent to the following code with Python built-in argparse
:
import argparse
from enum import Enum
class LoggingLevels(Enum):
Debug = "debug"
Info = "info"
Warning = "warning"
Error = "error"
Critical = "critical"
parser = argparse.ArgumentParser()
parser.add_argument("--model-name", type=str, required=True)
parser.add_argument("--hidden-size", type=int, default=512)
parser.add_argument("--activation", choices=["relu", "tanh", "sigmoid"], default="relu")
parser.add_argument("--logging-level", choices=list(LoggingLevels), type=LoggingLevels, default="info")
parser.add_argument("--use-dropout", action="store_true", dest="use_dropout", default=True)
parser.add_argument("--no-use-dropout", action="store_false", dest="use_dropout")
parser.add_argument("--dropout-prob", type=lambda s: None if s.lower() == 'none' else float(s), default=0.5)
args = parser.parse_args()
Save the code into a file named main.py
. Suppose the following arguments are provided:
python main.py \
--model-name LSTM \
--activation sigmoid \
--logging-level debug \
--no-use-dropout \
--dropout-prob none
Then the parsed arguments will be equivalent to the following structure returned by argparse
:
argparse.Namespace(
model_name="LSTM", hidden_size=512, activation="sigmoid", logging_level="debug",
use_dropout=False, dropout_prob=None)
Arguments can also be pretty-printed:
>>> print(args)
<class '__main__.MyArguments'>
╔═════════════════╤══════════════════════════════════╗
║ Arguments │ Values ║
╠═════════════════╪══════════════════════════════════╣
║ model_name │ 'LSTM' ║
║ hidden_size │ 512 ║
║ activation │ 'sigmoid' ║
║ logging_level │ <MyLoggingLevels.Debug: 'debug'> ║
║ use_dropout │ False ║
║ dropout_prob │ None ║
║ label_smoothing │ 0.1 ║
║ some_true_arg │ True ║
║ some_false_arg │ False ║
╚═════════════════╧══════════════════════════════════╝
It is recommended though to use the args.to_string()
method, which gives you control of the table width.
The way we define the arguments is very similar to defining a dataclass
or an attrs-class, so it seems natural to just write an attrs-class, and add parsing support to it.
To use argtyped
with attrs
, simply define an attrs-class as usual, and have it subclass AttrsArguments
. Here’s
the same example above, but implemented with attrs-classes, and with some bells and whistles:
import attr # note: new style `attrs` syntax is also supported
from argtyped import AttrsArguments
def _convert_logging_level(s: str) -> LoggingLevels:
# Custom conversion function that takes the raw string value from the command line.
return LoggingLevels[s.lower()]
@attr.s(auto_attribs=True)
class MyArguments(AttrsArguments):
model_name: str = attr.ib(metadata={"positional": True}) # positional argument
# Or: `model_name: str = argtyped.positional_arg()`.
layer_sizes: List[int] = attr.ib(metadata={"nargs": "+"}) # other metadata are treated as `argparse` options
activation: Literal["relu", "tanh", "sigmoid"] = "relu"
logging_level: LoggingLevels = attr.ib(default=LoggingLevels.Info, converter=_convert_logging_level)
use_dropout: Switch = True
dropout_prob: Optional[float] = 0.5
_activation_fn: Callable[[float], float] = attr.ib(init=False) # `init=False` attributes are not parsed
@dropout_prob.validator # validators still work as you would expect
def _dropout_prob_validator(self, attribute, value):
if not 0.0 <= value <= 1.0:
raise ValueError(f"Invalid probability {value}")
@_activation_fn.default
def _activation_fn(self):
return _ACTIVATION_FNS[self.activation]
A few things to note here:
"positional": True
as metadata. If this feels unnatural, you couldargtyped.positional_arg()
, which takes the same arguments as attr.ib
.ArgumentParser.add_argument
by listing them as metadata as well. Note thatargtyped
‘s computed arguments, for example, sequence arguments (List[T]
) bynargs="*"
, but you could override it with metadata.init=False
attributes are not treated as arguments, but they can be useful for storing computed values based onargtyped
None
, and no typeHere are the (current) differences between an attrs-based arguments class (AttrsArguments
) versus the normal arguments
class (Arguments
):
AttrsArguments
supports positional arguments and custom options via metadata.AttrsArguments
handles default values with attrs, so there’s no validation of default value types. This alsoNone
, otherwise it becomes a requiredAttrsArguments
does not support underscore=True
.AttrsArguments
does not have to_dict
, to_string
methods.AttrsArguments
needs to be called with the factory parse_args
method to parse, while Arguments
parses commandargtyped.Arguments
ClassThe argtyped.Arguments
class is main class of the package, from which you should derive your custom class that holds
arguments. Each argument takes the form of a class attribute, with its type annotation and an optional default value.
When an instance of your custom class is initialized, the command line arguments are parsed from sys.argv
into values
with your annotated types. You can also provide the list of strings to parse by passing them as the parameter.
The parsed arguments are stored in an object of your custom type. This gives you arguments that can be auto-completed
by the IDE, and type-checked by a static type checker like mypy.
The following example illustrates the keypoints:
class MyArgs(argtyped.Arguments):
# name: type [= default_val]
value: int = 0
args = MyArgs() # equivalent to `parser.parse_args()`
args = MyArgs(["--value", "123"]) # equivalent to `parser.parse_args(["--value", "123"])
assert isinstance(args, MyArgs)
Arguments.to_dict(self)
Convert the set of arguments to a dictionary (OrderedDict
).
Arguments.to_string(self, width: Optional[int] = None, max_width: Optional[int] = None)
Represent the arguments as a table.
width
: Width of the printed table. Defaults to None
, which fits the table to its contents. An exception is raisedmax_width
: Maximum width of the printed table. Defaults to None
, meaning no limits. Must be None
if width
isNone
.argtyped.argument_specs
Return a dictionary mapping argument names to their specifications, represented as the argtyped.ArgumentSpec
type.
This is useful for programmatically accessing the list of arguments.
To summarize, whatever works for argparse
works here. The following types are supported:
int
, float
, str
.bool
. Accepted values (case-insensitive) for True
are: y
, yes
, true
, ok
; accepted valuesFalse
are: n
, no
, false
.Choice types Literal[...]
. A choice argument is essentially an str
argument with limited
choice of values. The ellipses can be filled with a tuple of str
s, or an expression that evaluates to a list ofstr
s:
from argtyped import Arguments
from typing_extensions import Literal
class MyArgs(Arguments):
foo: Literal["debug", "info", "warning", "error"] # 4 choices
# argv: ["--foo=debug"] => foo="debug"
This is equivalent to the choices
keyword in argparse.add_argument
.
Note: The choice type was previously named Choices
. This is deprecated in favor of theLiteral
type introduced in Python 3.8 and back-ported to
3.6 and 3.7 in the typing_extensions
library. Choices
was removed since version 0.4.0.
Enum types derived from enum.Enum
. It is recommended to use argtyped.Enum
which uses the instance names as
values:
from argtyped import Enum
class MyEnum(Enum):
Debug = auto() # "debug"
Info = auto() # "info"
Warning = auto() # "warning"
Switch types Switch
. Switch
arguments are like bool
arguments, but they don’t take values. Instead, a switch
argument switch
requires --switch
to enable and --no-switch
to disable:
from argtyped import Arguments, Switch
class MyArgs(Arguments):
switch: Switch = True
bool_arg: bool = False
# argv: [] => flag=True, bool_arg=False
# argv: ["--switch", "--bool-arg=false"] => flag=True, bool_arg=False
# argv: ["--no-switch", "--bool-arg=true"] => flag=False, bool_arg=True
# argv: ["--switch=false"] => WRONG
# argv: ["--no-bool-arg"] => WRONG
List types List[T]
, where T
is any supported type except switch types. List arguments allow passing multiple
values on the command line following the argument flag, it is equivalent to setting nargs="*"
in argparse
.
Although there is no built-in support for other nargs
settings such as "+"
(one or more) or N
(fixed number),
you can add custom validation logic by overriding the __init__
method in your Arguments
subclass.
Optional types Optional[T]
, where T
is any supported type except list or switch types. An optional argument
will be filled with None
if no value is provided. It could also be explicitly set to None
by using none
as value
in the command line:
from argtyped import Arguments
from typing import Optional
class MyArgs(Arguments):
opt_arg: Optional[int] # implicitly defaults to `None`
# argv: [] => opt_arg=None
# argv: ["--opt-arg=1"] => opt_arg=1
# argv: ["--opt-arg=none"] => opt_arg=None
str
as __init__
parameters. It is also theoretically possible to use a functionstr
as input, but it’s not recommended as it’s not type-safe.Arguments
ClassesYou can split your arguments into separate Arguments
classes and then compose them together by inheritance. A subclass
will have the union of all arguments in its base classes. If the subclass contains an argument with the same name as an
argument in a base class, then the subclass definition takes precedence. For example:
class BaseArgs(Arguments):
a: int = 1
b: Switch = True
class DerivedArgs(BaseArgs):
b: str
# args = DerivedArgs([]) # bad; `b` is required
args = DerivedArgs(["--b=1234"])
Caveat: For simplicity, we do not completely follow the C3 linearization algorithm that determines the class MRO in Python. Thus, it is a bad idea to have
overridden arguments in cases where there’s diamond inheritance.
If you don’t understand the above, that’s fine. Just note that generally, it’s a bad idea to have too complicated
inheritance relationships with overridden arguments.
By default argtyped
uses --kebab-case
(with hyphens connecting words), which is the convention for UNIX command line
tools. However, many existing tools use the awkward --snake_case
(with underscores connecting words), and sometimes
consistency is preferred over aesthetics. If you want to use underscores, you can do so by setting underscore=True
inside the parentheses where you specify base classes, like this:
class UnderscoreArgs(Arguments, underscore=True):
underscore_arg: int
underscore_switch: Switch = True
args = UnderscoreArgs(["--underscore_arg", "1", "--no_underscore_switch"])
The underscore settings only affect arguments defined in the class scope; (non-overridden) inherited arguments are not
affects. Thus, you can mix-and-match snake_case
and kebab-case
arguments:
class MyArgs(UnderscoreArgs):
kebab_arg: str
class MyFinalArgs(MyArgs, underscore=True):
new_underscore_arg: float
args = MyArgs(["--underscore_arg", "1", "--kebab-arg", "kebab", "--new_underscore_arg", "1.0"])
argparse
features such as subparsers, groups, argument lists, and custom actions are not supported.arg
, there can be no argument withno_arg
.Optional types:
Optional
can be used with Literal
:
from argtyped import Arguments
from typing import Literal, Optional
class MyArgs(Arguments):
foo: Optional[Literal["a", "b"]] # valid
bar: Literal["a", "b", "none"] # also works but is less obvious
Optional[str]
would parse a value of "none"
(case-insensitive) into None
.List types:
List[Optional[T]]
is a valid type. For example:
from argtyped import Arguments
from typing import List, Literal, Optional
class MyArgs(Arguments):
foo: List[Optional[Literal["a", "b"]]] = ["a", None, "b"] # valid
# argv: ["--foo", "a", "b", "none", "a", "b"] => foo=["a", "b", None, "a", "b"]
Optional[List[int]]
andList[List[int]]
are not accepted.This is what happens under the hood:
argtyped.Arguments
is constructed, type annotations and class-level attributes (i.e., theargtyped.ArgumentSpec
are created for each argument and stored__arguments__
class attribute.argparse.ArgumentParser
is__parser__
parse_args
method is invoked with either sys.argv
or strings provided as parameters, returningself
(the instance of the Arguments
subclass being initialized).action="append"
or action="extend"
for List[T]
types.attrs
class into Arguments
.