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,652 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Source code generation for Tortoise ORM models.
|
|
3
|
+
|
|
4
|
+
Produces Python source code strings from ``FieldInfo`` and ``ModelInfo``
|
|
5
|
+
dataclasses. This module mirrors ``fields.py`` + ``generator.py`` but
|
|
6
|
+
outputs source code instead of live objects.
|
|
7
|
+
|
|
8
|
+
All functions are pure -- no file I/O, no Django app registry access.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import enum
|
|
14
|
+
import logging
|
|
15
|
+
import uuid
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
from django_tortoise.fields import ON_DELETE_MAP
|
|
20
|
+
from django_tortoise.introspection import FieldInfo, ModelInfo
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("django_tortoise")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Result dataclass
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ModelSourceResult:
|
|
32
|
+
"""Result of rendering a single Tortoise model to source code."""
|
|
33
|
+
|
|
34
|
+
class_name: str
|
|
35
|
+
source: str
|
|
36
|
+
imports: set[str] = field(default_factory=set)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Helpers
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
# Simple literal types whose ``repr()`` is valid Python source.
|
|
44
|
+
_SIMPLE_LITERAL_TYPES = (int, float, str, bool, type(None))
|
|
45
|
+
|
|
46
|
+
# Known safe callables that can be emitted by name.
|
|
47
|
+
_SAFE_CALLABLES = {dict, list, set, frozenset, tuple}
|
|
48
|
+
|
|
49
|
+
# Known safe callables that need a qualified module path (e.g., ``uuid.uuid4``).
|
|
50
|
+
_QUALIFIED_CALLABLES: dict[object, tuple[str, str]] = {
|
|
51
|
+
uuid.uuid4: ("uuid", "uuid.uuid4"),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _format_kwargs(kwargs: dict[str, str]) -> str:
|
|
56
|
+
"""Join kwarg pairs into a comma-separated source string."""
|
|
57
|
+
return ", ".join(f"{k}={v}" for k, v in kwargs.items())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _common_kwargs_source(field_info: FieldInfo) -> dict[str, str]:
|
|
61
|
+
"""
|
|
62
|
+
Build common kwargs as ``{name: source_repr}`` pairs.
|
|
63
|
+
|
|
64
|
+
Mirrors ``fields._common_kwargs()`` but returns source-code strings
|
|
65
|
+
instead of runtime values.
|
|
66
|
+
"""
|
|
67
|
+
kwargs: dict[str, str] = {}
|
|
68
|
+
if field_info.null:
|
|
69
|
+
kwargs["null"] = "True"
|
|
70
|
+
if field_info.unique:
|
|
71
|
+
kwargs["unique"] = "True"
|
|
72
|
+
if field_info.db_index:
|
|
73
|
+
kwargs["db_index"] = "True"
|
|
74
|
+
if field_info.primary_key:
|
|
75
|
+
kwargs["primary_key"] = "True"
|
|
76
|
+
# source_field when column differs from name
|
|
77
|
+
if field_info.column and field_info.column != field_info.name:
|
|
78
|
+
kwargs["source_field"] = repr(field_info.column)
|
|
79
|
+
# default handling
|
|
80
|
+
if field_info.has_default:
|
|
81
|
+
default = field_info.default
|
|
82
|
+
# Check enum before simple literals because IntegerChoices members
|
|
83
|
+
# are also int instances.
|
|
84
|
+
if isinstance(default, enum.Enum):
|
|
85
|
+
kwargs["default"] = f"{type(default).__name__}.{default.name}"
|
|
86
|
+
elif isinstance(default, _SIMPLE_LITERAL_TYPES):
|
|
87
|
+
kwargs["default"] = repr(default)
|
|
88
|
+
elif default in _SAFE_CALLABLES:
|
|
89
|
+
kwargs["default"] = default.__name__
|
|
90
|
+
elif default in _QUALIFIED_CALLABLES:
|
|
91
|
+
kwargs["default"] = _QUALIFIED_CALLABLES[default][1]
|
|
92
|
+
elif callable(default):
|
|
93
|
+
# Unserializable callable -- emit None placeholder
|
|
94
|
+
kwargs["default"] = "None"
|
|
95
|
+
kwargs["# TODO"] = "" # sentinel; handled by caller
|
|
96
|
+
else:
|
|
97
|
+
kwargs["default"] = repr(default)
|
|
98
|
+
return kwargs
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _extract_todo_comment(kwargs: dict[str, str], field_name: str) -> str | None:
|
|
102
|
+
"""Pop the TODO sentinel from kwargs and return a comment string, or None."""
|
|
103
|
+
if "# TODO" in kwargs:
|
|
104
|
+
del kwargs["# TODO"]
|
|
105
|
+
return f" # TODO: set default for '{field_name}'"
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# SOURCE_FIELD_MAP -- parallel to fields.FIELD_MAP
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _try_enum_field_source(info: FieldInfo) -> str | None:
|
|
115
|
+
"""Return an enum field source string if the field has an enum_type, else None."""
|
|
116
|
+
if info.enum_type is None:
|
|
117
|
+
return None
|
|
118
|
+
kwargs = _common_kwargs_source(info)
|
|
119
|
+
_extract_todo_comment(kwargs, info.name) # clean sentinel if present
|
|
120
|
+
enum_name = info.enum_type.__name__
|
|
121
|
+
if issubclass(info.enum_type, int):
|
|
122
|
+
extra = _format_kwargs(kwargs)
|
|
123
|
+
parts = [enum_name]
|
|
124
|
+
if extra:
|
|
125
|
+
parts.append(extra)
|
|
126
|
+
return f"fields.IntEnumField({', '.join(parts)})"
|
|
127
|
+
if issubclass(info.enum_type, str):
|
|
128
|
+
kwargs["max_length"] = repr(info.max_length or 255)
|
|
129
|
+
extra = _format_kwargs(kwargs)
|
|
130
|
+
parts = [enum_name]
|
|
131
|
+
if extra:
|
|
132
|
+
parts.append(extra)
|
|
133
|
+
return f"fields.CharEnumField({', '.join(parts)})"
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# Mapping: internal_type -> source-rendering function
|
|
138
|
+
SOURCE_FIELD_MAP: dict[str, Callable[[FieldInfo], str | None]] = {}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _register_source(internal_type: str):
|
|
142
|
+
"""Decorator to register a source renderer for a Django internal_type."""
|
|
143
|
+
|
|
144
|
+
def decorator(func: Callable[[FieldInfo], str | None]) -> Callable[[FieldInfo], str | None]:
|
|
145
|
+
SOURCE_FIELD_MAP[internal_type] = func
|
|
146
|
+
return func
|
|
147
|
+
|
|
148
|
+
return decorator
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# --- Auto fields ---
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@_register_source("AutoField")
|
|
155
|
+
def _auto_source(info: FieldInfo) -> str:
|
|
156
|
+
return "fields.IntField(primary_key=True, generated=True)"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@_register_source("BigAutoField")
|
|
160
|
+
def _big_auto_source(info: FieldInfo) -> str:
|
|
161
|
+
return "fields.BigIntField(primary_key=True, generated=True)"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@_register_source("SmallAutoField")
|
|
165
|
+
def _small_auto_source(info: FieldInfo) -> str:
|
|
166
|
+
return "fields.SmallIntField(primary_key=True, generated=True)"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# --- Integer fields ---
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _int_field_source(tortoise_type: str) -> Callable[[FieldInfo], str | None]:
|
|
173
|
+
"""Factory for integer field source renderers."""
|
|
174
|
+
|
|
175
|
+
def renderer(info: FieldInfo) -> str | None:
|
|
176
|
+
enum_src = _try_enum_field_source(info)
|
|
177
|
+
if enum_src is not None:
|
|
178
|
+
return enum_src
|
|
179
|
+
kwargs = _common_kwargs_source(info)
|
|
180
|
+
comment = _extract_todo_comment(kwargs, info.name)
|
|
181
|
+
result = f"fields.{tortoise_type}({_format_kwargs(kwargs)})"
|
|
182
|
+
if comment:
|
|
183
|
+
result += comment
|
|
184
|
+
return result
|
|
185
|
+
|
|
186
|
+
return renderer
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
SOURCE_FIELD_MAP["IntegerField"] = _int_field_source("IntField")
|
|
190
|
+
SOURCE_FIELD_MAP["BigIntegerField"] = _int_field_source("BigIntField")
|
|
191
|
+
SOURCE_FIELD_MAP["SmallIntegerField"] = _int_field_source("SmallIntField")
|
|
192
|
+
SOURCE_FIELD_MAP["PositiveIntegerField"] = _int_field_source("IntField")
|
|
193
|
+
SOURCE_FIELD_MAP["PositiveBigIntegerField"] = _int_field_source("BigIntField")
|
|
194
|
+
SOURCE_FIELD_MAP["PositiveSmallIntegerField"] = _int_field_source("SmallIntField")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# --- String fields ---
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@_register_source("CharField")
|
|
201
|
+
def _char_source(info: FieldInfo) -> str | None:
|
|
202
|
+
enum_src = _try_enum_field_source(info)
|
|
203
|
+
if enum_src is not None:
|
|
204
|
+
return enum_src
|
|
205
|
+
kwargs = _common_kwargs_source(info)
|
|
206
|
+
comment = _extract_todo_comment(kwargs, info.name)
|
|
207
|
+
kwargs["max_length"] = repr(info.max_length or 255)
|
|
208
|
+
result = f"fields.CharField({_format_kwargs(kwargs)})"
|
|
209
|
+
if comment:
|
|
210
|
+
result += comment
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@_register_source("TextField")
|
|
215
|
+
def _text_source(info: FieldInfo) -> str | None:
|
|
216
|
+
kwargs = _common_kwargs_source(info)
|
|
217
|
+
comment = _extract_todo_comment(kwargs, info.name)
|
|
218
|
+
result = f"fields.TextField({_format_kwargs(kwargs)})"
|
|
219
|
+
if comment:
|
|
220
|
+
result += comment
|
|
221
|
+
return result
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# --- Boolean ---
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@_register_source("BooleanField")
|
|
228
|
+
def _bool_source(info: FieldInfo) -> str | None:
|
|
229
|
+
kwargs = _common_kwargs_source(info)
|
|
230
|
+
comment = _extract_todo_comment(kwargs, info.name)
|
|
231
|
+
result = f"fields.BooleanField({_format_kwargs(kwargs)})"
|
|
232
|
+
if comment:
|
|
233
|
+
result += comment
|
|
234
|
+
return result
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# --- Date/Time fields ---
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@_register_source("DateField")
|
|
241
|
+
def _date_source(info: FieldInfo) -> str | None:
|
|
242
|
+
kwargs = _common_kwargs_source(info)
|
|
243
|
+
comment = _extract_todo_comment(kwargs, info.name)
|
|
244
|
+
result = f"fields.DateField({_format_kwargs(kwargs)})"
|
|
245
|
+
if comment:
|
|
246
|
+
result += comment
|
|
247
|
+
return result
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@_register_source("DateTimeField")
|
|
251
|
+
def _datetime_source(info: FieldInfo) -> str | None:
|
|
252
|
+
kwargs = _common_kwargs_source(info)
|
|
253
|
+
comment = _extract_todo_comment(kwargs, info.name)
|
|
254
|
+
result = f"fields.DatetimeField({_format_kwargs(kwargs)})"
|
|
255
|
+
if comment:
|
|
256
|
+
result += comment
|
|
257
|
+
return result
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@_register_source("TimeField")
|
|
261
|
+
def _time_source(info: FieldInfo) -> str | None:
|
|
262
|
+
kwargs = _common_kwargs_source(info)
|
|
263
|
+
comment = _extract_todo_comment(kwargs, info.name)
|
|
264
|
+
result = f"fields.TimeField({_format_kwargs(kwargs)})"
|
|
265
|
+
if comment:
|
|
266
|
+
result += comment
|
|
267
|
+
return result
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@_register_source("DurationField")
|
|
271
|
+
def _duration_source(info: FieldInfo) -> str | None:
|
|
272
|
+
kwargs = _common_kwargs_source(info)
|
|
273
|
+
comment = _extract_todo_comment(kwargs, info.name)
|
|
274
|
+
result = f"fields.TimeDeltaField({_format_kwargs(kwargs)})"
|
|
275
|
+
if comment:
|
|
276
|
+
result += comment
|
|
277
|
+
return result
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# --- Numeric fields ---
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@_register_source("DecimalField")
|
|
284
|
+
def _decimal_source(info: FieldInfo) -> str | None:
|
|
285
|
+
kwargs = _common_kwargs_source(info)
|
|
286
|
+
comment = _extract_todo_comment(kwargs, info.name)
|
|
287
|
+
kwargs["max_digits"] = repr(info.max_digits)
|
|
288
|
+
kwargs["decimal_places"] = repr(info.decimal_places)
|
|
289
|
+
result = f"fields.DecimalField({_format_kwargs(kwargs)})"
|
|
290
|
+
if comment:
|
|
291
|
+
result += comment
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@_register_source("FloatField")
|
|
296
|
+
def _float_source(info: FieldInfo) -> str | None:
|
|
297
|
+
kwargs = _common_kwargs_source(info)
|
|
298
|
+
comment = _extract_todo_comment(kwargs, info.name)
|
|
299
|
+
result = f"fields.FloatField({_format_kwargs(kwargs)})"
|
|
300
|
+
if comment:
|
|
301
|
+
result += comment
|
|
302
|
+
return result
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# --- Binary / UUID / JSON fields ---
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@_register_source("BinaryField")
|
|
309
|
+
def _binary_source(info: FieldInfo) -> str | None:
|
|
310
|
+
kwargs = _common_kwargs_source(info)
|
|
311
|
+
comment = _extract_todo_comment(kwargs, info.name)
|
|
312
|
+
result = f"fields.BinaryField({_format_kwargs(kwargs)})"
|
|
313
|
+
if comment:
|
|
314
|
+
result += comment
|
|
315
|
+
return result
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@_register_source("UUIDField")
|
|
319
|
+
def _uuid_source(info: FieldInfo) -> str | None:
|
|
320
|
+
kwargs = _common_kwargs_source(info)
|
|
321
|
+
comment = _extract_todo_comment(kwargs, info.name)
|
|
322
|
+
result = f"fields.UUIDField({_format_kwargs(kwargs)})"
|
|
323
|
+
if comment:
|
|
324
|
+
result += comment
|
|
325
|
+
return result
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@_register_source("JSONField")
|
|
329
|
+
def _json_source(info: FieldInfo) -> str | None:
|
|
330
|
+
kwargs = _common_kwargs_source(info)
|
|
331
|
+
comment = _extract_todo_comment(kwargs, info.name)
|
|
332
|
+
result = f"fields.JSONField({_format_kwargs(kwargs)})"
|
|
333
|
+
if comment:
|
|
334
|
+
result += comment
|
|
335
|
+
return result
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# --- File / path fields (approximate: stored as CharField) ---
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _file_like_source(default_max: int) -> Callable[[FieldInfo], str | None]:
|
|
342
|
+
"""Factory for file-like field source renderers (map to CharField)."""
|
|
343
|
+
|
|
344
|
+
def renderer(info: FieldInfo) -> str | None:
|
|
345
|
+
kwargs = _common_kwargs_source(info)
|
|
346
|
+
comment = _extract_todo_comment(kwargs, info.name)
|
|
347
|
+
kwargs["max_length"] = repr(info.max_length or default_max)
|
|
348
|
+
result = f"fields.CharField({_format_kwargs(kwargs)})"
|
|
349
|
+
if comment:
|
|
350
|
+
result += comment
|
|
351
|
+
return result
|
|
352
|
+
|
|
353
|
+
return renderer
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
SOURCE_FIELD_MAP["FileField"] = _file_like_source(100)
|
|
357
|
+
SOURCE_FIELD_MAP["ImageField"] = _file_like_source(100)
|
|
358
|
+
SOURCE_FIELD_MAP["FilePathField"] = _file_like_source(100)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# --- Specialised string fields ---
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _char_like_source(default_max: int) -> Callable[[FieldInfo], str | None]:
|
|
365
|
+
"""Factory for char-like field source renderers."""
|
|
366
|
+
|
|
367
|
+
def renderer(info: FieldInfo) -> str | None:
|
|
368
|
+
kwargs = _common_kwargs_source(info)
|
|
369
|
+
comment = _extract_todo_comment(kwargs, info.name)
|
|
370
|
+
kwargs["max_length"] = repr(info.max_length or default_max)
|
|
371
|
+
result = f"fields.CharField({_format_kwargs(kwargs)})"
|
|
372
|
+
if comment:
|
|
373
|
+
result += comment
|
|
374
|
+
return result
|
|
375
|
+
|
|
376
|
+
return renderer
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
SOURCE_FIELD_MAP["SlugField"] = _char_like_source(50)
|
|
380
|
+
SOURCE_FIELD_MAP["EmailField"] = _char_like_source(254)
|
|
381
|
+
SOURCE_FIELD_MAP["URLField"] = _char_like_source(200)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@_register_source("GenericIPAddressField")
|
|
385
|
+
def _ip_source(info: FieldInfo) -> str | None:
|
|
386
|
+
kwargs = _common_kwargs_source(info)
|
|
387
|
+
comment = _extract_todo_comment(kwargs, info.name)
|
|
388
|
+
kwargs["max_length"] = "39"
|
|
389
|
+
result = f"fields.CharField({_format_kwargs(kwargs)})"
|
|
390
|
+
if comment:
|
|
391
|
+
result += comment
|
|
392
|
+
return result
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ---------------------------------------------------------------------------
|
|
396
|
+
# Data field source rendering
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def render_field_source(field_info: FieldInfo) -> str | None:
|
|
401
|
+
"""
|
|
402
|
+
Render a non-relational field to source code.
|
|
403
|
+
|
|
404
|
+
Returns ``None`` if no renderer is registered for the field's internal_type.
|
|
405
|
+
"""
|
|
406
|
+
renderer = SOURCE_FIELD_MAP.get(field_info.internal_type)
|
|
407
|
+
if renderer is None:
|
|
408
|
+
logger.warning(
|
|
409
|
+
"Unsupported Django field type '%s' on field '%s'. Skipping.",
|
|
410
|
+
field_info.internal_type,
|
|
411
|
+
field_info.name,
|
|
412
|
+
)
|
|
413
|
+
return None
|
|
414
|
+
return renderer(field_info)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# ---------------------------------------------------------------------------
|
|
418
|
+
# Relational field source rendering
|
|
419
|
+
# ---------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _render_fk_source(field_info: FieldInfo, target_ref: str) -> str:
|
|
423
|
+
"""Render a ForeignKeyField source string. Mirrors ``fields._build_fk``."""
|
|
424
|
+
on_delete_name = ON_DELETE_MAP.get(field_info.on_delete or "", "CASCADE")
|
|
425
|
+
|
|
426
|
+
kwargs: dict[str, str] = {}
|
|
427
|
+
if field_info.related_name and field_info.related_name != "+":
|
|
428
|
+
kwargs["related_name"] = repr(field_info.related_name)
|
|
429
|
+
else:
|
|
430
|
+
kwargs["related_name"] = "False"
|
|
431
|
+
kwargs["on_delete"] = f"OnDelete.{on_delete_name}"
|
|
432
|
+
if field_info.column:
|
|
433
|
+
kwargs["source_field"] = repr(field_info.column)
|
|
434
|
+
if field_info.null:
|
|
435
|
+
kwargs["null"] = "True"
|
|
436
|
+
|
|
437
|
+
return f'fields.ForeignKeyField("{target_ref}", {_format_kwargs(kwargs)})'
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _render_o2o_source(field_info: FieldInfo, target_ref: str) -> str:
|
|
441
|
+
"""Render a OneToOneField source string. Mirrors ``fields._build_o2o``."""
|
|
442
|
+
on_delete_name = ON_DELETE_MAP.get(field_info.on_delete or "", "CASCADE")
|
|
443
|
+
|
|
444
|
+
kwargs: dict[str, str] = {}
|
|
445
|
+
if field_info.related_name and field_info.related_name != "+":
|
|
446
|
+
kwargs["related_name"] = repr(field_info.related_name)
|
|
447
|
+
else:
|
|
448
|
+
kwargs["related_name"] = "False"
|
|
449
|
+
kwargs["on_delete"] = f"OnDelete.{on_delete_name}"
|
|
450
|
+
if field_info.column:
|
|
451
|
+
kwargs["source_field"] = repr(field_info.column)
|
|
452
|
+
if field_info.null:
|
|
453
|
+
kwargs["null"] = "True"
|
|
454
|
+
|
|
455
|
+
return f'fields.OneToOneField("{target_ref}", {_format_kwargs(kwargs)})'
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _render_m2m_source(field_info: FieldInfo, target_ref: str) -> str:
|
|
459
|
+
"""Render a ManyToManyField source string. Mirrors ``fields._build_m2m``."""
|
|
460
|
+
kwargs: dict[str, str] = {}
|
|
461
|
+
if field_info.related_name and field_info.related_name != "+":
|
|
462
|
+
kwargs["related_name"] = repr(field_info.related_name)
|
|
463
|
+
else:
|
|
464
|
+
kwargs["related_name"] = "False"
|
|
465
|
+
if field_info.through_db_table:
|
|
466
|
+
kwargs["through"] = repr(field_info.through_db_table)
|
|
467
|
+
|
|
468
|
+
return f'fields.ManyToManyField("{target_ref}", {_format_kwargs(kwargs)})'
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def render_relation_field_source(
|
|
472
|
+
field_info: FieldInfo,
|
|
473
|
+
tortoise_app_name: str,
|
|
474
|
+
class_name_map: dict[type, str],
|
|
475
|
+
) -> tuple[str, str] | None:
|
|
476
|
+
"""
|
|
477
|
+
Render a relational field to source code.
|
|
478
|
+
|
|
479
|
+
Returns ``(field_name, source_string)`` or ``None`` if the target model
|
|
480
|
+
is not in *class_name_map*.
|
|
481
|
+
"""
|
|
482
|
+
target_model = field_info.related_model
|
|
483
|
+
if target_model is None:
|
|
484
|
+
logger.warning(
|
|
485
|
+
"Relation field '%s' has no related model. Skipping.",
|
|
486
|
+
field_info.name,
|
|
487
|
+
)
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
tortoise_class_name = class_name_map.get(target_model)
|
|
491
|
+
if tortoise_class_name is None:
|
|
492
|
+
logger.warning(
|
|
493
|
+
"Relation field '%s' points to unregistered/excluded model '%s'. Skipping.",
|
|
494
|
+
field_info.name,
|
|
495
|
+
field_info.related_model_label,
|
|
496
|
+
)
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
target_ref = f"{tortoise_app_name}.{tortoise_class_name}"
|
|
500
|
+
|
|
501
|
+
internal_type = field_info.internal_type
|
|
502
|
+
if internal_type == "ForeignKey":
|
|
503
|
+
return (field_info.name, _render_fk_source(field_info, target_ref))
|
|
504
|
+
elif internal_type == "OneToOneField":
|
|
505
|
+
return (field_info.name, _render_o2o_source(field_info, target_ref))
|
|
506
|
+
elif field_info.many_to_many:
|
|
507
|
+
return (field_info.name, _render_m2m_source(field_info, target_ref))
|
|
508
|
+
|
|
509
|
+
return None
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
# ---------------------------------------------------------------------------
|
|
513
|
+
# Model source rendering
|
|
514
|
+
# ---------------------------------------------------------------------------
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def render_model_source(
|
|
518
|
+
model_info: ModelInfo,
|
|
519
|
+
tortoise_app_name: str,
|
|
520
|
+
class_name_map: dict[type, str],
|
|
521
|
+
) -> ModelSourceResult | None:
|
|
522
|
+
"""
|
|
523
|
+
Render a full Tortoise model class definition as source code.
|
|
524
|
+
|
|
525
|
+
Returns ``None`` if the model has no convertible data fields.
|
|
526
|
+
"""
|
|
527
|
+
imports: set[str] = set()
|
|
528
|
+
imports.add("from tortoise import fields")
|
|
529
|
+
imports.add("from tortoise.models import Model")
|
|
530
|
+
|
|
531
|
+
class_name = f"{model_info.model_class.__name__}Tortoise"
|
|
532
|
+
field_lines: list[str] = []
|
|
533
|
+
converted_names: set[str] = set()
|
|
534
|
+
skipped_fields: list[str] = []
|
|
535
|
+
has_relations = False
|
|
536
|
+
|
|
537
|
+
# Data fields
|
|
538
|
+
for fi in model_info.fields:
|
|
539
|
+
if fi.is_relation:
|
|
540
|
+
continue
|
|
541
|
+
source = render_field_source(fi)
|
|
542
|
+
if source is None:
|
|
543
|
+
skipped_fields.append(fi.name)
|
|
544
|
+
continue
|
|
545
|
+
field_lines.append(f" {fi.name} = {source}")
|
|
546
|
+
converted_names.add(fi.name)
|
|
547
|
+
|
|
548
|
+
# Track enum imports
|
|
549
|
+
if fi.enum_type is not None:
|
|
550
|
+
model_module = model_info.model_class.__module__
|
|
551
|
+
enum_class_name = fi.enum_type.__name__
|
|
552
|
+
imports.add(f"from {model_module} import {enum_class_name}")
|
|
553
|
+
|
|
554
|
+
# Track qualified callable imports (e.g., uuid.uuid4)
|
|
555
|
+
if fi.has_default and fi.default in _QUALIFIED_CALLABLES:
|
|
556
|
+
module_name = _QUALIFIED_CALLABLES[fi.default][0]
|
|
557
|
+
imports.add(f"import {module_name}")
|
|
558
|
+
|
|
559
|
+
if not converted_names:
|
|
560
|
+
logger.warning(
|
|
561
|
+
"Model '%s.%s' has no convertible fields. Skipping.",
|
|
562
|
+
model_info.app_label,
|
|
563
|
+
model_info.model_name,
|
|
564
|
+
)
|
|
565
|
+
return None
|
|
566
|
+
|
|
567
|
+
if skipped_fields:
|
|
568
|
+
for name in skipped_fields:
|
|
569
|
+
field_lines.append(f" # Skipped unsupported field: {name}")
|
|
570
|
+
|
|
571
|
+
# Relational fields
|
|
572
|
+
for fi in model_info.fields:
|
|
573
|
+
if not fi.is_relation:
|
|
574
|
+
continue
|
|
575
|
+
result = render_relation_field_source(fi, tortoise_app_name, class_name_map)
|
|
576
|
+
if result is None:
|
|
577
|
+
continue
|
|
578
|
+
field_name, source = result
|
|
579
|
+
field_lines.append(f" {field_name} = {source}")
|
|
580
|
+
converted_names.add(field_name)
|
|
581
|
+
has_relations = True
|
|
582
|
+
|
|
583
|
+
if has_relations:
|
|
584
|
+
imports.add("from tortoise.fields.relational import OnDelete")
|
|
585
|
+
|
|
586
|
+
# Meta class
|
|
587
|
+
meta_lines: list[str] = []
|
|
588
|
+
meta_lines.append(f' table = "{model_info.db_table}"')
|
|
589
|
+
meta_lines.append(f' app = "{tortoise_app_name}"')
|
|
590
|
+
|
|
591
|
+
if model_info.unique_together:
|
|
592
|
+
valid_constraints: list[tuple[str, ...]] = []
|
|
593
|
+
for constraint in model_info.unique_together:
|
|
594
|
+
missing = [f for f in constraint if f not in converted_names]
|
|
595
|
+
if missing:
|
|
596
|
+
logger.warning(
|
|
597
|
+
"unique_together constraint %s on '%s.%s' references "
|
|
598
|
+
"unconverted fields %s; omitting constraint.",
|
|
599
|
+
constraint,
|
|
600
|
+
model_info.app_label,
|
|
601
|
+
model_info.model_name,
|
|
602
|
+
missing,
|
|
603
|
+
)
|
|
604
|
+
else:
|
|
605
|
+
valid_constraints.append(tuple(constraint))
|
|
606
|
+
if valid_constraints:
|
|
607
|
+
meta_lines.append(f" unique_together = {valid_constraints!r}")
|
|
608
|
+
|
|
609
|
+
# Assemble class source
|
|
610
|
+
lines: list[str] = []
|
|
611
|
+
lines.append(f"class {class_name}(Model):")
|
|
612
|
+
lines.extend(field_lines)
|
|
613
|
+
lines.append("")
|
|
614
|
+
lines.append(" class Meta:")
|
|
615
|
+
lines.extend(meta_lines)
|
|
616
|
+
|
|
617
|
+
source = "\n".join(lines)
|
|
618
|
+
return ModelSourceResult(class_name=class_name, source=source, imports=imports)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
# ---------------------------------------------------------------------------
|
|
622
|
+
# App module rendering
|
|
623
|
+
# ---------------------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def render_app_module(models: list[ModelSourceResult], app_label: str) -> str:
|
|
627
|
+
"""
|
|
628
|
+
Combine multiple ``ModelSourceResult`` objects into a complete Python module.
|
|
629
|
+
|
|
630
|
+
Produces a self-contained ``.py`` file with a header comment, merged
|
|
631
|
+
imports, and all model class definitions separated by blank lines.
|
|
632
|
+
"""
|
|
633
|
+
# Header
|
|
634
|
+
header = "# Auto-generated by django-tortoise-objects. Do not edit manually."
|
|
635
|
+
|
|
636
|
+
# Merge and sort imports
|
|
637
|
+
all_imports: set[str] = set()
|
|
638
|
+
for model in models:
|
|
639
|
+
all_imports.update(model.imports)
|
|
640
|
+
sorted_imports = sorted(all_imports)
|
|
641
|
+
|
|
642
|
+
# Combine model sources
|
|
643
|
+
model_sources = [m.source for m in models]
|
|
644
|
+
|
|
645
|
+
parts: list[str] = [header, ""]
|
|
646
|
+
parts.extend(sorted_imports)
|
|
647
|
+
parts.append("")
|
|
648
|
+
parts.append("")
|
|
649
|
+
parts.append("\n\n\n".join(model_sources))
|
|
650
|
+
parts.append("")
|
|
651
|
+
|
|
652
|
+
return "\n".join(parts)
|