Skip to main content

💡 type-safe args for argparse without much refactoring.

Project description

typed_argparse

💡 type-safe args for argparse without much refactoring.

PyPI version Build Status codecov Code style: black mypy license




Motivation

Want to add type annotations to a code base that makes use of argparse without refactoring all you CLIs? typed_argparse allows to do that with minimal changes:

  1. Add a type Args(TypedArgs) that inherits from TypedArgs and fill it with type annotations.
  2. Wrap the result of e.g. your parse_args function with Args.
  3. That's it, enjoy IDE auto-completion and strong type safety 😀.

Features

  • Implicit runtime validation to ensure type annotations are correct
  • Support for common types (Optional, List, Literal, Enum, Union, and regular classes)
  • Convenience functionality to map Literal/Enum to choices
  • Convenience functionality to map Union to subcommands
  • Very lightweight
  • No dependencies
  • Fully typed, no extra type stubs required

Install

$ pip install typed-argparse

The only requirement is a modern Python (3.6+).

Usage

import argparse
import sys
from typing import List, Optional
from typed_argparse import TypedArgs


# Step 1: Add an argument type.
class Args(TypedArgs):
    foo: str
    num: Optional[int]
    files: List[str]


def parse_args(args: List[str] = sys.argv[1:]) -> Args:
    parser = argparse.ArgumentParser()
    parser.add_argument("--foo", type=str, required=True)
    parser.add_argument("--num", type=int)
    parser.add_argument("--files", type=str, nargs="*")
    # Step 2: Wrap the plain argparser result with your type.
    return Args.from_argparse(parser.parse_args(args))


def main() -> None:
    args = parse_args()
    # Step 3: Done, enjoy IDE auto-completion and strong type safety
    assert args.foo == "foo"
    assert args.num == 42
    assert args.files == ["a", "b", "c"]


if __name__ == "__main__":
    main()

typed_argparse validates that no attributes from the type definition are missing, and that no unexpected extra types are present in the argparse.Namespace object. It also validates the types at runtime. Therefore, if the Args.from_argparse(args) doesn't throw a TypeError you can be sure that your type annotation is correct.

Feature Examples

Convenience functionality to map Literal/Enum to choices

When defining arguments that should be limited to certain values, a natural choice for the corresponding type is to use either Literal or Enum. On argparse side, the corresponding setting is to specify the choices=... parameter. In order to have a single source of truth (i.e., avoid having to specify the values twice), it is possible to use TypedArgs.get_choices_from(). For instance:

class Args(TypedArgs):
    mode: Literal["a", "b", "c"]


def parse_args(args: List[str] = sys.argv[1:]) -> Args:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--mode",
        type=str,
        required=True,
        choices=Args.get_choices_from("mode"),
    )
    return Args.from_argparse(parser.parse_args(args))

This makes sure that choices is always in sync with the values allowed by Args.mode. The same works when using mode: SomeEnum where SomeEnum is an enum inheriting enum.Enum.

class MyEnum(Enum):
    a = "a"
    b = "b"
    c = "c"


class Args(TypedArgs):
    mode: MyEnum


def parse_args(args: List[str] = sys.argv[1:]) -> Args:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--mode",
        type=MyEnum,
        required=True,
        choices=Args.get_choices_from("mode"),
    )
    return Args.from_argparse(parser.parse_args(args))

Support for Union (useful for subcommand parsing)

When implementing multi command CLIs, the various subparsers can often have completely different arguments. In terms of the type system such arguments are best modelled as a Union type. For instance, consider a CLI that has two modes foo and bar. In the foo mode, we want a --file arg, but in the bar mode, we want e.g. a --src and --dst args. We also want some shared args, like --verbose. This can be achieved by modeling the types as:

from typed_argparse import TypedArgs, WithUnionType


class CommonArgs(TypedArgs):
    verbose: bool


class ArgsFoo(CommonArgs):
    mode: Literal["foo"]
    file: str


class ArgsBar(CommonArgs):
    mode: Literal["bar"]
    src: str
    dst: str


Args = Union[ArgsFoo, ArgsBar]

On parsing side, WithUnionType[Args].validate(...) can be used to parse the arguments into a type union:

def parse_args(args: List[str] = sys.argv[1:]) -> Args:
    parser = argparse.ArgumentParser()
    parser.add_argument("--verbose", action="store_true", help="Verbose")
    subparsers = parser.add_subparsers(
        help="Available sub commands",
        dest="mode",
        required=True,
    )

    parser_foo = subparsers.add_parser("foo")
    parser_foo.add_argument("file", type=str)

    parser_bar = subparsers.add_parser("bar")
    parser_bar.add_argument("--src", required=True)
    parser_bar.add_argument("--dst", required=True)

    return WithUnionType[Args].validate(parser.parse_args(args))

Type checkers like mypy a pretty good at handling such "tagged unions". Usage could look like:

def main() -> None:
    args = parse_args()

    if args.mode == "foo":
        # In this branch, mypy knows (only) these fields (and their types)
        print(args.file, args.verbose)

    if args.mode == "bar":
        # In this branch, mypy knows (only) these fields (and their types)
        print(args.src, args.dst, args.verbose)

    # Alteratively:
    if isinstance(args, ArgsFoo):
        # It's an ArgsFoo
        ...
    if isinstance(args, ArgsBar):
        # It's an ArgsBar
        ...

Work-around for common argparse limitation

A known limitation (bug report, SO question 1, SO question 2) of argparse is that it is not possible to combine a positional choices parameters with nargs="*" and an list-like default. This may sounds exotic, but isn't such a rare use case in practice. Consider for instance a positional actions argument that should take the values "eat" and "sleep" and allow for arbitrary sequences "eat eat sleep eat ...". The library provides a small work-around wrapper class Choices that allows to work-around this argparse limitation:

from typed_argparse import Choices

def parse_args():
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "actions",
        nargs="*",
        choices=Choices("eat", "sleep"),
        default=[],
    )

TypedArgs.get_choices_from() internally uses this wrapper, i.e., it automatically solves the limitation.

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

typed_argparse-0.2.3.tar.gz (19.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

typed_argparse-0.2.3-py3-none-any.whl (18.6 kB view details)

Uploaded Python 3

File details

Details for the file typed_argparse-0.2.3.tar.gz.

File metadata

  • Download URL: typed_argparse-0.2.3.tar.gz
  • Upload date:
  • Size: 19.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/4.0.1 CPython/3.9.14

File hashes

Hashes for typed_argparse-0.2.3.tar.gz
Algorithm Hash digest
SHA256 aa7a15e79c2edbc2192f12554aac0958236813bb7aeac97af4923393d8298fc3
MD5 b839a5428c3134d07e6f898478cd7d09
BLAKE2b-256 55cad98e4e80ce46c42d433f0f9b11a7e55e90cf80af31af9f8a9ba63b380e1a

See more details on using hashes here.

File details

Details for the file typed_argparse-0.2.3-py3-none-any.whl.

File metadata

  • Download URL: typed_argparse-0.2.3-py3-none-any.whl
  • Upload date:
  • Size: 18.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/4.0.1 CPython/3.9.14

File hashes

Hashes for typed_argparse-0.2.3-py3-none-any.whl
Algorithm Hash digest
SHA256 df55118d744259a53448decb60c8999c687e6f8395b7d6a0076a5997050c3d09
MD5 e29ec8b75b81b2bc2681b5666da9b400
BLAKE2b-256 62cedd0d445199647ea59c8809342b519f75c4bea0732710796b5b39c1ef8c09

See more details on using hashes here.

Supported by

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