Code Monkey home page Code Monkey logo

typed-argument-parser's Introduction

Typed Argument Parser (Tap)

PyPI - Python Version PyPI version Downloads Build Status codecov license

Tap is a typed modernization of Python's argparse library.

Tap provides the following benefits:

  • Static type checking
  • Code completion
  • Source code navigation (e.g. go to definition and go to implementation)

Tap

See this poster, which we presented at PyCon 2020, for a presentation of some of the relevant concepts we used to guide the development of Tap.

As of version 1.8.0, Tap includes tapify, which runs functions or initializes classes with arguments parsed from the command line. We show an example below.

# square.py
from tap import tapify

def square(num: float) -> float:
    return num ** 2

if __name__ == '__main__':
    print(f'The square of your number is {tapify(square)}.')

Running python square.py --num 2 will print The square of your number is 4.0.. Please see tapify for more details.

Installation

Tap requires Python 3.8+

To install Tap from PyPI run:

pip install typed-argument-parser
To install Tap from source, run the following commands:
git clone https://github.com/swansonk14/typed-argument-parser.git
cd typed-argument-parser
pip install -e .
To develop this package, install development requirements (in a virtual environment):
python -m pip install -e ".[dev]"

Style:

  • Please use black formatting
  • Set your vertical line ruler to 121
  • Use flake8 linting.

To run tests, run:

pytest

Table of Contents

Tap is Python-native

To see this, let's look at an example:

"""main.py"""

from tap import Tap

class SimpleArgumentParser(Tap):
    name: str  # Your name
    language: str = 'Python'  # Programming language
    package: str = 'Tap'  # Package name
    stars: int  # Number of stars
    max_stars: int = 5  # Maximum stars

args = SimpleArgumentParser().parse_args()

print(f'My name is {args.name} and I give the {args.language} package '
      f'{args.package} {args.stars}/{args.max_stars} stars!')

You use Tap the same way you use standard argparse.

>>> python main.py --name Jesse --stars 5
My name is Jesse and I give the Python package Tap 5/5 stars!

The equivalent argparse code is:

"""main.py"""

from argparse import ArgumentParser

parser = ArgumentParser()
parser.add_argument('--name', type=str, required=True,
                    help='Your name')
parser.add_argument('--language', type=str, default='Python',
                    help='Programming language')
parser.add_argument('--package', type=str, default='Tap',
                    help='Package name')
parser.add_argument('--stars', type=int, required=True,
                    help='Number of stars')
parser.add_argument('--max_stars', type=int, default=5,
                    help='Maximum stars')
args = parser.parse_args()

print(f'My name is {args.name} and I give the {args.language} package '
      f'{args.package} {args.stars}/{args.max_stars} stars!')

The advantages of being Python-native include being able to:

  • Overwrite convenient built-in methods (e.g. process_args ensures consistency among arguments)
  • Add custom methods
  • Inherit from your own template classes

Tap features

Now we are going to highlight some of our favorite features and give examples of how they work in practice.

Arguments

Arguments are specified as class variables defined in a subclass of Tap. Variables defined as name: type are required arguments while variables defined as name: type = value are not required and default to the provided value.

class MyTap(Tap):
    required_arg: str
    default_arg: str = 'default value'

Tap help

Single line and/or multiline comments which appear after the argument are automatically parsed into the help string provided when running python main.py -h. The type and default values of arguments are also provided in the help string.

"""main.py"""

from tap import Tap

class MyTap(Tap):
    x: float  # What am I?
    pi: float = 3.14  # I'm pi!
    """Pi is my favorite number!"""

args = MyTap().parse_args()

Running python main.py -h results in the following:

>>> python main.py -h
usage: demo.py --x X [--pi PI] [-h]

optional arguments:
  --x X       (float, required) What am I?
  --pi PI     (float, default=3.14) I'm pi! Pi is my favorite number.
  -h, --help  show this help message and exit

Configuring arguments

To specify behavior beyond what can be specified using arguments as class variables, override the configure method. configure provides access to advanced argument parsing features such as add_argument and add_subparser. Since Tap is a wrapper around argparse, Tap provides all of the same functionality. We detail these two functions below.

Adding special argument behavior

In the configure method, call self.add_argument just as you would use argparse's add_argument. For example,

from tap import Tap

class MyTap(Tap):
    positional_argument: str
    list_of_three_things: List[str]
    argument_with_really_long_name: int

    def configure(self):
        self.add_argument('positional_argument')
        self.add_argument('--list_of_three_things', nargs=3)
        self.add_argument('-arg', '--argument_with_really_long_name')

Adding subparsers

To add a subparser, override the configure method and call self.add_subparser. Optionally, to specify keyword arguments (e.g., help) to the subparser collection, call self.add_subparsers. For example,

class SubparserA(Tap):
    bar: int  # bar help

class SubparserB(Tap):
    baz: Literal['X', 'Y', 'Z']  # baz help

class Args(Tap):
    foo: bool = False  # foo help

    def configure(self):
        self.add_subparsers(help='sub-command help')
        self.add_subparser('a', SubparserA, help='a help')
        self.add_subparser('b', SubparserB, help='b help')

Types

Tap automatically handles all the following types:

str, int, float, bool
Optional, Optional[str], Optional[int], Optional[float], Optional[bool]
List, List[str], List[int], List[float], List[bool]
Set, Set[str], Set[int], Set[float], Set[bool]
Tuple, Tuple[Type1, Type2, etc.], Tuple[Type, ...]  
Literal

If you're using Python 3.9+, then you can replace List with list, Set with set, and Tuple with tuple.

Tap also supports Union, but this requires additional specification (see Union section below).

Additionally, any type that can be instantiated with a string argument can be used. For example, in

from pathlib import Path
from tap import Tap

class Args(Tap):
   path: Path

args = Args().parse_args()

args.path is a Path instance containing the string passed in through the command line.

str, int, and float

Each is automatically parsed to their respective types, just like argparse.

bool

If an argument arg is specified as arg: bool or arg: bool = False, then adding the --arg flag to the command line will set arg to True. If arg is specified as arg: bool = True, then adding --arg sets arg to False.

Note that if the Tap instance is created with explicit_bool=True, then booleans can be specified on the command line as --arg True or --arg False rather than --arg. Additionally, booleans can be specified by prefixes of True and False with any capitalization as well as 1 or 0 (e.g. for True, --arg tRu, --arg T, --arg 1 all suffice).

Optional

These arguments are parsed in exactly the same way as str, int, float, and bool. Note bools can be specified using the same rules as above and that Optional is equivalent to Optional[str].

List

If an argument arg is a List, simply specify the values separated by spaces just as you would with regular argparse. For example, --arg 1 2 3 parses to arg = [1, 2, 3].

Set

Identical to List but parsed into a set rather than a list.

Tuple

