dclassql 0.1.5__tar.gz → 0.2.0__tar.gz

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 (42) hide show
  1. {dclassql-0.1.5 → dclassql-0.2.0}/PKG-INFO +1 -1
  2. {dclassql-0.1.5 → dclassql-0.2.0}/pyproject.toml +1 -1
  3. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/__init__.py +6 -1
  4. dclassql-0.2.0/src/dclassql/asdict.py +192 -0
  5. dclassql-0.2.0/src/dclassql/asdict.pyi +57 -0
  6. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/cli.py +26 -3
  7. dclassql-0.2.0/src/dclassql/client.py +863 -0
  8. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/codegen.py +87 -4
  9. dclassql-0.2.0/src/dclassql/generated_models/__init__.py +0 -0
  10. dclassql-0.2.0/src/dclassql/generated_models/test_models.py +78 -0
  11. dclassql-0.2.0/src/dclassql/runtime/backends/base.py +620 -0
  12. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/runtime/backends/lazy.py +17 -2
  13. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/runtime/backends/protocols.py +68 -2
  14. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/runtime/backends/sqlite.py +66 -0
  15. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/runtime/backends/where_compiler.py +5 -3
  16. dclassql-0.2.0/src/dclassql/runtime/sql_recorder.py +44 -0
  17. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/table_spec.py +4 -1
  18. dclassql-0.2.0/src/dclassql/templates/asdict_stub.pyi.jinja +27 -0
  19. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/templates/partials/imports.jinja +1 -1
  20. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/templates/partials/model_section.jinja +78 -7
  21. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/typing.py +1 -0
  22. dclassql-0.1.5/src/dclassql/runtime/backends/base.py +0 -300
  23. {dclassql-0.1.5 → dclassql-0.2.0}/README.md +0 -0
  24. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/.gitignore +0 -0
  25. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/db_pool.py +0 -0
  26. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/model_inspector.py +0 -0
  27. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/push/__init__.py +0 -0
  28. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/push/base.py +0 -0
  29. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/push/sqlite.py +0 -0
  30. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/runtime/backends/__init__.py +0 -0
  31. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/runtime/backends/metadata.py +0 -0
  32. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/runtime/datasource.py +0 -0
  33. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/runtime/sqlite_adapters.py +0 -0
  34. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/templates/__init__.py +0 -0
  35. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/templates/client_module.py.jinja +0 -0
  36. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/templates/partials/client_class.jinja +0 -0
  37. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/templates/partials/exports.jinja +0 -0
  38. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/templates/partials/macros.jinja +0 -0
  39. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/templates/partials/scalar_filters.jinja +0 -0
  40. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/unwarp.py +0 -0
  41. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/utils/__init__.py +0 -0
  42. {dclassql-0.1.5 → dclassql-0.2.0}/src/dclassql/utils/ensure.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: dclassql
3
- Version: 0.1.5
3
+ Version: 0.2.0
4
4
  Summary: A type-safe ORM generator for Python, creating fully type-hinted database clients from plain dataclass definitions.
5
5
  Keywords: orm,codegen,sqlite,dataclass,typed
6
6
  Author: myuanz
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dclassql"
3
- version = "0.1.5"
3
+ version = "0.2.0"
4
4
  description = "A type-safe ORM generator for Python, creating fully type-hinted database clients from plain dataclass definitions."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,9 +1,12 @@
1
- from .model_inspector import DataSourceConfig
1
+ from .asdict import asdict
2
2
  from .db_pool import BaseDBPool, save_local
3
+ from .model_inspector import DataSourceConfig
3
4
  from .push import db_push
4
5
  from .runtime.backends.lazy import eager
6
+ from .runtime.sql_recorder import record_sql
5
7
  from .unwarp import unwarp, unwarp_or, unwarp_or_raise
6
8
 
9
+
7
10
  class _MissingClient:
8
11
  def __init__(self, *args: object, **kwargs: object) -> None:
