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.
Files changed (30) hide show
  1. {sqlakit-0.8.1 → sqlakit-0.9.1}/PKG-INFO +28 -1
  2. {sqlakit-0.8.1 → sqlakit-0.9.1}/README.md +27 -0
  3. {sqlakit-0.8.1 → sqlakit-0.9.1}/pyproject.toml +6 -2
  4. {sqlakit-0.8.1 → sqlakit-0.9.1}/pyproject.toml.orig +6 -2
  5. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/__init__.py +2 -0
  6. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/_base.py +122 -27
  7. sqlakit-0.9.1/sqlakit/_cli.py +101 -0
  8. sqlakit-0.9.1/sqlakit/_debugserver.py +358 -0
  9. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/_model.py +9 -7
  10. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/_recording.py +84 -10
  11. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/_registry.py +3 -1
  12. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/asyncio/_registry.py +3 -1
  13. sqlakit-0.9.1/sqlakit/debugserver.html +93 -0
  14. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/exceptions.py +11 -3
  15. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/pytest_plugin.py +106 -3
  16. {sqlakit-0.8.1 → sqlakit-0.9.1}/LICENSE +0 -0
  17. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/_db.py +0 -0
  18. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/_discovery.py +0 -0
  19. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/_query.py +0 -0
  20. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/_routing.py +0 -0
  21. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/_sql.py +0 -0
  22. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/asyncio/__init__.py +0 -0
  23. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/asyncio/_db.py +0 -0
  24. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/asyncio/orm.py +0 -0
  25. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/asyncio/sql.py +0 -0
  26. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/orm.py +0 -0
  27. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/py.typed +0 -0
  28. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/sql.py +0 -0
  29. {sqlakit-0.8.1 → sqlakit-0.9.1}/sqlakit/testing.py +0 -0
  30. {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.8.1
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
+ ![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.1"
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.8.1"
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. ``stacks`` has every statement remember the frames that led to it,
312
- at the cost of a stack walk each time.
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 = getattr(self.engine, "sync_engine", self.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
- stack=caller_stack() if self._stacks.get() else (),
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 this one: what the code reaches without an alias.
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 the alias is `default`, which this registry is.
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
- raise DefaultAliasError
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, *self._aliased.values())
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(db, label, stacks=stacks, into=together)
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 [`configure`][sqlakit.Databases.configure] has been called."""
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.is_configured and self._engine is not None
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 an unconfigured database looks like.
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's own attributes are worth explaining. Anything
965
- # else is a name that does not exist, and saying so is what lets
966
- # `hasattr`, `copy` and every library that introspects work.
967
- if (
968
- not name.startswith("_")
969
- and hasattr(type(self), name)
970
- and not self.is_configured
971
- ):
972
- raise DatabaseNotConfiguredError from None
973
- raise AttributeError(name)
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())