Skip to content

Commit 13547a0

Browse files
committed
Finished code for generating dataclasses from schemas
1 parent 14e3e8a commit 13547a0

File tree

7 files changed

+251
-14
lines changed

7 files changed

+251
-14
lines changed

openapi_python_client/__init__.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
""" Generate modern Python clients from OpenAPI """
22
import sys
3+
from pathlib import Path
34
from typing import Dict
45

56
import orjson
67
import requests
8+
import stringcase
9+
from jinja2 import Environment, PackageLoader
710

811
from .models import OpenAPI
912

@@ -14,7 +17,7 @@ def main():
1417
json = _get_json(url)
1518
data_dict = _parse_json(json)
1619
openapi = OpenAPI.from_dict(data_dict)
17-
print(openapi)
20+
_build_project(openapi)
1821

1922

2023
def _get_json(url) -> bytes:
@@ -24,3 +27,29 @@ def _get_json(url) -> bytes:
2427

2528
def _parse_json(json: bytes) -> Dict:
2629
return orjson.loads(json)
30+
31+
32+
def _build_project(openapi: OpenAPI):
33+
env = Environment(loader=PackageLoader(__package__), trim_blocks=True, lstrip_blocks=True)
34+
35+
# Create output directories
36+
project_name = f"{openapi.title.replace(' ', '-').lower()}-client"
37+
package_name = f"{openapi.title.replace(' ', '_').lower()}_client"
38+
project_dir = Path.cwd() / project_name
39+
project_dir.mkdir()
40+
package_dir = project_dir / package_name
41+
package_dir.mkdir()
42+
43+
# Create a pyproject.toml file
44+
pyproject_template = env.get_template("pyproject.toml")
45+
pyproject_path = project_dir / "pyproject.toml"
46+
pyproject_path.write_text(pyproject_template.render(project_name=project_name, package_name=package_name))
47+
48+
# Generate models
49+
models_dir = package_dir / "models"
50+
models_dir.mkdir()
51+
model_template = env.get_template("model.pyi")
52+
for schema in openapi.schemas.values():
53+
module_path = models_dir / f"{stringcase.snakecase(schema.title)}.py"
54+
module_path.write_text(model_template.render(schema=schema))
55+

openapi_python_client/models/openapi.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from __future__ import annotations
22

3-
from dataclasses import dataclass
3+
from dataclasses import dataclass, field
44
from enum import Enum
5-
from typing import Dict, List, Optional
5+
from typing import Dict, List, Optional, Set
66

7-
from .properties import Property, property_from_dict
7+
import stringcase
8+
9+
from .properties import Property, property_from_dict, DateTimeProperty, ListProperty, RefProperty, EnumProperty
810

911

1012
class Method(Enum):
@@ -83,15 +85,20 @@ class Schema:
8385
title: str
8486
properties: List[Property]
8587
description: str
88+
relative_imports: Set[str] = field(default_factory=set)
8689

8790
@staticmethod
8891
def from_dict(d: Dict, /) -> Schema:
8992
""" A single Schema from its dict representation """
9093
required = set(d.get("required", []))
9194
properties: List[Property] = []
95+
schema = Schema(title=d["title"], properties=properties, description=d.get("description", ""))
9296
for key, value in d["properties"].items():
93-
properties.append(property_from_dict(name=key, required=key in required, data=value,))
94-
return Schema(title=d["title"], properties=properties, description=d.get("description", ""),)
97+
p = property_from_dict(name=key, required=key in required, data=value)
98+
properties.append(p)
99+
if isinstance(p, (ListProperty, RefProperty)) and p.ref:
100+
schema.relative_imports.add(f"from .{stringcase.snakecase(p.ref)} import {p.ref}")
101+
return schema
95102

96103
@staticmethod
97104
def dict(d: Dict, /) -> Dict[str, Schema]:
@@ -113,15 +120,26 @@ class OpenAPI:
113120
security_schemes: Dict
114121
schemas: Dict[str, Schema]
115122
endpoints: List[Endpoint]
123+
enums: Dict[str, List[str]]
116124

