Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/datamodel_code_generator/parser/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,7 @@ def get_ancestors(
continue
ancestors.add(parent_path)
parent_model = sorted_data_models.get(parent_path)
if not parent_model:
if not parent_model: # pragma: no cover
continue
to_visit.extend(
bc.reference.path
Expand Down
144 changes: 143 additions & 1 deletion src/datamodel_code_generator/parser/jsonschema.py
Original file line number Diff line number Diff line change
Expand Up @@ -993,12 +993,139 @@ def _should_generate_separate_models(

def _should_generate_base_model(self, *, generates_separate_models: bool = False) -> bool:
"""Determine if Base model should be generated."""
if getattr(self, "_force_base_model_generation", False):
return True
if self.read_only_write_only_model_type is None:
return True
if self.read_only_write_only_model_type == ReadOnlyWriteOnlyModelType.All:
return True
return not generates_separate_models

def _ref_schema_generates_variant(self, ref_path: str, suffix: str) -> bool:
"""Check if a referenced schema will generate a specific variant (Request or Response).

For Request variant: schema must have readOnly fields AND at least one non-readOnly field.
For Response variant: schema must have writeOnly fields AND at least one non-writeOnly field.
"""
try:
ref_schema = self._load_ref_schema_object(ref_path)
except Exception: # noqa: BLE001 # pragma: no cover
return False

has_read_only = False
has_write_only = False
has_non_read_only = False
has_non_write_only = False

for prop in (ref_schema.properties or {}).values():
if not isinstance(prop, JsonSchemaObject): # pragma: no cover
continue
is_read_only = self._resolve_field_flag(prop, "readOnly")
is_write_only = self._resolve_field_flag(prop, "writeOnly")
if is_read_only:
has_read_only = True
else:
has_non_read_only = True
if is_write_only:
has_write_only = True
else:
has_non_write_only = True

if suffix == "Request":
return has_read_only and has_non_read_only
if suffix == "Response":
return has_write_only and has_non_write_only
return False # pragma: no cover

def _ref_schema_has_model(self, ref_path: str) -> bool:
"""Check if a referenced schema will have a model (base or variant) generated.

Returns False if the schema has only readOnly or only writeOnly fields in request-response mode,
which would result in no model being generated at all.
"""
try:
ref_schema = self._load_ref_schema_object(ref_path)
except Exception: # noqa: BLE001 # pragma: no cover
return True

has_read_only = False
has_write_only = False

for prop in (ref_schema.properties or {}).values():
if not isinstance(prop, JsonSchemaObject): # pragma: no cover
continue
is_read_only = self._resolve_field_flag(prop, "readOnly")
is_write_only = self._resolve_field_flag(prop, "writeOnly")
if is_read_only:
has_read_only = True
elif is_write_only:
has_write_only = True
else: # pragma: no cover
return True

if has_read_only and not has_write_only:
return False
return not (has_write_only and not has_read_only)

def _update_data_type_ref_for_variant(self, data_type: DataType, suffix: str) -> None:
"""Recursively update data type references to point to variant models."""
if data_type.reference:
ref_path = data_type.reference.path
if self._ref_schema_generates_variant(ref_path, suffix):
path_parts = ref_path.split("/")
base_name = path_parts[-1]
variant_name = f"{base_name}{suffix}"
unique_name = self.model_resolver.get_class_name(variant_name, unique=False).name
path_parts[-1] = unique_name
variant_ref = self.model_resolver.add(path_parts, unique_name, class_name=True, unique=False)
data_type.reference = variant_ref
elif not self._ref_schema_has_model(ref_path): # pragma: no branch
if not hasattr(self, "_force_base_model_refs"):
self._force_base_model_refs: set[str] = set()
self._force_base_model_refs.add(ref_path)
for nested_dt in data_type.data_types:
self._update_data_type_ref_for_variant(nested_dt, suffix)

def _update_field_refs_for_variant(
self, model_fields: list[DataModelFieldBase], suffix: str
) -> list[DataModelFieldBase]:
"""Update field references in model_fields to point to variant models.

For Request models, refs should point to Request variants.
For Response models, refs should point to Response variants.
"""
if self.read_only_write_only_model_type != ReadOnlyWriteOnlyModelType.RequestResponse:
return model_fields
for field in model_fields:
if field.data_type: # pragma: no branch
self._update_data_type_ref_for_variant(field.data_type, suffix)
return model_fields

def _generate_forced_base_models(self) -> None:
"""Generate base models for schemas that are referenced as property types but lack models."""
if not hasattr(self, "_force_base_model_refs"):
return
if not self._force_base_model_refs: # pragma: no cover
return

existing_model_paths = {result.path for result in self.results}

for ref_path in sorted(self._force_base_model_refs):
if ref_path in existing_model_paths: # pragma: no cover
continue
try:
ref_schema = self._load_ref_schema_object(ref_path)
path_parts = ref_path.split("/")
schema_name = path_parts[-1]

self._force_base_model_generation = True
try:
self.parse_obj(schema_name, ref_schema, path_parts)
finally:
self._force_base_model_generation = False
except Exception: # noqa: BLE001, S110 # pragma: no cover
pass

def _create_variant_model( # noqa: PLR0913, PLR0917
self,
path: list[str],
Expand All @@ -1011,6 +1138,8 @@ def _create_variant_model( # noqa: PLR0913, PLR0917
"""Create a Request or Response model variant."""
if not model_fields:
return
# Update field refs to point to variant models when in request-response mode
self._update_field_refs_for_variant(model_fields, suffix)
variant_name = f"{base_name}{suffix}"
unique_name = self.model_resolver.get_class_name(variant_name, unique=True).name
model_path = [*path[:-1], unique_name]
Expand Down Expand Up @@ -3645,7 +3774,7 @@ def parse_raw_obj(
obj = self._validate_schema_object(raw, path)
self.parse_obj(name, obj, path)

def _check_version_specific_features(
def _check_version_specific_features( # noqa: PLR0912
self,
raw: dict[str, YamlValue] | YamlValue,
path: list[str],
Expand Down Expand Up @@ -3709,6 +3838,18 @@ def _check_version_specific_features(
stacklevel=3,
)

if not self.schema_features.read_only_write_only:
if raw.get("readOnly") is True:
warn(
f"readOnly is not supported in this schema version (Draft 7+ only). Schema path: {'/'.join(path)}",
stacklevel=3,
)
if raw.get("writeOnly") is True:
warn(
f"writeOnly is not supported in this schema version (Draft 7+ only). Schema path: {'/'.join(path)}",
stacklevel=3,
)

def _check_array_version_features(
self,
obj: JsonSchemaObject,
Expand Down Expand Up @@ -3846,6 +3987,7 @@ def parse_raw(self) -> None:
self._parse_file(self.raw_obj, obj_name, path_parts)

self._resolve_unparsed_json_pointer()
self._generate_forced_base_models()

def _resolve_unparsed_json_pointer(self) -> None:
"""Resolve any remaining unparsed JSON pointer references recursively."""
Expand Down
1 change: 1 addition & 0 deletions src/datamodel_code_generator/parser/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,7 @@ def parse_raw(self) -> None: # noqa: PLR0912
)

self._resolve_unparsed_json_pointer()
self._generate_forced_base_models()

def _collect_discriminator_schemas(self) -> None:
"""Collect schemas with discriminators but no oneOf/anyOf, and find their subtypes."""
Expand Down
23 changes: 20 additions & 3 deletions src/datamodel_code_generator/parser/schema_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class JsonSchemaFeatures:
id_field: The field name for schema ID ("id" for Draft 4, "$id" for Draft 6+).
definitions_key: The key for definitions ("definitions" or "$defs").
exclusive_as_number: Draft 6+ uses numeric exclusiveMin/Max (Draft 4 uses boolean).
read_only_write_only: Draft 7+ supports readOnly/writeOnly keywords.
"""

null_in_type_array: bool
Expand All @@ -40,6 +41,7 @@ class JsonSchemaFeatures:
id_field: str
definitions_key: str
exclusive_as_number: bool
read_only_write_only: bool

@classmethod
def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures:
Expand All @@ -54,8 +56,9 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures:
id_field="id",
definitions_key="definitions",
exclusive_as_number=False,
read_only_write_only=False,
)
case JsonSchemaVersion.Draft6 | JsonSchemaVersion.Draft7:
case JsonSchemaVersion.Draft6:
return cls(
null_in_type_array=False,
defs_not_definitions=False,
Expand All @@ -64,6 +67,18 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures:
id_field="$id",
definitions_key="definitions",
exclusive_as_number=True,
read_only_write_only=False,
)
case JsonSchemaVersion.Draft7:
return cls(
null_in_type_array=False,
defs_not_definitions=False,
prefix_items=False,
boolean_schemas=True,
id_field="$id",
definitions_key="definitions",
exclusive_as_number=True,
read_only_write_only=True,
)
case JsonSchemaVersion.Draft201909:
return cls(
Expand All @@ -74,6 +89,7 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures:
id_field="$id",
definitions_key="$defs",
exclusive_as_number=True,
read_only_write_only=True,
)
case _:
return cls(
Expand All @@ -84,6 +100,7 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures:
id_field="$id",
definitions_key="$defs",
exclusive_as_number=True,
read_only_write_only=True,
)


Expand All @@ -106,8 +123,6 @@ def from_openapi_version(cls, version: OpenAPIVersion) -> OpenAPISchemaFeatures:
"""Create OpenAPISchemaFeatures from an OpenAPI version."""
match version:
case OpenAPIVersion.V30:
# OpenAPI 3.0 schema dialect inherits JSON Schema Draft 4 semantics
# where exclusiveMinimum/Maximum are boolean values
return cls(
null_in_type_array=False,
defs_not_definitions=False,
Expand All @@ -116,6 +131,7 @@ def from_openapi_version(cls, version: OpenAPIVersion) -> OpenAPISchemaFeatures:
id_field="$id",
definitions_key="definitions",
exclusive_as_number=False,
read_only_write_only=True,
nullable_keyword=True,
discriminator_support=True,
)
Expand All @@ -128,6 +144,7 @@ def from_openapi_version(cls, version: OpenAPIVersion) -> OpenAPISchemaFeatures:
id_field="$id",
definitions_key="$defs",
exclusive_as_number=True,
read_only_write_only=True,
nullable_keyword=False,
discriminator_support=True,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# generated by datamodel-codegen:
# filename: read_only_write_only_ref_request_response.yaml
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from pydantic import BaseModel


class ChildMixedRequest(BaseModel):
name: str | None = None


class ParentWithMixedChildRequest(BaseModel):
child: ChildMixedRequest | None = None


class ParentWithChildListRequest(BaseModel):
children: list[ChildMixedRequest] | None = None


class ChildOnlyReadOnly(BaseModel):
id: int | None = None


class ChildOnlyWriteOnly(BaseModel):
secret: str | None = None


class ParentWithOnlyReadOnlyChildRequest(BaseModel):
child: ChildOnlyReadOnly | None = None


class ParentWithOnlyWriteOnlyChildResponse(BaseModel):
child: ChildOnlyWriteOnly | None = None
68 changes: 68 additions & 0 deletions tests/data/openapi/read_only_write_only_ref_request_response.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
openapi: "3.0.0"
info:
title: Read Only Write Only Ref Request Response Test API
version: "1.0"
paths: {}
components:
schemas:
# Child with only readOnly fields - should generate base model when referenced directly
ChildOnlyReadOnly:
type: object
properties:
id:
readOnly: true
type: integer
# Child with only writeOnly fields - should generate base model when referenced directly
ChildOnlyWriteOnly:
type: object
properties:
secret:
writeOnly: true
type: string
# Child with mixed fields - should generate ChildRequest variant
ChildMixed:
type: object
properties:
id:
readOnly: true
type: integer
name:
type: string
# Parent referencing child with only readOnly fields
ParentWithOnlyReadOnlyChild:
type: object
properties:
child:
$ref: "#/components/schemas/ChildOnlyReadOnly"
name:
readOnly: true
type: string
# Parent referencing child with only writeOnly fields
ParentWithOnlyWriteOnlyChild:
type: object
properties:
child:
$ref: "#/components/schemas/ChildOnlyWriteOnly"
secret:
writeOnly: true
type: string
# Parent referencing child with mixed fields
ParentWithMixedChild:
type: object
properties:
child:
$ref: "#/components/schemas/ChildMixed"
name:
readOnly: true
type: string
# Parent with list of children (nested type reference)
ParentWithChildList:
type: object
properties:
children:
type: array
items:
$ref: "#/components/schemas/ChildMixed"
name:
readOnly: true
type: string
Loading
Loading