项目作者: huzecong

项目描述 :
Command line argument parser, with types.
高级语言: Python
项目地址: git://github.com/huzecong/argtyped.git
创建时间: 2020-02-07T23:03:52Z
项目社区:https://github.com/huzecong/argtyped

开源协议:MIT License

下载


argtyped: Command Line Argument Parser, with Types

Build Status
CodeCov
PyPI
MIT License

argtyped is an command line argument parser with that relies on type annotations. It is built on
argparse, the command line argument parser library built into
Python. Compared with argparse, this library gives you:

  • More concise and intuitive syntax, less boilerplate code.
  • Type checking and IDE auto-completion for command line arguments.
  • A drop-in replacement for argparse in most cases.

Since v0.4.0, argtyped also supports parsing arguments defined with an attrs-class. See
Attrs Support for more details.

Installation

Install stable release from PyPI:

  1. pip install argtyped

Or, install the latest commit from GitHub:

  1. pip install git+https://github.com/huzecong/argtyped.git

Usage

With argtyped, you can define command line arguments in a syntax similar to
typing.NamedTuple. The syntax is intuitive and can
be illustrated with an example:

  1. from typing import Optional
  2. from typing_extensions import Literal # or directly import from `typing` in Python 3.8+
  3. from argtyped import Arguments, Switch
  4. from argtyped import Enum, auto
  5. class LoggingLevels(Enum):
  6. Debug = auto()
  7. Info = auto()
  8. Warning = auto()
  9. Error = auto()
  10. Critical = auto()
  11. class MyArguments(Arguments):
  12. model_name: str # required argument of `str` type
  13. hidden_size: int = 512 # `int` argument with default value of 512
  14. activation: Literal["relu", "tanh", "sigmoid"] = "relu" # argument with limited choices
  15. logging_level: LoggingLevels = LoggingLevels.Info # using `Enum` class as choices
  16. use_dropout: Switch = True # switch argument, enable with "--use-dropout" and disable with "--no-use-dropout"
  17. dropout_prob: Optional[float] = 0.5 # optional argument, "--dropout-prob=none" parses into `None`
  18. args = MyArguments()

This is equivalent to the following code with Python built-in argparse:

  1. import argparse
  2. from enum import Enum
  3. class LoggingLevels(Enum):
  4. Debug = "debug"
  5. Info = "info"
  6. Warning = "warning"
  7. Error = "error"
  8. Critical = "critical"
  9. parser = argparse.ArgumentParser()
  10. parser.add_argument("--model-name", type=str, required=True)
  11. parser.add_argument("--hidden-size", type=int, default=512)
  12. parser.add_argument("--activation", choices=["relu", "tanh", "sigmoid"], default="relu")
  13. parser.add_argument("--logging-level", choices=list(LoggingLevels), type=LoggingLevels, default="info")
  14. parser.add_argument("--use-dropout", action="store_true", dest="use_dropout", default=True)
  15. parser.add_argument("--no-use-dropout", action="store_false", dest="use_dropout")
  16. parser.add_argument("--dropout-prob", type=lambda s: None if s.lower() == 'none' else float(s), default=0.5)
  17. args = parser.parse_args()

Save the code into a file named main.py. Suppose the following arguments are provided:

  1. python main.py \
  2. --model-name LSTM \
  3. --activation sigmoid \
  4. --logging-level debug \
  5. --no-use-dropout \
  6. --dropout-prob none

Then the parsed arguments will be equivalent to the following structure returned by argparse:

  1. argparse.Namespace(
  2. model_name="LSTM", hidden_size=512, activation="sigmoid", logging_level="debug",
  3. use_dropout=False, dropout_prob=None)