Tuples can be used to specify a fixed number of arguments with specified types using the syntax Tuple[Type1, Type2, etc.] (e.g. Tuple[str, int, bool, str]). Tuples with a variable number of arguments are specified by Tuple[Type, ...] (e.g. Tuple[int, ...]). Note Tuple defaults to Tuple[str, ...].

Literal

Literal is analagous to argparse's choices, which specifies the values that an argument can take. For example, if arg can only be one of 'H', 1, False, or 1.0078 then you would specify that arg: Literal['H', 1, False, 1.0078]. For instance, --arg False assigns arg to False and --arg True throws error.

Union

Union types must include the type keyword argument in add_argument in order to specify which type to use, as in the example below.

def to_number(string: str) -> Union[float, int]:
    return float(string) if '.' in string else int(string)

class MyTap(Tap):
    number: Union[float, int]

    def configure(self):
        self.add_argument('--number', type=to_number)

In Python 3.10+, Union[Type1, Type2, etc.] can be replaced with Type1 | Type2 | etc., but the type keyword argument must still be provided in add_argument.

Complex Types

Tap can also support more complex types than the ones specified above. If the desired type is constructed with a single string as input, then the type can be specified directly without additional modifications. For example,

class Person:
    def __init__(self, name: str) -> None:
        self.name = name

class Args(Tap):
    person: Person

args = Args().parse_args('--person Tapper'.split())
print(args.person.name)  # Tapper

If the desired type has a more complex constructor, then the type keyword argument must be provided in add_argument. For example,

class AgedPerson:
    def __init__(self, name: str, age: int) -> None:
        self.name = name
        self.age = age

def to_aged_person(string: str) -> AgedPerson:
    name, age = string.split(',')
    return AgedPerson(name=name, age=int(age))

class Args(Tap):
    aged_person: AgedPerson

    def configure(self) -> None:
        self.add_argument('--aged_person', type=to_aged_person)

args = Args().parse_args('--aged_person Tapper,27'.split())
print(f'{args.aged_person.name} is {args.aged_person.age}')  # Tapper is 27

Argument processing

With complex argument parsing, arguments often end up having interdependencies. This means that it may be necessary to disallow certain combinations of arguments or to modify some arguments based on other arguments.

To handle such cases, simply override process_args and add the required logic. process_args is automatically called when parse_args is called.

class MyTap(Tap):
    package: str
    is_cool: bool
    stars: int

    def process_args(self):
        # Validate arguments
        if self.is_cool and self.stars < 4:
            raise ValueError('Cool packages cannot have fewer than 4 stars')

        # Modify arguments
        if self.package == 'Tap':
            self.is_cool = True
            self.stars = 5

Processing known args

Similar to argparse's parse_known_args, Tap is capable of parsing only arguments that it is aware of without raising an error due to additional arguments. This can be done by calling parse_args with known_only=True. The remaining un-parsed arguments are then available by accessing the extra_args field of the Tap object.

class MyTap(Tap):
    package: str

args = MyTap().parse_args(['--package', 'Tap', '--other_arg', 'value'], known_only=True)
print(args.extra_args)  # ['--other_arg', 'value']

Subclassing

It is sometimes useful to define a template Tap and then subclass it for different use cases. Since Tap is a native Python class, inheritance is built-in, making it easy to customize from a template Tap.

In the example below, StarsTap and AwardsTap inherit the arguments (package and is_cool) and the methods (process_args) from BaseTap.

class BaseTap(Tap):
    package: str
    is_cool: bool

    def process_args(self):
        if self.package == 'Tap':
            self.is_cool = True


class StarsTap(BaseTap):
    stars: int


class AwardsTap(BaseTap):
    awards: List[str]

Printing

Tap uses Python's pretty printer to print out arguments in an easy-to-read format.

"""main.py"""

from tap import Tap
from typing import List

class MyTap(Tap):
    package: str
    is_cool: bool = True
    awards: List[str] = ['amazing', 'wow', 'incredible', 'awesome']

args = MyTap().parse_args()
print(args)

Running python main.py --package Tap results in:

>>> python main.py
{'awards': ['amazing', 'wow', 'incredible', 'awesome'],
 'is_cool': True,
 'package': 'Tap'}

Reproducibility

Tap makes reproducibility easy, especially when running code in a git repo.

Reproducibility info

Specifically, Tap has a method called get_reproducibility_info that returns a dictionary containing all the information necessary to replicate the settings under which the code was run. This dictionary includes:

  • Python command
    • The Python command that was used to run the program
    • Ex. python main.py --package Tap
  • Time
    • The time when the command was run
    • Ex. Thu Aug 15 00:09:13 2019
  • Git root
    • The root of the git repo containing the code that was run
    • Ex. /Users/swansonk14/typed-argument-parser
  • Git url
  • Uncommitted changes
    • Whether there are any uncommitted changes in the git repo (i.e. whether the code is different from the code at the above git hash)
    • Ex. True or False

Conversion Tap to and from dictionaries

Tap has methods as_dict and from_dict that convert Tap objects to and from dictionaries. For example,

"""main.py"""
from tap import Tap

class Args(Tap):
    package: str
    is_cool: bool = True
    stars: int = 5

args = Args().parse_args(["--package", "Tap"])

args_data = args.as_dict()
print(args_data)  # {'package': 'Tap', 'is_cool': True, 'stars': 5}

args_data['stars'] = 2000
args = args.from_dict(args_data)
print(args.stars)  # 2000 

Note that as_dict does not include attributes set directly on an instance (e.g., arg is not included even after setting args.arg = "hi" in the code above because arg is not an attribute of the Args class). Also note that from_dict ensures that all required arguments are set.

Saving and loading arguments

Save

Tap has a method called save which saves all arguments, along with the reproducibility info, to a JSON file.

"""main.py"""

from tap import Tap

class MyTap(Tap):
    package: str
    is_cool: bool = True
    stars: int = 5

args = MyTap().parse_args()
args.save('args.json')

After running python main.py --package Tap, the file args.json will contain:

{
    "is_cool": true,
    "package": "Tap",
    "reproducibility": {
        "command_line": "python main.py --package Tap",
        "git_has_uncommitted_changes": false,
        "git_root": "/Users/swansonk14/typed-argument-parser",
        "git_url": "https://github.com/swansonk14/typed-argument-parser/tree/446cf046631d6bdf7cab6daec93bf7a02ac00998",
        "time": "Thu Aug 15 00:18:31 2019"
    },
    "stars": 5
}

Note: More complex types will be encoded in JSON as a pickle string.

Load

⚠️
Never call args.load('args.json') on untrusted files. Argument loading uses the pickle module to decode complex types automatically. Unpickling of untrusted data is a security risk and can lead to arbitrary code execution. See the warning in the pickle docs.
⚠️

Arguments can be loaded from a JSON file rather than parsed from the command line.

"""main.py"""

from tap import Tap

