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,680 @@
1
+ """Versioned schema changes for one database: read, planned, run and recorded."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from io import StringIO
6
+ from typing import TYPE_CHECKING, Final, TypeVar, final
7
+
8
+ from alembic.autogenerate import RevisionContext
9
+ from alembic.config import Config
10
+ from sqlalchemy import Column, MetaData, String, Table, delete
11
+ from xtr_logging_contracts import LoggerAware
12
+
13
+ from xtr_orm.exception import MigrationError
14
+
15
+ from ._environment import Environment
16
+ from ._history import HistoryRecorder, HistoryTable
17
+ from ._revisions import (
18
+ applied,
19
+ available,
20
+ known,
21
+ mark_step,
22
+ parents,
23
+ plan,
24
+ refusing,
25
+ resolve,
26
+ )
27
+ from ._revisions import migration as describe
28
+ from ._revisions import step as revision_step
29
+ from ._schema import creating, matching, tables_of
30
+ from .direction import Direction
31
+ from .executed_migration import ExecutedMigration
32
+ from .migration_plan import MigrationPlan
33
+ from .migration_status import MigrationStatus
34
+ from .migrations_config import MigrationsConfig
35
+
36
+ if TYPE_CHECKING:
37
+ from collections.abc import Callable, Iterator, Sequence
38
+
39
+ from alembic.operations.ops import MigrationScript
40
+ from alembic.runtime.migration import MigrationContext, RevisionStep
41
+ from alembic.script import Script, ScriptDirectory
42
+ from sqlalchemy.engine import Connection
43
+ from sqlalchemy.ext.asyncio import AsyncEngine
44
+
45
+ from .available_migration import AvailableMigration
46
+ from .execution_result import ExecutionResult
47
+
48
+ __all__ = ["Migrator"]
49
+
50
+ _T = TypeVar("_T")
51
+
52
+ _VERSION_LENGTH: Final = 32
53
+
54
+
55
+ @final
56
+ class Migrator(LoggerAware):
57
+ """Reads, plans, runs and records one database's revisions.
58
+
59
+ Revisions are ordinary migration files in the configured directory. The
60
+ database keeps the revisions it is at in the version table, and a row per
61
+ revision applied in the history table; every change to one is made to the
62
+ other in the same transaction.
63
+
64
+ Every method opens its own connection from the engine and returns once it
65
+ is closed. What reads files only — :meth:`generate` — opens none.
66
+
67
+ ```python
68
+ migrator = Migrator(engine, MigrationsConfig(directory="migrations"), Base.metadata)
69
+
70
+ await migrator.migrate() # to the latest revisions
71
+ status = await migrator.status()
72
+ ```
73
+ """
74
+
75
+ _engine: AsyncEngine
76
+ _config: MigrationsConfig
77
+ _metadata: MetaData | Sequence[MetaData] | None
78
+ _name: str
79
+ _environment: Environment
80
+ _history: HistoryTable
81
+
82
+ def __init__(
83
+ self,
84
+ engine: AsyncEngine,
85
+ config: MigrationsConfig | None = None,
86
+ metadata: MetaData | Sequence[MetaData] | None = None,
87
+ *,
88
+ name: str = "default",
89
+ ) -> None:
90
+ """Manage the revisions ``config`` describes on ``engine``'s database.
91
+
92
+ Args:
93
+ engine: The database the revisions apply to.
94
+ config: Where the revisions are and how they are recorded.
95
+ metadata: The table definitions a diff compares the database
96
+ with; a diff is refused without them.
97
+ name: What reports call this database.
98
+ """
99
+ self._engine = engine
100
+ self._config = config if config is not None else MigrationsConfig()
101
+ self._metadata = metadata
102
+ self._name = name
103
+ self._environment = Environment(self._config, metadata)
104
+ self._history = HistoryTable(self._config.history_table, self._config.version_table_schema)
105
+
106
+ @property
107
+ def name(self) -> str:
108
+ """What reports call this database."""
109
+ return self._name
110
+
111
+ @property
112
+ def engine(self) -> AsyncEngine:
113
+ """The database the revisions apply to."""
114
+ return self._engine
115
+
116
+ @property
117
+ def config(self) -> MigrationsConfig:
118
+ """Where the revisions are and how they are recorded."""
119
+ return self._config
120
+
121
+ async def status(self) -> MigrationStatus:
122
+ """Read where the database stands against the revision files."""
123
+ return await self._connected(self._status)
124
+
125
+ async def plan(self, target: str = "latest") -> tuple[MigrationPlan, ...]:
126
+ """Return the revisions :meth:`migrate` would run to reach ``target``, without running them.
127
+
128
+ Raises:
129
+ MigrationError: When the target is unknown or cannot be reached.
130
+ """
131
+
132
+ def work(connection: Connection) -> tuple[MigrationPlan, ...]:
133
+ directory = self._environment.directory()
134
+ heads = self._environment.context(connection).get_current_heads()
135
+ direction, steps = plan(directory, heads, target)
136
+ return tuple(
137
+ MigrationPlan(step.revision.revision, direction, (step.revision.doc or "").strip())
138
+ for step in steps
139
+ )
140
+
141
+ return await self._connected(work)
142
+
143
+ async def migrate(self, target: str = "latest") -> tuple[ExecutionResult, ...]:
144
+ """Run every revision between where the database is and ``target``.
145
+
146
+ ``target`` is a revision — a full or partial id, or a label — a
147
+ relative one (``+1``, ``-2``, ``ae10+1``), or one of ``latest``,
148
+ ``first`` (before every revision), ``next``, ``prev`` and ``current``.
149
+ A revision already applied is migrated down to; one that is not, up
150
+ to.
151
+
152
+ Raises:
153
+ MigrationError: When the target is unknown or cannot be reached.
154
+ """
155
+ results = await self._connected(lambda connection: self._migrate(connection, target))
156
+ self._log_results(results)
157
+ return results
158
+
159
+ async def migrate_sql(self, target: str = "latest") -> str:
160
+ """Return the SQL :meth:`migrate` would run, recording included, without running it.
161
+
162
+ Raises:
163
+ MigrationError: When the target is unknown or cannot be reached.
164
+ """
165
+
166
+ def work(connection: Connection) -> str:
167
+ directory = self._environment.directory()
168
+ heads = self._environment.context(connection).get_current_heads()
169
+ _, steps = plan(directory, heads, target)
170
+ return self._sql(connection, directory, heads, steps)
171
+
172
+ return await self._connected(work)
173
+
174
+ async def execute(
175
+ self, versions: Sequence[str], direction: Direction = Direction.UP
176
+ ) -> tuple[ExecutionResult, ...]:
177
+ """Run exactly ``versions``, in order, each one way — and nothing before or after them.
178
+
179
+ Going up, a revision must not be applied and must follow only applied
180
+ ones; going down, it must be applied and followed by none that is.
181
+ Each version is checked against the ones before it, so a revision and
182
+ the one following it may be run together.
183
+
184
+ Raises:
185
+ MigrationError: When a version is unknown, or out of order.
186
+ """
187
+ results = await self._connected(
188
+ lambda connection: self._apply(connection, versions, direction, marking=False)
189
+ )
190
+ self._log_results(results)
191
+ return results
192
+
193
+ async def plan_for_versions(
194
+ self, versions: Sequence[str], direction: Direction = Direction.UP
195
+ ) -> tuple[MigrationPlan, ...]:
196
+ """Return what :meth:`execute` would run for ``versions``, without running it.
197
+
198
+ Each version is resolved to the revision it names — a partial id to
199
+ the full one — and checked as :meth:`execute` checks it.
200
+
201
+ Raises:
202
+ MigrationError: When a version is unknown, or out of order.
203
+ """
204
+
205
+ def work(connection: Connection) -> tuple[MigrationPlan, ...]:
206
+ directory = self._environment.directory()
207
+ heads = self._environment.context(connection).get_current_heads()
208
+ return tuple(
209
+ MigrationPlan(script.revision, direction, (script.doc or "").strip())
210
+ for script in self._checked(directory, heads, versions, direction)
211
+ )
212
+
213
+ return await self._connected(work)
214
+
215
+ async def execute_sql(
216
+ self, versions: Sequence[str], direction: Direction = Direction.UP
217
+ ) -> str:
218
+ """Return the SQL :meth:`execute` would run, recording included, without running it.
219
+
220
+ Raises:
221
+ MigrationError: When a version is unknown, or out of order.
222
+ """
223
+
224
+ def work(connection: Connection) -> str:
225
+ directory = self._environment.directory()
226
+ heads = self._environment.context(connection).get_current_heads()
227
+ scripts = self._checked(directory, heads, versions, direction)
228
+ steps = [revision_step(directory, each, direction) for each in scripts]
229
+ return self._sql(connection, directory, heads, steps)
230
+
231
+ return await self._connected(work)
232
+
233
+ def generate(self, message: str | None = None) -> AvailableMigration:
234
+ """Write an empty revision following the latest one, and return it.
235
+
236
+ Raises:
237
+ MigrationError: When there are several latest revisions to follow.
238
+ """
239
+ directory = self._environment.directory()
240
+ context = RevisionContext(Config(), directory, _command_args(message, autogenerate=False))
241
+ written = self._written(context)
242
+ if written is None: # pragma: no cover — an empty revision is always written
243
+ raise MigrationError("No revision was written.")
244
+ return written
245
+
246
+ async def diff(
247
+ self,
248
+ message: str | None = None,
249
+ *,
250
+ allow_empty: bool = False,
251
+ from_empty_schema: bool = False,
252
+ ) -> AvailableMigration | None:
253
+ """Write a revision bringing the database to the table definitions, and return it.
254
+
255
+ The database is compared with the table definitions, and what differs
256
+ becomes the revision's upgrade. Nothing is written when nothing
257
+ differs, unless ``allow_empty``.
258
+
259
+ Args:
260
+ message: The revision's message.
261
+ allow_empty: Write a revision even when nothing differs.
262
+ from_empty_schema: Write every table, as if the database were
263
+ empty.
264
+
265
+ Raises:
266
+ MigrationError: With no table definitions to compare with, or when
267
+ the database is not at the latest revisions.
268
+ """
269
+ if self._metadata is None:
270
+ raise MigrationError(
271
+ f'The "{self._name}" connection has no table definitions to compare with.',
272
+ )
273
+ tables = tables_of(self._metadata) if from_empty_schema else None
274
+
275
+ def adjust(
276
+ context: MigrationContext, revision: object, directives: list[MigrationScript]
277
+ ) -> None:
278
+ del context, revision
279
+ script = directives[0]
280
+ if tables is not None:
281
+ script.upgrade_ops, script.downgrade_ops = creating(tables)
282
+ if not allow_empty and script.upgrade_ops is not None and script.upgrade_ops.is_empty():
283
+ directives.clear()
284
+
285
+ def work(connection: Connection) -> AvailableMigration | None:
286
+ directory = self._environment.directory()
287
+ context = RevisionContext(
288
+ Config(),
289
+ directory,
290
+ _command_args(message, autogenerate=True),
291
+ process_revision_directives=adjust,
292
+ )
293
+
294
+ def steps(heads: tuple[str, ...], migration: MigrationContext) -> list[RevisionStep]:
295
+ if tables is None:
296
+ context.run_autogenerate(heads, migration)
297
+ else:
298
+ context.run_no_autogenerate(heads, migration)
299
+ return []
300
+
301
+ with refusing():
302
+ self._environment.run(
303
+ connection, directory, steps, revision_context=context, read_only=True
304
+ )
305
+ return self._written(context)
306
+
307
+ return await self._connected(work)
308
+
309
+ async def dump_schema(
310
+ self, message: str | None = None, *, table_filters: Sequence[str] = ()
311
+ ) -> AvailableMigration:
312
+ """Write a first revision creating every table the database has, and return it.
313
+
314
+ For a database that predates its revisions: the revision is written,
315
+ not applied — record it as applied with :meth:`rollup`.
316
+
317
+ Args:
318
+ message: The revision's message.
319
+ table_filters: Regular expressions; only the tables whose name one
320
+ of them finds are written. Every table with none.
321
+
322
+ Raises:
323
+ MigrationError: When revisions exist already, or the database has
324
+ no table to write.
325
+ """
326
+
327
+ def work(connection: Connection) -> AvailableMigration:
328
+ directory = self._environment.directory()
329
+ if available(directory):
330
+ raise MigrationError(
331
+ f'Delete the previous revisions in "{self._config.directory}" '
332
+ "before dumping the schema.",
333
+ )
334
+ reflected = MetaData()
335
+ reflected.reflect(connection)
336
+ internal = {self._config.version_table, self._config.history_table}
337
+ tables = matching(
338
+ (each for each in reflected.sorted_tables if each.name not in internal),
339
+ table_filters,
340
+ )
341
+ if not tables:
342
+ raise MigrationError("The database schema does not contain any tables.")
343
+
344
+ def replace(
345
+ context: MigrationContext, revision: object, directives: list[MigrationScript]
346
+ ) -> None:
347
+ del context, revision
348
+ directives[0].upgrade_ops, directives[0].downgrade_ops = creating(tables)
349
+
350
+ context = RevisionContext(
351
+ Config(),
352
+ directory,
353
+ _command_args(message or "dump the existing schema", autogenerate=False),
354
+ process_revision_directives=replace,
355
+ )
356
+
357
+ def steps(heads: tuple[str, ...], migration: MigrationContext) -> list[RevisionStep]:
358
+ context.run_no_autogenerate(heads, migration)
359
+ return []
360
+
361
+ with refusing():
362
+ self._environment.run(
363
+ connection, directory, steps, revision_context=context, read_only=True
364
+ )
365
+ written = self._written(context)
366
+ if written is None: # pragma: no cover — the operations above are never empty
367
+ raise MigrationError("No revision was written.")
368
+ return written
369
+
370
+ return await self._connected(work)
371
+
372
+ async def add_versions(self, versions: Sequence[str]) -> tuple[str, ...]:
373
+ """Mark ``versions`` applied without running them, in order, and return them.
374
+
375
+ Each must not be applied and must follow only applied revisions, as
376
+ with :meth:`execute`.
377
+
378
+ Raises:
379
+ MigrationError: When a version is unknown, applied, or out of order.
380
+ """
381
+ results = await self._connected(
382
+ lambda connection: self._apply(connection, versions, Direction.UP, marking=True)
383
+ )
384
+ return tuple(result.version for result in results)
385
+
386
+ async def add_all_versions(self) -> tuple[str, ...]:
387
+ """Mark every revision not yet applied as applied, without running any; return them."""
388
+
389
+ def work(connection: Connection) -> tuple[ExecutionResult, ...]:
390
+ directory = self._environment.directory()
391
+ heads = self._environment.context(connection).get_current_heads()
392
+ done = applied(directory, heads)
393
+ pending = [each.revision for each in available(directory) if each.revision not in done]
394
+ return self._apply(connection, pending, Direction.UP, marking=True)
395
+
396
+ results = await self._connected(work)
397
+ return tuple(result.version for result in results)
398
+
399
+ async def delete_versions(self, versions: Sequence[str]) -> tuple[str, ...]:
400
+ """Mark ``versions`` not applied without running them, in order, and return them.
401
+
402
+ A version with a file must be applied and followed by no applied
403
+ revision, as with :meth:`execute`. A version without one — a revision
404
+ applied whose file is gone — is simply forgotten.
405
+
406
+ Raises:
407
+ MigrationError: When a version is not applied, or out of order.
408
+ """
409
+ return await self._connected(lambda connection: self._delete(connection, versions))
410
+
411
+ async def delete_all_versions(self) -> None:
412
+ """Mark every revision not applied, without running any."""
413
+
414
+ def work(connection: Connection) -> None:
415
+ def steps(_: tuple[str, ...], migration: MigrationContext) -> list[RevisionStep]:
416
+ self._history.ensure(migration, connection)
417
+ self._history.clear(migration)
418
+ return []
419
+
420
+ self._environment.run(connection, self._environment.directory(), steps, purge=True)
421
+
422
+ await self._connected(work)
423
+
424
+ async def rollup(self) -> AvailableMigration:
425
+ """Forget every applied revision, then mark the single remaining one applied.
426
+
427
+ For after squashing every revision into one: the database is left
428
+ exactly as it is, recorded as being at that one revision.
429
+
430
+ Raises:
431
+ MigrationError: When there is no revision, or more than one.
432
+ """
433
+
434
+ def work(connection: Connection) -> AvailableMigration:
435
+ directory = self._environment.directory()
436
+ scripts = list(available(directory))
437
+ if not scripts:
438
+ raise MigrationError("No migrations found.")
439
+ if len(scripts) > 1:
440
+ raise MigrationError(
441
+ f"Too many migrations: {len(scripts)} found, a rollup needs exactly one.",
442
+ )
443
+ recorder = HistoryRecorder(self._history, marking=True)
444
+
445
+ def steps(_: tuple[str, ...], migration: MigrationContext) -> Iterator[RevisionStep]:
446
+ self._history.ensure(migration, connection)
447
+ self._history.clear(migration)
448
+ recorder.started()
449
+ yield mark_step(directory, scripts[0], Direction.UP)
450
+
451
+ self._environment.run(
452
+ connection, directory, steps, purge=True, on_version_apply=recorder
453
+ )
454
+ return describe(scripts[0])
455
+
456
+ return await self._connected(work)
457
+
458
+ async def _connected(self, work: Callable[[Connection], _T]) -> _T:
459
+ """Run ``work`` on a connection of its own, committing what it did."""
460
+ async with self._engine.connect() as connection:
461
+ result = await connection.run_sync(work)
462
+ await connection.commit()
463
+ return result
464
+
465
+ def _status(self, connection: Connection) -> MigrationStatus:
466
+ directory = self._environment.directory()
467
+ heads = self._environment.context(connection).get_current_heads()
468
+ history = self._history.read(connection)
469
+ scripts = available(directory)
470
+ by_version = {each.revision: each for each in scripts}
471
+ # A head without a file hides what it followed; the history still names it.
472
+ done = applied(directory, heads) | {version for version in history if version in by_version}
473
+ migrations = tuple(describe(each) for each in scripts)
474
+ executed = [
475
+ ExecutedMigration(each.revision, *history.get(each.revision, (None, None)))
476
+ for each in scripts
477
+ if each.revision in done
478
+ ]
479
+ missing = [head for head in heads if head not in by_version]
480
+ missing += [version for version in history if version not in by_version]
481
+ unavailable = tuple(
482
+ ExecutedMigration(version, *history.get(version, (None, None)))
483
+ for version in dict.fromkeys(missing)
484
+ )
485
+ previous = [
486
+ parent
487
+ for head in heads
488
+ if head in by_version
489
+ for parent in parents(directory, by_version[head])
490
+ ]
491
+ new = tuple(each for each in migrations if each.version not in done)
492
+ runnable = tuple(
493
+ each.version
494
+ for each in new
495
+ if all(parent in done for parent in parents(directory, by_version[each.version]))
496
+ )
497
+ return MigrationStatus(
498
+ current=heads,
499
+ latest=tuple(each.version for each in migrations if each.is_head),
500
+ previous=tuple(dict.fromkeys(previous)),
501
+ next=runnable,
502
+ available=migrations,
503
+ executed=(*executed, *unavailable),
504
+ new=new,
505
+ executed_unavailable=unavailable,
506
+ )
507
+
508
+ def _migrate(self, connection: Connection, target: str) -> tuple[ExecutionResult, ...]:
509
+ directory = self._environment.directory()
510
+ recorder = HistoryRecorder(self._history)
511
+
512
+ def steps(heads: tuple[str, ...], migration: MigrationContext) -> Iterator[RevisionStep]:
513
+ self._history.ensure(migration, connection)
514
+ _, planned = plan(directory, heads, target)
515
+ for step in planned:
516
+ recorder.started()
517
+ yield step
518
+
519
+ self._environment.run(connection, directory, steps, on_version_apply=recorder)
520
+ return recorder.results
521
+
522
+ def _apply(
523
+ self,
524
+ connection: Connection,
525
+ versions: Sequence[str],
526
+ direction: Direction,
527
+ *,
528
+ marking: bool,
529
+ ) -> tuple[ExecutionResult, ...]:
530
+ directory = self._environment.directory()
531
+ recorder = HistoryRecorder(self._history, marking=marking)
532
+
533
+ def steps(heads: tuple[str, ...], migration: MigrationContext) -> Iterator[RevisionStep]:
534
+ scripts = self._checked(directory, heads, versions, direction)
535
+ self._history.ensure(migration, connection)
536
+ for each in scripts:
537
+ recorder.started()
538
+ yield (
539
+ mark_step(directory, each, direction)
540
+ if marking
541
+ else revision_step(directory, each, direction)
542
+ )
543
+
544
+ self._environment.run(connection, directory, steps, on_version_apply=recorder)
545
+ return recorder.results
546
+
547
+ def _delete(self, connection: Connection, versions: Sequence[str]) -> tuple[str, ...]:
548
+ directory = self._environment.directory()
549
+ heads = self._environment.context(connection).get_current_heads()
550
+ with_file = set(known(directory, versions))
551
+ recorded = self._history.read(connection)
552
+ orphans = [version for version in versions if version not in with_file]
553
+ for orphan in orphans:
554
+ if orphan not in heads and orphan not in recorded:
555
+ raise MigrationError(f'The version "{orphan}" is not applied.')
556
+ version_table = Table(
557
+ self._config.version_table,
558
+ MetaData(),
559
+ Column("version_num", String(_VERSION_LENGTH)),
560
+ schema=self._config.version_table_schema,
561
+ )
562
+ recorder = HistoryRecorder(self._history, marking=True)
563
+
564
+ def steps(current: tuple[str, ...], migration: MigrationContext) -> Iterator[RevisionStep]:
565
+ scripts = self._checked(
566
+ directory, current, [each for each in versions if each in with_file], Direction.DOWN
567
+ )
568
+ self._history.ensure(migration, connection)
569
+ for orphan in orphans:
570
+ migration.execute(
571
+ delete(version_table).where(version_table.c.version_num == orphan)
572
+ )
573
+ self._history.remove(migration, orphans)
574
+ for each in scripts:
575
+ yield mark_step(directory, each, Direction.DOWN)
576
+
577
+ self._environment.run(connection, directory, steps, on_version_apply=recorder)
578
+ done = {result.version for result in recorder.results}
579
+ return tuple(version for version in versions if version in done or version in orphans)
580
+
581
+ def _checked(
582
+ self,
583
+ directory: ScriptDirectory,
584
+ heads: tuple[str, ...],
585
+ versions: Sequence[str],
586
+ direction: Direction,
587
+ ) -> list[Script]:
588
+ """Return the revisions ``versions`` name, checking each may run ``direction`` in turn."""
589
+ done = applied(directory, heads)
590
+ found: list[Script] = []
591
+ for version in versions:
592
+ script = resolve(directory, version)
593
+ revision = script.revision
594
+ if direction is Direction.UP:
595
+ if revision in done:
596
+ raise MigrationError(f'The version "{revision}" is already applied.')
597
+ missing = [parent for parent in parents(directory, script) if parent not in done]
598
+ if missing:
599
+ raise MigrationError(
600
+ f'The version "{revision}" follows "{missing[0]}", which is not applied.',
601
+ )
602
+ done.add(revision)
603
+ else:
604
+ if revision not in done:
605
+ raise MigrationError(f'The version "{revision}" is not applied.')
606
+ followers = sorted(child for child in script.nextrev if child in done)
607
+ if followers:
608
+ raise MigrationError(
609
+ f'The version "{revision}" is followed by "{followers[0]}", '
610
+ "which is applied.",
611
+ )
612
+ done.discard(revision)
613
+ found.append(script)
614
+ return found
615
+
616
+ def _sql(
617
+ self,
618
+ connection: Connection,
619
+ directory: ScriptDirectory,
620
+ heads: tuple[str, ...],
621
+ steps: Sequence[RevisionStep],
622
+ ) -> str:
623
+ """Write the SQL of ``steps`` from ``heads``, as the runner would run it."""
624
+ output = StringIO()
625
+ # Nothing runs, so nothing is timed: the recorded duration is left empty.
626
+ recorder = HistoryRecorder(self._history, marking=True)
627
+
628
+ def planned(_: tuple[str, ...], migration: MigrationContext) -> Sequence[RevisionStep]:
629
+ self._history.ensure(migration, connection)
630
+ return steps
631
+
632
+ self._environment.run(
633
+ connection,
634
+ directory,
635
+ planned,
636
+ as_sql=True,
637
+ output=output,
638
+ starting_rev=heads,
639
+ on_version_apply=recorder,
640
+ )
641
+ return output.getvalue()
642
+
643
+ def _written(self, context: RevisionContext) -> AvailableMigration | None:
644
+ with refusing():
645
+ written = [each for each in context.generate_scripts() if each is not None]
646
+ if not written:
647
+ return None
648
+ migration = describe(written[0])
649
+ self.logger.info(
650
+ "Wrote the revision {version} to {path}",
651
+ {"connection": self._name, "version": migration.version, "path": migration.path},
652
+ )
653
+ return migration
654
+
655
+ def _log_results(self, results: Sequence[ExecutionResult]) -> None:
656
+ for result in results:
657
+ self.logger.info(
658
+ "Migrated {version} {direction} in {duration} s",
659
+ {
660
+ "connection": self._name,
661
+ "version": result.version,
662
+ "direction": result.direction.value,
663
+ "duration": round(result.duration, 3),
664
+ },
665
+ )
666
+
667
+
668
+ def _command_args(message: str | None, *, autogenerate: bool) -> dict[str, object]:
669
+ """What a revision is written from: its message, and where it goes in the history."""
670
+ return {
671
+ "message": message,
672
+ "autogenerate": autogenerate,
673
+ "sql": False,
674
+ "head": "head",
675
+ "splice": False,
676
+ "branch_label": None,
677
+ "version_path": None,
678
+ "rev_id": None,
679
+ "depends_on": None,
680
+ }