Arguments can also be pretty-printed:

  1. >>> print(args)
  2. <class '__main__.MyArguments'>
  3. ╔═════════════════╤══════════════════════════════════╗
  4. Arguments Values
  5. ╠═════════════════╪══════════════════════════════════╣
  6. model_name 'LSTM'
  7. hidden_size 512
  8. activation 'sigmoid'
  9. logging_level <MyLoggingLevels.Debug: 'debug'>
  10. use_dropout False
  11. dropout_prob None
  12. label_smoothing 0.1
  13. some_true_arg True
  14. some_false_arg False
  15. ╚═════════════════╧══════════════════════════════════╝

It is recommended though to use the args.to_string() method, which gives you control of the table width.

Attrs Support (New)

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:

  1. import attr # note: new style `attrs` syntax is also supported
  2. from argtyped import AttrsArguments
  3. def _convert_logging_level(s: str) -> LoggingLevels:
  4. # Custom conversion function that takes the raw string value from the command line.
  5. return LoggingLevels[s.lower()]
  6. @attr.s(auto_attribs=True)
  7. class MyArguments(AttrsArguments):
  8. model_name: str = attr.ib(metadata={"positional": True}) # positional argument
  9. # Or: `model_name: str = argtyped.positional_arg()`.
  10. layer_sizes: List[int] = attr.ib(metadata={"nargs": "+"}) # other metadata are treated as `argparse` options
  11. activation: Literal["relu", "tanh", "sigmoid"] = "relu"
  12. logging_level: LoggingLevels = attr.ib(default=LoggingLevels.Info, converter=_convert_logging_level)
  13. use_dropout: Switch = True
  14. dropout_prob: Optional[float] = 0.5
  15. _activation_fn: Callable[[float], float] = attr.ib(init=False) # `init=False` attributes are not parsed
  16. @dropout_prob.validator # validators still work as you would expect
  17. def _dropout_prob_validator(self, attribute, value):
  18. if not 0.0 <= value <= 1.0:
  19. raise ValueError(f"Invalid probability {value}")
  20. @_activation_fn.default
  21. def _activation_fn(self):
  22. return _ACTIVATION_FNS[self.activation]

A few things to note here:

  • You can define positional arguments by adding "positional": True as metadata. If this feels unnatural, you could
    also use argtyped.positional_arg(), which takes the same arguments as attr.ib.
  • You can pass additional options to ArgumentParser.add_argument by listing them as metadata as well. Note that
    these options take precedence over argtyped‘s computed arguments, for example, sequence arguments (List[T]) by
    default uses nargs="*", but you could override it with metadata.
  • Attributes with custom converters will not be parsed; its converter will be called with the raw string value from
    command line. If the attribute also has a default value, you should make sure that your converter works with both
    strings and the default value.
  • init=False attributes are not treated as arguments, but they can be useful for storing computed values based on
    arguments.
  • The default value logic is the same as normal attrs classes, and thus could be different from non-attrs argtyped
    classes. For example, optional arguments are not considered to have an implicit default of None, and no type
    validation is performed on default values.

Here 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 also
    means that nullable arguments must have an explicit default value of None, otherwise it becomes a required
    argument.
  • AttrsArguments 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 command
    line arguments on construction.

Reference

The argtyped.Arguments Class

The 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:

  1. class MyArgs(argtyped.Arguments):
  2. # name: type [= default_val]
  3. value: int = 0
  4. args = MyArgs() # equivalent to `parser.parse_args()`
  5. args = MyArgs(["--value", "123"]) # equivalent to `parser.parse_args(["--value", "123"])
  6. 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 raised
    when the table cannot be drawn with the given width.
  • max_width: Maximum width of the printed table. Defaults to None, meaning no limits. Must be None if width is
    not None.

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.

Argument Types