class MyTap(Tap):
    package: str
    is_cool: bool = True
    stars: int = 5

args = MyTap()
args.load('args.json')

Note: All required arguments (in this case package) must be present in the JSON file if not already set in the Tap object.

Load from dict

Arguments can be loaded from a Python dictionary rather than parsed from the command line.

"""main.py"""

from tap import Tap

class MyTap(Tap):
    package: str
    is_cool: bool = True
    stars: int = 5

args = MyTap()
args.from_dict({
    'package': 'Tap',
    'stars': 20
})

Note: As with load, all required arguments must be present in the dictionary if not already set in the Tap object. All values in the provided dictionary will overwrite values currently in the Tap object.

Loading from configuration files

Configuration files can be loaded along with arguments with the optional flag config_files: List[str]. Arguments passed in from the command line overwrite arguments from the configuration files. Arguments in configuration files that appear later in the list overwrite the arguments in previous configuration files.

For example, if you have the config file my_config.txt

--arg1 1
--arg2 two

then you can write

from tap import Tap

class Args(Tap):
    arg1: int
    arg2: str

args = Args(config_files=['my_config.txt']).parse_args()

Config files are parsed using shlex.split from the python standard library, which supports shell-style string quoting, as well as line-end comments starting with #.

For example, if you have the config file my_config_shlex.txt

--arg1 21 # Important arg value

# Multi-word quoted string
--arg2 "two three four"

then you can write

from tap import Tap

class Args(Tap):
    arg1: int
    arg2: str

args = Args(config_files=['my_config_shlex.txt']).parse_args()

to get the resulting args = {'arg1': 21, 'arg2': 'two three four'}

The legacy parsing behavior of using standard string split can be re-enabled by passing legacy_config_parsing=True to parse_args.

tapify

tapify makes it possible to run functions or initialize objects via command line arguments. This is inspired by Google's Python Fire, but tapify also automatically casts command line arguments to the appropriate types based on the type hints. Under the hood, tapify implicitly creates a Tap object and uses it to parse the command line arguments, which it then uses to run the function or initialize the class. We show a few examples below.

Examples

Function

# square_function.py
from tap import tapify

def square(num: float) -> float:
    """Square a number.

    :param num: The number to square.
    """
    return num ** 2

if __name__ == '__main__':
    squared = tapify(square)
    print(f'The square of your number is {squared}.')

Running python square_function.py --num 5 prints The square of your number is 25.0..

Class

# square_class.py
from tap import tapify

class Squarer:
    def __init__(self, num: float) -> None:
        """Initialize the Squarer with a number to square.

        :param  num: The number to square.
        """
        self.num = num

    def get_square(self) -> float:
        """Get the square of the number."""
        return self.num ** 2

if __name__ == '__main__':
    squarer = tapify(Squarer)
    print(f'The square of your number is {squarer.get_square()}.')

Running python square_class.py --num 2 prints The square of your number is 4.0..

Dataclass

# square_dataclass.py
from dataclasses import dataclass

from tap import tapify

@dataclass
class Squarer:
    """Squarer with a number to square.

    :param num: The number to square.
    """
    num: float

    def get_square(self) -> float:
        """Get the square of the number."""
        return self.num ** 2

if __name__ == '__main__':
    squarer = tapify(Squarer)
    print(f'The square of your number is {squarer.get_square()}.')

Running python square_dataclass.py --num -1 prints The square of your number is 1.0..

Argument descriptions

For dataclasses, the argument's description (which is displayed in the -h help message) can either be specified in the class docstring or the field's description in metadata. If both are specified, the description from the docstring is used. In the example below, the description is provided in metadata.

# square_dataclass.py
from dataclasses import dataclass, field

from tap import tapify

@dataclass
class Squarer:
    """Squarer with a number to square.
    """
    num: float = field(metadata={"description": "The number to square."})

    def get_square(self) -> float:
        """Get the square of the number."""
        return self.num ** 2

if __name__ == '__main__':
    squarer = tapify(Squarer)
    print(f'The square of your number is {squarer.get_square()}.')

Pydantic

Pydantic Models and dataclasses can be tapifyd.

# square_pydantic.py
from pydantic import BaseModel, Field

from tap import tapify

class Squarer(BaseModel):
    """Squarer with a number to square.
    """
    num: float = Field(description="The number to square.")

    def get_square(self) -> float:
        """Get the square of the number."""
        return self.num ** 2

if __name__ == '__main__':
    squarer = tapify(Squarer)
    print(f'The square of your number is {squarer.get_square()}.')
Argument descriptions

For Pydantic v2 models and dataclasses, the argument's description (which is displayed in the -h help message) can either be specified in the class docstring or the field's description. If both are specified, the description from the docstring is used. In the example below, the description is provided in the docstring.

For Pydantic v1 models and dataclasses, the argument's description must be provided in the class docstring:

# square_pydantic.py
from pydantic import BaseModel

from tap import tapify

class Squarer(BaseModel):
    """Squarer with a number to square.

    :param num: The number to square.
    """
    num: float

    def get_square(self) -> float:
        """Get the square of the number."""
        return self.num ** 2

if __name__ == '__main__':
    squarer = tapify(Squarer)
    print(f'The square of your number is {squarer.get_square()}.')

tapify help

The help string on the command line is set based on the docstring for the function or class. For example, running python square_function.py -h will print:

usage: square_function.py [-h] --num NUM

Square a number.

options:
  -h, --help  show this help message and exit
  --num NUM   (float, required) The number to square.

Note that for classes, if there is a docstring in the __init__ method, then tapify sets the help string description to that docstring. Otherwise, it uses the docstring from the top of the class.

Command line vs explicit arguments

tapify can simultaneously use both arguments passed from the command line and arguments passed in explicitly in the tapify call. Arguments provided in the tapify call override function defaults, and arguments provided via the command line override both arguments provided in the tapify call and function defaults. We show an example below.

# add.py
from tap import tapify

def add(num_1: float, num_2: float = 0.0, num_3: float = 0.0) -> float:
    """Add numbers.

    :param num_1: The first number.
    :param num_2: The second number.
    :param num_3: The third number.
    """
    return num_1 + num_2 + num_3

if __name__ == '__main__':
    added = tapify(add, num_2=2.2, num_3=4.1)
    print(f'The sum of your numbers is {added}.')

Running python add.py --num_1 1.0 --num_2 0.9 prints The sum of your numbers is 6.0.. (Note that add took num_1 = 1.0 and num_2 = 0.9 from the command line and num_3=4.1 from the tapify call due to the order of precedence.)

Known args

Calling tapify with known_only=True allows tapify to ignore additional arguments from the command line that are not needed for the function or class. If known_only=False (the default), then tapify will raise an error when additional arguments are provided. We show an example below where known_only=True might be useful for running multiple tapify calls.

# person.py
from tap import tapify