9
12
  raise RuntimeError(
@@ -20,10 +23,12 @@ __all__ = [
20
23
  'Client',
21
24
  'db_push',
22
25
  'eager',
26
+ 'asdict',
23
27
  'unwarp',
24
28
  'unwarp_or',
25
29
  'unwarp_or_raise',
26
30
  'BaseDBPool',
27
31
  'save_local',
28
32
  'DataSourceConfig',
33
+ 'record_sql',
29
34
  ]
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses as _dataclasses
4
+ from collections.abc import Mapping, Sequence
5
+ from dataclasses import fields, is_dataclass
6
+ from typing import Any, Literal, cast
7
+
8
+ from .runtime.backends.lazy import (
9
+ LAZY_RELATION_STATE,
10
+ LazyInstance,
11
+ LazyRelationState,
12
+ resolve_lazy_relation,
13
+ )
14
+
15
+ RelationPolicy = Literal['skip', 'fetch', 'keep']
16
+ RelationKey = tuple[type[Any] | None, tuple[tuple[str, Any], ...]]
17
+ _SEQUENCE_SKIP_TYPES = (str, bytes, bytearray)
18
+
19
+
20
+ def asdict(value: Any, *, relation_policy: RelationPolicy = 'keep') -> Any:
21
+ if value is None:
22
+ return None
23
+
24
+ memo: set[int] = set()
25
+ relation_guard: set[RelationKey] = set()
26
+ return _convert_value(value, relation_policy, memo, relation_guard)
27
+
28
+
29
+ def _convert_value(
30
+ value: Any,
31
+ relation_policy: RelationPolicy,
32
+ memo: set[int],
33
+ relation_guard: set[RelationKey],
34
+ ) -> Any:
35
+ if value is None:
36
+ return None
37
+ if is_dataclass(value):
38
+ return _convert_dataclass(value, relation_policy, memo, relation_guard)
39
+ if isinstance(value, LazyInstance):
40
+ resolved = value._lazy_resolve()
41
+ return _convert_value(resolved, relation_policy, memo, relation_guard)
42
+ if isinstance(value, Mapping):
43
+ return {
44
+ key: _convert_value(item, relation_policy, memo, relation_guard)
45
+ for key, item in value.items()
46
+ }
47
+ if isinstance(value, list):
48
+ return [_convert_value(item, relation_policy, memo, relation_guard) for item in value]
49
+ if isinstance(value, tuple):
50
+ return tuple(_convert_value(item, relation_policy, memo, relation_guard) for item in value)
51
+ if isinstance(value, Sequence) and not isinstance(value, _SEQUENCE_SKIP_TYPES):
52
+ return [_convert_value(item, relation_policy, memo, relation_guard) for item in value]
53
+ return value
54
+
55
+
56
+ def _convert_dataclass(
57
+ instance: Any,
58
+ relation_policy: RelationPolicy,
59
+ memo: set[int],
60
+ relation_guard: set[RelationKey],
61
+ ) -> dict[str, Any]:
62
+ instance_id = id(instance)
63
+ if instance_id in memo:
64
+ raise RecursionError('dclassql.asdict() detected a recursive dataclass reference')
65
+ memo.add(instance_id)
66
+ try:
67
+ state_map = LAZY_RELATION_STATE.get(instance)
68
+ result: dict[str, Any] = {}
69
+ for field_obj in fields(instance):
70
+ name = field_obj.name
71
+ state = _lookup_relation_state(state_map, name)
72
+ if state is not None:
73
+ result[name] = _convert_relation(instance, state, relation_policy, memo, relation_guard)
74
+ continue
75
+ value = getattr(instance, name)
76
+ result[name] = _convert_value(value, relation_policy, memo, relation_guard)
77
+ return result
78
+ finally:
79
+ memo.remove(instance_id)
80
+
81
+
82
+ def _lookup_relation_state(state_map: dict[str, LazyRelationState] | None, name: str) -> LazyRelationState | None:
83
+ if state_map is None:
84
+ return None
85
+ return state_map.get(name)
86
+
87
+
88
+ def _convert_relation(
89
+ owner: Any,
90
+ state: LazyRelationState,
91
+ relation_policy: RelationPolicy,
92
+ memo: set[int],
93
+ relation_guard: set[RelationKey],
94
+ ) -> Any:
95
+ relation_key = _relation_identity(owner, state)
96
+ if relation_key is not None and relation_key in relation_guard:
97
+ return [] if state.many else None
98
+
99
+ guard_added = False
100
+ if relation_key is not None:
101
+ relation_guard.add(relation_key)
102
+ guard_added = True
103
+
104
+ try:
105
+ if relation_policy == 'skip':
106
+ return [] if state.many else None
107
+
108
+ if relation_policy == 'fetch':
109
+ value = resolve_lazy_relation(owner, state)
110
+ elif state.loaded:
111
+ value = state.value
112
+ else:
113
+ if relation_policy == 'keep':
114
+ return [] if state.many else None
115
+ value = resolve_lazy_relation(owner, state)
116
+
117
+ if value is None:
118
+ return None if not state.many else []
119
+
120
+ if state.many:
121
+ result_list: list[Any] = []
122
+ for item in value:
123
+ if is_dataclass(item) and id(item) in memo:
124
+ continue
125
+ result_list.append(_convert_value(item, relation_policy, memo, relation_guard))
126
+ return result_list
127
+
128
+ if is_dataclass(value) and id(value) in memo:
129
+ return None
130
+
131
+ return _convert_value(value, relation_policy, memo, relation_guard)
132
+ finally:
133
+ if guard_added and relation_key is not None:
134
+ relation_guard.discard(relation_key)
135
+
136
+
137
+ def _relation_identity(owner: Any, state: LazyRelationState) -> RelationKey | None:
138
+ mapping = state.mapping
139
+ if not mapping:
140
+ return None
141
+ values: list[tuple[str, Any]] = []
142
+ for owner_column, target_column in mapping:
143
+ owner_value = getattr(owner, owner_column, None)
144
+ if owner_value is None:
145
+ return None
146
+ values.append((target_column, owner_value))
147
+ model_cls = getattr(state.table_cls, 'model', None)
148
+ return (model_cls, tuple(values))
149
+
150
+
151
+ __all__ = ['RelationPolicy', 'asdict']
152
+
153
+
154
+ def _apply_dict_factory(value: Any, dict_factory: Any) -> Any:
155
+ if isinstance(value, dict):
156
+ return dict_factory((key, _apply_dict_factory(val, dict_factory)) for key, val in value.items())
157
+ if isinstance(value, list):
158
+ return [_apply_dict_factory(item, dict_factory) for item in value]
159
+ if isinstance(value, tuple):
160
+ return tuple(_apply_dict_factory(item, dict_factory) for item in value)
161
+ if isinstance(value, set):
162
+ return {_apply_dict_factory(item, dict_factory) for item in value}
163
+ return value
164
+
165
+
166
+ def _patch_dataclasses_asdict() -> None:
167
+ original = getattr(_dataclasses, '_dclassql_original_asdict_inner', None)
168
+ if original is not None:
169
+ return
170
+
171
+ original_inner = cast(Any, getattr(_dataclasses, '_asdict_inner'))
172
+
173
+ def _patched_inner(obj: Any, dict_factory: Any):
174
+ obj_in_state = False
175
+ try:
176
+ # some objects may raise exceptions on __hash__/__eq__
177
+ obj_in_state = obj in LAZY_RELATION_STATE
178
+ except Exception:
179
+ pass
180
+
181
+ if obj_in_state:
182
+ data = asdict(obj)
183
+ if dict_factory is dict:
184
+ return data
185
+ return _apply_dict_factory(data, dict_factory)
186
+ return original_inner(obj, dict_factory)
187
+
188
+ setattr(_dataclasses, '_asdict_inner', _patched_inner)
189
+ setattr(_dataclasses, '_dclassql_original_asdict_inner', original_inner)
190
+
191
+
192
+ _patch_dataclasses_asdict()
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Any, Literal, overload
5
+
6
+ from .client import (
7
+ Address,
8
+ AddressDict,
9
+ BirthDay,
10
+ BirthDayDict,
11
+ Book,
12
+ BookDict,
13
+ User,
14
+ UserDict,
15
+ UserBook,
16
+ UserBookDict,
17
+ )
18
+
19
+ RelationPolicy = Literal['skip', 'fetch', 'keep']
20
+
21
+ @overload
22
+ def asdict(value: Address, *, relation_policy: RelationPolicy = 'keep') -> AddressDict: ...
23
+
24
+ @overload
25
+ def asdict(value: BirthDay, *, relation_policy: RelationPolicy = 'keep') -> BirthDayDict: ...
26
+
27
+ @overload
28
+ def asdict(value: Book, *, relation_policy: RelationPolicy = 'keep') -> BookDict: ...
29
+
30
+ @overload
31
+ def asdict(value: User, *, relation_policy: RelationPolicy = 'keep') -> UserDict: ...
32
+
33
+ @overload
34
+ def asdict(value: UserBook, *, relation_policy: RelationPolicy = 'keep') -> UserBookDict: ...
35
+
36
+ @overload
37
+ def asdict(value: Sequence[Address], *, relation_policy: RelationPolicy = 'keep') -> list[AddressDict]: ...
38
+
39
+ @overload
40
+ def asdict(value: Sequence[BirthDay], *, relation_policy: RelationPolicy = 'keep') -> list[BirthDayDict]: ...
41
+
42
+ @overload
43
+ def asdict(value: Sequence[Book], *, relation_policy: RelationPolicy = 'keep') -> list[BookDict]: ...
44
+
45
+ @overload
46
+ def asdict(value: Sequence[User], *, relation_policy: RelationPolicy = 'keep') -> list[UserDict]: ...
47
+
48
+ @overload
49
+ def asdict(value: Sequence[UserBook], *, relation_policy: RelationPolicy = 'keep') -> list[UserBookDict]: ...
50
+
51
+ @overload
52
+ def asdict(value: Sequence[Any], *, relation_policy: RelationPolicy = 'keep') -> list[Any]: ...
53
+
54
+ @overload
55
+ def asdict(value: None, *, relation_policy: RelationPolicy = 'keep') -> None: ...
56
+
57
+ def asdict(value: object, *, relation_policy: RelationPolicy = 'keep') -> Any: ...
@@ -28,11 +28,27 @@ ConfirmCallback = Callable[
28
28
  ]
29
29
 
30
30
 
31
+ def _module_name_from_path(module_path: Path) -> str:
32
+ candidate = module_path.with_suffix("")
33
+ cwd = Path.cwd()
34
+ try:
35
+ rel = candidate.relative_to(cwd)
36
+ parts = rel.parts
37
+ except ValueError:
38
+ parts = candidate.parts
39
+ trimmed_parts = list(parts)
40
+
41
+ if not trimmed_parts:
42
+ trimmed_parts = [module_path.stem]
43
+
44
+ sanitized_parts = [re.sub(r"[^0-9a-zA-Z_]+", "_", part) or "_" for part in trimmed_parts]
45
+ return ".".join(sanitized_parts)
46
+
31
47
  def load_module(module_path: Path) -> ModuleType:
32
48
  module_path = module_path.resolve()
33
49
  if not module_path.exists():
34
50
  raise FileNotFoundError(f"Model file '{module_path}' does not exist")
35
- module_name = module_path.stem
51
+ module_name = _module_name_from_path(module_path)
36
52
  spec = importlib.util.spec_from_file_location(module_name, module_path)
37
53
  if spec is None or spec.loader is None:
38
54
  raise ImportError(f"Unable to load module from '{module_path}'")
@@ -57,6 +73,10 @@ def resolve_generated_path() -> Path:
57
73
  return _find_package_directory() / GENERATED_CLIENT_FILENAME
58
74
 
59
75
 
76
+ def resolve_asdict_stub_path() -> Path:
77
+ return _find_package_directory() / "asdict.pyi"
78
+
79
+
60
80
  def resolve_models_directory() -> Path:
61
81
  return _find_package_directory() / GENERATED_MODELS_DIRNAME
62
82
 
@@ -195,6 +215,8 @@ def command_generate(module_path: Path) -> None:
195
215
  output_path = resolve_generated_path()
196
216
  output_path.parent.mkdir(parents=True, exist_ok=True)
197
217
  output_path.write_text(generated.code, encoding="utf-8")
218
+ asdict_stub_path = resolve_asdict_stub_path()
219
+ asdict_stub_path.write_text(generated.asdict_stub, encoding="utf-8")
198
220
  sys.stdout.write(f"Client written to {output_path}\n")
199
221
 
200
222
 
@@ -256,8 +278,9 @@ def main(argv: Sequence[str] | None = None) -> int:
256
278
  try:
257
279
  handler(args)
258
280
  return 0
259
- except Exception as exc: # pragma: no cover - CLI error reporting
260
- print(f"Error: {exc}", file=sys.stderr)
281
+ except Exception: # pragma: no cover - CLI error reporting
282
+ import traceback
283
+ traceback.print_exc(file=sys.stderr)
261
284
  return 1
262
285
 
263
286