To summarize, whatever works for argparse works here. The following types are supported:

  • Built-in types such as int, float, str.
  • Boolean type bool. Accepted values (case-insensitive) for True are: y, yes, true, ok; accepted values
    for False 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 strs, or an expression that evaluates to a list of
    strs:

    1. from argtyped import Arguments
    2. from typing_extensions import Literal
    3. class MyArgs(Arguments):
    4. foo: Literal["debug", "info", "warning", "error"] # 4 choices
    5. # 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 the
    Literal 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:

    1. from argtyped import Enum
    2. class MyEnum(Enum):
    3. Debug = auto() # "debug"
    4. Info = auto() # "info"
    5. 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:

    1. from argtyped import Arguments, Switch
    2. class MyArgs(Arguments):
    3. switch: Switch = True
    4. bool_arg: bool = False
    5. # argv: [] => flag=True, bool_arg=False
    6. # argv: ["--switch", "--bool-arg=false"] => flag=True, bool_arg=False
    7. # argv: ["--no-switch", "--bool-arg=true"] => flag=False, bool_arg=True
    8. # argv: ["--switch=false"] => WRONG
    9. # 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:

    1. from argtyped import Arguments
    2. from typing import Optional
    3. class MyArgs(Arguments):
    4. opt_arg: Optional[int] # implicitly defaults to `None`
    5. # argv: [] => opt_arg=None
    6. # argv: ["--opt-arg=1"] => opt_arg=1
    7. # argv: ["--opt-arg=none"] => opt_arg=None
  • Any other type that takes a single str as __init__ parameters. It is also theoretically possible to use a function
    that takes an str as input, but it’s not recommended as it’s not type-safe.

Composing Arguments Classes

You 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:

  1. class BaseArgs(Arguments):
  2. a: int = 1
  3. b: Switch = True
  4. class DerivedArgs(BaseArgs):
  5. b: str
  6. # args = DerivedArgs([]) # bad; `b` is required
  7. 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.

Argument Naming Styles

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:

  1. class UnderscoreArgs(Arguments, underscore=True):
  2. underscore_arg: int
  3. underscore_switch: Switch = True
  4. 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:

  1. class MyArgs(UnderscoreArgs):
  2. kebab_arg: str
  3. class MyFinalArgs(MyArgs, underscore=True):
  4. new_underscore_arg: float
  5. args = MyArgs(["--underscore_arg", "1", "--kebab-arg", "kebab", "--new_underscore_arg", "1.0"])

Notes

  • Advanced argparse features such as subparsers, groups, argument lists, and custom actions are not supported.
  • Using switch arguments may result in name clashes: if a switch argument has name arg, there can be no argument with
    the name no_arg.
  • Optional types:

    • Optional can be used with Literal:

      1. from argtyped import Arguments
      2. from typing import Literal, Optional
      3. class MyArgs(Arguments):
      4. foo: Optional[Literal["a", "b"]] # valid
      5. 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:

      1. from argtyped import Arguments
      2. from typing import List, Literal, Optional
      3. class MyArgs(Arguments):
      4. foo: List[Optional[Literal["a", "b"]]] = ["a", None, "b"] # valid
      5. # argv: ["--foo", "a", "b", "none", "a", "b"] => foo=["a", "b", None, "a", "b"]
    • List types cannot be nested inside a list or an optional type. Types such as Optional[List[int]] and
      List[List[int]] are not accepted.

Under the Hood

This is what happens under the hood:

  1. When a subclass of argtyped.Arguments is constructed, type annotations and class-level attributes (i.e., the
    default values) are collected to form argument declarations.
  2. After verifying the validity of declared arguments, argtyped.ArgumentSpec are created for each argument and stored
    within the subclass as the __arguments__ class attribute.
  3. When an instance of the subclass is initialized, if it’s the first time, an instance of argparse.ArgumentParser is
    created and arguments are registered with the parser. The parser is cached in the subclass as the __parser__
    attribute.
  4. The parser’s parse_args method is invoked with either sys.argv or strings provided as parameters, returning
    parsed arguments.
  5. The parsed arguments are assigned to self (the instance of the Arguments subclass being initialized).

Todo

  • Support action="append" or action="extend" for List[T] types.
    • Technically this is not a problem, but there’s no elegant way to configure whether this behavior is desired.
  • Throw (suppressible) warnings on using non-type callables as types.
  • Support converting an attrs class into Arguments.
  • Support forward references in type annotations.