django-tortoise-objects 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_tortoise/__init__.py +18 -0
- django_tortoise/_models.py +16 -0
- django_tortoise/apps.py +106 -0
- django_tortoise/code_generator.py +652 -0
- django_tortoise/conf.py +77 -0
- django_tortoise/db_config.py +91 -0
- django_tortoise/exceptions.py +26 -0
- django_tortoise/fields.py +505 -0
- django_tortoise/generator.py +206 -0
- django_tortoise/initialization.py +102 -0
- django_tortoise/introspection.py +280 -0
- django_tortoise/management/__init__.py +0 -0
- django_tortoise/management/commands/__init__.py +0 -0
- django_tortoise/management/commands/generate_tortoise_models.py +113 -0
- django_tortoise/manager.py +184 -0
- django_tortoise/registry.py +123 -0
- django_tortoise_objects-0.1.0.dist-info/METADATA +302 -0
- django_tortoise_objects-0.1.0.dist-info/RECORD +20 -0
- django_tortoise_objects-0.1.0.dist-info/WHEEL +4 -0
- django_tortoise_objects-0.1.0.dist-info/licenses/LICENSE +191 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dynamic Tortoise model class generation.
|
|
3
|
+
|
|
4
|
+
Creates Tortoise ORM model classes at runtime from ``ModelInfo`` objects
|
|
5
|
+
produced by the introspection module, using ``type()`` with Tortoise's
|
|
6
|
+
``Model`` as the base class. The ``ModelMeta`` metaclass (inherited from
|
|
7
|
+
``tortoise.models.Model``) handles field registration automatically.
|
|
8
|
+
|
|
9
|
+
Two functions are provided:
|
|
10
|
+
- ``generate_tortoise_model()`` -- creates data-field-only models (Phase 2).
|
|
11
|
+
- ``generate_tortoise_model_full()`` -- creates models with data + relational
|
|
12
|
+
fields, used by ``AppConfig.ready()`` after pre-registration.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from tortoise import models as tortoise_models
|
|
19
|
+
|
|
20
|
+
from django_tortoise.fields import convert_field, convert_relation_field_by_name
|
|
21
|
+
from django_tortoise.introspection import ModelInfo
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("django_tortoise")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def generate_tortoise_model(
|
|
27
|
+
model_info: ModelInfo,
|
|
28
|
+
tortoise_app_name: str = "django_tortoise",
|
|
29
|
+
) -> type | None:
|
|
30
|
+
"""
|
|
31
|
+
Generate a Tortoise ORM model class from Django model metadata.
|
|
32
|
+
|
|
33
|
+
Iterates over the non-relational fields in *model_info*, converts each
|
|
34
|
+
to a Tortoise field via ``convert_field()``, and constructs a new
|
|
35
|
+
``tortoise.models.Model`` subclass with ``type()``.
|
|
36
|
+
|
|
37
|
+
Returns ``None`` if the model has no convertible data fields.
|
|
38
|
+
"""
|
|
39
|
+
tortoise_fields, skipped_fields = _build_data_fields(model_info)
|
|
40
|
+
|
|
41
|
+
if not tortoise_fields:
|
|
42
|
+
logger.warning(
|
|
43
|
+
"Model '%s.%s' has no convertible fields. Skipping.",
|
|
44
|
+
model_info.app_label,
|
|
45
|
+
model_info.model_name,
|
|
46
|
+
)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
if skipped_fields:
|
|
50
|
+
logger.info(
|
|
51
|
+
"Skipped fields %s on model '%s.%s' (unsupported types).",
|
|
52
|
+
skipped_fields,
|
|
53
|
+
model_info.app_label,
|
|
54
|
+
model_info.model_name,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
meta_class = _build_meta_class(model_info, tortoise_fields, tortoise_app_name)
|
|
58
|
+
class_name = f"{model_info.model_class.__name__}Tortoise"
|
|
59
|
+
|
|
60
|
+
tortoise_model = type(
|
|
61
|
+
class_name,
|
|
62
|
+
(tortoise_models.Model,),
|
|
63
|
+
{
|
|
64
|
+
"Meta": meta_class,
|
|
65
|
+
**tortoise_fields,
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
logger.debug(
|
|
70
|
+
"Generated Tortoise model '%s' for Django model '%s.%s' (table: %s, fields: %d)",
|
|
71
|
+
class_name,
|
|
72
|
+
model_info.app_label,
|
|
73
|
+
model_info.model_name,
|
|
74
|
+
model_info.db_table,
|
|
75
|
+
len(tortoise_fields),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return tortoise_model
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def generate_tortoise_model_full(
|
|
82
|
+
model_info: ModelInfo,
|
|
83
|
+
tortoise_app_name: str = "django_tortoise",
|
|
84
|
+
class_name_map: dict[type, str] | None = None,
|
|
85
|
+
) -> type | None:
|
|
86
|
+
"""
|
|
87
|
+
Generate a Tortoise ORM model class with ALL fields (data + relational).
|
|
88
|
+
|
|
89
|
+
Uses *class_name_map* to resolve FK/O2O/M2M string references without
|
|
90
|
+
requiring the target Tortoise models to exist yet. The map is keyed
|
|
91
|
+
by Django model class and valued by the planned Tortoise class name.
|
|
92
|
+
|
|
93
|
+
Returns ``None`` if the model has no convertible data fields.
|
|
94
|
+
"""
|
|
95
|
+
tortoise_fields, skipped_fields = _build_data_fields(model_info)
|
|
96
|
+
|
|
97
|
+
if not tortoise_fields:
|
|
98
|
+
logger.warning(
|
|
99
|
+
"Model '%s.%s' has no convertible fields. Skipping.",
|
|
100
|
+
model_info.app_label,
|
|
101
|
+
model_info.model_name,
|
|
102
|
+
)
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
if skipped_fields:
|
|
106
|
+
logger.info(
|
|
107
|
+
"Skipped fields %s on model '%s.%s' (unsupported types).",
|
|
108
|
+
skipped_fields,
|
|
109
|
+
model_info.app_label,
|
|
110
|
+
model_info.model_name,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Add relational fields
|
|
114
|
+
for field_info in model_info.fields:
|
|
115
|
+
if not field_info.is_relation:
|
|
116
|
+
continue
|
|
117
|
+
result = convert_relation_field_by_name(
|
|
118
|
+
field_info,
|
|
119
|
+
tortoise_app_name=tortoise_app_name,
|
|
120
|
+
class_name_map=class_name_map,
|
|
121
|
+
)
|
|
122
|
+
if result is None:
|
|
123
|
+
continue
|
|
124
|
+
field_name, tortoise_field = result
|
|
125
|
+
tortoise_fields[field_name] = tortoise_field
|
|
126
|
+
|
|
127
|
+
meta_class = _build_meta_class(model_info, tortoise_fields, tortoise_app_name)
|
|
128
|
+
class_name = f"{model_info.model_class.__name__}Tortoise"
|
|
129
|
+
|
|
130
|
+
tortoise_model = type(
|
|
131
|
+
class_name,
|
|
132
|
+
(tortoise_models.Model,),
|
|
133
|
+
{
|
|
134
|
+
"Meta": meta_class,
|
|
135
|
+
**tortoise_fields,
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
logger.debug(
|
|
140
|
+
"Generated Tortoise model '%s' for Django model '%s.%s' "
|
|
141
|
+
"(table: %s, data fields: %d, total fields: %d)",
|
|
142
|
+
class_name,
|
|
143
|
+
model_info.app_label,
|
|
144
|
+
model_info.model_name,
|
|
145
|
+
model_info.db_table,
|
|
146
|
+
len([f for f in tortoise_fields.values() if not hasattr(f, "related_model")]),
|
|
147
|
+
len(tortoise_fields),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return tortoise_model
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# Internal helpers
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _build_data_fields(model_info: ModelInfo) -> tuple[dict[str, object], list[str]]:
|
|
159
|
+
"""Convert non-relational fields to Tortoise fields."""
|
|
160
|
+
tortoise_fields: dict[str, object] = {}
|
|
161
|
+
skipped_fields: list[str] = []
|
|
162
|
+
|
|
163
|
+
for field_info in model_info.fields:
|
|
164
|
+
if field_info.is_relation:
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
tortoise_field = convert_field(field_info)
|
|
168
|
+
if tortoise_field is None:
|
|
169
|
+
skipped_fields.append(field_info.name)
|
|
170
|
+
continue
|
|
171
|
+
tortoise_fields[field_info.name] = tortoise_field
|
|
172
|
+
|
|
173
|
+
return tortoise_fields, skipped_fields
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _build_meta_class(
|
|
177
|
+
model_info: ModelInfo,
|
|
178
|
+
tortoise_fields: dict[str, object],
|
|
179
|
+
tortoise_app_name: str,
|
|
180
|
+
) -> type:
|
|
181
|
+
"""Build the inner Meta class for a Tortoise model."""
|
|
182
|
+
meta_attrs: dict[str, Any] = {
|
|
183
|
+
"table": model_info.db_table,
|
|
184
|
+
"app": tortoise_app_name,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if model_info.unique_together:
|
|
188
|
+
valid_constraints: list[tuple[str, ...]] = []
|
|
189
|
+
converted_names = set(tortoise_fields.keys())
|
|
190
|
+
for constraint in model_info.unique_together:
|
|
191
|
+
missing = [f for f in constraint if f not in converted_names]
|
|
192
|
+
if missing:
|
|
193
|
+
logger.warning(
|
|
194
|
+
"unique_together constraint %s on '%s.%s' references "
|
|
195
|
+
"unconverted fields %s; omitting constraint.",
|
|
196
|
+
constraint,
|
|
197
|
+
model_info.app_label,
|
|
198
|
+
model_info.model_name,
|
|
199
|
+
missing,
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
valid_constraints.append(tuple(constraint))
|
|
203
|
+
if valid_constraints:
|
|
204
|
+
meta_attrs["unique_together"] = tuple(valid_constraints)
|
|
205
|
+
|
|
206
|
+
return type("Meta", (), meta_attrs)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lazy async initialization and explicit init/close for Tortoise ORM.
|
|
3
|
+
|
|
4
|
+
Provides ``init()`` and ``close()`` coroutines that manage the Tortoise
|
|
5
|
+
ORM lifecycle, including double-check locking for thread-safe lazy init.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
from tortoise import Tortoise
|
|
14
|
+
|
|
15
|
+
from django_tortoise.db_config import build_tortoise_config
|
|
16
|
+
from django_tortoise.exceptions import ConnectionError
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("django_tortoise")
|
|
19
|
+
|
|
20
|
+
_initialized: bool = False
|
|
21
|
+
_init_lock: asyncio.Lock | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def _ensure_lock() -> asyncio.Lock:
|
|
25
|
+
"""Get or create the init lock (lazily, to avoid event loop binding at import time)."""
|
|
26
|
+
global _init_lock
|
|
27
|
+
if _init_lock is None:
|
|
28
|
+
_init_lock = asyncio.Lock()
|
|
29
|
+
return _init_lock
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def ensure_initialized() -> None:
|
|
33
|
+
"""
|
|
34
|
+
Ensure Tortoise ORM is initialized. Called automatically before queries.
|
|
35
|
+
|
|
36
|
+
Uses double-check locking with ``asyncio.Lock`` for safety.
|
|
37
|
+
"""
|
|
38
|
+
global _initialized
|
|
39
|
+
if _initialized:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
lock = await _ensure_lock()
|
|
43
|
+
async with lock:
|
|
44
|
+
if _initialized:
|
|
45
|
+
return # Another coroutine already initialized
|
|
46
|
+
await init()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def init() -> None:
|
|
50
|
+
"""
|
|
51
|
+
Explicitly initialize Tortoise ORM connections using Django's DATABASES config.
|
|
52
|
+
|
|
53
|
+
Idempotent: calling multiple times is a no-op after the first success.
|
|
54
|
+
Can be called from ASGI lifespan handler for pre-warming connections.
|
|
55
|
+
"""
|
|
56
|
+
global _initialized
|
|
57
|
+
if _initialized:
|
|
58
|
+
logger.debug("Tortoise already initialized, skipping.")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
logger.info("Initializing Tortoise ORM connections...")
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
config = build_tortoise_config()
|
|
65
|
+
await Tortoise.init(config=config)
|
|
66
|
+
_initialized = True
|
|
67
|
+
logger.info("Tortoise ORM initialized successfully.")
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
raise ConnectionError(f"Failed to initialize Tortoise ORM: {exc}") from exc
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def close() -> None:
|
|
73
|
+
"""
|
|
74
|
+
Close all Tortoise ORM connections.
|
|
75
|
+
|
|
76
|
+
Call this during ASGI shutdown or when cleaning up.
|
|
77
|
+
"""
|
|
78
|
+
global _initialized
|
|
79
|
+
if not _initialized:
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
logger.info("Closing Tortoise ORM connections...")
|
|
83
|
+
try:
|
|
84
|
+
await Tortoise.close_connections()
|
|
85
|
+
except RuntimeError:
|
|
86
|
+
# Handle case where Tortoise context is no longer active
|
|
87
|
+
# (e.g., in test teardown scenarios)
|
|
88
|
+
logger.debug("Tortoise context already closed.")
|
|
89
|
+
_initialized = False
|
|
90
|
+
logger.info("Tortoise ORM connections closed.")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_initialized() -> bool:
|
|
94
|
+
"""Check if Tortoise ORM has been initialized."""
|
|
95
|
+
return _initialized
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _reset_for_testing() -> None:
|
|
99
|
+
"""Reset initialization state. For testing only."""
|
|
100
|
+
global _initialized, _init_lock
|
|
101
|
+
_initialized = False
|
|
102
|
+
_init_lock = None
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django model introspection logic.
|
|
3
|
+
|
|
4
|
+
Extracts schema metadata from Django models using the ``_meta`` API,
|
|
5
|
+
producing ``ModelInfo`` and ``FieldInfo`` dataclasses consumed by the
|
|
6
|
+
Tortoise model generator.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import enum
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from django.db import models
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("django_tortoise")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class FieldInfo:
|
|
21
|
+
"""Extracted metadata for a single Django model field."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
internal_type: str # from get_internal_type()
|
|
25
|
+
column: str # actual DB column name
|
|
26
|
+
primary_key: bool
|
|
27
|
+
null: bool
|
|
28
|
+
unique: bool
|
|
29
|
+
has_default: bool
|
|
30
|
+
default: Any
|
|
31
|
+
max_length: int | None
|
|
32
|
+
max_digits: int | None
|
|
33
|
+
decimal_places: int | None
|
|
34
|
+
db_index: bool
|
|
35
|
+
choices: list | None
|
|
36
|
+
enum_type: type | None # Python Enum class backing choices, if any
|
|
37
|
+
# Relational field info
|
|
38
|
+
is_relation: bool
|
|
39
|
+
related_model: type | None # Django model class
|
|
40
|
+
related_model_label: str | None # "app_label.ModelName"
|
|
41
|
+
on_delete: str | None # e.g. "CASCADE"
|
|
42
|
+
related_name: str | None
|
|
43
|
+
is_self_referential: bool
|
|
44
|
+
# M2M specific
|
|
45
|
+
many_to_many: bool
|
|
46
|
+
through_model: type | None
|
|
47
|
+
through_db_table: str | None
|
|
48
|
+
# Auto field detection
|
|
49
|
+
is_auto_field: bool
|
|
50
|
+
# Original Django field reference
|
|
51
|
+
django_field: models.Field | None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ModelInfo:
|
|
56
|
+
"""Extracted metadata for a single Django model."""
|
|
57
|
+
|
|
58
|
+
model_class: type
|
|
59
|
+
app_label: str
|
|
60
|
+
model_name: str
|
|
61
|
+
db_table: str
|
|
62
|
+
fields: list[FieldInfo]
|
|
63
|
+
unique_together: list[tuple[str, ...]]
|
|
64
|
+
is_abstract: bool
|
|
65
|
+
is_proxy: bool
|
|
66
|
+
is_managed: bool
|
|
67
|
+
pk_name: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _detect_enum_type(django_field) -> type | None:
|
|
71
|
+
"""Detect a Python Enum class backing a Django field's choices.
|
|
72
|
+
|
|
73
|
+
Django 6.0+ normalizes enum choices to plain ``(value, label)`` tuples
|
|
74
|
+
via ``normalize_choices``, so the enum class cannot be recovered from
|
|
75
|
+
``field.choices`` alone. Instead we check the field's default value:
|
|
76
|
+
when an ``IntegerChoices`` / ``TextChoices`` default is set, Django
|
|
77
|
+
preserves the enum member as the default.
|
|
78
|
+
|
|
79
|
+
Returns the Enum subclass when found, otherwise None.
|
|
80
|
+
"""
|
|
81
|
+
if not getattr(django_field, "choices", None):
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
# The default value is an enum member when choices=SomeEnum was used
|
|
85
|
+
if django_field.has_default():
|
|
86
|
+
default = django_field.default
|
|
87
|
+
if isinstance(default, enum.Enum):
|
|
88
|
+
return type(default)
|
|
89
|
+
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def introspect_field(django_field) -> FieldInfo | None:
|
|
94
|
+
"""
|
|
95
|
+
Extract metadata from a single Django field.
|
|
96
|
+
|
|
97
|
+
Returns None for reverse relations (fields where ``field.concrete`` is
|
|
98
|
+
False and the field is not a forward ManyToManyField).
|
|
99
|
+
"""
|
|
100
|
+
# Skip reverse relations: they are not concrete and not forward M2M
|
|
101
|
+
is_m2m = getattr(django_field, "many_to_many", False)
|
|
102
|
+
is_concrete = getattr(django_field, "concrete", False)
|
|
103
|
+
|
|
104
|
+
if not is_concrete and not is_m2m:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
# For M2M fields, only include forward relations (not reverse).
|
|
108
|
+
# Reverse M2M (ManyToManyRel) has a 'field' attribute pointing back.
|
|
109
|
+
if is_m2m and not is_concrete and hasattr(django_field, "field"):
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
name = django_field.name
|
|
113
|
+
internal_type = django_field.get_internal_type()
|
|
114
|
+
|
|
115
|
+
# Get DB column name. M2M fields don't have a column attribute.
|
|
116
|
+
try:
|
|
117
|
+
column = django_field.column or name
|
|
118
|
+
except AttributeError:
|
|
119
|
+
column = name
|
|
120
|
+
|
|
121
|
+
primary_key = getattr(django_field, "primary_key", False)
|
|
122
|
+
null = getattr(django_field, "null", False)
|
|
123
|
+
unique = getattr(django_field, "unique", False)
|
|
124
|
+
db_index = getattr(django_field, "db_index", False)
|
|
125
|
+
|
|
126
|
+
# Default handling
|
|
127
|
+
has_default = django_field.has_default()
|
|
128
|
+
default = None
|
|
129
|
+
if has_default:
|
|
130
|
+
default = django_field.default
|
|
131
|
+
|
|
132
|
+
max_length = getattr(django_field, "max_length", None)
|
|
133
|
+
max_digits = getattr(django_field, "max_digits", None)
|
|
134
|
+
decimal_places = getattr(django_field, "decimal_places", None)
|
|
135
|
+
|
|
136
|
+
choices = list(django_field.choices) if django_field.choices else None
|
|
137
|
+
enum_type = _detect_enum_type(django_field)
|
|
138
|
+
|
|
139
|
+
# Relational field info
|
|
140
|
+
is_relation = getattr(django_field, "is_relation", False)
|
|
141
|
+
related_model = None
|
|
142
|
+
related_model_label = None
|
|
143
|
+
on_delete = None
|
|
144
|
+
related_name = None
|
|
145
|
+
is_self_referential = False
|
|
146
|
+
many_to_many = is_m2m
|
|
147
|
+
through_model = None
|
|
148
|
+
through_db_table = None
|
|
149
|
+
|
|
150
|
+
if is_relation:
|
|
151
|
+
related_model = getattr(django_field, "related_model", None)
|
|
152
|
+
|
|
153
|
+
if related_model is not None:
|
|
154
|
+
try:
|
|
155
|
+
related_model_label = f"{related_model._meta.app_label}.{related_model.__name__}"
|
|
156
|
+
except AttributeError:
|
|
157
|
+
related_model_label = None
|
|
158
|
+
|
|
159
|
+
# on_delete from remote_field
|
|
160
|
+
remote_field = getattr(django_field, "remote_field", None)
|
|
161
|
+
if remote_field is not None:
|
|
162
|
+
on_delete_func = getattr(remote_field, "on_delete", None)
|
|
163
|
+
if on_delete_func is not None:
|
|
164
|
+
# Extract the name from the on_delete callable
|
|
165
|
+
# Django's on_delete functions have __name__ like 'CASCADE', 'SET_NULL', etc.
|
|
166
|
+
on_delete = getattr(on_delete_func, "__name__", None)
|
|
167
|
+
|
|
168
|
+
# related_name
|
|
169
|
+
related_name_val = getattr(django_field, "related_query_name", None)
|
|
170
|
+
if related_name_val and callable(related_name_val):
|
|
171
|
+
# related_query_name() returns the related name
|
|
172
|
+
pass
|
|
173
|
+
# Use remote_field.related_name for the actual related_name
|
|
174
|
+
if remote_field is not None:
|
|
175
|
+
related_name = getattr(remote_field, "related_name", None)
|
|
176
|
+
|
|
177
|
+
# Self-referential detection
|
|
178
|
+
if related_model is not None:
|
|
179
|
+
# Get the model that owns this field
|
|
180
|
+
owner_model = getattr(django_field, "model", None)
|
|
181
|
+
if owner_model is not None and related_model is owner_model:
|
|
182
|
+
is_self_referential = True
|
|
183
|
+
|
|
184
|
+
# M2M specific
|
|
185
|
+
if many_to_many and remote_field is not None:
|
|
186
|
+
through_model = getattr(remote_field, "through", None)
|
|
187
|
+
if through_model is not None:
|
|
188
|
+
try:
|
|
189
|
+
through_db_table = through_model._meta.db_table
|
|
190
|
+
except AttributeError:
|
|
191
|
+
through_db_table = None
|
|
192
|
+
|
|
193
|
+
# Auto field detection
|
|
194
|
+
is_auto_field = isinstance(
|
|
195
|
+
django_field,
|
|
196
|
+
(models.AutoField, models.BigAutoField, models.SmallAutoField),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return FieldInfo(
|
|
200
|
+
name=name,
|
|
201
|
+
internal_type=internal_type,
|
|
202
|
+
column=column,
|
|
203
|
+
primary_key=primary_key,
|
|
204
|
+
null=null,
|
|
205
|
+
unique=unique,
|
|
206
|
+
has_default=has_default,
|
|
207
|
+
default=default,
|
|
208
|
+
max_length=max_length,
|
|
209
|
+
max_digits=max_digits,
|
|
210
|
+
decimal_places=decimal_places,
|
|
211
|
+
db_index=db_index,
|
|
212
|
+
choices=choices,
|
|
213
|
+
enum_type=enum_type,
|
|
214
|
+
is_relation=is_relation,
|
|
215
|
+
related_model=related_model,
|
|
216
|
+
related_model_label=related_model_label,
|
|
217
|
+
on_delete=on_delete,
|
|
218
|
+
related_name=related_name,
|
|
219
|
+
is_self_referential=is_self_referential,
|
|
220
|
+
many_to_many=many_to_many,
|
|
221
|
+
through_model=through_model,
|
|
222
|
+
through_db_table=through_db_table,
|
|
223
|
+
is_auto_field=is_auto_field,
|
|
224
|
+
django_field=django_field,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def introspect_model(django_model: type) -> ModelInfo:
|
|
229
|
+
"""
|
|
230
|
+
Extract all schema metadata from a Django model.
|
|
231
|
+
|
|
232
|
+
Iterates over all fields returned by ``_meta.get_fields()`` and produces
|
|
233
|
+
a ``ModelInfo`` containing ``FieldInfo`` for each concrete or forward-M2M
|
|
234
|
+
field.
|
|
235
|
+
"""
|
|
236
|
+
meta = django_model._meta # type: ignore[attr-defined]
|
|
237
|
+
|
|
238
|
+
fields: list[FieldInfo] = []
|
|
239
|
+
for django_field in meta.get_fields():
|
|
240
|
+
field_info = introspect_field(django_field)
|
|
241
|
+
if field_info is not None:
|
|
242
|
+
fields.append(field_info)
|
|
243
|
+
|
|
244
|
+
# Extract unique_together as a list of tuples
|
|
245
|
+
unique_together = [tuple(ut) for ut in meta.unique_together]
|
|
246
|
+
|
|
247
|
+
# Primary key name
|
|
248
|
+
pk_name = meta.pk.name if meta.pk is not None else "id"
|
|
249
|
+
|
|
250
|
+
return ModelInfo(
|
|
251
|
+
model_class=django_model,
|
|
252
|
+
app_label=meta.app_label,
|
|
253
|
+
model_name=meta.model_name,
|
|
254
|
+
db_table=meta.db_table,
|
|
255
|
+
fields=fields,
|
|
256
|
+
unique_together=unique_together,
|
|
257
|
+
is_abstract=meta.abstract,
|
|
258
|
+
is_proxy=meta.proxy,
|
|
259
|
+
is_managed=meta.managed,
|
|
260
|
+
pk_name=pk_name,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def should_skip_model(model_info: ModelInfo) -> tuple[bool, str]:
|
|
265
|
+
"""
|
|
266
|
+
Check if a model should be skipped during Tortoise model generation.
|
|
267
|
+
|
|
268
|
+
Returns a ``(skip, reason)`` tuple. If ``skip`` is True, ``reason``
|
|
269
|
+
contains a human-readable explanation.
|
|
270
|
+
"""
|
|
271
|
+
if model_info.is_abstract:
|
|
272
|
+
return True, f"Model '{model_info.model_name}' is abstract."
|
|
273
|
+
|
|
274
|
+
if model_info.is_proxy:
|
|
275
|
+
return True, f"Model '{model_info.model_name}' is a proxy model."
|
|
276
|
+
|
|
277
|
+
if not model_info.fields:
|
|
278
|
+
return True, f"Model '{model_info.model_name}' has no concrete fields."
|
|
279
|
+
|
|
280
|
+
return False, ""
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Management command to generate static Tortoise ORM model source files.
|
|
3
|
+
|
|
4
|
+
Usage::
|
|
5
|
+
|
|
6
|
+
python manage.py generate_tortoise_models [--output-dir DIR] [--app-label APP ...]
|
|
7
|
+
[--tortoise-app-name NAME]
|
|
8
|
+
|
|
9
|
+
Produces one Python file per Django app containing Tortoise ORM model classes
|
|
10
|
+
that mirror the Django models. The generated files can be inspected,
|
|
11
|
+
customized, and version-controlled.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from collections import defaultdict
|
|
16
|
+
|
|
17
|
+
from django.apps import apps
|
|
18
|
+
from django.core.management.base import BaseCommand
|
|
19
|
+
|
|
20
|
+
from django_tortoise.code_generator import render_app_module, render_model_source
|
|
21
|
+
from django_tortoise.conf import get_config, should_include
|
|
22
|
+
from django_tortoise.introspection import introspect_model, should_skip_model
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Command(BaseCommand):
|
|
26
|
+
help = "Generate static Tortoise ORM model source files from Django models."
|
|
27
|
+
|
|
28
|
+
def add_arguments(self, parser):
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--output-dir",
|
|
31
|
+
default=".",
|
|
32
|
+
help='Directory where generated files are written (default: ".").',
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--app-label",
|
|
36
|
+
nargs="*",
|
|
37
|
+
default=None,
|
|
38
|
+
help="Restrict generation to specific Django app(s).",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--tortoise-app-name",
|
|
42
|
+
default="django_tortoise",
|
|
43
|
+
help='Tortoise app name used in Meta.app and FK references (default: "django_tortoise").',
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def handle(self, *args, **options):
|
|
47
|
+
output_dir = options["output_dir"]
|
|
48
|
+
app_labels = options["app_label"]
|
|
49
|
+
tortoise_app_name = options["tortoise_app_name"]
|
|
50
|
+
|
|
51
|
+
config = get_config()
|
|
52
|
+
include_patterns = config.get("INCLUDE_MODELS")
|
|
53
|
+
exclude_patterns = config.get("EXCLUDE_MODELS")
|
|
54
|
+
|
|
55
|
+
all_models = apps.get_models()
|
|
56
|
+
|
|
57
|
+
# Filter by --app-label if specified
|
|
58
|
+
if app_labels:
|
|
59
|
+
all_models = [m for m in all_models if m._meta.app_label in app_labels]
|
|
60
|
+
|
|
61
|
+
# Two-pass approach mirroring apps.py
|
|
62
|
+
# Pass 1: Introspect and build class_name_map
|
|
63
|
+
eligible = [] # list of (django_model, label, model_info)
|
|
64
|
+
class_name_map = {} # django_model -> tortoise class name
|
|
65
|
+
|
|
66
|
+
for django_model in all_models:
|
|
67
|
+
label = f"{django_model._meta.app_label}.{django_model.__name__}"
|
|
68
|
+
|
|
69
|
+
if not should_include(label, include_patterns, exclude_patterns):
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
model_info = introspect_model(django_model)
|
|
73
|
+
|
|
74
|
+
skip, _reason = should_skip_model(model_info)
|
|
75
|
+
if skip:
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
tortoise_class_name = f"{django_model.__name__}Tortoise"
|
|
79
|
+
class_name_map[django_model] = tortoise_class_name
|
|
80
|
+
eligible.append((django_model, label, model_info))
|
|
81
|
+
|
|
82
|
+
# Pass 2: Render source code grouped by app_label
|
|
83
|
+
models_by_app = defaultdict(list)
|
|
84
|
+
for _django_model, _label, model_info in eligible:
|
|
85
|
+
result = render_model_source(model_info, tortoise_app_name, class_name_map)
|
|
86
|
+
if result is not None:
|
|
87
|
+
models_by_app[model_info.app_label].append(result)
|
|
88
|
+
|
|
89
|
+
# Create output directory if needed
|
|
90
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
91
|
+
|
|
92
|
+
# Write files
|
|
93
|
+
files_written = []
|
|
94
|
+
total_models = 0
|
|
95
|
+
for app_label, model_results in sorted(models_by_app.items()):
|
|
96
|
+
filename = f"tortoise_models_{app_label}.py"
|
|
97
|
+
filepath = os.path.join(output_dir, filename)
|
|
98
|
+
source = render_app_module(model_results, app_label)
|
|
99
|
+
|
|
100
|
+
with open(filepath, "w") as f:
|
|
101
|
+
f.write(source)
|
|
102
|
+
|
|
103
|
+
files_written.append(filename)
|
|
104
|
+
total_models += len(model_results)
|
|
105
|
+
|
|
106
|
+
# Print summary
|
|
107
|
+
self.stdout.write(
|
|
108
|
+
self.style.SUCCESS(
|
|
109
|
+
f"Generated {total_models} Tortoise model(s) in {len(files_written)} file(s)."
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
for filename in files_written:
|
|
113
|
+
self.stdout.write(f" {filename}")
|