pyoq-sql 1.0.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.
- pyoq/__init__.py +7 -0
- pyoq/__main__.py +5 -0
- pyoq/_native.pyi +5 -0
- pyoq/cli/__init__.py +5 -0
- pyoq/cli/commands.py +270 -0
- pyoq/cli/defaults.py +98 -0
- pyoq/cli/services.py +97 -0
- pyoq/config/__init__.py +19 -0
- pyoq/config/loader.py +204 -0
- pyoq/config/models.py +243 -0
- pyoq/descriptors.py +165 -0
- pyoq/diagnostics/__init__.py +68 -0
- pyoq/diagnostics/budget.py +136 -0
- pyoq/diagnostics/events.py +137 -0
- pyoq/diagnostics/fingerprint.py +267 -0
- pyoq/diagnostics/instrumented.py +237 -0
- pyoq/diagnostics/metrics.py +61 -0
- pyoq/diagnostics/observation.py +227 -0
- pyoq/diagnostics/scoped.py +103 -0
- pyoq/django/__init__.py +15 -0
- pyoq/django/apps.py +17 -0
- pyoq/django/execution.py +317 -0
- pyoq/django/generation.py +59 -0
- pyoq/django/management/__init__.py +0 -0
- pyoq/django/management/commands/__init__.py +0 -0
- pyoq/django/management/commands/makemigrations.py +53 -0
- pyoq/django/management/commands/pyoq_codegen.py +75 -0
- pyoq/django/parameters.py +101 -0
- pyoq/django/schema.py +379 -0
- pyoq/django/settings.py +87 -0
- pyoq/django/timeouts.py +105 -0
- pyoq/dsl/__init__.py +64 -0
- pyoq/dsl/aio/__init__.py +31 -0
- pyoq/dsl/aio/context.py +295 -0
- pyoq/dsl/aio/queries.py +335 -0
- pyoq/dsl/aio/writes.py +368 -0
- pyoq/dsl/context.py +326 -0
- pyoq/dsl/entry.py +37 -0
- pyoq/dsl/labels.py +36 -0
- pyoq/dsl/queries.py +339 -0
- pyoq/dsl/result.py +164 -0
- pyoq/dsl/writes.py +360 -0
- pyoq/errors.py +317 -0
- pyoq/fastapi/__init__.py +32 -0
- pyoq/fastapi/dependencies.py +167 -0
- pyoq/fastapi/lifespan.py +119 -0
- pyoq/fetching/__init__.py +55 -0
- pyoq/fetching/collections.py +136 -0
- pyoq/fetching/execution.py +587 -0
- pyoq/fetching/joined.py +79 -0
- pyoq/fetching/nesting.py +183 -0
- pyoq/fetching/plans.py +541 -0
- pyoq/fetching/select_in.py +149 -0
- pyoq/fetching/tables.py +110 -0
- pyoq/generation/__init__.py +54 -0
- pyoq/generation/cleanup.py +44 -0
- pyoq/generation/contracts.py +248 -0
- pyoq/generation/drift.py +169 -0
- pyoq/generation/lock.py +33 -0
- pyoq/generation/manifest.py +114 -0
- pyoq/generation/model.py +1001 -0
- pyoq/generation/pipeline.py +119 -0
- pyoq/generation/rendering/__init__.py +5 -0
- pyoq/generation/rendering/domains.py +51 -0
- pyoq/generation/rendering/enums.py +29 -0
- pyoq/generation/rendering/exports.py +70 -0
- pyoq/generation/rendering/imports.py +63 -0
- pyoq/generation/rendering/package.py +56 -0
- pyoq/generation/rendering/relations.py +133 -0
- pyoq/generation/rendering/routines.py +396 -0
- pyoq/generation/rendering/rows.py +79 -0
- pyoq/generation/rendering/source.py +121 -0
- pyoq/generation/rendering/tables.py +300 -0
- pyoq/generation/rendering/writes.py +514 -0
- pyoq/generation/validation.py +27 -0
- pyoq/generation/writer.py +184 -0
- pyoq/hydration/__init__.py +24 -0
- pyoq/hydration/engine.py +155 -0
- pyoq/hydration/identity.py +194 -0
- pyoq/hydration/plan.py +116 -0
- pyoq/migrations/__init__.py +9 -0
- pyoq/migrations/alembic.py +106 -0
- pyoq/migrations/hooks.py +75 -0
- pyoq/naming.py +261 -0
- pyoq/policies/__init__.py +47 -0
- pyoq/policies/bypass.py +122 -0
- pyoq/policies/governed.py +430 -0
- pyoq/policies/model.py +242 -0
- pyoq/policies/rewriting.py +263 -0
- pyoq/py.typed +1 -0
- pyoq/query/__init__.py +312 -0
- pyoq/query/aggregates.py +172 -0
- pyoq/query/arrays.py +65 -0
- pyoq/query/binding.py +52 -0
- pyoq/query/capabilities.py +317 -0
- pyoq/query/casts.py +73 -0
- pyoq/query/choices.py +185 -0
- pyoq/query/decoding.py +360 -0
- pyoq/query/documents.py +56 -0
- pyoq/query/execution/__init__.py +63 -0
- pyoq/query/execution/aio/__init__.py +31 -0
- pyoq/query/execution/aio/operations.py +228 -0
- pyoq/query/execution/aio/pooling.py +233 -0
- pyoq/query/execution/aio/streaming.py +161 -0
- pyoq/query/execution/aio/transactions.py +105 -0
- pyoq/query/execution/batch.py +96 -0
- pyoq/query/execution/binding_style.py +30 -0
- pyoq/query/execution/compilation.py +48 -0
- pyoq/query/execution/context.py +61 -0
- pyoq/query/execution/control.py +50 -0
- pyoq/query/execution/operations.py +224 -0
- pyoq/query/execution/planning.py +107 -0
- pyoq/query/execution/pooling.py +279 -0
- pyoq/query/execution/results.py +36 -0
- pyoq/query/execution/streaming.py +178 -0
- pyoq/query/execution/transactions.py +95 -0
- pyoq/query/expressions.py +1200 -0
- pyoq/query/fields.py +60 -0
- pyoq/query/mysql/__init__.py +59 -0
- pyoq/query/mysql/aio/__init__.py +38 -0
- pyoq/query/mysql/aio/commands.py +389 -0
- pyoq/query/mysql/aio/driver.py +196 -0
- pyoq/query/mysql/aio/executor.py +123 -0
- pyoq/query/mysql/aio/factory.py +26 -0
- pyoq/query/mysql/aio/operations.py +38 -0
- pyoq/query/mysql/aio/pool.py +53 -0
- pyoq/query/mysql/aio/transactions.py +313 -0
- pyoq/query/mysql/commands.py +354 -0
- pyoq/query/mysql/compiler.py +134 -0
- pyoq/query/mysql/context.py +20 -0
- pyoq/query/mysql/executor.py +126 -0
- pyoq/query/mysql/expressions.py +244 -0
- pyoq/query/mysql/factory.py +46 -0
- pyoq/query/mysql/health.py +66 -0
- pyoq/query/mysql/identifiers.py +9 -0
- pyoq/query/mysql/model.py +79 -0
- pyoq/query/mysql/operations.py +43 -0
- pyoq/query/mysql/parameters.py +69 -0
- pyoq/query/mysql/planning.py +20 -0
- pyoq/query/mysql/pool.py +67 -0
- pyoq/query/mysql/transactions.py +331 -0
- pyoq/query/mysql/writes.py +73 -0
- pyoq/query/nodes.py +750 -0
- pyoq/query/postgres/__init__.py +48 -0
- pyoq/query/postgres/aio/__init__.py +25 -0
- pyoq/query/postgres/aio/bulk.py +56 -0
- pyoq/query/postgres/aio/commands.py +264 -0
- pyoq/query/postgres/aio/executor.py +152 -0
- pyoq/query/postgres/aio/factory.py +26 -0
- pyoq/query/postgres/aio/operations.py +26 -0
- pyoq/query/postgres/aio/pool.py +40 -0
- pyoq/query/postgres/aio/transactions.py +295 -0
- pyoq/query/postgres/bulk.py +62 -0
- pyoq/query/postgres/commands.py +238 -0
- pyoq/query/postgres/compiler.py +114 -0
- pyoq/query/postgres/context.py +20 -0
- pyoq/query/postgres/executor.py +147 -0
- pyoq/query/postgres/expressions.py +311 -0
- pyoq/query/postgres/factory.py +24 -0
- pyoq/query/postgres/health.py +24 -0
- pyoq/query/postgres/identifiers.py +9 -0
- pyoq/query/postgres/model.py +81 -0
- pyoq/query/postgres/operations.py +25 -0
- pyoq/query/postgres/parameters.py +71 -0
- pyoq/query/postgres/planning.py +20 -0
- pyoq/query/postgres/pool.py +52 -0
- pyoq/query/postgres/transactions.py +295 -0
- pyoq/query/postgres/writes.py +37 -0
- pyoq/query/projections.py +105 -0
- pyoq/query/raw.py +90 -0
- pyoq/query/recursion.py +265 -0
- pyoq/query/rendering/__init__.py +1 -0
- pyoq/query/rendering/expressions.py +913 -0
- pyoq/query/rendering/identifiers.py +40 -0
- pyoq/query/rendering/projections.py +63 -0
- pyoq/query/rendering/queries.py +334 -0
- pyoq/query/rendering/sources.py +66 -0
- pyoq/query/rendering/writes.py +176 -0
- pyoq/query/results.py +459 -0
- pyoq/query/routines.py +196 -0
- pyoq/query/rows.py +156 -0
- pyoq/query/select.py +793 -0
- pyoq/query/select_nodes.py +277 -0
- pyoq/query/sources.py +236 -0
- pyoq/query/sqlite/__init__.py +43 -0
- pyoq/query/sqlite/commands.py +201 -0
- pyoq/query/sqlite/compiler.py +139 -0
- pyoq/query/sqlite/context.py +20 -0
- pyoq/query/sqlite/executor.py +119 -0
- pyoq/query/sqlite/expressions.py +224 -0
- pyoq/query/sqlite/factory.py +32 -0
- pyoq/query/sqlite/health.py +28 -0
- pyoq/query/sqlite/identifiers.py +9 -0
- pyoq/query/sqlite/model.py +73 -0
- pyoq/query/sqlite/operations.py +36 -0
- pyoq/query/sqlite/parameters.py +50 -0
- pyoq/query/sqlite/planning.py +20 -0
- pyoq/query/sqlite/pool.py +50 -0
- pyoq/query/sqlite/streaming.py +13 -0
- pyoq/query/sqlite/transactions.py +274 -0
- pyoq/query/sqlite/writes.py +35 -0
- pyoq/query/statements.py +27 -0
- pyoq/query/values.py +23 -0
- pyoq/query/vendor.py +162 -0
- pyoq/query/windows.py +424 -0
- pyoq/query/write_nodes.py +174 -0
- pyoq/query/writes.py +628 -0
- pyoq/relations/__init__.py +66 -0
- pyoq/relations/batching.py +219 -0
- pyoq/relations/derivation.py +111 -0
- pyoq/relations/fetching.py +355 -0
- pyoq/relations/graph.py +245 -0
- pyoq/relations/loading.py +74 -0
- pyoq/relations/model.py +75 -0
- pyoq/relations/planning.py +206 -0
- pyoq/runtime/__init__.py +9 -0
- pyoq/runtime/kernels.py +25 -0
- pyoq/runtime/python.py +43 -0
- pyoq/runtime/selection.py +73 -0
- pyoq/sanic/__init__.py +32 -0
- pyoq/sanic/scope.py +197 -0
- pyoq/sanic/workers.py +129 -0
- pyoq/schema/__init__.py +108 -0
- pyoq/schema/codec.py +711 -0
- pyoq/schema/models.py +604 -0
- pyoq/schema/mysql/__init__.py +16 -0
- pyoq/schema/mysql/connection.py +72 -0
- pyoq/schema/mysql/dsn.py +72 -0
- pyoq/schema/mysql/records.py +354 -0
- pyoq/schema/mysql/reflection.py +309 -0
- pyoq/schema/mysql/source.py +30 -0
- pyoq/schema/mysql/sql.py +128 -0
- pyoq/schema/mysql/types.py +105 -0
- pyoq/schema/postgres/__init__.py +13 -0
- pyoq/schema/postgres/connection.py +62 -0
- pyoq/schema/postgres/records.py +384 -0
- pyoq/schema/postgres/reflection.py +466 -0
- pyoq/schema/postgres/source.py +30 -0
- pyoq/schema/postgres/sql.py +246 -0
- pyoq/schema/postgres/types.py +98 -0
- pyoq/schema/registry.py +45 -0
- pyoq/schema/source.py +15 -0
- pyoq/schema/sqlite/__init__.py +6 -0
- pyoq/schema/sqlite/connection.py +54 -0
- pyoq/schema/sqlite/records.py +167 -0
- pyoq/schema/sqlite/reflection.py +393 -0
- pyoq/schema/sqlite/source.py +30 -0
- pyoq/schema/sqlite/sql.py +254 -0
- pyoq/schema/sqlite/types.py +74 -0
- pyoq/serving/__init__.py +19 -0
- pyoq/serving/databases.py +107 -0
- pyoq/snapshots/__init__.py +20 -0
- pyoq/snapshots/drift.py +312 -0
- pyoq/snapshots/files.py +96 -0
- pyoq/snapshots/routing.py +40 -0
- pyoq/snapshots/source.py +33 -0
- pyoq/tracing/__init__.py +5 -0
- pyoq/tracing/spans.py +89 -0
- pyoq/unset.py +14 -0
- pyoq_sql-1.0.0.dist-info/METADATA +3034 -0
- pyoq_sql-1.0.0.dist-info/RECORD +264 -0
- pyoq_sql-1.0.0.dist-info/WHEEL +4 -0
- pyoq_sql-1.0.0.dist-info/entry_points.txt +3 -0
- pyoq_sql-1.0.0.dist-info/licenses/LICENSE +373 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""SQLite declared-type normalization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from pyoq.schema import SqlType, TypeKind
|
|
8
|
+
|
|
9
|
+
_TYPE_ARGUMENTS = re.compile(r"^\s*([^()]*)\s*(?:\(([^()]*)\))?\s*$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def sqlite_type(declared_type: str) -> SqlType:
|
|
13
|
+
source_name = declared_type.strip() or "ANY"
|
|
14
|
+
match = _TYPE_ARGUMENTS.fullmatch(source_name)
|
|
15
|
+
base_name = source_name if match is None else match.group(1).strip()
|
|
16
|
+
arguments = () if match is None else _numeric_arguments(match.group(2))
|
|
17
|
+
kind = _type_kind(base_name.upper())
|
|
18
|
+
if kind is TypeKind.STRING and len(arguments) == 1:
|
|
19
|
+
return SqlType(kind, source_name, length=arguments[0])
|
|
20
|
+
if kind is TypeKind.DECIMAL and arguments:
|
|
21
|
+
precision = arguments[0]
|
|
22
|
+
scale = arguments[1] if len(arguments) == 2 else None
|
|
23
|
+
return SqlType(kind, source_name, precision=precision, scale=scale)
|
|
24
|
+
return SqlType(kind, source_name)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _numeric_arguments(value: str | None) -> tuple[int, ...]:
|
|
28
|
+
if value is None:
|
|
29
|
+
return ()
|
|
30
|
+
parts = tuple(part.strip() for part in value.split(","))
|
|
31
|
+
if not 1 <= len(parts) <= 2 or any(not part.isdecimal() for part in parts):
|
|
32
|
+
return ()
|
|
33
|
+
return tuple(int(part) for part in parts)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_EXACT_KINDS: dict[str, TypeKind] = {
|
|
37
|
+
"DATE": TypeKind.DATE,
|
|
38
|
+
"TIME": TypeKind.TIME,
|
|
39
|
+
}
|
|
40
|
+
"""Names that say what they are only when they are the whole name."""
|
|
41
|
+
|
|
42
|
+
_CONTAINED_KINDS: tuple[tuple[tuple[str, ...], TypeKind], ...] = (
|
|
43
|
+
(("BOOL",), TypeKind.BOOLEAN),
|
|
44
|
+
(("BIGINT",), TypeKind.BIG_INTEGER),
|
|
45
|
+
(("SMALLINT", "TINYINT"), TypeKind.SMALL_INTEGER),
|
|
46
|
+
(("INT",), TypeKind.INTEGER),
|
|
47
|
+
(("CHAR", "CLOB", "TEXT"), TypeKind.STRING),
|
|
48
|
+
(("BLOB",), TypeKind.BINARY),
|
|
49
|
+
(("DOUBLE",), TypeKind.DOUBLE),
|
|
50
|
+
(("REAL", "FLOA"), TypeKind.REAL),
|
|
51
|
+
(("DATETIME", "TIMESTAMP"), TypeKind.DATETIME),
|
|
52
|
+
(("JSON",), TypeKind.JSON),
|
|
53
|
+
(("NUM", "DEC", "MONEY"), TypeKind.DECIMAL),
|
|
54
|
+
)
|
|
55
|
+
"""What a declared name holds, read in the order SQLite reads it.
|
|
56
|
+
|
|
57
|
+
The order is the rule rather than an arrangement of one: a name holding
|
|
58
|
+
`BIGINT` also holds `INT`, and one holding `SMALLINT` holds both, so the
|
|
59
|
+
narrower token has to be asked about first.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _type_kind(name: str) -> TypeKind:
|
|
64
|
+
"""What a declared type name holds, or that it says nothing recognised."""
|
|
65
|
+
exact = _EXACT_KINDS.get(name)
|
|
66
|
+
if exact is not None:
|
|
67
|
+
return exact
|
|
68
|
+
for tokens, kind in _CONTAINED_KINDS:
|
|
69
|
+
if any(token in name for token in tokens):
|
|
70
|
+
return kind
|
|
71
|
+
return TypeKind.OTHER
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ("sqlite_type",)
|
pyoq/serving/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Databases held by an application that serves requests."""
|
|
2
|
+
|
|
3
|
+
from pyoq.serving.databases import (
|
|
4
|
+
DEFAULT,
|
|
5
|
+
AsyncDatabase,
|
|
6
|
+
Databases,
|
|
7
|
+
SupportsAsyncTransaction,
|
|
8
|
+
SupportsTransaction,
|
|
9
|
+
SyncDatabase,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = (
|
|
13
|
+
"DEFAULT",
|
|
14
|
+
"AsyncDatabase",
|
|
15
|
+
"Databases",
|
|
16
|
+
"SupportsAsyncTransaction",
|
|
17
|
+
"SupportsTransaction",
|
|
18
|
+
"SyncDatabase",
|
|
19
|
+
)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""What a serving application opens once and closes once.
|
|
2
|
+
|
|
3
|
+
A pool belongs to the application, not to a request. Naming the close alongside
|
|
4
|
+
the operations keeps the two together, so a database cannot be registered
|
|
5
|
+
without saying how it is given back.
|
|
6
|
+
|
|
7
|
+
Nothing here is specific to one web framework. The adapters differ in where they
|
|
8
|
+
put these and when they run them, not in what they are.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING, Protocol, TypeVar, runtime_checkable
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Awaitable, Callable
|
|
18
|
+
from collections.abc import Set as AbstractSet
|
|
19
|
+
from contextlib import AbstractAsyncContextManager, AbstractContextManager
|
|
20
|
+
|
|
21
|
+
from pyoq.query.execution import QueryOperations
|
|
22
|
+
from pyoq.query.execution.aio import AsyncQueryOperations
|
|
23
|
+
|
|
24
|
+
DEFAULT = "default"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@runtime_checkable
|
|
28
|
+
class SupportsTransaction(Protocol):
|
|
29
|
+
"""Operations that can open a transaction of their own."""
|
|
30
|
+
|
|
31
|
+
def transaction(self) -> AbstractContextManager[QueryOperations]: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@runtime_checkable
|
|
35
|
+
class SupportsAsyncTransaction(Protocol):
|
|
36
|
+
def transaction(self) -> AbstractAsyncContextManager[AsyncQueryOperations]: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class SyncDatabase:
|
|
41
|
+
"""A database and the way to give it back."""
|
|
42
|
+
|
|
43
|
+
operations: QueryOperations
|
|
44
|
+
close: Callable[[], None]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class AsyncDatabase:
|
|
49
|
+
operations: AsyncQueryOperations
|
|
50
|
+
close: Callable[[], Awaitable[None]]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
DatabaseType = TypeVar("DatabaseType", SyncDatabase, AsyncDatabase)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Databases:
|
|
57
|
+
"""Every database this application opened, by name."""
|
|
58
|
+
|
|
59
|
+
__slots__ = ("_asynchronous", "_synchronous")
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
synchronous: dict[str, SyncDatabase],
|
|
64
|
+
asynchronous: dict[str, AsyncDatabase],
|
|
65
|
+
) -> None:
|
|
66
|
+
self._synchronous = synchronous
|
|
67
|
+
self._asynchronous = asynchronous
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def names(self) -> AbstractSet[str]:
|
|
71
|
+
return self._synchronous.keys() | self._asynchronous.keys()
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def opened(self) -> tuple[dict[str, SyncDatabase], dict[str, AsyncDatabase]]:
|
|
75
|
+
"""What was opened, for whoever has to give it back."""
|
|
76
|
+
return self._synchronous, self._asynchronous
|
|
77
|
+
|
|
78
|
+
def synchronous(self, name: str, /) -> QueryOperations:
|
|
79
|
+
return _require(self._synchronous, name, "synchronous").operations
|
|
80
|
+
|
|
81
|
+
def asynchronous(self, name: str, /) -> AsyncQueryOperations:
|
|
82
|
+
return _require(self._asynchronous, name, "asynchronous").operations
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _require(
|
|
86
|
+
databases: dict[str, DatabaseType],
|
|
87
|
+
name: str,
|
|
88
|
+
kind: str,
|
|
89
|
+
) -> DatabaseType:
|
|
90
|
+
found = databases.get(name)
|
|
91
|
+
if found is None:
|
|
92
|
+
known = ", ".join(sorted(databases)) or "none"
|
|
93
|
+
message = (
|
|
94
|
+
f"no {kind} database named {name!r} was opened; {kind} databases: {known}"
|
|
95
|
+
)
|
|
96
|
+
raise KeyError(message)
|
|
97
|
+
return found
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = (
|
|
101
|
+
"DEFAULT",
|
|
102
|
+
"AsyncDatabase",
|
|
103
|
+
"Databases",
|
|
104
|
+
"SupportsAsyncTransaction",
|
|
105
|
+
"SupportsTransaction",
|
|
106
|
+
"SyncDatabase",
|
|
107
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Schema snapshots as files, and what changed between two of them."""
|
|
2
|
+
|
|
3
|
+
from pyoq.snapshots.drift import (
|
|
4
|
+
SchemaChange,
|
|
5
|
+
SchemaDifference,
|
|
6
|
+
SchemaDriftReport,
|
|
7
|
+
compare_snapshots,
|
|
8
|
+
)
|
|
9
|
+
from pyoq.snapshots.files import read_snapshot, write_snapshot
|
|
10
|
+
from pyoq.snapshots.source import SnapshotSchemaSource
|
|
11
|
+
|
|
12
|
+
__all__ = (
|
|
13
|
+
"SchemaChange",
|
|
14
|
+
"SchemaDifference",
|
|
15
|
+
"SchemaDriftReport",
|
|
16
|
+
"SnapshotSchemaSource",
|
|
17
|
+
"compare_snapshots",
|
|
18
|
+
"read_snapshot",
|
|
19
|
+
"write_snapshot",
|
|
20
|
+
)
|
pyoq/snapshots/drift.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Comparing what a snapshot recorded against what a database now holds.
|
|
2
|
+
|
|
3
|
+
Generated code drifts when the schema moves under it. Comparing two snapshots
|
|
4
|
+
says what moved in terms of the schema rather than of the files, so the answer
|
|
5
|
+
names the table or the column that changed rather than the line that differs.
|
|
6
|
+
|
|
7
|
+
Named things are matched by name and then by value, so a renamed table reads
|
|
8
|
+
as one removed and one added rather than as a change to something that is no
|
|
9
|
+
longer there.
|
|
10
|
+
|
|
11
|
+
Everything else a container holds is compared by standing its named children
|
|
12
|
+
aside and comparing what is left. A comment, a capability, a format version
|
|
13
|
+
and any fact the schema model gains later are all compared that way, so a new
|
|
14
|
+
fact is compared from the day it exists rather than from the day someone
|
|
15
|
+
remembers to add it here.
|
|
16
|
+
|
|
17
|
+
The children are stood aside rather than emptied, because a snapshot without
|
|
18
|
+
a catalog and a table without a column are both refused by the model. One
|
|
19
|
+
fixed stand-in is put in their place, the same one on both sides, so what
|
|
20
|
+
remains is the container's own facts and nothing else.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
26
|
+
from dataclasses import dataclass, replace
|
|
27
|
+
from enum import StrEnum
|
|
28
|
+
from typing import TYPE_CHECKING, Any, Protocol, TypeVar
|
|
29
|
+
|
|
30
|
+
from pyoq.schema.models import (
|
|
31
|
+
Catalog,
|
|
32
|
+
Column,
|
|
33
|
+
Identifier,
|
|
34
|
+
Schema,
|
|
35
|
+
SqlType,
|
|
36
|
+
TypeKind,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from pyoq.schema.models import SchemaSnapshot, Table
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HasName(Protocol):
|
|
44
|
+
"""Anything a schema names, which is everything compared here."""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def name(self) -> Identifier | None: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
Named = TypeVar("Named", bound=HasName)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SchemaChange(StrEnum):
|
|
54
|
+
"""What happened to one named thing between two snapshots."""
|
|
55
|
+
|
|
56
|
+
ADDED = "added"
|
|
57
|
+
REMOVED = "removed"
|
|
58
|
+
CHANGED = "changed"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class SchemaDifference:
|
|
63
|
+
"""One named thing that is not the same in both snapshots."""
|
|
64
|
+
|
|
65
|
+
change: SchemaChange
|
|
66
|
+
kind: str
|
|
67
|
+
name: str
|
|
68
|
+
|
|
69
|
+
def describe(self) -> str:
|
|
70
|
+
"""What changed, and which one, where there is a which one to name.
|
|
71
|
+
|
|
72
|
+
The snapshot itself has no name to give, so it is described by what it
|
|
73
|
+
is rather than followed by nothing.
|
|
74
|
+
"""
|
|
75
|
+
if not self.name:
|
|
76
|
+
return f"{self.change.value} {self.kind}"
|
|
77
|
+
return f"{self.change.value} {self.kind} {self.name}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, slots=True)
|
|
81
|
+
class SchemaDriftReport:
|
|
82
|
+
"""Everything that moved between two snapshots, and whether anything did."""
|
|
83
|
+
|
|
84
|
+
differences: tuple[SchemaDifference, ...]
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def is_current(self) -> bool:
|
|
88
|
+
return not self.differences
|
|
89
|
+
|
|
90
|
+
def summary(self) -> str:
|
|
91
|
+
if self.is_current:
|
|
92
|
+
return "the recorded schema matches the database"
|
|
93
|
+
written = ", ".join(item.describe() for item in self.differences)
|
|
94
|
+
return f"{len(self.differences)} schema difference(s): {written}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def compare_snapshots(
|
|
98
|
+
recorded: SchemaSnapshot,
|
|
99
|
+
observed: SchemaSnapshot,
|
|
100
|
+
/,
|
|
101
|
+
) -> SchemaDriftReport:
|
|
102
|
+
"""What changed between the snapshot on file and the one just read."""
|
|
103
|
+
differences: list[SchemaDifference] = []
|
|
104
|
+
if recorded.dialect is not observed.dialect:
|
|
105
|
+
differences.append(
|
|
106
|
+
SchemaDifference(SchemaChange.CHANGED, "dialect", observed.dialect.value)
|
|
107
|
+
)
|
|
108
|
+
_compare_own(
|
|
109
|
+
differences,
|
|
110
|
+
"snapshot",
|
|
111
|
+
"",
|
|
112
|
+
replace(recorded, catalogs=_STAND_IN_CATALOGS, dialect=observed.dialect),
|
|
113
|
+
replace(observed, catalogs=_STAND_IN_CATALOGS),
|
|
114
|
+
)
|
|
115
|
+
left = _by_name(recorded.catalogs, "")
|
|
116
|
+
right = _by_name(observed.catalogs, "")
|
|
117
|
+
_record_missing(differences, "catalog", left, right)
|
|
118
|
+
for name in _shared(left, right):
|
|
119
|
+
_compare_catalog(differences, name, left[name], right[name])
|
|
120
|
+
return SchemaDriftReport(tuple(differences))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _compare_catalog(
|
|
124
|
+
differences: list[SchemaDifference],
|
|
125
|
+
prefix: str,
|
|
126
|
+
recorded: Catalog,
|
|
127
|
+
observed: Catalog,
|
|
128
|
+
) -> None:
|
|
129
|
+
if recorded == observed:
|
|
130
|
+
return
|
|
131
|
+
_compare_own(
|
|
132
|
+
differences,
|
|
133
|
+
"catalog",
|
|
134
|
+
prefix,
|
|
135
|
+
replace(recorded, schemas=_STAND_IN_SCHEMAS),
|
|
136
|
+
replace(observed, schemas=_STAND_IN_SCHEMAS),
|
|
137
|
+
)
|
|
138
|
+
left = _by_name(recorded.schemas, prefix)
|
|
139
|
+
right = _by_name(observed.schemas, prefix)
|
|
140
|
+
_record_missing(differences, "schema", left, right)
|
|
141
|
+
for name in _shared(left, right):
|
|
142
|
+
_compare_schema(differences, name, left[name], right[name])
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _compare_schema(
|
|
146
|
+
differences: list[SchemaDifference],
|
|
147
|
+
prefix: str,
|
|
148
|
+
recorded: Schema,
|
|
149
|
+
observed: Schema,
|
|
150
|
+
) -> None:
|
|
151
|
+
if recorded == observed:
|
|
152
|
+
return
|
|
153
|
+
_compare_own(
|
|
154
|
+
differences,
|
|
155
|
+
"schema",
|
|
156
|
+
prefix,
|
|
157
|
+
replace(recorded, **_NO_SCHEMA_CHILDREN),
|
|
158
|
+
replace(observed, **_NO_SCHEMA_CHILDREN),
|
|
159
|
+
)
|
|
160
|
+
_compare_tables(differences, prefix, recorded.tables, observed.tables)
|
|
161
|
+
_compare_leaves(differences, "view", prefix, recorded.views, observed.views)
|
|
162
|
+
_compare_leaves(differences, "enum", prefix, recorded.enums, observed.enums)
|
|
163
|
+
_compare_leaves(differences, "domain", prefix, recorded.domains, observed.domains)
|
|
164
|
+
_compare_leaves(
|
|
165
|
+
differences, "routine", prefix, recorded.routines, observed.routines
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _compare_tables(
|
|
170
|
+
differences: list[SchemaDifference],
|
|
171
|
+
prefix: str,
|
|
172
|
+
recorded: Sequence[Table],
|
|
173
|
+
observed: Sequence[Table],
|
|
174
|
+
) -> None:
|
|
175
|
+
left = _by_name(recorded, prefix)
|
|
176
|
+
right = _by_name(observed, prefix)
|
|
177
|
+
_record_missing(differences, "table", left, right)
|
|
178
|
+
for name in _shared(left, right):
|
|
179
|
+
if left[name] == right[name]:
|
|
180
|
+
continue
|
|
181
|
+
_compare_table(differences, name, left[name], right[name])
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _compare_table(
|
|
185
|
+
differences: list[SchemaDifference],
|
|
186
|
+
prefix: str,
|
|
187
|
+
recorded: Table,
|
|
188
|
+
observed: Table,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""A table's named parts by name, and everything else as the table itself."""
|
|
191
|
+
_compare_own(
|
|
192
|
+
differences,
|
|
193
|
+
"table",
|
|
194
|
+
prefix,
|
|
195
|
+
replace(recorded, **_NO_TABLE_CHILDREN),
|
|
196
|
+
replace(observed, **_NO_TABLE_CHILDREN),
|
|
197
|
+
)
|
|
198
|
+
for kind, held in _TABLE_CHILDREN.items():
|
|
199
|
+
_compare_leaves(differences, kind, prefix, held(recorded), held(observed))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _compare_own(
|
|
203
|
+
differences: list[SchemaDifference],
|
|
204
|
+
kind: str,
|
|
205
|
+
name: str,
|
|
206
|
+
recorded: object,
|
|
207
|
+
observed: object,
|
|
208
|
+
) -> None:
|
|
209
|
+
"""What a container holds itself, once its named children are emptied.
|
|
210
|
+
|
|
211
|
+
Comparing the remainder rather than a written list of attributes is what
|
|
212
|
+
keeps this exhaustive: a fact added to the schema model is compared
|
|
213
|
+
without being named here.
|
|
214
|
+
"""
|
|
215
|
+
if recorded == observed:
|
|
216
|
+
return
|
|
217
|
+
differences.append(SchemaDifference(SchemaChange.CHANGED, kind, name))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _compare_leaves(
|
|
221
|
+
differences: list[SchemaDifference],
|
|
222
|
+
kind: str,
|
|
223
|
+
prefix: str,
|
|
224
|
+
recorded: Sequence[Named],
|
|
225
|
+
observed: Sequence[Named],
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Things compared whole, because nothing inside them is named separately."""
|
|
228
|
+
left = _by_name(recorded, prefix)
|
|
229
|
+
right = _by_name(observed, prefix)
|
|
230
|
+
_record_missing(differences, kind, left, right)
|
|
231
|
+
for name in _shared(left, right):
|
|
232
|
+
if left[name] != right[name]:
|
|
233
|
+
differences.append(SchemaDifference(SchemaChange.CHANGED, kind, name))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
_STAND_IN_COLUMNS: tuple[Column, ...] = (
|
|
237
|
+
Column(Identifier("pyoq_stand_in"), SqlType(TypeKind.OTHER, "pyoq"), nullable=True),
|
|
238
|
+
)
|
|
239
|
+
_STAND_IN_SCHEMAS: tuple[Schema, ...] = (Schema(Identifier("pyoq_stand_in")),)
|
|
240
|
+
_STAND_IN_CATALOGS: tuple[Catalog, ...] = (
|
|
241
|
+
Catalog(Identifier("pyoq_stand_in"), _STAND_IN_SCHEMAS),
|
|
242
|
+
)
|
|
243
|
+
"""What stands in for a container's children while its own facts are compared.
|
|
244
|
+
|
|
245
|
+
The same value on both sides, so it cancels out and only the container's own
|
|
246
|
+
facts remain.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
_NO_SCHEMA_CHILDREN: dict[str, Any] = {
|
|
250
|
+
"tables": (),
|
|
251
|
+
"views": (),
|
|
252
|
+
"enums": (),
|
|
253
|
+
"domains": (),
|
|
254
|
+
"routines": (),
|
|
255
|
+
}
|
|
256
|
+
_NO_TABLE_CHILDREN: dict[str, Any] = {
|
|
257
|
+
"columns": _STAND_IN_COLUMNS,
|
|
258
|
+
"keys": (),
|
|
259
|
+
"relations": (),
|
|
260
|
+
"indexes": (),
|
|
261
|
+
"checks": (),
|
|
262
|
+
}
|
|
263
|
+
_TABLE_CHILDREN: dict[str, Callable[[Table], Sequence[HasName]]] = {
|
|
264
|
+
"column": lambda table: table.columns,
|
|
265
|
+
"key": lambda table: table.keys,
|
|
266
|
+
"relation": lambda table: table.relations,
|
|
267
|
+
"index": lambda table: table.indexes,
|
|
268
|
+
"check": lambda table: table.checks,
|
|
269
|
+
}
|
|
270
|
+
"""Everything a table names, so each is reported as itself rather than as the
|
|
271
|
+
table it sits in."""
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _record_missing(
|
|
275
|
+
differences: list[SchemaDifference],
|
|
276
|
+
kind: str,
|
|
277
|
+
recorded: Mapping[str, Named],
|
|
278
|
+
observed: Mapping[str, Named],
|
|
279
|
+
) -> None:
|
|
280
|
+
for name in sorted(set(recorded) - set(observed)):
|
|
281
|
+
differences.append(SchemaDifference(SchemaChange.REMOVED, kind, name))
|
|
282
|
+
for name in sorted(set(observed) - set(recorded)):
|
|
283
|
+
differences.append(SchemaDifference(SchemaChange.ADDED, kind, name))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _shared(
|
|
287
|
+
recorded: Mapping[str, Named],
|
|
288
|
+
observed: Mapping[str, Named],
|
|
289
|
+
) -> tuple[str, ...]:
|
|
290
|
+
return tuple(sorted(set(recorded) & set(observed)))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _by_name(items: Sequence[Named], prefix: str) -> dict[str, Named]:
|
|
294
|
+
"""Named things by their written name, qualified by whatever holds them.
|
|
295
|
+
|
|
296
|
+
A catalog or a schema may be unnamed, and an unnamed one is still a
|
|
297
|
+
position, so it is written as the position it holds.
|
|
298
|
+
"""
|
|
299
|
+
named: dict[str, Named] = {}
|
|
300
|
+
for index, item in enumerate(items):
|
|
301
|
+
written = f"[{index}]" if item.name is None else item.name.value
|
|
302
|
+
named[f"{prefix}.{written}" if prefix else written] = item
|
|
303
|
+
return named
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
__all__ = (
|
|
307
|
+
"HasName",
|
|
308
|
+
"SchemaChange",
|
|
309
|
+
"SchemaDifference",
|
|
310
|
+
"SchemaDriftReport",
|
|
311
|
+
"compare_snapshots",
|
|
312
|
+
)
|
pyoq/snapshots/files.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Reading and writing a schema snapshot as a file.
|
|
2
|
+
|
|
3
|
+
A snapshot is the one canonical record of what a database holds. Written to a
|
|
4
|
+
file it becomes reviewable in a diff and usable without a database, which is
|
|
5
|
+
what lets generation and drift checks run where no credentials exist.
|
|
6
|
+
|
|
7
|
+
The bytes are the codec's, so a snapshot written twice from one database is
|
|
8
|
+
byte for byte the same file and an unrelated change shows as no change.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from contextlib import suppress
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from tempfile import NamedTemporaryFile
|
|
17
|
+
|
|
18
|
+
from pyoq.errors import SchemaSerializationError
|
|
19
|
+
from pyoq.schema.codec import decode_snapshot, encode_snapshot
|
|
20
|
+
from pyoq.schema.models import SchemaSnapshot
|
|
21
|
+
|
|
22
|
+
_ENCODING = "utf-8"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def write_snapshot(snapshot: SchemaSnapshot, path: Path, /) -> Path:
|
|
26
|
+
"""Write a snapshot where a reader and a reviewer can both reach it.
|
|
27
|
+
|
|
28
|
+
Written beside its destination and moved into place, so a reader never
|
|
29
|
+
sees half a snapshot and a failed write leaves the previous one intact.
|
|
30
|
+
|
|
31
|
+
A write can fail while the staged file is being written, while it is being
|
|
32
|
+
closed, or as it is moved. Whichever it is, the staged file goes, so a
|
|
33
|
+
failed recording leaves the directory as it found it rather than beside a
|
|
34
|
+
partial file nobody will claim.
|
|
35
|
+
"""
|
|
36
|
+
destination = path.resolve()
|
|
37
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
staged = _staged(destination, encode_snapshot(snapshot))
|
|
39
|
+
try:
|
|
40
|
+
os.replace(staged, destination)
|
|
41
|
+
except BaseException:
|
|
42
|
+
_discard(staged)
|
|
43
|
+
raise
|
|
44
|
+
return destination
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _staged(destination: Path, written: str, /) -> Path:
|
|
48
|
+
"""The snapshot written beside where it is going, ready to be moved.
|
|
49
|
+
|
|
50
|
+
Closed inside the guard rather than after it, because a write is only
|
|
51
|
+
known to have reached the file once the file is closed, and a failure
|
|
52
|
+
there leaves as much behind as a failure while writing.
|
|
53
|
+
"""
|
|
54
|
+
handle = NamedTemporaryFile( # noqa: SIM115
|
|
55
|
+
"w",
|
|
56
|
+
encoding=_ENCODING,
|
|
57
|
+
newline="\n",
|
|
58
|
+
dir=destination.parent,
|
|
59
|
+
prefix=f".{destination.name}.",
|
|
60
|
+
suffix=".partial",
|
|
61
|
+
delete=False,
|
|
62
|
+
)
|
|
63
|
+
staged = Path(handle.name)
|
|
64
|
+
try:
|
|
65
|
+
with handle:
|
|
66
|
+
handle.write(written)
|
|
67
|
+
handle.write("\n")
|
|
68
|
+
except BaseException:
|
|
69
|
+
_discard(staged)
|
|
70
|
+
raise
|
|
71
|
+
return staged
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _discard(staged: Path, /) -> None:
|
|
75
|
+
"""Take the staged file away, and say nothing about having done so.
|
|
76
|
+
|
|
77
|
+
What went wrong is what the caller is told. A failure to clean up after a
|
|
78
|
+
failure would replace the reason with a second one, so it is not allowed
|
|
79
|
+
to.
|
|
80
|
+
"""
|
|
81
|
+
with suppress(OSError):
|
|
82
|
+
staged.unlink(missing_ok=True)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def read_snapshot(path: Path, /) -> SchemaSnapshot:
|
|
86
|
+
"""The snapshot a file holds, or a precise error saying why it does not."""
|
|
87
|
+
location = path.resolve()
|
|
88
|
+
try:
|
|
89
|
+
written = location.read_text(encoding=_ENCODING)
|
|
90
|
+
except OSError as error:
|
|
91
|
+
message = f"cannot read the schema snapshot at {location}: {error}"
|
|
92
|
+
raise SchemaSerializationError(message) from error
|
|
93
|
+
return decode_snapshot(written)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
__all__ = ("read_snapshot", "write_snapshot")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Choosing where a schema is read from.
|
|
2
|
+
|
|
3
|
+
Generation asks for a snapshot and does not care where it came from. A project
|
|
4
|
+
that records its schema in a file is read from that file, and one that does not
|
|
5
|
+
is read from its database, so the same pipeline serves both and neither knows
|
|
6
|
+
which it got.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from pyoq.schema.registry import DialectRoutingSchemaSource
|
|
15
|
+
from pyoq.snapshots.source import SnapshotSchemaSource
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from pyoq.config import Configuration
|
|
19
|
+
from pyoq.schema.models import SchemaSnapshot
|
|
20
|
+
from pyoq.schema.source import SchemaSource
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_LIVE = DialectRoutingSchemaSource()
|
|
24
|
+
"""The database itself, which is what a project without a snapshot reads."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class ConfiguredSchemaSource:
|
|
29
|
+
"""The recorded schema where a project keeps one, the database otherwise."""
|
|
30
|
+
|
|
31
|
+
live: SchemaSource = _LIVE
|
|
32
|
+
|
|
33
|
+
def load(self, configuration: Configuration) -> SchemaSnapshot:
|
|
34
|
+
recorded = configuration.schema_snapshot_path
|
|
35
|
+
if recorded is None:
|
|
36
|
+
return self.live.load(configuration)
|
|
37
|
+
return SnapshotSchemaSource(recorded).load(configuration)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = ("ConfiguredSchemaSource",)
|