sqlakit 0.1.0__py3-none-any.whl

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/_recording.py ADDED
@@ -0,0 +1,385 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import traceback
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ import sqlalchemy as sa
10
+
11
+ from .exceptions import DEFAULT_ALIAS
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Mapping, Sequence
15
+ from typing import TextIO
16
+
17
+ import sqlparse
18
+
19
+ from .types import QueryStats
20
+ else:
21
+ try:
22
+ import sqlparse
23
+ except ImportError: # pragma: no cover - the extra is installed in CI
24
+ sqlparse = None
25
+
26
+ __all__ = ["Recording", "Statement"]
27
+
28
+ _LIBRARIES = (
29
+ str(Path(__file__).parent),
30
+ str(Path(sa.__file__ or "").parent),
31
+ )
32
+
33
+ _TRUNCATE = 120
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class Statement:
38
+ """One statement the database was asked to run."""
39
+
40
+ sql: str
41
+ parameters: Any
42
+ duration: float
43
+ """How long it took, in seconds."""
44
+
45
+ database: str = DEFAULT_ALIAS
46
+ """Which database ran it, for a recording that covers more than one."""
47
+
48
+ stack: tuple[str, ...] = ()
49
+ """Where it came from, when the recording was asked for stacks."""
50
+
51
+ @property
52
+ def milliseconds(self) -> float:
53
+ """How long it took, in the unit a log line wants."""
54
+ return self.duration * 1000
55
+
56
+ @property
57
+ def pretty(self) -> str:
58
+ """The SQL laid out over several lines, for reading rather than scanning.
59
+
60
+ Laying it out needs `sqlakit[debug]`; without it this is the statement as it
61
+ ran, on one line.
62
+ """
63
+ return _formatted(self.sql)
64
+
65
+ def __rich__(self) -> Any: # noqa: ANN401
66
+ """Hand `rich` the SQL to colour, if the application prints with it.
67
+
68
+ Nothing here imports `rich`; this runs only when `rich` is the one
69
+ doing the printing.
70
+ """
71
+ from rich.syntax import Syntax # noqa: PLC0415
72
+
73
+ return Syntax(self.pretty, "sql", background_color="default", word_wrap=True)
74
+
75
+ def __str__(self) -> str:
76
+ return " ".join(self.sql.split())
77
+
78
+
79
+ @dataclass
80
+ class Recording:
81
+ """The statements of a block, and what they add up to.
82
+
83
+ What `Database.recording()` hands back:
84
+
85
+ ```python
86
+ with db.recording() as record:
87
+ build_report()
88
+
89
+ record.count, record.milliseconds, record.duplicates, record.slowest
90
+ ```
91
+ """
92
+
93
+ label: str | None = None
94
+ statements: list[Statement] = field(default_factory=list)
95
+
96
+ @property
97
+ def count(self) -> int:
98
+ """How many statements ran."""
99
+ return len(self.statements)
100
+
101
+ @property
102
+ def duration(self) -> float:
103
+ """How long they took together, in seconds."""
104
+ return sum(statement.duration for statement in self.statements)
105
+
106
+ @property
107
+ def milliseconds(self) -> float:
108
+ """How long they took together, in the unit a log line wants."""
109
+ return self.duration * 1000
110
+
111
+ @property
112
+ def slowest(self) -> Statement | None:
113
+ """The one that took longest, if anything ran at all."""
114
+ if not self.statements:
115
+ return None
116
+ return max(self.statements, key=lambda one: one.duration)
117
+
118
+ @property
119
+ def databases(self) -> tuple[str, ...]:
120
+ """The databases that ran anything, in the order they first did."""
121
+ return tuple(dict.fromkeys(one.database for one in self.statements))
122
+
123
+ @property
124
+ def duplicates(self) -> Mapping[str, list[Statement]]:
125
+ """The statements that ran more than once, by the SQL they ran.
126
+
127
+ Parameters are not part of the SQL, so the N+1 that fetches one row a
128
+ hundred times is one entry with a hundred statements in it.
129
+ """
130
+ grouped: dict[str, list[Statement]] = {}
131
+ for statement in self.statements:
132
+ grouped.setdefault(f"{statement.database}: {statement}", []).append(
133
+ statement
134
+ )
135
+ return {sql: group for sql, group in grouped.items() if len(group) > 1}
136
+
137
+ def log(
138
+ self,
139
+ logger: logging.Logger,
140
+ *,
141
+ level: int | None = None,
142
+ busy: int = 20,
143
+ slow: float = 500.0,
144
+ repeated: int = 5,
145
+ ) -> None:
146
+ """Write a summary, at a level the numbers choose unless you name one.
147
+
148
+ Left to itself it says INFO for a block that did little, WARNING once
149
+ anything repeats or a statement passes 100ms, and ERROR past ``busy``
150
+ statements, ``slow`` milliseconds or ``repeated`` repeated statements:
151
+ the shape of a log you can watch rather than read.
152
+
153
+ Args:
154
+ logger: Where the summary goes.
155
+ level: A level of your own, which turns the thresholds off.
156
+ busy: Statements past which the block logs at ERROR.
157
+ slow: Milliseconds past which the block logs at ERROR.
158
+ repeated: Repeated statements past which the block logs at ERROR.
159
+
160
+ """
161
+ if level is None:
162
+ level = self._level(busy=busy, slow=slow, repeated=repeated)
163
+ logger.log(level, self.summary(), extra=dict(self.stats()))
164
+
165
+ def echo(self, *, file: TextIO | None = None) -> None:
166
+ """Print the summary and the statements, for a block with no logger.
167
+
168
+ ```python
169
+ with db.recording(echo=True):
170
+ build_report()
171
+ ```
172
+
173
+ Coloured and laid out where `rich` and `sqlakit[debug]` are installed, plain
174
+ where they are not. A service wants `log` instead.
175
+ """
176
+ try:
177
+ from rich.console import Console # noqa: PLC0415
178
+ except ImportError:
179
+ print(self.summary(), file=file)
180
+ print(self.pretty, file=file)
181
+ else:
182
+ console = Console(file=file)
183
+ console.print(self.summary(), markup=False, highlight=False)
184
+ console.print(self)
185
+
186
+ def summary(self) -> str:
187
+ """Return the one line a log gets."""
188
+ head = f"{self.count} queries in {self.milliseconds:.1f}ms"
189
+ if self.label:
190
+ head = f"{self.label}: {head}"
191
+ notes = []
192
+ if self.duplicates:
193
+ repeated = sum(len(group) for group in self.duplicates.values())
194
+ notes.append(f"{repeated} repeated")
195
+ slowest = self.slowest
196
+ if slowest is not None and slowest.milliseconds >= 100: # noqa: PLR2004
197
+ notes.append(f"slowest {slowest.milliseconds:.1f}ms")
198
+ return f"{head} ({', '.join(notes)})" if notes else head
199
+
200
+ def stats(self) -> QueryStats:
201
+ """Return what this adds up to, as the fields a structured log takes."""
202
+ slowest = self.slowest
203
+ return {
204
+ "queries": self.count,
205
+ "milliseconds": round(self.milliseconds, 2),
206
+ "slowest_milliseconds": round(slowest.milliseconds, 2) if slowest else 0.0,
207
+ "duplicated": sum(len(group) for group in self.duplicates.values()),
208
+ "databases": self.databases,
209
+ "label": self.label,
210
+ }
211
+
212
+ def __str__(self) -> str:
213
+ """Return the statements, numbered, with the repeated ones marked."""
214
+ if not self.statements:
215
+ return "no queries"
216
+ numbered = list(enumerate(self.statements, 1))
217
+ repeats = self._repeats()
218
+ several = len(self.databases) > 1
219
+ lines = []
220
+ for index, statement in numbered:
221
+ sql = str(statement)
222
+ if len(sql) > _TRUNCATE:
223
+ sql = f"{sql[:_TRUNCATE]}…"
224
+ where = f"{statement.database} " if several else ""
225
+ lines.append(f" {index:>2} {statement.milliseconds:5.1f}ms {where}{sql}")
226
+ others = repeats.get(id(statement))
227
+ if others:
228
+ where = ", ".join(str(number) for number in others)
229
+ lines.append(
230
+ f" {' ' * 8}↑ same as {where} ({len(others) + 1} times in all)"
231
+ )
232
+ return "\n".join(lines)
233
+
234
+ @property
235
+ def pretty(self) -> str:
236
+ """The statements, numbered, each laid out over several lines.
237
+
238
+ What `print()` shows when the one-line listing has run out of room.
239
+ """
240
+ if not self.statements:
241
+ return "no queries"
242
+ repeats = self._repeats()
243
+ several = len(self.databases) > 1
244
+ lines = []
245
+ for index, statement in enumerate(self.statements, 1):
246
+ where = f" {statement.database}" if several else ""
247
+ lines.append(
248
+ f" {index:>2} {statement.milliseconds:5.1f}ms{where}"
249
+ f"{_repeated(repeats.get(id(statement)))}"
250
+ )
251
+ lines.extend(f" {line}" for line in statement.pretty.splitlines())
252
+ return "\n".join(lines)
253
+
254
+ def __rich__(self) -> Any: # noqa: ANN401
255
+ """Hand `rich` the same listing as `pretty`, for it to colour."""
256
+ from rich.console import Group # noqa: PLC0415
257
+ from rich.padding import Padding # noqa: PLC0415
258
+ from rich.text import Text # noqa: PLC0415
259
+
260
+ if not self.statements:
261
+ return Text("no queries")
262
+ repeats = self._repeats()
263
+ several = len(self.databases) > 1
264
+ parts: list[Any] = []
265
+ for index, statement in enumerate(self.statements, 1):
266
+ where = f" {statement.database}" if several else ""
267
+ said = _repeated(repeats.get(id(statement)))
268
+ parts.append(
269
+ Text(
270
+ f" {index:>2} {statement.milliseconds:5.1f}ms{where}{said}",
271
+ "yellow" if said else "dim",
272
+ )
273
+ )
274
+ parts.append(Padding(statement.__rich__(), (0, 0, 0, 6)))
275
+ return Group(*parts)
276
+
277
+ def _repeats(self) -> dict[int, list[int]]:
278
+ """Return, for each repeated statement, where else the same SQL ran."""
279
+ numbered = list(enumerate(self.statements, 1))
280
+ return {
281
+ id(statement): [
282
+ index
283
+ for index, other in numbered
284
+ if other is not statement and other in group
285
+ ]
286
+ for group in self.duplicates.values()
287
+ for statement in group
288
+ }
289
+
290
+ def _level(self, *, busy: int, slow: float, repeated: int) -> int:
291
+ slowest = self.slowest
292
+ milliseconds = slowest.milliseconds if slowest else 0.0
293
+ duplicated = sum(len(group) for group in self.duplicates.values())
294
+ if self.count > busy or milliseconds >= slow or duplicated > repeated:
295
+ return logging.ERROR
296
+ if self.count > busy // 4 or milliseconds >= 100 or duplicated: # noqa: PLR2004
297
+ return logging.WARNING
298
+ return logging.INFO
299
+
300
+
301
+ def _repeated(others: list[int] | None) -> str:
302
+ """Return what to say about a statement that ran more than once."""
303
+ if not others:
304
+ return ""
305
+ where = ", ".join(str(number) for number in others)
306
+ return f" ↑ same as {where} ({len(others) + 1} times in all)"
307
+
308
+
309
+ def require_expectation(
310
+ count: int | None,
311
+ at_most: int | None,
312
+ duplicates: bool, # noqa: FBT001 (it mirrors the caller's keyword)
313
+ ) -> None:
314
+ """Refuse a block that asserts nothing, before it runs rather than after.
315
+
316
+ Args:
317
+ count: The statements the block is expected to run.
318
+ at_most: A ceiling on them.
319
+ duplicates: Whether a statement may run more than once.
320
+
321
+ Raises:
322
+ TypeError: if none of the three was asked for.
323
+
324
+ """
325
+ if count is None and at_most is None and duplicates:
326
+ message = "assert_queries needs something to assert"
327
+ raise TypeError(message)
328
+
329
+
330
+ def check(
331
+ recording: Recording,
332
+ *,
333
+ count: int | None,
334
+ at_most: int | None,
335
+ duplicates: bool,
336
+ ) -> None:
337
+ """Fail unless the recording is what the block said it would be.
338
+
339
+ Args:
340
+ recording: What the block ran.
341
+ count: The statements it was expected to run.
342
+ at_most: A ceiling on them.
343
+ duplicates: Whether a statement may run more than once.
344
+
345
+ Raises:
346
+ AssertionError: with the reason and the statements behind it.
347
+
348
+ """
349
+ if count is not None and recording.count != count:
350
+ _fail(recording, f"{recording.count} queries, expected {count}")
351
+ if at_most is not None and recording.count > at_most:
352
+ _fail(recording, f"{recording.count} queries, expected at most {at_most}")
353
+ if not duplicates and recording.duplicates:
354
+ repeated = sum(len(group) for group in recording.duplicates.values())
355
+ _fail(recording, f"{repeated} of the queries repeat another")
356
+
357
+
358
+ def _fail(recording: Recording, reason: str) -> None:
359
+ """Raise with the reason, and the statements that led to it under it."""
360
+ message = f"{reason}\n\n{recording}\n"
361
+ raise AssertionError(message)
362
+
363
+
364
+ def _formatted(sql: str) -> str:
365
+ """Return the SQL laid out, or as it is when nothing can lay it out."""
366
+ if sqlparse is None: # pragma: no cover - the extra is installed in CI
367
+ return " ".join(sql.split())
368
+ return sqlparse.format(sql, reindent=True, keyword_case="upper").strip()
369
+
370
+
371
+ def caller_stack(skip: Sequence[str] = ()) -> tuple[str, ...]:
372
+ """Return the frames of your own code that led to a statement.
373
+
374
+ Ours and SQLAlchemy's are left out by directory rather than by name: a
375
+ project of yours may well live in a path that has our name in it.
376
+ """
377
+ skipped = (*_LIBRARIES, *skip)
378
+ frames = []
379
+ for frame in reversed(traceback.extract_stack()[:-1]):
380
+ if frame.filename.startswith(skipped):
381
+ continue
382
+ frames.append(f"{frame.filename}:{frame.lineno} in {frame.name}")
383
+ if len(frames) == 3: # noqa: PLR2004
384
+ break
385
+ return tuple(frames)
sqlakit/_registry.py ADDED
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import ExitStack, contextmanager
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from ._base import _DatabaseRegistryMixin
7
+ from ._db import Database
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Iterator
11
+
12
+ __all__ = ["Databases", "db"]
13
+
14
+
15
+ class Databases(_DatabaseRegistryMixin[Database], Database):
16
+ """The databases an application talks to, and the default one among them.
17
+
18
+ ```python
19
+ from sqlakit import db
20
+
21
+ db.configure(DB_URL)
22
+
23
+ db.session # the default database
24
+ db["replica"].session # another, if one was configured
25
+ ```
26
+
27
+ Use it when one database is enough and passing a handle around is not worth
28
+ it. `Database(url)` is the alternative.
29
+ """
30
+
31
+ _database_class = Database
32
+
33
+ @contextmanager
34
+ def transactions(
35
+ self,
36
+ **arguments: Any, # noqa: ANN401
37
+ ) -> Iterator[None]:
38
+ """Open a transaction on every database, not the default one alone.
39
+
40
+ Where a single database needs `transaction(rollback=True)`, a test harness
41
+ with several needs this:
42
+
43
+ ```python
44
+ with db.transactions(rollback=True):
45
+ yield
46
+ ```
47
+ """
48
+ with ExitStack() as stack:
49
+ for alias in self.aliases:
50
+ stack.enter_context(self[alias].transaction(**arguments))
51
+ yield
52
+
53
+ def dispose(self, *, close: bool = True) -> None:
54
+ """Dispose of every configured database, not just the default one."""
55
+ if self.is_configured:
56
+ super().dispose(close=close)
57
+ for db in self._aliased.values():
58
+ db.dispose(close=close)
59
+
60
+
61
+ db = Databases()
62
+ """The importable registry: one [`Databases`][sqlakit.Databases] for the process.
63
+
64
+ `db.configure(url)` fills it, and every module then reaches the same connections
65
+ by importing it. Reconfiguring is allowed until something connects, after which
66
+ it raises
67
+ [`DatabaseAlreadyConfiguredError`][sqlakit.DatabaseAlreadyConfiguredError]
68
+ and `dispose()` has to come first.
69
+ """
sqlakit/_routing.py ADDED
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
4
+
5
+ if TYPE_CHECKING:
6
+ from collections.abc import Callable
7
+
8
+ __all__ = ["Router", "as_router"]
9
+
10
+
11
+ @runtime_checkable
12
+ class Router(Protocol):
13
+ """A policy that says which database a model lives on.
14
+
15
+ Inherit it for the state a policy needs, or hand `route()` a plain function.
16
+ Both are asked the same question:
17
+
18
+ ```python
19
+ db.route(Sharding(SHARDS))
20
+ db.route(lambda model: SHARDS.get(model))
21
+ ```
22
+
23
+ The contract, either way:
24
+
25
+ - It takes the model class, and nothing else. Not an instance, not the
26
+ statement, not whether it reads or writes: placement is a property of the
27
+ model.
28
+ - It returns the alias the model lives on, or None to leave the question to
29
+ the next router, and then to the model's own ``__db__``.
30
+ - The alias has to be one the registry was configured with.
31
+ - It is asked every time a model resolves its database, so keep it cheap and
32
+ keep the answer the same for the same model.
33
+ """
34
+
35
+ def db_for(self, model: type[Any], /) -> str | None:
36
+ """Return the alias this model lives on, or None to say nothing."""
37
+ ...
38
+
39
+
40
+ def as_router(router: Router | Callable[[type[Any]], str | None]) -> Any: # noqa: ANN401
41
+ """Return what to ask, whichever of the two shapes came in."""
42
+ question = getattr(router, "db_for", None)
43
+ return router if question is None else question