Skip to main content

Declerative, type-safe command line argument parsers from dataclasses and attrs classes

Project description

datargs

A paper-thin wrapper around argparse that creates type-safe parsers from dataclass and attrs classes.

Quickstart

Install datargs:

pip install datargs

Create a dataclass (or an attrs class) describing your command line interface, and call datargs.parse() with the class:

# script.py
from dataclasses import dataclass
from pathlib import Path
from datargs import parse

@dataclass  # or @attr.s
class Args:
    url: str
    output_path: Path
    verbose: bool
    retries: int = 3

def main():
    args = parse(Args)
    print(args)

if __name__ == "__main__":
    main()

Mypy and pycharm correctly infer the type of args as Args, and your script is good to go!

$ python script.py -h
usage: test.py [-h] --url URL --output-path OUTPUT_PATH [--retries RETRIES]
               [--verbose]

optional arguments:
  -h, --help            show this help message and exit
  --url URL
  --output-path OUTPUT_PATH
  --retries RETRIES
  --verbose
$ python script.py --url "https://..." --output-path out --retries 4 --verbose
Args(url="https://...", output_path=Path("out"), retries=4, verbose=True)

Table of Contents

Features

Static verification

Mypy/Pycharm have your back when you when you make a mistake:

...
def main():
    args = parse(Args)
    args.urll  # typo
...

Pycharm says: Unresolved attribute reference 'urll' for class 'Args'.

Mypy says: script.py:15: error: "Args" has no attribute "urll"; maybe "url"?

dataclass/attr.s agnostic

>>> import attr, datargs
>>> @attr.s
... class Args:
...     flag: bool = attr.ib()
>>> datargs.parse(Args, [])
Args(flag=False)

Aliases

Aliases and ArgumentParser.add_argument() parameters are taken from metadata:

>>> from dataclasses import dataclass, field
>>> from datargs import parse
>>> @dataclass
... class Args:
...     retries: int = field(default=3, metadata=dict(help="number of retries", aliases=["-r"], metavar="RETRIES"))
>>> parse(Args, ["-h"])
usage: ...
optional arguments:
  -h, --help            show this help message and exit
  --retries RETRIES, -r RETRIES
>>> parse(Args, ["-r", "4"])
Args(retries=4)

arg is a replacement for field/attr.ib that puts add_argument() parameters in metadata. Use it to save precious keystrokes:

>>> from dataclasses import dataclass
>>> from datargs import parse, arg
>>> @dataclass
... class Args:
...     retries: int = arg(default=3, help="number of retries", aliases=["-r"], metavar="RETRIES")
>>> parse(Args, ["-h"])
# exactly the same as before

arg() also supports all field/attr.ib() keyword arguments.

ArgumentParser options

You can pass ArgumnetParser keyword arguments to argsclass:

>>> from datargs import parse, argsclass
>>> @argsclass(description="Romans go home!", prog="messiah.py")
... class Args:
...     flag: bool
>>> parse(Args, ["-h"], parser=parser)
usage: messiah.py [-h] [--flag]
Romans go home!
...

or you can pass your own parser:

>>> from argparse import ArgumentParser
>>> from datargs import parse, argsclass
>>> @argsclass
... class Args:
...     flag: bool
>>> parser = ArgumentParser(description="Romans go home!", prog="messiah.py")
>>> parse(Args, ["-h"], parser=parser)
usage: messiah.py [-h] [--flag]
Romans go home!
...

Use make_parser() to create a parser and save it for later:

>>> from datargs import make_parser
>>> @dataclass
... class Args:
...     ...
>>> parser = make_parser(Args)  # pass `parser=...` to modify an existing parser

NOTE: passing your own parser ignores ArgumentParser params passed to argsclass().

Enums

With datargs, enums Just Work™:

>>> import enum, attr, datargs
>>> class FoodEnum(enum.Enum):
...     ham = 0
...     spam = 1
>>> @attr.dataclass
... class Args:
...     food: FoodEnum
>>> datargs.parse(Args, ["--food", "eggs"])
Args(food=<FoodEnum.ham: 0>)
>>> datargs.parse(Args, ["--food", "eggs"])
usage: enum_test.py [-h] --food {ham,spam}
enum_test.py: error: argument --food: invalid choice: 'eggs' (choose from ['ham', 'spam'])

NOTE: enums are passed by name on the command line and not by value.

  • compatibility with both dataclass and attrs
  • args supports all field and attr.ib arguments.

Sub Commands

No need to specify a useless dest to dispatch on different commands. A Union of dataclasses/attrs classes automatically becomes a group of subparsers. The attribute holding the Union holds the appropriate instance upon parsing, making your code type-safe:

import typing, logging
from datargs import argsclass, arg, parse

@argsclass(description="install package")
class Install:
    package: str = arg(positional=True, help="package to install")

