Skip to content

Commit 6a8b282

Browse files
authored
Merge pull request #66 from swansonk14/Py310Unions
Union types in Python 3.10
2 parents 307fe24 + 59a2ea3 commit 6a8b282

File tree

8 files changed

+319
-51
lines changed

8 files changed

+319
-51
lines changed

.github/workflows/code-coverage.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ jobs:
66
run:
77
runs-on: ubuntu-latest
88
env:
9-
PYTHON: '3.9'
9+
PYTHON: '3.10'
1010
steps:
1111
- uses: actions/checkout@main
1212
- name: Setup Python
1313
uses: actions/setup-python@main
1414
with:
15-
python-version: 3.9
15+
python-version: '3.10'
1616
- name: Generate coverage report
1717
run: |
1818
git config --global user.email "you@example.com"

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
strategy:
1717
matrix:
1818
os: [ubuntu-latest, macos-latest, windows-latest]
19-
python-version: [3.6, 3.7, 3.8, 3.9]
19+
python-version: ['3.6', '3.7', '3.8', '3.9', '3.10']
2020

2121
steps:
2222
- uses: actions/checkout@v2

README.md

Lines changed: 77 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -41,25 +41,38 @@ pip install -e .
4141
## Table of Contents
4242

4343
* [Installation](#installation)
44+
* [Table of Contents](#table-of-contents)
4445
* [Tap is Python-native](#tap-is-python-native)
4546
* [Tap features](#tap-features)
46-
+ [Arguments](#arguments)
47-
+ [Help string](#help-string)
48-
+ [Flexibility of `configure`](#flexibility-of-configure)
49-
+ [Types](#types)
50-
+ [Argument processing with `process_args`](#argument-processing-with-process_args)
51-
+ [Processing known args](#processing-known-args)
52-
+ [Subclassing](#subclassing)
53-
+ [Printing](#printing)
54-
+ [Reproducibility](#reproducibility)
55-
- [Reproducibility info](#reproducibility-info)
56-
+ [Saving and loading arguments](#saving-and-loading-arguments)
57-
- [Save](#save)
58-
- [Load](#load)
59-
- [Load from dict](#load-from-dict)
60-
+ [Loading from configuration files](#loading-from-configuration-files)
47+
+ [Arguments](#arguments)
48+
+ [Help string](#help-string)
49+
+ [Flexibility of `configure`](#flexibility-of--configure-)
50+
- [Adding special argument behavior](#adding-special-argument-behavior)
51+
- [Adding subparsers](#adding-subparsers)
52+
+ [Types](#types)
53+
- [`str`, `int`, and `float`](#-str----int---and--float-)
54+
- [`bool`](#-bool-)
55+
- [`Optional`](#-optional-)
56+
- [`List`](#-list-)
57+
- [`Set`](#-set-)
58+
- [`Tuple`](#-tuple-)
59+
- [`Literal`](#-literal-)
60+
- [`Union`](#-union-)
61+
- [Complex Types](#complex-types)
62+
+ [Argument processing with `process_args`](#argument-processing-with--process-args-)
63+
+ [Processing known args](#processing-known-args)
64+
+ [Subclassing](#subclassing)
65+
+ [Printing](#printing)
66+
+ [Reproducibility](#reproducibility)
67+
- [Reproducibility info](#reproducibility-info)
68+
+ [Saving and loading arguments](#saving-and-loading-arguments)
69+
- [Save](#save)
70+
- [Load](#load)
71+
- [Load from dict](#load-from-dict)
72+
+ [Loading from configuration files](#loading-from-configuration-files)
6173

6274
## Tap is Python-native
75+
6376
To see this, let's look at an example:
6477

6578
```python
@@ -202,7 +215,7 @@ class Args(Tap):
202215

203216
### Types
204217

205-
Tap automatically handles all of the following types:
218+
Tap automatically handles all the following types:
206219

207220
```python
208221
str, int, float, bool
@@ -215,8 +228,10 @@ Literal
215228

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

231+
Tap also supports `Union`, but this requires additional specification (see [Union](#-union-) section below).
232+
218233
Additionally, any type that can be instantiated with a string argument can be used. For example, in
219-
```
234+
```python
220235
from pathlib import Path
221236
from tap import Tap
222237

@@ -257,21 +272,62 @@ Tuples can be used to specify a fixed number of arguments with specified types u
257272

258273
Literal is analagous to argparse's [choices](https://docs.python.org/3/library/argparse.html#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. The `Literal` type was introduced in Python 3.8 ([PEP 586](https://www.python.org/dev/peps/pep-0586/)) and can be imported with `from typing_extensions import Literal`.
259274

260-
#### Complex types
275+
#### `Union`
261276

262-
More complex types _must_ be specified with the `type` keyword argument in `add_argument`, as in the example below.
277+
Union types must include the `type` keyword argument in `add_argument` in order to specify which type to use, as in the example below.
263278

264279
```python
265-
def to_number(string: str):
280+
def to_number(string: str) -> Union[float, int]:
266281
return float(string) if '.' in string else int(string)
267282

268283
class MyTap(Tap):
269-
number: Union[int, float]
284+
number: Union[float, int]
270285

271286
def configure(self):
272287
self.add_argument('--number', type=to_number)
273288
```
274289

290+
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`.
291+
292+
#### Complex Types
293+
294+
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,
295+
296+
```python
297+
class Person:
298+
def __init__(self, name: str) -> None:
299+
self.name = name
300+
301+
class Args(Tap):
302+
person: Person
303+
304+
args = Args().parse_args('--person Tapper'.split())
305+
print(args.person.name) # Tapper
306+
```
307+
308+
If the desired type has a more complex constructor, then the `type` keyword argument must be provided in `add_argument`. For example,
309+
310+
```python
311+
class AgedPerson:
312+
def __init__(self, name: str, age: int) -> None:
313+
self.name = name
314+
self.age = age
315+
316+
def to_aged_person(string: str) -> AgedPerson:
317+
name, age = string.split(',')
318+
return AgedPerson(name=name, age=int(age))
319+
320+
class Args(Tap):
321+
aged_person: AgedPerson
322+
323+
def configure(self) -> None:
324+
self.add_argument('--aged_person', type=to_aged_person)
325+
326+
args = Args().parse_args('--aged_person Tapper,27'.split())
327+
print(f'{args.aged_person.name} is {args.aged_person.age}') # Tapper is 27
328+
```
329+
330+
275331
### Argument processing with `process_args`
276332

277333
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.

tap/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1+
from argparse import ArgumentError, ArgumentTypeError
12
from tap._version import __version__
23
from tap.tap import Tap
34

4-
__all__ = ['Tap', '__version__']
5+
__all__ = ['ArgumentError', 'ArgumentTypeError', 'Tap', '__version__']

tap/tap.py

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from argparse import ArgumentParser
1+
from argparse import ArgumentParser, ArgumentTypeError
22
from collections import OrderedDict
33
from copy import deepcopy
44
from functools import partial
@@ -10,11 +10,12 @@
1010
import time
1111
from types import MethodType
1212
from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, TypeVar, Union, get_type_hints
13-
from typing_inspect import is_literal_type, get_args
13+
from typing_inspect import is_literal_type
1414
from warnings import warn
1515

1616
from tap.utils import (
1717
get_class_variables,
18+
get_args,
1819
get_argument_name,
1920
get_dest,
2021
get_origin,
@@ -30,11 +31,15 @@
3031
enforce_reproducibility,
3132
)
3233

34+
if sys.version_info >= (3, 10):
35+
from types import UnionType
36+
3337

3438
# Constants
3539
EMPTY_TYPE = get_args(List)[0] if len(get_args(List)) > 0 else tuple()
3640
BOXED_COLLECTION_TYPES = {List, list, Set, set, Tuple, tuple}
37-
OPTIONAL_TYPES = {Optional, Union}
41+
UNION_TYPES = {Union} | ({UnionType} if sys.version_info >= (3, 10) else set())
42+
OPTIONAL_TYPES = {Optional} | UNION_TYPES
3843
BOXED_TYPES = BOXED_COLLECTION_TYPES | OPTIONAL_TYPES
3944

4045

@@ -169,13 +174,30 @@ def _add_argument(self, *name_or_flags, **kwargs) -> None:
169174

170175
# If type is not explicitly provided, set it if it's one of our supported default types
171176
if 'type' not in kwargs:
172-
173-
# Unbox Optional[type] and set var_type = type
177+
# Unbox Union[type] (Optional[type]) and set var_type = type
174178
if get_origin(var_type) in OPTIONAL_TYPES:
175179
var_args = get_args(var_type)
176180

181+
# If type is Union or Optional without inner types, set type to equivalent of Optional[str]
182+
if len(var_args) == 0:
183+
var_args = (str, type(None))
184+
185+
# Raise error if type function is not explicitly provided for Union types (not including Optionals)
186+
if get_origin(var_type) in UNION_TYPES and not (len(var_args) == 2 and var_args[1] == type(None)):
187+
raise ArgumentTypeError(
188+
'For Union types, you must include an explicit type function in the configure method. '
189+
'For example,\n\n'
190+
'def to_number(string: str) -> Union[float, int]:\n'
191+
' return float(string) if \'.\' in string else int(string)\n\n'
192+
'class Args(Tap):\n'
193+
' arg: Union[float, int]\n'
194+
'\n'
195+
' def configure(self) -> None:\n'
196+
' self.add_argument(\'--arg\', type=to_number)'
197+
)
198+
177199
if len(var_args) > 0:
178-
var_type = get_args(var_type)[0]
200+
var_type = var_args[0]
179201

180202
# If var_type is tuple as in Python 3.6, change to a typing type
181203
# (e.g., (typing.List, <class 'bool'>) ==> typing.List[bool])
@@ -187,21 +209,23 @@ def _add_argument(self, *name_or_flags, **kwargs) -> None:
187209
# First check whether it is a literal type or a boxed literal type
188210
if is_literal_type(var_type):
189211
var_type, kwargs['choices'] = get_literals(var_type, variable)
212+
190213
elif (get_origin(var_type) in (List, list, Set, set)
191214
and len(get_args(var_type)) > 0
192215
and is_literal_type(get_args(var_type)[0])):
193216
var_type, kwargs['choices'] = get_literals(get_args(var_type)[0], variable)
194217
if kwargs.get('action') not in {'append', 'append_const'}:
195218
kwargs['nargs'] = kwargs.get('nargs', '*')
219+
196220
# Handle Tuple type (with type args) by extracting types of Tuple elements and enforcing them
197221
elif get_origin(var_type) in (Tuple, tuple) and len(get_args(var_type)) > 0:
198222
loop = False
199223
types = get_args(var_type)
200224

201225
# Don't allow Tuple[()]
202226
if len(types) == 1 and types[0] == tuple():
203-
raise ValueError('Empty Tuples (i.e. Tuple[()]) are not a valid Tap type '
204-
'because they have no arguments.')
227+
raise ArgumentTypeError('Empty Tuples (i.e. Tuple[()]) are not a valid Tap type '
228+
'because they have no arguments.')
205229

206230
# Handle Tuple[type, ...]
207231
if len(types) == 2 and types[1] == Ellipsis:
@@ -237,7 +261,7 @@ def _add_argument(self, *name_or_flags, **kwargs) -> None:
237261
kwargs['type'] = boolean_type
238262
kwargs['choices'] = [True, False] # this makes the help message more helpful
239263
else:
240-
action_cond = "true" if kwargs.get("required", False) or not kwargs["default"] else "false"
264+
action_cond = 'true' if kwargs.get('required', False) or not kwargs['default'] else 'false'
241265
kwargs['action'] = kwargs.get('action', f'store_{action_cond}')
242266
elif kwargs.get('action') not in {'count', 'append_const'}:
243267
kwargs['type'] = var_type

tap/utils.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@
2424
Union,
2525
)
2626
from typing_extensions import Literal
27-
from typing_inspect import get_args, get_origin as typing_inspect_get_origin
27+
from typing_inspect import get_args as typing_inspect_get_args, get_origin as typing_inspect_get_origin
2828

29+
if sys.version_info >= (3, 10):
30+
from types import UnionType
2931

3032
NO_CHANGES_STATUS = """nothing to commit, working tree clean"""
3133
PRIMITIVES = (str, int, float, bool)
@@ -255,7 +257,7 @@ def get_literals(literal: Literal, variable: str) -> Tuple[Callable[[str], Any],
255257
literals = list(get_args(literal))
256258

257259
if not all(isinstance(literal, PRIMITIVES) for literal in literals):
258-
raise ValueError(
260+
raise ArgumentTypeError(
259261
f'The type for variable "{variable}" contains a literal'
260262
f'of a non-primitive type e.g. (str, int, float, bool).\n'
261263
f'Currently only primitive-typed literals are supported.'
@@ -264,7 +266,7 @@ def get_literals(literal: Literal, variable: str) -> Tuple[Callable[[str], Any],
264266
str_to_literal = {str(literal): literal for literal in literals}
265267

266268
if len(literals) != len(str_to_literal):
267-
raise ValueError('All literals must have unique string representations')
269+
raise ArgumentTypeError('All literals must have unique string representations')
268270

269271
def var_type(arg: str) -> Any:
270272
return str_to_literal.get(arg, arg)
@@ -403,7 +405,7 @@ def as_python_object(dct: Any) -> Any:
403405
return UnpicklableObject()
404406

405407
else:
406-
raise ValueError(f'Special type "{_type}" not supported for JSON loading.')
408+
raise ArgumentTypeError(f'Special type "{_type}" not supported for JSON loading.')
407409

408410
return dct
409411

@@ -471,7 +473,7 @@ def enforce_reproducibility(saved_reproducibility_data: Optional[Dict[str, str]]
471473
f'in current args.')
472474

473475

474-
# TODO: remove this once typing_inspect.get_origin is fixed for Python 3.8 and 3.9
476+
# TODO: remove this once typing_inspect.get_origin is fixed for Python 3.8, 3.9, and 3.10
475477
# https://github.com/ilevkivskyi/typing_inspect/issues/64
476478
# https://github.com/ilevkivskyi/typing_inspect/issues/65
477479
def get_origin(tp: Any) -> Any:
@@ -481,4 +483,16 @@ def get_origin(tp: Any) -> Any:
481483
if origin is None:
482484
origin = tp
483485

486+
if sys.version_info >= (3, 10) and isinstance(origin, UnionType):
487+
origin = UnionType
488+
484489
return origin
490+
491+
492+
# TODO: remove this once typing_insepct.get_args is fixed for Python 3.10 union types
493+
def get_args(tp: Any) -> Tuple[type, ...]:
494+
"""Same as typing_inspect.get_args but fixes Python 3.10 union types."""
495+
if sys.version_info >= (3, 10) and isinstance(tp, UnionType):
496+
return tp.__args__
497+
498+
return typing_inspect_get_args(tp)

0 commit comments

Comments
 (0)