sqlakit 0.12.0__tar.gz → 0.13.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 (30) hide show
  1. {sqlakit-0.12.0 → sqlakit-0.13.0}/PKG-INFO +2 -2
  2. {sqlakit-0.12.0 → sqlakit-0.13.0}/pyproject.toml +4 -4
  3. {sqlakit-0.12.0 → sqlakit-0.13.0}/pyproject.toml.orig +4 -4
  4. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/__init__.py +2 -0
  5. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/_sql.py +63 -15
  6. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/asyncio/sql.py +19 -4
  7. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/pytest_plugin.py +12 -1
  8. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/sql.py +17 -3
  9. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/types.py +17 -0
  10. {sqlakit-0.12.0 → sqlakit-0.13.0}/LICENSE +0 -0
  11. {sqlakit-0.12.0 → sqlakit-0.13.0}/README.md +0 -0
  12. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/_base.py +0 -0
  13. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/_cli.py +0 -0
  14. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/_db.py +0 -0
  15. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/_debugserver.py +0 -0
  16. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/_discovery.py +0 -0
  17. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/_model.py +0 -0
  18. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/_query.py +0 -0
  19. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/_recording.py +0 -0
  20. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/_registry.py +0 -0
  21. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/_routing.py +0 -0
  22. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/asyncio/__init__.py +0 -0
  23. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/asyncio/_db.py +0 -0
  24. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/asyncio/_registry.py +0 -0
  25. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/asyncio/orm.py +0 -0
  26. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/debugserver.html +0 -0
  27. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/exceptions.py +0 -0
  28. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/orm.py +0 -0
  29. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/py.typed +0 -0
  30. {sqlakit-0.12.0 → sqlakit-0.13.0}/sqlakit/testing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlakit
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: A toolkit for SQLAlchemy applications.
5
5
  Keywords: sqlalchemy,database,orm,sql,asyncio
6
6
  Author: Anton Ruhlov
@@ -22,7 +22,7 @@ Requires-Dist: sqlalchemy>=2.0.22
22
22
  Requires-Dist: typing-extensions>=4.13
23
23
  Requires-Dist: sqlalchemy[asyncio]>=2.0.22 ; extra == 'asyncio'
24
24
  Requires-Dist: sqlparse>=0.6.0 ; extra == 'debug'
25
- Requires-Dist: jinja2sql>=0.11.0 ; extra == 'sql'
25
+ Requires-Dist: jinja2sql>=0.12.0 ; extra == 'sql'
26
26
  Requires-Python: >=3.11
27
27
  Project-URL: Repository, https://github.com/antonrh/sqlakit
28
28
  Project-URL: Documentation, https://github.com/antonrh/sqlakit
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.12.0"
3
+ version = "0.13.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -47,7 +47,7 @@ Documentation = "https://github.com/antonrh/sqlakit"
47
47
 
48
48
  [project.optional-dependencies]
49
49
  asyncio = ["sqlalchemy[asyncio]>=2.0.22"]
50
- sql = ["jinja2sql>=0.11.0"]
50
+ sql = ["jinja2sql>=0.12.0"]
51
51
  debug = ["sqlparse>=0.6.0"]
52
52
 
53
53
  [build-system]
@@ -183,7 +183,7 @@ skip = "./.venv,./site,./uv.lock,./.git,./debugserver/node_modules,./debugserver
183
183
  [dependency-groups]
