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,176 @@
1
+ """PyMySQLDriver: runs SQL against MySQL/MariaDB with PyMySQL (synchronous). PyMySQL (pure Python) so
2
+ no C toolchain is required; whoever wants `mysqlclient` plugs it in here without touching the dialect
3
+ (same `%s`). A THIN adapter like `PsycopgDriver`: it only runs the compiled `(sql, params)`.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Iterator, Sequence
9
+ from typing import Any, Protocol, cast
10
+
11
+ from snakeorm.drivers.failures import translating
12
+ from snakeorm.drivers.savepoints import quote_savepoint
13
+
14
+ from snakeorm.sql.adapt import adapt_params
15
+
16
+ _QUOTE = "`"
17
+ """The character THIS engine quotes an identifier with.
18
+
19
+ MySQL and MariaDB reject a double-quoted identifier unless `ANSI_QUOTES` is on, so a shared helper
20
+ that always wrote a double quote turned every `SAVEPOINT` into `ERROR 1064` here. The RULE about
21
+ what a valid name is, is shared; the quoting character is the engine's own grammar.
22
+ """
23
+
24
+
25
+ class _Cursor(Protocol):
26
+ """The minimal DBAPI cursor the driver needs."""
27
+
28
+ @property
29
+ def rowcount(self) -> int: ...
30
+ @property
31
+ def lastrowid(self) -> int: ...
32
+ @translating
33
+ # `| None` is how these two libraries spell NO PARAMETERS, and it is not the same as an
34
+ # empty sequence: given `()` they re-read the SQL as a format template, so a literal `%` —
35
+ # every DDL statement the migration runner emits — dies before reaching the server. The
36
+ # Protocol has to admit it or the drivers cannot say it.
37
+ def execute(self, sql: str, params: Sequence[object] | None) -> object: ...
38
+ # `Sequence` and not `list`, because PyMySQL hands back a TUPLE of tuples. Declaring `list`
39
+ # here was a claim about somebody else's library that nothing ever measured, and mypy believed
40
+ # it: `fetch_all` promised a list and returned a tuple on this engine alone.
41
+ def fetchall(self) -> Sequence[tuple[object, ...]]: ...
42
+ def fetchmany(self, size: int) -> Sequence[tuple[object, ...]]: ...
43
+ def close(self) -> None: ...
44
+
45
+
46
+ class _Connection(Protocol):
47
+ """The minimal DBAPI connection the driver needs."""
48
+
49
+ def cursor(self, cursor_class: object = ...) -> _Cursor: ...
50
+ @translating
51
+ def commit(self) -> None: ...
52
+ def rollback(self) -> None: ...
53
+ def close(self) -> None: ...
54
+
55
+
56
+ class PyMySQLDriver:
57
+ """Synchronous driver over PyMySQL. Implements the SnakeDriver Protocol."""
58
+
59
+ def __init__(self, connection: _Connection) -> None:
60
+ self._connection = connection
61
+ self._last_insert_id = 0 # the id generated by the last INSERT (MySQL has no RETURNING; MariaDB does)
62
+
63
+ def server_version(self) -> str:
64
+ """What the server calls itself: `11.8.8-MariaDB-ubu2404`, `8.0.46`.
65
+
66
+ Empty if it does not answer: a flavour read wrong is worse than none.
67
+ """
68
+ try:
69
+ cursor = self._connection.cursor()
70
+ cursor.execute("SELECT VERSION()", ())
71
+ rows = cursor.fetchall()
72
+ return str(rows[0][0]) if rows else ""
73
+ except Exception:
74
+ return ""
75
+
76
+ @classmethod
77
+ def adopt(cls, connection: object) -> PyMySQLDriver:
78
+ """Wraps a raw PyMySQL connection, adapting it to our minimal DBAPI (the edge)."""
79
+ return cls(cast("_Connection", connection))
80
+
81
+ @classmethod
82
+ def connect(cls, **kwargs: Any) -> PyMySQLDriver:
83
+ """Opens a PyMySQL connection with the given kwargs (host, user, password, database, port...).
84
+ MySQL uses arguments, not a one-piece DSN. Lazy import: whoever only generates SQL does not need PyMySQL.
85
+ """
86
+ import pymysql
87
+
88
+ return cls.adopt(pymysql.connect(**kwargs))
89
+
90
+ @translating
91
+ def fetch_all(self, sql: str, params: Sequence[object]) -> list[tuple[object, ...]]:
92
+ """Runs a query and returns all of its rows, closing the cursor."""
93
+ cursor = self._connection.cursor()
94
+ try:
95
+ cursor.execute(
96
+ sql, adapt_params(params, native_arrays=False, percent_formatting=True)
97
+ )
98
+ # `list(...)` and not the cursor's own answer: the Protocol promises a list and PyMySQL
99
+ # gives a tuple. Everything downstream only iterates, so it went unnoticed — until
100
+ # somebody compared the result to one.
101
+ return list(cursor.fetchall())
102
+ finally:
103
+ cursor.close()
104
+
105
+ def fetch_iter(
106
+ self, sql: str, params: Sequence[object], *, chunk: int = 1000
107
+ ) -> Iterator[tuple[object, ...]]:
108
+ """Yields the rows in chunks using PyMySQL's UNBUFFERED cursor (`SSCursor`).
109
+
110
+ `SSCursor` is the MySQL equivalent of Postgres's named cursor: the result stays on the
111
+ server and arrives on demand. With the normal cursor, PyMySQL pulls EVERYTHING to the
112
+ client on execute, and `fetchmany` would only be slicing a list that is already fully in
113
+ memory.
114
+
115
+ Its price, and you have to know it: while the unbuffered cursor is alive, that connection
116
+ cannot fire another query. That is why the cursor is closed once the iteration runs out.
117
+ """
118
+ import pymysql.cursors
119
+
120
+ cursor = self._connection.cursor(pymysql.cursors.SSCursor)
121
+ try:
122
+ cursor.execute(
123
+ sql, adapt_params(params, native_arrays=False, percent_formatting=True)
124
+ )
125
+ while True:
126
+ rows = cursor.fetchmany(chunk)
127
+ if not rows:
128
+ return
129
+ yield from rows
130
+ finally:
131
+ cursor.close()
132
+
133
+ @translating
134
+ def execute(self, sql: str, params: Sequence[object]) -> int:
135
+ """Runs a statement fetching no rows and returns the rowcount.
136
+ It stores `lastrowid` (read BEFORE closing the cursor): on MySQL, which has no RETURNING, that is the only way to recover the generated PK.
137
+ """
138
+ cursor = self._connection.cursor()
139
+ try:
140
+ cursor.execute(
141
+ sql, adapt_params(params, native_arrays=False, percent_formatting=True)
142
+ )
143
+ self._last_insert_id = cursor.lastrowid
144
+ return cursor.rowcount
145
+ finally:
146
+ cursor.close()
147
+
148
+ @property
149
+ def last_insert_id(self) -> int:
150
+ """The autoincrement id of the last INSERT. The session uses it where there is no RETURNING."""
151
+ return self._last_insert_id
152
+
153
+ @translating
154
+ def commit(self) -> None:
155
+ """Commits the transaction in progress."""
156
+ self._connection.commit()
157
+
158
+ def rollback(self) -> None:
159
+ """Rolls back the transaction in progress."""
160
+ self._connection.rollback()
161
+
162
+ def savepoint(self, name: str) -> None:
163
+ """Emits `SAVEPOINT \\`n\\``. MySQL supports savepoints; the name is quoted with backticks."""
164
+ self.execute(f"SAVEPOINT {quote_savepoint(name, quote=_QUOTE)}", ())
165
+
166
+ def release_savepoint(self, name: str) -> None:
167
+ """Emits `RELEASE SAVEPOINT \\`n\\``."""
168
+ self.execute(f"RELEASE SAVEPOINT {quote_savepoint(name, quote=_QUOTE)}", ())
169
+
170
+ def rollback_to_savepoint(self, name: str) -> None:
171
+ """Emits `ROLLBACK TO SAVEPOINT \\`n\\``."""
172
+ self.execute(f"ROLLBACK TO SAVEPOINT {quote_savepoint(name, quote=_QUOTE)}", ())
173
+
174
+ def close(self) -> None:
175
+ """Closes the connection."""
176
+ self._connection.close()
@@ -0,0 +1,46 @@
1
+ """How a savepoint NAME is written, once, for every driver.
2
+
3
+ What is shared is the RULE — what counts as a valid name — and NOT the quoting character. MySQL and
4
+ MariaDB reject a double-quoted identifier unless `ANSI_QUOTES` is on, so `SAVEPOINT "sp1"` comes
5
+ back as `ERROR 1064` — measured, not reasoned about. The quote is engine grammar; the rule is not.
6
+ Each driver answers with its own character and there is no default, for the same reason
7
+ `adapt_params` has none: a new driver has to DECIDE.
8
+
9
+ The names the ORM itself generates come from `SnakeSession.savepoint` (`f"sp{depth}"`), so no
10
+ caller-supplied name reaches here through the public API.
11
+
12
+ It lives with the DRIVERS and not with the dialects, even though it is grammar. A driver has no
13
+ dialect and never has had — `TimeoutDriver` receives one as a call argument for exactly that reason —
14
+ so putting it there would mean handing every driver a dialect to write one pair of quotes.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+
21
+ _PLAIN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
22
+ """A plain ASCII identifier, which is all the ORM ever generates.
23
+
24
+ Stricter than the `isalnum()` two of the four copies used, and deliberately: a name starting with a
25
+ digit and a non-ASCII one both pass that check and neither is something this ORM produces.
26
+ """
27
+
28
+
29
+ def quote_savepoint(name: str, *, quote: str) -> str:
30
+ """The name, quoted for a `SAVEPOINT` statement with THIS engine's character, or a refusal.
31
+
32
+ It REFUSES rather than escaping, and that is the decision. The name is always generated by the
33
+ session, so anything that is not a plain identifier is a bug in whoever called — and escaping a
34
+ bug is how it ends up written into the database instead of reported.
35
+
36
+ `quote` is keyword-only and has no default: a driver has to say which character its engine
37
+ accepts. A default of `"` looked harmless and is what broke MySQL, where an identifier in double
38
+ quotes is a syntax error unless `ANSI_QUOTES` is set.
39
+ """
40
+ if not _PLAIN.match(name):
41
+ raise ValueError(
42
+ f"Invalid savepoint name: {name!r}. The ORM generates these itself, so a name it does "
43
+ f"not recognise means somebody called the driver directly with one of their own. It "
44
+ f"must be a plain ASCII identifier."
45
+ )
46
+ return f"{quote}{name}{quote}"
@@ -0,0 +1,170 @@
1
+ """SQLite driver over the stdlib's `sqlite3`. It mirrors `PsycopgDriver` (same Protocol) except for
2
+ two connector decisions that bite in silence:
3
+
4
+ 1. `PRAGMA foreign_keys = ON`: SQLite ignores FKs by default; emitting FKs that are not enforced is
5
+ exactly the silent failure this project hunts down.
6
+ 2. `isolation_level = None` + a lazy `BEGIN` issued by the driver ITSELF. sqlite3's "magic" handling
7
+ does not wrap DDL (it breaks migrations); with `None` it stays in autocommit and `rollback()`
8
+ undoes nothing. The driver opens the transaction on the first statement and closes it on
9
+ commit/rollback (like psycopg2), so the DDL goes inside the transaction, which is what
10
+ `supports_transactional_ddl=True` promises.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Iterator, Sequence
16
+ from typing import Any
17
+
18
+ from snakeorm.drivers.failures import translating
19
+ from snakeorm.drivers.savepoints import quote_savepoint
20
+
21
+ from snakeorm.core.exceptions import SnakeConfigError
22
+ from snakeorm.sql.adapt import adapt_params
23
+
24
+ _QUOTE = '"'
25
+ """The character THIS engine quotes an identifier with.
26
+
27
+ MySQL and MariaDB reject a double-quoted identifier unless `ANSI_QUOTES` is on, so a shared helper
28
+ that always wrote a double quote turned every `SAVEPOINT` into `ERROR 1064` here. The RULE about
29
+ what a valid name is, is shared; the quoting character is the engine's own grammar.
30
+ """
31
+
32
+
33
+ class SQLiteDriver:
34
+ """A connection to SQLite. A file, or `:memory:` for an ephemeral database (tests).
35
+ `sqlite3` is imported INSIDE `connect` (like psycopg2), so it is not loaded on every `import snakeorm`.
36
+ """
37
+
38
+ def __init__(self, connection: Any) -> None:
39
+ self._connection = connection
40
+ self._in_tx = False # is there a transaction opened by our lazy BEGIN?
41
+
42
+ def _ensure_tx(self) -> None:
43
+ """Opens a transaction if there is none, before the first statement (like psycopg2), so the
44
+ session sees a coherent snapshot and the rollback has something to undo.
45
+ """
46
+ if not self._in_tx:
47
+ self._connection.execute("BEGIN")
48
+ self._in_tx = True
49
+
50
+ @classmethod
51
+ def connect(cls, database: str) -> SQLiteDriver:
52
+ """Opens the database and leaves the connector in a state with no surprises.
53
+
54
+ `database` is a NAME, not a DSN: a path, `:memory:`, or a `file:` URI. A `sqlite:` scheme
55
+ RAISES, because stripping it here would make this a SECOND place translating a DSN, and one
56
+ string would name two databases, silently (bug #38).
57
+
58
+ `uri=True` is passed unconditionally, and that is measured rather than assumed. SQLite reads
59
+ a connection string as a URI only when it begins with `file:`; everything else is a literal
60
+ filename, question marks included (`weird?name.db` is still that file). So there is no flag
61
+ and no heuristic about what the caller meant.
62
+
63
+ WITHOUT IT, `file:cache?mode=memory&cache=shared` — the standard spelling of a shared
64
+ in-memory database — is taken as a FILENAME, and SQLite creates a file called exactly that.
65
+ It does not fail; it opens the wrong database and carries on, and everything downstream
66
+ works because it IS a real database. With it, a MALFORMED `file:` DSN raises instead of
67
+ quietly creating a file named after the mistake.
68
+ """
69
+ import sqlite3
70
+
71
+ # On the SCHEME, not a prefix match: `sqlite_backup.db` is an ordinary filename.
72
+ if database.startswith("sqlite:"):
73
+ raise SnakeConfigError(
74
+ f"SQLiteDriver.connect() takes a database name, not a DSN, and got {database!r}. "
75
+ "A DSN is translated in ONE place: "
76
+ "SnakeConnectionConfig.from_dsn(dsn, SnakeBackend.SQLITE), whose `.name` is what "
77
+ "belongs here. Two places translating is how one string ends up naming two "
78
+ "different databases."
79
+ )
80
+ # `isolation_level=None`: the session drives the transactions, not the connector.
81
+ connection = sqlite3.connect(
82
+ database or ":memory:", isolation_level=None, uri=True
83
+ )
84
+ # Without this, SQLite accepts the FKs and does NOT enforce them. Unacceptable silence.
85
+ connection.execute("PRAGMA foreign_keys = ON")
86
+ return cls(connection)
87
+
88
+ @translating
89
+ def fetch_all(self, sql: str, params: Sequence[object]) -> list[tuple[object, ...]]:
90
+ """Runs the query and returns every row as tuples, closing the cursor."""
91
+ self._ensure_tx()
92
+ cursor = self._connection.execute(
93
+ sql, adapt_params(params, native_arrays=False, percent_formatting=False)
94
+ )
95
+ try:
96
+ return [tuple(row) for row in cursor.fetchall()]
97
+ finally:
98
+ cursor.close()
99
+
100
+ def fetch_iter(
101
+ self, sql: str, params: Sequence[object], *, chunk: int = 1000
102
+ ) -> Iterator[tuple[object, ...]]:
103
+ """Yields the rows in chunks with `fetchmany`.
104
+
105
+ SQLite has no server-side cursors —the database IS the process—, so there is nothing to
106
+ leave over there. What this does avoid is building the whole result list in Python: the
107
+ memory peak becomes the chunk, not the result. Less than on Postgres, but real, and the
108
+ same contract for whoever calls it.
109
+ """
110
+ self._ensure_tx()
111
+ cursor = self._connection.execute(
112
+ sql, adapt_params(params, native_arrays=False, percent_formatting=False)
113
+ )
114
+ try:
115
+ while True:
116
+ rows = cursor.fetchmany(chunk)
117
+ if not rows:
118
+ return
119
+ for row in rows:
120
+ yield tuple(row)
121
+ finally:
122
+ cursor.close()
123
+
124
+ @translating
125
+ def execute(self, sql: str, params: Sequence[object]) -> int:
126
+ """Runs a statement fetching no rows and returns how many it affected."""
127
+ self._ensure_tx()
128
+ cursor = self._connection.execute(
129
+ sql, adapt_params(params, native_arrays=False, percent_formatting=False)
130
+ )
131
+ try:
132
+ return int(cursor.rowcount)
133
+ finally:
134
+ cursor.close()
135
+
136
+ @property
137
+ def last_insert_id(self) -> int:
138
+ """The id of the last INSERT (see the Protocol). Unused: this engine returns the PK through RETURNING."""
139
+ return 0 # SQLite returns the PK through RETURNING; this value is not used
140
+
141
+ @translating
142
+ def commit(self) -> None:
143
+ """Commits the transaction in progress (if any). The next statement opens another one."""
144
+ if self._in_tx:
145
+ self._connection.execute("COMMIT")
146
+ self._in_tx = False
147
+
148
+ def rollback(self) -> None:
149
+ """Rolls back the transaction in progress (if any). A no-op if nothing was open."""
150
+ if self._in_tx:
151
+ self._connection.execute("ROLLBACK")
152
+ self._in_tx = False
153
+
154
+ def savepoint(self, name: str) -> None:
155
+ """Opens a named savepoint. `execute` opens the transaction lazily, so a `SAVEPOINT` as the
156
+ very first operation works too.
157
+ """
158
+ self.execute(f"SAVEPOINT {quote_savepoint(name, quote=_QUOTE)}", ())
159
+
160
+ def release_savepoint(self, name: str) -> None:
161
+ """Commits the savepoint (you can no longer go back to it)."""
162
+ self.execute(f"RELEASE SAVEPOINT {quote_savepoint(name, quote=_QUOTE)}", ())
163
+
164
+ def rollback_to_savepoint(self, name: str) -> None:
165
+ """Goes back to the savepoint, undoing what was done since it was opened."""
166
+ self.execute(f"ROLLBACK TO SAVEPOINT {quote_savepoint(name, quote=_QUOTE)}", ())
167
+
168
+ def close(self) -> None:
169
+ """Closes the connection."""
170
+ self._connection.close()
@@ -0,0 +1,143 @@
1
+ """A SYNCHRONOUS driver served as an `AsyncDriver`, on a thread of its own.
2
+
3
+ All three engines are first class, but only one has a native asyncio library among the project's
4
+ deps (psycopg 3). For the other two, the synchronous driver is served from a thread instead of
5
+ adding a dependency per engine.
6
+
7
+ **It is not faking async.** It is exactly what `aiosqlite` does on the inside, and for MySQL it
8
+ gives REAL concurrency: Python releases the GIL while the socket waits, so two queries from two
9
+ different tasks really do overlap. What it does NOT give is the performance of a native protocol
10
+ under heavy concurrency, because every connection takes up a thread.
11
+
12
+ **One thread per connection, and not a shared pool.** A DBAPI connection is not thread-safe:
13
+ `sqlite3` checks and refuses, and PyMySQL simply corrupts itself if two threads use it at once.
14
+ With a single thread per driver, the calls serialise by construction and the connection always
15
+ sees the same thread. Serialising costs nothing here: a session already waits for each query
16
+ before firing the next one, and whoever wants parallelism opens more connections.
17
+
18
+ Once a native driver exists for an engine, it comes in as its own implementation of the Protocol
19
+ and this adapter stops being used for that one. The seam does not change: both are `AsyncDriver`.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ from itertools import islice
26
+ from collections.abc import AsyncIterator, Callable, Iterator, Sequence
27
+ from concurrent.futures import ThreadPoolExecutor
28
+ from typing import Self, TypeVar
29
+
30
+ from snakeorm.drivers.base import SnakeDriver
31
+
32
+ T = TypeVar("T")
33
+
34
+ _END = object()
35
+ """End-of-iteration sentinel. A `StopIteration` cannot cross a `Future`: asyncio turns it into a
36
+ `RuntimeError` and the message says nothing about what actually happened."""
37
+
38
+
39
+ class ThreadedAsyncDriver:
40
+ """Wraps a `SnakeDriver` and exposes it as an `AsyncDriver`, on a dedicated thread."""
41
+
42
+ def __init__(self, inner: SnakeDriver, *, executor: ThreadPoolExecutor) -> None:
43
+ self._inner = inner
44
+ self._executor = executor
45
+
46
+ @classmethod
47
+ async def open(cls, factory: Callable[[], SnakeDriver]) -> Self:
48
+ """Opens the connection INSIDE the adapter's thread and wraps it.
49
+
50
+ Having the thread that will use it open it is not a precaution: `sqlite3` TIES the
51
+ connection to its creating thread and raises `ProgrammingError` if another one touches it,
52
+ so building it outside and using it inside blew up on the first `close()`. With this, the
53
+ connection is born and dies on the same thread.
54
+
55
+ `max_workers=1` is what makes the wrapper correct, not a performance knob: a DBAPI
56
+ connection is not thread-safe, and a single thread serialises the calls by construction.
57
+ """
58
+ executor = ThreadPoolExecutor(
59
+ max_workers=1, thread_name_prefix="snakeorm-driver"
60
+ )
61
+ try:
62
+ inner = await asyncio.get_running_loop().run_in_executor(executor, factory)
63
+ except BaseException:
64
+ # If the connection never gets opened, the thread stays alive and the process never ends.
65
+ executor.shutdown(wait=False)
66
+ raise
67
+ return cls(inner, executor=executor)
68
+
69
+ async def _run(self, work: Callable[[], T]) -> T:
70
+ """Runs `work` on the driver's thread and waits without blocking the loop."""
71
+ return await asyncio.get_running_loop().run_in_executor(self._executor, work)
72
+
73
+ async def fetch_all(
74
+ self, sql: str, params: Sequence[object]
75
+ ) -> list[tuple[object, ...]]:
76
+ """Runs the query and returns every row as tuples."""
77
+ return await self._run(lambda: self._inner.fetch_all(sql, params))
78
+
79
+ async def fetch_iter(
80
+ self, sql: str, params: Sequence[object], *, chunk: int = 1000
81
+ ) -> AsyncIterator[tuple[object, ...]]:
82
+ """Yields the rows WITHOUT materialising them all, crossing the thread once per CHUNK.
83
+
84
+ It stays LAZY, at the granularity of `chunk` — which is what `chunk` means everywhere else
85
+ in this ORM, the inner driver included (`sqlite.py` uses `fetchmany(chunk)`). That `chunk`
86
+ governs the round trip to the SERVER; this one governs the crossing of the thread.
87
+
88
+ Crossing per ROW instead —a Future, a callback on the loop and waking a thread, for every
89
+ row— measured 103 trips to the executor for 100 rows, where the chunked read costs a
90
+ handful. `islice` is what keeps the laziness and the chunking at the same time, and this is
91
+ the only async path two of the three first-class engines have.
92
+
93
+ What a `break` costs goes from one row to one chunk. It never costs the whole result: the
94
+ synchronous iterator is still only advanced as far as it is read.
95
+ """
96
+ iterator: Iterator[tuple[object, ...]] = await self._run(
97
+ lambda: iter(self._inner.fetch_iter(sql, params, chunk=chunk))
98
+ )
99
+ size = max(1, chunk)
100
+ try:
101
+ while True:
102
+ batch = await self._run(lambda: list(islice(iterator, size)))
103
+ if not batch:
104
+ return
105
+ for row in batch:
106
+ yield row
107
+ finally:
108
+ # The cursor lives as long as the iteration does; closing it here covers the `break` too.
109
+ await self._run(lambda: iterator.close()) # type: ignore[attr-defined]
110
+
111
+ async def execute(self, sql: str, params: Sequence[object]) -> int:
112
+ """Runs a statement fetching no rows; returns how many it affected."""
113
+ return await self._run(lambda: self._inner.execute(sql, params))
114
+
115
+ @property
116
+ def last_insert_id(self) -> int:
117
+ """The autoincrement id of the last INSERT. It does not travel to the database: no thread needed."""
118
+ return self._inner.last_insert_id
119
+
120
+ async def commit(self) -> None:
121
+ """Commits the transaction in progress."""
122
+ await self._run(self._inner.commit)
123
+
124
+ async def rollback(self) -> None:
125
+ """Rolls back the transaction in progress."""
126
+ await self._run(self._inner.rollback)
127
+
128
+ async def savepoint(self, name: str) -> None:
129
+ """Opens a named savepoint."""
130
+ await self._run(lambda: self._inner.savepoint(name))
131
+
132
+ async def release_savepoint(self, name: str) -> None:
133
+ """Commits the savepoint."""
134
+ await self._run(lambda: self._inner.release_savepoint(name))
135
+
136
+ async def rollback_to_savepoint(self, name: str) -> None:
137
+ """Goes back to the savepoint, undoing what was done since it was opened."""
138
+ await self._run(lambda: self._inner.rollback_to_savepoint(name))
139
+
140
+ async def close(self) -> None:
141
+ """Closes the connection AND shuts the thread down. Without the second, the process never ends."""
142
+ await self._run(self._inner.close)
143
+ self._executor.shutdown(wait=True)
@@ -0,0 +1,87 @@
1
+ """A driver that caps how long a statement may take, by wrapping another one."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator, Sequence
6
+
7
+ from snakeorm.core.exceptions import SnakeDialectError
8
+ from snakeorm.dialects.base import SnakeDialect
9
+ from snakeorm.drivers.base import SnakeDriver
10
+
11
+
12
+ class TimeoutDriver:
13
+ """Wraps a `SnakeDriver` and sets a `statement_timeout` for the whole connection: one hung
14
+ query drains the pool. The setting is emitted ONCE on wrapping (it is per CONNECTION, not per
15
+ statement).
16
+ """
17
+
18
+ __slots__ = ("_inner",)
19
+
20
+ def __init__(
21
+ self, inner: SnakeDriver, dialect: SnakeDialect, *, statement_timeout_ms: int
22
+ ) -> None:
23
+ if statement_timeout_ms <= 0:
24
+ # On Postgres `statement_timeout = 0` means NO LIMIT: accepting 0 would silently do the opposite.
25
+ raise ValueError(
26
+ "statement_timeout_ms has to be greater than zero: in Postgres 0 means NO limit, "
27
+ "so asking for 0 would switch the timeout off instead of tightening it."
28
+ )
29
+ # The statement is ASKED of the dialect. It used to be written here as
30
+ # `SET statement_timeout = <ms>`, which is Postgres and only Postgres, under a class name
31
+ # that promises nothing about engines: MySQL answered `1193 Unknown system variable` and
32
+ # SQLite a syntax error, so the knob that keeps one hung query from draining the pool
33
+ # worked on one engine out of three.
34
+ statement = dialect.statement_timeout_sql(statement_timeout_ms)
35
+ if statement is None:
36
+ raise SnakeDialectError(
37
+ f"{type(dialect).__name__} has no server-side statement timeout, so this driver "
38
+ f"cannot cap anything on it. Accepting the wrap would hand back a connection that "
39
+ f"LOOKS capped and is not, which is the opposite of what asking for a timeout "
40
+ f"means. (SQLite's `busy_timeout` waits for a lock; it does nothing about a slow "
41
+ f"query.)"
42
+ )
43
+ self._inner = inner
44
+ self._inner.execute(statement, ())
45
+
46
+ def fetch_all(self, sql: str, params: Sequence[object]) -> list[tuple[object, ...]]:
47
+ """Delegates the query; the limit is already set on the connection."""
48
+ return self._inner.fetch_all(sql, params)
49
+
50
+ def fetch_iter(
51
+ self, sql: str, params: Sequence[object], *, chunk: int = 1000
52
+ ) -> Iterator[tuple[object, ...]]:
53
+ """Delegates the streaming; the limit is already set on the connection."""
54
+ return self._inner.fetch_iter(sql, params, chunk=chunk)
55
+
56
+ def execute(self, sql: str, params: Sequence[object]) -> int:
57
+ """Delegates the statement; the limit is already set on the connection."""
58
+ return self._inner.execute(sql, params)
59
+
60
+ @property
61
+ def last_insert_id(self) -> int:
62
+ """The id of the last INSERT (see the Protocol). Forwarded to the wrapped driver."""
63
+ return self._inner.last_insert_id
64
+
65
+ def commit(self) -> None:
66
+ """Commits the inner driver's transaction."""
67
+ self._inner.commit()
68
+
69
+ def rollback(self) -> None:
70
+ """Rolls back the inner driver's transaction."""
71
+ self._inner.rollback()
72
+
73
+ def savepoint(self, name: str) -> None:
74
+ """Marks a savepoint on the inner driver."""
75
+ self._inner.savepoint(name)
76
+
77
+ def release_savepoint(self, name: str) -> None:
78
+ """Releases a savepoint on the inner driver."""
79
+ self._inner.release_savepoint(name)
80
+
81
+ def rollback_to_savepoint(self, name: str) -> None:
82
+ """Rolls back to a savepoint on the inner driver."""
83
+ self._inner.rollback_to_savepoint(name)
84
+
85
+ def close(self) -> None:
86
+ """Closes the inner driver."""
87
+ self._inner.close()
@@ -0,0 +1,55 @@
1
+ """Typed DTOs: you declare the shape, `snakeorm dto --sync` writes the TypedDicts into your file.
2
+
3
+ if TYPE_CHECKING:
4
+ from blog.models import Author, Post
5
+ from snakeorm.dto import snake_dto
6
+
7
+ snake_dto(Author, fields=[Author.id, Author.username], name="AuthorDto")
8
+ snake_dto(Post, fields=[Post.id, Post.title, Post.author], name="PostCard")
9
+
10
+ The declaration sits inside `if TYPE_CHECKING:` because that buys three things at once: the checker
11
+ validates every path, nothing runs at import, and the module does not drag in the models. The
12
+ generator READS that block with `ast` and never executes the file it rewrites.
13
+
14
+ Four modules, one job each:
15
+
16
+ spec.py what a declaration may say, and the checks both routes share
17
+ read.py what this file says, read from source without importing it
18
+ resolve.py what it MEANS against the compiled graph: types, nesting, write order
19
+ region.py where it is written, and why nothing outside the markers is touched
20
+
21
+ It writes the SHAPE and never the query. A spec that nests `Post.author` describes a row loaded with
22
+ `include(Post.author)`; forget it and the ORM raises `SnakeRelationshipNotLoaded` naming the call.
23
+
24
+ The facade does not re-export any of this — it is a prototype. Import from `snakeorm.dto`.
25
+ See `docs/users/guide/typed-dtos.md`.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from snakeorm.dto.read import (
31
+ specs_in_source as specs_in_source,
32
+ )
33
+ from snakeorm.dto.region import (
34
+ BEGIN as BEGIN,
35
+ END as END,
36
+ SnakeDtoChange as SnakeDtoChange,
37
+ SnakeDtoChangeKind as SnakeDtoChangeKind,
38
+ SnakeDtoSyncResult as SnakeDtoSyncResult,
39
+ sync_file as sync_file,
40
+ sync_source as sync_source,
41
+ )
42
+ from snakeorm.dto.resolve import (
43
+ SnakeDtoField as SnakeDtoField,
44
+ SnakeDtoShape as SnakeDtoShape,
45
+ resolve_all as resolve_all,
46
+ )
47
+ from snakeorm.dto.spec import (
48
+ SnakeDtoEntry as SnakeDtoEntry,
49
+ SnakeDtoPath as SnakeDtoPath,
50
+ SnakeDtoPick as SnakeDtoPick,
51
+ SnakeDtoSelection as SnakeDtoSelection,
52
+ SnakeDtoSpec as SnakeDtoSpec,
53
+ build_spec as build_spec,
54
+ snake_dto as snake_dto,
55
+ )