From d0c0f16ca3bba782a9b664d56c649560c7109c0b Mon Sep 17 00:00:00 2001 From: Anis Date: Wed, 16 Oct 2024 18:14:00 +0200 Subject: [PATCH] dataclass generator improvements (#2102) * Use apply_discriminator_type for dataclasses Allow dataclass models to be properly generated with discriminator field * Fix dataclass inheritance Thanks to keyword only, dataclass models can use inheritance and no have issues with default values * Support datetime types in dataclass fields applying `--output-datetime-class` from #2100 to dataclass to map date, time and date time to the python `datetime` objects instead of strings. * fix unittest * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix unittest * fix unittest --------- Co-authored-by: Koudai Aono Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- README.md | 8 +- datamodel_code_generator/__init__.py | 4 +- datamodel_code_generator/__main__.py | 31 ++++- datamodel_code_generator/arguments.py | 10 +- datamodel_code_generator/format.py | 4 + datamodel_code_generator/model/__init__.py | 2 +- datamodel_code_generator/model/base.py | 3 + datamodel_code_generator/model/dataclass.py | 69 +++++++++- datamodel_code_generator/model/enum.py | 2 + datamodel_code_generator/model/msgspec.py | 2 + .../model/pydantic/base_model.py | 4 + .../model/pydantic/types.py | 2 +- .../model/pydantic_v2/base_model.py | 2 + .../model/pydantic_v2/types.py | 4 +- datamodel_code_generator/model/scalar.py | 2 + .../model/template/dataclass.jinja2 | 2 +- datamodel_code_generator/model/typed_dict.py | 2 + datamodel_code_generator/model/union.py | 2 + datamodel_code_generator/parser/base.py | 9 +- datamodel_code_generator/parser/graphql.py | 3 + datamodel_code_generator/parser/jsonschema.py | 4 + datamodel_code_generator/parser/openapi.py | 3 + datamodel_code_generator/types.py | 2 +- docs/index.md | 6 + .../main/openapi/dataclass_keyword_only.py | 21 +++ .../main/openapi/datetime_dataclass.py | 13 ++ .../dataclass_enum_one_literal_as_default.py | 33 +++++ tests/data/openapi/inheritance.yaml | 28 ++++ tests/main/openapi/test_main_openapi.py | 124 ++++++++++++++++-- 29 files changed, 372 insertions(+), 29 deletions(-) create mode 100644 tests/data/expected/main/openapi/dataclass_keyword_only.py create mode 100644 tests/data/expected/main/openapi/datetime_dataclass.py create mode 100644 tests/data/expected/main/openapi/discriminator/dataclass_enum_one_literal_as_default.py create mode 100644 tests/data/openapi/inheritance.yaml diff --git a/README.md b/README.md index dc06fc35f..740c2eb16 100644 --- a/README.md +++ b/README.md @@ -450,6 +450,12 @@ Model customization: --enable-version-header Enable package version on file headers --keep-model-order Keep generated models'' order + --keyword-only Defined models as keyword only (for example + dataclass(kw_only=True)). + --output-datetime-class {datetime,AwareDatetime,NaiveDatetime} + Choose Datetime class between AwareDatetime, NaiveDatetime or + datetime. Each output model has its default mapping, and only + pydantic and dataclass support this override" --reuse-model Reuse models on the field when a module has the model with the same content --target-python-version {3.6,3.7,3.8,3.9,3.10,3.11,3.12} @@ -462,8 +468,6 @@ Model customization: --use-schema-description Use schema description to populate class docstring --use-title-as-name use titles as class names of models - - ----output-datetime-class Choose Datetime class between AwareDatetime, NaiveDatetime or datetime, default: "datetime" Template customization: --aliases ALIASES Alias mapping file diff --git a/datamodel_code_generator/__init__.py b/datamodel_code_generator/__init__.py index dc222bf9c..da52789e6 100644 --- a/datamodel_code_generator/__init__.py +++ b/datamodel_code_generator/__init__.py @@ -301,7 +301,8 @@ def generate( treat_dots_as_module: bool = False, use_exact_imports: bool = False, union_mode: Optional[UnionMode] = None, - output_datetime_class: DataModelType = DatetimeClassType.Datetime, + output_datetime_class: Optional[DatetimeClassType] = None, + keyword_only: bool = False, ) -> None: remote_text_cache: DefaultPutDict[str, str] = DefaultPutDict() if isinstance(input_, str): @@ -476,6 +477,7 @@ def get_header_and_first_line(csv_file: IO[str]) -> Dict[str, Any]: use_exact_imports=use_exact_imports, default_field_extras=default_field_extras, target_datetime_class=output_datetime_class, + keyword_only=keyword_only, **kwargs, ) diff --git a/datamodel_code_generator/__main__.py b/datamodel_code_generator/__main__.py index f612fd7af..3323b9158 100644 --- a/datamodel_code_generator/__main__.py +++ b/datamodel_code_generator/__main__.py @@ -160,7 +160,7 @@ def validate_use_generic_container_types( target_python_version: PythonVersion = values['target_python_version'] if target_python_version == target_python_version.PY_36: raise Error( - f'`--use-generic-container-types` can not be used with `--target-python_version` {target_python_version.PY_36.value}.\n' + f'`--use-generic-container-types` can not be used with `--target-python-version` {target_python_version.PY_36.value}.\n' ' The version will be not supported in a future version' ) return values @@ -184,6 +184,31 @@ def validate_custom_file_header(cls, values: Dict[str, Any]) -> Dict[str, Any]: ) # pragma: no cover return values + @model_validator(mode='after') + def validate_keyword_only(cls, values: Dict[str, Any]) -> Dict[str, Any]: + python_target: PythonVersion = values.get('target_python_version') + if values.get('keyword_only') and not python_target.has_kw_only_dataclass: + raise Error( + f'`--keyword-only` requires `--target-python-version` {PythonVersion.PY_310.value} or higher.' + ) + return values + + @model_validator(mode='after') + def validate_output_datetime_class(cls, values: Dict[str, Any]) -> Dict[str, Any]: + datetime_class_type: Optional[DatetimeClassType] = values.get( + 'output_datetime_class' + ) + if ( + datetime_class_type + and datetime_class_type is not DatetimeClassType.Datetime + and values.get('output_model_type') == DataModelType.DataclassesDataclass + ): + raise Error( + '`--output-datetime-class` only allows "datetime" for ' + f'`--output-model-type` {DataModelType.DataclassesDataclass.value}' + ) + return values + # Pydantic 1.5.1 doesn't support each_item=True correctly @field_validator('http_headers', mode='before') def validate_http_headers(cls, value: Any) -> Optional[List[Tuple[str, str]]]: @@ -314,7 +339,8 @@ def validate_root(cls, values: Any) -> Any: treat_dot_as_module: bool = False use_exact_imports: bool = False union_mode: Optional[UnionMode] = None - output_datetime_class: DatetimeClassType = DatetimeClassType.Datetime + output_datetime_class: Optional[DatetimeClassType] = None + keyword_only: bool = False def merge_args(self, args: Namespace) -> None: set_args = { @@ -515,6 +541,7 @@ def main(args: Optional[Sequence[str]] = None) -> Exit: use_exact_imports=config.use_exact_imports, union_mode=config.union_mode, output_datetime_class=config.output_datetime_class, + keyword_only=config.keyword_only, ) return Exit.OK except InvalidClassNameError as e: diff --git a/datamodel_code_generator/arguments.py b/datamodel_code_generator/arguments.py index f7c416106..3b4ff4212 100644 --- a/datamodel_code_generator/arguments.py +++ b/datamodel_code_generator/arguments.py @@ -150,6 +150,12 @@ def start_section(self, heading: Optional[str]) -> None: action='store_true', default=None, ) +model_options.add_argument( + '--keyword-only', + help='Defined models as keyword only (for example dataclass(kw_only=True)).', + action='store_true', + default=None, +) model_options.add_argument( '--reuse-model', help='Reuse models on the field when a module has the model with the same content', @@ -194,8 +200,10 @@ def start_section(self, heading: Optional[str]) -> None: ) model_options.add_argument( '--output-datetime-class', - help='Choose Datetime class between AwareDatetime, NaiveDatetime or datetime, default: "datetime"', + help='Choose Datetime class between AwareDatetime, NaiveDatetime or datetime. ' + 'Each output model has its default mapping (for example pydantic: datetime, dataclass: str, ...)', choices=[i.value for i in DatetimeClassType], + default=None, ) # ====================================================================================== diff --git a/datamodel_code_generator/format.py b/datamodel_code_generator/format.py index 1435692c1..485e0e258 100644 --- a/datamodel_code_generator/format.py +++ b/datamodel_code_generator/format.py @@ -79,6 +79,10 @@ def has_typed_dict(self) -> bool: def has_typed_dict_non_required(self) -> bool: return self._is_py_311_or_later + @property + def has_kw_only_dataclass(self) -> bool: + return self._is_py_310_or_later + if TYPE_CHECKING: diff --git a/datamodel_code_generator/model/__init__.py b/datamodel_code_generator/model/__init__.py index 19dcd1e0d..79e3d1fb4 100644 --- a/datamodel_code_generator/model/__init__.py +++ b/datamodel_code_generator/model/__init__.py @@ -48,7 +48,7 @@ def get_data_model_types( data_model=dataclass.DataClass, root_model=rootmodel.RootModel, field_model=dataclass.DataModelField, - data_type_manager=DataTypeManager, + data_type_manager=dataclass.DataTypeManager, dump_resolve_reference_action=None, ) elif data_model_type == DataModelType.TypingTypedDict: diff --git a/datamodel_code_generator/model/base.py b/datamodel_code_generator/model/base.py index 3ae6fe2f7..8ffc63236 100644 --- a/datamodel_code_generator/model/base.py +++ b/datamodel_code_generator/model/base.py @@ -293,7 +293,9 @@ def __init__( description: Optional[str] = None, default: Any = UNDEFINED, nullable: bool = False, + keyword_only: bool = False, ) -> None: + self.keyword_only = keyword_only if not self.TEMPLATE_FILE_PATH: raise Exception('TEMPLATE_FILE_PATH is undefined') @@ -452,6 +454,7 @@ def render(self, *, class_name: Optional[str] = None) -> str: base_class=self.base_class, methods=self.methods, description=self.description, + keyword_only=self.keyword_only, **self.extra_template_data, ) return response diff --git a/datamodel_code_generator/model/dataclass.py b/datamodel_code_generator/model/dataclass.py index 6a7f16233..fd492d61e 100644 --- a/datamodel_code_generator/model/dataclass.py +++ b/datamodel_code_generator/model/dataclass.py @@ -1,13 +1,32 @@ from pathlib import Path -from typing import Any, ClassVar, DefaultDict, Dict, List, Optional, Set, Tuple - -from datamodel_code_generator.imports import Import +from typing import ( + Any, + ClassVar, + DefaultDict, + Dict, + List, + Optional, + Sequence, + Set, + Tuple, +) + +from datamodel_code_generator import DatetimeClassType, PythonVersion +from datamodel_code_generator.imports import ( + IMPORT_DATE, + IMPORT_DATETIME, + IMPORT_TIME, + IMPORT_TIMEDELTA, + Import, +) from datamodel_code_generator.model import DataModel, DataModelFieldBase from datamodel_code_generator.model.base import UNDEFINED from datamodel_code_generator.model.imports import IMPORT_DATACLASS, IMPORT_FIELD from datamodel_code_generator.model.pydantic.base_model import Constraints +from datamodel_code_generator.model.types import DataTypeManager as _DataTypeManager +from datamodel_code_generator.model.types import type_map_factory from datamodel_code_generator.reference import Reference -from datamodel_code_generator.types import chain_as_tuple +from datamodel_code_generator.types import DataType, StrictTypes, Types, chain_as_tuple def _has_field_assignment(field: DataModelFieldBase) -> bool: @@ -36,6 +55,7 @@ def __init__( description: Optional[str] = None, default: Any = UNDEFINED, nullable: bool = False, + keyword_only: bool = False, ) -> None: super().__init__( reference=reference, @@ -50,6 +70,7 @@ def __init__( description=description, default=default, nullable=nullable, + keyword_only=keyword_only, ) @@ -118,3 +139,43 @@ def __str__(self) -> str: f'{k}={v if k == "default_factory" else repr(v)}' for k, v in data.items() ] return f'field({", ".join(kwargs)})' + + +class DataTypeManager(_DataTypeManager): + def __init__( + self, + python_version: PythonVersion = PythonVersion.PY_38, + use_standard_collections: bool = False, + use_generic_container_types: bool = False, + strict_types: Optional[Sequence[StrictTypes]] = None, + use_non_positive_negative_number_constrained_types: bool = False, + use_union_operator: bool = False, + use_pendulum: bool = False, + target_datetime_class: DatetimeClassType = DatetimeClassType.Datetime, + ): + super().__init__( + python_version, + use_standard_collections, + use_generic_container_types, + strict_types, + use_non_positive_negative_number_constrained_types, + use_union_operator, + use_pendulum, + target_datetime_class, + ) + + datetime_map = ( + { + Types.time: self.data_type.from_import(IMPORT_TIME), + Types.date: self.data_type.from_import(IMPORT_DATE), + Types.date_time: self.data_type.from_import(IMPORT_DATETIME), + Types.timedelta: self.data_type.from_import(IMPORT_TIMEDELTA), + } + if target_datetime_class is DatetimeClassType.Datetime + else {} + ) + + self.type_map: Dict[Types, DataType] = { + **type_map_factory(self.data_type), + **datetime_map, + } diff --git a/datamodel_code_generator/model/enum.py b/datamodel_code_generator/model/enum.py index c6663d41c..c3275a140 100644 --- a/datamodel_code_generator/model/enum.py +++ b/datamodel_code_generator/model/enum.py @@ -47,6 +47,7 @@ def __init__( type_: Optional[Types] = None, default: Any = UNDEFINED, nullable: bool = False, + keyword_only: bool = False, ): super().__init__( reference=reference, @@ -61,6 +62,7 @@ def __init__( description=description, default=default, nullable=nullable, + keyword_only=keyword_only, ) if not base_classes and type_: diff --git a/datamodel_code_generator/model/msgspec.py b/datamodel_code_generator/model/msgspec.py index 1614fb7ae..6499efc8c 100644 --- a/datamodel_code_generator/model/msgspec.py +++ b/datamodel_code_generator/model/msgspec.py @@ -86,6 +86,7 @@ def __init__( description: Optional[str] = None, default: Any = UNDEFINED, nullable: bool = False, + keyword_only: bool = False, ) -> None: super().__init__( reference=reference, @@ -100,6 +101,7 @@ def __init__( description=description, default=default, nullable=nullable, + keyword_only=keyword_only, ) diff --git a/datamodel_code_generator/model/pydantic/base_model.py b/datamodel_code_generator/model/pydantic/base_model.py index 3c50fcb92..954fb2257 100644 --- a/datamodel_code_generator/model/pydantic/base_model.py +++ b/datamodel_code_generator/model/pydantic/base_model.py @@ -225,6 +225,7 @@ def __init__( description: Optional[str] = None, default: Any = UNDEFINED, nullable: bool = False, + keyword_only: bool = False, ) -> None: methods: List[str] = [field.method for field in fields if field.method] @@ -241,6 +242,7 @@ def __init__( description=description, default=default, nullable=nullable, + keyword_only=keyword_only, ) @cached_property @@ -275,6 +277,7 @@ def __init__( description: Optional[str] = None, default: Any = UNDEFINED, nullable: bool = False, + keyword_only: bool = False, ) -> None: super().__init__( reference=reference, @@ -288,6 +291,7 @@ def __init__( description=description, default=default, nullable=nullable, + keyword_only=keyword_only, ) config_parameters: Dict[str, Any] = {} diff --git a/datamodel_code_generator/model/pydantic/types.py b/datamodel_code_generator/model/pydantic/types.py index 7938ef741..e9fc37f61 100644 --- a/datamodel_code_generator/model/pydantic/types.py +++ b/datamodel_code_generator/model/pydantic/types.py @@ -163,7 +163,7 @@ def __init__( use_non_positive_negative_number_constrained_types: bool = False, use_union_operator: bool = False, use_pendulum: bool = False, - target_datetime_class: DatetimeClassType = DatetimeClassType.Datetime, + target_datetime_class: Optional[DatetimeClassType] = None, ): super().__init__( python_version, diff --git a/datamodel_code_generator/model/pydantic_v2/base_model.py b/datamodel_code_generator/model/pydantic_v2/base_model.py index b40002873..9ab0018d2 100644 --- a/datamodel_code_generator/model/pydantic_v2/base_model.py +++ b/datamodel_code_generator/model/pydantic_v2/base_model.py @@ -183,6 +183,7 @@ def __init__( description: Optional[str] = None, default: Any = UNDEFINED, nullable: bool = False, + keyword_only: bool = False, ) -> None: super().__init__( reference=reference, @@ -196,6 +197,7 @@ def __init__( description=description, default=default, nullable=nullable, + keyword_only=keyword_only, ) config_parameters: Dict[str, Any] = {} diff --git a/datamodel_code_generator/model/pydantic_v2/types.py b/datamodel_code_generator/model/pydantic_v2/types.py index 9d9013489..d0327fcc9 100644 --- a/datamodel_code_generator/model/pydantic_v2/types.py +++ b/datamodel_code_generator/model/pydantic_v2/types.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import ClassVar, Dict, Sequence, Type +from typing import ClassVar, Dict, Optional, Sequence, Type from datamodel_code_generator.format import DatetimeClassType from datamodel_code_generator.model.pydantic import DataTypeManager as _DataTypeManager @@ -20,7 +20,7 @@ def type_map_factory( data_type: Type[DataType], strict_types: Sequence[StrictTypes], pattern_key: str, - target_datetime_class: DatetimeClassType, + target_datetime_class: Optional[DatetimeClassType] = None, ) -> Dict[Types, DataType]: result = { **super().type_map_factory( diff --git a/datamodel_code_generator/model/scalar.py b/datamodel_code_generator/model/scalar.py index 5c4d58264..8894d7235 100644 --- a/datamodel_code_generator/model/scalar.py +++ b/datamodel_code_generator/model/scalar.py @@ -46,6 +46,7 @@ def __init__( description: Optional[str] = None, default: Any = UNDEFINED, nullable: bool = False, + keyword_only: bool = False, ): extra_template_data = extra_template_data or defaultdict(dict) @@ -75,4 +76,5 @@ def __init__( description=description, default=default, nullable=nullable, + keyword_only=keyword_only, ) diff --git a/datamodel_code_generator/model/template/dataclass.jinja2 b/datamodel_code_generator/model/template/dataclass.jinja2 index 7ad16c65d..632026b59 100644 --- a/datamodel_code_generator/model/template/dataclass.jinja2 +++ b/datamodel_code_generator/model/template/dataclass.jinja2 @@ -1,7 +1,7 @@ {% for decorator in decorators -%} {{ decorator }} {% endfor -%} -@dataclass +@dataclass{%- if keyword_only -%}(kw_only=True){%- endif %} {%- if base_class %} class {{ class_name }}({{ base_class }}): {%- else %} diff --git a/datamodel_code_generator/model/typed_dict.py b/datamodel_code_generator/model/typed_dict.py index af2557b58..25616c5e2 100644 --- a/datamodel_code_generator/model/typed_dict.py +++ b/datamodel_code_generator/model/typed_dict.py @@ -63,6 +63,7 @@ def __init__( description: Optional[str] = None, default: Any = UNDEFINED, nullable: bool = False, + keyword_only: bool = False, ) -> None: super().__init__( reference=reference, @@ -77,6 +78,7 @@ def __init__( description=description, default=default, nullable=nullable, + keyword_only=keyword_only, ) @property diff --git a/datamodel_code_generator/model/union.py b/datamodel_code_generator/model/union.py index 2148f4ce6..6ba2fb713 100644 --- a/datamodel_code_generator/model/union.py +++ b/datamodel_code_generator/model/union.py @@ -32,6 +32,7 @@ def __init__( description: Optional[str] = None, default: Any = UNDEFINED, nullable: bool = False, + keyword_only: bool = False, ): super().__init__( reference=reference, @@ -46,4 +47,5 @@ def __init__( description=description, default=default, nullable=nullable, + keyword_only=keyword_only, ) diff --git a/datamodel_code_generator/parser/base.py b/datamodel_code_generator/parser/base.py index 7aaf79477..5fe58e23e 100644 --- a/datamodel_code_generator/parser/base.py +++ b/datamodel_code_generator/parser/base.py @@ -38,6 +38,7 @@ Import, Imports, ) +from datamodel_code_generator.model import dataclass as dataclass_model from datamodel_code_generator.model import pydantic as pydantic_model from datamodel_code_generator.model import pydantic_v2 as pydantic_model_v2 from datamodel_code_generator.model.base import ( @@ -409,7 +410,9 @@ def __init__( use_exact_imports: bool = False, default_field_extras: Optional[Dict[str, Any]] = None, target_datetime_class: DatetimeClassType = DatetimeClassType.Datetime, + keyword_only: bool = False, ) -> None: + self.keyword_only = keyword_only self.data_type_manager: DataTypeManager = data_type_manager_type( python_version=target_python_version, use_standard_collections=use_standard_collections, @@ -802,7 +805,11 @@ def __apply_discriminator_type( discriminator_model = data_type.reference.source if not isinstance( # pragma: no cover discriminator_model, - (pydantic_model.BaseModel, pydantic_model_v2.BaseModel), + ( + pydantic_model.BaseModel, + pydantic_model_v2.BaseModel, + dataclass_model.DataClass, + ), ): continue # pragma: no cover type_names = [] diff --git a/datamodel_code_generator/parser/graphql.py b/datamodel_code_generator/parser/graphql.py index 470c3c69e..1f620ad98 100644 --- a/datamodel_code_generator/parser/graphql.py +++ b/datamodel_code_generator/parser/graphql.py @@ -159,6 +159,7 @@ def __init__( use_exact_imports: bool = False, default_field_extras: Optional[Dict[str, Any]] = None, target_datetime_class: DatetimeClassType = DatetimeClassType.Datetime, + keyword_only: bool = False, ) -> None: super().__init__( source=source, @@ -230,6 +231,7 @@ def __init__( use_exact_imports=use_exact_imports, default_field_extras=default_field_extras, target_datetime_class=target_datetime_class, + keyword_only=keyword_only, ) self.data_model_scalar_type = data_model_scalar_type @@ -463,6 +465,7 @@ def parse_object_like( extra_template_data=self.extra_template_data, path=self.current_source_path, description=obj.description, + keyword_only=self.keyword_only, ) self.results.append(data_model_type) diff --git a/datamodel_code_generator/parser/jsonschema.py b/datamodel_code_generator/parser/jsonschema.py index c1af379dc..60d31ce58 100644 --- a/datamodel_code_generator/parser/jsonschema.py +++ b/datamodel_code_generator/parser/jsonschema.py @@ -447,6 +447,7 @@ def __init__( use_exact_imports: bool = False, default_field_extras: Optional[Dict[str, Any]] = None, target_datetime_class: DatetimeClassType = DatetimeClassType.Datetime, + keyword_only: bool = False, ) -> None: super().__init__( source=source, @@ -518,6 +519,7 @@ def __init__( use_exact_imports=use_exact_imports, default_field_extras=default_field_extras, target_datetime_class=target_datetime_class, + keyword_only=keyword_only, ) self.remote_object_cache: DefaultPutDict[str, Dict[str, Any]] = DefaultPutDict() @@ -808,6 +810,7 @@ def _parse_object_common_part( extra_template_data=self.extra_template_data, path=self.current_source_path, description=obj.description if self.use_schema_description else None, + keyword_only=self.keyword_only, ) self.results.append(data_model_type) @@ -1051,6 +1054,7 @@ def parse_object( path=self.current_source_path, description=obj.description if self.use_schema_description else None, nullable=obj.type_has_null, + keyword_only=self.keyword_only, ) self.results.append(data_model_type) return self.data_type(reference=reference) diff --git a/datamodel_code_generator/parser/openapi.py b/datamodel_code_generator/parser/openapi.py index b34bb018d..c4874768f 100644 --- a/datamodel_code_generator/parser/openapi.py +++ b/datamodel_code_generator/parser/openapi.py @@ -227,6 +227,7 @@ def __init__( use_exact_imports: bool = False, default_field_extras: Optional[Dict[str, Any]] = None, target_datetime_class: DatetimeClassType = DatetimeClassType.Datetime, + keyword_only: bool = False, ): super().__init__( source=source, @@ -298,6 +299,7 @@ def __init__( use_exact_imports=use_exact_imports, default_field_extras=default_field_extras, target_datetime_class=target_datetime_class, + keyword_only=keyword_only, ) self.open_api_scopes: List[OpenAPIScope] = openapi_scopes or [ OpenAPIScope.Schemas @@ -511,6 +513,7 @@ def parse_all_parameters( fields=fields, reference=reference, custom_base_class=self.base_class, + keyword_only=self.keyword_only, ) ) diff --git a/datamodel_code_generator/types.py b/datamodel_code_generator/types.py index 498bbc22f..6df57e7d1 100644 --- a/datamodel_code_generator/types.py +++ b/datamodel_code_generator/types.py @@ -575,7 +575,7 @@ def __init__( use_non_positive_negative_number_constrained_types: bool = False, use_union_operator: bool = False, use_pendulum: bool = False, - target_datetime_class: DatetimeClassType = DatetimeClassType.Datetime, + target_datetime_class: Optional[DatetimeClassType] = None, ) -> None: self.python_version = python_version self.use_standard_collections: bool = use_standard_collections diff --git a/docs/index.md b/docs/index.md index 2e5e4986f..485e5c95a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -444,6 +444,12 @@ Model customization: --enable-version-header Enable package version on file headers --keep-model-order Keep generated models'' order + --keyword-only Defined models as keyword only (for example + dataclass(kw_only=True)). + --output-datetime-class {datetime,AwareDatetime,NaiveDatetime} + Choose Datetime class between AwareDatetime, NaiveDatetime or + datetime. Each output model has its default mapping, and only + pydantic and dataclass support this override" --reuse-model Reuse models on the field when a module has the model with the same content --target-python-version {3.6,3.7,3.8,3.9,3.10,3.11,3.12} diff --git a/tests/data/expected/main/openapi/dataclass_keyword_only.py b/tests/data/expected/main/openapi/dataclass_keyword_only.py new file mode 100644 index 000000000..a3d089302 --- /dev/null +++ b/tests/data/expected/main/openapi/dataclass_keyword_only.py @@ -0,0 +1,21 @@ +# generated by datamodel-codegen: +# filename: inheritance.yaml +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + + +@dataclass(kw_only=True) +class Base: + id: str + createdAt: Optional[str] = None + version: Optional[float] = 1 + + +@dataclass(kw_only=True) +class Child(Base): + title: str + url: Optional[str] = 'https://example.com' diff --git a/tests/data/expected/main/openapi/datetime_dataclass.py b/tests/data/expected/main/openapi/datetime_dataclass.py new file mode 100644 index 000000000..e6b778795 --- /dev/null +++ b/tests/data/expected/main/openapi/datetime_dataclass.py @@ -0,0 +1,13 @@ +# generated by datamodel-codegen: +# filename: datetime.yaml +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class InventoryItem: + releaseDate: datetime diff --git a/tests/data/expected/main/openapi/discriminator/dataclass_enum_one_literal_as_default.py b/tests/data/expected/main/openapi/discriminator/dataclass_enum_one_literal_as_default.py new file mode 100644 index 000000000..4234485a4 --- /dev/null +++ b/tests/data/expected/main/openapi/discriminator/dataclass_enum_one_literal_as_default.py @@ -0,0 +1,33 @@ +# generated by datamodel-codegen: +# filename: discriminator_enum.yaml +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Literal, Union + + +class RequestVersionEnum(Enum): + v1 = 'v1' + v2 = 'v2' + + +@dataclass +class RequestBase: + version: RequestVersionEnum + + +@dataclass +class RequestV1(RequestBase): + request_id: str + version: Literal['v1'] = 'v1' + + +@dataclass +class RequestV2(RequestBase): + version: Literal['v2'] = 'v2' + + +Request = Union[RequestV1, RequestV2] diff --git a/tests/data/openapi/inheritance.yaml b/tests/data/openapi/inheritance.yaml new file mode 100644 index 000000000..eb87dcddd --- /dev/null +++ b/tests/data/openapi/inheritance.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +components: + schemas: + Base: + required: + - id + properties: + id: + type: string + format: uuid + createdAt: + type: string + format: date-time + version: + type: number + default: 1 + Child: + allOf: + - $ref: "#/components/schemas/Base" + - properties: + url: + type: string + format: uri + default: "https://example.com" + title: + type: string + required: + - title \ No newline at end of file diff --git a/tests/main/openapi/test_main_openapi.py b/tests/main/openapi/test_main_openapi.py index 1300c8c54..cad26e590 100644 --- a/tests/main/openapi/test_main_openapi.py +++ b/tests/main/openapi/test_main_openapi.py @@ -1049,7 +1049,7 @@ def test_main_use_generic_container_types_py36(capsys) -> None: assert return_code == Exit.ERROR assert ( captured.err == '`--use-generic-container-types` can not be used with ' - '`--target-python_version` 3.6.\n ' + '`--target-python-version` 3.6.\n ' 'The version will be not supported in a future version\n' ) @@ -1075,19 +1075,14 @@ def test_main_original_field_name_delimiter_without_snake_case_field(capsys) -> @freeze_time('2019-07-26') @pytest.mark.parametrize( - 'output_model,expected_output', + 'output_model,expected_output,date_type', [ - ( - 'pydantic.BaseModel', - 'datetime.py', - ), - ( - 'pydantic_v2.BaseModel', - 'datetime_pydantic_v2.py', - ), + ('pydantic.BaseModel', 'datetime.py', 'AwareDatetime'), + ('pydantic_v2.BaseModel', 'datetime_pydantic_v2.py', 'AwareDatetime'), + ('dataclasses.dataclass', 'datetime_dataclass.py', 'datetime'), ], ) -def test_main_openapi_aware_datetime(output_model, expected_output): +def test_main_openapi_aware_datetime(output_model, expected_output, date_type): with TemporaryDirectory() as output_dir: output_file: Path = Path(output_dir) / 'output.py' return_code: Exit = main( @@ -1099,7 +1094,7 @@ def test_main_openapi_aware_datetime(output_model, expected_output): '--input-file-type', 'openapi', '--output-datetime-class', - 'AwareDatetime', + date_type, '--output-model', output_model, ] @@ -2864,3 +2859,108 @@ def test_main_openapi_discriminator_one_literal_as_default(): / 'enum_one_literal_as_default.py' ).read_text() ) + + +@freeze_time('2019-07-26') +def test_main_openapi_discriminator_one_literal_as_default_dataclass(): + with TemporaryDirectory() as output_dir: + output_file: Path = Path(output_dir) / 'output.py' + return_code: Exit = main( + [ + '--input', + str(OPEN_API_DATA_PATH / 'discriminator_enum.yaml'), + '--output', + str(output_file), + '--input-file-type', + 'openapi', + '--output-model-type', + 'dataclasses.dataclass', + '--use-one-literal-as-default', + ] + ) + assert return_code == Exit.OK + assert ( + output_file.read_text() + == ( + EXPECTED_OPENAPI_PATH + / 'discriminator' + / 'dataclass_enum_one_literal_as_default.py' + ).read_text() + ) + + +@freeze_time('2019-07-26') +@pytest.mark.skipif( + black.__version__.split('.')[0] == '19', + reason="Installed black doesn't support the old style", +) +def test_main_openapi_keyword_only_dataclass(): + with TemporaryDirectory() as output_dir: + output_file: Path = Path(output_dir) / 'output.py' + return_code: Exit = main( + [ + '--input', + str(OPEN_API_DATA_PATH / 'inheritance.yaml'), + '--output', + str(output_file), + '--input-file-type', + 'openapi', + '--output-model-type', + 'dataclasses.dataclass', + '--keyword-only', + '--target-python-version', + '3.10', + ] + ) + assert return_code == Exit.OK + assert ( + output_file.read_text() + == (EXPECTED_OPENAPI_PATH / 'dataclass_keyword_only.py').read_text() + ) + + +@freeze_time('2019-07-26') +def test_main_openapi_keyword_only_dataclass_with_python_3_9(capsys: CaptureFixture): + return_code = main( + [ + '--input', + str(OPEN_API_DATA_PATH / 'inheritance.yaml'), + '--input-file-type', + 'openapi', + '--output-model-type', + 'dataclasses.dataclass', + '--keyword-only', + '--target-python-version', + '3.9', + ] + ) + assert return_code == Exit.ERROR + captured = capsys.readouterr() + assert captured.out == '' + assert ( + captured.err + == '`--keyword-only` requires `--target-python-version` 3.10 or higher.\n' + ) + + +@freeze_time('2019-07-26') +def test_main_openapi_dataclass_with_NaiveDatetime(capsys: CaptureFixture): + return_code = main( + [ + '--input', + str(OPEN_API_DATA_PATH / 'inheritance.yaml'), + '--input-file-type', + 'openapi', + '--output-model-type', + 'dataclasses.dataclass', + '--output-datetime-class', + 'NaiveDatetime', + ] + ) + assert return_code == Exit.ERROR + captured = capsys.readouterr() + assert captured.out == '' + assert ( + captured.err + == '`--output-datetime-class` only allows "datetime" for `--output-model-type` dataclasses.dataclass\n' + )