184
184
  dev = [
185
185
  "pytest>=9.1.1",
186
- "ty>=0.0.79",
186
+ "ty>=0.0.80",
187
187
  "ruff>=0.16.6",
188
188
  "poethepoet>=0.48.0",
189
189
  "anyio>=4.14.2",
@@ -194,7 +194,7 @@ dev = [
194
194
  "httpx>=0.28.1",
195
195
  "httpx2>=2.10.0",
196
196
  "sqlmodel>=0.0.39",
197
- "jinja2sql>=0.11.0",
197
+ "jinja2sql>=0.12.0",
198
198
  "pydantic>=2.13.4",
199
199
  "sqlparse>=0.6.0",
200
200
  "rich>=15.0.0",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.12.0"
3
+ version = "0.13.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -41,7 +41,7 @@ asyncio = [
41
41
  "sqlalchemy[asyncio]>=2.0.22",
42
42
  ]
43
43
  sql = [
44
- "jinja2sql>=0.11.0",
44
+ "jinja2sql>=0.12.0",
45
45
  ]
46
46
  debug = [
47
47
  "sqlparse>=0.6.0",
@@ -61,7 +61,7 @@ default-groups = ["dev", "docs"]
61
61
  [dependency-groups]
62
62
  dev = [
63
63
  "pytest>=9.1.1",
64
- "ty>=0.0.79",
64
+ "ty>=0.0.80",
65
65
  "ruff>=0.16.6",
66
66
  "poethepoet>=0.48.0",
67
67
  "anyio>=4.14.2",
@@ -72,7 +72,7 @@ dev = [
72
72
  "httpx>=0.28.1",
73
73
  "httpx2>=2.10.0",
74
74
  "sqlmodel>=0.0.39",
75
- "jinja2sql>=0.11.0",
75
+ "jinja2sql>=0.12.0",
76
76
  "pydantic>=2.13.4",
77
77
  "sqlparse>=0.6.0",
78
78
  "rich>=15.0.0",
@@ -60,6 +60,7 @@ from .types import (
60
60
  SessionArgs,
61
61
  TemplatesLike,
62
62
  UrlParts,
63
+ ValidationArgs,
63
64
  )
64
65
 
65
66
  __all__ = [
@@ -122,6 +123,7 @@ __all__ = [
122
123
  "UnknownOrderFieldError",
123
124
  "UnorderedPageError",
124
125
  "UrlParts",
126
+ "ValidationArgs",
125
127
  "db",
126
128
  "import_models",
127
129
  "import_string",
@@ -42,7 +42,13 @@ else:
42
42
  except ImportError: # pragma: no cover - pydantic is installed in CI
43
43
  BaseModel = TypeAdapter = None
44
44
 
45
- __all__ = ["BaseSQLQuery", "Templates", "require_pydantic", "templates_of"]
45
+ __all__ = [
46
+ "BaseSQLQuery",
47
+ "Filter",
48
+ "Templates",
49
+ "require_pydantic",
50
+ "templates_of",
51
+ ]
46
52
 
47
53
  _preparer: ContextVar[Any] = ContextVar("sqlakit.identifier_preparer")
48
54
  """The preparer of the database a template is rendering for."""
@@ -57,6 +63,38 @@ PathLike = str | Path
57
63
  """Where templates are looked for: one directory, or several."""
58
64
 
59
65
 
66
+ class Filter:
67
+ """A template filter, registered the way jinja2sql registers one.
68
+
69
+ ```python
70
+ Templates("app/sql", filters={"in_span": Filter(in_span, bind=True)})
71
+ ```
72
+
73
+ ``bind=True`` calls the filter with a jinja2sql `Binder` as its first
74
+ argument, so a filter writing SQL of its own binds the values through it:
75
+
76
+ ```python
77
+ def in_span(binder, span):
78
+ start, end = span
79
+ return binder.raw(
80
+ f"BETWEEN {binder.bind('span', start)} AND {binder.bind('span', end)}"
81
+ )
82
+ ```
83
+
84
+ A plain function needs none of this and goes in as it is: whatever it
85
+ returns is bound as one more value of the statement.
86
+ """
87
+
88
+ __slots__ = ("bind", "func")
89
+
90
+ def __init__(self, func: Callable[..., Any], *, bind: bool = False) -> None:
91
+ self.func = func
92
+ self.bind = bind
93
+
94
+ def __repr__(self) -> str:
95
+ return f"{type(self).__name__}({self.func!r}, bind={self.bind})"
96
+
97
+
60
98
  class Templates:
61
99
  """The directory a database's SQL templates live in, and how they render.
62
100
 
@@ -70,6 +108,10 @@ class Templates:
70
108
  development server wants and a production one does not. ``filters`` and
71
109
  ``globals`` are handed to the Jinja environment, and are refused if they have
72
110
  to be awaited: rendering makes a string, in both APIs.
111
+
112
+ A filter is a plain function, whose return value is bound as one more value
113
+ of the statement. `Filter(func, bind=True)` registers one that writes SQL of
114
+ its own instead, and is handed a binder for the values inside it.
73
115
  """
74
116
 
75
117
  def __init__(
@@ -77,7 +119,7 @@ class Templates:
77
119
  path: PathLike | Sequence[PathLike] = (),
78
120
  *,
79
121
  auto_reload: bool = False,
80
- filters: Mapping[str, Callable[..., Any]] | None = None,
122
+ filters: Mapping[str, Callable[..., Any] | Filter] | None = None,
81
123
  globals: Mapping[str, Any] | None = None, # noqa: A002
82
124
  ) -> None:
83
125
  self.paths = (
@@ -87,7 +129,8 @@ class Templates:
87
129
  self.filters = dict(filters or {})
88
130
  self.globals = dict(globals or {})
89
131
  for name, value in (*self.filters.items(), *self.globals.items()):
90
- if iscoroutinefunction(value):
132
+ called = value.func if isinstance(value, Filter) else value
133
+ if iscoroutinefunction(called):
91
134
  raise AsyncFilterError(name)
92
135
 
93
136
  def __repr__(self) -> str:
@@ -109,17 +152,15 @@ class Templates:
109
152
  autoescape=True,
110
153
  )
111
154
  environment.globals.update(self.globals)
112
- # Always named parameters: what comes back is handed to `text()`,
113
- # which reads `:name` and nothing else. The driver's own style is
114
- # SQLAlchemy's business, and a template that picked one would be wrong
115
- # on the next database.
155
+ # Named parameters: `text()` reads `:name` and nothing else.
116
156
  renderer = jinja2sql(environment, param_style=_placeholder)
117
- # Ours rather than jinja2sql's: the preparer of the database in hand
118
- # knows both how it quotes and when it has to, which is the difference
119
- # between `"name"` and `name` on Oracle.
157
+ # Ours quotes through the dialect's preparer, jinja2sql's through one char.
120
158
  renderer.register_filter("identifier", _identifier)
121
159
  for name, filter_ in self.filters.items():
122
- renderer.register_filter(name, filter_)
160
+ if isinstance(filter_, Filter):
161
+ renderer.register_filter(name, filter_.func, bind=filter_.bind)
162
+ else:
163
+ renderer.register_filter(name, filter_)
123
164
  return renderer
124
165
 
125
166
  def render(
@@ -152,8 +193,7 @@ class Templates:
152
193
  raise TemplateNotFoundError(source, self.paths) from None
153
194
  finally:
154
195
  _preparer.reset(token)
155
- # Always a mapping: the parameters are named, and only a positional
156
- # style would hand back a sequence.
196
+ # Named parameters come back as a mapping, positional ones as a sequence.
157
197
  return sql, cast("Mapping[str, Any]", params)
158
198
 
159
199
  def check(self) -> None:
@@ -188,6 +228,7 @@ class BaseSQLQuery(Generic[RowT, DatabaseT]):
188
228
  inline: bool = False,
189
229
  type_: type[Any] | None = None,
190
230
  scalar: bool = False,
231
+ validation: Mapping[str, Any] | None = None,
191
232
  ) -> None:
192
233
  self.db = db
193
234
  self.source = source
@@ -195,6 +236,7 @@ class BaseSQLQuery(Generic[RowT, DatabaseT]):
195
236
  self.inline = inline
196
237
  self.type = type_
197
238
  self.scalar = scalar
239
+ self.validation = dict(validation or {})
198
240
 
199
241
  def __repr__(self) -> str:
200
242
  return f"{type(self).__name__}({self.source!r})"
@@ -237,6 +279,7 @@ class BaseSQLQuery(Generic[RowT, DatabaseT]):
237
279
  "inline": self.inline,
238
280
  "type_": self.type,
239
281
  "scalar": self.scalar,
282
+ "validation": self.validation,
240
283
  **changes,
241
284
  }
242
285
  return query(self.db, self.source, self.context, **arguments)
@@ -245,12 +288,17 @@ class BaseSQLQuery(Generic[RowT, DatabaseT]):
245
288
  if self.type is None:
246
289
  return rows
247
290
  adapter = _adapter(self.type)
248
- return [adapter.validate_python(_as_python(row, self.type)) for row in rows]
291
+ return [
292
+ adapter.validate_python(_as_python(row, self.type), **self.validation)
293
+ for row in rows
294
+ ]
249
295
 
250
296
  def _shaped_one(self, row: Any) -> Any: # noqa: ANN401
251
297
  if self.type is None or row is None:
252
298
  return row
253
- return _adapter(self.type).validate_python(_as_python(row, self.type))
299
+ return _adapter(self.type).validate_python(
300
+ _as_python(row, self.type), **self.validation
301
+ )
254
302
 
255
303
  def _executable(self, *, size: int | None = None) -> Executable:
256
304
  if size is None:
@@ -3,9 +3,11 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING, Any, TypeVar, cast
4
4
 
5
5
  import sqlalchemy as sa
6
+ from typing_extensions import Unpack
6
7
 
7
8
  from sqlakit._sql import (
8
9
  BaseSQLQuery,
10
+ Filter,
9
11
  Templates,
10
12
  require_pydantic,
11
13
  templates_of,
@@ -18,9 +20,11 @@ if TYPE_CHECKING:
18
20
  from sqlalchemy.ext.asyncio import AsyncConnection
19
21
  from sqlalchemy.sql import Executable
20
22
 
23
+ from sqlakit.types import ValidationArgs
24
+
21
25
  from ._db import Database
22
26
 
23
- __all__ = ["SQL", "SQLQuery", "SQLRows", "Templates"]
27
+ __all__ = ["SQL", "Filter", "SQLQuery", "SQLRows", "Templates"]
24
28
 
25
29
  RowT = TypeVar("RowT")
26
30
  OtherT = TypeVar("OtherT")
@@ -189,11 +193,13 @@ class SQLQuery(SQLRows[sa.Row[Any]]):
189
193
  same way and carry no further say, so each is asked once.
190
194
  """
191
195
 
192
- def typed(self, type_: type[OtherT], /) -> SQLRows[OtherT]:
196
+ def typed(
197
+ self, type_: type[OtherT], /, **validation: Unpack[ValidationArgs]
198
+ ) -> SQLRows[OtherT]:
193
199
  """Read the rows as this type, one row at a time.
194
200
 
195
201
  ```python
196
- await db.sql("reports/by_team.sql", since=since).typed(TeamReport).all()
202
+ db.sql("reports/by_team.sql", since=since).typed(TeamReport).all()
197
203
  ```
198
204
 
199
205
  The type says what one row becomes, and the terminal decides the container.
@@ -202,12 +208,21 @@ class SQLQuery(SQLRows[sa.Row[Any]]):
202
208
  from columns is given the whole row, and anything else is given the
203
209
  first column, so `SELECT count(*)` with `typed(int)` reads as an `int`.
204
210
 
211
+ Keyword arguments go to pydantic's `validate_python`, `context` and
212
+ `strict` among them:
213
+
214
+ ```python
215
+ db.sql("reports/by_team.sql").typed(TeamReport, context={"tz": tz}).all()
216
+ ```
217
+
205
218
  Raises:
206
219
  MissingDependencyError: if pydantic is not installed.
207
220
 
208
221
  """
209
222
  require_pydantic()
210
- return cast("SQLRows[OtherT]", self._as(SQLRows, type_=type_))
223
+ return cast(
224
+ "SQLRows[OtherT]", self._as(SQLRows, type_=type_, validation=validation)
225
+ )
211
226
 
212
227
  def scalars(self) -> SQLRows[Any]:
213
228
  """Read the first column of each row instead of whole rows.
@@ -286,6 +286,12 @@ def _sqlakit_transaction(
286
286
  ) -> Iterator[None]:
287
287
  with ExitStack() as stack:
288
288
  for block in _rolled_back(sqlakit_db, _asked_for(request)):
289
+ if hasattr(block, "__aenter__"):
290
+ pytest.fail(
291
+ f"`{request.node.name}` runs on an async database, whose "
292
+ "blocks are awaited. Write the test as `async def`.",
293
+ pytrace=False,
294
+ )
289
295
  stack.enter_context(block)
290
296
  if _hidden(request):
291
297
  stack.enter_context(sqlakit_db.unbound())
@@ -301,7 +307,12 @@ async def _sqlakit_async_transaction(
301
307
  ) -> AsyncIterator[None]:
302
308
  async with AsyncExitStack() as stack:
303
309
  for block in _rolled_back(sqlakit_db, _asked_for(request)):
304
- await stack.enter_async_context(block)
310
+ # A synchronous database under a test that awaits something else,
311
+ # a handler it runs in a worker thread among them.
312
+ if hasattr(block, "__aenter__"):
313
+ await stack.enter_async_context(block)
314
+ else:
315
+ stack.enter_context(block)
305
316
  if _hidden(request):
306
317
  stack.enter_context(sqlakit_db.unbound())
307
318
  with _reported(request, sqlakit_db):
@@ -3,9 +3,11 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING, Any, TypeVar, cast
4
4
 
5
5
  import sqlalchemy as sa
6
+ from typing_extensions import Unpack
6
7
 
7
8
  from ._sql import (
8
9
  BaseSQLQuery,
10
+ Filter,
9
11
  Templates,
10
12
  require_pydantic,
11
13
  templates_of,
@@ -18,8 +20,9 @@ if TYPE_CHECKING:
18
20
  from sqlalchemy.sql import Executable
19
21
 
20
22
  from ._db import Database
23
+ from .types import ValidationArgs
21
24
 
22
- __all__ = ["SQL", "SQLQuery", "SQLRows", "Templates"]
25
+ __all__ = ["SQL", "Filter", "SQLQuery", "SQLRows", "Templates"]
23
26
 
24
27
  RowT = TypeVar("RowT")
25
28
  OtherT = TypeVar("OtherT")
@@ -178,7 +181,9 @@ class SQLQuery(SQLRows[sa.Row[Any]]):
178
181
  same way and carry no further say, so each is asked once.
179
182
  """
180
183
 
181
- def typed(self, type_: type[OtherT], /) -> SQLRows[OtherT]:
184
+ def typed(
185
+ self, type_: type[OtherT], /, **validation: Unpack[ValidationArgs]
186
+ ) -> SQLRows[OtherT]:
182
187
  """Read the rows as this type, one row at a time.
183
188
 
184
189
  ```python
@@ -191,12 +196,21 @@ class SQLQuery(SQLRows[sa.Row[Any]]):
191
196
  from columns is given the whole row, and anything else is given the
192
197
  first column, so `SELECT count(*)` with `typed(int)` reads as an `int`.
193
198
 
199
+ Keyword arguments go to pydantic's `validate_python`, `context` and
200
+ `strict` among them:
201
+
202
+ ```python
203
+ db.sql("reports/by_team.sql").typed(TeamReport, context={"tz": tz}).all()
204
+ ```
205
+
194
206
  Raises:
195
207
  MissingDependencyError: if pydantic is not installed.
196
208
 
197
209
  """
198
210
  require_pydantic()
199
- return cast("SQLRows[OtherT]", self._as(SQLRows, type_=type_))
211
+ return cast(
212
+ "SQLRows[OtherT]", self._as(SQLRows, type_=type_, validation=validation)
213
+ )
200
214
 
201
215
  def scalars(self) -> SQLRows[Any]:
202
216
  """Read the first column of each row instead of whole rows.
@@ -21,6 +21,7 @@ __all__ = [
21
21
  "SessionArgs",
22
22
  "TemplatesLike",
23
23
  "UrlParts",
24
+ "ValidationArgs",
24
25
  ]
25
26
 
26
27
  # Quoted, so importing this module never reaches `Templates` and the
@@ -66,6 +67,22 @@ class EngineArgs(TypedDict, total=False, extra_items=Any):
66
67
  use_insertmanyvalues: bool
67
68
 
68
69
 
70
+ class ValidationArgs(TypedDict, total=False, extra_items=Any):
71
+ """Keyword arguments accepted by [`pydantic.TypeAdapter.validate_python`](https://docs.pydantic.dev/latest/api/type_adapter/#pydantic.type_adapter.TypeAdapter.validate_python).
72
+
73
+ `typed()` hands them to pydantic for every row it reads. A keyword a later
74
+ pydantic adds is allowed too.
75
+ """
76
+
77
+ strict: bool | None
78
+ extra: Literal["allow", "ignore", "forbid"] | None
79
+ from_attributes: bool | None
80
+ context: Any
81
+ experimental_allow_partial: bool | Literal["off", "on", "trailing-strings"]
82
+ by_alias: bool | None
83
+ by_name: bool | None
84
+
85
+
69
86
  class SessionArgs(TypedDict, total=False, extra_items=Any):
70
87
  """Keyword arguments accepted by `sqlalchemy.orm.sessionmaker`.
71
88
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes