oxyde-postgres 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.
Files changed (66) hide show
  1. oxyde/__init__.py +151 -0
  2. oxyde/_msgpack.py +3 -0
  3. oxyde/cli/__init__.py +7 -0
  4. oxyde/cli/app.py +107 -0
  5. oxyde/cli/codegen.py +66 -0
  6. oxyde/cli/config.py +84 -0
  7. oxyde/cli/migrations.py +536 -0
  8. oxyde/codegen/__init__.py +15 -0
  9. oxyde/codegen/stub_generator.py +1229 -0
  10. oxyde/core/__init__.py +90 -0
  11. oxyde/core/column_types.py +367 -0
  12. oxyde/core/ir.py +380 -0
  13. oxyde/core/types.py +69 -0
  14. oxyde/core/wrapper.py +242 -0
  15. oxyde/db/__init__.py +230 -0
  16. oxyde/db/pool.py +305 -0
  17. oxyde/db/registry.py +113 -0
  18. oxyde/db/schema.py +51 -0
  19. oxyde/db/transaction.py +392 -0
  20. oxyde/exceptions.py +89 -0
  21. oxyde/migrations/__init__.py +49 -0
  22. oxyde/migrations/config.py +187 -0
  23. oxyde/migrations/context.py +598 -0
  24. oxyde/migrations/executor.py +456 -0
  25. oxyde/migrations/extract.py +316 -0
  26. oxyde/migrations/generator.py +534 -0
  27. oxyde/migrations/replay.py +367 -0
  28. oxyde/migrations/squash.py +109 -0
  29. oxyde/migrations/tracker.py +162 -0
  30. oxyde/migrations/utils.py +145 -0
  31. oxyde/models/__init__.py +83 -0
  32. oxyde/models/base.py +871 -0
  33. oxyde/models/decorators.py +111 -0
  34. oxyde/models/field.py +204 -0
  35. oxyde/models/lookups.py +667 -0
  36. oxyde/models/metadata.py +174 -0
  37. oxyde/models/registry.py +170 -0
  38. oxyde/models/serializers.py +144 -0
  39. oxyde/models/utils.py +116 -0
  40. oxyde/py.typed +0 -0
  41. oxyde/queries/__init__.py +79 -0
  42. oxyde/queries/aggregates.py +145 -0
  43. oxyde/queries/base.py +249 -0
  44. oxyde/queries/conditions.py +92 -0
  45. oxyde/queries/expressions.py +171 -0
  46. oxyde/queries/insert.py +124 -0
  47. oxyde/queries/joins.py +66 -0
  48. oxyde/queries/manager.py +409 -0
  49. oxyde/queries/mixins/__init__.py +68 -0
  50. oxyde/queries/mixins/aggregation.py +335 -0
  51. oxyde/queries/mixins/debug.py +154 -0
  52. oxyde/queries/mixins/execution.py +701 -0
  53. oxyde/queries/mixins/filtering.py +144 -0
  54. oxyde/queries/mixins/joining.py +160 -0
  55. oxyde/queries/mixins/mutation.py +508 -0
  56. oxyde/queries/mixins/pagination.py +145 -0
  57. oxyde/queries/q.py +250 -0
  58. oxyde/queries/raw.py +114 -0
  59. oxyde/queries/select.py +345 -0
  60. oxyde/queries/typed.py +213 -0
  61. oxyde_postgres-0.1.0.dist-info/METADATA +141 -0
  62. oxyde_postgres-0.1.0.dist-info/RECORD +66 -0
  63. oxyde_postgres-0.1.0.dist-info/WHEEL +5 -0
  64. oxyde_postgres-0.1.0.dist-info/entry_points.txt +2 -0
  65. oxyde_postgres-0.1.0.dist-info/licenses/LICENSE +21 -0
  66. oxyde_postgres-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1229 @@