@argsclass(description="show all packages")
class Show:
    verbose: bool = arg(help="show extra info")

@argsclass(description="Pip Install Packages!")
class Pip:
    action: typing.Union[Install, Show]
    log: str = None

args = parse(Pip, ["--log", "debug.log", "install", "my_package"])
print(args)
# prints: Pip(action=Install(package='my_package'), log='debug.log') 

# Consume arguments:
if args.log:
    logging.basicConfig(filename=args.log)
if isinstance(args.action, Install):
    install_package(args.action.package)
    # static type error: args.action.verbose
elif isinstance(args.action, Show):
    list_all_packages(verbose=args.action.verbose)
else:
    assert False, "Unreachable code"

NOTE: if the commented-out line above does not issue a type error, try adding an @dataclass/@attr.s before or instead of @argsclass():

@argsclass(description="Pip Install Packages!")  # optional
@dataclass
class Pip:
    action: typing.Union[Install, Show]
    log: str = None
...
if isinstance(args.action, Install):
    install_package(args.action.package)
    # this should now produce a type error: args.action.verbose

"Why not"s and design choices

Many libraries out there do similar things. This list serves as documentation for existing solutions and differences.

So, why not...

Just use argparse?

That's easy. The interface is clumsy and repetitive, a.k.a boilerplate. Additionally, ArgumentParser.parse_args() returns a Namespace, which is equivalent to Any, meaning that it any attribute access is legal when type checking. Alas, invalid attribute access will fail at runtime. For example:

def parse_args():
    parser = ArgumentParser()
    parser.add_argument("--url")
    return parser.parse_args()
 
def main():
    args = parse_args()
    print(args.url)

Let's say for some reason --url is changed to --uri:

parser.add_argument("--uri")
...
print(args.url)  # oops

You won't discover you made a mistake until you run the code. With datargs, a static type checker will issue an error. Also, why use a carriage when you have a spaceship?

Use click?

click is a great library. It provides many utilities for command line programs.

Use datargs if you believe user interface should not be coupled with implementation, or if you want to use argparse without boilerplate. Use click if you don't care.

Use clout?

It seems that clout aims to be an end-to-end solution for command line programs à la click.

Use it if you need a broader solution. Use datargs if you want to use argparse without boilerplate.

Use simple-parsing?

This is another impressive library.

Use it if you have deeply-nested options, or if the following points don't apply to you.

Use datargs if you:

  • need attrs support
  • want as little magic as possible
  • don't have many options or they're not nested
  • prefer dashes (--like-this) over underscores (--like_this)

Use argparse-dataclass?

It's similar to this library. The main differences I found are:

  • no attrs support
  • not on github, so who you gonna call?

Use argparse-dataclasses?

Same points argparse-dataclass but also Uses inheritance.

FAQs

Is this cross-platform?

Yes, just like argparse. If you find a bug on a certain platform (or any other bug), please report it.

Why are mutually exclusive options not supported?

This library is based on the idea of a one-to-one correspondence between most parsers and simple classes. Conceptually, mutually exclusive options are analogous to sum types, just like subparsers are, but writing a class for each flag is not ergonomic enough. Contact me if you want this feature or if you come up with a better solution.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

datargs-0.2.1.tar.gz (15.3 kB view details)

Uploaded Source

Built Distribution

datargs-0.2.1-py3-none-any.whl (13.0 kB view details)

Uploaded Python 3

File details

Details for the file datargs-0.2.1.tar.gz.

File metadata

  • Download URL: datargs-0.2.1.tar.gz
  • Upload date:
  • Size: 15.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/1.0.10 CPython/3.8.2 Linux/4.19.104-microsoft-standard

File hashes

Hashes for datargs-0.2.1.tar.gz
Algorithm Hash digest
SHA256 b9cddc9279d150ab14a49d33bceef58570e61c017026254d2cf3f1e9c4883445
MD5 9462966c7b8da78eec468e112ad8a927
BLAKE2b-256 ed4fa5c985910c570965e8c73b3459aa9e0ab06c4ab3fa82709eda9014fecaf5

See more details on using hashes here.

File details

Details for the file datargs-0.2.1-py3-none-any.whl.

File metadata

  • Download URL: datargs-0.2.1-py3-none-any.whl
  • Upload date:
  • Size: 13.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/1.0.10 CPython/3.8.2 Linux/4.19.104-microsoft-standard

File hashes

Hashes for datargs-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 664e9ba5ea9f34a8531e1ad8cabef97c8cfde8a51a443b9aa3374f3c4eb7af63
MD5 42f1ea0f1ecfcf6c4022af6bfb8e3631
BLAKE2b-256 25220a8d5ef1863bb1c4ea637f4533781da288b078f52a28196d5507a725e930

See more details on using hashes here.

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page