def print_name(name: str) -> None:
    """Print a person's name.

    :param name: A person's name.
    """
    print(f'My name is {name}.')

def print_age(age: int) -> None:
    """Print a person's age.

    :param name: A person's age.
    """
    print(f'My age is {age}.')

if __name__ == '__main__':
    tapify(print_name, known_only=True)
    tapify(print_age, known_only=True)

Running python person.py --name Jesse --age 1 prints My name is Jesse. followed by My age is 1.. Without known_only=True, the tapify calls would raise an error due to the extra argument.

Explicit boolean arguments

Tapify supports explicit specification of boolean arguments (see bool for more details). By default, explicit_bool=False and it can be set with tapify(..., explicit_bool=True).

Convert to a Tap class

to_tap_class turns a function or class into a Tap class. The returned class can be subclassed to add special argument behavior. For example, you can override configure and process_args.

If the object can be tapifyd, then it can be to_tap_classd, and vice-versa. to_tap_class provides full control over argument parsing.

to_tap_class examples

Simple

# main.py
"""
My script description
"""

from pydantic import BaseModel

from tap import to_tap_class

class Project(BaseModel):
    package: str
    is_cool: bool = True
    stars: int = 5

if __name__ == "__main__":
    ProjectTap = to_tap_class(Project)
    tap = ProjectTap(description=__doc__)  # from the top of this script
    args = tap.parse_args()
    project = Project(**args.as_dict())
    print(f"Project instance: {project}")

Running python main.py --package tap will print Project instance: package='tap' is_cool=True stars=5.

Complex

The general pattern is:

from tap import to_tap_class

class MyCustomTap(to_tap_class(my_class_or_function)):
    # Special argument behavior, e.g., override configure and/or process_args

Please see demo_data_model.py for an example of overriding configure and process_args.

typed-argument-parser's People

Contributors

alexwaygood avatar arbellea avatar cebtenzzre avatar cnoor0171 avatar jmichel-asapp avatar kddubey avatar kswanson-asapp avatar linuxdaemon avatar magicshoebox avatar marcoffee avatar martinjm97 avatar shaneeverittm avatar shorie000 avatar swansonk14 avatar sykloid avatar timrepke avatar tmke8 avatar wj-mcat avatar wordpr3ss avatar zephvr avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar

typed-argument-parser's Issues

Bug in writing reproducibility section to file

Hi, thanks for this great project!

I encountered an error when using tap within Chemprop when training a model within a folder that is a git repo, but without origin specified (see Chemprop PR 195). To reproduce the error:
mkdir test; cd test; git init
python <path_to_chemprop>/train.py --data_path <path_to_data_csv> --dataset_type regression
This is a tap issue since when writing arguments to file, it runs git remote get-url origin, in the current folder, not in the chemprop folder, i.e. the folder <path_to_chemprop> (which would be correct). Is there any way to fix it? I have now introduced a workaround in Chemprop where I write the arguments without the reproducibility section, but it would be great if this issue would be fixed at some point.

Thanks!

Implement deepcopy

Requires implementing __deepcopy__() so that deepcopy(args) works correctly

underscores_to_dashes not being considered when checking for default and required

When I try to add_argument myself on configure, even when underscore_to_dashes is true, it ignores the default / required values for the arguments.

Here is a minimal example of this problem:

from typing import Optional
from tap import Tap

class ArgumentParser (Tap):
    foo_arg: Optional[str] = None

    def configure (self) -> None:
        self.add_argument("-f", "--foo-arg")

args = ArgumentParser(underscores_to_dashes=True).parse_args([])

It exits with error: the following arguments are required: -f/--foo-arg.

It looks like it should be a variable.replace("-", "_") on the hasattr / getattr calls after this line.

`underscore_to_dashes` isn't propagated to subparsers

My example is:

class AddProposal(Tap):
    proposal_id: int

class Arguments(Tap):
    def configure(self) -> None:
        self.add_subparsers(dest="subparser_name")

        self.add_subparser(
            "add-proposal",
            AddProposal,
            help="Add a new proposal",
        )

args = Arguments(underscores_to_dashes=True).parse_args()

Typing python myapp.py add-proposal --help I get proposal_id as an argument

Arguments that shadow variables or functions defined in Tap

If an argument uses the same name as any of the self variables we define (ex. self.extra_args), there may be unexpected behavior. Need to add tests and check the behavior.

A minimal example is:

from tap import Tap

class MyParser(Tap):
    as_dict: bool = True

if __name__ == '__main__':
    args = MyParser().parse_args()
    print(args)

which produces the error TypeError: 'bool' object is not callable. There should be guards against this raise an error preventing the user from doing this sort of overwriting and perhaps add in an overwrite flag so that a user could, with acknowledgement of the repercussions, do something like this.

can I give tap a parser description??

