xtr-orm 1.5.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.
- xtr_orm/__init__.py +51 -0
- xtr_orm/bundle/__init__.py +11 -0
- xtr_orm/bundle/connection_config.py +250 -0
- xtr_orm/bundle/orm_bundle.py +346 -0
- xtr_orm/bundle/orm_config.py +101 -0
- xtr_orm/command/__init__.py +56 -0
- xtr_orm/command/connection_command.py +134 -0
- xtr_orm/command/connections.py +37 -0
- xtr_orm/command/database_create_command.py +53 -0
- xtr_orm/command/database_drop_command.py +65 -0
- xtr_orm/command/migrations_current_command.py +44 -0
- xtr_orm/command/migrations_diff_command.py +60 -0
- xtr_orm/command/migrations_dump_schema_command.py +56 -0
- xtr_orm/command/migrations_execute_command.py +86 -0
- xtr_orm/command/migrations_generate_command.py +47 -0
- xtr_orm/command/migrations_latest_command.py +44 -0
- xtr_orm/command/migrations_list_command.py +71 -0
- xtr_orm/command/migrations_migrate_command.py +108 -0
- xtr_orm/command/migrations_rollup_command.py +43 -0
- xtr_orm/command/migrations_status_command.py +66 -0
- xtr_orm/command/migrations_up_to_date_command.py +83 -0
- xtr_orm/command/migrations_version_command.py +89 -0
- xtr_orm/command/run_sql_command.py +77 -0
- xtr_orm/connection_registry.py +192 -0
- xtr_orm/database/__init__.py +7 -0
- xtr_orm/database/database_manager.py +196 -0
- xtr_orm/exception/__init__.py +19 -0
- xtr_orm/exception/invalid_argument_error.py +28 -0
- xtr_orm/exception/migration_error.py +28 -0
- xtr_orm/exception/orm_error.py +14 -0
- xtr_orm/exception/session_unavailable_error.py +29 -0
- xtr_orm/exception/unknown_connection_error.py +28 -0
- xtr_orm/exception/unsupported_database_error.py +29 -0
- xtr_orm/messenger/__init__.py +23 -0
- xtr_orm/messenger/close_connection_middleware.py +75 -0
- xtr_orm/messenger/open_transaction_logger_middleware.py +90 -0
- xtr_orm/messenger/transaction_middleware.py +104 -0
- xtr_orm/migrations/__init__.py +29 -0
- xtr_orm/migrations/_environment.py +188 -0
- xtr_orm/migrations/_history.py +178 -0
- xtr_orm/migrations/_revisions.py +198 -0
- xtr_orm/migrations/_schema.py +66 -0
- xtr_orm/migrations/available_migration.py +27 -0
- xtr_orm/migrations/direction.py +14 -0
- xtr_orm/migrations/executed_migration.py +31 -0
- xtr_orm/migrations/execution_result.py +26 -0
- xtr_orm/migrations/migration_plan.py +26 -0
- xtr_orm/migrations/migration_status.py +48 -0
- xtr_orm/migrations/migrations_config.py +162 -0
- xtr_orm/migrations/migrator.py +680 -0
- xtr_orm/migrations/template/script.py.mako +26 -0
- xtr_orm/py.typed +0 -0
- xtr_orm-1.5.0.dist-info/METADATA +487 -0
- xtr_orm-1.5.0.dist-info/RECORD +57 -0
- xtr_orm-1.5.0.dist-info/WHEEL +4 -0
- xtr_orm-1.5.0.dist-info/entry_points.txt +3 -0
- xtr_orm-1.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""``orm_close_connection``: a worker gives its database connections back after each message."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import final
|
|
7
|
+
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
from xtr_messenger import (
|
|
10
|
+
Envelope,
|
|
11
|
+
MiddlewareInterface,
|
|
12
|
+
ReceivedStamp,
|
|
13
|
+
StackInterface,
|
|
14
|
+
as_middleware,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from xtr_orm.connection_registry import ConnectionRegistry
|
|
18
|
+
from xtr_orm.exception import UnknownConnectionError
|
|
19
|
+
|
|
20
|
+
__all__ = ["CloseConnectionMiddleware"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@final
|
|
24
|
+
@as_middleware("orm_close_connection")
|
|
25
|
+
class CloseConnectionMiddleware(MiddlewareInterface):
|
|
26
|
+
"""Closes connections once a message a worker consumed is handled, saving connections.
|
|
27
|
+
|
|
28
|
+
A worker waiting for its next message then holds none open; the next
|
|
29
|
+
one connects again. A message dispatched in the process that sends it
|
|
30
|
+
closes nothing.
|
|
31
|
+
|
|
32
|
+
A worker handling several messages at once closes them once the last of
|
|
33
|
+
those still being handled is done: closing drops the pool, and dropping
|
|
34
|
+
it after each message would make every other one in flight connect anew.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
__slots__ = ("_connection_names", "_connections", "_consumed")
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
connections: ConnectionRegistry,
|
|
42
|
+
connection_names: str | Sequence[str] = (),
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Close ``connection_names`` — every connection when none is given."""
|
|
45
|
+
self._connections = connections
|
|
46
|
+
self._consumed = 0
|
|
47
|
+
self._connection_names = (
|
|
48
|
+
(connection_names,) if isinstance(connection_names, str) else tuple(connection_names)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@override
|
|
52
|
+
async def handle(self, envelope: Envelope, stack: StackInterface, /) -> Envelope:
|
|
53
|
+
"""Run the rest of the chain, then close the connections if a worker consumed it.
|
|
54
|
+
|
|
55
|
+
Closing waits for every consumed message this middleware is handling
|
|
56
|
+
to be done with.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
UnknownConnectionError: When a connection named is not registered.
|
|
60
|
+
"""
|
|
61
|
+
names = self._connection_names or self._connections.names()
|
|
62
|
+
for name in names:
|
|
63
|
+
if not self._connections.has(name):
|
|
64
|
+
raise UnknownConnectionError(name, self._connections.names())
|
|
65
|
+
if envelope.last(ReceivedStamp) is None:
|
|
66
|
+
return await stack.next().handle(envelope, stack)
|
|
67
|
+
# Counted per instance, on one event loop: no lock needed.
|
|
68
|
+
self._consumed += 1
|
|
69
|
+
try:
|
|
70
|
+
return await stack.next().handle(envelope, stack)
|
|
71
|
+
finally:
|
|
72
|
+
self._consumed -= 1
|
|
73
|
+
if self._consumed == 0:
|
|
74
|
+
for name in names:
|
|
75
|
+
await self._connections.close(name)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""``orm_open_transaction_logger``: say so when a handler leaves a transaction open."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from typing import TYPE_CHECKING, Final, final
|
|
8
|
+
|
|
9
|
+
from sqlalchemy.orm import SessionTransactionOrigin
|
|
10
|
+
from typing_extensions import override
|
|
11
|
+
from xtr_logging_contracts import LoggerInterface, NullLogger
|
|
12
|
+
from xtr_messenger import Envelope, MiddlewareInterface, StackInterface, as_middleware
|
|
13
|
+
|
|
14
|
+
from xtr_orm.connection_registry import ConnectionRegistry
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
18
|
+
|
|
19
|
+
__all__ = ["OpenTransactionLoggerMiddleware"]
|
|
20
|
+
|
|
21
|
+
_OPENED: Final = frozenset({SessionTransactionOrigin.BEGIN, SessionTransactionOrigin.BEGIN_NESTED})
|
|
22
|
+
"""How a transaction a handler opened itself began; the session's own autobegin is not one."""
|
|
23
|
+
|
|
24
|
+
_DISCARD: Final = NullLogger()
|
|
25
|
+
"""Where findings go without a logger."""
|
|
26
|
+
|
|
27
|
+
# Set while a message is checked, so a message dispatched during it is not checked twice.
|
|
28
|
+
_handling: ContextVar[bool] = ContextVar("xtr_orm_open_transaction_logging", default=False)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@final
|
|
32
|
+
@as_middleware("orm_open_transaction_logger")
|
|
33
|
+
class OpenTransactionLoggerMiddleware(MiddlewareInterface):
|
|
34
|
+
"""Logs an error when a handler opened a transaction and did not close it.
|
|
35
|
+
|
|
36
|
+
A transaction counts when a handler began it — ``begin()`` or
|
|
37
|
+
``begin_nested()`` on the unit of work's session — not when the session
|
|
38
|
+
began one by itself for a query.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
__slots__ = ("_connection_names", "_connections", "_logger")
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
connections: ConnectionRegistry,
|
|
46
|
+
logger: LoggerInterface = _DISCARD,
|
|
47
|
+
connection_names: str | Sequence[str] = (),
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Check ``connection_names`` — every connection when none is given — logging to ``logger``.
|
|
50
|
+
|
|
51
|
+
Under a container, ``logger`` is the application's logger when the
|
|
52
|
+
logging bundle is active; otherwise what is found goes nowhere.
|
|
53
|
+
"""
|
|
54
|
+
self._connections = connections
|
|
55
|
+
self._logger = logger
|
|
56
|
+
self._connection_names = (
|
|
57
|
+
(connection_names,) if isinstance(connection_names, str) else tuple(connection_names)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@override
|
|
61
|
+
async def handle(self, envelope: Envelope, stack: StackInterface, /) -> Envelope:
|
|
62
|
+
"""Run the rest of the chain, then log each connection left with a transaction open."""
|
|
63
|
+
if _handling.get():
|
|
64
|
+
return await stack.next().handle(envelope, stack)
|
|
65
|
+
names = self._connection_names or self._connections.names()
|
|
66
|
+
sessions = {name: await self._connections.session(name) for name in names}
|
|
67
|
+
initial = {name: _opened(session) for name, session in sessions.items()}
|
|
68
|
+
token = _handling.set(True)
|
|
69
|
+
try:
|
|
70
|
+
return await stack.next().handle(envelope, stack)
|
|
71
|
+
finally:
|
|
72
|
+
_handling.reset(token)
|
|
73
|
+
left = [name for name, session in sessions.items() if _opened(session) > initial[name]]
|
|
74
|
+
if left:
|
|
75
|
+
self._logger.error(
|
|
76
|
+
"A handler opened a transaction but did not close it.",
|
|
77
|
+
{"connections": left, "message": envelope.message},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _opened(session: AsyncSession) -> int:
|
|
82
|
+
"""Count the transactions open on ``session`` that someone began explicitly."""
|
|
83
|
+
sync = session.sync_session
|
|
84
|
+
transaction = sync.get_nested_transaction() or sync.get_transaction()
|
|
85
|
+
count = 0
|
|
86
|
+
while transaction is not None:
|
|
87
|
+
if transaction.is_active and transaction.origin in _OPENED:
|
|
88
|
+
count += 1
|
|
89
|
+
transaction = transaction.parent
|
|
90
|
+
return count
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""``orm_transaction``: every handler of a message in one database transaction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from typing import TYPE_CHECKING, final
|
|
7
|
+
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
from xtr_messenger import (
|
|
10
|
+
Envelope,
|
|
11
|
+
HandledStamp,
|
|
12
|
+
HandlersFailedError,
|
|
13
|
+
MiddlewareInterface,
|
|
14
|
+
StackInterface,
|
|
15
|
+
as_middleware,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from xtr_orm.connection_registry import ConnectionRegistry
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
22
|
+
|
|
23
|
+
__all__ = ["TransactionMiddleware"]
|
|
24
|
+
|
|
25
|
+
# The sessions a transaction middleware has begun in this context: a message
|
|
26
|
+
# dispatched while another is handled runs inside the outer one's transaction.
|
|
27
|
+
_begun: ContextVar[frozenset[int]] = ContextVar("xtr_orm_transaction_begun", default=frozenset())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@final
|
|
31
|
+
@as_middleware("orm_transaction")
|
|
32
|
+
class TransactionMiddleware(MiddlewareInterface):
|
|
33
|
+
"""Wraps every handler of a message in a single transaction on one connection.
|
|
34
|
+
|
|
35
|
+
The transaction is the session of the message's unit of work, the one
|
|
36
|
+
every handler is given: it is committed — flushing what the handlers
|
|
37
|
+
added — once they all succeeded, and rolled back otherwise. Handlers
|
|
38
|
+
leave committing to it.
|
|
39
|
+
|
|
40
|
+
When a handler fails, the handled stamps of the others are dropped from
|
|
41
|
+
the error's envelope: their work was rolled back too, so a retry runs
|
|
42
|
+
every handler again.
|
|
43
|
+
|
|
44
|
+
A message dispatched while another is handled runs inside that one's
|
|
45
|
+
transaction, in a savepoint of its own: if it fails, what its handlers
|
|
46
|
+
wrote is rolled back to the savepoint — so a handler that catches the
|
|
47
|
+
failure and carries on commits only its own work — and the failure goes
|
|
48
|
+
on to the handler that dispatched it.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
__slots__ = ("_connection_name", "_connections")
|
|
52
|
+
|
|
53
|
+
def __init__(self, connections: ConnectionRegistry, connection_name: str | None = None) -> None:
|
|
54
|
+
"""Run transactions on ``connection_name``, the default connection when ``None``."""
|
|
55
|
+
self._connections = connections
|
|
56
|
+
self._connection_name = connection_name
|
|
57
|
+
|
|
58
|
+
@override
|
|
59
|
+
async def handle(self, envelope: Envelope, stack: StackInterface, /) -> Envelope:
|
|
60
|
+
"""Run the rest of the chain inside a transaction, committed once it all succeeded."""
|
|
61
|
+
session = await self._connections.session(self._connection_name)
|
|
62
|
+
begun = _begun.get()
|
|
63
|
+
if id(session) in begun:
|
|
64
|
+
return await _in_savepoint(session, envelope, stack)
|
|
65
|
+
if not session.in_transaction():
|
|
66
|
+
_ = await session.begin()
|
|
67
|
+
token = _begun.set(begun | {id(session)})
|
|
68
|
+
try:
|
|
69
|
+
handled = await stack.next().handle(envelope, stack)
|
|
70
|
+
await session.commit()
|
|
71
|
+
except HandlersFailedError as error:
|
|
72
|
+
await session.rollback()
|
|
73
|
+
raise _unhandled(error) from error
|
|
74
|
+
except BaseException:
|
|
75
|
+
await session.rollback()
|
|
76
|
+
raise
|
|
77
|
+
finally:
|
|
78
|
+
_begun.reset(token)
|
|
79
|
+
return handled
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def _in_savepoint(
|
|
83
|
+
session: AsyncSession, envelope: Envelope, stack: StackInterface
|
|
84
|
+
) -> Envelope:
|
|
85
|
+
"""Run the rest of the chain in a savepoint of the transaction under way."""
|
|
86
|
+
savepoint = await session.begin_nested()
|
|
87
|
+
try:
|
|
88
|
+
handled = await stack.next().handle(envelope, stack)
|
|
89
|
+
except HandlersFailedError as error:
|
|
90
|
+
if savepoint.is_active:
|
|
91
|
+
await savepoint.rollback()
|
|
92
|
+
raise _unhandled(error) from error
|
|
93
|
+
except BaseException:
|
|
94
|
+
if savepoint.is_active:
|
|
95
|
+
await savepoint.rollback()
|
|
96
|
+
raise
|
|
97
|
+
if savepoint.is_active:
|
|
98
|
+
await savepoint.commit()
|
|
99
|
+
return handled
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _unhandled(error: HandlersFailedError) -> HandlersFailedError:
|
|
103
|
+
"""Return ``error`` without the handled stamps of handlers whose work was rolled back."""
|
|
104
|
+
return HandlersFailedError(error.envelope.without_stamps(HandledStamp), error.errors)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Versioned schema changes: revision files, run and recorded against one database.
|
|
2
|
+
|
|
3
|
+
:class:`Migrator` is the whole surface: it reads the revision files, plans a
|
|
4
|
+
migration, runs it, writes new revisions — blank, diffed against the table
|
|
5
|
+
definitions, or dumped from the database — and keeps the version and
|
|
6
|
+
history tables in step.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .available_migration import AvailableMigration
|
|
12
|
+
from .direction import Direction
|
|
13
|
+
from .executed_migration import ExecutedMigration
|
|
14
|
+
from .execution_result import ExecutionResult
|
|
15
|
+
from .migration_plan import MigrationPlan
|
|
16
|
+
from .migration_status import MigrationStatus
|
|
17
|
+
from .migrations_config import MigrationsConfig
|
|
18
|
+
from .migrator import Migrator
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AvailableMigration",
|
|
22
|
+
"Direction",
|
|
23
|
+
"ExecutedMigration",
|
|
24
|
+
"ExecutionResult",
|
|
25
|
+
"MigrationPlan",
|
|
26
|
+
"MigrationStatus",
|
|
27
|
+
"MigrationsConfig",
|
|
28
|
+
"Migrator",
|
|
29
|
+
]
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""The migration runner, set up in memory for one database.
|
|
2
|
+
|
|
3
|
+
No ``env.py`` and no ``alembic.ini``: the revision directory is built from a
|
|
4
|
+
:class:`MigrationsConfig`, and the runner is handed the connection, the
|
|
5
|
+
table definitions and every option directly, the way a start-up script would
|
|
6
|
+
hand them over.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING, Final, TypeAlias, cast, final
|
|
13
|
+
|
|
14
|
+
from alembic.config import Config
|
|
15
|
+
from alembic.runtime.environment import EnvironmentContext
|
|
16
|
+
from alembic.runtime.migration import MigrationContext
|
|
17
|
+
from alembic.script import ScriptDirectory
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
21
|
+
from typing import IO
|
|
22
|
+
|
|
23
|
+
from alembic.autogenerate import RevisionContext
|
|
24
|
+
from alembic.autogenerate.api import AutogenContext
|
|
25
|
+
from alembic.config import PostWriteHookConfig
|
|
26
|
+
from alembic.runtime.migration import MigrationStep
|
|
27
|
+
from sqlalchemy import MetaData
|
|
28
|
+
from sqlalchemy.engine import Connection
|
|
29
|
+
|
|
30
|
+
from .migrations_config import MigrationsConfig
|
|
31
|
+
|
|
32
|
+
MigrationsFn: TypeAlias = Callable[[tuple[str, ...], MigrationContext], Iterable[MigrationStep]]
|
|
33
|
+
"""What the runner asks for the steps to apply, given the current revisions."""
|
|
34
|
+
|
|
35
|
+
__all__ = ["Environment"]
|
|
36
|
+
|
|
37
|
+
_TEMPLATE_DIRECTORY: Final = Path(__file__).parent / "template"
|
|
38
|
+
"""Where the package's own ``script.py.mako`` is."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@final
|
|
42
|
+
class Environment:
|
|
43
|
+
"""One database's revision directory and runner options."""
|
|
44
|
+
|
|
45
|
+
__slots__ = ("_config", "_metadata")
|
|
46
|
+
|
|
47
|
+
_config: MigrationsConfig
|
|
48
|
+
_metadata: MetaData | Sequence[MetaData] | None
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self, config: MigrationsConfig, metadata: MetaData | Sequence[MetaData] | None
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Set up for the revisions ``config`` describes, diffing against ``metadata``."""
|
|
54
|
+
self._config = config
|
|
55
|
+
self._metadata = metadata
|
|
56
|
+
|
|
57
|
+
def directory(self) -> ScriptDirectory:
|
|
58
|
+
"""Read the revision directory afresh, so files written since are seen."""
|
|
59
|
+
config = self._config
|
|
60
|
+
hooks = cast(
|
|
61
|
+
"list[PostWriteHookConfig]",
|
|
62
|
+
[
|
|
63
|
+
{"_hook_name": f"hook_{index}", **hook}
|
|
64
|
+
for index, hook in enumerate(config.post_write_hooks)
|
|
65
|
+
],
|
|
66
|
+
)
|
|
67
|
+
return ScriptDirectory(
|
|
68
|
+
config.template_directory or _TEMPLATE_DIRECTORY,
|
|
69
|
+
file_template=config.alembic_file_template(),
|
|
70
|
+
truncate_slug_length=config.truncate_slug_length,
|
|
71
|
+
version_locations=[config.directory],
|
|
72
|
+
timezone=config.timezone,
|
|
73
|
+
hooks=hooks,
|
|
74
|
+
recursive_version_locations=config.recursive_version_locations,
|
|
75
|
+
messaging_opts={"quiet": True},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def context(self, connection: Connection) -> MigrationContext:
|
|
79
|
+
"""Return a context reading ``connection``'s version table, for reports."""
|
|
80
|
+
return MigrationContext.configure(
|
|
81
|
+
connection,
|
|
82
|
+
opts={
|
|
83
|
+
"version_table": self._config.version_table,
|
|
84
|
+
"version_table_schema": self._config.version_table_schema,
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def run( # noqa: PLR0913 — every runner option, each passed by keyword
|
|
89
|
+
self,
|
|
90
|
+
connection: Connection,
|
|
91
|
+
directory: ScriptDirectory,
|
|
92
|
+
steps: MigrationsFn,
|
|
93
|
+
*,
|
|
94
|
+
as_sql: bool = False,
|
|
95
|
+
output: IO[str] | None = None,
|
|
96
|
+
starting_rev: tuple[str, ...] | None = None,
|
|
97
|
+
purge: bool = False,
|
|
98
|
+
on_version_apply: Callable[..., None] | None = None,
|
|
99
|
+
revision_context: RevisionContext | None = None,
|
|
100
|
+
read_only: bool = False,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Run ``steps`` against ``connection``, or write their SQL to ``output``.
|
|
103
|
+
|
|
104
|
+
``read_only`` leaves even a missing version table uncreated, for a run
|
|
105
|
+
that only reads — one writing a revision file.
|
|
106
|
+
"""
|
|
107
|
+
config = self._config
|
|
108
|
+
extra: dict[str, object] = {}
|
|
109
|
+
if revision_context is not None:
|
|
110
|
+
extra["revision_context"] = revision_context
|
|
111
|
+
extra["template_args"] = revision_context.template_args
|
|
112
|
+
with EnvironmentContext(
|
|
113
|
+
Config(),
|
|
114
|
+
directory,
|
|
115
|
+
fn=steps,
|
|
116
|
+
as_sql=as_sql,
|
|
117
|
+
starting_rev=list(starting_rev) if as_sql and starting_rev else None,
|
|
118
|
+
purge=purge,
|
|
119
|
+
**extra,
|
|
120
|
+
) as environment:
|
|
121
|
+
options: dict[str, object] = {
|
|
122
|
+
"connection": connection,
|
|
123
|
+
"target_metadata": self._metadata,
|
|
124
|
+
"version_table": config.version_table,
|
|
125
|
+
"version_table_schema": config.version_table_schema,
|
|
126
|
+
"transaction_per_migration": config.transaction_per_migration,
|
|
127
|
+
"compare_type": config.compare_type,
|
|
128
|
+
"compare_server_default": config.compare_server_default,
|
|
129
|
+
"render_as_batch": config.render_as_batch,
|
|
130
|
+
"include_schemas": config.include_schemas,
|
|
131
|
+
"include_name": _include_name(config.include_name, config.history_table),
|
|
132
|
+
"include_object": config.include_object,
|
|
133
|
+
"render_item": _render_item(config.render_item, config.user_module_prefix),
|
|
134
|
+
"process_revision_directives": config.process_revision_directives,
|
|
135
|
+
"user_module_prefix": config.user_module_prefix,
|
|
136
|
+
"literal_binds": as_sql,
|
|
137
|
+
"output_buffer": output,
|
|
138
|
+
"on_version_apply": on_version_apply,
|
|
139
|
+
"dont_mutate": read_only,
|
|
140
|
+
}
|
|
141
|
+
if config.transactional_ddl is not None:
|
|
142
|
+
options["transactional_ddl"] = config.transactional_ddl
|
|
143
|
+
options.update(config.context_options)
|
|
144
|
+
# Forwarded by name, the application's own options included; the
|
|
145
|
+
# runner checks each as it reads it.
|
|
146
|
+
configure = cast("Callable[..., None]", environment.configure)
|
|
147
|
+
configure(**options)
|
|
148
|
+
with environment.begin_transaction():
|
|
149
|
+
environment.run_migrations()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _include_name(
|
|
153
|
+
custom: Callable[..., bool] | None, history_table: str
|
|
154
|
+
) -> Callable[[str | None, str, dict[str, str | None]], bool]:
|
|
155
|
+
"""Keep the history table out of every diff, as the runner keeps its version table out.
|
|
156
|
+
|
|
157
|
+
Any configured filter decides for every other name.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def include_name(name: str | None, kind: str, parents: dict[str, str | None]) -> bool:
|
|
161
|
+
if kind == "table" and name == history_table:
|
|
162
|
+
return False
|
|
163
|
+
return custom is None or custom(name, kind, parents)
|
|
164
|
+
|
|
165
|
+
return include_name
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _render_item(
|
|
169
|
+
custom: Callable[..., str | bool] | None, user_module_prefix: str | None
|
|
170
|
+
) -> Callable[[str, object, AutogenContext], str | bool]:
|
|
171
|
+
"""Chain the configured renderer with one importing each custom type's module.
|
|
172
|
+
|
|
173
|
+
A type from outside the database library is written under its own
|
|
174
|
+
module's name; the revision imports that module so the name resolves.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def render_item(kind: str, value: object, context: AutogenContext) -> str | bool:
|
|
178
|
+
if custom is not None:
|
|
179
|
+
rendered = custom(kind, value, context)
|
|
180
|
+
if rendered is not False:
|
|
181
|
+
return rendered
|
|
182
|
+
if kind == "type" and user_module_prefix is None:
|
|
183
|
+
module = type(value).__module__
|
|
184
|
+
if not module.startswith("sqlalchemy.") and module != "builtins":
|
|
185
|
+
context.imports.add(f"import {module}")
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
return render_item
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""The history table: a row per revision applied, beside the version table's current ones.
|
|
2
|
+
|
|
3
|
+
The version table holds only the revisions a database is *at*; the migration
|
|
4
|
+
runner rewrites it as it goes. The history table keeps what that table
|
|
5
|
+
forgets — every revision applied, when, and how long it took — so a report
|
|
6
|
+
can name each applied revision, including one whose file is gone.
|
|
7
|
+
|
|
8
|
+
A row is written by the step that applies its revision, in the same
|
|
9
|
+
transaction, so the two tables agree wherever the database's transactions
|
|
10
|
+
reach.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from typing import TYPE_CHECKING, Final, cast, final
|
|
18
|
+
|
|
19
|
+
from sqlalchemy import (
|
|
20
|
+
Column,
|
|
21
|
+
DateTime,
|
|
22
|
+
Integer,
|
|
23
|
+
MetaData,
|
|
24
|
+
String,
|
|
25
|
+
Table,
|
|
26
|
+
delete,
|
|
27
|
+
func,
|
|
28
|
+
insert,
|
|
29
|
+
select,
|
|
30
|
+
)
|
|
31
|
+
from sqlalchemy import inspect as inspect_connection
|
|
32
|
+
from sqlalchemy.schema import CreateTable
|
|
33
|
+
|
|
34
|
+
from .direction import Direction
|
|
35
|
+
from .execution_result import ExecutionResult
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from collections.abc import Iterable, Mapping
|
|
39
|
+
|
|
40
|
+
from alembic.runtime.migration import MigrationContext, MigrationInfo
|
|
41
|
+
from sqlalchemy.engine import Connection
|
|
42
|
+
|
|
43
|
+
__all__ = ["HistoryRecorder", "HistoryTable"]
|
|
44
|
+
|
|
45
|
+
_VERSION_LENGTH: Final = 255
|
|
46
|
+
_MILLISECONDS: Final = 1000
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@final
|
|
50
|
+
class HistoryTable:
|
|
51
|
+
"""The history table of one database: its definition, and how it is read and written."""
|
|
52
|
+
|
|
53
|
+
__slots__ = ("table",)
|
|
54
|
+
|
|
55
|
+
table: Table
|
|
56
|
+
|
|
57
|
+
def __init__(self, name: str, schema: str | None) -> None:
|
|
58
|
+
"""Describe the table ``name`` in ``schema``."""
|
|
59
|
+
self.table = Table(
|
|
60
|
+
name,
|
|
61
|
+
MetaData(),
|
|
62
|
+
Column("version", String(_VERSION_LENGTH), primary_key=True),
|
|
63
|
+
Column("executed_at", DateTime(timezone=True), nullable=True),
|
|
64
|
+
Column("execution_time", Integer, nullable=True),
|
|
65
|
+
schema=schema,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def exists(self, connection: Connection) -> bool:
|
|
69
|
+
"""Whether the database has the table."""
|
|
70
|
+
return inspect_connection(connection).has_table(self.table.name, self.table.schema)
|
|
71
|
+
|
|
72
|
+
def read(self, connection: Connection) -> dict[str, tuple[datetime | None, float | None]]:
|
|
73
|
+
"""Return when each recorded revision was applied and how long it took, in seconds."""
|
|
74
|
+
if not self.exists(connection):
|
|
75
|
+
return {}
|
|
76
|
+
columns = self.table.c
|
|
77
|
+
rows = connection.execute(
|
|
78
|
+
select(columns.version, columns.executed_at, columns.execution_time),
|
|
79
|
+
)
|
|
80
|
+
found: dict[str, tuple[datetime | None, float | None]] = {}
|
|
81
|
+
for row in rows:
|
|
82
|
+
version, executed_at, execution_time = cast(
|
|
83
|
+
"tuple[str, object, int | None]", tuple(row)
|
|
84
|
+
)
|
|
85
|
+
found[version] = (
|
|
86
|
+
_as_datetime(executed_at),
|
|
87
|
+
None if execution_time is None else execution_time / _MILLISECONDS,
|
|
88
|
+
)
|
|
89
|
+
return found
|
|
90
|
+
|
|
91
|
+
def ensure(self, context: MigrationContext, connection: Connection) -> None:
|
|
92
|
+
"""Create the table through ``context`` unless ``connection`` shows it exists.
|
|
93
|
+
|
|
94
|
+
Through the context, so in SQL-only mode the statement is written
|
|
95
|
+
with the others instead of run.
|
|
96
|
+
"""
|
|
97
|
+
if not self.exists(connection):
|
|
98
|
+
context.execute(CreateTable(self.table))
|
|
99
|
+
|
|
100
|
+
def add(self, context: MigrationContext, version: str, duration: float | None) -> None:
|
|
101
|
+
"""Record ``version`` as applied now, having taken ``duration`` seconds."""
|
|
102
|
+
self.remove(context, (version,))
|
|
103
|
+
context.execute(
|
|
104
|
+
insert(self.table).values(
|
|
105
|
+
version=version,
|
|
106
|
+
executed_at=func.current_timestamp(),
|
|
107
|
+
execution_time=None if duration is None else round(duration * _MILLISECONDS),
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def remove(self, context: MigrationContext, versions: Iterable[str]) -> None:
|
|
112
|
+
"""Forget ``versions``."""
|
|
113
|
+
for version in versions:
|
|
114
|
+
context.execute(delete(self.table).where(self.table.c.version == version))
|
|
115
|
+
|
|
116
|
+
def clear(self, context: MigrationContext) -> None:
|
|
117
|
+
"""Forget every revision."""
|
|
118
|
+
context.execute(delete(self.table))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@final
|
|
122
|
+
class HistoryRecorder:
|
|
123
|
+
"""Writes a history row for every step the runner applies, and times each one.
|
|
124
|
+
|
|
125
|
+
The runner calls it after each step, inside the step's transaction. The
|
|
126
|
+
steps it times are handed out through :meth:`started`, which the planned
|
|
127
|
+
steps' generator calls just before giving the runner each one.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
__slots__ = ("_history", "_marking", "_results", "_started")
|
|
131
|
+
|
|
132
|
+
_history: HistoryTable
|
|
133
|
+
_marking: bool
|
|
134
|
+
_results: list[ExecutionResult]
|
|
135
|
+
_started: float
|
|
136
|
+
|
|
137
|
+
def __init__(self, history: HistoryTable, *, marking: bool = False) -> None:
|
|
138
|
+
"""Record into ``history``; ``marking`` steps change the tables without running."""
|
|
139
|
+
self._history = history
|
|
140
|
+
self._marking = marking
|
|
141
|
+
self._results = []
|
|
142
|
+
self._started = time.perf_counter()
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def results(self) -> tuple[ExecutionResult, ...]:
|
|
146
|
+
"""Every step recorded so far, in the order it ran."""
|
|
147
|
+
return tuple(self._results)
|
|
148
|
+
|
|
149
|
+
def started(self) -> None:
|
|
150
|
+
"""Note that the next step starts now."""
|
|
151
|
+
self._started = time.perf_counter()
|
|
152
|
+
|
|
153
|
+
def __call__(
|
|
154
|
+
self,
|
|
155
|
+
*,
|
|
156
|
+
ctx: MigrationContext,
|
|
157
|
+
step: MigrationInfo,
|
|
158
|
+
heads: set[str],
|
|
159
|
+
run_args: Mapping[str, object],
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Record ``step``, which the runner has just applied."""
|
|
162
|
+
del heads, run_args
|
|
163
|
+
duration = time.perf_counter() - self._started
|
|
164
|
+
versions = step.up_revision_ids
|
|
165
|
+
if step.is_upgrade:
|
|
166
|
+
for version in versions:
|
|
167
|
+
self._history.add(ctx, version, None if self._marking else duration)
|
|
168
|
+
else:
|
|
169
|
+
self._history.remove(ctx, versions)
|
|
170
|
+
direction = Direction.UP if step.is_upgrade else Direction.DOWN
|
|
171
|
+
self._results.extend(ExecutionResult(version, direction, duration) for version in versions)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _as_datetime(value: object) -> datetime | None:
|
|
175
|
+
"""Read a timestamp column, which some drivers hand back as text."""
|
|
176
|
+
if value is None or isinstance(value, datetime):
|
|
177
|
+
return value
|
|
178
|
+
return datetime.fromisoformat(str(value))
|