1
+ """Generator for .pyi stub files with type hints for model Queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import copy
7
+ import inspect
8
+ import re
9
+ import sys
10
+ import types
11
+ from datetime import date, datetime, time
12
+ from decimal import Decimal
13
+ from pathlib import Path
14
+ from typing import Any, ForwardRef, Literal, NamedTuple, Union, get_args, get_origin
15
+ from uuid import UUID
16
+
17
+ from oxyde.models.base import Model
18
+ from oxyde.models.lookups import (
19
+ ARRAY_LOOKUPS,
20
+ _allowed_lookups_for_meta,
21
+ _field_spec,
22
+ _lookup_category,
23
+ _resolve_column_meta,
24
+ _supports_array_lookups,
25
+ )
26
+ from oxyde.models.registry import registered_tables
27
+ from oxyde.models.utils import _unpack_annotated, _unwrap_optional
28
+
29
+
30
+ def _get_python_type_name(python_type: Any) -> str:
31
+ """Get string representation of Python type for stub file."""
32
+ if python_type is str:
33
+ return "str"
34
+ elif python_type is int:
35
+ return "int"
36
+ elif python_type is float:
37
+ return "float"
38
+ elif python_type is bool:
39
+ return "bool"
40
+ elif python_type is bytes:
41
+ return "bytes"
42
+ elif python_type is datetime:
43
+ return "datetime"
44
+ elif python_type is date:
45
+ return "date"
46
+ elif python_type is time:
47
+ return "time"
48
+ elif python_type is Decimal:
49
+ return "Decimal"
50
+ elif python_type is UUID:
51
+ return "UUID"
52
+ elif python_type is type(None):
53
+ return "None"
54
+ elif python_type is Ellipsis:
55
+ return "..."
56
+ elif isinstance(python_type, ForwardRef):
57
+ # Unresolved forward reference (e.g. reverse FK to a class defined
58
+ # later in the module). Its target name is already stub-safe.
59
+ return python_type.__forward_arg__
60
+ elif isinstance(python_type, str):
61
+ # Unevaluated string annotation. Emit verbatim.
62
+ return python_type
63
+ elif isinstance(python_type, type) and issubclass(python_type, Model):
64
+ # FK/relation field pointing to another model
65
+ return python_type.__name__
66
+ elif (origin := get_origin(python_type)) is not None:
67
+ # Parameterized generics — rendered recursively so type arguments
68
+ # referencing user models come out unqualified (list[Post], not
69
+ # list[some_module.Post] as str() would produce).
70
+ if origin is Literal:
71
+ values = ", ".join(repr(arg) for arg in get_args(python_type))
72
+ return f"Literal[{values}]"
73
+ if origin is Union or origin is types.UnionType:
74
+ return " | ".join(_get_python_type_name(a) for a in get_args(python_type))
75
+ args = ", ".join(_get_python_type_name(a) for a in get_args(python_type))
76
+ origin_name = getattr(origin, "__name__", None) or str(origin).replace(
77
+ "typing.", ""
78
+ )
79
+ return f"{origin_name}[{args}]"
80
+ elif isinstance(python_type, type):
81
+ # Bare built-in types (dict, list, set, tuple, etc.)
82
+ return python_type.__name__
83
+ else:
84
+ # Other complex types (e.g. typing constructs)
85
+ return str(python_type).replace("typing.", "")
86
+
87
+
88
+ class _FilterFieldSpec(NamedTuple):
89
+ type_name: str
90
+ lookups: list[str]
91
+ # Value type of contains/contained_by/overlap on array and JSON fields
92
+ array_value_type: str | None = None
93
+ # A str field stored as JSON: `contains` also takes a string (LIKE)
94
+ string_contains: bool = False
95
+
96
+
97
+ def _array_value_type(python_type: Any) -> str:
98
+ """Operand type of an array lookup; ``Any`` elements unless a list[T] field.
99
+
100
+ ``None`` matches a null element, so elements may be ``None``. ``list`` is
101
+ invariant, so ``list[T]`` stays for a ``list[T]`` variable, and mypy infers
102
+ ``[None]`` as ``list[None]``; ``tuple`` is covariant and needs one form.
103
+ """
104
+ if get_origin(python_type) is list and (args := get_args(python_type)):
105
+ element, _ = _unwrap_optional(_unpack_annotated(args[0])[0])
106
+ name = _get_python_type_name(element)
107
+ return (
108
+ f"list[{name}] | list[{name} | None] | list[None]"
109
+ f" | tuple[{name} | None, ...]"
110
+ )
111
+ return "list[Any] | tuple[Any, ...]"
112
+
113
+
114
+ def _filter_field_specs(model_class: type[Model]) -> dict[str, _FilterFieldSpec]:
115
+ """Per-field filter value type and allowed lookups.
116
+
117
+ Lookups come from the runtime lookup tables (``_allowed_lookups_for_meta``),
118
+ so the stub can never drift from what ``filter()`` actually accepts. For FK
119
+ fields the value type is the *primary-key* type of the synthetic column
120
+ (``filter(author=5)``): the runtime compares against the pk value and
121
+ cannot serialize a model instance.
122
+ """
123
+ from oxyde.models.field import OxydeFieldInfo
124
+
125
+ specs: dict[str, _FilterFieldSpec] = {}
126
+ for field_name, field_info in model_class.model_fields.items():
127
+ # Virtual fields (reverse FK, m2m) are not filterable columns
128
+ if isinstance(field_info, OxydeFieldInfo):
129
+ if getattr(field_info, "db_reverse_fk", None) or getattr(
130
+ field_info, "db_m2m", False
131
+ ):
132
+ continue
133
+
134
+ meta = _resolve_column_meta(model_class, field_name)
135
+ spec = _field_spec(model_class, meta)
136
+ lookups = _allowed_lookups_for_meta(meta, spec)
137
+
138
+ if meta.foreign_key is not None:
139
+ synthetic = model_class.model_fields.get(meta.db_column)
140
+ if synthetic is not None and synthetic is not field_info:
141
+ base_hint, _ = _unpack_annotated(synthetic.annotation)
142
+ python_type, _ = _unwrap_optional(base_hint)
143
+ else:
144
+ python_type = int
145
+ else:
146
+ base_hint, _ = _unpack_annotated(field_info.annotation)
147
+ python_type, _ = _unwrap_optional(base_hint)
148
+
149
+ array_value_type = None
150
+ if _supports_array_lookups(meta, spec):
151
+ array_value_type = _array_value_type(python_type)
152
+ specs[field_name] = _FilterFieldSpec(
153
+ _get_python_type_name(python_type),
154
+ lookups,
155
+ array_value_type,
156
+ string_contains=_lookup_category(meta) == "string",
157
+ )
158
+
159
+ return specs
160
+
161
+
162
+ def _get_field_info(model_class: type[Model]) -> dict[str, tuple[Any, bool]]:
163
+ """
164
+ Get field info from model_fields, returns dict of field_name -> (python_type, is_pk).
165
+
166
+ Uses model_fields (Pydantic) as primary source, with _db_meta as fallback for PK info.
167
+ Excludes virtual fields (reverse FK, m2m) that don't map to DB columns.
168
+ """
169
+ from oxyde.models.field import OxydeFieldInfo
170
+
171
+ result = {}
172
+
173
+ for field_name, field_info in model_class.model_fields.items():
174
+ # Skip virtual fields (reverse FK, m2m) - they don't map to DB columns
175
+ if isinstance(field_info, OxydeFieldInfo):
176
+ if getattr(field_info, "db_reverse_fk", None) or getattr(
177
+ field_info, "db_m2m", False
178
+ ):
179
+ continue
180
+
181
+ # Get python type from annotation
182
+ annotation = field_info.annotation
183
+ base_hint, _ = _unpack_annotated(annotation)
184
+ python_type, _ = _unwrap_optional(base_hint)
185
+
186
+ # Check if primary key
187
+ is_pk = False
188
+ if isinstance(field_info, OxydeFieldInfo):
189
+ is_pk = field_info.db_pk or False
190
+ else:
191
+ json_extra = getattr(field_info, "json_schema_extra", None)
192
+ if isinstance(json_extra, dict):
193
+ is_pk = json_extra.get("db_pk", False)
194
+
195
+ result[field_name] = (python_type, is_pk)
196
+
197
+ return result
198
+
199
+
200
+ # Field names that would collide with service parameters in the generated
201
+ # method signatures (filter/exclude and the get-family share one parameter
202
+ # block). Colliding fields are not enumerated — they stay reachable through
203
+ # ``**kwargs``, which mirrors the runtime situation: terminal methods swallow
204
+ # e.g. ``client=`` as the service argument anyway.
205
+ _RESERVED_FILTER_PARAMS = frozenset(
206
+ {"self", "args", "kwargs", "client", "using", "defaults"}
207
+ )
208
+ _RESERVED_CREATE_PARAMS = frozenset({"self", "instance", "client", "using"})
209
+
210
+
211
+ def _generate_filter_params(model_class: type[Model]) -> str:
212
+ """Generate filter method parameters with all lookups.
213
+
214
+ Ends with ``**kwargs: Any`` because the runtime also accepts FK traversal
215
+ paths (``author__name="Alice"``) that cannot be enumerated statically.
216
+ """
217
+ lines = []
218
+
219
+ for field_name, spec in sorted(_filter_field_specs(model_class).items()):
220
+ type_name, lookups = spec.type_name, spec.lookups
221
+ if field_name in _RESERVED_FILTER_PARAMS:
222
+ continue
223
+
224
+ # Bare field name (implicit exact match)
225
+ lines.append(f" {field_name}: {type_name} | None = None,")
226
+
227
+ for lookup in lookups:
228
+ lookup_field = f"{field_name}__{lookup}"
229
+
230
+ # Determine type for lookup (matching what the runtime accepts:
231
+ # `in` takes any iterable, between/range/month/day take a pair or
232
+ # triple as tuple or list, year takes a bare int)
233
+ if lookup == "in":
234
+ lookup_type = f"Iterable[{type_name}] | None"
235
+ elif lookup in ("between", "range"):
236
+ lookup_type = (
237
+ f"tuple[{type_name}, {type_name}] | list[{type_name}] | None"
238
+ )
239
+ elif lookup == "isnull":
240
+ lookup_type = "bool | None"
241
+ elif lookup == "year":
242
+ lookup_type = "int | None"
243
+ elif lookup == "month":
244
+ lookup_type = "tuple[int, int] | list[int] | None"
245
+ elif lookup == "day":
246
+ lookup_type = "tuple[int, int, int] | list[int] | None"
247
+ elif lookup in ARRAY_LOOKUPS and spec.array_value_type is not None:
248
+ if lookup == "contains" and spec.string_contains:
249
+ lookup_type = f"str | {spec.array_value_type} | None"
250
+ else:
251
+ lookup_type = f"{spec.array_value_type} | None"
252
+ else:
253
+ lookup_type = f"{type_name} | None"
254
+
255
+ lines.append(f" {lookup_field}: {lookup_type} = None,")
256
+
257
+ lines.append(" **kwargs: Any,")
258
+ return "\n".join(lines)
259
+
260
+
261
+ def _generate_order_by_literal(model_class: type[Model]) -> str:
262
+ """Generate the order_by fields type (includes - prefix for DESC).
263
+
264
+ The union with ``str`` keeps the literals as autocomplete suggestions while
265
+ still accepting values the runtime supports but the stub cannot enumerate:
266
+ ``"?"`` (random ordering), ``annotate()`` aliases and FK traversal paths.
267
+ """
268
+ field_info = _get_field_info(model_class)
269
+ literals = []
270
+
271
+ for field_name in sorted(field_info.keys()):
272
+ literals.append(f'"{field_name}"')
273
+ literals.append(f'"-{field_name}"')
274
+
275
+ if not literals:
276
+ return "str" # Fallback if no fields
277
+
278
+ return f"Literal[{', '.join(literals)}] | str"
279
+
280
+
281
+ def _generate_field_literal(model_class: type[Model]) -> str:
282
+ """Generate the field-names type (for select/values/group_by).
283
+
284
+ Union with ``str`` for the same reason as ``_generate_order_by_literal``:
285
+ ``annotate()`` aliases and traversal paths are legal at runtime.
286
+ """
287
+ field_info = _get_field_info(model_class)
288
+ literals = [f'"{field_name}"' for field_name in sorted(field_info.keys())]
289
+
290
+ if not literals:
291
+ return "str" # Fallback if no fields
292
+
293
+ return f"Literal[{', '.join(literals)}] | str"
294
+
295
+
296
+ def _generate_create_params(model_class: type[Model]) -> str:
297
+ """Generate create method parameters with proper optionality."""
298
+ lines = []
299
+ field_info = _get_field_info(model_class)
300
+
301
+ for field_name, (python_type, _is_pk) in sorted(field_info.items()):
302
+ if field_name in _RESERVED_CREATE_PARAMS:
303
+ continue
304
+ type_name = _get_python_type_name(python_type)
305
+ # All create params are optional in stub (instance can be passed instead)
306
+ lines.append(f" {field_name}: {type_name} | None = None,")
307
+
308
+ return "\n".join(lines)
309
+
310
+
311
+ def _has_overload_decorator(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
312
+ """Return True if the function is decorated with ``@overload``."""
313
+ for dec in node.decorator_list:
314
+ if isinstance(dec, ast.Name) and dec.id == "overload":
315
+ return True
316
+ if isinstance(dec, ast.Attribute) and dec.attr == "overload":
317
+ return True
318
+ return False
319
+
320
+
321
+ def _stub_function_defs(body: list[ast.stmt]) -> list[str]:
322
+ """Turn the function definitions of an AST body into stub sources.
323
+
324
+ Bodies are replaced by ``...`` (stub convention); decorators and type
325
+ annotations are preserved. For ``@overload`` functions, only the
326
+ ``@overload``-decorated variants are kept; the implementation (PEP 484
327
+ requires one in a ``.py`` file but forbids it in a ``.pyi``) is dropped.
328
+ """
329
+ overloaded_names: set[str] = {
330
+ node.name
331
+ for node in body
332
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
333
+ and _has_overload_decorator(node)
334
+ }
335
+
336
+ functions: list[str] = []
337
+ for node in body:
338
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
339
+ if node.name in overloaded_names and not _has_overload_decorator(node):
340
+ continue
341
+ node_copy = copy.deepcopy(node)
342
+ node_copy.body = [ast.Expr(value=ast.Constant(value=Ellipsis))]
343
+ functions.append(ast.unparse(node_copy))
344
+ return functions
345
+
346
+
347
+ def _stubbed_class_body(body: list[ast.stmt]) -> list[ast.stmt]:
348
+ """Class body with function bodies replaced by ``...``.
349
+
350
+ Nested classes are processed recursively. Assignments are kept verbatim:
351
+ for enums the member values ARE the type interface (checkers read them for
352
+ Literal narrowing), and dataclass/plain-class attribute defaults are almost
353
+ always literals. Overload implementations are dropped as at top level.
354
+ """
355
+ overloaded_names: set[str] = {
356
+ node.name
357
+ for node in body
358
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
359
+ and _has_overload_decorator(node)
360
+ }
361
+
362
+ out: list[ast.stmt] = []
363
+ for node in body:
364
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
365
+ if node.name in overloaded_names and not _has_overload_decorator(node):
366
+ continue
367
+ node_copy = copy.deepcopy(node)
368
+ node_copy.body = [ast.Expr(value=ast.Constant(value=Ellipsis))]
369
+ out.append(node_copy)
370
+ elif isinstance(node, ast.ClassDef):
371
+ class_copy = copy.deepcopy(node)
372
+ class_copy.body = _stubbed_class_body(class_copy.body) or [ast.Pass()]
373
+ out.append(class_copy)
374
+ else:
375
+ out.append(copy.deepcopy(node))
376
+ return out or [ast.Pass()]
377
+
378
+
379
+ def _stub_class_def(class_def: ast.ClassDef) -> str:
380
+ """Stub source for a supporting top-level class (enum, dataclass, helper)."""
381
+ node_copy = copy.deepcopy(class_def)
382
+ node_copy.body = _stubbed_class_body(node_copy.body)
383
+ return ast.unparse(node_copy)
384
+
385
+
386
+ def _extract_top_level_copyable(
387
+ tree: ast.Module,
388
+ ) -> tuple[list[ast.Import | ast.ImportFrom], list[str], list[ast.ClassDef]]:
389
+ """Collect top-level imports, function stubs and class defs from source.
390
+
391
+ - ``Import`` / ``ImportFrom``: returned as AST nodes so the caller can
392
+ dedupe them against the stub skeleton imports and drop unused ones.
393
+ ``from __future__ import ...`` is dropped entirely — a ``.pyi`` never
394
+ needs it, and anywhere but the top of the file it is a syntax error.
395
+ - ``FunctionDef`` / ``AsyncFunctionDef``: stubbed via ``_stub_function_defs``.
396
+ - ``ClassDef``: returned as raw AST nodes. The caller stubs model classes
397
+ via the generator and copies supporting classes (enums used as column
398
+ types, helper classes referenced by custom methods) via
399
+ ``_stub_class_def`` when the generated body references them — a ``.pyi``
400
+ shadows the module entirely, so an unreferenced name would otherwise
401
+ break both the stub and user imports of that class.
402
+ - Top-level assignments (constants) are intentionally skipped.
403
+ """
404
+ imports: list[ast.Import | ast.ImportFrom] = [
405
+ node
406
+ for node in tree.body
407
+ if isinstance(node, (ast.Import, ast.ImportFrom))
408
+ and not (isinstance(node, ast.ImportFrom) and node.module == "__future__")
409
+ ]
410
+ class_defs = [node for node in tree.body if isinstance(node, ast.ClassDef)]
411
+ return imports, _stub_function_defs(tree.body), class_defs
412
+
413
+
414
+ # Names the generated stub skeleton may reference, grouped by stdlib module.
415
+ # Each is emitted only when the generated body actually uses it.
416
+ _STUB_HEADER_IMPORTS: tuple[tuple[str, tuple[str, ...]], ...] = (
417
+ ("collections.abc", ("Iterable",)),
418
+ ("datetime", ("date", "datetime", "time")),
419
+ ("decimal", ("Decimal",)),
420
+ ("typing", ("Any", "ClassVar", "Literal", "overload")),
421
+ ("uuid", ("UUID",)),
422
+ )
423
+
424
+
425
+ def _is_name_used(name: str, body: str) -> bool:
426
+ return re.search(rf"\b{re.escape(name)}\b", body) is not None
427
+
428
+
429
+ def _assemble_imports(
430
+ user_imports: list[ast.Import | ast.ImportFrom], body: str
431
+ ) -> list[str]:
432
+ """Build the stub import block: skeleton imports plus surviving user imports.
433
+
434
+ Skeleton names (typing/datetime/decimal/uuid + oxyde) are emitted only when
435
+ the generated body references them. User imports are kept for names the body
436
+ still needs (cross-module FK types, custom-method signatures, decorators);
437
+ unused names and duplicates of skeleton names are dropped — on a name
438
+ collision the skeleton import wins, since the generated code references the
439
+ skeleton's meaning of that name. Stdlib ``from`` imports are merged with the
440
+ skeleton block so each module is imported once.
441
+ """
442
+ stdlib_from: dict[str, set[str]] = {}
443
+ for module, names in _STUB_HEADER_IMPORTS:
444
+ used = {name for name in names if _is_name_used(name, body)}
445
+ if used:
446
+ stdlib_from[module] = used
447
+
448
+ provided: set[str] = {
449
+ "FlatValuesListQuery",
450
+ "Model",
451
+ "Query",
452
+ "QueryManager",
453
+ "ValuesListQuery",
454
+ "ValuesQuery",
455
+ }
456
+ provided.update(*stdlib_from.values())
457
+
458
+ other_lines: list[str] = []
459
+ for node in user_imports:
460
+ kept = [
461
+ alias
462
+ for alias in node.names
463
+ if (bound := alias.asname or alias.name.split(".")[0]) not in provided
464
+ and _is_name_used(bound, body)
465
+ ]
466
+ if not kept:
467
+ continue
468
+ if (
469
+ isinstance(node, ast.ImportFrom)
470
+ and node.level == 0
471
+ and node.module is not None
472
+ and node.module.split(".")[0] in sys.stdlib_module_names
473
+ and all(alias.asname is None for alias in kept)
474
+ ):
475
+ stdlib_from.setdefault(node.module, set()).update(a.name for a in kept)
476
+ continue
477
+ node_copy = copy.deepcopy(node)
478
+ node_copy.names = [copy.deepcopy(alias) for alias in kept]
479
+ other_lines.append(ast.unparse(node_copy))
480
+
481
+ lines = [
482
+ f"from {module} import {', '.join(sorted(names))}"
483
+ for module, names in sorted(stdlib_from.items())
484
+ ]
485
+ if lines:
486
+ lines.append("")
487
+ # Parenthesized because the one-line form exceeds the 88-char line limit
488
+ # and would trip isort's formatting check in user projects.
489
+ oxyde_queries_import = (
490
+ "from oxyde.queries import (\n"
491
+ " FlatValuesListQuery,\n"
492
+ " Query,\n"
493
+ " QueryManager,\n"
494
+ " ValuesListQuery,\n"
495
+ " ValuesQuery,\n"
496
+ ")"
497
+ )
498
+ lines.extend(
499
+ sorted(
500
+ [
501
+ "from oxyde import Model",
502
+ oxyde_queries_import,
503
+ *other_lines,
504
+ ]
505
+ )
506
+ )
507
+ return lines
508
+
509
+
510
+ def _extract_custom_methods(class_def: ast.ClassDef) -> list[str]:
511
+ """Extract user-defined methods from a Model ClassDef with body replaced by `...`.
512
+
513
+ Returns list of method definitions as strings, each indented relative to the
514
+ class body (i.e. without the leading class indent — caller adds it).
515
+ """
516
+ return _stub_function_defs(class_def.body)
517
+
518
+
519
+ def _generate_model_class_stub(
520
+ model_class: type[Model],
521
+ custom_methods: list[str],
522
+ ) -> str:
523
+ """Generate stub class definition for model (to avoid circular imports in .pyi)."""
524
+ model_name = model_class.__name__
525
+ lines = [f"class {model_name}(Model):"]
526
+
527
+ # Add Meta class if present
528
+ if hasattr(model_class, "Meta"):
529
+ lines.append(" class Meta:")
530
+ meta = model_class.Meta
531
+ if hasattr(meta, "is_table"):
532
+ lines.append(" is_table: bool")
533
+ if hasattr(meta, "table_name"):
534
+ lines.append(" table_name: str")
535
+ if hasattr(meta, "schema"):
536
+ lines.append(" schema: str")
537
+ if hasattr(meta, "database"):
538
+ lines.append(" database: str")
539
+
540
+ # Add field annotations. Virtual relation fields (reverse FK, m2m) are
541
+ # included too: they exist on instances (filled by prefetch()) even though
542
+ # they don't map to DB columns and are excluded from filter/select params.
543
+ # Fields with a default (or default_factory) get an `= ...` marker —
544
+ # pydantic's dataclass_transform derives the synthesized __init__ from
545
+ # these assignments, so a bare annotation would make the field a required
546
+ # constructor argument in every type checker.
547
+ for field_name, field_info in model_class.model_fields.items():
548
+ annotation = field_info.annotation
549
+ base_hint, _ = _unpack_annotated(annotation)
550
+ python_type, is_optional = _unwrap_optional(base_hint)
551
+ type_name = _get_python_type_name(python_type)
552
+
553
+ optional_suffix = " | None" if is_optional else ""
554
+ default_marker = "" if field_info.is_required() else " = ..."
555
+ lines.append(f" {field_name}: {type_name}{optional_suffix}{default_marker}")
556
+
557
+ # Copy user-defined methods (body replaced by `...`)
558
+ for method_src in custom_methods:
559
+ for raw_line in method_src.splitlines():
560
+ lines.append(f" {raw_line}" if raw_line else "")
561
+
562
+ # Add objects manager with proper type. The base Model declares
563
+ # `objects: ClassVar[QueryManager]`; narrowing it here is intentional,
564
+ # so silence pyright's invariant-variable-override check.
565
+ lines.append(
566
+ f" objects: ClassVar[{model_name}Manager]"
567
+ " # pyright: ignore[reportIncompatibleVariableOverride]"
568
+ )
569
+
570
+ return "\n".join(lines)
571
+
572
+
573
+ def generate_model_stub(model_class: type[Model]) -> str:
574
+ """Generate .pyi stub content for a single model (without imports)."""
575
+ model_name = model_class.__name__
576
+
577
+ # Generate model-dependent parameters
578
+ filter_params = _generate_filter_params(model_class)
579
+ order_by_literal = _generate_order_by_literal(model_class)
580
+ field_literal = _generate_field_literal(model_class)
581
+ create_params = _generate_create_params(model_class)
582
+
583
+ queryset_class = f"""
584
+ class {model_name}Query(Query[{model_name}]):
585
+ \"\"\"Type-safe Query for {model_name} model.\"\"\"
586
+
587
+ # Query building methods (sync, return Query)
588
+
589
+ def filter(
590
+ self,
591
+ *args: Any,
592
+ {filter_params}
593
+ ) -> {model_name}Query:
594
+ \"\"\"Filter by Q-expressions or field lookups.\"\"\"
595
+ ...
596
+
597
+ def exclude(
598
+ self,
599
+ *args: Any,
600
+ {filter_params}
601
+ ) -> {model_name}Query:
602
+ \"\"\"Exclude objects matching field lookups.\"\"\"
603
+ ...
604
+
605
+ def order_by(self, *fields: {order_by_literal}) -> {model_name}Query:
606
+ \"\"\"Order results by fields ("?" = random order).\"\"\"
607
+ ...
608
+
609
+ def limit(self, value: int) -> {model_name}Query:
610
+ \"\"\"Limit number of results.\"\"\"
611
+ ...
612
+
613
+ def offset(self, value: int) -> {model_name}Query:
614
+ \"\"\"Skip first value results.\"\"\"
615
+ ...
616
+
617
+ def distinct(self, distinct: bool = True) -> {model_name}Query:
618
+ \"\"\"Return distinct results.\"\"\"
619
+ ...
620
+
621
+ def select(self, *fields: {field_literal}) -> {model_name}Query:
622
+ \"\"\"Select specific fields.\"\"\"
623
+ ...
624
+
625
+ def join(self, *paths: str) -> {model_name}Query:
626
+ \"\"\"Perform LEFT JOIN for relations.\"\"\"
627
+ ...
628
+
629
+ def prefetch(self, *paths: str) -> {model_name}Query:
630
+ \"\"\"Prefetch related objects (separate queries).\"\"\"
631
+ ...
632
+
633
+ def for_update(self) -> {model_name}Query:
634
+ \"\"\"Add FOR UPDATE lock to query.\"\"\"
635
+ ...
636
+
637
+ def for_share(self) -> {model_name}Query:
638
+ \"\"\"Add FOR SHARE lock to query.\"\"\"
639
+ ...
640
+
641
+ def annotate(self, **annotations: Any) -> {model_name}Query:
642
+ \"\"\"Add computed fields using aggregate functions.\"\"\"
643
+ ...
644
+
645
+ def group_by(self, *fields: {field_literal}) -> {model_name}Query:
646
+ \"\"\"Add GROUP BY clause.\"\"\"
647
+ ...
648
+
649
+ def having(self, *q_exprs: Any, **kwargs: Any) -> {model_name}Query:
650
+ \"\"\"Add HAVING clause for filtering grouped results.\"\"\"
651
+ ...
652
+
653
+ def values(self, *fields: {field_literal}) -> ValuesQuery[{model_name}]:
654
+ \"\"\"Return dicts instead of models.\"\"\"
655
+ ...
656
+
657
+ @overload
658
+ def values_list(self, field: {field_literal}, /, *, flat: Literal[True]) -> FlatValuesListQuery[{model_name}]:
659
+ ...
660
+
661
+ @overload
662
+ def values_list(self, *fields: {field_literal}, flat: Literal[False] = ...) -> ValuesListQuery[{model_name}]:
663
+ ...
664
+
665
+ @overload
666
+ def values_list(self, *fields: {field_literal}, flat: bool) -> ValuesListQuery[{model_name}] | FlatValuesListQuery[{model_name}]:
667
+ ...
668
+
669
+ # Terminal methods (async, execute query)
670
+
671
+ async def all(
672
+ self,
673
+ *,
674
+ client: Any | None = None,
675
+ using: str | None = None,
676
+ ) -> list[{model_name}]:
677
+ \"\"\"Execute query and return all results.\"\"\"
678
+ ...
679
+
680
+ async def first(
681
+ self,
682
+ *,
683
+ client: Any | None = None,
684
+ using: str | None = None,
685
+ ) -> {model_name} | None:
686
+ \"\"\"Execute query and return first result.\"\"\"
687
+ ...
688
+
689
+ async def last(
690
+ self,
691
+ *,
692
+ client: Any | None = None,
693
+ using: str | None = None,
694
+ ) -> {model_name} | None:
695
+ \"\"\"Execute query and return last result.\"\"\"
696
+ ...
697
+
698
+ async def count(
699
+ self,
700
+ *,
701
+ client: Any | None = None,
702
+ using: str | None = None,
703
+ ) -> int:
704
+ \"\"\"Count matching objects.\"\"\"
705
+ ...
706
+
707
+ async def sum(
708
+ self,
709
+ field: str,
710
+ *,
711
+ client: Any | None = None,
712
+ using: str | None = None,
713
+ ) -> Any:
714
+ \"\"\"Calculate sum of field values.\"\"\"
715
+ ...
716
+
717
+ async def avg(
718
+ self,
719
+ field: str,
720
+ *,
721
+ client: Any | None = None,
722
+ using: str | None = None,
723
+ ) -> Any:
724
+ \"\"\"Calculate average of field values.\"\"\"
725
+ ...
726
+
727
+ async def max(
728
+ self,
729
+ field: str,
730
+ *,
731
+ client: Any | None = None,
732
+ using: str | None = None,
733
+ ) -> Any:
734
+ \"\"\"Get maximum field value.\"\"\"
735
+ ...
736
+
737
+ async def min(
738
+ self,
739
+ field: str,
740
+ *,
741
+ client: Any | None = None,
742
+ using: str | None = None,
743
+ ) -> Any:
744
+ \"\"\"Get minimum field value.\"\"\"
745
+ ...
746
+
747
+ async def exists(
748
+ self,
749
+ *,
750
+ client: Any | None = None,
751
+ using: str | None = None,
752
+ ) -> bool:
753
+ \"\"\"Check if any records match the query.\"\"\"
754
+ ...
755
+
756
+ async def get(
757
+ self,
758
+ *,
759
+ client: Any | None = None,
760
+ using: str | None = None,
761
+ ) -> {model_name}:
762
+ \"\"\"Return exactly one result (raises if none or many).\"\"\"
763
+ ...
764
+
765
+ async def get_or_none(
766
+ self,
767
+ *,
768
+ client: Any | None = None,
769
+ using: str | None = None,
770
+ ) -> {model_name} | None:
771
+ \"\"\"Return one result or None (raises if many).\"\"\"
772
+ ...
773
+
774
+ @overload
775
+ async def update(
776
+ self,
777
+ *,
778
+ returning: Literal[True],
779
+ client: Any | None = ...,
780
+ using: str | None = ...,
781
+ **values: Any,
782
+ ) -> list[{model_name}]:
783
+ ...
784
+
785
+ @overload
786
+ async def update(
787
+ self,
788
+ *,
789
+ returning: Literal[False] = ...,
790
+ client: Any | None = ...,
791
+ using: str | None = ...,
792
+ **values: Any,
793
+ ) -> int:
794
+ ...
795
+
796
+ async def increment(
797
+ self,
798
+ field: str,
799
+ by: int | float = 1,
800
+ *,
801
+ client: Any | None = None,
802
+ using: str | None = None,
803
+ ) -> int:
804
+ \"\"\"Atomically increment a field value.\"\"\"
805
+ ...
806
+
807
+ async def delete(
808
+ self,
809
+ *,
810
+ client: Any | None = None,
811
+ using: str | None = None,
812
+ ) -> int:
813
+ \"\"\"Delete matching objects.\"\"\"
814
+ ...
815
+ """
816
+
817
+ # Generate Manager class
818
+ manager_class = f"""
819
+ class {model_name}Manager(QueryManager[{model_name}]):
820
+ \"\"\"Type-safe Manager for {model_name} model.\"\"\"
821
+
822
+ # Query building methods (sync, return Query)
823
+
824
+ def query(self) -> {model_name}Query:
825
+ \"\"\"Return a Query builder for this model.\"\"\"
826
+ ...
827
+
828
+ def filter(
829
+ self,
830
+ *args: Any,
831
+ {filter_params}
832
+ ) -> {model_name}Query:
833
+ \"\"\"Filter by Q-expressions or field lookups.\"\"\"
834
+ ...
835
+
836
+ def exclude(
837
+ self,
838
+ *args: Any,
839
+ {filter_params}
840
+ ) -> {model_name}Query:
841
+ \"\"\"Exclude objects matching field lookups.\"\"\"
842
+ ...
843
+
844
+ def values(self, *fields: {field_literal}) -> ValuesQuery[{model_name}]:
845
+ \"\"\"Return dicts instead of models.\"\"\"
846
+ ...
847
+
848
+ @overload
849
+ def values_list(self, field: {field_literal}, /, *, flat: Literal[True]) -> FlatValuesListQuery[{model_name}]:
850
+ ...
851
+
852
+ @overload
853
+ def values_list(self, *fields: {field_literal}, flat: Literal[False] = ...) -> ValuesListQuery[{model_name}]:
854
+ ...
855
+
856
+ @overload
857
+ def values_list(self, *fields: {field_literal}, flat: bool) -> ValuesListQuery[{model_name}] | FlatValuesListQuery[{model_name}]:
858
+ ...
859
+
860
+ def distinct(self, distinct: bool = True) -> {model_name}Query:
861
+ \"\"\"Return distinct results.\"\"\"
862
+ ...
863
+
864
+ def join(self, *paths: str) -> {model_name}Query:
865
+ \"\"\"Perform LEFT JOIN for relations.\"\"\"
866
+ ...
867
+
868
+ def prefetch(self, *paths: str) -> {model_name}Query:
869
+ \"\"\"Prefetch related objects (separate queries).\"\"\"
870
+ ...
871
+
872
+ def for_update(self) -> {model_name}Query:
873
+ \"\"\"Add FOR UPDATE lock to query.\"\"\"
874
+ ...
875
+
876
+ def for_share(self) -> {model_name}Query:
877
+ \"\"\"Add FOR SHARE lock to query.\"\"\"
878
+ ...
879
+
880
+ def order_by(self, *fields: {order_by_literal}) -> {model_name}Query:
881
+ \"\"\"Order results by fields ("?" = random order).\"\"\"
882
+ ...
883
+
884
+ def limit(self, value: int) -> {model_name}Query:
885
+ \"\"\"Limit number of results.\"\"\"
886
+ ...
887
+
888
+ def offset(self, value: int) -> {model_name}Query:
889
+ \"\"\"Skip first value results.\"\"\"
890
+ ...
891
+
892
+ def annotate(self, **annotations: Any) -> {model_name}Query:
893
+ \"\"\"Add computed fields using aggregate functions.\"\"\"
894
+ ...
895
+
896
+ # Terminal methods (async, execute query)
897
+
898
+ async def get(
899
+ self,
900
+ *,
901
+ client: Any | None = None,
902
+ using: str | None = None,
903
+ {filter_params}
904
+ ) -> {model_name}:
905
+ \"\"\"Get single object matching lookups.\"\"\"
906
+ ...
907
+
908
+ async def get_or_none(
909
+ self,
910
+ *,
911
+ client: Any | None = None,
912
+ using: str | None = None,
913
+ {filter_params}
914
+ ) -> {model_name} | None:
915
+ \"\"\"Get object or None if not found.\"\"\"
916
+ ...
917
+
918
+ async def get_or_create(
919
+ self,
920
+ *,
921
+ defaults: dict[str, Any] | None = None,
922
+ client: Any | None = None,
923
+ using: str | None = None,
924
+ {filter_params}
925
+ ) -> tuple[{model_name}, bool]:
926
+ \"\"\"Get object or create if not found. Returns (object, created).\"\"\"
927
+ ...
928
+
929
+ async def update_or_create(
930
+ self,
931
+ *,
932
+ defaults: dict[str, Any] | None = None,
933
+ client: Any | None = None,
934
+ using: str | None = None,
935
+ {filter_params}
936
+ ) -> tuple[{model_name}, bool]:
937
+ \"\"\"Get object, create if missing, or update it when defaults are provided.\"\"\"
938
+ ...
939
+
940
+ async def exists(
941
+ self,
942
+ *,
943
+ client: Any | None = None,
944
+ using: str | None = None,
945
+ ) -> bool:
946
+ \"\"\"Check if any records exist.\"\"\"
947
+ ...
948
+
949
+ @overload
950
+ async def all(
951
+ self,
952
+ *,
953
+ client: Any | None = ...,
954
+ using: str | None = ...,
955
+ mode: Literal["models"] = ...,
956
+ ) -> list[{model_name}]:
957
+ ...
958
+
959
+ @overload
960
+ async def all(
961
+ self,
962
+ *,
963
+ client: Any | None = ...,
964
+ using: str | None = ...,
965
+ mode: str,
966
+ ) -> Any:
967
+ ...
968
+
969
+ async def first(
970
+ self,
971
+ *,
972
+ client: Any | None = None,
973
+ using: str | None = None,
974
+ ) -> {model_name} | None:
975
+ \"\"\"Get first object.\"\"\"
976
+ ...
977
+
978
+ async def last(
979
+ self,
980
+ *,
981
+ client: Any | None = None,
982
+ using: str | None = None,
983
+ ) -> {model_name} | None:
984
+ \"\"\"Get last object.\"\"\"
985
+ ...
986
+
987
+ async def count(
988
+ self,
989
+ *,
990
+ client: Any | None = None,
991
+ using: str | None = None,
992
+ ) -> int:
993
+ \"\"\"Count all objects.\"\"\"
994
+ ...
995
+
996
+ async def sum(
997
+ self,
998
+ field: str,
999
+ *,
1000
+ client: Any | None = None,
1001
+ using: str | None = None,
1002
+ ) -> Any:
1003
+ \"\"\"Calculate sum of field values.\"\"\"
1004
+ ...
1005
+
1006
+ async def avg(
1007
+ self,
1008
+ field: str,
1009
+ *,
1010
+ client: Any | None = None,
1011
+ using: str | None = None,
1012
+ ) -> Any:
1013
+ \"\"\"Calculate average of field values.\"\"\"
1014
+ ...
1015
+
1016
+ async def max(
1017
+ self,
1018
+ field: str,
1019
+ *,
1020
+ client: Any | None = None,
1021
+ using: str | None = None,
1022
+ ) -> Any:
1023
+ \"\"\"Get maximum field value.\"\"\"
1024
+ ...
1025
+
1026
+ async def min(
1027
+ self,
1028
+ field: str,
1029
+ *,
1030
+ client: Any | None = None,
1031
+ using: str | None = None,
1032
+ ) -> Any:
1033
+ \"\"\"Get minimum field value.\"\"\"
1034
+ ...
1035
+
1036
+ async def create( # type: ignore[override] # ty: ignore[invalid-method-override]
1037
+ self,
1038
+ *,
1039
+ instance: {model_name} | None = None,
1040
+ client: Any | None = None,
1041
+ using: str | None = None,
1042
+ {create_params}
1043
+ ) -> {model_name}:
1044
+ \"\"\"Create new object.\"\"\"
1045
+ ...
1046
+
1047
+ async def bulk_create(
1048
+ self,
1049
+ objects: Iterable[{model_name} | dict[str, Any]],
1050
+ *,
1051
+ batch_size: int | None = None,
1052
+ client: Any | None = None,
1053
+ using: str | None = None,
1054
+ ) -> list[{model_name}]:
1055
+ \"\"\"Bulk create objects.\"\"\"
1056
+ ...
1057
+
1058
+ async def bulk_update(
1059
+ self,
1060
+ objects: Iterable[{model_name}],
1061
+ fields: Iterable[str],
1062
+ *,
1063
+ client: Any | None = None,
1064
+ using: str | None = None,
1065
+ ) -> int:
1066
+ \"\"\"Bulk update objects.\"\"\"
1067
+ ...
1068
+ """
1069
+
1070
+ return queryset_class + manager_class
1071
+
1072
+
1073
+ def _build_stub(file_path: Path, models: list[type[Model]]) -> str:
1074
+ """Build .pyi stub content for a source file that contains Model classes.
1075
+
1076
+ Policy: the .pyi covers models plus top-level functions (as stubs). From
1077
+ the source we also keep the imports still referenced by the generated body
1078
+ (cross-module FK types, custom-method signatures, decorators) and copy the
1079
+ supporting top-level classes the body references — most importantly enums
1080
+ used as column types, which appear throughout the generated filter/create
1081
+ signatures. Unreferenced classes and top-level constants are not
1082
+ propagated.
1083
+ """
1084
+ source = file_path.read_text()
1085
+ tree = ast.parse(source)
1086
+
1087
+ user_imports, function_stubs, class_defs = _extract_top_level_copyable(tree)
1088
+
1089
+ model_names = {m.__name__ for m in models}
1090
+ custom_methods_by_model: dict[str, list[str]] = {
1091
+ node.name: _extract_custom_methods(node)
1092
+ for node in class_defs
1093
+ if node.name in model_names
1094
+ }
1095
+
1096
+ body_parts: list[str] = []
1097
+ for function_stub in function_stubs:
1098
+ body_parts.append(function_stub)
1099
+ body_parts.append("")
1100
+ for model in models:
1101
+ body_parts.append(
1102
+ _generate_model_class_stub(
1103
+ model,
1104
+ custom_methods_by_model.get(model.__name__, []),
1105
+ )
1106
+ )
1107
+ body_parts.append("")
1108
+ body_parts.append(generate_model_stub(model))
1109
+ body_parts.append("")
1110
+ core_body = "\n".join(body_parts)
1111
+
1112
+ # Copy supporting classes the body references. Iterated to a fixpoint:
1113
+ # a kept class may itself reference another top-level class (an enum used
1114
+ # in a dataclass field, a base class).
1115
+ support_defs = [node for node in class_defs if node.name not in model_names]
1116
+ support_stubs: dict[str, str] = {}
1117
+ search_space = core_body
1118
+ changed = True
1119
+ while changed:
1120
+ changed = False
1121
+ for node in support_defs:
1122
+ if node.name in support_stubs:
1123
+ continue
1124
+ if _is_name_used(node.name, search_space):
1125
+ stub_src = _stub_class_def(node)
1126
+ support_stubs[node.name] = stub_src
1127
+ search_space = f"{search_space}\n{stub_src}"
1128
+ changed = True
1129
+
1130
+ support_parts: list[str] = []
1131
+ for node in support_defs:
1132
+ if node.name in support_stubs:
1133
+ support_parts.append(support_stubs[node.name])
1134
+ support_parts.append("")
1135
+ body = "\n".join([*support_parts, core_body])
1136
+
1137
+ parts: list[str] = [
1138
+ "# Auto-generated by oxyde generate-stubs",
1139
+ "# DO NOT EDIT - This file will be overwritten",
1140
+ "",
1141
+ *_assemble_imports(user_imports, body),
1142
+ "",
1143
+ body,
1144
+ ]
1145
+ return "\n".join(parts)
1146
+
1147
+
1148
+ def generate_stub_for_file(file_path: Path) -> None:
1149
+ """Generate .pyi stub file for models in a Python file."""
1150
+ # Import the module to get model classes
1151
+ import importlib.util
1152
+
1153
+ spec = importlib.util.spec_from_file_location("temp_module", file_path)
1154
+ if spec is None or spec.loader is None:
1155
+ print(f"Could not load module from {file_path}")
1156
+ return
1157
+
1158
+ module = importlib.util.module_from_spec(spec)
1159
+ sys.modules["temp_module"] = module
1160
+ spec.loader.exec_module(module)
1161
+
1162
+ # Find all Model subclasses in the module
1163
+ models = []
1164
+ for name in dir(module):
1165
+ obj = getattr(module, name)
1166
+ if (
1167
+ inspect.isclass(obj)
1168
+ and issubclass(obj, Model)
1169
+ and obj is not Model
1170
+ and getattr(obj, "_is_table", False)
1171
+ ):
1172
+ models.append(obj)
1173
+
1174
+ if not models:
1175
+ print(f"No table models found in {file_path}")
1176
+ return
1177
+
1178
+ stub_path = file_path.with_suffix(".pyi")
1179
+ stub_path.write_text(_build_stub(file_path, models))
1180
+ print(f"Generated stub: {stub_path}")
1181
+
1182
+
1183
+ def generate_stubs_for_models(
1184
+ models: list[type[Model]] | None = None,
1185
+ ) -> dict[Path, str]:
1186
+ """
1187
+ Generate stubs for models and return mapping of file paths to stub content.
1188
+
1189
+ Args:
1190
+ models: List of model classes. If None, uses all registered tables.
1191
+
1192
+ Returns:
1193
+ Dict mapping source file paths to stub content
1194
+ """
1195
+ if models is None:
1196
+ models = list(registered_tables().values())
1197
+
1198
+ # Group models by source file
1199
+ file_models: dict[Path, list[type[Model]]] = {}
1200
+ for model in models:
1201
+ if not getattr(model, "_is_table", False):
1202
+ continue
1203
+
1204
+ source_file = inspect.getfile(model)
1205
+ file_path = Path(source_file)
1206
+
1207
+ if file_path not in file_models:
1208
+ file_models[file_path] = []
1209
+ file_models[file_path].append(model)
1210
+
1211
+ return {
1212
+ file_path.with_suffix(".pyi"): _build_stub(file_path, file_model_list)
1213
+ for file_path, file_model_list in file_models.items()
1214
+ }
1215
+
1216
+
1217
+ def write_stubs(stub_mapping: dict[Path, str]) -> None:
1218
+ """Write stub files to disk."""
1219
+ for stub_path, content in stub_mapping.items():
1220
+ stub_path.write_text(content)
1221
+ print(f"Generated stub: {stub_path}")
1222
+
1223
+
1224
+ __all__ = [
1225
+ "generate_model_stub",
1226
+ "generate_stub_for_file",
1227
+ "generate_stubs_for_models",
1228
+ "write_stubs",
1229
+ ]