sqlakit 0.8.1__tar.gz → 0.9.1__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.
- {sqlakit-0.8.1 → sqlakit-0.9.1}/PKG-INFO +28 -1
- {sqlakit-0.8.1 → sqlakit-0.9.1}/README.md +27 -0
- {sqlakit-0.8.1 → sqlakit-0.9.1}/pyproject.toml +6 -2
- {sqlakit-0.8.1 → sqlakit-0.9.1}/pyproject.toml.orig +6 -2
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/__init__.py +2 -0
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/_base.py +122 -27
- sqlakit-0.9.1/sqlakit/_cli.py +101 -0
- sqlakit-0.9.1/sqlakit/_debugserver.py +358 -0
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/_model.py +9 -7
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/_recording.py +84 -10
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/_registry.py +3 -1
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/asyncio/_registry.py +3 -1
- sqlakit-0.9.1/sqlakit/debugserver.html +93 -0
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/exceptions.py +11 -3
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/pytest_plugin.py +106 -3
- {sqlakit-0.8.1 → sqlakit-0.9.1}/LICENSE +0 -0
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/_db.py +0 -0
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/_discovery.py +0 -0
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/_query.py +0 -0
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/_routing.py +0 -0
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/_sql.py +0 -0
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/asyncio/__init__.py +0 -0
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/asyncio/_db.py +0 -0
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/asyncio/orm.py +0 -0
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/asyncio/sql.py +0 -0
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/orm.py +0 -0
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/py.typed +0 -0
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/sql.py +0 -0
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/testing.py +0 -0
- {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlakit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.1
|
|
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
|
+

|
|
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
|
+

|
|
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.
|
|
3
|
+
version = "0.9.1"
|
|
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.
|
|
3
|
+
version = "0.9.1"
|
|
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,17 +24,22 @@ 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 (
|
|
37
41
|
DEFAULT_ALIAS,
|
|
42
|
+
REGISTERED_DEFAULT,
|
|
38
43
|
AliasInUseError,
|
|
39
44
|
ConflictingDatabaseUrlError,
|
|
40
45
|
DatabaseAlreadyConfiguredError,
|
|
@@ -51,6 +56,7 @@ from .exceptions import (
|
|
|
51
56
|
if TYPE_CHECKING:
|
|
52
57
|
import logging
|
|
53
58
|
from collections.abc import Iterator, Sequence
|
|
59
|
+
from os import PathLike
|
|
54
60
|
|
|
55
61
|
from sqlalchemy.engine import Engine
|
|
56
62
|
|
|
@@ -96,6 +102,12 @@ RetryOn = (
|
|
|
96
102
|
|
|
97
103
|
_random = random.SystemRandom()
|
|
98
104
|
|
|
105
|
+
|
|
106
|
+
def _elsewhere(frames: tuple[str, ...], skipped: tuple[str, ...]) -> bool:
|
|
107
|
+
"""Whether one of the files a recording leaves out ran this statement."""
|
|
108
|
+
return bool(skipped) and bool(frames) and frames[0].startswith(skipped)
|
|
109
|
+
|
|
110
|
+
|
|
99
111
|
ConnectionT = TypeVar("ConnectionT")
|
|
100
112
|
SessionT = TypeVar("SessionT")
|
|
101
113
|
|
|
@@ -241,7 +253,11 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
241
253
|
self._stacks: ContextVar[bool] = ContextVar(
|
|
242
254
|
f"{type(self).__name__}.stacks", default=False
|
|
243
255
|
)
|
|
256
|
+
self._skipped: ContextVar[tuple[str, ...]] = ContextVar(
|
|
257
|
+
f"{type(self).__name__}.skipped", default=()
|
|
258
|
+
)
|
|
244
259
|
self._listening = 0
|
|
260
|
+
self._listened: Any = None
|
|
245
261
|
self._listening_lock = threading.Lock()
|
|
246
262
|
self._name = DEFAULT_ALIAS
|
|
247
263
|
|
|
@@ -286,14 +302,16 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
286
302
|
return scope.session
|
|
287
303
|
|
|
288
304
|
@contextmanager
|
|
289
|
-
def recording(
|
|
305
|
+
def recording( # noqa: PLR0913 - what a recording may report to, one each
|
|
290
306
|
self,
|
|
291
307
|
label: str | None = None,
|
|
292
308
|
*,
|
|
293
309
|
logger: logging.Logger | None = None,
|
|
294
310
|
echo: bool = False,
|
|
295
311
|
stacks: bool = False,
|
|
312
|
+
skip_queries_from: Sequence[str | PathLike[str]] = (),
|
|
296
313
|
into: Recording | None = None,
|
|
314
|
+
debugserver: DebugServer | tuple[str, int] | None = None,
|
|
297
315
|
) -> Iterator[Recording]:
|
|
298
316
|
"""Record the statements of this block, and what they add up to.
|
|
299
317
|
|
|
@@ -308,8 +326,13 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
308
326
|
|
|
309
327
|
``logger`` writes a summary when the block ends, at a level the numbers
|
|
310
328
|
choose. ``echo`` prints the statements instead, coloured where `rich` is
|
|
311
|
-
installed. ``
|
|
312
|
-
|
|
329
|
+
installed. ``debugserver`` sends the recording to a `sqlakit debugserver`
|
|
330
|
+
listening there, and says nothing when none is. ``stacks`` has every
|
|
331
|
+
statement remember the frames that led to it, at the cost of a stack walk
|
|
332
|
+
each time. ``skip_queries_from`` names the files whose statements are none of
|
|
333
|
+
your business: what those run is not recorded at all, which leaves a test's
|
|
334
|
+
report showing the code under test rather than the rows a factory of the tests
|
|
335
|
+
wrote to set the scene.
|
|
313
336
|
|
|
314
337
|
Blocks nest, each recording what runs inside it, and the listeners come off
|
|
315
338
|
after. `with` is right on either side, awaited or not: it listens, it does
|
|
@@ -319,9 +342,13 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
319
342
|
self._listen()
|
|
320
343
|
recordings = self._recordings.set((*self._recordings.get(), recording))
|
|
321
344
|
asked = self._stacks.set(stacks or self._stacks.get())
|
|
345
|
+
skipped = self._skipped.set(
|
|
346
|
+
(*self._skipped.get(), *resolved(skip_queries_from))
|
|
347
|
+
)
|
|
322
348
|
try:
|
|
323
349
|
yield recording
|
|
324
350
|
finally:
|
|
351
|
+
self._skipped.reset(skipped)
|
|
325
352
|
self._stacks.reset(asked)
|
|
326
353
|
self._recordings.reset(recordings)
|
|
327
354
|
self._silence()
|
|
@@ -329,6 +356,8 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
329
356
|
recording.log(logger)
|
|
330
357
|
if echo:
|
|
331
358
|
recording.echo()
|
|
359
|
+
if debugserver is not None:
|
|
360
|
+
send_recording(recording, debugserver)
|
|
332
361
|
|
|
333
362
|
@contextmanager
|
|
334
363
|
def assert_queries(
|
|
@@ -377,13 +406,16 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
377
406
|
engine = getattr(self.engine, "sync_engine", self.engine)
|
|
378
407
|
sa.event.listen(engine, "before_cursor_execute", self._statement_began)
|
|
379
408
|
sa.event.listen(engine, "after_cursor_execute", self._statement_ended)
|
|
409
|
+
# Held, rather than looked up again: a block that disposes the
|
|
410
|
+
# database gets a new engine, and the listeners are on the old.
|
|
411
|
+
self._listened = engine
|
|
380
412
|
self._listening += 1
|
|
381
413
|
|
|
382
414
|
def _silence(self) -> None:
|
|
383
415
|
with self._listening_lock:
|
|
384
416
|
self._listening -= 1
|
|
385
|
-
if self._listening == 0:
|
|
386
|
-
engine =
|
|
417
|
+
if self._listening == 0 and self._listened is not None:
|
|
418
|
+
engine, self._listened = self._listened, None
|
|
387
419
|
sa.event.remove(engine, "before_cursor_execute", self._statement_began)
|
|
388
420
|
sa.event.remove(engine, "after_cursor_execute", self._statement_ended)
|
|
389
421
|
|
|
@@ -407,12 +439,19 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
407
439
|
recordings = self._recordings.get()
|
|
408
440
|
if not recordings or statement.split(None, 1)[0].upper() in _CONTROL:
|
|
409
441
|
return
|
|
442
|
+
stacks = self._stacks.get()
|
|
443
|
+
skipped = self._skipped.get()
|
|
444
|
+
frames = caller_stack(keep=WIDE) if stacks or skipped else ()
|
|
445
|
+
if _elsewhere(frames, skipped):
|
|
446
|
+
# A row a factory of the tests wrote, not what the block is about.
|
|
447
|
+
return
|
|
410
448
|
record = Statement(
|
|
411
449
|
sql=statement,
|
|
412
450
|
parameters=parameters,
|
|
413
451
|
duration=time.perf_counter() - started,
|
|
414
452
|
database=self._name,
|
|
415
|
-
|
|
453
|
+
dialect=connection.dialect.name,
|
|
454
|
+
stack=frames[:KEEP] if stacks else (),
|
|
416
455
|
)
|
|
417
456
|
for recording in recordings:
|
|
418
457
|
recording.statements.append(record)
|
|
@@ -665,6 +704,7 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
665
704
|
|
|
666
705
|
def __init__(self) -> None:
|
|
667
706
|
"""Leave everything to [`configure`][sqlakit.Databases.configure]."""
|
|
707
|
+
self._default: DatabaseT | None = None
|
|
668
708
|
self._aliased: dict[str, DatabaseT] = {}
|
|
669
709
|
self._routers: tuple[Any, ...] = ()
|
|
670
710
|
self._using: ContextVar[str | None] = ContextVar(
|
|
@@ -679,14 +719,15 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
679
719
|
def __getitem__(self, alias: str) -> Self | DatabaseT:
|
|
680
720
|
"""Return the database configured as ``alias``.
|
|
681
721
|
|
|
682
|
-
``db["default"]`` is
|
|
722
|
+
``db["default"]`` is what the code reaches without an alias: this
|
|
723
|
+
registry, or the database `register` was given for that name.
|
|
683
724
|
|
|
684
725
|
Raises:
|
|
685
726
|
UnknownDatabaseError: if nothing is configured under that alias.
|
|
686
727
|
|
|
687
728
|
"""
|
|
688
729
|
if alias == DEFAULT_ALIAS:
|
|
689
|
-
return self
|
|
730
|
+
return self if self._default is None else self._default
|
|
690
731
|
try:
|
|
691
732
|
return self._aliased[alias]
|
|
692
733
|
except KeyError:
|
|
@@ -706,29 +747,40 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
706
747
|
db.register("shard-7", Database(SHARD_URL))
|
|
707
748
|
```
|
|
708
749
|
|
|
750
|
+
`default` is the alias the code reaches without naming one, and a
|
|
751
|
+
database registered under it stands where `configure` would have built
|
|
752
|
+
one. The registry itself is then a registry alone: reach that database
|
|
753
|
+
as `db["default"]`, or through the models that live on it.
|
|
754
|
+
|
|
709
755
|
The alias has to be free. Replacing one under a name already in use
|
|
710
756
|
would leave the code that holds the old database talking to it.
|
|
711
757
|
|
|
712
758
|
Raises:
|
|
713
759
|
AliasInUseError: if another database holds that alias.
|
|
714
|
-
DefaultAliasError: if
|
|
760
|
+
DefaultAliasError: if `default` is asked for and the registry
|
|
761
|
+
already has one.
|
|
715
762
|
|
|
716
763
|
"""
|
|
717
764
|
if alias == DEFAULT_ALIAS:
|
|
718
|
-
|
|
765
|
+
if self.is_configured:
|
|
766
|
+
raise DefaultAliasError
|
|
767
|
+
self._default = self._named(alias, db)
|
|
768
|
+
return
|
|
719
769
|
if alias in self._aliased:
|
|
720
770
|
raise AliasInUseError(alias)
|
|
721
771
|
self._aliased[alias] = self._named(alias, db)
|
|
722
772
|
|
|
723
773
|
@contextmanager
|
|
724
|
-
def recording(
|
|
774
|
+
def recording( # noqa: PLR0913 - what a recording may report to, one each
|
|
725
775
|
self,
|
|
726
776
|
label: str | None = None,
|
|
727
777
|
*,
|
|
728
778
|
logger: logging.Logger | None = None,
|
|
729
779
|
echo: bool = False,
|
|
730
780
|
stacks: bool = False,
|
|
781
|
+
skip_queries_from: Sequence[str | PathLike[str]] = (),
|
|
731
782
|
into: Recording | None = None,
|
|
783
|
+
debugserver: DebugServer | tuple[str, int] | None = None,
|
|
732
784
|
) -> Iterator[Recording]:
|
|
733
785
|
"""Record every database this registry has, not the default one alone.
|
|
734
786
|
|
|
@@ -743,11 +795,17 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
743
795
|
that one on its own.
|
|
744
796
|
"""
|
|
745
797
|
together = Recording(label=label) if into is None else into
|
|
746
|
-
databases = (self
|
|
798
|
+
databases = tuple(self[alias] for alias in self.aliases)
|
|
747
799
|
with ExitStack() as stack:
|
|
748
800
|
for db in databases:
|
|
749
801
|
stack.enter_context(
|
|
750
|
-
BaseDatabase.recording(
|
|
802
|
+
BaseDatabase.recording(
|
|
803
|
+
db,
|
|
804
|
+
label,
|
|
805
|
+
stacks=stacks,
|
|
806
|
+
skip_queries_from=skip_queries_from,
|
|
807
|
+
into=together,
|
|
808
|
+
)
|
|
751
809
|
)
|
|
752
810
|
try:
|
|
753
811
|
yield together
|
|
@@ -756,6 +814,8 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
756
814
|
together.log(logger)
|
|
757
815
|
if echo:
|
|
758
816
|
together.echo()
|
|
817
|
+
if debugserver is not None:
|
|
818
|
+
send_recording(together, debugserver)
|
|
759
819
|
|
|
760
820
|
@staticmethod
|
|
761
821
|
def _named(alias: str, db: DatabaseT) -> DatabaseT:
|
|
@@ -840,7 +900,12 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
840
900
|
|
|
841
901
|
@property
|
|
842
902
|
def is_configured(self) -> bool:
|
|
843
|
-
"""Whether
|
|
903
|
+
"""Whether this registry has a default database to reach."""
|
|
904
|
+
return "url" in self.__dict__ or self._default is not None
|
|
905
|
+
|
|
906
|
+
@property
|
|
907
|
+
def _built_its_own(self) -> bool:
|
|
908
|
+
"""Whether the default database is this registry, `configure` having built it."""
|
|
844
909
|
return "url" in self.__dict__
|
|
845
910
|
|
|
846
911
|
@overload
|
|
@@ -915,6 +980,8 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
915
980
|
``InvalidDatabaseConfigError``.
|
|
916
981
|
|
|
917
982
|
"""
|
|
983
|
+
if self._default is not None:
|
|
984
|
+
raise DefaultAliasError
|
|
918
985
|
if not isinstance(url, Mapping):
|
|
919
986
|
self._reject_if_connected()
|
|
920
987
|
super().__init__(url, engine_args, session_args, templates, **parts)
|
|
@@ -949,7 +1016,7 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
949
1016
|
self.route(*routers)
|
|
950
1017
|
|
|
951
1018
|
def _reject_if_connected(self) -> None:
|
|
952
|
-
connected = self.
|
|
1019
|
+
connected = self._built_its_own and self._engine is not None
|
|
953
1020
|
if connected or any(
|
|
954
1021
|
db._engine is not None # noqa: SLF001
|
|
955
1022
|
for db in self._aliased.values()
|
|
@@ -959,19 +1026,47 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
959
1026
|
if not TYPE_CHECKING:
|
|
960
1027
|
# Hidden from type checkers: seeing it, they would take every attribute
|
|
961
1028
|
# to exist and stop reporting typos. It is reached when normal lookup
|
|
962
|
-
# fails, which is what
|
|
1029
|
+
# fails, which is what a registry with no database of its own looks
|
|
1030
|
+
# like, from the outside and from its own methods.
|
|
963
1031
|
def __getattr__(self, name: str) -> object:
|
|
964
|
-
# Only the database
|
|
965
|
-
#
|
|
966
|
-
# `
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1032
|
+
# Only the database half is worth explaining. Anything else is a
|
|
1033
|
+
# name that does not exist, and saying so is what lets `hasattr`,
|
|
1034
|
+
# `copy` and every library that introspects work.
|
|
1035
|
+
state = self.__dict__
|
|
1036
|
+
if "url" in state:
|
|
1037
|
+
raise AttributeError(name)
|
|
1038
|
+
asked_as_a_database = name in DATABASE_STATE or (
|
|
1039
|
+
not name.startswith("_") and hasattr(type(self), name)
|
|
1040
|
+
)
|
|
1041
|
+
if not asked_as_a_database:
|
|
1042
|
+
raise AttributeError(name)
|
|
1043
|
+
if state.get("_default") is not None:
|
|
1044
|
+
raise DatabaseNotConfiguredError(REGISTERED_DEFAULT) from None
|
|
1045
|
+
raise DatabaseNotConfiguredError from None
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
DATABASE_STATE = frozenset(
|
|
1049
|
+
# What `BaseDatabase.__init__` sets. A registry has these once it has a
|
|
1050
|
+
# database of its own, and reaching for one before then is the question
|
|
1051
|
+
# `DatabaseNotConfiguredError` answers, whichever method asked.
|
|
1052
|
+
{
|
|
1053
|
+
"url",
|
|
1054
|
+
"templates",
|
|
1055
|
+
"engine_args",
|
|
1056
|
+
"session_args",
|
|
1057
|
+
"_sessionmaker",
|
|
1058
|
+
"_engine_lock",
|
|
1059
|
+
"_scope",
|
|
1060
|
+
"_outer",
|
|
1061
|
+
"_recordings",
|
|
1062
|
+
"_stacks",
|
|
1063
|
+
"_skipped",
|
|
1064
|
+
"_listening",
|
|
1065
|
+
"_listened",
|
|
1066
|
+
"_listening_lock",
|
|
1067
|
+
"_name",
|
|
1068
|
+
}
|
|
1069
|
+
)
|
|
975
1070
|
|
|
976
1071
|
_CONTROL = frozenset(
|
|
977
1072
|
# Transaction control is not a query, and which of these reach a cursor
|
|
@@ -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())
|