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/db_pool.py ADDED
@@ -0,0 +1,76 @@
1
+ import functools
2
+ import sqlite3
3
+ import threading
4
+ from typing import Callable, Concatenate, Protocol
5
+
6
+
7
+ class HasLocalClass(Protocol):
8
+ _local: threading.local
9
+
10
+ def save_local[C: HasLocalClass, **P, T](func: Callable[Concatenate[type[C], P], T]) -> Callable[Concatenate[type[C], P], T]:
11
+ @functools.wraps(func)
12
+ def wrapper(cls: type[C], *args: P.args, **kwargs: P.kwargs) -> T:
13
+ field_name = func.__name__
14
+ if hasattr(cls._local, field_name):
15
+ return getattr(cls._local, field_name)
16
+
17
+ r = func(cls, *args, **kwargs)
18
+ setattr(cls._local, field_name, r)
19
+ return r
20
+ return wrapper
21
+
22
+
23
+ class BaseDBPool:
24
+ ''' Thread-level database pool base class. Methods decorated with `@save_local` are cached in `threading.local()`. Usage example:
25
+ ```python
26
+ class ExampleDBPool(BaseDBPool):
27
+ sqlite_db_path = 'data/news.db'
28
+ visitor_sqlite_db_path = 'data/visitors.db'
29
+
30
+ @classmethod
31
+ @save_local
32
+ def sqlite_conn(cls) -> sqlite3.Connection:
33
+ conn = sqlite3.connect(cls.sqlite_db_path, check_same_thread=False)
34
+ cls._setup_sqlite_db(conn)
35
+ return conn
36
+
37
+ @classmethod
38
+ @save_local
39
+ def fastlite_conn(cls):
40
+ from fastlite import database
41
+ fastlite_db = database(cls.sqlite_db_path)
42
+ return fastlite_db
43
+
44
+ @classmethod
45
+ @save_local
46
+ def fastlite_conn_visitor(cls):
47
+ from fastlite import database
48
+ fastlite_db_visitor = database(cls.visitor_sqlite_db_path)
49
+ cls._setup_sqlite_db(fastlite_db_visitor.conn)
50
+ return fastlite_db_visitor
51
+ ```
52
+ '''
53
+
54
+ _local = threading.local()
55
+
56
+ @classmethod
57
+ def close_all(cls, verbose: bool = False):
58
+ for attr in dir(cls._local):
59
+ if '_conn' in attr or '_db' in attr:
60
+ if verbose:
61
+ print(f'Check {attr}')
62
+ obj = getattr(cls._local, attr)
63
+ if hasattr(obj, 'close') and callable(obj.close):
64
+ if verbose:
65
+ print(f'\tClosing {attr}')
66
+ obj.close()
67
+ delattr(cls._local, attr)
68
+
69
+ @classmethod
70
+ def _setup_sqlite_db(cls, conn: sqlite3.Connection):
71
+ conn.execute('PRAGMA journal_mode = WAL;')
72
+ conn.execute('PRAGMA synchronous = NORMAL;')
73
+ conn.execute('pragma temp_store = memory;')
74
+ conn.execute('pragma page_size = 32768;')
75
+ conn.execute("PRAGMA busy_timeout = 3000;")
76
+ conn.execute('PRAGMA journal_size_limit=104857600;')
@@ -0,0 +1,383 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from dataclasses import MISSING, dataclass, fields, is_dataclass
5
+ from types import UnionType
6
+ from typing import Annotated, Any, Iterable, Mapping, Sequence, get_args, get_origin, get_type_hints
7
+
8
+ from .table_spec import Col, TableInfo
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class ColumnInfo:
13
+ name: str
14
+ python_type: Any
15
+ optional: bool
16
+ auto_increment: bool
17
+ has_default: bool
18
+ default_value: Any
19
+ has_default_factory: bool
20
+ default_factory: Any | None
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class RelationInfo:
25
+ name: str
26
+ target: type[Any]
27
+ many: bool
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class ForeignKeyInfo:
32
+ local_columns: tuple[str, ...]
33
+ remote_model: type[Any]
34
+ remote_columns: tuple[str, ...]
35
+ backref_attribute: str | None
36
+
37
+
38
+ @dataclass(slots=True)
39
+ class ModelInfo:
40
+ model: type[Any]
41
+ columns: list[ColumnInfo]
42
+ relations: list[RelationInfo]
43
+ primary_key: tuple[str, ...]
44
+ indexes: list[tuple[str, ...]]
45
+ unique_indexes: list[tuple[str, ...]]
46
+ foreign_keys: list[ForeignKeyInfo]
47
+ datasource: 'DataSourceConfig'
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class DataSourceConfig:
52
+ provider: str
53
+ url: str | None
54
+ name: str | None = None
55
+
56
+ @property
57
+ def key(self) -> str:
58
+ return self.name or self.provider
59
+
60
+
61
+ @dataclass(slots=True)
62
+ class FieldSpec:
63
+ name: str
64
+ kind: str
65
+ target: type[Any] | None = None
66
+
67
+
68
+ class RelationAttribute:
69
+ def __init__(self, model: type[Any], attribute: str) -> None:
70
+ self.model = model
71
+ self.attribute = attribute
72
+
73
+ def __repr__(self) -> str: # pragma: no cover - diagnostic only
74
+ return f"RelationAttribute(model={self.model.__name__}, attribute={self.attribute})"
75
+
76
+
77
+ class ForeignKeyComparison:
78
+ def __init__(self, left: Col | tuple[Col, ...], right: Col | tuple[Col, ...]) -> None:
79
+ self.left = left
80
+ self.right = right
81
+
82
+
83
+ class _ProxyCol(Col):
84
+ def __eq__(self, other: object) -> ForeignKeyComparison | bool: # type: ignore[override]
85
+ other_col = _normalize_col(other)
86
+ if other_col is None:
87
+ return NotImplemented # type: ignore[return-value]
88
+ return ForeignKeyComparison(self._to_base(), other_col)
89
+
90
+ def _to_base(self) -> Col:
91
+ return Col(self.name, table=self.table)
92
+
93
+
94
+ class RelationProxy:
95
+ def __init__(self, target: type[Any]) -> None:
96
+ self._target = target
97
+
98
+ def __getattr__(self, name: str) -> _ProxyCol:
99
+ return _ProxyCol(name, table=self._target)
100
+
101
+
102
+ class FakeSelf:
103
+ def __init__(self, model: type[Any], specs: Mapping[str, FieldSpec]) -> None:
104
+ self._model = model
105
+ self._specs = specs
106
+
107
+ def __getattr__(self, name: str) -> _ProxyCol | RelationProxy:
108
+ spec = self._specs.get(name)
109
+ if spec is None:
110
+ raise AttributeError(name)
111
+ if spec.kind == "column":
112
+ return _ProxyCol(name, table=self._model)
113
+ if spec.kind in {"relation", "relation_many"} and spec.target is not None:
114
+ return RelationProxy(spec.target)
115
+ raise AttributeError(name)
116
+
117
+
118
+ def inspect_models(models: Sequence[type[Any]]) -> dict[str, ModelInfo]:
119
+ registry: dict[str, type[Any]] = {model.__name__: model for model in models}
120
+ globalns: dict[str, Any] = {}
121
+ module_map: dict[type[Any], Any] = {}
122
+ for model in models:
123
+ module = sys.modules.get(model.__module__)
124
+ if module is None:
125
+ module = __import__(model.__module__, fromlist=["*"])
126
+ module_map[model] = module
127
+ globalns.update(vars(module))
128
+ globalns.update(registry)
129
+
130
+ annotations_map: dict[type[Any], dict[str, Any]] = {}
131
+ field_specs_map: dict[type[Any], dict[str, FieldSpec]] = {}
132
+ relation_map: dict[type[Any], list[RelationInfo]] = {}
133
+ for model in models:
134
+ annotations = get_type_hints(model, globalns=globalns, include_extras=True)
135
+ annotations_map[model] = annotations
136
+ columns, relations, specs = _categorize_fields(model, annotations, registry)
137
+ field_specs_map[model] = specs
138
+ relation_map[model] = relations
139
+
140
+ datasource_map: dict[type[Any], DataSourceConfig] = {
141
+ model: _module_datasource(module_map[model]) for model in models
142
+ }
143
+
144
+ backref_records: list[tuple[type[Any], str, Any, bool]] = []
145
+ for model, relations in relation_map.items():
146
+ for relation in relations:
147
+ has_attr = hasattr(model, relation.name)
148
+ previous = getattr(model, relation.name, None)
149
+ setattr(model, relation.name, RelationAttribute(model, relation.name))
150
+ backref_records.append((model, relation.name, previous, has_attr))
151
+
152
+ try:
153
+ infos: dict[str, ModelInfo] = {}
154
+ for model in models:
155
+ annotations = annotations_map[model]
156
+ columns, relations, specs = _categorize_fields(model, annotations, registry)
157
+ table_info = TableInfo.from_dc(model)
158
+ primary_key = _col_names(table_info.primary_key.cols)
159
+ indexes: list[tuple[str, ...]] = []
160
+ unique_indexes: list[tuple[str, ...]] = []
161
+ for spec in table_info.index:
162
+ col_names = _col_names(spec.cols)
163
+ if spec.is_unique_index:
164
+ unique_indexes.append(col_names)
165
+ else:
166
+ indexes.append(col_names)
167
+ foreign_keys = _extract_foreign_keys(model, field_specs_map[model])
168
+ infos[model.__name__] = ModelInfo(
169
+ model=model,
170
+ columns=columns,
171
+ relations=relations,
172
+ primary_key=primary_key,
173
+ indexes=indexes,
174
+ unique_indexes=unique_indexes,
175
+ foreign_keys=foreign_keys,
176
+ datasource=datasource_map[model],
177
+ )
178
+ return infos
179
+ finally:
180
+ for model, attr, previous, has_attr in backref_records:
181
+ if has_attr:
182
+ setattr(model, attr, previous)
183
+ else:
184
+ delattr(model, attr)
185
+
186
+
187
+ def _categorize_fields(
188
+ model: type[Any],
189
+ annotations: Mapping[str, Any],
190
+ registry: Mapping[str, type[Any]],
191
+ ) -> tuple[list[ColumnInfo], list[RelationInfo], dict[str, FieldSpec]]:
192
+ columns: list[ColumnInfo] = []
193
+ relations: list[RelationInfo] = []
194
+ specs: dict[str, FieldSpec] = {}
195
+
196
+ table_info = TableInfo.from_dc(model)
197
+ pk_cols = set(_col_names(table_info.primary_key.cols))
198
+
199
+ for field in fields(model):
200
+ name = field.name
201
+ annotation = annotations.get(name)
202
+ if annotation is None:
203
+ continue
204
+ optional_flag = False
205
+ annotation, optional_flag = _strip_optional(annotation)
206
+ base_annotation = _unwrap_annotation(annotation)
207
+ if _is_relationship(base_annotation, registry):
208
+ target = _resolve_model(base_annotation, registry)
209
+ many = _is_collection_type(annotation)
210
+ relations.append(RelationInfo(name=name, target=target, many=many))
211
+ specs[name] = FieldSpec(name=name, kind="relation_many" if many else "relation", target=target)
212
+ continue
213
+ has_default_value = field.default is not MISSING
214
+ has_default_factory = field.default_factory is not MISSING
215
+ columns.append(
216
+ ColumnInfo(
217
+ name=name,
218
+ python_type=annotations[name],
219
+ optional=optional_flag or has_default_value or has_default_factory,
220
+ auto_increment=_is_auto_increment(name, annotations[name], pk_cols),
221
+ has_default=has_default_value,
222
+ default_value=field.default if has_default_value else None,
223
+ has_default_factory=has_default_factory,
224
+ default_factory=field.default_factory if has_default_factory else None,
225
+ )
226
+ )
227
+ specs[name] = FieldSpec(name=name, kind="column")
228
+
229
+ return columns, relations, specs
230
+
231
+
232
+ def _strip_optional(tp: Any) -> tuple[Any, bool]:
233
+ origin = get_origin(tp)
234
+ if origin is UnionType:
235
+ args = get_args(tp)
236
+ non_none = tuple(arg for arg in args if arg is not type(None)) # noqa: E721
237
+ is_optional = len(non_none) < len(args)
238
+ if len(non_none) == 1:
239
+ return non_none[0], is_optional
240
+ return tp, is_optional
241
+ return tp, False
242
+
243
+
244
+ def _unwrap_annotation(tp: Any) -> Any:
245
+ while get_origin(tp) is Annotated:
246
+ tp = get_args(tp)[0]
247
+ if _is_collection_type(tp):
248
+ args = get_args(tp)
249
+ if args:
250
+ return args[0]
251
+ return tp
252
+
253
+
254
+ def _is_collection_type(tp: Any) -> bool:
255
+ origin = get_origin(tp)
256
+ return origin in (list, set, frozenset, tuple)
257
+
258
+
259
+ def _is_relationship(tp: Any, registry: Mapping[str, type[Any]]) -> bool:
260
+ if isinstance(tp, type) and is_dataclass(tp):
261
+ return True
262
+ if isinstance(tp, str) and tp in registry:
263
+ return True
264
+ return False
265
+
266
+
267
+ def _resolve_model(tp: Any, registry: Mapping[str, type[Any]]) -> type[Any]:
268
+ if isinstance(tp, type) and is_dataclass(tp):
269
+ return tp
270
+ if isinstance(tp, str):
271
+ model = registry.get(tp)
272
+ if model is None:
273
+ raise KeyError(f"Unknown model reference: {tp}")
274
+ return model
275
+ raise TypeError(f"Unsupported model type: {tp!r}")
276
+
277
+
278
+ def _is_auto_increment(name: str, annotation: Any, pk_cols: set[str]) -> bool:
279
+ if name != "id" or name not in pk_cols:
280
+ return False
281
+ base, _ = _strip_optional(annotation)
282
+ base = _unwrap_annotation(base)
283
+ return base is int
284
+
285
+
286
+ def _normalize_col(value: object) -> Col | tuple[Col, ...] | None:
287
+ if isinstance(value, _ProxyCol):
288
+ return value._to_base()
289
+ if isinstance(value, Col):
290
+ return value
291
+ if isinstance(value, tuple):
292
+ cols = []
293
+ for item in value:
294
+ col = _normalize_col(item)
295
+ if not isinstance(col, Col):
296
+ return None
297
+ cols.append(col)
298
+ return tuple(cols)
299
+ return None
300
+
301
+
302
+ def _col_names(cols: Col | tuple[Col, ...]) -> tuple[str, ...]:
303
+ if isinstance(cols, Col):
304
+ return (cols.name,)
305
+ return tuple(col.name for col in cols)
306
+
307
+
308
+ def _extract_foreign_keys(model: type[Any], specs: Mapping[str, FieldSpec]) -> list[ForeignKeyInfo]:
309
+ if not hasattr(model, "foreign_key"):
310
+ return []
311
+ fake = FakeSelf(model, specs)
312
+ fn = getattr(model, "foreign_key")
313
+ results = fn(fake)
314
+ entries = _iterate_results(results)
315
+ foreign_keys: list[ForeignKeyInfo] = []
316
+ for entry in entries:
317
+ if not isinstance(entry, tuple) or len(entry) != 2:
318
+ raise TypeError("foreign_key must yield tuples of (comparison, backref)")
319
+ comparison, backref = entry
320
+ if not isinstance(comparison, ForeignKeyComparison):
321
+ raise TypeError("foreign_key comparison must be column equality")
322
+ local_cols, remote_cols = _determine_direction(model, comparison)
323
+ backref_attr: str | None = None
324
+ remote_model = remote_cols[0].table
325
+ if isinstance(backref, RelationAttribute):
326
+ backref_attr = backref.attribute
327
+ remote_model = backref.model
328
+ foreign_keys.append(
329
+ ForeignKeyInfo(
330
+ local_columns=tuple(col.name for col in local_cols),
331
+ remote_model=remote_model,
332
+ remote_columns=tuple(col.name for col in remote_cols),
333
+ backref_attribute=backref_attr,
334
+ )
335
+ )
336
+ return foreign_keys
337
+
338
+
339
+ def _iterate_results(results: Any) -> Iterable[Any]:
340
+ if results is None:
341
+ return []
342
+ if isinstance(results, Iterable) and not isinstance(results, (str, bytes)):
343
+ return results
344
+ return [results]
345
+
346
+
347
+ def _determine_direction(model: type[Any], comparison: ForeignKeyComparison) -> tuple[list[Col], list[Col]]:
348
+ left_cols = _ensure_sequence(comparison.left)
349
+ right_cols = _ensure_sequence(comparison.right)
350
+ left_local = all(col.table is model for col in left_cols)
351
+ right_local = all(col.table is model for col in right_cols)
352
+ if left_local and not right_local:
353
+ return left_cols, right_cols
354
+ if right_local and not left_local:
355
+ return right_cols, left_cols
356
+ raise ValueError("Unable to determine foreign key direction")
357
+
358
+
359
+ def _ensure_sequence(value: Col | tuple[Col, ...]) -> list[Col]:
360
+ if isinstance(value, Col):
361
+ return [value]
362
+ return list(value)
363
+
364
+
365
+ def _module_datasource(module: Any | None) -> DataSourceConfig:
366
+ if module is None:
367
+ raise ValueError("Model module is not available while resolving datasource")
368
+ config = getattr(module, "__datasource__", None)
369
+ if not isinstance(config, Mapping):
370
+ raise ValueError(
371
+ f"Module {module.__name__} must define __datasource__ = "
372
+ "{'provider': 'sqlite', 'url': 'sqlite:///example.db'}"
373
+ )
374
+ if "provider" not in config:
375
+ raise ValueError(
376
+ f"Module {module.__name__} __datasource__ must declare a 'provider' key"
377
+ )
378
+ provider = str(config["provider"])
379
+ raw_url = config.get("url")
380
+ url = str(raw_url) if raw_url is not None else None
381
+ raw_name = config.get("name")
382
+ name = str(raw_name) if raw_name is not None else None
383
+ return DataSourceConfig(provider=provider, url=url, name=name)
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Mapping, Sequence
4
+
5
+ from ..model_inspector import ModelInfo, inspect_models
6
+ from .base import DatabasePusher, ExistingColumn, SchemaDiff, SchemaPlan
7
+ from .sqlite import SQLITE_PUSHER, SQLitePusher, push_sqlite, _build_sqlite_schema
8
+
9
+ _PUSHER_REGISTRY: dict[str, DatabasePusher] = {
10
+ "sqlite": SQLITE_PUSHER,
11
+ }
12
+
13
+
14
+ def register_pusher(provider: str, pusher: DatabasePusher) -> None:
15
+ _PUSHER_REGISTRY[provider] = pusher
16
+
17
+
18
+ def get_pusher(provider: str) -> DatabasePusher:
19
+ if provider not in _PUSHER_REGISTRY:
20
+ raise ValueError(f"Unsupported provider: {provider}")
21
+ return _PUSHER_REGISTRY[provider]
22
+
23
+
24
+ def db_push(
25
+ models: Sequence[type[Any]],
26
+ connections: Mapping[str, Any],
27
+ *,
28
+ sync_indexes: bool = False,
29
+ confirm_rebuild: Callable[[ModelInfo, SchemaPlan, tuple[ExistingColumn, ...] | None, SchemaDiff], bool] | None = None,
30
+ ) -> None:
31
+ model_infos = inspect_models(models)
32
+ grouped: dict[str, dict[str, list[ModelInfo]]] = {}
33
+ for info in model_infos.values():
34
+ provider = info.datasource.provider
35
+ key = info.datasource.key
36
+ provider_map = grouped.setdefault(provider, {})
37
+ provider_map.setdefault(key, []).append(info)
38
+
39
+ for provider, key_map in grouped.items():
40
+ pusher = get_pusher(provider)
41
+ for key, infos in key_map.items():
42
+ if key not in connections:
43
+ raise KeyError(f"Connection for datasource '{key}' is missing")
44
+ connection = connections[key]
45
+ pusher.push(
46
+ connection,
47
+ infos,
48
+ sync_indexes=sync_indexes,
49
+ confirm_rebuild=confirm_rebuild,
50
+ )
51
+
52
+
53
+ __all__ = [
54
+ "db_push",
55
+ "get_pusher",
56
+ "register_pusher",
57
+ "SQLitePusher",
58
+ "push_sqlite",
59
+ "_build_sqlite_schema",
60
+ ]