sqlakit 0.10.0__tar.gz → 0.10.2__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.10.0 → sqlakit-0.10.2}/PKG-INFO +1 -1
- {sqlakit-0.10.0 → sqlakit-0.10.2}/pyproject.toml +1 -1
- {sqlakit-0.10.0 → sqlakit-0.10.2}/pyproject.toml.orig +1 -1
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/_base.py +16 -16
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/_db.py +8 -8
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/_debugserver.py +41 -3
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/_discovery.py +1 -1
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/_model.py +8 -8
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/_query.py +8 -8
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/_recording.py +8 -5
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/_registry.py +1 -1
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/_sql.py +2 -2
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/asyncio/_db.py +8 -8
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/asyncio/_registry.py +2 -2
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/asyncio/orm.py +1 -1
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/asyncio/sql.py +3 -3
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/debugserver.html +42 -42
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/exceptions.py +1 -1
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/orm.py +1 -1
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/pytest_plugin.py +1 -1
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/sql.py +3 -3
- {sqlakit-0.10.0 → sqlakit-0.10.2}/LICENSE +0 -0
- {sqlakit-0.10.0 → sqlakit-0.10.2}/README.md +0 -0
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/__init__.py +0 -0
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/_cli.py +0 -0
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/_routing.py +0 -0
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/asyncio/__init__.py +0 -0
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/py.typed +0 -0
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/testing.py +0 -0
- {sqlakit-0.10.0 → sqlakit-0.10.2}/sqlakit/types.py +0 -0
|
@@ -193,7 +193,7 @@ class _Outer(Generic[ConnectionT]):
|
|
|
193
193
|
|
|
194
194
|
|
|
195
195
|
class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
196
|
-
"""
|
|
196
|
+
"""The sync and async databases share this: everything that is not IO.
|
|
197
197
|
|
|
198
198
|
Binding to the context lives here. Opening and closing connections and
|
|
199
199
|
sessions is left to the subclass.
|
|
@@ -578,9 +578,9 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
578
578
|
) -> Iterator[_Outer[ConnectionT] | None]:
|
|
579
579
|
"""Make ``connection`` the outer one for this context. See `_Outer`.
|
|
580
580
|
|
|
581
|
-
``None`` leaves this context without an outer transaction at all,
|
|
582
|
-
|
|
583
|
-
|
|
581
|
+
``None`` leaves this context without an outer transaction at all, as
|
|
582
|
+
``autocommit()`` needs: blocks under it must not join a transaction
|
|
583
|
+
its own connection is not part of.
|
|
584
584
|
"""
|
|
585
585
|
outer = (
|
|
586
586
|
_Outer(
|
|
@@ -605,9 +605,9 @@ DatabaseT = TypeVar("DatabaseT", bound="BaseDatabase[Any, Any]")
|
|
|
605
605
|
class _Using:
|
|
606
606
|
"""A database standing in for the default one, while a block of it is open.
|
|
607
607
|
|
|
608
|
-
|
|
609
|
-
it adds
|
|
610
|
-
model that lives on the default database resolves here instead.
|
|
608
|
+
`db.using(alias)` returns one of these. Everything a database does, it
|
|
609
|
+
does, and it adds the redirection: for as long as one of its blocks is
|
|
610
|
+
open, a model that lives on the default database resolves here instead.
|
|
611
611
|
"""
|
|
612
612
|
|
|
613
613
|
def __init__(
|
|
@@ -726,7 +726,7 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
726
726
|
def __getitem__(self, alias: str) -> Self | DatabaseT:
|
|
727
727
|
"""Return the database configured as ``alias``.
|
|
728
728
|
|
|
729
|
-
``db["default"]`` is
|
|
729
|
+
``db["default"]`` is the database the code reaches without an alias: this
|
|
730
730
|
registry, or the database `register` was given for that name.
|
|
731
731
|
|
|
732
732
|
Raises:
|
|
@@ -882,7 +882,7 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
882
882
|
"""Say which database a model lives on, for models that do not say it.
|
|
883
883
|
|
|
884
884
|
Each router takes a model and returns an alias, or None to leave the question
|
|
885
|
-
to the next one. A dotted path is imported,
|
|
885
|
+
to the next one. A dotted path is imported, since settings carry paths:
|
|
886
886
|
|
|
887
887
|
```python
|
|
888
888
|
db.route(lambda model: "warehouse" if is_report(model) else None)
|
|
@@ -1058,11 +1058,11 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
1058
1058
|
if not TYPE_CHECKING:
|
|
1059
1059
|
# Hidden from type checkers: seeing it, they would take every attribute
|
|
1060
1060
|
# to exist and stop reporting typos. It is reached when normal lookup
|
|
1061
|
-
# fails,
|
|
1062
|
-
#
|
|
1061
|
+
# fails, as it does on a registry with no database of its own, from
|
|
1062
|
+
# the outside and from its own methods.
|
|
1063
1063
|
def __getattr__(self, name: str) -> object:
|
|
1064
1064
|
# Only the database half is worth explaining. Anything else is a
|
|
1065
|
-
# name that does not exist, and saying so
|
|
1065
|
+
# name that does not exist, and saying so lets `hasattr`,
|
|
1066
1066
|
# `copy` and every library that introspects work.
|
|
1067
1067
|
state = self.__dict__
|
|
1068
1068
|
if "url" in state:
|
|
@@ -1078,9 +1078,9 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
1078
1078
|
|
|
1079
1079
|
|
|
1080
1080
|
DATABASE_STATE = frozenset(
|
|
1081
|
-
#
|
|
1082
|
-
# database of its own, and reaching for one before then
|
|
1083
|
-
# `DatabaseNotConfiguredError
|
|
1081
|
+
# The attributes `BaseDatabase.__init__` sets. A registry has these once
|
|
1082
|
+
# it has a database of its own, and reaching for one before then raises
|
|
1083
|
+
# `DatabaseNotConfiguredError`, whichever method asked.
|
|
1084
1084
|
{
|
|
1085
1085
|
"url",
|
|
1086
1086
|
"templates",
|
|
@@ -1164,7 +1164,7 @@ def lazy_session_class(base: type) -> type:
|
|
|
1164
1164
|
|
|
1165
1165
|
|
|
1166
1166
|
class BaseRetryingTransaction:
|
|
1167
|
-
"""
|
|
1167
|
+
"""``transaction()`` returns this when given ``retry_on``.
|
|
1168
1168
|
|
|
1169
1169
|
A decorator, and deliberately not a context manager: retrying re-runs the
|
|
1170
1170
|
block, which a ``with`` statement cannot do. Entering one is rejected by
|
|
@@ -281,7 +281,7 @@ class Database(BaseDatabase[sa.Connection, Session]):
|
|
|
281
281
|
let them reach the database on their own, seeing nothing of this
|
|
282
282
|
block and surviving its rollback.
|
|
283
283
|
rollback: Roll back on the way out rather than commit. Implies
|
|
284
|
-
``savepoint``, and
|
|
284
|
+
``savepoint``, and wraps a test.
|
|
285
285
|
commit_on_error: Exception types whose escape still commits. The
|
|
286
286
|
exception propagates; what was written before it stays.
|
|
287
287
|
retry_on: Exception types, or a predicate over the exception, worth
|
|
@@ -375,7 +375,7 @@ class Database(BaseDatabase[sa.Connection, Session]):
|
|
|
375
375
|
) -> Iterator[None]:
|
|
376
376
|
"""Create these tables here, and drop them when the block ends.
|
|
377
377
|
|
|
378
|
-
|
|
378
|
+
A test session opens this once, around everything that needs a schema:
|
|
379
379
|
|
|
380
380
|
```python
|
|
381
381
|
@pytest.fixture(scope="session")
|
|
@@ -384,8 +384,8 @@ class Database(BaseDatabase[sa.Connection, Session]):
|
|
|
384
384
|
yield
|
|
385
385
|
```
|
|
386
386
|
|
|
387
|
-
Every table of the metadata unless ``tables`` names fewer,
|
|
388
|
-
|
|
387
|
+
Every table of the metadata unless ``tables`` names fewer, as a second
|
|
388
|
+
database wants.
|
|
389
389
|
"""
|
|
390
390
|
with self.transaction() as connection:
|
|
391
391
|
metadata.create_all(connection, tables=tables)
|
|
@@ -434,10 +434,10 @@ def _owned(
|
|
|
434
434
|
|
|
435
435
|
|
|
436
436
|
class Transaction(ContextDecorator, AbstractContextManager["sa.Connection"]):
|
|
437
|
-
"""
|
|
437
|
+
"""[`Database.transaction`][sqlakit.Database.transaction] returns this.
|
|
438
438
|
|
|
439
439
|
A class rather than a generator, so that it works as a decorator and can be
|
|
440
|
-
entered more than once,
|
|
440
|
+
entered more than once, as decorating a function does.
|
|
441
441
|
"""
|
|
442
442
|
|
|
443
443
|
def __init__(
|
|
@@ -595,8 +595,8 @@ class Transaction(ContextDecorator, AbstractContextManager["sa.Connection"]):
|
|
|
595
595
|
class RetryingTransaction(BaseRetryingTransaction):
|
|
596
596
|
"""A transaction that runs its block again.
|
|
597
597
|
|
|
598
|
-
|
|
599
|
-
given ``retry_on``.
|
|
598
|
+
[`Database.transaction`][sqlakit.Database.transaction] returns this when
|
|
599
|
+
it is given ``retry_on``.
|
|
600
600
|
"""
|
|
601
601
|
|
|
602
602
|
def __call__(self, func: _FuncT) -> _FuncT:
|
|
@@ -57,16 +57,33 @@ class Records:
|
|
|
57
57
|
def __init__(self, history: int = HISTORY) -> None:
|
|
58
58
|
self._lock = threading.Lock()
|
|
59
59
|
self._records: deque[dict[str, Any]] = deque(maxlen=history)
|
|
60
|
+
self._held: set[str] = set()
|
|
60
61
|
self._watchers: list[queue.Queue[dict[str, Any]]] = []
|
|
61
62
|
|
|
62
63
|
def add(self, record: dict[str, Any]) -> None:
|
|
63
|
-
"""Keep a recording, and hand it to every page that is watching.
|
|
64
|
+
"""Keep a recording, and hand it to every page that is watching.
|
|
65
|
+
|
|
66
|
+
A recording is kept once. Several blocks may report one recording,
|
|
67
|
+
and each of them sends it, which is how databases outside a registry
|
|
68
|
+
report under a single label.
|
|
69
|
+
"""
|
|
64
70
|
with self._lock:
|
|
71
|
+
sent = record.get("id")
|
|
72
|
+
if sent is not None:
|
|
73
|
+
if sent in self._held:
|
|
74
|
+
return
|
|
75
|
+
self._held.add(sent)
|
|
76
|
+
if len(self._records) == self._records.maxlen:
|
|
77
|
+
self._forget(self._records[0])
|
|
65
78
|
self._records.append(record)
|
|
66
79
|
watchers = list(self._watchers)
|
|
67
80
|
for watcher in watchers:
|
|
68
81
|
watcher.put(record)
|
|
69
82
|
|
|
83
|
+
def _forget(self, record: dict[str, Any]) -> None:
|
|
84
|
+
"""Drop the id of a recording the history no longer holds."""
|
|
85
|
+
self._held.discard(record.get("id", ""))
|
|
86
|
+
|
|
70
87
|
def all(self) -> list[dict[str, Any]]:
|
|
71
88
|
"""Every recording held, oldest first."""
|
|
72
89
|
with self._lock:
|
|
@@ -76,6 +93,7 @@ class Records:
|
|
|
76
93
|
"""Forget them."""
|
|
77
94
|
with self._lock:
|
|
78
95
|
self._records.clear()
|
|
96
|
+
self._held.clear()
|
|
79
97
|
|
|
80
98
|
def watch(self) -> queue.Queue[dict[str, Any]]:
|
|
81
99
|
"""Return a queue that every later recording arrives on."""
|
|
@@ -93,7 +111,7 @@ class Records:
|
|
|
93
111
|
|
|
94
112
|
@dataclass(frozen=True, slots=True)
|
|
95
113
|
class DebugServer:
|
|
96
|
-
"""
|
|
114
|
+
"""The address recordings go to, and the application sending them.
|
|
97
115
|
|
|
98
116
|
A server watches several applications at once, so a recording says which
|
|
99
117
|
one it came from:
|
|
@@ -126,7 +144,22 @@ class DebugServer:
|
|
|
126
144
|
@property
|
|
127
145
|
def sender(self) -> str:
|
|
128
146
|
"""The name the page groups this application under."""
|
|
129
|
-
return self.app or
|
|
147
|
+
return self.app or _program()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _program() -> str:
|
|
151
|
+
"""Name the running program, for a sender that named no application.
|
|
152
|
+
|
|
153
|
+
`python -m myapp` runs a file called `__main__.py`, which says nothing on
|
|
154
|
+
a page, so the module it was started as is the name instead.
|
|
155
|
+
"""
|
|
156
|
+
started = getattr(sys.modules.get("__main__"), "__spec__", None)
|
|
157
|
+
if started is not None and started.name:
|
|
158
|
+
return started.name.removesuffix(".__main__")
|
|
159
|
+
script = pathlib.Path(sys.argv[0])
|
|
160
|
+
if script.name in ("", "__main__.py"):
|
|
161
|
+
return script.parent.name or "python"
|
|
162
|
+
return script.name
|
|
130
163
|
|
|
131
164
|
|
|
132
165
|
def as_payload(
|
|
@@ -134,6 +167,7 @@ def as_payload(
|
|
|
134
167
|
) -> dict[str, Any]:
|
|
135
168
|
"""Return what travels: the recording, flattened to what a page shows."""
|
|
136
169
|
return {
|
|
170
|
+
"id": recording.id,
|
|
137
171
|
"app": app,
|
|
138
172
|
"tags": list(tags),
|
|
139
173
|
"label": recording.label,
|
|
@@ -160,6 +194,10 @@ def send_recording(recording: Recording, to: DebugServer | tuple[str, int]) -> N
|
|
|
160
194
|
|
|
161
195
|
A thread of its own does the sending, so the block that recorded pays
|
|
162
196
|
nothing for a server that is slow, or down, or not there at all.
|
|
197
|
+
|
|
198
|
+
A recording carries an id, and a server keeps the first that arrives under
|
|
199
|
+
it. Two blocks writing into one recording, which is how several databases
|
|
200
|
+
report under a single label, would otherwise show up twice.
|
|
163
201
|
"""
|
|
164
202
|
server = DebugServer.of(to)
|
|
165
203
|
body = json.dumps(
|
|
@@ -15,7 +15,7 @@ __all__ = ["import_models", "import_string"]
|
|
|
15
15
|
def import_string(path: str) -> Any: # noqa: ANN401
|
|
16
16
|
"""Return what a dotted path names, importing what it has to.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Settings carry a path instead of the thing itself:
|
|
19
19
|
|
|
20
20
|
```python
|
|
21
21
|
db.configure(
|
|
@@ -50,7 +50,7 @@ DatabaseT = TypeVar("DatabaseT", bound="BaseDatabase[Any, Any]")
|
|
|
50
50
|
|
|
51
51
|
|
|
52
52
|
class DatabaseSource(Protocol):
|
|
53
|
-
"""
|
|
53
|
+
"""The registry an alias is looked up in: the importable one, or a stand-in."""
|
|
54
54
|
|
|
55
55
|
def __getitem__(self, alias: str) -> BaseDatabase[Any, Any]: ...
|
|
56
56
|
|
|
@@ -82,7 +82,7 @@ class RegistryDescriptor:
|
|
|
82
82
|
|
|
83
83
|
|
|
84
84
|
class BaseModel(Generic[DatabaseT]):
|
|
85
|
-
"""
|
|
85
|
+
"""The sync and async models share this: everything that is not IO.
|
|
86
86
|
|
|
87
87
|
A model works on the database named by ``__db__``: an alias in the
|
|
88
88
|
importable registry, or a database of its own.
|
|
@@ -95,7 +95,7 @@ class BaseModel(Generic[DatabaseT]):
|
|
|
95
95
|
"""Where an alias in ``__db__`` is looked up."""
|
|
96
96
|
|
|
97
97
|
# Declared, not assigned: the declarative base a model is built on brings
|
|
98
|
-
# them, and saying so here
|
|
98
|
+
# them, and saying so here lets the helpers below read them.
|
|
99
99
|
registry: ClassVar[sa.orm.registry]
|
|
100
100
|
metadata: ClassVar[sa.MetaData]
|
|
101
101
|
|
|
@@ -134,9 +134,9 @@ class BaseModel(Generic[DatabaseT]):
|
|
|
134
134
|
User(name="ada").save()
|
|
135
135
|
```
|
|
136
136
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
137
|
+
A model still resolves through `__db__`, the routers and the open
|
|
138
|
+
`using()` block, in that order. A model left on the default alias
|
|
139
|
+
follows `using()`, and that makes the switch above work.
|
|
140
140
|
|
|
141
141
|
Without an alias it goes in as the default one, which is where a model
|
|
142
142
|
that names no database lives:
|
|
@@ -196,7 +196,7 @@ class BaseModel(Generic[DatabaseT]):
|
|
|
196
196
|
campaign.set_loaded("thumbnail", None) # known to be empty
|
|
197
197
|
```
|
|
198
198
|
|
|
199
|
-
|
|
199
|
+
A `lazy="raise"` relationship needs this when the value is in hand:
|
|
200
200
|
rows fetched for a whole page at once, a row this block created, or an
|
|
201
201
|
instance that outlives the session that loaded it.
|
|
202
202
|
|
|
@@ -293,7 +293,7 @@ def tables_for(
|
|
|
293
293
|
) -> list[sa.Table] | None:
|
|
294
294
|
"""Return the tables of this model's metadata that live on that database.
|
|
295
295
|
|
|
296
|
-
None when they all do,
|
|
296
|
+
None when they all do, as `create_all` wants for the ordinary
|
|
297
297
|
case of one database. Otherwise the tables of the models pointed at it,
|
|
298
298
|
and the tables that only reference those. An association table belongs
|
|
299
299
|
with the rows it joins.
|
|
@@ -221,7 +221,7 @@ class Page(Generic[ModelT, TotalT]):
|
|
|
221
221
|
def with_items(self, items: Sequence[OtherT]) -> Page[OtherT, TotalT]:
|
|
222
222
|
"""Return the page carrying these rows instead, counts unchanged.
|
|
223
223
|
|
|
224
|
-
|
|
224
|
+
An asynchronous transform needs this: `page.with_items(await serialize(...))`.
|
|
225
225
|
|
|
226
226
|
Raises:
|
|
227
227
|
PageItemsMismatchError: if there is not one item per row.
|
|
@@ -385,7 +385,7 @@ class BaseQuery(Generic[ModelT]):
|
|
|
385
385
|
def with_select(self, select: sa.Select[Any]) -> Self:
|
|
386
386
|
"""Return a query like this one, over the given select.
|
|
387
387
|
|
|
388
|
-
|
|
388
|
+
Every builder is made of this. Use it in a method of your own
|
|
389
389
|
when the statement needs SQLAlchemy the builders do not cover.
|
|
390
390
|
|
|
391
391
|
Raises:
|
|
@@ -415,7 +415,7 @@ class BaseQuery(Generic[ModelT]):
|
|
|
415
415
|
def is_ordered(self) -> bool:
|
|
416
416
|
"""Whether this query carries an ordering.
|
|
417
417
|
|
|
418
|
-
|
|
418
|
+
A method of your own asks this before it adds a default one, since
|
|
419
419
|
`page` and `cursor_page` refuse a query with no ordering.
|
|
420
420
|
"""
|
|
421
421
|
return bool(self._select._order_by_clauses) # noqa: SLF001
|
|
@@ -458,7 +458,7 @@ class BaseQuery(Generic[ModelT]):
|
|
|
458
458
|
```
|
|
459
459
|
|
|
460
460
|
Nothing can be added afterwards, and ``__query_filter__`` is not applied:
|
|
461
|
-
|
|
461
|
+
the statement selects the rows that come back.
|
|
462
462
|
"""
|
|
463
463
|
query = self._copy()
|
|
464
464
|
query._statement = self._select.from_statement( # noqa: SLF001
|
|
@@ -561,7 +561,7 @@ class BaseQuery(Generic[ModelT]):
|
|
|
561
561
|
User.query.order_by(request.sort, ignore_case=["name"])
|
|
562
562
|
```
|
|
563
563
|
|
|
564
|
-
|
|
564
|
+
The dialect decides which SQL that becomes, and
|
|
565
565
|
`CASE_INSENSITIVE_COLLATIONS` names the collation. A cursor cannot page
|
|
566
566
|
it: use it with `page`.
|
|
567
567
|
|
|
@@ -948,7 +948,7 @@ class BaseQuery(Generic[ModelT]):
|
|
|
948
948
|
def orderable_columns(model: type[Any]) -> Mapping[str, Any]:
|
|
949
949
|
"""Return every mapped column of a model, by name.
|
|
950
950
|
|
|
951
|
-
|
|
951
|
+
A model orders by these when it declares no ``__orderable__``, and an
|
|
952
952
|
``__orderable__`` that adds to the columns rather than replacing them starts
|
|
953
953
|
from:
|
|
954
954
|
|
|
@@ -1174,7 +1174,7 @@ def _join_for(
|
|
|
1174
1174
|
"""Return what to join for a field that is a plain column of another table.
|
|
1175
1175
|
|
|
1176
1176
|
A relationship of the model that reaches that table is the join, condition
|
|
1177
|
-
and all,
|
|
1177
|
+
and all, as a view with no foreign key needs. Failing that, the
|
|
1178
1178
|
table itself, and `SQLAlchemy` works the condition out from the key.
|
|
1179
1179
|
|
|
1180
1180
|
An expression may name several tables or none, so it is left alone:
|
|
@@ -1409,7 +1409,7 @@ def _from_json(value: Any, type_: sa.types.TypeEngine[Any]) -> Any: # noqa: ANN
|
|
|
1409
1409
|
|
|
1410
1410
|
|
|
1411
1411
|
class _Rows(Protocol[RowT_co]):
|
|
1412
|
-
"""
|
|
1412
|
+
"""The helpers below take one row of a result, or none."""
|
|
1413
1413
|
|
|
1414
1414
|
def one(self) -> RowT_co: ...
|
|
1415
1415
|
|
|
@@ -4,6 +4,7 @@ import logging
|
|
|
4
4
|
import sys
|
|
5
5
|
import sysconfig
|
|
6
6
|
import traceback
|
|
7
|
+
import uuid
|
|
7
8
|
from dataclasses import dataclass, field
|
|
8
9
|
from itertools import islice
|
|
9
10
|
from pathlib import Path
|
|
@@ -106,7 +107,7 @@ class Statement:
|
|
|
106
107
|
class Recording:
|
|
107
108
|
"""The statements of a block, and what they add up to.
|
|
108
109
|
|
|
109
|
-
|
|
110
|
+
`Database.recording()` hands this back:
|
|
110
111
|
|
|
111
112
|
```python
|
|
112
113
|
with db.recording() as record:
|
|
@@ -118,6 +119,8 @@ class Recording:
|
|
|
118
119
|
|
|
119
120
|
label: str | None = None
|
|
120
121
|
statements: list[Statement] = field(default_factory=list)
|
|
122
|
+
id: str = field(default_factory=lambda: uuid.uuid4().hex, repr=False, compare=False)
|
|
123
|
+
"""A unique id, so a recording several blocks sent is kept once."""
|
|
121
124
|
|
|
122
125
|
@property
|
|
123
126
|
def count(self) -> int:
|
|
@@ -261,7 +264,7 @@ class Recording:
|
|
|
261
264
|
def pretty(self) -> str:
|
|
262
265
|
"""The statements, numbered, each laid out over several lines.
|
|
263
266
|
|
|
264
|
-
|
|
267
|
+
`print()` shows this when the one-line listing has run out of room.
|
|
265
268
|
"""
|
|
266
269
|
if not self.statements:
|
|
267
270
|
return "no queries"
|
|
@@ -360,7 +363,7 @@ def check(
|
|
|
360
363
|
at_most: int | None,
|
|
361
364
|
duplicates: bool,
|
|
362
365
|
) -> None:
|
|
363
|
-
"""Fail unless the recording
|
|
366
|
+
"""Fail unless the recording matches what the block said.
|
|
364
367
|
|
|
365
368
|
Args:
|
|
366
369
|
recording: What the block ran.
|
|
@@ -402,8 +405,8 @@ def resolved(paths: Sequence[str | PathLike[str]]) -> tuple[str, ...]:
|
|
|
402
405
|
def caller_stack(skip: Sequence[str] = (), keep: int = KEEP) -> tuple[str, ...]:
|
|
403
406
|
"""Return the frames of your own code that led to a statement.
|
|
404
407
|
|
|
405
|
-
|
|
406
|
-
|
|
408
|
+
This library's and SQLAlchemy's are left out by directory rather than by
|
|
409
|
+
name: a project may well live in a path carrying the library's name. So is
|
|
407
410
|
generated code, `<string>`: SQLAlchemy builds wrappers that way, and the
|
|
408
411
|
line numbers lead nowhere.
|
|
409
412
|
|
|
@@ -37,7 +37,7 @@ class Databases(_DatabaseRegistryMixin[Database], Database):
|
|
|
37
37
|
) -> Iterator[None]:
|
|
38
38
|
"""Open a transaction on every database, not the default one alone.
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
A single database needs `transaction(rollback=True)`. A test harness
|
|
41
41
|
with several needs this:
|
|
42
42
|
|
|
43
43
|
```python
|
|
@@ -58,7 +58,7 @@ PathLike = str | Path
|
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
class Templates:
|
|
61
|
-
"""
|
|
61
|
+
"""The directory a database's SQL templates live in, and how they render.
|
|
62
62
|
|
|
63
63
|
A path is enough; the object is for the rest:
|
|
64
64
|
|
|
@@ -173,7 +173,7 @@ class Templates:
|
|
|
173
173
|
|
|
174
174
|
|
|
175
175
|
class BaseSQLQuery(Generic[RowT, DatabaseT]):
|
|
176
|
-
"""
|
|
176
|
+
"""The source of the SQL, its context, and the type its rows become.
|
|
177
177
|
|
|
178
178
|
Built by `db.sql(...)`. Nothing can be narrowed: what the SQL selects is
|
|
179
179
|
what comes back.
|
|
@@ -315,7 +315,7 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
|
|
|
315
315
|
let them reach the database on their own, seeing nothing of this
|
|
316
316
|
block and surviving its rollback.
|
|
317
317
|
rollback: Roll back on the way out rather than commit. Implies
|
|
318
|
-
``savepoint``, and
|
|
318
|
+
``savepoint``, and wraps a test.
|
|
319
319
|
commit_on_error: Exception types whose escape still commits. The
|
|
320
320
|
exception propagates; what was written before it stays.
|
|
321
321
|
retry_on: Exception types, or a predicate over the exception, worth
|
|
@@ -409,7 +409,7 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
|
|
|
409
409
|
) -> AsyncIterator[None]:
|
|
410
410
|
"""Create these tables here, and drop them when the block ends.
|
|
411
411
|
|
|
412
|
-
|
|
412
|
+
A test session opens this once, around everything that needs a schema:
|
|
413
413
|
|
|
414
414
|
```python
|
|
415
415
|
@pytest.fixture(scope="session")
|
|
@@ -418,8 +418,8 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
|
|
|
418
418
|
yield
|
|
419
419
|
```
|
|
420
420
|
|
|
421
|
-
Every table of the metadata unless ``tables`` names fewer,
|
|
422
|
-
|
|
421
|
+
Every table of the metadata unless ``tables`` names fewer, as a second
|
|
422
|
+
database wants.
|
|
423
423
|
"""
|
|
424
424
|
async with self.transaction() as connection:
|
|
425
425
|
await connection.run_sync(metadata.create_all, tables=tables)
|
|
@@ -473,11 +473,11 @@ class Transaction(
|
|
|
473
473
|
):
|
|
474
474
|
"""The transaction an awaited block runs, as an object.
|
|
475
475
|
|
|
476
|
-
|
|
476
|
+
[`Database.transaction`][sqlakit.asyncio.Database.transaction] returns this.
|
|
477
477
|
|
|
478
478
|
Kept as a class rather than a generator so that it also works as a
|
|
479
479
|
decorator, and so that a single instance can be entered more than once,
|
|
480
|
-
|
|
480
|
+
as ``@db.transaction()`` on a coroutine function does.
|
|
481
481
|
"""
|
|
482
482
|
|
|
483
483
|
def __init__(
|
|
@@ -637,8 +637,8 @@ class Transaction(
|
|
|
637
637
|
class RetryingTransaction(BaseRetryingTransaction):
|
|
638
638
|
"""A transaction that runs its block again.
|
|
639
639
|
|
|
640
|
-
|
|
641
|
-
when it is given ``retry_on``.
|
|
640
|
+
[`Database.transaction`][sqlakit.asyncio.Database.transaction] returns
|
|
641
|
+
this when it is given ``retry_on``.
|
|
642
642
|
"""
|
|
643
643
|
|
|
644
644
|
def __call__(self, func: _FuncT) -> _FuncT:
|
|
@@ -37,8 +37,8 @@ class Databases(_DatabaseRegistryMixin[Database], Database):
|
|
|
37
37
|
) -> AsyncIterator[None]:
|
|
38
38
|
"""Open a transaction on every database, not the default one alone.
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
A test harness with more than one database needs this, where a single
|
|
41
|
+
database needs `transaction(rollback=True)`.
|
|
42
42
|
"""
|
|
43
43
|
async with AsyncExitStack() as stack:
|
|
44
44
|
for alias in self.aliases:
|
|
@@ -559,7 +559,7 @@ class ModelMixin(BaseModel[Database]):
|
|
|
559
559
|
async def provisioned_tables(cls, alias: str | None = None) -> AsyncIterator[None]:
|
|
560
560
|
"""Create the tables that belong on this model's database, and drop them after.
|
|
561
561
|
|
|
562
|
-
|
|
562
|
+
A test session opens this once, around everything that needs a schema:
|
|
563
563
|
|
|
564
564
|
```python
|
|
565
565
|
@pytest.fixture(scope="session")
|
|
@@ -95,7 +95,7 @@ class SQL:
|
|
|
95
95
|
|
|
96
96
|
@property
|
|
97
97
|
def templates(self) -> Templates:
|
|
98
|
-
"""
|
|
98
|
+
"""The directory this database looks in for its templates."""
|
|
99
99
|
return templates_of(self.db)
|
|
100
100
|
|
|
101
101
|
def check(self) -> None:
|
|
@@ -110,7 +110,7 @@ class SQL:
|
|
|
110
110
|
class SQLRows(BaseSQLQuery[RowT, "Database"]):
|
|
111
111
|
"""The rows of a SQL template, on the connection of the block it runs in.
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
The rows are settled: reading them is all that is left.
|
|
114
114
|
"""
|
|
115
115
|
|
|
116
116
|
async def all(self) -> Sequence[RowT]:
|
|
@@ -196,7 +196,7 @@ class SQLQuery(SQLRows[sa.Row[Any]]):
|
|
|
196
196
|
await db.sql("reports/by_team.sql", since=since).typed(TeamReport).all()
|
|
197
197
|
```
|
|
198
198
|
|
|
199
|
-
The type
|
|
199
|
+
The type says what one row becomes, and the terminal decides the container.
|
|
200
200
|
Anything pydantic can validate works: a model, a dataclass, a
|
|
201
201
|
`TypedDict`. The type also says how much of the row it takes: one built
|
|
202
202
|
from columns is given the whole row, and anything else is given the
|