Skip to content

Commit b090449

Browse files
authored
Merge pull request #29 from bogdandm/pydantic
Pydantic models generation
2 parents a3fcbec + 0eb0fe1 commit b090449

31 files changed

+932
-193
lines changed

README.md

Lines changed: 91 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
![Example](/etc/convert.png)
99

1010
json2python-models is a [Python](https://www.python.org/) tool that can generate Python models classes
11-
(dataclasses, [attrs](https://github.com/python-attrs/attrs)) from JSON dataset.
11+
([pydantic](https://github.com/samuelcolvin/pydantic), dataclasses, [attrs](https://github.com/python-attrs/attrs))
12+
from JSON dataset.
1213

1314
## Features
1415

@@ -17,11 +18,11 @@ json2python-models is a [Python](https://www.python.org/) tool that can generate
1718
* Fields and models **names** generation (unicode support included)
1819
* Similar **models generalization**
1920
* Handling **recursive data** structures (i.e family tree)
20-
* Detecting **string literals** (i.e. datetime or just stringify numbers)
21-
and providing decorators to easily convert into Python representation
21+
* Detecting **string serializable types** (i.e. datetime or just stringify numbers)
22+
* Detecting fields containing string constants (`Literal['foo', 'bar']`)
2223
* Generation models as **tree** (nested models) or **list**
2324
* Specifying when dictionaries should be processed as **`dict` type** (by default every dict is considered as some model)
24-
* **CLI** tool
25+
* **CLI** API with a lot of options
2526

2627
## Table of Contents
2728

@@ -38,7 +39,26 @@ json2python-models is a [Python](https://www.python.org/) tool that can generate
3839
* [Contributing](#contributing)
3940
* [License](#license)
4041

41-
## Example
42+
## Examples
43+
44+
### Part of Path of Exile public items API
45+
46+
```python
47+
from pydantic import BaseModel, Field
48+
from typing import List, Optional
49+
from typing_extensions import Literal
50+
51+
52+
class Tab(BaseModel):
53+
id_: str = Field(..., alias="id")
54+
public: bool
55+
stash_type: Literal["CurrencyStash", "NormalStash", "PremiumStash"] = Field(..., alias="stashType")
56+
items: List['Item']
57+
account_name: Optional[str] = Field(None, alias="accountName")
58+
last_character_name: Optional[str] = Field(None, alias="lastCharacterName")
59+
stash: Optional[str] = None
60+
league: Optional[Literal["Hardcore", "Standard"]] = None
61+
```
4262

4363
### F1 Season Results
4464

@@ -83,47 +103,46 @@ driver_standings.json
83103
```
84104

85105
```
86-
json2models -f attrs -l DriverStandings driver_standings.json
106+
json2models -f pydantic -s flat -l DriverStandings - driver_standings.json
87107
```
88108

89109
```python
90-
import attr
91-
from json_to_models.dynamic_typing import IntString, IsoDateString
110+
r"""
111+
generated by json2python-models v0.1.2 at Mon May 4 17:46:30 2020
112+
command: /opt/projects/json2python-models/venv/bin/json2models -f pydantic -s flat -l DriverStandings - driver_standings.json
113+
"""
114+
from pydantic import BaseModel, Field
92115
from typing import List
93-
94-
95-
@attr.s
96-
class DriverStandings:
97-
@attr.s
98-
class DriverStanding:
99-
@attr.s
100-
class Driver:
101-
driver_id: str = attr.ib()
102-
permanent_number: IntString = attr.ib(converter=IntString)
103-
code: str = attr.ib()
104-
url: str = attr.ib()
105-
given_name: str = attr.ib()
106-
family_name: str = attr.ib()
107-
date_of_birth: IsoDateString = attr.ib(converter=IsoDateString)
108-
nationality: str = attr.ib()
109-
110-
@attr.s
111-
class Constructor:
112-
constructor_id: str = attr.ib()
113-
url: str = attr.ib()
114-
name: str = attr.ib()
115-
nationality: str = attr.ib()
116-
117-
position: IntString = attr.ib(converter=IntString)
118-
position_text: IntString = attr.ib(converter=IntString)
119-
points: IntString = attr.ib(converter=IntString)
120-
wins: IntString = attr.ib(converter=IntString)
121-
driver: 'Driver' = attr.ib()
122-
constructors: List['Constructor'] = attr.ib()
123-
124-
season: IntString = attr.ib(converter=IntString)
125-
round: IntString = attr.ib(converter=IntString)
126-
driver_standings: List['DriverStanding'] = attr.ib()
116+
from typing_extensions import Literal
117+
118+
class DriverStandings(BaseModel):
119+
season: int
120+
round_: int = Field(..., alias="round")
121+
DriverStandings: List['DriverStanding']
122+
123+
class DriverStanding(BaseModel):
124+
position: int
125+
position_text: int = Field(..., alias="positionText")
126+
points: int
127+
wins: int
128+
driver: 'Driver' = Field(..., alias="Driver")
129+
constructors: List['Constructor'] = Field(..., alias="Constructors")
130+
131+
class Driver(BaseModel):
132+
driver_id: str = Field(..., alias="driverId")
133+
permanent_number: int = Field(..., alias="permanentNumber")
134+
code: str
135+
url: str
136+
given_name: str = Field(..., alias="givenName")
137+
family_name: str = Field(..., alias="familyName")
138+
date_of_birth: str = Field(..., alias="dateOfBirth")
139+
nationality: str
140+
141+
class Constructor(BaseModel):
142+
constructor_id: str = Field(..., alias="constructorId")
143+
url: str
144+
name: str
145+
nationality: Literal["Austrian", "German", "American", "British", "Italian", "French"]
127146
```
128147

129148
</p>
@@ -139,14 +158,19 @@ class DriverStandings:
139158
It requires a lit bit of tweaking:
140159
* Some fields store routes/models specs as dicts
141160
* There is a lot of optinal fields so we reduce merging threshold
161+
* Disable string literals
142162

143163
```
144-
json_to_models -s flat -f dataclasses -m Swagger testing_tools/swagger.json
145-
--dict-keys-fields securityDefinitions paths responses definitions properties
146-
--merge percent_50 number
164+
json2models -s flat -f dataclasses -m Swagger testing_tools/swagger.json \
165+
--dict-keys-fields securityDefinitions paths responses definitions properties \
166+
--merge percent_50 number --max-strings-literals 0
147167
```
148168

149169
```python
170+
r"""
171+
generated by json2python-models v0.1.2 at Mon May 4 18:08:09 2020
172+
command: /opt/projects/json2python-models/json_to_models/__main__.py -s flat -f dataclasses -m Swagger testing_tools/swagger.json --max-strings-literals 0 --dict-keys-fields securityDefinitions paths responses definitions properties --merge percent_50 number
173+
"""
150174
from dataclasses import dataclass, field
151175
from json_to_models.dynamic_typing import FloatString
152176
from typing import Any, Dict, List, Optional, Union
@@ -192,15 +216,15 @@ class Path:
192216

193217
@dataclass
194218
class Property:
195-
type: str
196-
format: Optional[str] = None
219+
type_: str
220+
format_: Optional[str] = None
197221
xnullable: Optional[bool] = None
198222
items: Optional['Item_Schema'] = None
199223

200224

201225
@dataclass
202226
class Property_2E:
203-
type: str
227+
type_: str
204228
title: Optional[str] = None
205229
read_only: Optional[bool] = None
206230
max_length: Optional[int] = None
@@ -209,26 +233,26 @@ class Property_2E:
209233
enum: Optional[List[str]] = field(default_factory=list)
210234
maximum: Optional[int] = None
211235
minimum: Optional[int] = None
212-
format: Optional[str] = None
236+
format_: Optional[str] = None
213237

214238

215239
@dataclass
216240
class Item:
217-
ref: Optional[str] = None
218241
title: Optional[str] = None
219-
type: Optional[str] = None
242+
type_: Optional[str] = None
243+
ref: Optional[str] = None
220244
max_length: Optional[int] = None
221245
min_length: Optional[int] = None
222246

223247

224248
@dataclass
225249
class Parameter_SecurityDefinition:
226-
name: str
227-
in_: str
250+
name: Optional[str] = None
251+
in_: Optional[str] = None
228252
required: Optional[bool] = None
229253
schema: Optional['Item_Schema'] = None
230-
type: Optional[str] = None
231254
description: Optional[str] = None
255+
type_: Optional[str] = None
232256

233257

234258
@dataclass
@@ -253,10 +277,10 @@ class Response:
253277

254278
@dataclass
255279
class Definition_Schema:
256-
ref: Optional[str] = None
280+
type_: str
257281
required: Optional[List[str]] = field(default_factory=list)
258-
type: Optional[str] = None
259-
properties: Optional[Dict[str, Union['Property_2E', 'Property']]] = field(default_factory=dict)
282+
properties: Optional[Dict[str, Union['Property', 'Property_2E']]] = field(default_factory=dict)
283+
ref: Optional[str] = None
260284
```
261285

262286
</p>
@@ -309,8 +333,8 @@ Arguments:
309333

310334
* `-f`, `--framework` - Model framework for which python code is generated.
311335
`base` (default) mean no framework so code will be generated without any decorators and additional meta-data.
312-
* **Format**: `-f {base,attrs,dataclasses,custom}`
313-
* **Example**: `-f attrs`
336+
* **Format**: `-f {base, pydantic, attrs, dataclasses, custom}`
337+
* **Example**: `-f pydantic`
314338
* **Default**: `-f base`
315339

316340
* `-s`, `--structure` - Models composition style.
@@ -327,6 +351,13 @@ Arguments:
327351

328352
* `--strings-converters` - Enable generation of string types converters (i.e. `IsoDatetimeString` or `BooleanString`).
329353
* **Default**: disabled
354+
355+
* `--max-strings-literals` - Generate `Literal['foo', 'bar']` when field have less than NUMBER string constants as values.
356+
* **Format**: `--max-strings-literals <NUMBER>`
357+
* **Default**: 10 (generator classes could override it)
358+
* **Example**: `--max-strings-literals 5` - only 5 literals will be saved and used to code generation
359+
* **Note**: There could not be more than **15** literals per field (for performance reasons)
360+
* **Note**: `attrs` code generator do not use Literals and just generate `str` fields instead
330361

331362
* `--merge` - Merge policy settings. Possible values are:
332363
* **Format**: `--merge MERGE_POLICY [MERGE_POLICY ...]`
@@ -369,7 +400,7 @@ One of model arguments (`-m` or `-l`) is required.
369400

370401
### Low level API
371402

372-
> Coming soon (Wiki)
403+
\-
373404

374405
## Tests
375406

json_to_models/cli.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from .models.attr import AttrsModelCodeGenerator
1818
from .models.base import GenericModelCodeGenerator, generate_code
1919
from .models.dataclasses import DataclassModelCodeGenerator
20+
from .models.pydantic import PydanticModelCodeGenerator
2021
from .models.structure import compose_models, compose_models_flat
2122
from .registry import (
2223
ModelCmp, ModelFieldsEquals, ModelFieldsNumberMatch, ModelFieldsPercentMatch, ModelRegistry
@@ -42,14 +43,17 @@ class Cli:
4243
MODEL_GENERATOR_MAPPING: Dict[str, Type[GenericModelCodeGenerator]] = {
4344
"base": convert_args(GenericModelCodeGenerator),
4445
"attrs": convert_args(AttrsModelCodeGenerator, meta=bool_js_style),
45-
"dataclasses": convert_args(DataclassModelCodeGenerator, meta=bool_js_style, post_init_converters=bool_js_style)
46+
"dataclasses": convert_args(DataclassModelCodeGenerator, meta=bool_js_style,
47+
post_init_converters=bool_js_style),
48+
"pydantic": convert_args(PydanticModelCodeGenerator),
4649
}
4750

4851
def __init__(self):
4952
self.initialized = False
5053
self.models_data: Dict[str, Iterable[dict]] = {} # -m/-l
5154
self.enable_datetime: bool = False # --datetime
5255
self.strings_converters: bool = False # --strings-converters
56+
self.max_literals: int = -1 # --max-strings-literals
5357
self.merge_policy: List[ModelCmp] = [] # --merge
5458
self.structure_fn: STRUCTURE_FN_TYPE = None # -s
5559
self.model_generator: Type[GenericModelCodeGenerator] = None # -f & --code-generator
@@ -80,6 +84,7 @@ def parse_args(self, args: List[str] = None):
8084
self.enable_datetime = namespace.datetime
8185
disable_unicode_conversion = namespace.disable_unicode_conversion
8286
self.strings_converters = namespace.strings_converters
87+
self.max_literals = namespace.max_strings_literals
8388
merge_policy = [m.split("_") if "_" in m else m for m in namespace.merge]
8489
structure = namespace.structure
8590
framework = namespace.framework
@@ -201,8 +206,11 @@ def set_args(
201206
m = importlib.import_module(module)
202207
self.model_generator = getattr(m, cls)
203208

204-
self.model_generator_kwargs = {} if not self.strings_converters else {'post_init_converters': True}
205-
self.model_generator_kwargs['convert_unicode'] = not disable_unicode_conversion
209+
self.model_generator_kwargs = dict(
210+
post_init_converters=self.strings_converters,
211+
convert_unicode=not disable_unicode_conversion,
212+
max_literals=self.max_literals
213+
)
206214
if code_generator_kwargs_raw:
207215
for item in code_generator_kwargs_raw:
208216
if item[0] == '"':
@@ -276,6 +284,15 @@ def _create_argparser(cls) -> argparse.ArgumentParser:
276284
action="store_true",
277285
help="Enable generation of string types converters (i.e. IsoDatetimeString or BooleanString).\n\n"
278286
)
287+
parser.add_argument(
288+
"--max-strings-literals",
289+
type=int,
290+
default=GenericModelCodeGenerator.DEFAULT_MAX_LITERALS,
291+
metavar='NUMBER',
292+
help="Generate Literal['foo', 'bar'] when field have less than NUMBER string constants as values.\n"
293+
f"Pass 0 to disable. By default NUMBER={GenericModelCodeGenerator.DEFAULT_MAX_LITERALS}"
294+
f" (some generator classes could override it)\n\n"
295+
)
279296
parser.add_argument(
280297
"--disable-unicode-conversion", "--no-unidecode",
281298
action="store_true",

json_to_models/dynamic_typing/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from .base import (
22
BaseType, ImportPathList, MetaData, Null, Unknown, get_hash_string
33
)
4-
from .complex import ComplexType, DDict, DList, DOptional, DTuple, DUnion, SingleType
4+
from .complex import ComplexType, DDict, DList, DOptional, DTuple, DUnion, SingleType, StringLiteral
55
from .models_meta import AbsoluteModelRef, ModelMeta, ModelPtr
66
from .string_datetime import IsoDateString, IsoDatetimeString, IsoTimeString, register_datetime_classes
77
from .string_serializable import (

json_to_models/dynamic_typing/base.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from inspect import isclass
2-
from typing import Any, Generator, Iterable, List, Tuple, Union
2+
from typing import Any, Dict, Generator, Iterable, List, Tuple, Type, Union
33

44
ImportPathList = List[Tuple[str, Union[Iterable[str], str, None]]]
55

@@ -21,14 +21,30 @@ def replace(self, t: Union['MetaData', List['MetaData']], **kwargs) -> 'BaseType
2121
"""
2222
raise NotImplementedError()
2323

24-
def to_typing_code(self) -> Tuple[ImportPathList, str]:
24+
def to_typing_code(self, types_style: Dict[Union['BaseType', Type['BaseType']], dict]) \
25+
-> Tuple[ImportPathList, str]:
2526
"""
2627
Return typing code that represents this metadata and import path of classes that are used in this code
2728
29+
:param types_style: Hints for .to_typing_code() for different type wrappers
2830
:return: ((module_name, (class_name, ...)), code)
2931
"""
3032
raise NotImplementedError()
3133

34+
@classmethod
35+
def get_options_for_type(
36+
cls,
37+
t: Union['BaseType', Type['BaseType']],
38+
types_style: Dict[Union['BaseType', Type['BaseType']], dict]
39+
) -> dict:
40+
t_cls = t if isclass(t) else type(t)
41+
mro = t_cls.__mro__
42+
for base in mro:
43+
options = types_style.get(base, ...)
44+
if options is not Ellipsis:
45+
return options
46+
return {}
47+
3248
def to_hash_string(self) -> str:
3349
"""
3450
Return unique string that can be used to generate hash of type instance.
@@ -71,7 +87,8 @@ def __iter__(self) -> Iterable['MetaData']:
7187
def replace(self, t: 'MetaData', **kwargs) -> 'UnknownType':
7288
return self
7389

74-
def to_typing_code(self) -> Tuple[ImportPathList, str]:
90+
def to_typing_code(self, types_style: Dict[Union['BaseType', Type['BaseType']], dict]) \
91+
-> Tuple[ImportPathList, str]:
7592
return ([('typing', 'Any')], 'Any')
7693

7794
def to_hash_string(self) -> str:
@@ -90,7 +107,8 @@ def __iter__(self) -> Iterable['MetaData']:
90107
def replace(self, t: 'MetaData', **kwargs) -> 'NoneType':
91108
return self
92109

93-
def to_typing_code(self) -> Tuple[ImportPathList, str]:
110+
def to_typing_code(self, types_style: Dict[Union['BaseType', Type['BaseType']], dict]) \
111+
-> Tuple[ImportPathList, str]:
94112
return ([], 'None')
95113

96114
def to_hash_string(self) -> str:

0 commit comments

Comments
 (0)