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.
- {sqlakit-0.8.0 → sqlakit-0.9.0}/PKG-INFO +28 -1
- {sqlakit-0.8.0 → sqlakit-0.9.0}/README.md +27 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/pyproject.toml +6 -2
- {sqlakit-0.8.0 → sqlakit-0.9.0}/pyproject.toml.orig +6 -2
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/__init__.py +2 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/_base.py +56 -8
- sqlakit-0.9.0/sqlakit/_cli.py +101 -0
- sqlakit-0.9.0/sqlakit/_debugserver.py +358 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/_recording.py +84 -10
- sqlakit-0.9.0/sqlakit/debugserver.html +93 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/pytest_plugin.py +112 -2
- {sqlakit-0.8.0 → sqlakit-0.9.0}/LICENSE +0 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/_db.py +0 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/_discovery.py +0 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/_model.py +0 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/_query.py +0 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/_registry.py +0 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/_routing.py +0 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/_sql.py +0 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/asyncio/__init__.py +0 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/asyncio/_db.py +0 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/asyncio/_registry.py +0 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/asyncio/orm.py +0 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/asyncio/sql.py +0 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/exceptions.py +0 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/orm.py +0 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/py.typed +0 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/sql.py +0 -0
- {sqlakit-0.8.0 → sqlakit-0.9.0}/sqlakit/testing.py +0 -0
- {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.
|
|
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
|
+

|
|
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.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.
|
|
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. ``
|
|
312
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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())
|