Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 81 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,16 @@ Running `python square.py --num 2` will print `The square of your number is 4.0.

Tap requires Python 3.10+

To install Tap from PyPI run:
To install Tap from PyPI run:

```
```shell
pip install typed-argument-parser
```

<details>
<summary>To install Tap from source, run the following commands:</summary>

```
```shell
git clone https://github.com/swansonk14/typed-argument-parser.git
cd typed-argument-parser
pip install -e .
Expand All @@ -61,51 +61,69 @@ pip install -e .
<details>
<summary>To develop this package, install development requirements (in a virtual environment):</summary>

```
```shell
python -m pip install -e ".[dev]"
```

Use [`flake8`](https://github.com/PyCQA/flake8) linting.

To run tests, run:

```
```shell
pytest
```

</details>

## Table of Contents

* [Installation](#installation)
* [Table of Contents](#table-of-contents)
* [Tap is Python-native](#tap-is-python-native)
* [Tap features](#tap-features)
+ [Arguments](#arguments)
+ [Tap help](#tap-help)
+ [Configuring arguments](#configuring-arguments)
- [Adding special argument behavior](#adding-special-argument-behavior)
- [Adding subparsers](#adding-subparsers)
+ [Types](#types)
+ [Argument processing](#argument-processing)
+ [Processing known args](#processing-known-args)
+ [Subclassing](#subclassing)
+ [Printing](#printing)
+ [Reproducibility](#reproducibility)
+ [Saving and loading arguments](#saving-and-loading-arguments)
+ [Loading from configuration files](#loading-from-configuration-files)
* [tapify](#tapify)
+ [Examples](#examples)
- [Function](#function)
- [Class](#class)
- [Dataclass](#dataclass)
+ [tapify help](#tapify-help)
+ [Command line vs explicit arguments](#command-line-vs-explicit-arguments)
+ [Known args](#known-args)
* [Convert to a `Tap` class](#convert-to-a-tap-class)
+ [`to_tap_class` examples](#to_tap_class-examples)
- [Simple](#simple)
- [Complex](#complex)
- [Typed Argument Parser (Tap)](#typed-argument-parser-tap)
- [Installation](#installation)
- [Table of Contents](#table-of-contents)
- [Tap is Python-native](#tap-is-python-native)
- [Tap features](#tap-features)
- [Arguments](#arguments)
- [Tap help](#tap-help)
- [Configuring arguments](#configuring-arguments)
- [Adding special argument behavior](#adding-special-argument-behavior)
- [Adding subparsers](#adding-subparsers)
- [Types](#types)
- [`str`, `int`, and `float`](#str-int-and-float)
- [`bool`](#bool)
- [`Optional`](#optional)
- [`List`](#list)
- [`Set`](#set)
- [`Tuple`](#tuple)
- [`Literal`](#literal)
- [`Union`](#union)
- [Complex Types](#complex-types)
- [Ignore Attribute](#ignore-attribute)
- [Argument processing](#argument-processing)
- [Processing known args](#processing-known-args)
- [Subclassing](#subclassing)
- [Printing](#printing)
- [Reproducibility](#reproducibility)
- [Reproducibility info](#reproducibility-info)
- [Conversion Tap to and from dictionaries](#conversion-tap-to-and-from-dictionaries)
- [Saving and loading arguments](#saving-and-loading-arguments)
- [Save](#save)
- [Load](#load)
- [Load from dict](#load-from-dict)
- [Loading from configuration files](#loading-from-configuration-files)
- [tapify](#tapify)
- [Examples](#examples)
- [Function](#function)
- [Class](#class)
- [Dataclass](#dataclass)
- [Pydantic](#pydantic)
- [tapify help](#tapify-help)
- [Command line vs explicit arguments](#command-line-vs-explicit-arguments)
- [Known args](#known-args)
- [Explicit boolean arguments](#explicit-boolean-arguments)
- [Convert to a `Tap` class](#convert-to-a-tap-class)
- [`to_tap_class` examples](#to_tap_class-examples)
- [Simple](#simple)
- [Complex](#complex)

## Tap is Python-native

Expand Down Expand Up @@ -361,6 +379,33 @@ args = Args().parse_args('--aged_person Tapper,27'.split())
print(f'{args.aged_person.name} is {args.aged_person.age}') # Tapper is 27
```

### Ignore Attribute

Sometimes you may want to define attributes that should not be parsed as command line arguments, but you still want to type them.
This can be achieved by using `TapIgnore`.

```python
from tap import Tap, TapIgnore

class MyTap(Tap):
# Regular arguments (will be parsed)
package: str
stars: int = 5

# Ignored attributes (will not be parsed)
internal_counter: TapIgnore[int] = 0

args = MyTap().parse_args(["--help"])
```

```txt
usage: ipython --package PACKAGE [--stars STARS] [-h]

options:
--package PACKAGE (str, required)
--stars STARS (int, default=5)
-h, --help show this help message and exit
```

### Argument processing

Expand Down Expand Up @@ -863,7 +908,7 @@ Running `python person.py --name Jesse --age 1` prints `My name is Jesse.` follo

### Explicit boolean arguments

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

## Convert to a `Tap` class

Expand Down Expand Up @@ -903,7 +948,7 @@ if __name__ == "__main__":

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

### Complex
#### Complex

The general pattern is:

Expand Down
3 changes: 3 additions & 0 deletions src/tap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@
__version__ = "1.11.0"

from argparse import ArgumentError, ArgumentTypeError

from tap.tap import Tap
from tap.tapify import tapify, to_tap_class
from tap.utils import TapIgnore

__all__ = [
"ArgumentError",
"ArgumentTypeError",
"Tap",
"TapIgnore",
"tapify",
"to_tap_class",
"__version__",
Expand Down
55 changes: 48 additions & 7 deletions src/tap/tap.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,22 @@
from pprint import pformat
from shlex import quote, split
from types import MethodType, UnionType
from typing import Any, Callable, Iterable, List, Optional, Sequence, Set, Tuple, TypeVar, Union, get_type_hints
from typing import (
Annotated,
Any,
Callable,
Iterable,
List,
Optional,
Sequence,
Set,
Tuple,
TypeVar,
Union,
get_type_hints,
get_origin as typing_get_origin,
get_args as typing_get_args,
)
from typing_inspect import is_literal_type

from tap.utils import (
Expand All @@ -23,6 +38,7 @@
type_to_str,
get_literals,
boolean_type,
_TapIgnoreMarker,
TupleTypeEnforcer,
define_python_object_encoder,
as_python_object,
Expand Down Expand Up @@ -92,6 +108,7 @@ def __init__(

# Get annotations from self and all super classes up through tap
self._annotations = self._get_annotations()
self._annotations_with_extras = self._get_annotations(include_extras=True)

# Set the default description to be the docstring
kwargs.setdefault("description", self.__doc__)
Expand Down Expand Up @@ -300,10 +317,21 @@ def add_argument(self, *name_or_flags, **kwargs) -> None:
variable = get_argument_name(*name_or_flags).replace("-", "_")
self.argument_buffer[variable] = (name_or_flags, kwargs)

def _is_ignored_argument(self, variable: str, annotations: Optional[dict[str, Any]] = None) -> bool:
annotations = self._annotations_with_extras if annotations is None else annotations
if variable in annotations:
var_type = annotations[variable]
if typing_get_origin(var_type) is Annotated and _TapIgnoreMarker in typing_get_args(var_type):
return True
return False

def _add_arguments(self) -> None:
"""Add arguments to self in the order they are defined as class variables (so the help string is in order)."""
# Add class variables (in order)
for variable in self.class_variables:
if self._is_ignored_argument(variable):
continue

if variable in self.argument_buffer:
name_or_flags, kwargs = self.argument_buffer[variable]
self._add_argument(*name_or_flags, **kwargs)
Expand All @@ -313,6 +341,8 @@ def _add_arguments(self) -> None:
# Add any arguments that were added manually in configure but aren't class variables (in order)
for variable, (name_or_flags, kwargs) in self.argument_buffer.items():
if variable not in self.class_variables:
if self._is_ignored_argument(variable):
continue
self._add_argument(*name_or_flags, **kwargs)

def process_args(self) -> None:
Expand Down Expand Up @@ -483,7 +513,7 @@ def parse_args(
return self

@classmethod
def _get_from_self_and_super(cls, extract_func: Callable[[type], dict]) -> dict[str, Any] | dict:
def _get_from_self_and_super(cls, extract_func: Callable[[type], dict]) -> dict[str, Any]:
"""Returns a dictionary mapping variable names to values.

Variables and values are extracted from classes using key starting
Expand Down Expand Up @@ -531,11 +561,16 @@ def _get_class_dict(self) -> dict[str, Any]:

return class_dict

def _get_annotations(self) -> dict[str, Any]:
"""Returns a dictionary mapping variable names to their type annotations."""
return self._get_from_self_and_super(extract_func=lambda super_class: dict(get_type_hints(super_class)))
def _get_annotations(self, include_extras: bool = False) -> dict[str, Any]:
"""
Returns a dictionary mapping variable names to their type annotations.
Keep Annotations and other extras if include_extras is True.
"""
return self._get_from_self_and_super(
extract_func=lambda super_class: dict(get_type_hints(super_class, include_extras=include_extras))
)

def _get_class_variables(self) -> dict:
def _get_class_variables(self, exclude_tap_ignores: bool = True) -> dict:
"""Returns a dictionary mapping class variables names to their additional information."""
class_variable_names = {**self._get_annotations(), **self._get_class_dict()}.keys()

Expand All @@ -560,7 +595,13 @@ def _get_class_variables(self) -> dict:
class_variables = {}
for variable in class_variable_names:
class_variables[variable] = {"comment": ""}

if exclude_tap_ignores:
extra_annotations = self._get_annotations(include_extras=True)
return {
var: data
for var, data in class_variables.items()
if not self._is_ignored_argument(var, extra_annotations)
}
return class_variables

def _get_argument_names(self) -> set[str]:
Expand Down
40 changes: 34 additions & 6 deletions src/tap/utils.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,33 @@
from argparse import ArgumentParser, ArgumentTypeError
import ast
from base64 import b64encode, b64decode
import inspect
from io import StringIO
from json import JSONEncoder
import os
import pickle
import re
import subprocess
import sys
import textwrap
import tokenize
import warnings
from argparse import ArgumentParser, ArgumentTypeError
from base64 import b64decode, b64encode
from io import StringIO
from json import JSONEncoder
from types import UnionType
from typing import (
Annotated,
Any,
Callable,
Generator,
Iterable,
Iterator,
Literal,
Optional,
TypeAlias,
TypeVar,
)
from typing_inspect import get_args as typing_inspect_get_args, get_origin as typing_inspect_get_origin
import warnings

from typing_inspect import get_args as typing_inspect_get_args
from typing_inspect import get_origin as typing_inspect_get_origin

NO_CHANGES_STATUS = """nothing to commit, working tree clean"""
PRIMITIVES = (str, int, float, bool)
Expand Down Expand Up @@ -594,3 +599,26 @@ def get_args(tp: Any) -> tuple[type, ...]:
return tp.__args__

return typing_inspect_get_args(tp)


_T = TypeVar("_T")


class _TapIgnoreMarker:
"""Internal marker that if present in a type annotation indicates that the argument should be ignored."""


# TODO: Python 3.12 turn this into a TypeAliasType for better IDE tooltips
TapIgnore: TypeAlias = Annotated[_T, _TapIgnoreMarker]
"""
Type annotation to indicate that an argument should be ignored by Tap and not be added as an argument.

Usage:
from tap import Tap, TapIgnore

class Args(Tap):
a: int

# TapIgnore is generic and preserves the type of the ignored attribute
e: TapIgnore[int] = 5
"""
Loading