dclassql 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.
dclassql/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ from .db_pool import BaseDBPool, save_local
2
+ from .push import db_push
3
+ from .unwarp import unwarp, unwarp_or, unwarp_or_raise
4
+
5
+ __all__ = [
6
+ 'db_push',
7
+ 'unwarp',
8
+ 'unwarp_or',
9
+ 'unwarp_or_raise',
10
+ 'BaseDBPool',
11
+ 'save_local',
12
+ ]
dclassql/cli.py ADDED
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import importlib.util
5
+ import sys
6
+ from pathlib import Path
7
+ from types import ModuleType
8
+ from typing import Any, Sequence
9
+
10
+ from .codegen import generate_client
11
+ from .push import db_push
12
+ from .runtime.datasource import open_sqlite_connection, resolve_sqlite_path
13
+
14
+
15
+ DEFAULT_MODEL_FILE = "model.py"
16
+
17
+
18
+ def load_module(module_path: Path) -> ModuleType:
19
+ module_path = module_path.resolve()
20
+ if not module_path.exists():
21
+ raise FileNotFoundError(f"Model file '{module_path}' does not exist")
22
+ module_name = module_path.stem
23
+ spec = importlib.util.spec_from_file_location(module_name, module_path)
24
+ if spec is None or spec.loader is None:
25
+ raise ImportError(f"Unable to load module from '{module_path}'")
26
+ module = importlib.util.module_from_spec(spec)
27
+ sys.modules[module_name] = module
28
+ sys.path.insert(0, str(module_path.parent))
29
+ try:
30
+ spec.loader.exec_module(module)
31
+ finally:
32
+ sys.path.pop(0)
33
+ return module
34
+
35
+
36
+ def collect_models(module: ModuleType) -> list[type[Any]]:
37
+ from dataclasses import is_dataclass
38
+
39
+ models: list[type[Any]] = []
40
+ for value in vars(module).values():
41
+ if isinstance(value, type) and is_dataclass(value) and value.__module__ == module.__name__:
42
+ models.append(value)
43
+ if not models:
44
+ raise ValueError("No dataclass models were found in the provided module")
45
+ return models
46
+
47
+
48
+ def push_database(models: Sequence[type[Any]]) -> None:
49
+ from .model_inspector import inspect_models
50
+
51
+ model_infos = inspect_models(models)
52
+ connections: dict[str, Any] = {}
53
+ opened: list[Any] = []
54
+ try:
55
+ for info in model_infos.values():
56
+ config = info.datasource
57
+ key = config.key
58
+ if key in connections:
59
+ continue
60
+ if config.provider != "sqlite":
61
+ raise ValueError(f"Unsupported provider '{config.provider}'")
62
+ connection = open_sqlite_connection(config.url)
63
+ connections[key] = connection
64
+ opened.append(connection)
65
+ db_push(models, connections)
66
+ finally:
67
+ for conn in opened:
68
+ try:
69
+ conn.close()
70
+ except Exception:
71
+ pass
72
+
73
+
74
+ def command_generate(module_path: Path) -> None:
75
+ module = load_module(module_path)
76
+ models = collect_models(module)
77
+ generated = generate_client(models)
78
+ sys.stdout.write(generated.code)
79
+
80
+
81
+ def command_push_db(module_path: Path) -> None:
82
+ module = load_module(module_path)
83
+ models = collect_models(module)
84
+ push_database(models)
85
+
86
+
87
+ def build_parser() -> argparse.ArgumentParser:
88
+ parser = argparse.ArgumentParser(prog="typed-db", description="Typed DB utilities.")
89
+ parser.add_argument(
90
+ "-m",
91
+ "--module",
92
+ type=Path,
93
+ default=Path(DEFAULT_MODEL_FILE),
94
+ help="Path to the model module file (default: model.py)",
95
+ )
96
+ subparsers = parser.add_subparsers(dest="command", required=True)
97
+
98
+ generate_parser = subparsers.add_parser("generate", help="Generate client code for given models")
99
+ generate_parser.set_defaults(handler=lambda args: command_generate(args.module))
100
+
101
+ push_parser = subparsers.add_parser("push-db", help="Apply schema and indexes to configured databases")
102
+ push_parser.set_defaults(handler=lambda args: command_push_db(args.module))
103
+
104
+ return parser
105
+
106
+
107
+ def main(argv: Sequence[str] | None = None) -> int:
108
+ parser = build_parser()
109
+ args = parser.parse_args(argv)
110
+ handler = getattr(args, "handler", None)
111
+ if handler is None:
112
+ parser.print_help()
113
+ return 1
114
+ try:
115
+ handler(args)
116
+ return 0
117
+ except Exception as exc: # pragma: no cover - CLI error reporting
118
+ print(f"Error: {exc}", file=sys.stderr)
119
+ return 1
120
+
121
+
122
+ if __name__ == "__main__": # pragma: no cover
123
+ raise SystemExit(main())
dclassql/codegen.py ADDED
@@ -0,0 +1,508 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections import defaultdict
5
+ from dataclasses import dataclass, field
6
+ from types import UnionType
7
+ from typing import Annotated, Any, Iterable, Mapping, Sequence, get_args, get_origin, Literal
8
+
9
+ from .model_inspector import ColumnInfo, ForeignKeyInfo, ModelInfo, inspect_models, DataSourceConfig
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class GeneratedModule:
14
+ code: str
15
+ model_names: tuple[str, ...]
16
+
17
+
18
+ def generate_client(models: Sequence[type[Any]]) -> GeneratedModule:
19
+ model_infos = inspect_models(models)
20
+ renderer = _TypeRenderer({info.model: name for name, info in model_infos.items()})
21
+
22
+ header_lines: list[str] = ["from __future__ import annotations", ""]
23
+
24
+ base_imports = {
25
+ "from dataclasses import dataclass, field",
26
+ "import sqlite3",
27
+ "from dclassql.db_pool import BaseDBPool, save_local",
28
+ "from dclassql.runtime.backends import BackendProtocol, RelationSpec, create_backend",
29
+ "from dclassql.runtime.datasource import resolve_sqlite_path",
30
+ }
31
+
32
+ model_imports: dict[str, set[str]] = defaultdict(set)
33
+ for info in model_infos.values():
34
+ module = info.model.__module__
35
+ model_imports.setdefault(module, set()).add(info.model.__name__)
36
+
37
+ body_sections: list[str] = [_render_metadata_base()]
38
+
39
+ rendered_models: list[str] = []
40
+ for name in sorted(model_infos.keys()):
41
+ info = model_infos[name]
42
+ rendered_models.append(_render_model(info, renderer, model_infos))
43
+
44
+ body_sections.extend(rendered_models)
45
+ body_sections.append(_render_client_class(model_infos))
46
+
47
+ module_imports = renderer.build_imports()
48
+ combined_imports: dict[str, set[str]] = defaultdict(set)
49
+ for module, names in model_imports.items():
50
+ combined_imports[module].update(names)
51
+ for module, names in module_imports.items():
52
+ combined_imports[module].update(names)
53
+
54
+ import_lines = sorted(base_imports)
55
+ for module, names in sorted(combined_imports.items()):
56
+ names_list = ", ".join(sorted(names))
57
+ import_lines.append(f"from {module} import {names_list}")
58
+ typing_names = {"Any", "Literal", "Mapping", "Sequence", "TypedDict", "cast"}
59
+ typing_names.update(renderer.typing_names)
60
+ if typing_names:
61
+ import_lines.append(f"from typing import {', '.join(sorted(typing_names))}")
62
+
63
+ lines = header_lines + import_lines + [""]
64
+ for section in body_sections:
65
+ lines.append(section)
66
+ lines.append("")
67
+ if lines and lines[-1] == "":
68
+ lines.pop()
69
+ if lines and lines[-1] != "":
70
+ lines.append("")
71
+ lines.append(_render_all(model_infos))
72
+ code = "\n".join(lines) + "\n"
73
+ return GeneratedModule(code=code, model_names=tuple(sorted(model_infos.keys())))
74
+
75
+
76
+ def _render_metadata_base() -> str:
77
+ return """@dataclass(slots=True)
78
+ class DataSourceConfig:
79
+ provider: str
80
+ url: str | None
81
+ name: str | None = None
82
+
83
+ @property
84
+ def key(self) -> str:
85
+ return self.name or self.provider
86
+
87
+
88
+ @dataclass(slots=True)
89
+ class ForeignKeySpec:
90
+ local_columns: tuple[str, ...]
91
+ remote_model: type[Any]
92
+ remote_columns: tuple[str, ...]
93
+ backref: str | None
94
+ """
95
+
96
+
97
+ def _render_model(info: ModelInfo, renderer: "_TypeRenderer", model_infos: Mapping[str, ModelInfo]) -> str:
98
+ alias_block, include_alias, sortable_alias = _render_type_aliases(info)
99
+ sections: list[str] = []
100
+ if alias_block:
101
+ sections.append(alias_block)
102
+ sections.append(_render_insert_structures(info, renderer))
103
+ sections.append(_render_where_dict(info, renderer))
104
+ sections.append(_render_table_class(info, renderer, include_alias, sortable_alias, model_infos))
105
+ return "\n\n".join(sections)
106
+
107
+
108
+ def _render_type_aliases(info: ModelInfo) -> tuple[str, str, str]:
109
+ name = info.model.__name__
110
+ include_literals = sorted({relation.target.__name__ for relation in info.relations})
111
+ sortable_literals = [col.name for col in info.columns]
112
+
113
+ lines: list[str] = []
114
+ include_alias = f"T{name}IncludeCol"
115
+ include_literal_expr = _literal_expression(include_literals)
116
+ lines.append(f"{include_alias} = {include_literal_expr}")
117
+
118
+ sortable_alias = f"T{name}SortableCol"
119
+ lines.append(f"{sortable_alias} = {_literal_expression(sortable_literals)}")
120
+
121
+ return "\n".join(lines), include_alias, sortable_alias
122
+
123
+
124
+ def _render_insert_structures(info: ModelInfo, renderer: "_TypeRenderer") -> str:
125
+ insert_fields = info.columns
126
+ dataclass_lines: list[str] = []
127
+ for col in insert_fields:
128
+ annotation = _format_insert_annotation(col, renderer)
129
+ default_fragment = _render_default_fragment(info.model.__name__, col)
130
+ if default_fragment is not None:
131
+ dataclass_lines.append(f" {col.name}: {annotation} = {default_fragment}")
132
+ elif col.auto_increment:
133
+ dataclass_lines.append(f" {col.name}: {annotation} = None")
134
+ else:
135
+ dataclass_lines.append(f" {col.name}: {annotation}")
136
+ if not dataclass_lines:
137
+ dataclass_lines.append(" pass")
138
+ dataclass_block = f"@dataclass(slots=True, kw_only=True)\nclass {info.model.__name__}Insert:\n" + "\n".join(dataclass_lines)
139
+
140
+ dict_lines = []
141
+ for col in insert_fields:
142
+ annotation = _format_insert_annotation(col, renderer)
143
+ if col.auto_increment:
144
+ renderer.require_typing("NotRequired")
145
+ base_annotation = _strip_optional_annotation(annotation)
146
+ dict_lines.append(f" {col.name}: NotRequired[{base_annotation}]")
147
+ else:
148
+ dict_lines.append(f" {col.name}: {annotation}")
149
+ if not dict_lines:
150
+ dict_lines.append(" pass")
151
+ dict_block = f"class {info.model.__name__}InsertDict(TypedDict):\n" + "\n".join(dict_lines)
152
+
153
+ return "\n\n".join([dataclass_block, dict_block])
154
+
155
+
156
+ def _render_where_dict(info: ModelInfo, renderer: "_TypeRenderer") -> str:
157
+ name = info.model.__name__
158
+ lines = [f"class {name}WhereDict(TypedDict, total=False):"]
159
+ field_lines: list[str] = []
160
+ for col in info.columns:
161
+ annotation = renderer.render(col.python_type)
162
+ if "None" not in annotation:
163
+ annotation = f"{annotation} | None"
164
+ field_lines.append(f" {col.name}: {annotation}")
165
+ if not field_lines:
166
+ field_lines.append(" pass")
167
+ lines.extend(field_lines)
168
+ return "\n".join(lines)
169
+
170
+
171
+ def _build_relation_entries(info: ModelInfo, model_infos: Mapping[str, ModelInfo]) -> list[dict[str, Any]]:
172
+ entries: list[dict[str, Any]] = []
173
+ if not info.relations:
174
+ return entries
175
+
176
+ target_index: dict[str, ModelInfo] = {name: model for name, model in model_infos.items()}
177
+
178
+ for relation in info.relations:
179
+ target_model = relation.target
180
+ target_info = target_index.get(target_model.__name__)
181
+ if target_info is None:
182
+ continue
183
+
184
+ mapping: tuple[tuple[str, str], ...] | None = None
185
+ if not relation.many:
186
+ for fk in info.foreign_keys:
187
+ if fk.remote_model is target_model:
188
+ mapping = tuple((local, remote) for local, remote in zip(fk.local_columns, fk.remote_columns))
189
+ break
190
+ if mapping is None:
191
+ for fk in target_info.foreign_keys:
192
+ if fk.remote_model is info.model and fk.backref_attribute == relation.name:
193
+ mapping = tuple((remote, local) for remote, local in zip(fk.remote_columns, fk.local_columns))
194
+ break
195
+ else:
196
+ for fk in target_info.foreign_keys:
197
+ if fk.remote_model is info.model and fk.backref_attribute == relation.name:
198
+ mapping = tuple((remote, local) for remote, local in zip(fk.remote_columns, fk.local_columns))
199
+ break
200
+ if mapping is None:
201
+ continue
202
+ if target_model.__module__ == info.model.__module__:
203
+ module_expr = "__name__"
204
+ else:
205
+ module_expr = repr(target_model.__module__)
206
+ entries.append(
207
+ {
208
+ "name": relation.name,
209
+ "table_name": f"{target_model.__name__}Table",
210
+ "many": relation.many,
211
+ "mapping": mapping,
212
+ "table_module_expr": module_expr,
213
+ }
214
+ )
215
+ return entries
216
+
217
+
218
+ def _render_table_class(
219
+ info: ModelInfo,
220
+ renderer: "_TypeRenderer",
221
+ include_alias: str | None,
222
+ sortable_alias: str,
223
+ model_infos: Mapping[str, ModelInfo],
224
+ ) -> str:
225
+ name = info.model.__name__
226
+ indent = " "
227
+ where_alias = f"{name}WhereDict"
228
+ lines = [f"class {name}Table:"]
229
+ lines.append(f"{indent}model = {name}")
230
+ lines.append(f"{indent}insert_model = {name}Insert")
231
+ ds = info.datasource
232
+ ds_url_repr = repr(ds.url)
233
+ lines.append(
234
+ f"{indent}datasource = DataSourceConfig(provider={ds.provider!r}, url={ds_url_repr}, name={repr(ds.name)})"
235
+ )
236
+ lines.append(f"{indent}columns: tuple[str, ...] = {_tuple_literal(col.name for col in info.columns)}")
237
+ auto_increment = tuple(col.name for col in info.columns if col.auto_increment)
238
+ if auto_increment:
239
+ lines.append(f"{indent}auto_increment_columns: tuple[str, ...] = {_tuple_literal(auto_increment)}")
240
+ else:
241
+ lines.append(f"{indent}auto_increment_columns: tuple[str, ...] = ()")
242
+ lines.append(f"{indent}primary_key: tuple[str, ...] = {_tuple_literal(info.primary_key)}")
243
+ if info.indexes:
244
+ lines.append(f"{indent}indexes: tuple[tuple[str, ...], ...] = {_tuple_literal(tuple(idx) for idx in info.indexes)}")
245
+ else:
246
+ lines.append(f"{indent}indexes: tuple[tuple[str, ...], ...] = ()")
247
+ if info.unique_indexes:
248
+ lines.append(f"{indent}unique_indexes: tuple[tuple[str, ...], ...] = {_tuple_literal(tuple(idx) for idx in info.unique_indexes)}")
249
+ else:
250
+ lines.append(f"{indent}unique_indexes: tuple[tuple[str, ...], ...] = ()")
251
+ if info.foreign_keys:
252
+ lines.append(f"{indent}foreign_keys: tuple[ForeignKeySpec, ...] = (")
253
+ for fk in info.foreign_keys:
254
+ lines.append(f"{indent*2}ForeignKeySpec(")
255
+ lines.append(f"{indent*3}local_columns={_tuple_literal(fk.local_columns)},")
256
+ lines.append(f"{indent*3}remote_model={fk.remote_model.__name__},")
257
+ lines.append(f"{indent*3}remote_columns={_tuple_literal(fk.remote_columns)},")
258
+ lines.append(f"{indent*3}backref={repr(fk.backref_attribute)},")
259
+ lines.append(f"{indent*2}),")
260
+ lines.append(f"{indent})")
261
+ else:
262
+ lines.append(f"{indent}foreign_keys: tuple[ForeignKeySpec, ...] = ()")
263
+ relation_entries = _build_relation_entries(info, model_infos)
264
+ if relation_entries:
265
+ lines.append(f"{indent}relations: tuple[RelationSpec, ...] = (")
266
+ for entry in relation_entries:
267
+ mapping_literal = _tuple_literal(entry["mapping"])
268
+ module_expr = entry["table_module_expr"]
269
+ lines.append(
270
+ f"{indent*2}RelationSpec(name={entry['name']!r}, table_name={entry['table_name']!r}, table_module={module_expr}, many={entry['many']}, mapping={mapping_literal}),"
271
+ )
272
+ lines.append(f"{indent})")
273
+ else:
274
+ lines.append(f"{indent}relations: tuple[RelationSpec, ...] = ()")
275
+ lines.append("")
276
+ lines.append(f"{indent}def __init__(self, backend: BackendProtocol[{name}, {name}Insert, {where_alias}]) -> None:")
277
+ lines.append(f"{indent*2}self._backend: BackendProtocol[{name}, {name}Insert, {where_alias}] = backend")
278
+ lines.append("")
279
+ lines.append(f"{indent}def insert(self, data: {name}Insert | {name}InsertDict) -> {name}:")
280
+ lines.append(f"{indent*2}return self._backend.insert(self, data)")
281
+ lines.append("")
282
+ lines.append(f"{indent}def insert_many(self, data: Sequence[{name}Insert | {name}InsertDict], *, batch_size: int | None = None) -> list[{name}]:")
283
+ lines.append(f"{indent*2}return self._backend.insert_many(self, data, batch_size=batch_size)")
284
+ lines.append("")
285
+ include_annotation = f"dict[{include_alias}, bool] | None"
286
+ lines.append(f"{indent}def find_many(self, *, where: {where_alias} | None = None, include: {include_annotation} = None, order_by: Sequence[tuple[{sortable_alias}, Literal['asc', 'desc']]] | None = None, take: int | None = None, skip: int | None = None) -> list[{name}]:")
287
+ lines.append(
288
+ f"{indent*2}return self._backend.find_many(self, where=where, include=cast(Mapping[str, bool] | None, include), order_by=order_by, take=take, skip=skip)"
289
+ )
290
+ lines.append("")
291
+ lines.append(f"{indent}def find_first(self, *, where: {where_alias} | None = None, include: {include_annotation} = None, order_by: Sequence[tuple[{sortable_alias}, Literal['asc', 'desc']]] | None = None, skip: int | None = None) -> {name} | None:")
292
+ lines.append(
293
+ f"{indent*2}return self._backend.find_first(self, where=where, include=cast(Mapping[str, bool] | None, include), order_by=order_by, skip=skip)"
294
+ )
295
+ return "\n".join(lines)
296
+
297
+
298
+ def _format_insert_annotation(col: ColumnInfo, renderer: "_TypeRenderer") -> str:
299
+ annotation = renderer.render(col.python_type)
300
+ needs_optional = col.auto_increment
301
+ if needs_optional and "None" not in annotation:
302
+ annotation = f"{annotation} | None"
303
+ return annotation
304
+
305
+
306
+ def _render_default_fragment(model_name: str, col: ColumnInfo) -> str | None:
307
+ if col.has_default_factory and col.default_factory is not None:
308
+ factory_expr = f"{model_name}.__dataclass_fields__['{col.name}'].default_factory"
309
+ return f"field(default_factory={factory_expr})"
310
+ if col.has_default:
311
+ return repr(col.default_value)
312
+ return None
313
+
314
+
315
+ def _render_client_class(model_infos: Mapping[str, ModelInfo]) -> str:
316
+ indent = " "
317
+ lines = ["class GeneratedClient(BaseDBPool):"]
318
+ datasource_configs: dict[str, DataSourceConfig] = {}
319
+ for info in model_infos.values():
320
+ datasource = info.datasource
321
+ key = datasource.name or datasource.provider
322
+ existing = datasource_configs.get(key)
323
+ if existing is None:
324
+ datasource_configs[key] = datasource
325
+ elif existing != datasource:
326
+ raise ValueError(
327
+ f"Conflicting datasource key '{key}' for providers"
328
+ )
329
+ lines.append(f"{indent}datasources = {{")
330
+ for key in sorted(datasource_configs.keys()):
331
+ ds = datasource_configs[key]
332
+ lines.append(
333
+ f"{indent*2}{key!r}: DataSourceConfig(provider={ds.provider!r}, url={repr(ds.url)}, name={repr(ds.name)}),"
334
+ )
335
+ lines.append(f"{indent}}}")
336
+
337
+ backend_methods: list[tuple[str, str]] = []
338
+ for key in sorted(datasource_configs.keys()):
339
+ method_suffix = _sanitize_identifier(key)
340
+ method_name = f"_backend_{method_suffix}"
341
+ backend_methods.append((key, method_name))
342
+ lines.append("")
343
+ lines.append(f"{indent}@classmethod")
344
+ lines.append(f"{indent}@save_local")
345
+ lines.append(
346
+ f"{indent}def {method_name}(cls) -> BackendProtocol[Any, Any, Mapping[str, object]]:"
347
+ )
348
+ lines.append(f"{indent*2}config = cls.datasources[{key!r}]")
349
+ lines.append(f"{indent*2}if config.provider == 'sqlite':")
350
+ lines.append(f"{indent*3}path = resolve_sqlite_path(config.url)")
351
+ lines.append(f"{indent*3}conn = sqlite3.connect(path, check_same_thread=False)")
352
+ lines.append(f"{indent*3}cls._setup_sqlite_db(conn)")
353
+ lines.append(f"{indent*3}return create_backend('sqlite', conn)")
354
+ lines.append(
355
+ f"{indent*2}raise ValueError(f\"Unsupported provider '{{config.provider}}' for datasource '{key}'\")"
356
+ )
357
+
358
+ lines.append("")
359
+ lines.append(f"{indent}def __init__(self) -> None:")
360
+ method_map = {key: method for key, method in backend_methods}
361
+
362
+ for name in sorted(model_infos.keys()):
363
+ attr = _camel_to_snake(name)
364
+ datasource = model_infos[name].datasource
365
+ ds_key = datasource.name or datasource.provider
366
+ where_alias = f"{name}WhereDict"
367
+ method_name = method_map[ds_key]
368
+ lines.append(
369
+ f"{indent*2}self.{attr} = {name}Table(cast(BackendProtocol[{name}, {name}Insert, {where_alias}], self.{method_name}()))"
370
+ )
371
+ lines.append("")
372
+ lines.append(f"{indent}@classmethod")
373
+ lines.append(f"{indent}def close_all(cls, verbose: bool = False) -> None:")
374
+ lines.append(f"{indent*2}super().close_all(verbose=verbose)")
375
+ for _, method_name in backend_methods:
376
+ lines.append(f"{indent*2}if hasattr(cls._local, '{method_name}'):")
377
+ lines.append(f"{indent*3}backend = getattr(cls._local, '{method_name}')")
378
+ lines.append(f"{indent*3}if hasattr(backend, 'close') and callable(getattr(backend, 'close')):")
379
+ lines.append(f"{indent*4}backend.close()")
380
+ lines.append(f"{indent*3}delattr(cls._local, '{method_name}')")
381
+ return "\n".join(lines)
382
+
383
+
384
+ def _render_all(model_infos: Mapping[str, ModelInfo]) -> str:
385
+ exports: list[str] = ["DataSourceConfig", "ForeignKeySpec", "GeneratedClient"]
386
+ for name in sorted(model_infos.keys()):
387
+ exports.extend([
388
+ f"T{name}IncludeCol",
389
+ f"T{name}SortableCol",
390
+ f"{name}Insert",
391
+ f"{name}InsertDict",
392
+ f"{name}WhereDict",
393
+ f"{name}Table",
394
+ ])
395
+ exports_literal = ", ".join(f"\"{item}\"" for item in exports)
396
+ return f"__all__ = ({exports_literal},)"
397
+
398
+
399
+ def _literal_expression(values: Sequence[str]) -> str:
400
+ unique = list(dict.fromkeys(values))
401
+ if not unique:
402
+ return "Literal[()]"
403
+ items = ", ".join(repr(value) for value in unique)
404
+ return f"Literal[{items}]"
405
+
406
+
407
+ def _tuple_literal(values: Iterable[Any]) -> str:
408
+ items = list(values)
409
+ if not items:
410
+ return "()"
411
+ if all(isinstance(item, (tuple, list)) for item in items):
412
+ parts = []
413
+ for item in items:
414
+ parts.append(_tuple_literal(item))
415
+ joined = ", ".join(parts)
416
+ return f"({joined},)"
417
+ joined = ", ".join(repr(item) for item in items)
418
+ if len(items) == 1:
419
+ return f"({joined},)"
420
+ return f"({joined})"
421
+
422
+
423
+ def _sanitize_identifier(value: str) -> str:
424
+ result_chars: list[str] = []
425
+ for char in value:
426
+ if char.isalnum() or char == "_":
427
+ result_chars.append(char.lower())
428
+ else:
429
+ result_chars.append("_")
430
+ identifier = "".join(result_chars).replace("__", "_")
431
+ if not identifier or identifier[0].isdigit():
432
+ identifier = f"ds_{identifier}" if identifier else "ds"
433
+ return identifier
434
+
435
+
436
+ def _strip_optional_annotation(annotation: str) -> str:
437
+ parts = [part.strip() for part in annotation.split("|")]
438
+ filtered = [part for part in parts if part != "None"]
439
+ return filtered[0] if len(filtered) == 1 else " | ".join(filtered)
440
+
441
+
442
+ def _camel_to_snake(name: str) -> str:
443
+ pattern = re.compile(r"(?<!^)(?=[A-Z])")
444
+ return pattern.sub("_", name).lower()
445
+
446
+
447
+ class _TypeRenderer:
448
+ def __init__(self, model_map: Mapping[type[Any], str]) -> None:
449
+ self._model_map = dict(model_map)
450
+ self._module_imports: dict[str, set[str]] = defaultdict(set)
451
+ self._typing_imports: set[str] = set()
452
+
453
+ def render(self, tp: Any) -> str:
454
+ if tp is Any:
455
+ return "Any"
456
+ if tp is type(None):
457
+ return "None"
458
+ if isinstance(tp, UnionType):
459
+ parts = [self.render(arg) for arg in get_args(tp)]
460
+ return " | ".join(dict.fromkeys(parts))
461
+ origin = get_origin(tp)
462
+ if origin is Annotated:
463
+ return self.render(get_args(tp)[0])
464
+ if origin is Literal:
465
+ self._typing_imports.add("Literal")
466
+ values = ", ".join(repr(value) for value in get_args(tp))
467
+ return f"Literal[{values}]"
468
+ if origin in (list, set, frozenset):
469
+ args = get_args(tp) or (Any,)
470
+ if origin is set:
471
+ container = "set"
472
+ elif origin is frozenset:
473
+ container = "frozenset"
474
+ else:
475
+ container = "list"
476
+ return f"{container}[{self.render(args[0])}]"
477
+ if origin is tuple:
478
+ args = get_args(tp)
479
+ if len(args) == 2 and args[1] is Ellipsis:
480
+ return f"tuple[{self.render(args[0])}, ...]"
481
+ return f"tuple[{', '.join(self.render(arg) for arg in args)}]"
482
+ if origin is dict:
483
+ key, value = get_args(tp) or (Any, Any)
484
+ return f"dict[{self.render(key)}, {self.render(value)}]"
485
+ if origin is None:
486
+ pass
487
+ if isinstance(tp, type):
488
+ mapped = self._model_map.get(tp)
489
+ if mapped is not None:
490
+ return mapped
491
+ if tp.__module__ == "builtins":
492
+ return tp.__name__
493
+ if tp.__module__ == "datetime":
494
+ self._module_imports.setdefault("datetime", set()).add(tp.__name__)
495
+ return tp.__name__
496
+ self._module_imports.setdefault(tp.__module__, set()).add(tp.__qualname__.split(".")[0])
497
+ return tp.__qualname__
498
+ return repr(tp)
499
+
500
+ def build_imports(self) -> Mapping[str, set[str]]:
501
+ return self._module_imports
502
+
503
+ @property
504
+ def typing_names(self) -> set[str]:
505
+ return set(self._typing_imports)
506
+
507
+ def require_typing(self, name: str) -> None:
508
+ self._typing_imports.add(name)