sqlakit 0.8.0__tar.gz → 0.9.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.8.0 → sqlakit-0.9.0}/PKG-INFO +28 -1
  2. {sqlakit-0.8.0 → sqlakit-0.9.0}/README.md +27 -0
  3. {sqlakit-0.8.0 → sqlakit-0.9.0}/pyproject.toml +6 -2
  4. {sqlakit-0.8.0 → sqlakit-0.9.0}/pyproject.toml.orig +6 -2
  5. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/__init__.py +2 -0
  6. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/_base.py +56 -8
  7. sqlakit-0.9.0/sqlakit/_cli.py +101 -0
  8. sqlakit-0.9.0/sqlakit/_debugserver.py +358 -0
  9. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/_recording.py +84 -10
  10. sqlakit-0.9.0/sqlakit/debugserver.html +93 -0
  11. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/pytest_plugin.py +112 -2
  12. {sqlakit-0.8.0 → sqlakit-0.9.0}/LICENSE +0 -0
  13. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/_db.py +0 -0
  14. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/_discovery.py +0 -0
  15. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/_model.py +0 -0
  16. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/_query.py +0 -0
  17. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/_registry.py +0 -0
  18. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/_routing.py +0 -0
  19. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/_sql.py +0 -0
  20. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/asyncio/__init__.py +0 -0
  21. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/asyncio/_db.py +0 -0
  22. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/asyncio/_registry.py +0 -0
  23. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/asyncio/orm.py +0 -0
  24. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/asyncio/sql.py +0 -0
  25. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/exceptions.py +0 -0
  26. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/orm.py +0 -0
  27. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/py.typed +0 -0
  28. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/sql.py +0 -0
  29. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/testing.py +0 -0
  30. {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlakit
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: A toolkit for SQLAlchemy applications.
5
5
  Keywords: sqlalchemy,database,orm,sql,asyncio
6
6
  Author: Anton Ruhlov
@@ -246,6 +246,33 @@ You can spot the N+1 right away: one query for the users and two identical
246
246
  ones for the teams. Formatting needs the `sqlakit[debug]` extra, and if the
247
247
  project has `rich`, the output is colored too.
248
248
 
249
+ ### The debug server
250
+
251
+ `sqlakit debugserver` serves a page that fills as the recordings arrive:
252
+
253
+ ```console
254
+ $ sqlakit debugserver
255
+
256
+ SQLAKit debug server on http://localhost:5555
257
+
258
+ Send recordings to it:
259
+
260
+ │ with db.recording("GET /users", debugserver=("localhost", 5555)):
261
+ │ list_users()
262
+ ```
263
+
264
+ ![The SQLAKit debug server](docs/assets/debugserver.png)
265
+
266
+ The recordings are listed on the left, the one you pick opens on the right:
267
+ the SQL highlighted, the parameters ready to paste, the repeats counted, and
268
+ the line of your code behind every statement. Search by `table:`, `kind:`,
269
+ `ms:>50` or `repeated:>0`, and one server watches as many applications as you
270
+ point at it.
271
+
272
+ `pytest --sqlakit-report` writes the same page for a test run, as a file that
273
+ opens without a server: the test is the label, and each statement carries the
274
+ line of the test that ran it.
275
+
249
276
  ## The registry
250
277
 
251
278
  To avoid passing a `Database` from module to module, configure the registry
@@ -214,6 +214,33 @@ You can spot the N+1 right away: one query for the users and two identical
214
214
  ones for the teams. Formatting needs the `sqlakit[debug]` extra, and if the
215
215
  project has `rich`, the output is colored too.
216
216
 
217
+ ### The debug server
218
+
219
+ `sqlakit debugserver` serves a page that fills as the recordings arrive:
220
+
221
+ ```console
222
+ $ sqlakit debugserver
223
+
224
+ SQLAKit debug server on http://localhost:5555
225
+
226
+ Send recordings to it:
227
+
228
+ │ with db.recording("GET /users", debugserver=("localhost", 5555)):
229
+ │ list_users()
230
+ ```
231
+
232
+ ![The SQLAKit debug server](docs/assets/debugserver.png)
233
+
234
+ The recordings are listed on the left, the one you pick opens on the right:
235
+ the SQL highlighted, the parameters ready to paste, the repeats counted, and
236
+ the line of your code behind every statement. Search by `table:`, `kind:`,
237
+ `ms:>50` or `repeated:>0`, and one server watches as many applications as you
238
+ point at it.
239
+
240
+ `pytest --sqlakit-report` writes the same page for a test run, as a file that
241
+ opens without a server: the test is the label, and each statement carries the
242
+ line of the test that ran it.
243
+
217
244
  ## The registry
218
245
 
219
246
  To avoid passing a `Database` from module to module, configure the registry
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.8.0"
3
+ version = "0.9.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -32,6 +32,9 @@ dependencies = ["sqlalchemy>=2.0.22"]
32
32
  name = "Anton Ruhlov"
33
33
  email = "antonruhlov@gmail.com"
34
34
 
35
+ [project.scripts]
36
+ sqlakit = "sqlakit._cli:main"
37
+
35
38
  [project.entry-points.pytest11]
36
39
  sqlakit = "sqlakit.pytest_plugin"
37
40
 
@@ -117,6 +120,7 @@ ignore = [
117
120
  "PLW0108",
118
121
  "DTZ005",
119
122
  "S106",
123
+ "S310",
120
124
  ]
121
125
  "tools/**/*.py" = [
122
126
  "INP001",
@@ -171,7 +175,7 @@ default_item_type = "cmd"
171
175
 
172
176
  [tool.codespell]
173
177
  ignore-words-list = "froms"
174
- skip = "./.venv,./site,./uv.lock,./.git"
178
+ skip = "./.venv,./site,./uv.lock,./.git,./debugserver/node_modules,./debugserver/bun.lock,./sqlakit/debugserver.html"
175
179
 
176
180
  [dependency-groups]
177
181
  dev = [
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.8.0"
3
+ version = "0.9.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -25,6 +25,9 @@ dependencies = [
25
25
  "sqlalchemy>=2.0.22",
26
26
  ]
27
27
 
28
+ [project.scripts]
29
+ sqlakit = "sqlakit._cli:main"
30
+
28
31
  [project.entry-points.pytest11]
29
32
  sqlakit = "sqlakit.pytest_plugin"
30
33
 
@@ -150,6 +153,7 @@ ignore = [
150
153
  "PLW0108", # a lambda in a parameter table is deliberate
151
154
  "DTZ005", # naive datetimes are fine as fixtures
152
155
  "S106", # the passwords here are fake, and the point of the test
156
+ "S310", # the URLs here are the tests' own server
153
157
  ]
154
158
  "tools/**/*.py" = [
155
159
  "INP001", # a script, not a package
@@ -206,4 +210,4 @@ default_item_type = "cmd"
206
210
  [tool.codespell]
207
211
  # `froms` is SQLAlchemy's own word for what a SELECT selects from.
208
212
  ignore-words-list = "froms"
209
- skip = "./.venv,./site,./uv.lock,./.git"
213
+ skip = "./.venv,./site,./uv.lock,./.git,./debugserver/node_modules,./debugserver/bun.lock,./sqlakit/debugserver.html"
@@ -1,5 +1,6 @@
1
1
  from ._base import DEFAULT_ENGINE_ARGS, DEFAULT_SESSION_ARGS
2
2
  from ._db import Database, RetryingTransaction, Transaction
3
+ from ._debugserver import DebugServer
3
4
  from ._discovery import import_models, import_string
4
5
  from ._query import (
5
6
  CASE_INSENSITIVE_COLLATIONS,
@@ -77,6 +78,7 @@ __all__ = [
77
78
  "DatabaseConfig",
78
79
  "DatabaseNotConfiguredError",
79
80
  "Databases",
81
+ "DebugServer",
80
82
  "DefaultAliasError",
81
83
  "DetachedInstanceError",
82
84
  "EngineArgs",
@@ -24,13 +24,17 @@ 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
27
28
  from ._discovery import import_string
28
29
  from ._recording import (
30
+ KEEP,
31
+ WIDE,
29
32
  Recording,
30
33
  Statement,
31
34
  caller_stack,
32
35
  check,
33
36
  require_expectation,
37
+ resolved,
34
38
  )
35
39
  from ._routing import Router, as_router
36
40
  from .exceptions import (
@@ -51,6 +55,7 @@ from .exceptions import (
51
55
  if TYPE_CHECKING:
52
56
  import logging
53
57
  from collections.abc import Iterator, Sequence
58
+ from os import PathLike
54
59
 
55
60
  from sqlalchemy.engine import Engine
56
61
 
@@ -96,6 +101,12 @@ RetryOn = (
96
101
 
97
102
  _random = random.SystemRandom()
98
103
 
104
+
105
+ def _elsewhere(frames: tuple[str, ...], skipped: tuple[str, ...]) -> bool:
106
+ """Whether one of the files a recording leaves out ran this statement."""
107
+ return bool(skipped) and bool(frames) and frames[0].startswith(skipped)
108
+
109
+
99
110
  ConnectionT = TypeVar("ConnectionT")
100
111
  SessionT = TypeVar("SessionT")
101
112
 
@@ -241,7 +252,11 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
241
252
  self._stacks: ContextVar[bool] = ContextVar(
242
253
  f"{type(self).__name__}.stacks", default=False
243
254
  )
255
+ self._skipped: ContextVar[tuple[str, ...]] = ContextVar(
256
+ f"{type(self).__name__}.skipped", default=()
257
+ )
244
258
  self._listening = 0
259
+ self._listened: Any = None
245
260
  self._listening_lock = threading.Lock()
246
261
  self._name = DEFAULT_ALIAS
247
262
 
@@ -286,14 +301,16 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
286
301
  return scope.session
287
302
 
288
303
  @contextmanager
289
- def recording(
304
+ def recording( # noqa: PLR0913 - what a recording may report to, one each
290
305
  self,
291
306
  label: str | None = None,
292
307
  *,
293
308
  logger: logging.Logger | None = None,
294
309
  echo: bool = False,
295
310
  stacks: bool = False,
311
+ skip_queries_from: Sequence[str | PathLike[str]] = (),
296
312
  into: Recording | None = None,
313
+ debugserver: DebugServer | tuple[str, int] | None = None,
297
314
  ) -> Iterator[Recording]:
298
315
  """Record the statements of this block, and what they add up to.
299
316
 
@@ -308,8 +325,13 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
308
325
 
309
326
  ``logger`` writes a summary when the block ends, at a level the numbers
310
327
  choose. ``echo`` prints the statements instead, coloured where `rich` is
311
- installed. ``stacks`` has every statement remember the frames that led to it,
312
- at the cost of a stack walk each time.
328
+ installed. ``debugserver`` sends the recording to a `sqlakit debugserver`
329
+ listening there, and says nothing when none is. ``stacks`` has every
330
+ statement remember the frames that led to it, at the cost of a stack walk
331
+ each time. ``skip_queries_from`` names the files whose statements are none of
332
+ your business: what those run is not recorded at all, which leaves a test's
333
+ report showing the code under test rather than the rows a factory of the tests
334
+ wrote to set the scene.
313
335
 
314
336
  Blocks nest, each recording what runs inside it, and the listeners come off
315
337
  after. `with` is right on either side, awaited or not: it listens, it does
@@ -319,9 +341,13 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
319
341
  self._listen()
320
342
  recordings = self._recordings.set((*self._recordings.get(), recording))
321
343
  asked = self._stacks.set(stacks or self._stacks.get())
344
+ skipped = self._skipped.set(
345
+ (*self._skipped.get(), *resolved(skip_queries_from))
346
+ )
322
347
  try:
323
348
  yield recording
324
349
  finally:
350
+ self._skipped.reset(skipped)
325
351
  self._stacks.reset(asked)
326
352
  self._recordings.reset(recordings)
327
353
  self._silence()
@@ -329,6 +355,8 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
329
355
  recording.log(logger)
330
356
  if echo:
331
357
  recording.echo()
358
+ if debugserver is not None:
359
+ send_recording(recording, debugserver)
332
360
 
333
361
  @contextmanager
334
362
  def assert_queries(
@@ -377,13 +405,16 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
377
405
  engine = getattr(self.engine, "sync_engine", self.engine)
378
406
  sa.event.listen(engine, "before_cursor_execute", self._statement_began)
379
407
  sa.event.listen(engine, "after_cursor_execute", self._statement_ended)
408
+ # Held, rather than looked up again: a block that disposes the
409
+ # database gets a new engine, and the listeners are on the old.
410
+ self._listened = engine
380
411
  self._listening += 1
381
412
 
382
413
  def _silence(self) -> None:
383
414
  with self._listening_lock:
384
415
  self._listening -= 1
385
- if self._listening == 0:
386
- engine = getattr(self.engine, "sync_engine", self.engine)
416
+ if self._listening == 0 and self._listened is not None:
417
+ engine, self._listened = self._listened, None
387
418
  sa.event.remove(engine, "before_cursor_execute", self._statement_began)
388
419
  sa.event.remove(engine, "after_cursor_execute", self._statement_ended)
389
420
 
@@ -407,12 +438,19 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
407
438
  recordings = self._recordings.get()
408
439
  if not recordings or statement.split(None, 1)[0].upper() in _CONTROL:
409
440
  return
441
+ stacks = self._stacks.get()
442
+ skipped = self._skipped.get()
443
+ frames = caller_stack(keep=WIDE) if stacks or skipped else ()
444
+ if _elsewhere(frames, skipped):
445
+ # A row a factory of the tests wrote, not what the block is about.
446
+ return
410
447
  record = Statement(
411
448
  sql=statement,
412
449
  parameters=parameters,
413
450
  duration=time.perf_counter() - started,
414
451
  database=self._name,
415
- stack=caller_stack() if self._stacks.get() else (),
452
+ dialect=connection.dialect.name,
453
+ stack=frames[:KEEP] if stacks else (),
416
454
  )
417
455
  for recording in recordings:
418
456
  recording.statements.append(record)
@@ -721,14 +759,16 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
721
759
  self._aliased[alias] = self._named(alias, db)
722
760
 
723
761
  @contextmanager
724
- def recording(
762
+ def recording( # noqa: PLR0913 - what a recording may report to, one each
725
763
  self,
726
764
  label: str | None = None,
727
765
  *,
728
766
  logger: logging.Logger | None = None,
729
767
  echo: bool = False,
730
768
  stacks: bool = False,
769
+ skip_queries_from: Sequence[str | PathLike[str]] = (),
731
770
  into: Recording | None = None,
771
+ debugserver: DebugServer | tuple[str, int] | None = None,
732
772
  ) -> Iterator[Recording]:
733
773
  """Record every database this registry has, not the default one alone.
734
774
 
@@ -747,7 +787,13 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
747
787
  with ExitStack() as stack:
748
788
  for db in databases:
749
789
  stack.enter_context(
750
- BaseDatabase.recording(db, label, stacks=stacks, into=together)
790
+ BaseDatabase.recording(
791
+ db,
792
+ label,
793
+ stacks=stacks,
794
+ skip_queries_from=skip_queries_from,
795
+ into=together,
796
+ )
751
797
  )
752
798
  try:
753
799
  yield together
@@ -756,6 +802,8 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
756
802
  together.log(logger)
757
803
  if echo:
758
804
  together.echo()
805
+ if debugserver is not None:
806
+ send_recording(together, debugserver)
759
807
 
760
808
  @staticmethod
761
809
  def _named(alias: str, db: DatabaseT) -> DatabaseT:
@@ -0,0 +1,101 @@
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())