Hi.
I am trying to change old codes to TAP.
However, it seems that tap does not have description like argparser.
For example
parser = ArgumentParser(description='this is a sample parser')
Does tap has parser description?
plus, metavar option and shortcuts like this
parser.add_argument( '--very_long_arg', '-a', type=int, default=1, metavar='N', help='it is a very long argument, )

Implement a way to load a saved Tap from a file

Tap currently supports a save function but doesn't have a way to load from a saved file. Similarly, one can convert a Tap to a dict using as_dict. It would be helpful to be able to create a Tap using something like a from_saved and from_dict.

How do you make mutually exclusive groups?

This subclasses argparse's own ArgumentParser, and the method for adding mutually exclusive groups remains. You can, in fact, use this without issue... however you cannot use that while also specifying the variable inside the TAP class. If you do specify the variable in the class, and then also specify it via add_argument, it will fail with something like:

argparse.ArgumentError: argument -f/--flag: conflicting option string: --flag

You can of course omit the variable from the class... but then you don't get the type hints on the value. What is the right approach?

Nested arguments

Is it possible to have nested arguments?
Something like:

class SubArgs(Tap):
    subarg1: int
    subarg2: str

class Args(Tap):
    arg1: int
    arg2: str
    subargs: SubArgs

args = Args().parse_args()
print(args.arg1, args.subargs.subarg1)

Which you can then call like python main.py --arg1 0 --subargs.subarg1 2 or as nested entries in a yaml or json file (for me it would mainly be interesting when using config yaml or json files). Example:

{
  "arg1": 1,
  "subargs": {
    "subarg1": 2
  }
}

`parse_args` should return the chosen sub parser object

Hi,

I'm using sub parsers and I assumed that the Tap for the chosen sub parser would be returned from parse_args() however the parent parser is actually returned. The motivation for having the sub parser returned is as follows:

  • allow additional functionality to be loaded on to the parser
  • get code completion for the sub parser as you do for the parent parser
  • determine which sub parser was chosen in a manner that typing can understand.

For example:

class SubParserA(Tap):

    foo: str

    def do_foo(self):
        # Do something with self.foo here


class SubParserB(Tap)

    bar: str

    def do_bar(self):
        # Do something with self.bar here


class MainParser(Tap):

    def configure(self) -> None:
        self.add_subparsers()
        self.add_subparser('a', SubParserA)
        self.add_subparser('b', SubParserB)


if __name__ == '__main__':
    main_parser = MainParser()
    args = main_parser.parse_args()

    if isinstance(args, SubParserA):
        args.do_foo()
    elif isinstance(args, SubParserB):
        args.do_bar()

Would be great to hear your thoughts!

Cheers
Jase

Subparsers are not typed.

Unless I've missed something, the usage of subparsers annihilates the advantages of this library.

If I take your example:

class SubparserA(Tap):
    bar: int  # bar help

class Args(Tap):
    foo: bool = False  # foo help

    def configure(self):
        self.add_subparsers(help='sub-command help')
        self.add_subparser('a', SubparserA, help='a help')

args = Args().parse_args('--foo a --bar'.split())
print(args.foo)  # <--- OK, this is typed
print(args.bar)  # <--- not typed!!!!!

Can't this be more like that?

class SubparserA(Tap):
    bar: int  # bar help

class Args(Tap):
    foo: bool = False  # foo help
    a: Optional[SubParserA] = None  # a help

    def configure(self):
        self.add_subparsers(help='sub-command help')

args = Args().parse_args('--foo a --bar'.split())
print(args.a.bar)  # <--- now, it's typed

Add support for pathlib.Path

It would be great if Tap supported Path, I often use it as the type argument to argparse.ArgumentParser.add_argument.

Quotes within arguments are removed

Introduced in 1.7.2, when using TAP to parse arguments, arguments with quotes within the argument remove the quote. This was likely introduced when handling quoted arguments. It appears to be the same for either quote style.

For instance:
--foo b'a'r
is parsed as:
foo: 'bar'

It works fine if the argument is quoted though, so perhaps this is not an issue:
--foo "b'a'r"
is parsed as:
foo: 'b\'a\'r'

Document pathlib support

I figured having pathlib Path type support would be nice.

I decided to fork to add the support. After some digging I decided I would add a test. Turns out, there already are tests for this, and they pass.

Since this is a cool feature, might want to add it to the Readme.md.

Cannot integrate with argcomplete

I'd like to use TAP with argcomplete, but I have trouble replicating this code with TAP:

import argparse
import argcomplete
from argcomplete.completers import EnvironCompleter

parser = argparse.ArgumentParser()
var1_arg = parser.add_argument("--env-var1")
var1_arg.completer = EnvironCompleter
argcomplete.autocomplete(parser)

I could use a configure method and call self.add_argument, but it doesn't return the added argument. I guess it couldn't because it doesn't call argparse's add_argument right away.

Any idea how I could do that? Maybe, would it be possible to add a post_configure method to the Tap class and somehow give access to all the added arguments from inside it? If you are OK with this idea, I could come up with a PR...

Feature request: Allow pickling / unpickling

I'm trying to pass a Tap object with to a different thread / process, but it's failing with AttributeError: Can't pickle local object 'ArgumentParser.__init__.<locals>.identity'

The following seems to work:

class Args(tap.Tap):
    runs: List[str]
    mean: Literal["median", "mean"] = "median"
    # ....

    def __getstate__(self):
        return self.as_dict()

    def __setstate__(self, d):
        self.__init__()
        self.from_dict(d)

Seems like this could be included in the library?

No typing when calling parse_known_args

when calling parse_known_args() I dont get typings and autocomplete functionalities on VS code
If i want to use args.something I dont get autocomplete. If anyone asks - yes I need to use parse_known_args here because I have multiple parsers.

def parse_args():
 class TapParser(Tap):
     something: str = 'some_str'
     something_more: str = 'some_more_str'

 args, other_args = TapParser().parse_known_args()
 return args

Adding support for subparsers

First of all this is a great project and I love the direction it's taken!

Now, my question is how to basically do argparse's subparsers using tap?

Crash when trying to report unsupported type usage message for arg with default value

Reproducing example:

from pathlib import Path
from tap import Tap

class CrashingArgumentParser(Tap):
    some_path: Path = "some_path"

CrashingArgumentParser().parse_args()

Traceback:

Traceback (most recent call last):
  File "tap_crash.py", line 10, in <module>
    CrashingArgumentParser().parse_args()
  File "…/site-packages/tap/tap.py", line 87, in __init__
    self._add_arguments()  # Adds all arguments in order to self
  File "…/site-packages/tap/tap.py", line 239, in _add_arguments
    self._add_argument(f'--{variable}')
  File "…/site-packages/tap/tap.py", line 183, in _add_argument
    arg_params = "required=True" if kwargs["required"] else f"default={getattr(self, variable)}"
KeyError: 'required'

The fix might be as simple as replacing kwargs["required"] with kwargs.get("required", False)?

TAP works much slower than standard ArgumentParser

TAP works very slow:
to reproduce

from tap import Tap
from argparse import ArgumentParser
from profilehooks import profile, timecall


class SimpleArgumentParser(Tap):
    name: str  # Your name
    language: str = 'Python'  # Programming language
    package: str = 'Tap'  # Package name
    stars: int  # Number of stars
    max_stars: int = 5  # Maximum stars


@profile(dirs=True, sort='time')
@timecall()
def tap_parser():
    return SimpleArgumentParser().parse_args()


@profile(dirs=True, sort='time')
@timecall()
def arg_parser():
    parser = ArgumentParser()
    parser.add_argument('--name', type=str, required=True,
                        help='Your name')
    parser.add_argument('--language', type=str, default='Python',
                        help='Programming language')
    parser.add_argument('--package', type=str, default='Tap',
                        help='Package name')
    parser.add_argument('--stars', type=int, required=True,
                        help='Number of stars')
    parser.add_argument('--max_stars', type=int, default=5,
                        help='Maximum stars')
    return parser.parse_args()


tap_args = tap_parser()
args = arg_parser()

print(f'My name is {tap_args.name} and I give the {tap_args.language} package '
      f'{tap_args.package} {tap_args.stars}/{tap_args.max_stars} stars!')
print(f'My name is {args.name} and I give the {args.language} package '
      f'{args.package} {args.stars}/{args.max_stars} stars!')

tap_parser: 0.050 seconds (tap 1.6.1)
tap_parser: 0.042 seconds (tap 1.4.3)
arg_parser: 0.000 seconds

The most time consuming actions are
ncalls tottime percall cumtime percall filename:lineno(function)
7754 0.013 0.000 0.032 0.000 /home/artem/.conda/envs/rdclone/lib/python3.6/tokenize.py:492(_tokenize)
7148 0.006 0.000 0.006 0.000 {method 'match' of '_sre.SRE_Pattern' objects}

I assume these two involve regular expressions.

Environment
Ubuntu 20.04.2 LTS
Python 3.6.10
conda 4.9.2
profilehooks 1.12.0

Add support for Optional[List[int]] etc.

Currently, we support only Optional[primitive], but we should also support things like:
Optional[List],
Optional[List[int]]
Optional[Set]
Optional[Tuple]
etc.

Empty metavar wreaks havoc on an `assert` statement

Here I have a very slippery bug.

Using metavar="" on that --branch argument on the subparser makes the argparse assert ' '.join(opt_parts) == opt_usage assertion (see it in context here) false because Tap doesn't send any item on the metavar instead of an item with an empty string.

opt_parts array with metavar="":

[..., "--branch", "[-h]", ...]

Without:

[ ...,  "--branch", "BRANCH", ...]

Code for reproducing:

from tap import Tap
from typing import Literal


class InstallsArgParser(Tap):
    org: str
    app: Literal["cycode"]
    branch: str

    def configure(self) -> None:
        super().configure()
        self.add_argument(
            "--branch",
            metavar="",  # very unusual error
        )
        self.add_argument(
            "--limit",
            nargs="+",
        )


class GeneralArgParser(Tap):
    def configure(self) -> None:
        super().configure()
        self.add_subparsers(help="Choose an action.", dest="command")
        self.add_subparser(
            "installs", InstallsArgParser, help="App installations on repos."
        )


GeneralArgParser().parse_args()

Then run it on the command line with $ python file.py installs.

And before you ask, yes I tried removing the seemingly extraneous lines of code, but then the bug would run away! lol

Consistent behavior for the new '|' operator on types for Python 3.10

Problem

Tap has inconsistent behavior when an argument is typed with Union[int, float] versus int | float. The current behavior in Case 1 is unhelpful because Tap always tries to cast arguments using first type in the union (in this case, int). An improvement would be to tell the user to specify a type function as in Case 2. We have a similar concern for Case 3. Luckily, Case 2 and Case 4 are consistent and implement the expected behavior.

Common header:

from tap import Tap
from typing import Union


def to_number(string: str):
    return float(string) if '.' in string else int(string)

Case 1 (Union without configure):

class Py39Tap(Tap):
    number: Union[int, float]

args = Py39Tap().parse_args()
print(type(args.number), args.number)  # error: argument --number: invalid int value: '7.0'

Case 2 (Union with configure):

class Py39TapWithConfig(Tap):
    number: Union[int, float]

    def configure(self):
        self.add_argument('--number', type=to_number)

args = Py39TapWithConfig().parse_args()  
print(type(args.number), args.number)  # <class 'float'> 7.0

Case 3 (| without configure):

class Py310Tap(Tap):
    number: int | float

args = Py310Tap().parse_args()
print(type(args.number), args.number)  # ValueError: int | float is not callable

Case 4 (| with configure):

class Py310TapWithConfig(Tap):
    number: int | float

    def configure(self):
        self.add_argument('--number', type=to_number)

args = Py310TapWithConfig().parse_args()
print(type(args.number), args.number)  # <class 'float'> 7.0

Solution

Change the error handling for both Union and |. If there is not an explicit typing function specified for Union, it should throw an error. Both Union and | should throw the same error in Cases 1 and 3 that recommends that the user manually define a type function in configure.

Multi line comments as help string?

Is there a way to support multi-line comments as help string? For really long, descriptive help strings, it would be useful to be able to split them up...

Parser breaking when using the same attributes as Tap class methods

Hi, first of all thanks a lot for working on this Python package, it's great! 🎉

I wanted to let you know about a potential issue I've stumbled into recently.
To give you some context - I was working on a Python package which generates code for NamedTuple classes based on e.g. json files. Now I wanted to give the CLI user an option to specify a boolean --as-dict flag, which, if set, would add nice as_dict() methods to the generated classes. However, it turned out that giving such a name to the flag was a mistake.

To give you a minimal example to reproduce this:

from tap import Tap

class MyParser(Tap):
	as_something: bool = True

if __name__ == '__main__':
	args = MyParser().parse_args()
	print(args)

If I save this as main.py and try to run it, I'll get:

$ python main.py
Traceback (most recent call last):
  File "main.py", line 8, in <module>
    print(args)
  File "/Users/mrapacz/opt/anaconda3/envs/tap_bug/lib/python3.8/site-packages/tap/tap.py", line 412, in __str__
    return pformat(self.as_dict())
TypeError: 'bool' object is not callable

At the same time, though, if I rename the flag to something else, the error doesn't appear:

from tap import Tap

class MyParser(Tap):
	as_whatever: bool = True

if __name__ == '__main__':
	args = MyParser().parse_args()
	print(args)
$ python main.py
{'as_whatever': True}

The issue seems to be a result of naming collision between the flag and the method in the Tap class. When Python wants to call self.as_dict(), if turns out it's not callable (since at that point it's no longer a callable, but a boolean).