117125
@staticmethod
118126
def from_dict(d: Dict, /) -> OpenAPI:
119127
""" Create an OpenAPI from dict """
128+
schemas = Schema.dict(d["components"]["schemas"])
129+
enums = {}
130+
for schema in schemas.values():
131+
for prop in schema.properties:
132+
if isinstance(prop, EnumProperty):
133+
enum_class_name = stringcase.pascalcase(prop.name)
134+
enums[enum_class_name] = prop.values
135+
schema.relative_imports.add(f"from .{prop.name} import {enum_class_name}")
136+
120137
return OpenAPI(
121138
title=d["info"]["title"],
122139
description=d["info"]["description"],
123140
version=d["info"]["version"],
124141
endpoints=Endpoint.get_list_from_dict(d["paths"]),
125-
schemas=Schema.dict(d["components"]["schemas"]),
142+
schemas=schemas,
126143
security_schemes=d["components"]["securitySchemes"],
144+
enums=enums,
127145
)

openapi_python_client/models/properties.py

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from dataclasses import dataclass
2-
from typing import Optional, List, Dict, Union
2+
from typing import Optional, List, Dict, Union, ClassVar
3+
import stringcase
34

45

56
@dataclass
@@ -9,6 +10,14 @@ class Property:
910
name: str
1011
required: bool
1112

13+
_type_string: ClassVar[str]
14+
15+
def get_type_string(self):
16+
""" Get a string representation of type that should be used when declaring this property """
17+
if self.required:
18+
return self._type_string
19+
return f"Optional[{self._type_string}]"
20+
1221

1322
@dataclass
1423
class StringProperty(Property):
@@ -18,41 +27,82 @@ class StringProperty(Property):
1827
default: Optional[str] = None
1928
pattern: Optional[str] = None
2029

30+
_type_string: ClassVar[str] = "str"
31+
32+
def to_string(self) -> str:
33+
""" How this should be declared in a dataclass """
34+
if self.default:
35+
return f"{self.name}: {self.get_type_string()} = {self.default}"
36+
else:
37+
return f"{self.name}: {self.get_type_string()}"
38+
2139

2240
@dataclass
2341
class DateTimeProperty(Property):
2442
""" A property of type datetime.datetime """
43+
_type_string: ClassVar[str] = "datetime"
2544

26-
pass
45+
def to_string(self) -> str:
46+
""" How this should be declared in a dataclass """
47+
return f"{self.name}: {self.get_type_string()}"
2748

2849

2950
@dataclass
3051
class FloatProperty(Property):
3152
""" A property of type float """
3253

3354
default: Optional[float] = None
55+
_type_string: ClassVar[str] = "float"
56+
57+
def to_string(self) -> str:
58+
""" How this should be declared in a dataclass """
59+
if self.default:
60+
return f"{self.name}: {self.get_type_string()} = {self.default}"
61+
else:
62+
return f"{self.name}: {self.get_type_string()}"
3463

3564

3665
@dataclass
3766
class IntProperty(Property):
3867
""" A property of type int """
3968

4069
default: Optional[int] = None
70+
_type_string: ClassVar[str] = "int"
71+
72+
def to_string(self) -> str:
73+
""" How this should be declared in a dataclass """
74+
if self.default:
75+
return f"{self.name}: {self.get_type_string()} = {self.default}"
76+
else:
77+
return f"{self.name}: {self.get_type_string()}"
4178

4279

4380
@dataclass
4481
class BooleanProperty(Property):
4582
""" Property for bool """
83+
_type_string: ClassVar[str] = "bool"
4684

47-
pass
85+
def to_string(self) -> str:
86+
""" How this should be declared in a dataclass """
87+
return f"{self.name}: {self.get_type_string()}"
4888

4989

5090
@dataclass
5191
class ListProperty(Property):
5292
""" Property for list """
5393

54-
type: Optional[str] = None
55-
ref: Optional[str] = None
94+
type: Optional[str]
95+
ref: Optional[str]
96+
97+
def get_type_string(self):
98+
""" Get a string representation of type that should be used when declaring this property """
99+
if self.required:
100+
return f"List[{self.type}]"
101+
return f"Optional[List[{self.type}]]"
102+
103+
def to_string(self) -> str:
104+
""" How this should be declared in a dataclass """
105+
return f"{self.name}: {self.get_type_string()}"
56106

57107

58108
@dataclass
@@ -61,22 +111,59 @@ class EnumProperty(Property):
61111

62112
values: List[str]
63113

114+
def get_type_string(self):
115+
""" Get a string representation of type that should be used when declaring this property """
116+
class_name = stringcase.pascalcase(self.name)
117+
if self.required:
118+
return class_name
119+
return f"Optional[{class_name}]"
120+
121+
def to_string(self) -> str:
122+
""" How this should be declared in a dataclass """
123+
return f"{self.name}: {self.get_type_string()}"
124+
64125

65126
@dataclass
66127
class RefProperty(Property):
67128
""" A property which refers to another Schema """
68129

69130
ref: str
70131

132+
def get_type_string(self):
133+
""" Get a string representation of type that should be used when declaring this property """
134+
if self.required:
135+
return self.ref
136+
return f"Optional[{self.ref}]"
137+
138+
def to_string(self) -> str:
139+
""" How this should be declared in a dataclass """
140+
return f"{self.name}: {self.get_type_string()}"
141+
71142

72143
@dataclass
73144
class DictProperty(Property):
74145
""" Property that is a general Dict """
75146

147+
_type_string: ClassVar[str] = "Dict"
148+
149+
def to_string(self) -> str:
150+
""" How this should be declared in a dataclass """
151+
return f"{self.name}: {self.get_type_string()}"
152+
153+
154+
_openapi_types_to_python_type_strings = {
155+
"string": "str",
156+
"number": "float",
157+
"integer": "int",
158+
"boolean": "bool",
159+
"object": "Dict",
160+
}
161+
76162

77163
def property_from_dict(
78164
name: str, required: bool, data: Dict[str, Union[float, int, str, List[str], Dict[str, str]]]
79165
) -> Property:
166+
""" Generate a Property from the OpenAPI dictionary representation of it """
80167
if "enum" in data:
81168
return EnumProperty(name=name, required=required, values=data["enum"],)
82169
if "$ref" in data:
@@ -99,7 +186,10 @@ def property_from_dict(
99186
ref = None
100187
if "$ref" in data["items"]:
101188
ref = data["items"]["$ref"].split("/")[-1]
102-
return ListProperty(name=name, required=required, type=data["items"].get("type"), ref=ref,)
189+
_type = None
190+
if "type" in data["items"]:
191+
_type = _openapi_types_to_python_type_strings[data["items"]["type"]]
192+
return ListProperty(name=name, required=required, type=_type, ref=ref)
103193
elif data["type"] == "object":
104194
return DictProperty(name=name, required=required,)
105195
raise ValueError(f"Did not recognize type of {data}")
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from dataclasses import dataclass
2+
from datetime import datetime
3+
from typing import Dict, Optional, List
4+
5+
{% for relative in schema.relative_imports %}
6+
{{ relative }}
7+
{% endfor %}
8+
9+
10+
@dataclass
11+
class {{ schema.title }}:
12+
{% for property in schema.properties %}
13+
{{ property.to_string() }}
14+
{% endfor %}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[tool.poetry]
2+
name = "{{ project_name }}"
3+
version = "0.1.0"
4+
description = "A client API"
5+
6+
authors = []
7+
8+
readme = "README.md"
9+
packages = [
10+
{include = "{{ package_name }}"},
11+
]
12+
include = ["CHANGELOG.md", "{{ package_name }}/py.typed"]
13+
14+
15+
[tool.poetry.dependencies]
16+
python = "^3.8"
17+
requests = "^2.22.0"

0 commit comments

Comments
 (0)