Skip to content

Commit 8147f75

Browse files
authored
Merge pull request #128 from kddubey/kddubey/from-data-model
`to_tap_class`, and inspect fields instead of signature for data models
2 parents 50b46d7 + 7461ce7 commit 8147f75

File tree

8 files changed

+1624
-95
lines changed

8 files changed

+1624
-95
lines changed

.github/workflows/tests.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ jobs:
4343
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
4444
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
4545
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
46-
- name: Test with pytest
46+
- name: Test without pydantic
4747
run: |
4848
pytest
49+
- name: Test with pydantic v1
50+
run: |
51+
python -m pip install "pydantic < 2"
52+
pytest
53+
- name: Test with pydantic v2
54+
run: |
55+
python -m pip install "pydantic >= 2"
56+
pytest

README.md

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -666,7 +666,7 @@ from tap import tapify
666666
class Squarer:
667667
"""Squarer with a number to square.
668668
669-
:param num: The number to square.
669+
:param num: The number to square.
670670
"""
671671
num: float
672672

@@ -681,6 +681,94 @@ if __name__ == '__main__':
681681

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

684+
<details>
685+
<summary>Argument descriptions</summary>
686+
687+
For dataclasses, the argument's description (which is displayed in the `-h` help message) can either be specified in the
688+
class docstring or the field's description in `metadata`. If both are specified, the description from the docstring is
689+
used. In the example below, the description is provided in `metadata`.
690+
691+
```python
692+
# square_dataclass.py
693+
from dataclasses import dataclass, field
694+
695+
from tap import tapify
696+
697+
@dataclass
698+
class Squarer:
699+
"""Squarer with a number to square.
700+
"""
701+
num: float = field(metadata={"description": "The number to square."})
702+
703+
def get_square(self) -> float:
704+
"""Get the square of the number."""
705+
return self.num ** 2
706+
707+
if __name__ == '__main__':
708+
squarer = tapify(Squarer)
709+
print(f'The square of your number is {squarer.get_square()}.')
710+
```
711+
712+
</details>
713+
714+
#### Pydantic
715+
716+
Pydantic [Models](https://docs.pydantic.dev/latest/concepts/models/) and
717+
[dataclasses](https://docs.pydantic.dev/latest/concepts/dataclasses/) can be `tapify`d.
718+
719+
```python
720+
# square_pydantic.py
721+
from pydantic import BaseModel, Field
722+
723+
from tap import tapify
724+
725+
class Squarer(BaseModel):
726+
"""Squarer with a number to square.
727+
"""
728+
num: float = Field(description="The number to square.")
729+
730+
def get_square(self) -> float:
731+
"""Get the square of the number."""
732+
return self.num ** 2
733+
734+
if __name__ == '__main__':
735+
squarer = tapify(Squarer)
736+
print(f'The square of your number is {squarer.get_square()}.')
737+
```
738+
739+
<details>
740+
<summary>Argument descriptions</summary>
741+
742+
For Pydantic v2 models and dataclasses, the argument's description (which is displayed in the `-h` help message) can
743+
either be specified in the class docstring or the field's `description`. If both are specified, the description from the
744+
docstring is used. In the example below, the description is provided in the docstring.
745+
746+
For Pydantic v1 models and dataclasses, the argument's description must be provided in the class docstring:
747+
748+
```python
749+
# square_pydantic.py
750+
from pydantic import BaseModel
751+
752+
from tap import tapify
753+
754+
class Squarer(BaseModel):
755+
"""Squarer with a number to square.
756+
757+
:param num: The number to square.
758+
"""
759+
num: float
760+
761+
def get_square(self) -> float:
762+
"""Get the square of the number."""
763+
return self.num ** 2
764+
765+
if __name__ == '__main__':
766+
squarer = tapify(Squarer)
767+
print(f'The square of your number is {squarer.get_square()}.')
768+
```
769+
770+
</details>
771+
684772
### tapify help
685773

686774
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:
@@ -751,4 +839,57 @@ if __name__ == '__main__':
751839
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.
752840

753841
### Explicit boolean arguments
842+
754843
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)`.
844+
845+
## Convert to a `Tap` class
846+
847+
`to_tap_class` turns a function or class into a `Tap` class. The returned class can be [subclassed](#subclassing) to add
848+
special argument behavior. For example, you can override [`configure`](#configuring-arguments) and
849+
[`process_args`](#argument-processing).
850+
851+
If the object can be `tapify`d, then it can be `to_tap_class`d, and vice-versa. `to_tap_class` provides full control
852+
over argument parsing.
853+
854+
### Examples
855+
856+
#### Simple
857+
858+
```python
859+
# main.py
860+
"""
861+
My script description
862+
"""
863+
864+
from pydantic import BaseModel
865+
866+
from tap import to_tap_class
867+
868+
class Project(BaseModel):
869+
package: str
870+
is_cool: bool = True
871+
stars: int = 5
872+
873+
if __name__ == "__main__":
874+
ProjectTap = to_tap_class(Project)
875+
tap = ProjectTap(description=__doc__) # from the top of this script
876+
args = tap.parse_args()
877+
project = Project(**args.as_dict())
878+
print(f"Project instance: {project}")
879+
```
880+
881+
Running `python main.py --package tap` will print `Project instance: package='tap' is_cool=True stars=5`.
882+
883+
### Complex
884+
885+
The general pattern is:
886+
887+
```python
888+
from tap import to_tap_class
889+
890+
class MyCustomTap(to_tap_class(my_class_or_function)):
891+
# Special argument behavior, e.g., override configure and/or process_args
892+
```
893+
894+
Please see `demo_data_model.py` for an example of overriding [`configure`](#configuring-arguments) and
895+
[`process_args`](#argument-processing).

demo_data_model.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""
2+
Works for Pydantic v1 and v2.
3+
4+
Example commands:
5+
6+
python demo_data_model.py -h
7+
8+
python demo_data_model.py \
9+
--arg_int 1 \
10+
--arg_list x y z \
11+
--argument_with_really_long_name 3
12+
13+
python demo_data_model.py \
14+
--arg_int 1 \
15+
--arg_list x y z \
16+
--arg_bool \
17+
-arg 3.14
18+
"""
19+
from typing import List, Literal, Optional, Union
20+
21+
from pydantic import BaseModel, Field
22+
from tap import tapify, to_tap_class, Tap
23+
24+
25+
class Model(BaseModel):
26+
"""
27+
My Pydantic Model which contains script args.
28+
"""
29+
30+
arg_int: int = Field(description="some integer")
31+
arg_bool: bool = Field(default=True)
32+
arg_list: Optional[List[str]] = Field(default=None, description="some list of strings")
33+
34+
35+
def main(model: Model) -> None:
36+
print("Parsed args into Model:")
37+
print(model)
38+
39+
40+
def to_number(string: str) -> Union[float, int]:
41+
return float(string) if "." in string else int(string)
42+
43+
44+
class ModelTap(to_tap_class(Model)):
45+
# You can supply additional arguments here
46+
argument_with_really_long_name: Union[float, int] = 3
47+
"This argument has a long name and will be aliased with a short one"
48+
49+
def configure(self) -> None:
50+
# You can still add special argument behavior
51+
self.add_argument("-arg", "--argument_with_really_long_name", type=to_number)
52+
53+
def process_args(self) -> None:
54+
# You can still validate and modify arguments
55+
# (You should do this in the Pydantic Model. I'm just demonstrating that this functionality is still possible)
56+
if self.argument_with_really_long_name > 4:
57+
raise ValueError("argument_with_really_long_name cannot be > 4")
58+
59+
# No auto-complete (and other niceties) for the super class attributes b/c this is a dynamic subclass. Sorry
60+
if self.arg_bool and self.arg_list is not None:
61+
self.arg_list.append("processed")
62+
63+
64+
# class SubparserA(Tap):
65+
# bar: int # bar help
66+
67+
68+
# class SubparserB(Tap):
69+
# baz: Literal["X", "Y", "Z"] # baz help
70+
71+
72+
# class ModelTapWithSubparsing(to_tap_class(Model)):
73+
# foo: bool = False # foo help
74+
75+
# def configure(self):
76+
# self.add_subparsers(help="sub-command help")
77+
# self.add_subparser("a", SubparserA, help="a help", description="Description (a)")
78+
# self.add_subparser("b", SubparserB, help="b help")
79+
80+
81+
if __name__ == "__main__":
82+
# You don't have to subclass tap_class_from_data_model(Model) if you just want a plain argument parser:
83+
# ModelTap = to_tap_class(Model)
84+
args = ModelTap(description="Script description").parse_args()
85+
# args = ModelTapWithSubparsing(description="Script description").parse_args()
86+
print("Parsed args:")
87+
print(args)
88+
# Run the main function
89+
model = Model(**args.as_dict())
90+
main(model)
91+
92+
93+
# tapify works with Model. It immediately returns a Model instance instead of a Tap class
94+
# if __name__ == "__main__":
95+
# model = tapify(Model)
96+
# print(model)

setup.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
with open("README.md", encoding="utf-8") as f:
1313
long_description = f.read()
1414

15+
test_requirements = [
16+
"pydantic >= 2.5.0",
17+
"pytest",
18+
]
19+
1520
setup(
1621
name="typed-argument-parser",
1722
version=__version__,
@@ -26,7 +31,8 @@
2631
packages=find_packages(),
2732
package_data={"tap": ["py.typed"]},
2833
install_requires=["typing-inspect >= 0.7.1", "docstring-parser >= 0.15"],
29-
tests_require=["pytest"],
34+
tests_require=test_requirements,
35+
extras_require={"dev": test_requirements},
3036
python_requires=">=3.8",
3137
classifiers=[
3238
"Programming Language :: Python :: 3",

tap/__init__.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
from argparse import ArgumentError, ArgumentTypeError
22
from tap._version import __version__
33
from tap.tap import Tap
4-
from tap.tapify import tapify
4+
from tap.tapify import tapify, to_tap_class
55

6-
__all__ = ["ArgumentError", "ArgumentTypeError", "Tap", "tapify", "__version__"]
6+
__all__ = [
7+
"ArgumentError",
8+
"ArgumentTypeError",
9+
"Tap",
10+
"tapify",
11+
"to_tap_class",
12+
"__version__",
13+
]

0 commit comments

Comments
 (0)