The setup I'm using:

$ python --version
Python 3.8.2
$ pip freeze
certifi==2019.11.28
mypy-extensions==0.4.3
typed-argument-parser==1.4.1
typing-extensions==3.7.4.2
typing-inspect==0.5.0

I'm not sure if this is something you'd like to fix, but if not, I guess it would be valuable to mention somewhere, so as to save some debugging time from other developers 😅
Thanks a lot again!

Reproducibility info is lossy due to space joining

Came across this issue when trying to rerun an older command:

for a cmdline like python plot.py --title "Foo Bar" ... the output is python plot.py --title Foo Bar

which is not distinguishable from having separate arguments. In the above cmdline it's easy to see but in my case the command_lines are longer and I might want to parse the cmdline programmatically after the fact

The related line is in:

'command_line': f'python {" ".join(sys.argv)}',

I'd propose to just directly output sys.argv as an arry instead of joining it.

Feature request: allow double parsing

I've got a use-case where I need to parse args repeatedly. For now, I am simply doing

parser._parsed = False

But an official flag in __init__ would be appreciated.

Process_args not called in from_dict

Title explains itself, I believe it would make sense to call process_args() in from_dict, like it is called in parse_args, or am I missing something?

`tap.utils.get_argument_name()` should choose a canonical argument name like `ArgumentParser.add_argument()` does

Motivation

tap.tap.Tap.add_argument() restricts the *name_or_flags vararg differently from ArgumentParser.add_argument().

Consider this pure argparse example:

import argparse

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("-t", "--with", "--to",
                        metavar="TARGET",
                        dest="target_name",
                        help="connect to this target",
                        )
    args = parser.parse_args()
    print(args)

argparse will choose the value of dest as the argument name if available. Otherwise, it will choose the first long argument name as the argument name.

$ python3 /tmp/scratch.py
Namespace(target_name=None)

$ python3 /tmp/scratch.py --help
usage: scratch.py [-h] [-t TARGET]

