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.
Files changed (57) hide show
  1. xtr_orm/__init__.py +51 -0
  2. xtr_orm/bundle/__init__.py +11 -0
  3. xtr_orm/bundle/connection_config.py +250 -0
  4. xtr_orm/bundle/orm_bundle.py +346 -0
  5. xtr_orm/bundle/orm_config.py +101 -0
  6. xtr_orm/command/__init__.py +56 -0
  7. xtr_orm/command/connection_command.py +134 -0
  8. xtr_orm/command/connections.py +37 -0
  9. xtr_orm/command/database_create_command.py +53 -0
  10. xtr_orm/command/database_drop_command.py +65 -0
  11. xtr_orm/command/migrations_current_command.py +44 -0
  12. xtr_orm/command/migrations_diff_command.py +60 -0
  13. xtr_orm/command/migrations_dump_schema_command.py +56 -0
  14. xtr_orm/command/migrations_execute_command.py +86 -0
  15. xtr_orm/command/migrations_generate_command.py +47 -0
  16. xtr_orm/command/migrations_latest_command.py +44 -0
  17. xtr_orm/command/migrations_list_command.py +71 -0
  18. xtr_orm/command/migrations_migrate_command.py +108 -0
  19. xtr_orm/command/migrations_rollup_command.py +43 -0
  20. xtr_orm/command/migrations_status_command.py +66 -0
  21. xtr_orm/command/migrations_up_to_date_command.py +83 -0
  22. xtr_orm/command/migrations_version_command.py +89 -0
  23. xtr_orm/command/run_sql_command.py +77 -0
  24. xtr_orm/connection_registry.py +192 -0
  25. xtr_orm/database/__init__.py +7 -0
  26. xtr_orm/database/database_manager.py +196 -0
  27. xtr_orm/exception/__init__.py +19 -0
  28. xtr_orm/exception/invalid_argument_error.py +28 -0
  29. xtr_orm/exception/migration_error.py +28 -0
  30. xtr_orm/exception/orm_error.py +14 -0
  31. xtr_orm/exception/session_unavailable_error.py +29 -0
  32. xtr_orm/exception/unknown_connection_error.py +28 -0
  33. xtr_orm/exception/unsupported_database_error.py +29 -0
  34. xtr_orm/messenger/__init__.py +23 -0
  35. xtr_orm/messenger/close_connection_middleware.py +75 -0
  36. xtr_orm/messenger/open_transaction_logger_middleware.py +90 -0
  37. xtr_orm/messenger/transaction_middleware.py +104 -0
  38. xtr_orm/migrations/__init__.py +29 -0
  39. xtr_orm/migrations/_environment.py +188 -0
  40. xtr_orm/migrations/_history.py +178 -0
  41. xtr_orm/migrations/_revisions.py +198 -0
  42. xtr_orm/migrations/_schema.py +66 -0
  43. xtr_orm/migrations/available_migration.py +27 -0
  44. xtr_orm/migrations/direction.py +14 -0
  45. xtr_orm/migrations/executed_migration.py +31 -0
  46. xtr_orm/migrations/execution_result.py +26 -0
  47. xtr_orm/migrations/migration_plan.py +26 -0
  48. xtr_orm/migrations/migration_status.py +48 -0
  49. xtr_orm/migrations/migrations_config.py +162 -0
  50. xtr_orm/migrations/migrator.py +680 -0
  51. xtr_orm/migrations/template/script.py.mako +26 -0
  52. xtr_orm/py.typed +0 -0
  53. xtr_orm-1.5.0.dist-info/METADATA +487 -0
  54. xtr_orm-1.5.0.dist-info/RECORD +57 -0
  55. xtr_orm-1.5.0.dist-info/WHEEL +4 -0
  56. xtr_orm-1.5.0.dist-info/entry_points.txt +3 -0
  57. 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))