sqlakit 0.18.0__tar.gz → 0.20.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.18.0 → sqlakit-0.20.0}/PKG-INFO +14 -9
  2. {sqlakit-0.18.0 → sqlakit-0.20.0}/README.md +13 -8
  3. {sqlakit-0.18.0 → sqlakit-0.20.0}/pyproject.toml +2 -5
  4. {sqlakit-0.18.0 → sqlakit-0.20.0}/pyproject.toml.orig +2 -5
  5. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/__init__.py +2 -2
  6. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/_base.py +8 -9
  7. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/_query.py +61 -2
  8. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/asyncio/orm.py +57 -0
  9. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/exceptions.py +16 -0
  10. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/orm.py +51 -0
  11. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/pytest_plugin.py +24 -10
  12. sqlakit-0.18.0/sqlakit/_cli.py +0 -101
  13. sqlakit-0.18.0/sqlakit/_debugserver.py +0 -396
  14. sqlakit-0.18.0/sqlakit/debugserver.html +0 -103
  15. {sqlakit-0.18.0 → sqlakit-0.20.0}/LICENSE +0 -0
  16. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/_db.py +0 -0
  17. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/_discovery.py +0 -0
  18. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/_model.py +0 -0
  19. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/_recording.py +0 -0
  20. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/_registry.py +0 -0
  21. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/_routing.py +0 -0
  22. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/_sql.py +0 -0
  23. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/asyncio/__init__.py +0 -0
  24. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/asyncio/_db.py +0 -0
  25. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/asyncio/_registry.py +0 -0
  26. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/asyncio/sql.py +0 -0
  27. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/py.typed +0 -0
  28. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/sql.py +0 -0
  29. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/testing.py +0 -0
  30. {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlakit
3
- Version: 0.18.0
3
+ Version: 0.20.0
4
4
  Summary: A toolkit for SQLAlchemy applications.
5
5
  Keywords: sqlalchemy,database,orm,sql,asyncio
6
6
  Author: Anton Ruhlov
@@ -249,16 +249,20 @@ project has `rich`, the output is colored too.
249
249
 
250
250
  ### The debug server
251
251
 
252
- `sqlakit debugserver` serves a page that fills as the recordings arrive:
252
+ [`sqlakit-debugserver`](https://github.com/sqlakit/sqlakit-debugserver) serves a page that fills as the recordings
253
+ arrive. It is a package of its own:
253
254
 
254
255
  ```console
255
- $ sqlakit debugserver
256
+ $ pip install sqlakit-debugserver
257
+ $ sqlakit-debugserver
256
258
 
257
259
  SQLAKit debug server on http://localhost:5555
258
260
 
259
261
  Send recordings to it:
260
262
 
261
- │ with db.recording("GET /users", debugserver=("localhost", 5555)):
263
+ │ from sqlakit_debugserver import DebugServer
264
+ │
265
+ │ with db.recording("GET /users", send_to=DebugServer("localhost", 5555)):
262
266
  │ list_users()
263
267
  ```
264
268
 
@@ -429,10 +433,11 @@ On shutdown, `dispose()` closes the pool.
429
433
 
430
434
  [Getting started](docs/getting-started.md) builds a database, a model and a
431
435
  test from an empty file. The rest is under [`docs/`](docs/):
432
- [queries](docs/queries.md), [SQL templates](docs/sql.md),
433
- [models](docs/models.md), [testing](docs/testing.md),
434
- [debugging](docs/debugging.md), [multiple databases](docs/routing.md) and
435
- [the reference](docs/reference.md). Complete example apps live in
436
- [`examples/`](examples/), and each one is run by the test suite.
436
+ [compared to SQLAlchemy](docs/comparison.md), [queries](docs/queries.md),
437
+ [SQL templates](docs/sql.md), [models](docs/models.md),
438
+ [testing](docs/testing.md), [debugging](docs/debugging.md),
439
+ [multiple databases](docs/routing.md) and [the reference](docs/reference.md).
440
+ Complete example apps live in [`examples/`](examples/), and each one is run by
441
+ the test suite.
437
442
 
438
443
  What changed in each version is in the [changelog](CHANGELOG.md).
@@ -216,16 +216,20 @@ project has `rich`, the output is colored too.
216
216
 
217
217
  ### The debug server
218
218
 
219
- `sqlakit debugserver` serves a page that fills as the recordings arrive:
219
+ [`sqlakit-debugserver`](https://github.com/sqlakit/sqlakit-debugserver) serves a page that fills as the recordings
220
+ arrive. It is a package of its own:
220
221
 
221
222
  ```console
222
- $ sqlakit debugserver
223
+ $ pip install sqlakit-debugserver
224
+ $ sqlakit-debugserver
223
225
 
224
226
  SQLAKit debug server on http://localhost:5555
225
227
 
226
228
  Send recordings to it:
227
229
 
228
- │ with db.recording("GET /users", debugserver=("localhost", 5555)):
230
+ │ from sqlakit_debugserver import DebugServer
231
+ │
232
+ │ with db.recording("GET /users", send_to=DebugServer("localhost", 5555)):
229
233
  │ list_users()
230
234
  ```
231
235
 
@@ -396,10 +400,11 @@ On shutdown, `dispose()` closes the pool.
396
400
 
397
401
  [Getting started](docs/getting-started.md) builds a database, a model and a
398
402
  test from an empty file. The rest is under [`docs/`](docs/):
399
- [queries](docs/queries.md), [SQL templates](docs/sql.md),
400
- [models](docs/models.md), [testing](docs/testing.md),
401
- [debugging](docs/debugging.md), [multiple databases](docs/routing.md) and
402
- [the reference](docs/reference.md). Complete example apps live in
403
- [`examples/`](examples/), and each one is run by the test suite.
403
+ [compared to SQLAlchemy](docs/comparison.md), [queries](docs/queries.md),
404
+ [SQL templates](docs/sql.md), [models](docs/models.md),
405
+ [testing](docs/testing.md), [debugging](docs/debugging.md),
406
+ [multiple databases](docs/routing.md) and [the reference](docs/reference.md).
407
+ Complete example apps live in [`examples/`](examples/), and each one is run by
408
+ the test suite.
404
409
 
405
410
  What changed in each version is in the [changelog](CHANGELOG.md).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.18.0"
3
+ version = "0.20.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -35,9 +35,6 @@ dependencies = [
35
35
  name = "Anton Ruhlov"
36
36
  email = "antonruhlov@gmail.com"
37
37
 
38
- [project.scripts]
39
- sqlakit = "sqlakit._cli:main"
40
-
41
38
  [project.entry-points.pytest11]
42
39
  sqlakit = "sqlakit.pytest_plugin"
43
40
 
@@ -178,7 +175,7 @@ default_item_type = "cmd"
178
175
 
179
176
  [tool.codespell]
180
177
  ignore-words-list = "froms"
181
- skip = "./.venv,./site,./uv.lock,./.git,./debugserver/node_modules,./debugserver/bun.lock,./sqlakit/debugserver.html"
178
+ skip = "./.venv,./site,./uv.lock,./.git"
182
179
 
183
180
  [dependency-groups]
184
181
  dev = [
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.18.0"
3
+ version = "0.20.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -26,9 +26,6 @@ dependencies = [
26
26
  "typing-extensions>=4.13",
27
27
  ]
28
28
 
29
- [project.scripts]
30
- sqlakit = "sqlakit._cli:main"
31
-
32
29
  [project.entry-points.pytest11]
33
30
  sqlakit = "sqlakit.pytest_plugin"
34
31
 
@@ -211,4 +208,4 @@ default_item_type = "cmd"
211
208
  [tool.codespell]
212
209
  # `froms` is SQLAlchemy's own word for what a SELECT selects from.
213
210
  ignore-words-list = "froms"
214
- skip = "./.venv,./site,./uv.lock,./.git,./debugserver/node_modules,./debugserver/bun.lock,./sqlakit/debugserver.html"
211
+ skip = "./.venv,./site,./uv.lock,./.git"
@@ -1,6 +1,5 @@
1
1
  from ._base import DEFAULT_ENGINE_ARGS, DEFAULT_SESSION_ARGS
2
2
  from ._db import Database, RetryingTransaction, Transaction
3
- from ._debugserver import DebugServer
4
3
  from ._discovery import import_models, import_string
5
4
  from ._query import (
6
5
  CASE_INSENSITIVE_COLLATIONS,
@@ -24,6 +23,7 @@ from .exceptions import (
24
23
  DatabaseNotConfiguredError,
25
24
  DefaultAliasError,
26
25
  DetachedInstanceError,
26
+ DuplicateKeyError,
27
27
  InstanceNotFoundError,
28
28
  InvalidCursorError,
29
29
  InvalidDatabaseConfigError,
@@ -80,9 +80,9 @@ __all__ = [
80
80
  "DatabaseConfig",
81
81
  "DatabaseNotConfiguredError",
82
82
  "Databases",
83
- "DebugServer",
84
83
  "DefaultAliasError",
85
84
  "DetachedInstanceError",
85
+ "DuplicateKeyError",
86
86
  "EngineArgs",
87
87
  "InstanceNotFoundError",
88
88
  "InvalidCursorError",
@@ -24,7 +24,6 @@ import sqlalchemy as sa
24
24
  import sqlalchemy.event
25
25
  from typing_extensions import Unpack
26
26
 
27
- from ._debugserver import DebugServer, send_recording
28
27
  from ._discovery import import_string
29
28
  from ._recording import (
30
29
  KEEP,
@@ -358,7 +357,7 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
358
357
  stacks: bool = False,
359
358
  skip_queries_from: Sequence[str | PathLike[str]] = (),
360
359
  into: Recording | None = None,
361
- debugserver: DebugServer | tuple[str, int] | None = None,
360
+ send_to: Callable[[Recording], object] | None = None,
362
361
  ) -> Iterator[Recording]:
363
362
  """Record the statements of this block, and what they add up to.
364
363
 
@@ -373,8 +372,8 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
373
372
 
374
373
  ``logger`` writes a summary when the block ends, at a level the numbers
375
374
  choose. ``echo`` prints the statements instead, coloured where `rich` is
376
- installed. ``debugserver`` sends the recording to a `sqlakit debugserver`
377
- listening there, and says nothing when none is. ``stacks`` has every
375
+ installed. ``send_to`` is called with the recording, to hand it on:
376
+ `sqlakit_debugserver.DebugServer` is one. ``stacks`` has every
378
377
  statement remember the frames that led to it, at the cost of a stack walk
379
378
  each time. ``skip_queries_from`` names the files whose statements are none of
380
379
  your business: what those run is not recorded at all, which leaves a test's
@@ -403,8 +402,8 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
403
402
  recording.log(logger)
404
403
  if echo:
405
404
  recording.echo()
406
- if debugserver is not None:
407
- send_recording(recording, debugserver)
405
+ if send_to is not None:
406
+ send_to(recording)
408
407
 
409
408
  @contextmanager
410
409
  def assert_queries(
@@ -886,7 +885,7 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
886
885
  stacks: bool = False,
887
886
  skip_queries_from: Sequence[str | PathLike[str]] = (),
888
887
  into: Recording | None = None,
889
- debugserver: DebugServer | tuple[str, int] | None = None,
888
+ send_to: Callable[[Recording], object] | None = None,
890
889
  using: str | DatabaseT | Sequence[str | DatabaseT] | None = None,
891
890
  ) -> Iterator[Recording]:
892
891
  """Record every database this registry has, not the default one alone.
@@ -936,8 +935,8 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
936
935
  together.log(logger)
937
936
  if echo:
938
937
  together.echo()
939
- if debugserver is not None:
940
- send_recording(together, debugserver)
938
+ if send_to is not None:
939
+ send_to(together)
941
940
 
942
941
  @contextmanager
943
942
  def unbound(self) -> Iterator[None]:
@@ -43,6 +43,7 @@ from ._model import resolve_alias, soft_delete_column
43
43
  from .exceptions import (
44
44
  BulkQueryError,
45
45
  ConflictingJoinError,
46
+ DuplicateKeyError,
46
47
  InstanceNotFoundError,
47
48
  InvalidCursorError,
48
49
  InvalidNullsError,
@@ -53,6 +54,7 @@ from .exceptions import (
53
54
  PageItemsMismatchError,
54
55
  RawStatementError,
55
56
  UncomparableOrderingError,
57
+ UnknownFieldError,
56
58
  UnknownOrderFieldError,
57
59
  UnorderedPageError,
58
60
  )
@@ -734,9 +736,13 @@ class BaseQuery(Generic[ModelT]):
734
736
  ```
735
737
 
736
738
  The attribute holds what this statement selects into it, so a count or
737
- a window function arrives on the instance rather than beside it.
739
+ a window function arrives on the instance rather than beside it. An
740
+ instance the session already holds is read again, so the value follows
741
+ the expression given this time rather than the one it was loaded with.
738
742
  """
739
- return self.options(with_expression(key, expression))
743
+ return self.options(with_expression(key, expression)).execution_options(
744
+ populate_existing=True
745
+ )
740
746
 
741
747
  def with_for_update(
742
748
  self,
@@ -1518,6 +1524,59 @@ class _Rows(Protocol[RowT_co]):
1518
1524
  def one_or_none(self) -> RowT_co | None: ...
1519
1525
 
1520
1526
 
1527
+ def key_names(model: type[Any], keys: Sequence[Any]) -> list[str]:
1528
+ """Return the attribute names `in_bulk` keys by, checked against the model.
1529
+
1530
+ With no columns the key is the primary key. A key has to be a column
1531
+ attribute of the model itself: an expression cannot be read off a row, and
1532
+ a column of another table with the same name would read the wrong value.
1533
+
1534
+ Raises:
1535
+ UnknownFieldError: if a key is not a column of the model.
1536
+
1537
+ """
1538
+ mapper = sa.inspect(model, raiseerr=True)
1539
+ if not keys:
1540
+ return [
1541
+ mapper.get_property_by_column(column).key for column in mapper.primary_key
1542
+ ]
1543
+ names = []
1544
+ for key in keys:
1545
+ name = getattr(key, "key", None)
1546
+ owner = getattr(key, "class_", None)
1547
+ if (
1548
+ name is None
1549
+ or owner is None
1550
+ or not issubclass(model, owner)
1551
+ or name not in mapper.column_attrs
1552
+ ):
1553
+ raise UnknownFieldError(model.__name__, str(key))
1554
+ names.append(name)
1555
+ return names
1556
+
1557
+
1558
+ def keyed(
1559
+ rows: Iterable[Any], model: type[Any], names: Sequence[str]
1560
+ ) -> dict[Any, Any]:
1561
+ """Return the rows under the value each has in these attributes, one row per key.
1562
+
1563
+ One name keys by its value, several by the tuple of theirs, in the order
1564
+ the rows arrived.
1565
+
1566
+ Raises:
1567
+ DuplicateKeyError: if two rows share a key.
1568
+
1569
+ """
1570
+ result: dict[Any, Any] = {}
1571
+ for row in rows:
1572
+ values = tuple(getattr(row, name) for name in names)
1573
+ key = values[0] if len(values) == 1 else values
1574
+ if key in result:
1575
+ raise DuplicateKeyError(model.__name__, names, key)
1576
+ result[key] = row
1577
+ return result
1578
+
1579
+
1521
1580
  def one_row(rows: _Rows[RowT], name: str) -> RowT:
1522
1581
  """Return the single row, saying what had none or too many.
1523
1582
 
@@ -31,6 +31,8 @@ from sqlakit._query import (
31
31
  BaseQuery,
32
32
  CursorPage,
33
33
  Page,
34
+ key_names,
35
+ keyed,
34
36
  merged,
35
37
  one_row,
36
38
  one_row_or_none,
@@ -152,6 +154,61 @@ class Query(BaseQuery[ModelT]):
152
154
  result = await self._rows(self._executable())
153
155
  return result.all()
154
156
 
157
+ @overload
158
+ async def in_bulk(self) -> dict[Any, ModelT]: ...
159
+
160
+ @overload
161
+ async def in_bulk(
162
+ self, key: _TypedColumnClauseArgument[C0], /
163
+ ) -> dict[C0, ModelT]: ...
164
+
165
+ @overload
166
+ async def in_bulk(
167
+ self,
168
+ key: _TypedColumnClauseArgument[C0],
169
+ other: _TypedColumnClauseArgument[C1],
170
+ /,
171
+ ) -> dict[tuple[C0, C1], ModelT]: ...
172
+
173
+ @overload
174
+ async def in_bulk(
175
+ self,
176
+ key: _TypedColumnClauseArgument[C0],
177
+ other: _TypedColumnClauseArgument[C1],
178
+ third: _TypedColumnClauseArgument[C2],
179
+ /,
180
+ ) -> dict[tuple[C0, C1, C2], ModelT]: ...
181
+
182
+ @overload
183
+ async def in_bulk(
184
+ self, *keys: _TypedColumnClauseArgument[Any]
185
+ ) -> dict[Any, ModelT]: ...
186
+
187
+ async def in_bulk(
188
+ self, *keys: _TypedColumnClauseArgument[Any]
189
+ ) -> dict[Any, ModelT]:
190
+ """Return every matching row, under the value it has in these columns.
191
+
192
+ ```python
193
+ User.query.where(User.id.in_(ids)).in_bulk() # dict[int, User], by key
194
+ User.query.in_bulk(User.email) # dict[str, User]
195
+ Seat.query.in_bulk(Seat.row, Seat.col) # dict[tuple[int, str], Seat]
196
+ ```
197
+
198
+ One column keys by its value and several by the tuple of theirs, so
199
+ the checker knows the key's type. The rows keep the query's order, and
200
+ a key column the query defers is loaded with the row.
201
+
202
+ Raises:
203
+ DuplicateKeyError: if two rows share a key.
204
+ UnknownFieldError: if a key is not a column of the model.
205
+
206
+ """
207
+ # A key the model defers is read with the row rather than one row at a time.
208
+ names = key_names(self.model, keys)
209
+ rows = await self.undefer(*keys).all()
210
+ return keyed(rows, self.model, names)
211
+
155
212
  async def first(self) -> ModelT | None:
156
213
  """Return the first matching row, or None."""
157
214
  # A raw statement takes no limit; the first row is read off the result.
@@ -479,6 +479,22 @@ class InvalidNullsError(SQLAKitError, ValueError):
479
479
  super().__init__(f"`nulls` is `first` or `last`, not `{nulls!r}`.")
480
480
 
481
481
 
482
+ class DuplicateKeyError(SQLAKitError, ValueError):
483
+ """Raised when `in_bulk` finds two rows under one key."""
484
+
485
+ def __init__(
486
+ self, model: str, key: Iterable[str] = (), value: object = None
487
+ ) -> None:
488
+ super().__init__(
489
+ f"`in_bulk` keys {model} rows by {', '.join(key) or 'the primary key'}, "
490
+ f"and two rows share {value!r}. Key by something unique, or narrow "
491
+ f"the query to one row per key."
492
+ )
493
+ self.model = model
494
+ self.key = tuple(key)
495
+ self.value = value
496
+
497
+
482
498
  class KeyLookupError(SQLAKitError, TypeError):
483
499
  """Raised when a lookup by primary key is asked to honour what it cannot."""
484
500
 
@@ -32,6 +32,8 @@ from ._query import (
32
32
  BaseQuery,
33
33
  CursorPage,
34
34
  Page,
35
+ key_names,
36
+ keyed,
35
37
  merged,
36
38
  one_row,
37
39
  one_row_or_none,
@@ -148,6 +150,55 @@ class Query(BaseQuery[ModelT]):
148
150
  """Return every matching row."""
149
151
  return self._rows(self._executable()).all()
150
152
 
153
+ @overload
154
+ def in_bulk(self) -> dict[Any, ModelT]: ...
155
+
156
+ @overload
157
+ def in_bulk(self, key: _TypedColumnClauseArgument[C0], /) -> dict[C0, ModelT]: ...
158
+
159
+ @overload
160
+ def in_bulk(
161
+ self,
162
+ key: _TypedColumnClauseArgument[C0],
163
+ other: _TypedColumnClauseArgument[C1],
164
+ /,
165
+ ) -> dict[tuple[C0, C1], ModelT]: ...
166
+
167
+ @overload
168
+ def in_bulk(
169
+ self,
170
+ key: _TypedColumnClauseArgument[C0],
171
+ other: _TypedColumnClauseArgument[C1],
172
+ third: _TypedColumnClauseArgument[C2],
173
+ /,
174
+ ) -> dict[tuple[C0, C1, C2], ModelT]: ...
175
+
176
+ @overload
177
+ def in_bulk(self, *keys: _TypedColumnClauseArgument[Any]) -> dict[Any, ModelT]: ...
178
+
179
+ def in_bulk(self, *keys: _TypedColumnClauseArgument[Any]) -> dict[Any, ModelT]:
180
+ """Return every matching row, under the value it has in these columns.
181
+
182
+ ```python
183
+ User.query.where(User.id.in_(ids)).in_bulk() # dict[int, User], by key
184
+ User.query.in_bulk(User.email) # dict[str, User]
185
+ Seat.query.in_bulk(Seat.row, Seat.col) # dict[tuple[int, str], Seat]
186
+ ```
187
+
188
+ One column keys by its value and several by the tuple of theirs, so
189
+ the checker knows the key's type. The rows keep the query's order, and
190
+ a key column the query defers is loaded with the row.
191
+
192
+ Raises:
193
+ DuplicateKeyError: if two rows share a key.
194
+ UnknownFieldError: if a key is not a column of the model.
195
+
196
+ """
197
+ # A key the model defers is read with the row rather than one row at a time.
198
+ names = key_names(self.model, keys)
199
+ rows = self.undefer(*keys).all()
200
+ return keyed(rows, self.model, names)
201
+
151
202
  def first(self) -> ModelT | None:
152
203
  """Return the first matching row, or None."""
153
204
  # A raw statement takes no limit; the first row is read off the result.
@@ -38,6 +38,7 @@ Every other test connects to nothing, and reaching for a session there raises
38
38
  from __future__ import annotations
39
39
 
40
40
  import asyncio
41
+ import importlib.util
41
42
  import inspect
42
43
  import pathlib
43
44
  import time
@@ -48,7 +49,6 @@ from typing import TYPE_CHECKING, Any
48
49
 
49
50
  import pytest
50
51
 
51
- from ._debugserver import as_payload, write_report
52
52
  from ._recording import Recording, check, require_expectation
53
53
  from ._registry import db as importable_db
54
54
  from .exceptions import UnknownDatabaseError
@@ -63,7 +63,7 @@ if TYPE_CHECKING:
63
63
  MARKER = "db"
64
64
  SYNC_FIXTURE = "_sqlakit_transaction"
65
65
  ASYNC_FIXTURE = "_sqlakit_async_transaction"
66
- REPORT = pytest.StashKey[list[dict[str, Any]]]()
66
+ REPORT = pytest.StashKey[list[tuple[Recording, str, list[str]]]]()
67
67
  WHERE = pytest.StashKey["pathlib.Path | None"]()
68
68
  NAMED_BY_THE_CLOCK = ""
69
69
  """What `--sqlakit-report` with no path means."""
@@ -114,9 +114,18 @@ def pytest_terminal_summary(
114
114
  ) -> None:
115
115
  """Write the report at the end, and say where it went."""
116
116
  where = config.stash.get(WHERE, None)
117
- records = config.stash.get(REPORT, [])
118
- if where is None or not records:
117
+ reported = config.stash.get(REPORT, [])
118
+ if where is None or not reported:
119
119
  return
120
+ # Installed apart from `sqlakit`, and only when a report is asked for.
121
+ from sqlakit_debugserver import ( # noqa: PLC0415 # ty: ignore[unresolved-import]
122
+ as_payload,
123
+ write_report,
124
+ )
125
+
126
+ records = [
127
+ as_payload(recording, app=app, tags=tags) for recording, app, tags in reported
128
+ ]
120
129
  tests = len({record["app"] + record["label"] for record in records})
121
130
  queries = sum(record["count"] for record in records)
122
131
  written = write_report(where, records, about=f"{tests} tests · {queries} queries")
@@ -133,9 +142,8 @@ def _report_path(config: pytest.Config) -> pathlib.Path | None:
133
142
  if asked is None:
134
143
  return None
135
144
  if asked == NAMED_BY_THE_CLOCK:
136
- return config.rootpath / time.strftime("sqlakit-%Y%m%d-%H%M%S.html")
137
- where = pathlib.Path(asked)
138
- if where.is_dir():
145
+ where = config.rootpath / time.strftime("sqlakit-%Y%m%d-%H%M%S.html")
146
+ elif (where := pathlib.Path(asked)).is_dir():
139
147
  # `--sqlakit-report tests/unit` reads as the path to write to.
140
148
  message = (
141
149
  f"--sqlakit-report was given `{asked}`, which is a directory. "
@@ -143,6 +151,12 @@ def _report_path(config: pytest.Config) -> pathlib.Path | None:
143
151
  f"for a name with the time in it."
144
152
  )
145
153
  raise pytest.UsageError(message)
154
+ if importlib.util.find_spec("sqlakit_debugserver") is None:
155
+ message = (
156
+ "--sqlakit-report writes the debug server's page, which comes with "
157
+ "it: pip install sqlakit-debugserver"
158
+ )
159
+ raise pytest.UsageError(message)
146
160
  return where
147
161
 
148
162
 
@@ -388,10 +402,10 @@ def _reported(request: pytest.FixtureRequest, db: Any) -> Iterator[None]: # noq
388
402
  )
389
403
  yield
390
404
  request.config.stash[REPORT].append(
391
- as_payload(
405
+ (
392
406
  recording,
393
- app=str(node.path.relative_to(request.config.rootpath)),
394
- tags=[mark.name for mark in node.iter_markers() if mark.name != MARKER],
407
+ str(node.path.relative_to(request.config.rootpath)),
408
+ [mark.name for mark in node.iter_markers() if mark.name != MARKER],
395
409
  )
396
410
  )
397
411
 
@@ -1,101 +0,0 @@
1
- """`sqlakit`, the command line."""
2
-
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import os
7
- import sys
8
-
9
- from ._debugserver import create_server
10
-
11
-
12
- def main(argv: list[str] | None = None) -> int:
13
- """Run a command, and return what the shell should make of it."""
14
- parser = argparse.ArgumentParser(prog="sqlakit", description="SQLAKit.")
15
- commands = parser.add_subparsers(dest="command", required=True)
16
-
17
- debugserver = commands.add_parser(
18
- "debugserver", help="watch the recordings an application sends"
19
- )
20
- debugserver.add_argument("-H", "--host", default="localhost")
21
- debugserver.add_argument("-p", "--port", type=int, default=5555)
22
-
23
- arguments = parser.parse_args(argv)
24
- if arguments.command == "debugserver":
25
- return _debugserver(arguments.host, arguments.port)
26
- return 1
27
-
28
-
29
- def _debugserver(host: str, port: int) -> int:
30
- """Serve the recordings until the terminal says otherwise."""
31
- try:
32
- server = create_server(host, port)
33
- except OSError as error:
34
- _say(f"\n{_paint('cannot listen', BOLD, RED)} on {host}:{port} — {error}\n")
35
- return 1
36
- _greeting(host, port)
37
- try:
38
- server.serve_forever()
39
- except KeyboardInterrupt:
40
- _say(_paint("\nstopped", DIM))
41
- finally:
42
- server.server_close()
43
- return 0
44
-
45
-
46
- BOLD = "1"
47
- DIM = "2"
48
- TEAL = "36"
49
- RED = "31"
50
- GREEN = "32"
51
- VIOLET = "35"
52
-
53
-
54
- def _colours() -> bool:
55
- """Whether to paint: a terminal that wants it, and was not told otherwise."""
56
- if os.environ.get("NO_COLOR"):
57
- return False
58
- if os.environ.get("FORCE_COLOR"):
59
- return True
60
- return sys.stdout.isatty()
61
-
62
-
63
- def _paint(text: str, *codes: str) -> str:
64
- """Return the text in those colours, or as it is where colour is unwanted."""
65
- if not codes or not _colours():
66
- return text
67
- return f"\033[{';'.join(codes)}m{text}\033[0m"
68
-
69
-
70
- def _say(text: str) -> None:
71
- """Print, and flush: the server then blocks, and a pipe would hold this."""
72
- print(text, flush=True) # noqa: T201
73
-
74
-
75
- def _block(*lines: str) -> str:
76
- """Return these lines set off by a rule, the way a page sets off code."""
77
- rule = _paint("│", DIM)
78
- return "".join(f"\n {rule} {line}" for line in lines) + "\n"
79
-
80
-
81
- def _greeting(host: str, port: int) -> None:
82
- """Print where the page is, and the block that fills it."""
83
- where = _paint(f"http://{host}:{port}", TEAL, BOLD)
84
- label = _paint('"GET /users"', GREEN)
85
- named = _paint(f'"{host}"', GREEN)
86
- number = _paint(str(port), VIOLET)
87
- _say(
88
- f"\nSQLAKit debug server on {where}\n\n"
89
- + _paint(
90
- "Send recordings to it:\n",
91
- DIM,
92
- )
93
- + _block(
94
- f"with db.recording({label}, debugserver=({named}, {number})):",
95
- " list_users()",
96
- )
97
- )
98
-
99
-
100
- if __name__ == "__main__":
101
- sys.exit(main())