Skip to content
Draft
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
6 changes: 6 additions & 0 deletions docs/references/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ Options:
faster processing and better type support.
Defaults to False.

--use-awaredatetime Use timezone-aware datetime objects instead of naive
datetime objects. This ensures proper handling of
timezone information in the generated models.
Only supported with Pydantic v2.
Defaults to False.

--custom-template-path TEXT
Custom template path to use. Allows overriding of the
built in templates.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Changelog = "https://github.com/MarcoMuellner/openapi-python-generator/releases"

[tool.poetry.dependencies]
python = "^3.8"
httpx = {extras = ["all"], version = "^0.23.0"}
httpx = {extras = ["all"], version = ">=0.23.0,<1.0.0"}
pydantic = "^2.10.2"
orjson = "^3.9.15"
Jinja2 = "^3.1.2"
Expand Down
11 changes: 10 additions & 1 deletion src/openapi_python_generator/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@
help="Use the orjson library to serialize the data. This is faster than the default json library and provides "
"serialization of datetimes and other types that are not supported by the default json library.",
)
@click.option(
"--use-awaredatetime",
is_flag=True,
show_default=True,
default=False,
help="Use timezone-aware datetime objects instead of naive datetime objects. This ensures proper handling of "
"timezone information in the generated models.",
)
@click.option(
"--custom-template-path",
type=str,
Expand Down Expand Up @@ -58,6 +66,7 @@ def main(
library: Optional[HTTPLibrary] = HTTPLibrary.httpx,
env_token_name: Optional[str] = None,
use_orjson: bool = False,
use_awaredatetime: bool = False,
custom_template_path: Optional[str] = None,
pydantic_version: PydanticVersion = PydanticVersion.V2,
formatter: Formatter = Formatter.BLACK,
Expand All @@ -69,7 +78,7 @@ def main(
an OUTPUT path, where the resulting client is created.
"""
generate_data(
source, output, library, env_token_name, use_orjson, custom_template_path, pydantic_version, formatter
source, output, library, env_token_name, use_orjson, use_awaredatetime, custom_template_path, pydantic_version, formatter
)


Expand Down
2 changes: 2 additions & 0 deletions src/openapi_python_generator/generate_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ def generate_data(
library: Optional[HTTPLibrary] = HTTPLibrary.httpx,
env_token_name: Optional[str] = None,
use_orjson: bool = False,
use_awaredatetime: bool = False,
custom_template_path: Optional[str] = None,
pydantic_version: PydanticVersion = PydanticVersion.V2,
formatter: Formatter = Formatter.BLACK,
Expand All @@ -195,6 +196,7 @@ def generate_data(
library_config_dict[library],
env_token_name,
use_orjson,
use_awaredatetime,
custom_template_path,
pydantic_version,
)
Expand Down
34 changes: 34 additions & 0 deletions src/openapi_python_generator/language_converters/python/common.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import keyword
import re
from typing import Optional
from openapi_python_generator.common import PydanticVersion


_use_orjson: bool = False
_pydantic_version: PydanticVersion = PydanticVersion.V2
_custom_template_path: str = None
_pydantic_use_awaredatetime: bool = False
_symbol_ascii_strip_re = re.compile(r"[^A-Za-z0-9_]")


Expand All @@ -16,6 +19,13 @@ def set_use_orjson(value: bool) -> None:
global _use_orjson
_use_orjson = value

def set_pydantic_version(value: PydanticVersion) -> None:
"""
Set the value of the global variable
:param value: value of the variable
"""
global _pydantic_version
_pydantic_version = value

def get_use_orjson() -> bool:
"""
Expand All @@ -25,6 +35,13 @@ def get_use_orjson() -> bool:
global _use_orjson
return _use_orjson

def get_pydantic_version() -> PydanticVersion:
"""
Get the value of the global variable _pydantic_version.
:return: value of the variable
"""
global _pydantic_version
return _pydantic_version

def set_custom_template_path(value: Optional[str]) -> None:
"""
Expand All @@ -44,6 +61,23 @@ def get_custom_template_path() -> Optional[str]:
return _custom_template_path


def set_pydantic_use_awaredatetime(value: bool) -> None:
"""
Set whether to use AwareDateTime from pydantic instead of datetime.
:param value: value of the variable
"""
global _pydantic_use_awaredatetime
_pydantic_use_awaredatetime = value

def get_pydantic_use_awaredatetime() -> bool:
"""
Get whether to use AwareDateTime from pydantic instead of datetime.
:return: value of the variable
"""
global _pydantic_use_awaredatetime
return _pydantic_use_awaredatetime


def normalize_symbol(symbol: str) -> str:
"""
Remove invalid characters & keywords in Python symbol names
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,20 @@ def generator(
library_config: LibraryConfig,
env_token_name: Optional[str] = None,
use_orjson: bool = False,
use_awaredatetime: bool = False,
custom_template_path: Optional[str] = None,
pydantic_version: PydanticVersion = PydanticVersion.V2,
) -> ConversionResult:
"""
Generate Python code from an OpenAPI 3.0 specification.
"""
if use_awaredatetime and pydantic_version != PydanticVersion.V2:
raise ValueError("Timezone-aware datetime is only supported with Pydantic v2. Please use --pydantic-version v2.")

common.set_use_orjson(use_orjson)
common.set_custom_template_path(custom_template_path)
common.set_pydantic_version(pydantic_version)
common.set_pydantic_use_awaredatetime(use_awaredatetime)

if data.components is not None:
models = generate_models(data.components, pydantic_version)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from openapi_python_generator.models import Model
from openapi_python_generator.models import Property
from openapi_python_generator.models import TypeConversion
from openapi_python_generator.models import ParentModel


def type_converter( # noqa: C901
Expand Down Expand Up @@ -118,16 +119,14 @@ def type_converter( # noqa: C901
*[i.import_types for i in conversions if i.import_types is not None]
)
)
# We only want to auto convert to datetime if orjson is used throghout the code, otherwise we can not
# serialize it to JSON.
elif schema.type == "string" and (
schema.schema_format is None or not common.get_use_orjson()
):
converted_type = pre_type + "str" + post_type
# With custom string format fields, in order to cast these to strict types (e.g. date, datetime, UUID)
# orjson is required for JSON serialiation.
elif (
schema.type == "string"
and schema.schema_format.startswith("uuid")
and common.get_use_orjson()
schema.type == "string"
and schema.schema_format is not None
and schema.schema_format.startswith("uuid")
# orjson and pydantic v2 both support UUID
and (common.get_use_orjson() or common.get_pydantic_version() == PydanticVersion.V2)
):
if len(schema.schema_format) > 4 and schema.schema_format[4].isnumeric():
uuid_type = schema.schema_format.upper()
Expand All @@ -136,9 +135,39 @@ def type_converter( # noqa: C901
else:
converted_type = pre_type + "UUID" + post_type
import_types = ["from uuid import UUID"]
elif schema.type == "string" and schema.schema_format == "date-time":
converted_type = pre_type + "datetime" + post_type
import_types = ["from datetime import datetime"]
elif (
schema.type == "string"
and schema.schema_format == "date-time"
# orjson and pydantic v2 both support datetime
and (common.get_use_orjson() or common.get_pydantic_version() == PydanticVersion.V2)
):
if common.get_pydantic_use_awaredatetime():
converted_type = pre_type + "AwareDatetime" + post_type
import_types = ["from pydantic import AwareDatetime"]
else:
converted_type = pre_type + "datetime" + post_type
import_types = ["from datetime import datetime"]
elif (
schema.type == "string"
and schema.schema_format == "date"
# orjson and pydantic v2 both support date
and (common.get_use_orjson() or common.get_pydantic_version() == PydanticVersion.V2)
):
converted_type = pre_type + "date" + post_type
import_types = ["from datetime import date"]
elif (
schema.type == "string"
and schema.schema_format == "decimal"
# orjson does not support Decimal
# See https://github.com/ijl/orjson/issues/444
and not common.get_use_orjson()
# pydantic v2 supports Decimal
and common.get_pydantic_version() == PydanticVersion.V2
):
converted_type = pre_type + "Decimal" + post_type
import_types = ["from decimal import Decimal"]
elif schema.type == "string":
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also captures unknown "schema_format" that would earlier result in "unknown type string" errors.

converted_type = pre_type + "str" + post_type
elif schema.type == "integer":
converted_type = pre_type + "int" + post_type
elif schema.type == "number":
Expand All @@ -157,7 +186,9 @@ def type_converter( # noqa: C901
elif isinstance(schema.items, Schema):
original_type = "array<" + (
str(schema.items.type.value) if schema.items.type is not None else "unknown") + ">"
retVal += type_converter(schema.items, True).converted_type
items_type = type_converter(schema.items, True)
import_types = items_type.import_types
retVal += items_type.converted_type
else:
original_type = "array<unknown>"
retVal += "Any"
Expand Down Expand Up @@ -257,6 +288,32 @@ def _generate_property_from_reference(
import_type=[import_model],
)

def _generate_property(
model_name: str,
name: str,
schema_or_reference: Schema | Reference,
parent_schema: Optional[Schema] = None,
) -> Property:
if isinstance(schema_or_reference, Reference):
return _generate_property_from_reference(
model_name, name, schema_or_reference, parent_schema
)

return _generate_property_from_schema(
model_name, name, schema_or_reference, parent_schema
)

def _collect_properties_from_schema(model_name: str, parent_schema: Schema):
property_iterator = (
parent_schema.properties.items()
if parent_schema.properties is not None
else {}
)
for name, schema_or_reference in property_iterator:
conv_property = _generate_property(
model_name, name, schema_or_reference, parent_schema
)
yield conv_property

def generate_models(components: Components, pydantic_version: PydanticVersion = PydanticVersion.V2) -> List[Model]:
"""
Expand Down Expand Up @@ -299,27 +356,39 @@ def generate_models(components: Components, pydantic_version: PydanticVersion =

continue # pragma: no cover

# Enumerate properties for this model
properties = []
property_iterator = (
schema_or_reference.properties.items()
if schema_or_reference.properties is not None
else {}
)
for prop_name, property in property_iterator:
if isinstance(property, Reference):
conv_property = _generate_property_from_reference(
name, prop_name, property, schema_or_reference
)
else:
conv_property = _generate_property_from_schema(
name, prop_name, property, schema_or_reference
)
for conv_property in _collect_properties_from_schema(name, schema_or_reference):
properties.append(conv_property)

# Enumerate union types that compose this model (if any) from allOf, oneOf, anyOf
parent_components = []
components_iterator = (
(schema_or_reference.allOf or []) + (schema_or_reference.oneOf or []) + (schema_or_reference.anyOf or [])
)
for parent_component in components_iterator:
# For references, instead of importing properties, record inherited components
if isinstance(parent_component, Reference):
ref = parent_component.ref
parent_name = common.normalize_symbol(ref.split("/")[-1])
parent_components.append(ParentModel(
ref = ref,
name = parent_name,
import_type = f"from .{parent_name} import {parent_name}"
))

# Collect inline properties
if isinstance(parent_component, Schema):
for conv_property in _collect_properties_from_schema(name, parent_component):
properties.append(conv_property)

template_name = MODELS_TEMPLATE_PYDANTIC_V2 if pydantic_version == PydanticVersion.V2 else MODELS_TEMPLATE

generated_content = jinja_env.get_template(template_name).render(
schema_name=name, schema=schema_or_reference, properties=properties
schema_name=name,
schema=schema_or_reference,
properties=properties,
parent_components=parent_components
)

try:
Expand All @@ -333,6 +402,7 @@ def generate_models(components: Components, pydantic_version: PydanticVersion =
content=generated_content,
openapi_object=schema_or_reference,
properties=properties,
parent_components=parent_components
)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,29 @@

HTTP_OPERATIONS = ["get", "post", "put", "delete", "options", "head", "patch", "trace"]

def _generate_body_dump_expression(data = "data") -> str:
"""
Generate expression for dumping abstract body as a dictionary.
"""

# Use old v1 method for pydantic
if common.get_pydantic_version() == common.PydanticVersion.V1:
return f"{data}.dict()"

# Dump model but allow orjson to serialise (fastest)
if common.get_use_orjson():
return f"{data}.model_dump()"

# rely on pydantic v2 to serialise (slowest, but best compatibility)
return f"{data}.model_dump(mode=\"json\")"


def generate_body_param(operation: Operation) -> Union[str, None]:
if operation.requestBody is None:
return None
else:
if isinstance(operation.requestBody, Reference):
return "data.dict()"
return _generate_body_dump_expression("data")

if operation.requestBody.content is None:
return None # pragma: no cover
Expand All @@ -46,11 +62,12 @@ def generate_body_param(operation: Operation) -> Union[str, None]:
return None # pragma: no cover

if isinstance(media_type.media_type_schema, Reference):
return "data.dict()"
return _generate_body_dump_expression("data")
elif isinstance(media_type.media_type_schema, Schema):
schema = media_type.media_type_schema
if schema.type == "array":
return "[i.dict() for i in data]"
expression = _generate_body_dump_expression("i")
return f"[{expression} for i in data]"
elif schema.type == "object":
return "data"
else:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ async def {{ operation_id }}({{ params }} api_config_override : Optional[APIConf
params=query_params,
{% if body_param %}
{% if use_orjson %}
data=orjson.dumps({{ body_param }})
data=orjson.dumps({{ body_param | safe }})
{% else %}
json = {{ body_param }}
json = {{ body_param | safe}}
{% endif %}
{% endif %}
) as inital_response:
Expand Down
Loading