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.
@@ -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)