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,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}")