optional arguments:
  -h, --help            show this help message and exit
  -t TARGET, --target TARGET, --to TARGET
                        connect to this target

But Tap.add_argument() will refuse to do the same:

from tap import Tap


class ScratchParser(Tap):
    target_name: str  # connect to this target

    def configure(self) -> None:
        super().configure()
        self.add_argument("-t", "--target", "--to", dest="target_name", metavar="TARGET")


if __name__ == "__main__":
    args = ScratchParser().parse_args()
    print(args)
$ python3 /tmp/scratch_1.py --help
Traceback (most recent call last):
  File "/tmp/scratch_1.py", line 13, in <module>
    args = ScratchParser().parse_args()
  File "~/lib/python3.8/site-packages/tap/tap.py", line 103, in __init__
    self._configure()
  File "~/lib/python3.8/site-packages/tap/tap.py", line 309, in _configure
    self.configure()
  File "/tmp/scratch_1.py", line 9, in configure
    self.add_argument("--target-name", "-t", "--with", "--to", metavar="TARGET")
  File "~/lib/python3.8/site-packages/tap/tap.py", line 259, in add_argument
    variable = get_argument_name(*name_or_flags).replace('-', '_')
  File "~/lib/python3.8/site-packages/tap/utils.py", line 145, in get_argument_name
    raise ValueError(f'There should only be a single canonical name for argument {name_or_flags}!')
ValueError: There should only be a single canonical name for argument ['--target', '--to']!

I expected Typed Argument Parser to associate the added argument to the attribute ScratchParser.target_name because the keyword argument dest="target_name" was specified.

If dest="target_name" had not been specified, then I would have expected the canonical argument name to be target because that's what ArgumentParser.add_argument("-t", "--target", "--to", …) would have done.

And if it had been ArgumentParser.add_argument("-t", "-n", …) instead, the canonical argument name would have been t because that is the first name specified and there was no option that began with two hyphens to take precedence.

Proposed Solution

tap.utils.get_argument_name() should be rewritten to determine the canonical argument name based on this order of preference:

  1. The value of the dest keyword argument, if it is a string / not None.
  2. The first *name_or_flags that begins with "--", if any.
  3. The first *name_or_flags that begins with "-".

Behaviors that should be unchanged but may be worth testing:

  • If the argument is a positional argument (i.e. it does not begin with "-"), it must be the only item in *name_or_flags.
  • It is an error to mix a positional argument (e.g. "target") and an option (e.g. "--target").

Alternatives

I have not been able to find a way to Tap.add_argument() two long-named option aliases. The alternative for this case would be to use plain argparse, but I'd lose the typing benefits offered by Typed Argument Parser.

Without ArgumentParser.add_argument(…, dest=…) support, the first long-named option would have stand in for dest. This is not ideal if I want to receive an primary option named with a reserved keyword like ArgumentParser.add_argument("--with", "--to", dest="target_name").

`underscores_to_dashes` doesn't play nicely with custom types

Hi all,

I recently discovered this library and it seems lovely, thanks for the efforts! While trying out, I bumped into a problem.

Running the following code:

from tap import Tap
from pathlib import Path

class ScriptArgumentParser(Tap):
    root_dir: Path
    """The root dir to process."""

    def __init__(self):
        super().__init__(underscores_to_dashes=True)

    def configure(self):
        self.add_argument('--root-dir', '-r', type=Path)

if __name__ == '__main__':
    args = ScriptArgumentParser().parse_args()

crashes tap with the following message

ValueError: Variable "root_dir" has type "<class 'pathlib.Path'>" which is not supported by default.

Looking in the code, it seems the type check is done before the replacement of underscores with dashes, causing this problem.

Update
Rewriting the configure call as such:

def configure(self):
        self.add_argument('--root_dir', '-r', type=Path)

seems to work well (no duplicate arguments root-dir and root_dir), but is rather unintuitive to me.

As a side-note, since dashes in argument names is the POSIX-standard way, I'd vote for making this default True instead of False.

Kind regards,
steven

Add documentation warning about pickled data

TAP offers this great feature about saving and loading all arguments, along with reproducibility info into a json file. Complex python types are encoded and automatically decoded using the pickle module. From the docs,

Note: More complex types will be encoded in JSON as a pickle string.

However, the unpickling of untrusted data is a big security risk, since it is possible to construct malicious pickle data which will execute arbitrary code during unpickling. Novice python programmers might not know about the implications of using pickle, or people might assume only json parsing is taking place, and simply not notice the note about pickle.

I think it would be beneficial to include a prominent warning for users not to run args.load('args.json') on untrusted json files. The warning could either share a short explanation of why its a security risk, or just link to the pickle documentation.

action='count' and other action= commands fail

class MyArgumentParser(Tap):
    debug: int = 0 # debug level

    def add_arguments(self):
        self.add_argument('-D', '--debug', action='count')

This code will fails, because class _CountAction doesn't accept the "type" parameter. Please check the following error message.

Traceback (most recent call last):
File "./main.py", line 52, in
args = MyArgumentParser().parse_args(); print(args)
File "/Users/lsai/Library/Python/3.7/lib/python/site-packages/tap/tap.py", line 86, in init
self._add_arguments() # Adds all arguments in order to self
File "/Users/lsai/Library/Python/3.7/lib/python/site-packages/tap/tap.py", line 225, in _add_arguments
self._add_argument(*name_or_flags, **kwargs)
File "/Users/lsai/Library/Python/3.7/lib/python/site-packages/tap/tap.py", line 212, in _add_argument
super(Tap, self).add_argument(*name_or_flags, **kwargs)
File "/usr/local/Cellar/python/3.7.4/Frameworks/Python.framework/Versions/3.7/lib/python3.7/argparse.py", line 1353, in add_argument
action = action_class(**kwargs)
TypeError: init() got an unexpected keyword argument 'type'

import gives AttributeError: __args__

When importing the library from python, I get an AttributeError:

