271 lines
9.2 KiB
Python
271 lines
9.2 KiB
Python
"""
|
|
Django Generator
|
|
|
|
Generates Django ORM models from model definitions.
|
|
"""
|
|
|
|
import dataclasses as dc
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
from typing import Any, List, get_type_hints
|
|
|
|
from ..helpers import format_opts, get_origin_name, get_type_name, unwrap_optional
|
|
from ..loader.schema import EnumDefinition, ModelDefinition
|
|
from ..types import DJANGO_SPECIAL, DJANGO_TYPES
|
|
from .base import BaseGenerator
|
|
|
|
|
|
class DjangoGenerator(BaseGenerator):
|
|
"""Generates Django ORM model files."""
|
|
|
|
def file_extension(self) -> str:
|
|
return ".py"
|
|
|
|
def generate(self, models, output_path: Path) -> None:
|
|
"""Generate Django models to output_path."""
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Handle different input types
|
|
if hasattr(models, "models"):
|
|
# SchemaLoader or similar
|
|
content = self._generate_from_definitions(
|
|
models.models, getattr(models, "enums", [])
|
|
)
|
|
elif isinstance(models, tuple):
|
|
# (models, enums) tuple
|
|
content = self._generate_from_definitions(models[0], models[1])
|
|
elif isinstance(models, list):
|
|
# List of dataclasses (MPR style)
|
|
content = self._generate_from_dataclasses(models)
|
|
else:
|
|
raise ValueError(f"Unsupported input type: {type(models)}")
|
|
|
|
output_path.write_text(content)
|
|
|
|
def _generate_from_definitions(
|
|
self, models: List[ModelDefinition], enums: List[EnumDefinition]
|
|
) -> str:
|
|
"""Generate from ModelDefinition objects."""
|
|
lines = self._generate_header()
|
|
|
|
# Generate enums as TextChoices
|
|
for enum_def in enums:
|
|
lines.extend(self._generate_text_choices(enum_def))
|
|
lines.append("")
|
|
|
|
# Generate models
|
|
for model_def in models:
|
|
lines.extend(self._generate_model_from_definition(model_def))
|
|
lines.extend(["", ""])
|
|
|
|
return "\n".join(lines)
|
|
|
|
def _generate_from_dataclasses(self, dataclasses: List[type]) -> str:
|
|
"""Generate from Python dataclasses (MPR style)."""
|
|
lines = self._generate_header()
|
|
|
|
for cls in dataclasses:
|
|
lines.extend(self._generate_model_from_dataclass(cls))
|
|
lines.extend(["", ""])
|
|
|
|
return "\n".join(lines)
|
|
|
|
def _generate_header(self) -> List[str]:
|
|
"""Generate file header."""
|
|
return [
|
|
'"""',
|
|
"Django ORM Models - GENERATED FILE",
|
|
"",
|
|
"Do not edit directly. Regenerate using modelgen.",
|
|
'"""',
|
|
"",
|
|
"import uuid",
|
|
"from django.db import models",
|
|
"",
|
|
]
|
|
|
|
def _generate_text_choices(self, enum_def: EnumDefinition) -> List[str]:
|
|
"""Generate Django TextChoices from EnumDefinition."""
|
|
lines = [
|
|
f"class {enum_def.name}(models.TextChoices):",
|
|
]
|
|
for name, value in enum_def.values:
|
|
label = name.replace("_", " ").title()
|
|
lines.append(f' {name} = "{value}", "{label}"')
|
|
return lines
|
|
|
|
def _generate_model_from_definition(self, model_def: ModelDefinition) -> List[str]:
|
|
"""Generate Django model from ModelDefinition."""
|
|
docstring = model_def.docstring or model_def.name
|
|
lines = [
|
|
f"class {model_def.name}(models.Model):",
|
|
f' """{docstring.strip().split(chr(10))[0]}"""',
|
|
"",
|
|
]
|
|
|
|
for field in model_def.fields:
|
|
django_field = self._resolve_field_type(
|
|
field.name, field.type_hint, field.default, field.optional
|
|
)
|
|
lines.append(f" {field.name} = {django_field}")
|
|
|
|
# Add Meta and __str__
|
|
lines.extend(
|
|
[
|
|
"",
|
|
" class Meta:",
|
|
' ordering = ["-created_at"]'
|
|
if any(f.name == "created_at" for f in model_def.fields)
|
|
else " pass",
|
|
"",
|
|
" def __str__(self):",
|
|
]
|
|
)
|
|
|
|
# Determine __str__ return
|
|
field_names = [f.name for f in model_def.fields]
|
|
if "filename" in field_names:
|
|
lines.append(" return self.filename")
|
|
elif "name" in field_names:
|
|
lines.append(" return self.name")
|
|
else:
|
|
lines.append(" return str(self.id)")
|
|
|
|
return lines
|
|
|
|
def _generate_model_from_dataclass(self, cls: type) -> List[str]:
|
|
"""Generate Django model from a dataclass (MPR style)."""
|
|
docstring = cls.__doc__ or cls.__name__
|
|
lines = [
|
|
f"class {cls.__name__}(models.Model):",
|
|
f' """{docstring.strip().split(chr(10))[0]}"""',
|
|
"",
|
|
]
|
|
|
|
hints = get_type_hints(cls)
|
|
fields = {f.name: f for f in dc.fields(cls)}
|
|
|
|
# Check for enums and add Status inner class if needed
|
|
for type_hint in hints.values():
|
|
base, _ = unwrap_optional(type_hint)
|
|
if isinstance(base, type) and issubclass(base, Enum):
|
|
lines.append(" class Status(models.TextChoices):")
|
|
for member in base:
|
|
label = member.name.replace("_", " ").title()
|
|
lines.append(f' {member.name} = "{member.value}", "{label}"')
|
|
lines.append("")
|
|
break
|
|
|
|
# Generate fields
|
|
for name, type_hint in hints.items():
|
|
if name.startswith("_"):
|
|
continue
|
|
field = fields.get(name)
|
|
default = dc.MISSING
|
|
if field and field.default is not dc.MISSING:
|
|
default = field.default
|
|
django_field = self._resolve_field_type(name, type_hint, default, False)
|
|
lines.append(f" {name} = {django_field}")
|
|
|
|
# Add Meta and __str__
|
|
lines.extend(
|
|
[
|
|
"",
|
|
" class Meta:",
|
|
' ordering = ["-created_at"]'
|
|
if "created_at" in hints
|
|
else " pass",
|
|
"",
|
|
" def __str__(self):",
|
|
]
|
|
)
|
|
|
|
if "filename" in hints:
|
|
lines.append(" return self.filename")
|
|
elif "name" in hints:
|
|
lines.append(" return self.name")
|
|
else:
|
|
lines.append(" return str(self.id)")
|
|
|
|
return lines
|
|
|
|
def _resolve_field_type(
|
|
self, name: str, type_hint: Any, default: Any, optional: bool
|
|
) -> str:
|
|
"""Resolve Python type to Django field."""
|
|
# Special fields
|
|
if name in DJANGO_SPECIAL:
|
|
return DJANGO_SPECIAL[name]
|
|
|
|
base, is_optional = unwrap_optional(type_hint)
|
|
optional = optional or is_optional
|
|
origin = get_origin_name(base)
|
|
type_name = get_type_name(base)
|
|
opts = format_opts(optional)
|
|
|
|
# Container types
|
|
if origin == "dict":
|
|
return DJANGO_TYPES["dict"]
|
|
if origin == "list":
|
|
return DJANGO_TYPES["list"]
|
|
|
|
# UUID / datetime
|
|
if type_name == "UUID":
|
|
return DJANGO_TYPES["UUID"].format(opts=opts)
|
|
if type_name == "datetime":
|
|
return DJANGO_TYPES["datetime"].format(opts=opts)
|
|
|
|
# Enum
|
|
if isinstance(base, type) and issubclass(base, Enum):
|
|
enum_name = base.__name__
|
|
extra = []
|
|
if optional:
|
|
extra.append("null=True, blank=True")
|
|
if default is not dc.MISSING and isinstance(default, Enum):
|
|
extra.append(f"default={enum_name}.{default.name}")
|
|
return DJANGO_TYPES["enum"].format(
|
|
enum_name=enum_name,
|
|
opts=", " + ", ".join(extra) if extra else ""
|
|
)
|
|
|
|
# Text fields (based on name heuristics)
|
|
if base is str and any(
|
|
x in name for x in ("message", "comments", "description")
|
|
):
|
|
return DJANGO_TYPES["text"]
|
|
|
|
# BigInt fields
|
|
if base is int and name in ("file_size", "bitrate"):
|
|
return DJANGO_TYPES["bigint"].format(opts=opts)
|
|
|
|
# String with max_length
|
|
if base is str:
|
|
max_length = 1000 if "path" in name else 500 if "filename" in name else 255
|
|
return DJANGO_TYPES[str].format(
|
|
max_length=max_length, opts=", " + opts if opts else ""
|
|
)
|
|
|
|
# Integer
|
|
if base is int:
|
|
extra = [opts] if opts else []
|
|
if default is not dc.MISSING and not callable(default):
|
|
extra.append(f"default={default}")
|
|
return DJANGO_TYPES[int].format(opts=", ".join(extra))
|
|
|
|
# Float
|
|
if base is float:
|
|
extra = [opts] if opts else []
|
|
if default is not dc.MISSING and not callable(default):
|
|
extra.append(f"default={default}")
|
|
return DJANGO_TYPES[float].format(opts=", ".join(extra))
|
|
|
|
# Boolean
|
|
if base is bool:
|
|
default_val = default if default is not dc.MISSING else False
|
|
return DJANGO_TYPES[bool].format(default=default_val)
|
|
|
|
# Fallback to CharField
|
|
return DJANGO_TYPES[str].format(
|
|
max_length=255, opts=", " + opts if opts else ""
|
|
)
|