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,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)