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,198 @@
|
|
|
1
|
+
"""Reading the revision files: what exists, what is applied, and what a target means.
|
|
2
|
+
|
|
3
|
+
Everything here works on a revision directory and a database's current
|
|
4
|
+
revisions, and touches no database. The runner is handed the steps these
|
|
5
|
+
functions return and applies them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from typing import TYPE_CHECKING, Final
|
|
13
|
+
|
|
14
|
+
from alembic.runtime.migration import MigrationStep, RevisionStep
|
|
15
|
+
from alembic.script.revision import ResolutionError, RevisionError
|
|
16
|
+
from alembic.util import CommandError
|
|
17
|
+
|
|
18
|
+
from xtr_orm.exception import MigrationError
|
|
19
|
+
|
|
20
|
+
from .available_migration import AvailableMigration
|
|
21
|
+
from .direction import Direction
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Generator, Iterable, Sequence
|
|
25
|
+
|
|
26
|
+
from alembic.script import Script, ScriptDirectory
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"applied",
|
|
30
|
+
"available",
|
|
31
|
+
"known",
|
|
32
|
+
"mark_step",
|
|
33
|
+
"migration",
|
|
34
|
+
"parents",
|
|
35
|
+
"plan",
|
|
36
|
+
"refusing",
|
|
37
|
+
"resolve",
|
|
38
|
+
"revision_steps",
|
|
39
|
+
"step",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
_ALIASES: Final[dict[str, tuple[str, Direction]]] = {
|
|
43
|
+
"latest": ("heads", Direction.UP),
|
|
44
|
+
"head": ("heads", Direction.UP),
|
|
45
|
+
"heads": ("heads", Direction.UP),
|
|
46
|
+
"next": ("+1", Direction.UP),
|
|
47
|
+
"first": ("base", Direction.DOWN),
|
|
48
|
+
"base": ("base", Direction.DOWN),
|
|
49
|
+
"prev": ("-1", Direction.DOWN),
|
|
50
|
+
}
|
|
51
|
+
"""Names a target may be given by, and the revision and direction each stands for."""
|
|
52
|
+
|
|
53
|
+
_RELATIVE: Final = re.compile(r"^(?:.+?@)?\w*([+-])\d+$")
|
|
54
|
+
"""``+2``, ``-1``, ``ae10+1``, ``branch@head-1``: a number of steps from somewhere."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def migration(script: Script) -> AvailableMigration:
|
|
58
|
+
"""Describe ``script`` as the rest of the library sees a revision file."""
|
|
59
|
+
return AvailableMigration(
|
|
60
|
+
version=script.revision,
|
|
61
|
+
description=(script.doc or "").strip(),
|
|
62
|
+
down_versions=_as_tuple(script.down_revision),
|
|
63
|
+
path=script.path,
|
|
64
|
+
is_head=script.is_head,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def available(directory: ScriptDirectory) -> tuple[Script, ...]:
|
|
69
|
+
"""Return every revision in ``directory``, earliest first."""
|
|
70
|
+
with refusing():
|
|
71
|
+
return tuple(reversed(list(directory.walk_revisions())))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def known(directory: ScriptDirectory, versions: Iterable[str]) -> tuple[str, ...]:
|
|
75
|
+
"""Return the ``versions`` that have a revision file."""
|
|
76
|
+
found: list[str] = []
|
|
77
|
+
for version in versions:
|
|
78
|
+
try:
|
|
79
|
+
_ = directory.get_revision(version)
|
|
80
|
+
except (CommandError, ResolutionError, RevisionError):
|
|
81
|
+
continue
|
|
82
|
+
found.append(version)
|
|
83
|
+
return tuple(found)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def applied(directory: ScriptDirectory, heads: Sequence[str]) -> set[str]:
|
|
87
|
+
"""Return every revision ``heads`` stand for: themselves and all they follow.
|
|
88
|
+
|
|
89
|
+
A head without a file stands only for itself; what it followed cannot be
|
|
90
|
+
known.
|
|
91
|
+
"""
|
|
92
|
+
present = known(directory, heads)
|
|
93
|
+
if not present:
|
|
94
|
+
return set()
|
|
95
|
+
with refusing():
|
|
96
|
+
return {each.revision for each in directory.iterate_revisions(present, "base")}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def resolve(directory: ScriptDirectory, version: str) -> Script:
|
|
100
|
+
"""Return the one revision ``version`` names — a full or partial id, or a label.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
MigrationError: When no revision, or more than one, answers to it.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
found = directory.get_revision(version)
|
|
107
|
+
except (CommandError, ResolutionError, RevisionError) as error:
|
|
108
|
+
raise MigrationError(f'Unknown version "{version}": {error}') from error
|
|
109
|
+
return found
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parents(directory: ScriptDirectory, script: Script) -> tuple[str, ...]:
|
|
113
|
+
"""Return the revisions ``script`` needs applied first: those it follows and depends on."""
|
|
114
|
+
dependencies = (resolve(directory, each).revision for each in _as_tuple(script.dependencies))
|
|
115
|
+
return (*_as_tuple(script.down_revision), *dependencies)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def plan(
|
|
119
|
+
directory: ScriptDirectory, heads: tuple[str, ...], target: str
|
|
120
|
+
) -> tuple[Direction, list[RevisionStep]]:
|
|
121
|
+
"""Return which way reaching ``target`` from ``heads`` goes, and the steps it takes.
|
|
122
|
+
|
|
123
|
+
``target`` is a revision, a relative one (``+1``, ``-2``, ``ae10+1``), or
|
|
124
|
+
one of ``latest``, ``first``, ``next``, ``prev`` and ``current``.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
MigrationError: When the target is unknown, or cannot be reached.
|
|
128
|
+
"""
|
|
129
|
+
if target == "current":
|
|
130
|
+
return Direction.UP, []
|
|
131
|
+
destination, direction = _ALIASES.get(target, (target, Direction.UP))
|
|
132
|
+
if target not in _ALIASES:
|
|
133
|
+
relative = _RELATIVE.match(target)
|
|
134
|
+
if relative is not None:
|
|
135
|
+
direction = Direction.UP if relative.group(1) == "+" else Direction.DOWN
|
|
136
|
+
elif resolve(directory, target).revision in applied(directory, heads):
|
|
137
|
+
direction = Direction.DOWN
|
|
138
|
+
return direction, revision_steps(directory, heads, destination, direction)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def revision_steps(
|
|
142
|
+
directory: ScriptDirectory, heads: tuple[str, ...], destination: str, direction: Direction
|
|
143
|
+
) -> list[RevisionStep]:
|
|
144
|
+
"""Return the steps from ``heads`` to ``destination``, going ``direction``.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
MigrationError: When the destination cannot be reached that way.
|
|
148
|
+
"""
|
|
149
|
+
revision_map = directory.revision_map
|
|
150
|
+
with refusing():
|
|
151
|
+
if direction is Direction.UP:
|
|
152
|
+
scripts = reversed(
|
|
153
|
+
list(directory.iterate_revisions(destination, heads, implicit_base=True))
|
|
154
|
+
)
|
|
155
|
+
return [MigrationStep.upgrade_from_script(revision_map, each) for each in scripts]
|
|
156
|
+
scripts = directory.iterate_revisions(heads, destination, select_for_downgrade=True)
|
|
157
|
+
return [MigrationStep.downgrade_from_script(revision_map, each) for each in scripts]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def step(directory: ScriptDirectory, script: Script, direction: Direction) -> RevisionStep:
|
|
161
|
+
"""Return the step running ``script``'s upgrade or downgrade alone."""
|
|
162
|
+
if direction is Direction.UP:
|
|
163
|
+
return MigrationStep.upgrade_from_script(directory.revision_map, script)
|
|
164
|
+
return MigrationStep.downgrade_from_script(directory.revision_map, script)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def mark_step(directory: ScriptDirectory, script: Script, direction: Direction) -> RevisionStep:
|
|
168
|
+
"""Return a step that moves the version table over ``script`` without running it."""
|
|
169
|
+
marked = step(directory, script, direction)
|
|
170
|
+
marked.migration_fn = _nothing
|
|
171
|
+
return marked
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _nothing(**_: object) -> None:
|
|
175
|
+
"""Stand in for a revision's upgrade or downgrade when it is only marked."""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _as_tuple(value: str | Sequence[str] | None) -> tuple[str, ...]:
|
|
179
|
+
if value is None:
|
|
180
|
+
return ()
|
|
181
|
+
if isinstance(value, str):
|
|
182
|
+
return (value,)
|
|
183
|
+
return tuple(value)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@contextmanager
|
|
187
|
+
def refusing() -> Generator[None]:
|
|
188
|
+
"""Turn the revision reader's own errors into :class:`MigrationError`."""
|
|
189
|
+
try:
|
|
190
|
+
yield
|
|
191
|
+
except (CommandError, ResolutionError, RevisionError) as error:
|
|
192
|
+
raise MigrationError(str(error)) from error
|
|
193
|
+
except KeyError as error:
|
|
194
|
+
# The reader indexes its map by the ids revisions name; one naming a
|
|
195
|
+
# revision that has no file surfaces as the missing key.
|
|
196
|
+
raise MigrationError(
|
|
197
|
+
f"A revision follows {error.args[0]!r}, which has no revision file.",
|
|
198
|
+
) from error
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Revisions written from table definitions rather than from a comparison.
|
|
2
|
+
|
|
3
|
+
A dump of an existing database, or a diff "from an empty schema", both come
|
|
4
|
+
down to one revision creating every table — and its downgrade dropping them
|
|
5
|
+
again — written through the same renderer a compared diff uses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from alembic.operations.ops import (
|
|
14
|
+
CreateIndexOp,
|
|
15
|
+
CreateTableOp,
|
|
16
|
+
DowngradeOps,
|
|
17
|
+
DropIndexOp,
|
|
18
|
+
DropTableOp,
|
|
19
|
+
UpgradeOps,
|
|
20
|
+
)
|
|
21
|
+
from sqlalchemy import MetaData
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Iterable, Sequence
|
|
25
|
+
|
|
26
|
+
from sqlalchemy import Index, Table
|
|
27
|
+
|
|
28
|
+
__all__ = ["creating", "matching", "tables_of"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def tables_of(metadata: MetaData | Sequence[MetaData] | None) -> list[Table]:
|
|
32
|
+
"""Return every table ``metadata`` defines, each after the tables it refers to."""
|
|
33
|
+
if metadata is None:
|
|
34
|
+
return []
|
|
35
|
+
each = [metadata] if isinstance(metadata, MetaData) else metadata
|
|
36
|
+
return [table for one in each for table in one.sorted_tables]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def matching(tables: Iterable[Table], patterns: Sequence[str]) -> list[Table]:
|
|
40
|
+
"""Return the ``tables`` whose name one of ``patterns`` finds; all of them with none."""
|
|
41
|
+
if not patterns:
|
|
42
|
+
return list(tables)
|
|
43
|
+
compiled = [re.compile(pattern) for pattern in patterns]
|
|
44
|
+
return [table for table in tables if any(each.search(table.name) for each in compiled)]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def creating(tables: Sequence[Table]) -> tuple[UpgradeOps, DowngradeOps]:
|
|
48
|
+
"""Return operations creating ``tables`` with their indexes, and dropping them again."""
|
|
49
|
+
indexes = [index for table in tables for index in _indexes(table)]
|
|
50
|
+
upgrade = UpgradeOps(
|
|
51
|
+
[
|
|
52
|
+
*(CreateTableOp.from_table(table) for table in tables),
|
|
53
|
+
*(CreateIndexOp.from_index(index) for index in indexes),
|
|
54
|
+
],
|
|
55
|
+
)
|
|
56
|
+
downgrade = DowngradeOps(
|
|
57
|
+
[
|
|
58
|
+
*(DropIndexOp.from_index(index) for index in reversed(indexes)),
|
|
59
|
+
*(DropTableOp.from_table(table) for table in reversed(tables)),
|
|
60
|
+
],
|
|
61
|
+
)
|
|
62
|
+
return upgrade, downgrade
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _indexes(table: Table) -> list[Index]:
|
|
66
|
+
return sorted(table.indexes, key=lambda index: str(index.name))
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""A revision file, as it was found in the migrations directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
__all__ = ["AvailableMigration"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class AvailableMigration:
|
|
12
|
+
"""A revision file, as it was found in the migrations directory.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
version: The revision's identifier.
|
|
16
|
+
description: The first line of its message.
|
|
17
|
+
down_versions: The revisions it follows; empty for a first revision,
|
|
18
|
+
two or more for one merging branches.
|
|
19
|
+
path: Where its file is.
|
|
20
|
+
is_head: Whether no revision follows it.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
version: str
|
|
24
|
+
description: str
|
|
25
|
+
down_versions: tuple[str, ...]
|
|
26
|
+
path: str
|
|
27
|
+
is_head: bool
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Which way a revision runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
__all__ = ["Direction"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Direction(Enum):
|
|
11
|
+
"""Which way a revision runs: its upgrade, or its downgrade."""
|
|
12
|
+
|
|
13
|
+
UP = "up"
|
|
14
|
+
DOWN = "down"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""A revision the database counts as applied."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
__all__ = ["ExecutedMigration"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class ExecutedMigration:
|
|
16
|
+
"""A revision the database counts as applied.
|
|
17
|
+
|
|
18
|
+
Applied means recorded in the version table, preceding a revision that
|
|
19
|
+
is, or recorded in the history table. When and how long come from the
|
|
20
|
+
history table: a revision applied before it existed has neither, and one
|
|
21
|
+
only marked as applied has no duration.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
version: The revision's identifier.
|
|
25
|
+
executed_at: When it was applied, by the database's clock.
|
|
26
|
+
execution_time: How long it took, in seconds.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
version: str
|
|
30
|
+
executed_at: datetime | None = None
|
|
31
|
+
execution_time: float | None = None
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""One revision a migration ran, and how long it took."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .direction import Direction
|
|
10
|
+
|
|
11
|
+
__all__ = ["ExecutionResult"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class ExecutionResult:
|
|
16
|
+
"""One revision a migration ran, and how long it took.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
version: The revision's identifier.
|
|
20
|
+
direction: Whether its upgrade or its downgrade ran.
|
|
21
|
+
duration: How long it took, in seconds.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
direction: Direction
|
|
26
|
+
duration: float
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""One revision a migration would run, and which way."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .direction import Direction
|
|
10
|
+
|
|
11
|
+
__all__ = ["MigrationPlan"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class MigrationPlan:
|
|
16
|
+
"""One revision a migration would run, and which way.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
version: The revision's identifier.
|
|
20
|
+
direction: Whether its upgrade or its downgrade runs.
|
|
21
|
+
description: The first line of its message.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
direction: Direction
|
|
26
|
+
description: str
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Where a database stands against its revision files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .available_migration import AvailableMigration
|
|
10
|
+
from .executed_migration import ExecutedMigration
|
|
11
|
+
|
|
12
|
+
__all__ = ["MigrationStatus"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class MigrationStatus:
|
|
17
|
+
"""Where a database stands against its revision files, read in one go.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
current: The revisions the version table holds; empty when nothing
|
|
21
|
+
is applied.
|
|
22
|
+
latest: The revisions no other follows.
|
|
23
|
+
previous: The revisions the current ones follow.
|
|
24
|
+
next: The revisions that could run next, their predecessors all
|
|
25
|
+
applied.
|
|
26
|
+
available: Every revision file, earliest first.
|
|
27
|
+
executed: Every revision counted as applied — those with a file,
|
|
28
|
+
earliest first, then those without. A revision is applied when
|
|
29
|
+
the version table holds it or one following it, or the history
|
|
30
|
+
table records it.
|
|
31
|
+
new: The revisions with a file that are not applied.
|
|
32
|
+
executed_unavailable: The revisions recorded as applied that have no
|
|
33
|
+
file.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
current: tuple[str, ...]
|
|
37
|
+
latest: tuple[str, ...]
|
|
38
|
+
previous: tuple[str, ...]
|
|
39
|
+
next: tuple[str, ...]
|
|
40
|
+
available: tuple[AvailableMigration, ...]
|
|
41
|
+
executed: tuple[ExecutedMigration, ...]
|
|
42
|
+
new: tuple[AvailableMigration, ...]
|
|
43
|
+
executed_unavailable: tuple[ExecutedMigration, ...]
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def is_up_to_date(self) -> bool:
|
|
47
|
+
"""Whether every revision with a file is applied."""
|
|
48
|
+
return not self.new
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""How one database's migrations are written, found and recorded."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING, Final, cast
|
|
8
|
+
|
|
9
|
+
from xtr_orm.exception import InvalidArgumentError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
13
|
+
|
|
14
|
+
__all__ = ["MigrationsConfig"]
|
|
15
|
+
|
|
16
|
+
_FILE_TEMPLATE_FIELDS: Final = {
|
|
17
|
+
"rev": "%(rev)s",
|
|
18
|
+
"slug": "%(slug)s",
|
|
19
|
+
"epoch": "%(epoch)s",
|
|
20
|
+
"year": "%(year)d",
|
|
21
|
+
"month": "%(month).2d",
|
|
22
|
+
"day": "%(day).2d",
|
|
23
|
+
"hour": "%(hour).2d",
|
|
24
|
+
"minute": "%(minute).2d",
|
|
25
|
+
"second": "%(second).2d",
|
|
26
|
+
}
|
|
27
|
+
_FIELD: Final = re.compile(r"\{(\w+)\}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _no_options() -> dict[str, object]:
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class MigrationsConfig:
|
|
36
|
+
"""Where a database's revisions live, how new ones are written, and how runs are recorded.
|
|
37
|
+
|
|
38
|
+
Revisions are ordinary migration files, one per change, in
|
|
39
|
+
:attr:`directory`. The database keeps its current revisions in
|
|
40
|
+
:attr:`version_table`, and a row per revision ever applied — when, and how
|
|
41
|
+
long it took — in :attr:`history_table`.
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
MigrationsConfig(
|
|
45
|
+
directory="%kernel.project_dir%/migrations",
|
|
46
|
+
version_table_schema="meta",
|
|
47
|
+
render_as_batch=True, # SQLite cannot alter a table in place
|
|
48
|
+
)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
directory: Where revision files are read from and written to; created
|
|
53
|
+
when the first one is written.
|
|
54
|
+
version_table: The table holding the database's current revisions.
|
|
55
|
+
version_table_schema: The schema of both tables; the connection's
|
|
56
|
+
default when ``None``.
|
|
57
|
+
history_table: The table recording every revision applied, when it
|
|
58
|
+
ran and how long it took.
|
|
59
|
+
file_template: How a new revision's file is named. ``{rev}``,
|
|
60
|
+
``{slug}``, ``{epoch}``, ``{year}``, ``{month}``, ``{day}``,
|
|
61
|
+
``{hour}``, ``{minute}`` and ``{second}`` are filled in; the
|
|
62
|
+
default sorts files by the time they were written.
|
|
63
|
+
truncate_slug_length: The longest the message part of a file name
|
|
64
|
+
gets.
|
|
65
|
+
timezone: The time zone a revision's creation date is written in;
|
|
66
|
+
local time when ``None``.
|
|
67
|
+
template_directory: A directory holding a ``script.py.mako`` that new
|
|
68
|
+
revisions are rendered from; the package's own when ``None``.
|
|
69
|
+
recursive_version_locations: Read revisions from subdirectories of
|
|
70
|
+
:attr:`directory` too.
|
|
71
|
+
post_write_hooks: Programs run on every file written, such as a
|
|
72
|
+
formatter — each a mapping with a ``type`` (``"console_scripts"``,
|
|
73
|
+
``"exec"`` or ``"module"``) and that type's options.
|
|
74
|
+
transaction_per_migration: Commit after each revision rather than
|
|
75
|
+
once after all of them.
|
|
76
|
+
transactional_ddl: Whether schema changes run inside the
|
|
77
|
+
transaction; the database's own answer when ``None``.
|
|
78
|
+
compare_type: Compare column types when generating a diff. Off by
|
|
79
|
+
default: a type the database stores as another — a UUID kept as
|
|
80
|
+
bytes on SQLite — would otherwise differ in every diff.
|
|
81
|
+
compare_server_default: Compare server defaults when generating a
|
|
82
|
+
diff.
|
|
83
|
+
render_as_batch: Write table changes as copy-and-move batches, which
|
|
84
|
+
SQLite needs for anything but adding a column.
|
|
85
|
+
include_schemas: Compare every schema when generating a diff, not
|
|
86
|
+
only the default one.
|
|
87
|
+
include_name: Decides, by name, which schemas and tables a diff
|
|
88
|
+
reads from the database.
|
|
89
|
+
include_object: Decides which tables, columns, indexes and
|
|
90
|
+
constraints a diff considers.
|
|
91
|
+
render_item: Renders a type or a construct a diff writes, before the
|
|
92
|
+
default rendering.
|
|
93
|
+
process_revision_directives: Adjusts a revision about to be written.
|
|
94
|
+
user_module_prefix: Written before a custom type's class name; the
|
|
95
|
+
type's own module — imported by the revision — when ``None``.
|
|
96
|
+
context_options: Any other migration context option, passed as it
|
|
97
|
+
is.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
directory: str = "migrations"
|
|
101
|
+
version_table: str = "alembic_version"
|
|
102
|
+
version_table_schema: str | None = None
|
|
103
|
+
history_table: str = "alembic_version_history"
|
|
104
|
+
file_template: str = "{year}{month}{day}{hour}{minute}{second}_{rev}_{slug}"
|
|
105
|
+
truncate_slug_length: int = 40
|
|
106
|
+
timezone: str | None = None
|
|
107
|
+
template_directory: str | None = None
|
|
108
|
+
recursive_version_locations: bool = False
|
|
109
|
+
post_write_hooks: Sequence[Mapping[str, str]] = ()
|
|
110
|
+
transaction_per_migration: bool = False
|
|
111
|
+
transactional_ddl: bool | None = None
|
|
112
|
+
compare_type: bool = False
|
|
113
|
+
compare_server_default: bool = False
|
|
114
|
+
render_as_batch: bool = False
|
|
115
|
+
include_schemas: bool = False
|
|
116
|
+
include_name: Callable[..., bool] | None = None
|
|
117
|
+
include_object: Callable[..., bool] | None = None
|
|
118
|
+
render_item: Callable[..., str | bool] | None = None
|
|
119
|
+
process_revision_directives: Callable[..., None] | None = None
|
|
120
|
+
user_module_prefix: str | None = None
|
|
121
|
+
context_options: Mapping[str, object] = field(default_factory=_no_options)
|
|
122
|
+
|
|
123
|
+
def __post_init__(self) -> None:
|
|
124
|
+
"""Refuse an empty directory or table name, or a file template naming nothing known.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
InvalidArgumentError: When the configuration cannot be used.
|
|
128
|
+
"""
|
|
129
|
+
for name in ("directory", "version_table", "history_table"):
|
|
130
|
+
# Compared, not tested for truth: a value may be an environment
|
|
131
|
+
# placeholder, which refuses to decide anything before it is read.
|
|
132
|
+
if cast("str", getattr(self, name)) == "":
|
|
133
|
+
raise InvalidArgumentError(f'The migrations "{name}" must not be empty.')
|
|
134
|
+
if self.version_table == self.history_table:
|
|
135
|
+
raise InvalidArgumentError(
|
|
136
|
+
"The migrations version table and history table must differ, "
|
|
137
|
+
f'both are "{self.version_table}".',
|
|
138
|
+
)
|
|
139
|
+
unknown = [
|
|
140
|
+
name
|
|
141
|
+
for name in cast("list[str]", _FIELD.findall(self.file_template))
|
|
142
|
+
if name not in _FILE_TEMPLATE_FIELDS
|
|
143
|
+
]
|
|
144
|
+
if unknown:
|
|
145
|
+
known = ", ".join(f"{{{name}}}" for name in _FILE_TEMPLATE_FIELDS)
|
|
146
|
+
raise InvalidArgumentError(
|
|
147
|
+
f'The migrations file template names "{{{unknown[0]}}}"; it may use {known}.',
|
|
148
|
+
)
|
|
149
|
+
if "{rev}" not in self.file_template and "{epoch}" not in self.file_template:
|
|
150
|
+
raise InvalidArgumentError(
|
|
151
|
+
"The migrations file template must hold {rev} or {epoch}, "
|
|
152
|
+
"or two revisions could be written to one file.",
|
|
153
|
+
)
|
|
154
|
+
if self.truncate_slug_length < 1:
|
|
155
|
+
raise InvalidArgumentError(
|
|
156
|
+
f"The migrations slug length must be at least 1, got {self.truncate_slug_length}.",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def alembic_file_template(self) -> str:
|
|
160
|
+
"""Return :attr:`file_template` in the form the revision writer reads."""
|
|
161
|
+
escaped = self.file_template.replace("%", "%%")
|
|
162
|
+
return _FIELD.sub(lambda match: _FILE_TEMPLATE_FIELDS[match.group(1)], escaped)
|