snake-orm 0.1.0b1__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 (198) hide show
  1. snake_orm-0.1.0b1.dist-info/METADATA +366 -0
  2. snake_orm-0.1.0b1.dist-info/RECORD +198 -0
  3. snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
  4. snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
  5. snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
  6. snakeorm/__init__.py +214 -0
  7. snakeorm/advisor.py +109 -0
  8. snakeorm/cli/__init__.py +3 -0
  9. snakeorm/cli/app.py +992 -0
  10. snakeorm/cli/discovery.py +179 -0
  11. snakeorm/cli/hooks.py +86 -0
  12. snakeorm/compiler/__init__.py +3 -0
  13. snakeorm/compiler/compiler.py +330 -0
  14. snakeorm/connection.py +228 -0
  15. snakeorm/contrib/__init__.py +17 -0
  16. snakeorm/contrib/asgi.py +192 -0
  17. snakeorm/contrib/config.py +264 -0
  18. snakeorm/contrib/deliver.py +256 -0
  19. snakeorm/contrib/django.py +245 -0
  20. snakeorm/contrib/sidecar.py +44 -0
  21. snakeorm/contrib/wsgi.py +176 -0
  22. snakeorm/core/__init__.py +1 -0
  23. snakeorm/core/config.py +173 -0
  24. snakeorm/core/converters.py +172 -0
  25. snakeorm/core/exceptions.py +175 -0
  26. snakeorm/core/placement.py +15 -0
  27. snakeorm/core/sentinels.py +37 -0
  28. snakeorm/core/signals.py +114 -0
  29. snakeorm/debug/__init__.py +42 -0
  30. snakeorm/debug/assets/css/panel.css +616 -0
  31. snakeorm/debug/assets/css/snake_orm_app.css +293 -0
  32. snakeorm/debug/assets/img/icon.svg +5 -0
  33. snakeorm/debug/assets/js/history.js +425 -0
  34. snakeorm/debug/assets/js/language.js +283 -0
  35. snakeorm/debug/assets/js/panel.js +241 -0
  36. snakeorm/debug/assets/js/snake_orm_app.js +82 -0
  37. snakeorm/debug/assets/pages/cli.html +28 -0
  38. snakeorm/debug/assets/pages/config.html +34 -0
  39. snakeorm/debug/assets/pages/database_first.html +29 -0
  40. snakeorm/debug/assets/pages/help.html +21 -0
  41. snakeorm/debug/assets/pages/history.html +27 -0
  42. snakeorm/debug/capture.py +234 -0
  43. snakeorm/debug/channel.py +131 -0
  44. snakeorm/debug/collector.py +144 -0
  45. snakeorm/debug/config.py +149 -0
  46. snakeorm/debug/html.py +578 -0
  47. snakeorm/debug/matrix.py +60 -0
  48. snakeorm/debug/origin.py +42 -0
  49. snakeorm/debug/otel/__init__.py +68 -0
  50. snakeorm/debug/otel/context.py +43 -0
  51. snakeorm/debug/otel/exporter.py +277 -0
  52. snakeorm/debug/otel/payload.py +97 -0
  53. snakeorm/debug/otel/report.py +29 -0
  54. snakeorm/debug/otel/spans.py +393 -0
  55. snakeorm/debug/otel/summary.py +59 -0
  56. snakeorm/debug/record.py +54 -0
  57. snakeorm/debug/report.py +311 -0
  58. snakeorm/debug/testing.py +23 -0
  59. snakeorm/decorators/__init__.py +13 -0
  60. snakeorm/decorators/abstract.py +26 -0
  61. snakeorm/decorators/db_first.py +104 -0
  62. snakeorm/decorators/function.py +28 -0
  63. snakeorm/decorators/model.py +268 -0
  64. snakeorm/decorators/polymorphic.py +140 -0
  65. snakeorm/decorators/result.py +167 -0
  66. snakeorm/decorators/row.py +90 -0
  67. snakeorm/decorators/trigger.py +47 -0
  68. snakeorm/decorators/view.py +149 -0
  69. snakeorm/dialects/__init__.py +6 -0
  70. snakeorm/dialects/base.py +373 -0
  71. snakeorm/dialects/capabilities.py +571 -0
  72. snakeorm/dialects/literals.py +46 -0
  73. snakeorm/dialects/matrix.py +384 -0
  74. snakeorm/dialects/mysql.py +519 -0
  75. snakeorm/dialects/postgres.py +504 -0
  76. snakeorm/dialects/sqlite.py +417 -0
  77. snakeorm/drivers/__init__.py +18 -0
  78. snakeorm/drivers/asyncbase.py +73 -0
  79. snakeorm/drivers/asyncdecorators.py +244 -0
  80. snakeorm/drivers/asyncpool.py +228 -0
  81. snakeorm/drivers/asyncpsycopg.py +124 -0
  82. snakeorm/drivers/asyncpymysql.py +50 -0
  83. snakeorm/drivers/asyncsqlite.py +30 -0
  84. snakeorm/drivers/base.py +76 -0
  85. snakeorm/drivers/failures.py +181 -0
  86. snakeorm/drivers/logging.py +167 -0
  87. snakeorm/drivers/pool.py +352 -0
  88. snakeorm/drivers/psycopg.py +188 -0
  89. snakeorm/drivers/pymysql.py +176 -0
  90. snakeorm/drivers/savepoints.py +46 -0
  91. snakeorm/drivers/sqlite.py +170 -0
  92. snakeorm/drivers/threaded.py +143 -0
  93. snakeorm/drivers/timeout.py +87 -0
  94. snakeorm/dto/__init__.py +55 -0
  95. snakeorm/dto/read.py +231 -0
  96. snakeorm/dto/region.py +326 -0
  97. snakeorm/dto/resolve.py +354 -0
  98. snakeorm/dto/spec.py +201 -0
  99. snakeorm/expressions/__init__.py +88 -0
  100. snakeorm/expressions/conditional.py +178 -0
  101. snakeorm/expressions/expression.py +810 -0
  102. snakeorm/expressions/functions.py +71 -0
  103. snakeorm/expressions/keys.py +299 -0
  104. snakeorm/expressions/paths.py +43 -0
  105. snakeorm/expressions/scalar.py +293 -0
  106. snakeorm/expressions/window.py +248 -0
  107. snakeorm/fields/__init__.py +68 -0
  108. snakeorm/fields/check.py +79 -0
  109. snakeorm/fields/column.py +316 -0
  110. snakeorm/fields/enum.py +64 -0
  111. snakeorm/fields/index.py +46 -0
  112. snakeorm/fields/relationship.py +738 -0
  113. snakeorm/fields/typed.py +736 -0
  114. snakeorm/helpers/__init__.py +1 -0
  115. snakeorm/helpers/annotations.py +21 -0
  116. snakeorm/helpers/inheritance.py +41 -0
  117. snakeorm/helpers/pyliteral.py +52 -0
  118. snakeorm/helpers/pytype.py +50 -0
  119. snakeorm/introspection/__init__.py +13 -0
  120. snakeorm/introspection/base.py +30 -0
  121. snakeorm/introspection/drift.py +78 -0
  122. snakeorm/introspection/models.py +633 -0
  123. snakeorm/introspection/mysql.py +367 -0
  124. snakeorm/introspection/postgres.py +397 -0
  125. snakeorm/introspection/sqlite.py +217 -0
  126. snakeorm/introspection/unsupported.py +94 -0
  127. snakeorm/linker/__init__.py +3 -0
  128. snakeorm/linker/linker.py +521 -0
  129. snakeorm/metadata/__init__.py +41 -0
  130. snakeorm/metadata/check.py +37 -0
  131. snakeorm/metadata/column.py +201 -0
  132. snakeorm/metadata/enum_storage.py +33 -0
  133. snakeorm/metadata/fk_action.py +19 -0
  134. snakeorm/metadata/foreign_key.py +26 -0
  135. snakeorm/metadata/index.py +43 -0
  136. snakeorm/metadata/index_method.py +19 -0
  137. snakeorm/metadata/int_size.py +20 -0
  138. snakeorm/metadata/json_storage.py +21 -0
  139. snakeorm/metadata/polymorphic.py +30 -0
  140. snakeorm/metadata/primary_key.py +23 -0
  141. snakeorm/metadata/relationship.py +60 -0
  142. snakeorm/metadata/relationship_kind.py +43 -0
  143. snakeorm/metadata/routine.py +26 -0
  144. snakeorm/metadata/server_default.py +20 -0
  145. snakeorm/metadata/table.py +97 -0
  146. snakeorm/metadata/table_kind.py +23 -0
  147. snakeorm/metadata/trigger.py +62 -0
  148. snakeorm/metadata/type_params.py +348 -0
  149. snakeorm/migration/__init__.py +88 -0
  150. snakeorm/migration/asyncrunner.py +168 -0
  151. snakeorm/migration/autodetect.py +96 -0
  152. snakeorm/migration/ddl.py +1172 -0
  153. snakeorm/migration/diff.py +701 -0
  154. snakeorm/migration/loader.py +77 -0
  155. snakeorm/migration/operations.py +952 -0
  156. snakeorm/migration/planner.py +78 -0
  157. snakeorm/migration/realize.py +420 -0
  158. snakeorm/migration/renames.py +153 -0
  159. snakeorm/migration/render.py +1095 -0
  160. snakeorm/migration/runner.py +314 -0
  161. snakeorm/migration/squash.py +60 -0
  162. snakeorm/migration/state.py +107 -0
  163. snakeorm/model.py +162 -0
  164. snakeorm/py.typed +0 -0
  165. snakeorm/query/__init__.py +9 -0
  166. snakeorm/query/compound.py +288 -0
  167. snakeorm/query/join_kind.py +19 -0
  168. snakeorm/query/joined.py +143 -0
  169. snakeorm/query/query.py +1001 -0
  170. snakeorm/query/recursive.py +244 -0
  171. snakeorm/registry/__init__.py +5 -0
  172. snakeorm/registry/by_module.py +163 -0
  173. snakeorm/registry/registry.py +166 -0
  174. snakeorm/session/__init__.py +8 -0
  175. snakeorm/session/asyncsession.py +638 -0
  176. snakeorm/session/coercion.py +306 -0
  177. snakeorm/session/factory.py +29 -0
  178. snakeorm/session/guards.py +269 -0
  179. snakeorm/session/isolation.py +23 -0
  180. snakeorm/session/mapper.py +213 -0
  181. snakeorm/session/planning.py +815 -0
  182. snakeorm/session/retry.py +96 -0
  183. snakeorm/session/session.py +871 -0
  184. snakeorm/session/shared.py +355 -0
  185. snakeorm/sql/__init__.py +18 -0
  186. snakeorm/sql/adapt.py +104 -0
  187. snakeorm/sql/aggregate.py +232 -0
  188. snakeorm/sql/condition.py +359 -0
  189. snakeorm/sql/delete.py +46 -0
  190. snakeorm/sql/insert.py +149 -0
  191. snakeorm/sql/joins.py +158 -0
  192. snakeorm/sql/pk_subquery.py +54 -0
  193. snakeorm/sql/refs.py +19 -0
  194. snakeorm/sql/resolver.py +28 -0
  195. snakeorm/sql/select.py +175 -0
  196. snakeorm/sql/update.py +82 -0
  197. snakeorm/sql/value.py +552 -0
  198. snakeorm/times.py +276 -0
