Files
privateGPT/private_gpt/chat/schema_models.py
2026-07-16 13:36:11 +02:00

467 lines
17 KiB
Python

from collections.abc import Callable
from keyword import iskeyword, issoftkeyword
from types import UnionType
from typing import Any, Literal, Self
from pydantic import BaseModel, Field, create_model
from pydantic.config import ConfigDict, ExtraValues
from pydantic.json_schema import (
DEFAULT_REF_TEMPLATE,
GenerateJsonSchema,
JsonSchemaMode,
)
from pydantic.main import IncEx
RESERVED_NAMES = frozenset(
[
"model_config",
"model_fields",
"model_computed_fields",
"model_extra",
"model_fields_set",
"model_construct",
"model_copy",
"model_dump",
"model_dump_json",
"model_json_schema",
"model_validate",
"model_validate_json",
"model_validate_strings",
"model_rebuild",
"dict",
"json",
"copy",
"parse_obj",
"parse_raw",
"parse_file",
"from_orm",
"schema",
"schema_json",
"construct",
"validate",
"update_forward_refs",
]
)
def _sanitize_field_name(field_name: str) -> str:
def _handle_empty_field_name(field_name: str) -> str:
"""Handle empty or None field names - fallback to 'field'."""
return field_name if field_name.strip() else "field"
def _handle_leading_underscores(field_name: str) -> str:
"""Handle leading underscores - Pydantic treats them as private fields."""
return field_name.lstrip("_") or "field"
def _handle_dunder_names(field_name: str) -> str:
"""Handle dunder names (__name__) - Python magic methods conflict."""
if field_name.startswith("__") and field_name.endswith("__"):
return f"dunder_{field_name[2:-2]}_field"
return field_name
def _handle_python_keywords(field_name: str) -> str:
"""Handle Python keywords and soft keywords - reserved language constructs."""
if iskeyword(field_name) or issoftkeyword(field_name):
return f"{field_name}_"
return field_name
def _handle_reserved_basemodel_attributes(field_name: str) -> str:
"""Handle reserved BaseModel attributes - conflicts with Pydantic internals."""
if field_name.lower() in RESERVED_NAMES:
return f"{field_name}_field"
return field_name
# Apply all sanitization steps in order
sanitized = _handle_empty_field_name(field_name)
sanitized = _handle_leading_underscores(sanitized)
sanitized = _handle_dunder_names(sanitized)
sanitized = _handle_python_keywords(sanitized)
sanitized = _handle_reserved_basemodel_attributes(sanitized)
return sanitized
def _validate_json_schema_item(schema: dict[str, Any], strict: bool = True) -> None:
"""Validate that the schema is a valid JSON Schema object."""
if not isinstance(schema, dict):
raise ValueError("Schema must be a dictionary representing JSON Schema")
if schema == {}:
return
# Handle combinators first
if "oneOf" in schema:
if not isinstance(schema["oneOf"], list):
raise ValueError("'oneOf' must be an array of schemas")
for sub_schema in schema["oneOf"]:
_validate_json_schema_item(sub_schema, strict=False)
return # oneOf schemas don't need type field
if "anyOf" in schema:
if not isinstance(schema["anyOf"], list):
raise ValueError("'anyOf' must be an array of schemas")
for sub_schema in schema["anyOf"]:
_validate_json_schema_item(sub_schema, strict=False)
return # anyOf schemas don't need type field
if "allOf" in schema:
if not isinstance(schema["allOf"], list):
raise ValueError("'allOf' must be an array of schemas")
for sub_schema in schema["allOf"]:
_validate_json_schema_item(sub_schema, strict=False)
return # allOf schemas don't need type field
# Regular schema validation
if "type" not in schema:
if strict:
raise ValueError("Schema must define a 'type' field")
else:
return # Non-strict mode allows missing type
if schema["type"] == "array" and "items" not in schema:
raise ValueError("Array schemas must define 'items'")
if schema["type"] == "array" and not isinstance(schema.get("items"), dict):
raise ValueError("Array 'items' must be a dictionary representing JSON Schema")
# Recursively validate array items
if schema["type"] == "array":
_validate_json_schema_item(schema.get("items", {}))
def _validate_json_schema(schema: dict[str, Any]) -> None:
"""Validate that the schema is a valid JSON Schema object."""
if not isinstance(schema, dict):
raise ValueError("Schema must be a dictionary representing JSON Schema")
if not schema:
return
if "type" not in schema:
raise ValueError("Schema must define a 'type' field")
if schema["type"] == "object":
if "properties" not in schema:
raise ValueError("Object schemas must define 'properties'")
for _, prop_schema in schema.get("properties", {}).items():
_validate_json_schema_item(prop_schema, strict=False)
elif schema["type"] == "array":
_validate_json_schema_item(schema.get("items", {}))
else:
pass
def _resolve_field_name_collisions(field_name: str, used_names: set[str]) -> str:
"""Resolve field name collisions by appending counter."""
if field_name not in used_names:
return field_name
counter = 1
while f"{field_name}_{counter}" in used_names:
counter += 1
return f"{field_name}_{counter}"
def _resolve_json_type(field_schema: dict[str, Any]) -> type[Any] | UnionType:
"""Resolve JSON schema type to Python type."""
json_type_mapping = {
"string": str,
"number": float,
"integer": int,
"boolean": bool,
"array": list,
"object": dict,
"null": type(None),
}
# Handle anyOf - create a Union type
if "anyOf" in field_schema:
types = []
for sub_schema in field_schema["anyOf"]:
resolved_type = _resolve_json_type(sub_schema)
types.append(resolved_type)
if len(types) == 1:
return types[0]
# Create Union type
from typing import Union
return Union[tuple(types)] # type: ignore # noqa: UP007
# Handle oneOf - similar to anyOf for typing purposes
if "oneOf" in field_schema:
types = []
for sub_schema in field_schema["oneOf"]:
resolved_type = _resolve_json_type(sub_schema)
types.append(resolved_type)
if len(types) == 1:
return types[0]
from typing import Union
return Union[tuple(types)] # type: ignore # noqa: UP007
# Handle allOf - for typing, we'll use the first type or Any
# (proper allOf merging would require schema composition)
if "allOf" in field_schema:
for sub_schema in field_schema["allOf"]:
if "type" in sub_schema:
return _resolve_json_type(sub_schema)
return Any
json_type = field_schema.get("type", "string")
json_type = json_type[0] if isinstance(json_type, list) else json_type
if json_type == "array":
items_schema = field_schema.get("items", {})
if items_schema:
item_type = _resolve_json_type(items_schema)
return list[item_type] # type: ignore
return list[Any]
return json_type_mapping.get(json_type, str)
def _create_array_model(
schema: dict[str, Any], model_name: str = "DynamicArrayModel"
) -> type[BaseModel]:
"""Create a model for root-level array schemas with proper item handling."""
items_schema = schema.get("items", {})
# If items are objects, create a nested model for proper field sanitization
if items_schema.get("type") == "object" or "properties" in items_schema:
item_model = create_model_from_json_schema(items_schema, f"{model_name}Item")
list_type = list[item_model] # type: ignore
else:
# For primitive types
item_type = _resolve_json_type(items_schema) if items_schema else Any
list_type = list[item_type] # type: ignore
class ArrayModel(BaseModel):
items: list_type = Field(default_factory=list)
def model_dump(
self,
*,
mode: Literal["json", "python"] | str = "python",
include: IncEx | None = None,
exclude: IncEx | None = None,
context: Any | None = None,
by_alias: bool | None = True,
exclude_unset: bool = False,
exclude_defaults: bool = False,
exclude_none: bool = False,
exclude_computed_fields: bool = False,
round_trip: bool = False,
warnings: bool | Literal["none", "warn", "error"] = True,
fallback: Callable[[Any], Any] | None = None,
serialize_as_any: bool = False,
) -> list[Any]:
"""Return the array directly, not wrapped in dict."""
dumped_items = []
for item in self.items:
if isinstance(item, BaseModel):
dumped_items.append(
item.model_dump(
mode=mode,
include=include,
exclude=exclude,
context=context,
by_alias=by_alias,
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
round_trip=round_trip,
warnings=warnings,
serialize_as_any=serialize_as_any,
)
)
else:
dumped_items.append(item)
return dumped_items
def model_dump_json(
self,
*,
indent: int | None = None,
ensure_ascii: bool = False,
include: IncEx | None = None,
exclude: IncEx | None = None,
context: Any | None = None,
by_alias: bool | None = None,
exclude_unset: bool = False,
exclude_defaults: bool = False,
exclude_none: bool = False,
exclude_computed_fields: bool = False,
round_trip: bool = False,
warnings: bool | Literal["none", "warn", "error"] = True,
fallback: Callable[[Any], Any] | None = None,
serialize_as_any: bool = False,
polymorphic_serialization: bool | None = None,
) -> str:
"""Return JSON array directly, not wrapped in object."""
import json
dumped_items = self.model_dump(
mode="python",
include=include,
exclude=exclude,
context=context,
by_alias=by_alias,
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
round_trip=round_trip,
warnings=warnings,
serialize_as_any=serialize_as_any,
)
return json.dumps(dumped_items, indent=indent, default=str)
@classmethod
def model_validate(
cls,
obj: Any,
*,
strict: bool | None = None,
extra: ExtraValues | None = None,
from_attributes: bool | None = None,
context: Any | None = None,
by_alias: bool | None = None,
by_name: bool | None = None,
) -> Self:
"""Accept array data directly."""
if isinstance(obj, list):
return cls(items=obj)
elif isinstance(obj, dict) and "items" in obj:
return super().model_validate(
obj,
strict=strict,
from_attributes=from_attributes,
context=context,
)
else:
raise ValueError(
f"Expected list or dict with 'items' key, got {type(obj)}"
)
@classmethod
def model_json_schema(
cls,
by_alias: bool = True,
ref_template: str = DEFAULT_REF_TEMPLATE,
schema_generator: type[GenerateJsonSchema] = GenerateJsonSchema,
mode: JsonSchemaMode = "validation",
*,
union_format: Literal["any_of", "primitive_type_array"] = "any_of",
) -> dict[str, Any]:
"""Return the original array schema, not wrapped in object schema."""
return schema
model_config = ConfigDict(populate_by_name=True, use_attribute_docstrings=True)
ArrayModel.__name__ = model_name
ArrayModel.__qualname__ = model_name
return ArrayModel
def create_model_from_json_schema(
schema: dict[str, Any], model_name: str = "DynamicModel"
) -> type[BaseModel]:
"""Create a Pydantic model from JSON Schema, handling underscore field names.
Args:
schema: A JSON Schema dictionary containing properties and required fields
model_name: The name of the model
Returns:
A Pydantic model class with sanitized field names
"""
# Initial validation of the schema
_validate_json_schema(schema)
# Handle array schemas at root level
if schema.get("type") == "array":
return _create_array_model(schema, model_name)
# Original object handling logic (unchanged)
properties = schema.get("properties", {})
required_fields = set(schema.get("required", []))
fields: dict[str, Any] = {}
field_aliases: dict[str, str] = {}
used_field_names: set[str] = set()
for original_field_name, field_schema in properties.items():
sanitized_field_name = _sanitize_field_name(original_field_name)
sanitized_field_name = _resolve_field_name_collisions(
sanitized_field_name, used_field_names
)
used_field_names.add(sanitized_field_name)
# Handle type mapping
base_field_type = _resolve_json_type(field_schema)
# Handle required vs optional
# If the field already has None in its type (from anyOf with null), respect that
has_null_in_union = (
hasattr(base_field_type, "__args__")
and type(None) in base_field_type.__args__
)
if original_field_name in required_fields:
if has_null_in_union:
# Field is required but allows null
field_type = base_field_type
default_value = field_schema.get("default", ...)
else:
# Field is required and doesn't allow null
default_value = ...
field_type = base_field_type
else:
# Field is optional
if has_null_in_union:
# Already has None in union
field_type = base_field_type
else:
# Add None to make it optional
field_type = base_field_type | None
default_value = field_schema.get("default", None)
# Create field with alias if name was sanitized
field_kwargs = {"description": field_schema.get("description", "")}
if sanitized_field_name != original_field_name:
field_kwargs["alias"] = original_field_name
field_aliases[sanitized_field_name] = original_field_name
fields[sanitized_field_name] = (
field_type,
Field(default_value, **field_kwargs),
)
# Create the base dynamic model
DynamicBaseModel: type[BaseModel] = create_model(model_name, **fields)
class CustomModel(DynamicBaseModel): # type: ignore
def model_dump(self, by_alias: bool = True, **kwargs: Any) -> dict[str, Any]:
obj: dict[str, Any] = super().model_dump(by_alias=by_alias, **kwargs)
return obj
def model_dump_json(self, by_alias: bool = True, **kwargs: Any) -> str:
json: str = super().model_dump_json(by_alias=by_alias, **kwargs)
return json
@classmethod
def model_json_schema(
cls,
by_alias: bool = True,
ref_template: str = "#/$defs/{model}",
schema_generator: Any = None,
mode: str = "validation",
) -> dict[str, Any]:
"""Return the original schema, not Pydantic's generated schema."""
return schema
model_config = ConfigDict(populate_by_name=True, use_attribute_docstrings=True)
CustomModel.__name__ = model_name
CustomModel.__qualname__ = model_name
return CustomModel