$ python3
Python 3.9.5 (default, May  4 2021, 00:00:00)
[GCC 10.3.1 20210422 (Red Hat 10.3.1-1)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from tap import Tap
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/tim/.local/lib/python3.9/site-packages/tap/__init__.py", line 2, in <module>
    from tap.tap import Tap
  File "/home/tim/.local/lib/python3.9/site-packages/tap/tap.py", line 35, in <module>
    EMPTY_TYPE = get_args(List)[0] if len(get_args(List)) > 0 else tuple()
  File "/home/tim/.local/lib/python3.9/site-packages/typing_inspect.py", line 471, in get_args
    res = tp.__args__
  File "/usr/lib64/python3.9/typing.py", line 694, in __getattr__
    raise AttributeError(attr)
AttributeError: __args__
>>>

Version installed:

typed-argument-parser==1.6.2

Unsupported Operation

writer.writerow(header)
io.UnsupportedOperation: Not Writable

Any help clearing this issue would be appreciated

How to tell which subparser was called?

Is there a way to find out which subparser was called?

For example, here's an example with two subparsers for git add and git commit:

class GitAddParser(Tap):
    verbose: bool = False
    patch: bool = False

class GitCommitParser(Tap):
    verbose: bool = False
    author: Optional[str] = None

class GitParser(Tap):
    version: bool = False

    def configure(self):
        self.add_subparsers(help='sub-command help')
        self.add_subparser('add', GitAddParser, help='git add help')
        self.add_subparser('commit', GitCommitParser, help='git commit help')

args = GitParser().parse_args()

# if the command was `git add` do some logic
# TODO
# elif the command was `git commit` do some other logic
# TODO

I want to do different things based on which subparser was called, but I can't tell where / if that information is being stored when parse_args is called.

If that information isn't being tracked, I can see two ways to work around this but they both have drawbacks.

One way is to check whether args has a field name that is unique to only one subparser; ie, if args.author is defined in the above example, the command must have been commit. This seems dangerous because it's entirely possible that in the future the code changes so that another subparser has a field with the same name.

The other way is I can see is to modify all subparsers to have an explicit field which tracks which command they represent:

class GitAddParser(Tap):
    verbose: bool = False
    patch: bool = False
    command: str = "add"

class GitCommitParser(Tap):
    verbose: bool = False
    author: Optional[str] = None
    command: str = "commit"

However, this is also not a great solution because then command is a field which can be changed when invoking the parser, which shouldn't be the case.

Is there a better way to tell which subparser was called?

feature-request: List options for enums in the help-message

hello,

Could you make it so that options for enums appear in the help-message?

here is a code example

from enum import Enum
from typing import Literal
from tap import Tap


class MyEnum(str, Enum):
    A = "aaa"
    B = "bbb"
    C = "ccc"

    def __str__(self):
        # it would be nice if this overwrite wouldn't be needed
        return self.value


class MyArgs(Tap):
    myEnum1: MyEnum = MyEnum.A  # here options are not listed
    myEnum2: Literal[MyEnum.A, MyEnum.B, MyEnum.C] = MyEnum.A  # workaround, that repeats all options

    def configure(self):
        # this works fine, but it would be neat if it wasn't needed
        self.add_argument('--myEnum3', type=MyEnum, choices=list(MyEnum))


print(MyArgs().parse_args())

help-message:

 $ python main2.py -h                                       
usage: main2.py [--myEnum1 MYENUM1] [--myEnum2 {aaa,bbb,ccc}] [-h] --myEnum3 {aaa,bbb,ccc}

optional arguments:
  --myEnum1 MYENUM1     (<enum 'MyEnum'>, default=aaa) here options are not listed
  --myEnum2 {aaa,bbb,ccc}
                        (Literal[<MyEnum.A: 'aaa'>, <MyEnum.B: 'bbb'>, <MyEnum.C: 'ccc'>], default=aaa) workaround, that repeats all options
  -h, --help            show this help message and exit
  --myEnum3 {aaa,bbb,ccc}
                        (required)

the actual parsed values are as expected the same in all 3 cases:

$ python main2.py --myEnum1 aaa --myEnum2 bbb --myEnum3 ccc                                                                                                                                                                                         
{'myEnum1': <MyEnum.A: 'aaa'>,
 'myEnum2': <MyEnum.B: 'bbb'>,
 'myEnum3': <MyEnum.C: 'ccc'>}

Additional note: i have considered literals, but they are weird when used together with enums. I want enums because later if use the old "if elif elif" to handle the selected option, When I use string-literals there, typos will become an issue. Also with enums I can "jump to source" in my IDE. It would be neat if I could convert an enum to a literal (or vice verse) without repeating all options, but I have not found a way to do this.

Consider exporting argparse error type

It might be useful to provide some kind of error classes as part of this package.
For example, I use code like this:

from argparse import ArgumentError, ArgumentParser

def file_path(x: str):
   d = Path(x)
   if d.is_dir:
      raise ArgumentError(f"{d} is a directory")
   return d

parser = ArgumentParser()
parser.add_argument('output_file', type=file_path, help="Path to output file")
parser.parse_args()

I would like to use TAP for this, so would be nice to import all the classes I need from the single package, rather than still importing error classes from argparse, or writing my own each time.
The easiest would probably be to just expose the classes from argparse.

typed-argument-parser is uninstallable with "tap.py", which uses the same package name

This looks like a really excellent and much-needed package, thank you!

Then I thought: I wonder if it's in Debian? But to my dismay, I discovered that Debian already has tap.py in the archive (in the package python3-tap. Since both tap.py and typed-argument-parser provide the Python package tap, they cannot be simultaneously installed in any environment, and tap.py seems generic enough that one might wish to use both simultaneously.

Unfortunately, I don't have an obvious suggestion for how to resolve this, but I wanted to bring it to your attention.

Should Tap parse the default values for Set, Tuple types?

Tap tries to parse values for arguments of Set and Tuple types into sets and tuples. This is fine when the arguments are provided on the command line as they must conform to the structure of a set or a tuple, but this parsing also occurs for the default value if a value isn't provided on the command line. Again, this is fine if the default value is in fact a set or a tuple (or even a related object like a list), but it's problematic when the default value is unrelated, e.g., if the default value is None. See the example below:

from typing import Set, Tuple

from tap import Tap


class Args(Tap):
    arg_tuple: Tuple[str, ...] = None
    arg_set: Set[str] = None


args = Args().parse_args([])  # Crash!
Traceback (most recent call last):
  File "blah.py", line 11, in <module>
    args = Args().parse_args([])
  File "/Users/kyleswanson/typed-argument-parser/tap/tap.py", line 318, in parse_args
    value = tuple(value)
TypeError: 'NoneType' object is not iterable

This manual parsing into sets and tuples must be changed so that it only applies to arguments provided on the command line and not to default arguments.

Support for configuration files

Hi,
Will it be possible to add support for optional configuration files, the same general idea as ConfigArgParse: https://github.com/bw2/ConfigArgParse

The idea is to be able to pass a file path as an argument and some additional arguments.
the logic should be to first parse the config file and then parse the rest of the arguments.
Thank you!

Load arguments from configuration file specified in argument

Is it possible to specify which configuration file to load in an argument? So instead of:

class Args(Tap):
    arg1: int
    arg2: str

args = Args(config_files=['my_config.txt']).parse_args()

Something like this:

class Args(Tap):
    arg1: int
    arg2: str
    cfg_file: ConfigFile

args = Args().parse_args()

For which you could then run something like python main.py --cfg_file cfg/train.yaml. If not, this would be a valuable improvement in my opinion. It makes sense that which config file will be loaded does not have to be hardcoded. The jsonargparse package offers a similar functionality: https://jsonargparse.readthedocs.io/en/stable/#configuration-files.

As a side question: can yaml or json files already be loaded?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.