@@ -0,0 +1,306 @@
1
+ """Coercion of the driver's values to the declared python_type (the ORM is type-first: it guarantees the type).
2
+
3
+ DBAPI drivers do not always return the exact type (psycopg2 hands UUIDs back as str). The converters
4
+ are IDEMPOTENT, so they work for any driver without coupling to it. No converter for `int` on
5
+ purpose: `Decimal` -> `int` would truncate in silence.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from collections.abc import Callable
13
+ from datetime import UTC, date, datetime, time, timedelta
14
+ from decimal import Decimal
15
+ from enum import Enum
16
+ from typing import get_origin
17
+ from uuid import UUID
18
+
19
+ from snakeorm.core.converters import from_db_for, mark_builtin
20
+ from snakeorm.core.exceptions import SnakeValueError
21
+ from snakeorm.times import SnakeUtc
22
+
23
+
24
+ def _to_uuid(value: object) -> object:
25
+ """psycopg2 returns UUID columns as `str`; other drivers already hand back a `UUID`."""
26
+ return value if isinstance(value, UUID) else UUID(str(value))
27
+
28
+
29
+ def _to_float(value: object) -> object:
30
+ """A Postgres `numeric` (AVG/SUM over numeric) arrives as a `Decimal`. It only converts
31
+ numerics; the rest passes through untouched instead of blowing up."""
32
+ if isinstance(value, float):
33
+ return value
34
+ if isinstance(value, (int, Decimal)):
35
+ return float(value)
36
+ return value
37
+
38
+
39
+ def _to_bool(value: object) -> object:
40
+ """Converts to `bool` whatever the engine returns as 0/1 (SQLite stores integers; Postgres
41
+ already hands back a `bool`, idempotent)."""
42
+ return bool(value)
43
+
44
+
45
+ def _to_json(value: object) -> object:
46
+ """Returns a `dict` out of whatever each engine hands over: Postgres already gives a `dict`
47
+ (jsonb), SQLite gives TEXT and it gets parsed. Idempotent."""
48
+ if isinstance(value, str):
49
+ return json.loads(value)
50
+ return value
51
+
52
+
53
+ def _to_decimal(value: object) -> object:
54
+ """Rebuilds the `Decimal` out of whatever each engine returns (Postgres already gives a
55
+ `Decimal`, SQLite gives text). It is parsed from the TEXT, never from a `float`: going through
56
+ floating point would corrupt the precision (`1234.56` -> `1234.5599...`), exactly what a
57
+ `Decimal` avoids.
58
+ """
59
+ if isinstance(value, Decimal):
60
+ return value
61
+ if isinstance(value, (str, int)):
62
+ return Decimal(value)
63
+ return value
64
+
65
+
66
+ def _to_list(value: object) -> object:
67
+ """Rebuilds the list out of whatever each engine returns.
68
+
69
+ Postgres has arrays and psycopg2 already hands over a `list`; SQLite and MySQL do not have them,
70
+ so the column is TEXT and what arrives is the JSON `adapt_param` wrote. Idempotent, and therein
71
+ lies the trick that lets the converter registry be engine-agnostic while the SQL type is not:
72
+ the same code swallows both shapes, so nobody has to ask which engine they are on.
73
+
74
+ A text that is NOT JSON passes through untouched instead of blowing up: in Postgres a text column
75
+ declared `list[str]` is an error of the model, and the error has to come out over there and not here.
76
+ """
77
+ if isinstance(value, str):
78
+ try:
79
+ return json.loads(value)
80
+ except json.JSONDecodeError:
81
+ return value
82
+ return value
83
+
84
+
85
+ def _to_bytes(value: object) -> object:
86
+ """A Postgres `bytea` arrives as a `memoryview`, not as `bytes`. The bug is invisible (it
87
+ compares equal with `==`) until somebody does `hashlib.sha256(row.blob)`."""
88
+ if isinstance(value, memoryview):
89
+ return value.tobytes()
90
+ return value
91
+
92
+
93
+ def _to_snake_utc(value: object) -> object:
94
+ """Rebuilds a `SnakeUtc` out of whatever the driver returns.
95
+
96
+ Without this the column would promise an instant in UTC and hand back a bare `datetime`: the
97
+ declared type would stop being the type received, which is the central promise of the project.
98
+ """
99
+ read_back = _to_datetime(value)
100
+ assert isinstance(
101
+ read_back, datetime
102
+ ) # `_to_datetime` normalises already or raises
103
+ return (
104
+ SnakeUtc.of(read_back)
105
+ if read_back.tzinfo
106
+ else SnakeUtc.of(read_back.replace(tzinfo=UTC))
107
+ )
108
+
109
+
110
+ def _to_datetime(value: object) -> object:
111
+ """Rebuilds the `datetime` from SQLite's ISO 8601 (Postgres already gives a `datetime`,
112
+ idempotent; SQLite gives text ever since Python 3.12 withdrew its default converters)."""
113
+ if isinstance(value, str):
114
+ return datetime.fromisoformat(value)
115
+ return value
116
+
117
+
118
+ def _to_date(value: object) -> object:
119
+ """The same for `date`. A `datetime` is NOT truncated here: that would be inventing a conversion."""
120
+ if isinstance(value, str):
121
+ return date.fromisoformat(value)
122
+ return value
123
+
124
+
125
+ def _to_time(value: object) -> object:
126
+ """Rebuilds the `time` from what each engine hands back. Idempotent on Postgres.
127
+
128
+ SQLite returns ISO text. **MySQL returns a `timedelta`**, and it is not a driver quirk: its
129
+ `TIME` is a DURATION (-838:59:59 to 838:59:59), so PyMySQL is right to hand one over. A column
130
+ declared `time` still has to come back as a `time`, which is this ORM's whole contract.
131
+
132
+ A duration that does not fit in a day is not a clock reading, and it RAISES instead of wrapping
133
+ round: `timedelta(hours=30)` quietly becoming `06:00` is a wrong answer wearing the face of a
134
+ right one.
135
+ """
136
+ if isinstance(value, str):
137
+ return time.fromisoformat(value)
138
+ if isinstance(value, timedelta):
139
+ if not timedelta(0) <= value < timedelta(days=1):
140
+ raise SnakeValueError(
141
+ f"a column declared `time` came back as {value!r}, which is not a time of day. "
142
+ f"MySQL's TIME stores a duration and reaches from -838:59:59 to 838:59:59; declare "
143
+ f"the column `timedelta` if that is what it holds."
144
+ )
145
+ hours, rest = divmod(value.seconds, 3600)
146
+ minutes, seconds = divmod(rest, 60)
147
+ return time(hours, minutes, seconds, value.microseconds)
148
+ return value
149
+
150
+
151
+ _DURATION = re.compile(
152
+ r"^(?:(?P<days>-?\d+) days?, )?(?P<hours>-?\d+):(?P<minutes>\d{2}):"
153
+ r"(?P<seconds>\d{2})(?:\.(?P<micros>\d{1,6}))?$"
154
+ )
155
+
156
+
157
+ def _to_timedelta(value: object) -> object:
158
+ """Rebuilds the `timedelta` from the text of `str(timedelta)` (Postgres already gives a
159
+ `timedelta` via `interval`, idempotent; SQLite gives text). A text that does NOT match passes
160
+ through untouched instead of blowing up.
161
+ """
162
+ if not isinstance(value, str):
163
+ return value
164
+ parts = _DURATION.match(value)
165
+ if parts is None:
166
+ return value
167
+ days = int(parts["days"] or 0)
168
+ hours = int(parts["hours"])
169
+ sign = -1 if hours < 0 or (parts["hours"] or "").startswith("-") else 1
170
+ return timedelta(
171
+ days=days,
172
+ hours=hours,
173
+ minutes=sign * int(parts["minutes"]),
174
+ seconds=sign * int(parts["seconds"]),
175
+ microseconds=sign * int((parts["micros"] or "0").ljust(6, "0")),
176
+ )
177
+
178
+
179
+ _CONVERTERS: dict[type, Callable[[object], object]] = {
180
+ SnakeUtc: _to_snake_utc,
181
+ UUID: _to_uuid,
182
+ dict: _to_json,
183
+ Decimal: _to_decimal,
184
+ bytes: _to_bytes,
185
+ datetime: _to_datetime,
186
+ date: _to_date,
187
+ time: _to_time,
188
+ timedelta: _to_timedelta,
189
+ float: _to_float,
190
+ bool: _to_bool,
191
+ }
192
+
193
+
194
+ # They are declared at import time, and not listed by hand in `core/`: the source is this very
195
+ # registry, so adding an internal converter protects it on its own. `core/` cannot import from here
196
+ # (it would be a cycle between layers), so the direction is this one: the upper layer registers
197
+ # itself as it loads.
198
+ mark_builtin(_CONVERTERS.keys())
199
+
200
+
201
+ def _to_decimal_with_scale(scale: int) -> Callable[[object], object]:
202
+ """Converter for a `Decimal` column that DECLARES a scale: it pads the value out to it.
203
+
204
+ Adding zeros is safe; rounding is not, and this only ever adds. That is not a hope, it is
205
+ guaranteed by the other half: `_guard_declared_limits` refuses on WRITE any value carrying more
206
+ decimals than the scale, so nothing with more can be in the database to begin with. `quantize`
207
+ here can only ever go the padding direction.
208
+
209
+ It exists because the engines disagreed and the disagreement reached the screen. Postgres and
210
+ MySQL apply the declared scale in storage and hand back `Decimal('1.00')`; SQLite keeps the text
211
+ it was given and hands back `Decimal('1')`. The same NUMBER — `Decimal('1') == Decimal('1.00')`
212
+ — so arithmetic never noticed, and `str()` did: one price printed `1` on one engine and `1.00`
213
+ on another, out of one model.
214
+ """
215
+ exponent = Decimal(1).scaleb(-scale)
216
+
217
+ def convert(value: object) -> object:
218
+ """`Decimal('1')` -> `Decimal('1.00')`, from whatever each engine handed over."""
219
+ rebuilt = _to_decimal(value)
220
+ return rebuilt.quantize(exponent) if isinstance(rebuilt, Decimal) else rebuilt
221
+
222
+ return convert
223
+
224
+
225
+ def converter_for(
226
+ python_type: object, scale: int | None = None
227
+ ) -> Callable[[object], object] | None:
228
+ """The converter of a type, resolved ONCE, or `None` if the value passes through as is.
229
+
230
+ It is resolved by TYPE (fixed per column), not per row, so as not to repeat the
231
+ `issubclass`/lookup on the hot path; `None` avoids paying for a call per column with no conversion.
232
+
233
+ Uniform CONTRACT: the converter does NOT handle the NULL — the caller guards it (`value is None`
234
+ BEFORE invoking). A NULL is of another type (`_to_bool(None)` would give `False`, `_to_uuid(None)`
235
+ would blow up); the inline guard in `hydrate` is free, and having NONE of them be None-safe stops
236
+ one from handling it while another does not."""
237
+ if python_type is Decimal and scale is not None:
238
+ # The DECLARED scale travels with the column, so the value comes back carrying it whichever
239
+ # engine stored it. See `_to_decimal_with_scale`: it only ever pads.
240
+ return _to_decimal_with_scale(scale)
241
+ if isinstance(python_type, type) and issubclass(python_type, Enum):
242
+ # An enum is built by calling it, and it is idempotent with an already-built member.
243
+ return _to_enum(python_type)
244
+ # `python_type` is accepted as `object` because a column may declare a generic alias
245
+ # (`list[str]`), which is NOT a class. Only `type`s can be in the registry.
246
+ registered = from_db_for(python_type)
247
+ if registered is not None:
248
+ # A domain type declared with `register_converter`. It is consulted BEFORE the internal
249
+ # registry so that a subclass of a handled type (of `str`, say) can declare its own way back;
250
+ # without this it would come back as its base and the declared type would stop being the one
251
+ # you receive, which is the silent failure this ORM does not commit.
252
+ return registered
253
+ if get_origin(python_type) is dict:
254
+ # The twin of the `list` case below, and it was missing for the same reason it went
255
+ # unnoticed: the compiler refused a parameterised dict before anybody could get here. With
256
+ # that refusal lifted, no converter means the attribute declared `dict[str, object]` holds
257
+ # the raw JSON string — and on an engine where the column is TEXT nothing complains at all.
258
+ return _CONVERTERS.get(dict)
259
+ if get_origin(python_type) is list:
260
+ # This used to return `None`, and on Postgres it got away with it because psycopg2 already
261
+ # hands over a list. On the engines without arrays the column is TEXT, so with no converter
262
+ # the attribute declared `list[str]` would have held the raw JSON.
263
+ return _to_list
264
+ if not isinstance(python_type, type):
265
+ return None
266
+ return _CONVERTERS.get(python_type)
267
+
268
+
269
+ def _to_enum(enum_type: type[Enum]) -> Callable[[object], object]:
270
+ """Converter for a concrete enum, closed over its class. It does NOT handle the NULL (the
271
+ contract is in `converter_for`): the caller guards `value is None` beforehand."""
272
+
273
+ def convert(value: object) -> object:
274
+ """`Level("pro")` -> `Level.PRO`."""
275
+ return enum_type(value)
276
+
277
+ return convert
278
+
279
+
280
+ def coerce(value: object, python_type: object, scale: int | None = None) -> object:
281
+ """Coerces `value` to the declared `python_type`. NULL and the types with no converter pass through untouched.
282
+
283
+ A DELEGATION and not a second implementation, and that is the whole point. This is the door the
284
+ write-back path comes through —the RETURNING of `add()`/`refresh()`, and `annotate`/prefetch—
285
+ while `hydrate` comes through `converter_for`. Two doors into the same wardrobe: a value that
286
+ went in one way and came out the other must not have changed TYPE on the trip. A subset here
287
+ drifts, and what it drops is the user's own registrations: `from_db_for` — what
288
+ `register_converter` fills — and the generic aliases. Measured,
289
+ `converter_for(list[str])('["a","b"]')` gives `['a', 'b']` while a hand-rolled subset gave back
290
+ the raw JSON string, so the same column read as a list through `all()` and as a `str` through
291
+ `add()`.
292
+
293
+ `python_type` is `object` and not `type` for the reason `converter_for` states: a column may
294
+ declare a generic alias (`list[str]`), which is not a class.
295
+
296
+ `scale` has to travel too, or the two doors disagree one parameter further down: without it the
297
+ same column comes back `Decimal('1.00')` through `all()` and `Decimal('1')` through
298
+ `add()`/`refresh()` — and `refresh()` is the sharp one, it takes an object that was already
299
+ right and makes it worse. That stays invisible because `Decimal('1') == Decimal('1.00')` is
300
+ True: every equality passes across the gap and arithmetic never notices. It shows up in
301
+ `str()`, on a screen.
302
+ """
303
+ if value is None:
304
+ return None
305
+ converter = converter_for(python_type, scale)
306
+ return converter(value) if converter is not None else value
@@ -0,0 +1,29 @@
1
+ """Session factory keyed by connection NAME."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from snakeorm.core.config import DEFAULT_DATABASE, backend_name_for, dsn_for
6
+ from snakeorm.session.session import SnakeSession
7
+
8
+
9
+ def snake_session(database: str = DEFAULT_DATABASE) -> SnakeSession:
10
+ """Opens a session against the connection with that name, resolving the DSN by configuration.
11
+
12
+ A single path for the common case (multi-DB). Whoever needs to decorate the driver (logging,
13
+ pool, timeout) still builds the session by hand: this is convenience, not a replacement for the
14
+ seam.
15
+
16
+ Pairing driver and dialect is delegated to `SnakeConnectionConfig`, which exists precisely so
17
+ that a driver cannot be put together with another engine's dialect. Two composition roots are
18
+ one too many: the one nobody reviews is the one that ends up lying. The engine is READ like the
19
+ DSN is (see `backend_name_for`); hardcoding it would reach only one of the three.
20
+ """
21
+ # Lazy, and not for performance: `connection.py` imports `session` so it can return a session,
22
+ # so importing it up top would close the cycle. That is the natural direction — the connection
23
+ # config sits ABOVE the session, not the other way around — and this function is the
24
+ # convenience exception that swims against it.
25
+ from snakeorm.connection import SnakeBackend, SnakeConnectionConfig
26
+
27
+ return SnakeConnectionConfig.from_dsn(
28
+ dsn_for(database), SnakeBackend(backend_name_for(database))
29
+ ).open()
@@ -0,0 +1,269 @@
1
+ """The guards of a DECLARED limit: they are enforced in Python, before touching the database.
2
+
3
+ `snake_str(max_length=5)`, `snake_int(size=SMALLINT)` and `snake_decimal(precision=, scale=)` are
4
+ rules of the DOMAIN, not DDL ornaments. The ORM enforces them on write and does not delegate to the
5
+ engine, for two reasons: SQLite ignores the declared parameter — so in dev nothing would fail and in
6
+ prod it would — and when the engine does complain, it does so in its own jargon and halfway through
7
+ a bulk write.
8
+
9
+ They live here and not inside the session because they are not execution: they do not talk to the
10
+ driver, know nothing of transactions and do not depend on whether anyone awaited. The SYNCHRONOUS
11
+ session had them inside it, so the asynchronous one depended on it in order not to duplicate them —
12
+ one layer importing its sibling to ask it for something that belongs to neither of them.
13
+
14
+ None of them truncates or rounds. A `max_length` that cuts the string turns a rule into silent data
15
+ loss; the ORM shouts and whoever is writing decides.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Mapping
21
+ from datetime import datetime
22
+ from decimal import Decimal
23
+
24
+ from snakeorm.core.exceptions import (
25
+ SnakeEmitError,
26
+ SnakeUnsupportedFeature,
27
+ SnakeValueError,
28
+ )
29
+ from snakeorm.dialects.base import SnakeDialect
30
+ from snakeorm.dialects.capabilities import Cap, Nope
31
+ from snakeorm.metadata import (
32
+ SnakeColumnInfo,
33
+ SnakeIntSize,
34
+ SnakeTableInfo,
35
+ )
36
+ from snakeorm.fields import MISSING
37
+ from snakeorm.times import SnakeUtc
38
+
39
+
40
+ # Range of a signed integer per declared width. It is what Postgres enforces and SQLite does not:
41
+ # SQLite collapses every integer into its 64-bit INTEGER, so an overflowed SMALLINT gets in.
42
+ _INT_RANGES: dict[SnakeIntSize, tuple[int, int]] = {
43
+ SnakeIntSize.SMALLINT: (-(2**15), 2**15 - 1),
44
+ SnakeIntSize.INTEGER: (-(2**31), 2**31 - 1),
45
+ SnakeIntSize.BIGINT: (-(2**63), 2**63 - 1),
46
+ }
47
+
48
+
49
+ def _guard_declared_limits(table: SnakeTableInfo, values: dict[str, object]) -> None:
50
+ """Rejects a value that falls outside its column's DECLARED limit, before touching the database.
51
+
52
+ A declared knob (`scale`, `max_length`, `int_size`) is a rule of the domain. If the ORM only
53
+ writes it into the DDL, the ENGINE is what enforces it — and then it means different things
54
+ depending on where you run: Postgres rejects (`value too long`, `smallint out of range`) and
55
+ SQLite accepts, because it ignores the VARCHAR's length and collapses the integers. Checking it
56
+ here makes it hold on BOTH.
57
+
58
+ This is no detail: the SQLite dialect exists so one can work without a server, so without this
59
+ the suite comes out green in development and the deployment to Postgres blows up.
60
+
61
+ It SHOUTS, it never trims: truncating the text or rounding the number would be converting behind
62
+ the developer's back. The value gets fixed by whoever wrote it.
63
+ """
64
+ for column in table.columns:
65
+ value = values.get(column.name)
66
+ if value is None or column.name not in values:
67
+ continue # a NULL is not measured: nullability is decided by the annotation, not by these limits
68
+ _guard_scale(table, column, value)
69
+ _guard_length(table, column, value)
70
+ _guard_int_range(table, column, value)
71
+ _guard_timezone(table, column, value)
72
+
73
+
74
+ def _guard_scale(table: SnakeTableInfo, column: SnakeColumnInfo, value: object) -> None:
75
+ """Decimal places of a `Decimal` against the declared `scale`.
76
+
77
+ NaN/Inf (a non-numeric exponent) pass through so the engine rejects them, with a more specific
78
+ message than could be given here.
79
+ """
80
+ if column.scale is None or not isinstance(value, Decimal):
81
+ return
82
+ exponent = value.as_tuple().exponent
83
+ if isinstance(exponent, int) and -exponent > column.scale:
84
+ raise SnakeValueError(
85
+ f"{table.name}.{column.name} declares scale={column.scale} but an attempt was made to "
86
+ f"write {value}, with {-exponent} decimal places. Quantize it yourself "
87
+ f"(value.quantize(...)) before saving it: the ORM does not round in silence."
88
+ )
89
+
90
+
91
+ def _guard_length(
92
+ table: SnakeTableInfo, column: SnakeColumnInfo, value: object
93
+ ) -> None:
94
+ """Length of a text against the declared `max_length`. The limit is INCLUSIVE."""
95
+ if column.max_length is None or not isinstance(value, str):
96
+ return
97
+ if len(value) > column.max_length:
98
+ raise SnakeValueError(
99
+ f"{table.name}.{column.name} declares max_length={column.max_length} but an attempt was "
100
+ f"made to write a text of {len(value)} characters. Trim it yourself before saving it: "
101
+ f"the ORM does not truncate in silence."
102
+ )
103
+
104
+
105
+ def _guard_timezone(
106
+ table: SnakeTableInfo, column: SnakeColumnInfo, value: object
107
+ ) -> None:
108
+ """Demands that a `datetime` say the same thing as its column about carrying a zone, and be UTC.
109
+
110
+ It is the runtime safety net: the type already prevents it in the editor (`SnakeUtc` cannot be
111
+ built outside UTC), but whoever skips the checker also deserves a clear message.
112
+
113
+ On a `SnakeUtc` column (`TIMESTAMPTZ`) ONLY UTC is admitted. The column stores the INSTANT, not
114
+ the offset: were `14:30+02:00` accepted, Postgres would return `12:30+00:00` and SQLite
115
+ `14:30+02:00`, that is, `.hour` would be 12 or 14 depending on the engine.
116
+
117
+ On a `datetime` column (`TIMESTAMP`, wall-clock time) the opposite is rejected: discarding a
118
+ `tzinfo` really does destroy the instant, silently and only sometimes.
119
+ """
120
+ if not isinstance(value, datetime):
121
+ return
122
+ offset = value.utcoffset()
123
+ if column.python_type is SnakeUtc:
124
+ if offset is None:
125
+ raise SnakeValueError(
126
+ f"{table.name}.{column.name} stores an instant (with a zone) but {value!r} does not "
127
+ f"carry one, so it identifies none: the same 14:30 is a different moment in "
128
+ f"Madrid and in Bogotá. Place it with utc_from_zone(value, 'Europe/Madrid') or "
129
+ f"use SnakeUtc.now(); if it really is a wall-clock time, declare it with "
130
+ f"snake_datetime() and SnakeColumn[datetime]."
131
+ )
132
+ if offset:
133
+ raise SnakeValueError(
134
+ f"{table.name}.{column.name} only accepts UTC and {value!r} comes with offset "
135
+ f"{offset}. The column stores the INSTANT, not the offset: Postgres would give "
136
+ f"it back to you in UTC and SQLite with the original offset, so `.hour` would "
137
+ f"change from engine to engine. Convert it yourself with to_utc(value): the ORM "
138
+ f"does not change zone in silence."
139
+ )
140
+ return
141
+ if column.python_type is not datetime:
142
+ return
143
+ if offset is not None:
144
+ raise SnakeValueError(
145
+ f"{table.name}.{column.name} is declared SnakeColumn[datetime], that is, a WALL-CLOCK "
146
+ f"TIME, and {value!r} carries a zone. Saving it would throw away the tzinfo and with "
147
+ f"it the instant, silently. If you meant to store the instant, declare the column "
148
+ f"SnakeColumn[SnakeUtc]; if it really is a wall-clock time, pass a datetime without "
149
+ f"a zone."
150
+ )
151
+
152
+
153
+ def _guard_int_range(
154
+ table: SnakeTableInfo, column: SnakeColumnInfo, value: object
155
+ ) -> None:
156
+ """Value of an integer against the range of its declared width.
157
+
158
+ It dispatches on the column's DECLARED TYPE, not on the value's: in Python `bool` is a subclass
159
+ of `int`, so looking at the value would measure the width of a boolean column.
160
+ """
161
+ if column.python_type is not int or not isinstance(value, int):
162
+ return
163
+ minimum, maximum = _INT_RANGES[column.int_size]
164
+ if not minimum <= value <= maximum:
165
+ raise SnakeValueError(
166
+ f"{table.name}.{column.name} declares {column.int_size.name} (from {minimum} to {maximum}) "
167
+ f"but an attempt was made to write {value}. Postgres would reject it and SQLite would "
168
+ f"accept it: the ORM rejects it on both so that your model means the same on either."
169
+ )
170
+
171
+
172
+ def _guard_required_values(
173
+ table: SnakeTableInfo, raw_values: Mapping[str, object]
174
+ ) -> None:
175
+ """Shouts if a MANDATORY column arrives carrying the `MISSING` sentinel instead of a value.
176
+
177
+ Leaving a `MISSING` out of the INSERT is the right thing almost always — an autoincrementing PK,
178
+ a column with a default, one the server fills in — and that is why the silence got mistaken for
179
+ the norm. What is separated out here is the case where omitting is exactly the opposite: NOT
180
+ NULL, with no default of any kind and no autoincrement. There the `MISSING` does not mean "let
181
+ the database put it in", it means nobody put it in, and taking it out of the INSERT turns a
182
+ program error into an engine problem.
183
+
184
+ Where a `MISSING` sneaking in as a value comes from: on an engine without `RETURNING`, an
185
+ `add_all()` leaves the PKs unfilled, and that id which never came back ends up being the foreign
186
+ key of the next row. Four steps in silence, and an `INSERT INTO bridge () VALUES ()` at the end
187
+ that points at none of them.
188
+ """
189
+ for column in table.columns:
190
+ if raw_values.get(column.name, None) is not MISSING:
191
+ continue
192
+ if _fills_itself(column):
193
+ continue
194
+ raise SnakeValueError(
195
+ f"'{table.name}.{column.name}' is mandatory (NOT NULL, without a default and without "
196
+ f"autoincrement) and an attempt was made to write it without a value. It usually means "
197
+ f"the value comes from another row whose id never came back: on an engine without "
198
+ f"RETURNING, `add_all()` does not fill in autoincrementing keys, so use `add()` for "
199
+ f"the rows whose id you need afterwards."
200
+ )
201
+
202
+
203
+ def _fills_itself(column: SnakeColumnInfo) -> bool:
204
+ """Whether it is legitimate for the column to arrive with no value: somebody puts it in for it.
205
+
206
+ Four ways for that to be so, and all four count: it accepts NULL, the database generates it, it
207
+ carries a declared default, or the server puts it in. Missing one would turn this guard into a
208
+ false positive over perfectly correct models, which is how a guard dies.
209
+ """
210
+ return (
211
+ column.nullable
212
+ or column.autoincrement
213
+ or column.has_default
214
+ or column.default_factory is not None
215
+ or column.has_server_default
216
+ )
217
+
218
+
219
+ def guard_uniform_bulk_columns(rows: list[dict[str, object]], model_name: str) -> None:
220
+ """Every instance of an `add_all` must carry the SAME columns. Refuses the batch if not.
221
+
222
+ The second half of a requirement stated three lines above the caller: `add_all` demands a single
223
+ MODEL. One model does not mean one shape — a column with a server default stays out of
224
+ `__init__`, so two instances of the same class can legitimately end up with different sets of
225
+ assigned values, and rows with different columns cannot share one multi-row INSERT. Without this
226
+ guard the POSITION of an element in the list decided between a loud error and a silent loss of
227
+ data: an empty first row emitted `DEFAULT VALUES` for EVERY instance, while the same two rows
228
+ the other way round raised. Same batch, opposite outcomes, decided by the order they were
229
+ appended in.
230
+
231
+ Refusing rather than splitting the batch into groups is the same answer the emitter already
232
+ gives, and it is the one this ORM's doctrine asks for: what cannot be done as asked gets said,
233
+ not quietly turned into something else. Grouping would also hand back rows in an order the
234
+ caller did not choose, which `add_all` promises it does not do.
235
+ """
236
+ if len(rows) < 2:
237
+ return
238
+ shapes = {tuple(sorted(row)) for row in rows}
239
+ if len(shapes) == 1:
240
+ return
241
+ spellings = sorted(
242
+ ", ".join(shape) if shape else "(no columns)" for shape in shapes
243
+ )
244
+ raise SnakeEmitError(
245
+ f"Every row of a bulk INSERT must have the same columns, and these {model_name} "
246
+ f"instances do not: {' / '.join(spellings)}. One model does not mean one shape — a column "
247
+ f"with a server default stays out of the constructor, so assigning it on some instances "
248
+ f"and not others splits the batch. Either assign it on all of them or on none, or call "
249
+ f"add() per instance."
250
+ )
251
+
252
+
253
+ def guard_can_set_isolation(dialect: SnakeDialect) -> None:
254
+ """Refuses `SET TRANSACTION ISOLATION LEVEL` on an engine that declares it cannot.
255
+
256
+ It lives here, in one piece called by BOTH colours, and that is the whole point of moving it.
257
+ The synchronous session asked the catalogue and the asynchronous one handed the statement
258
+ straight to the driver, so SQLite answered `near "SET": syntax error` — the exact failure the
259
+ synchronous docstring says the check exists to prevent, reproduced in the other colour because
260
+ the fix had only been applied to one.
261
+
262
+ A session emitting engine SQL without asking is also what the dialect seam exists to stop, so
263
+ the drift was not only about wording: half the ORM was reaching past the seam.
264
+ """
265
+ support = dialect.capabilities.support_for(Cap.SET_ISOLATION)
266
+ if isinstance(support, Nope):
267
+ raise SnakeUnsupportedFeature(
268
+ f"{type(dialect).__name__} cannot set the isolation level: {support.reason}."
269
+ )
@@ -0,0 +1,23 @@
1
+ """Transaction isolation levels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+
8
+ class SnakeIsolation(Enum):
9
+ """What a transaction sees of what the others are doing while it is alive.
10
+
11
+ STANDARD SQL values (not engine jargon), the other half of concurrency control alongside
12
+ `for_update()`: the lock says which rows you reserve, the isolation what you see meanwhile.
13
+
14
+ - `READ_COMMITTED`: each statement sees what was committed at its instant (Postgres default).
15
+ - `REPEATABLE_READ`: a still photo of the whole transaction; a write conflict aborts it.
16
+ - `SERIALIZABLE`: as if they ran single file. The strongest guarantee and the one that aborts most.
17
+ - `READ_UNCOMMITTED`: for standard completeness; Postgres treats it as `READ COMMITTED`.
18
+ """
19
+
20
+ READ_UNCOMMITTED = "READ UNCOMMITTED"
21
+ READ_COMMITTED = "READ COMMITTED"
22
+ REPEATABLE_READ = "REPEATABLE READ"
23
+ SERIALIZABLE = "SERIALIZABLE"