swiftpy-framework 0.1.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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import importlib.util
|
|
5
|
+
import inspect
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from functools import partial
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import ModuleType
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import asyncpg
|
|
16
|
+
|
|
17
|
+
from swiftpy.core.container import get_container
|
|
18
|
+
from swiftpy.database.identifiers import validate_identifier
|
|
19
|
+
from swiftpy.database.migrations.lock import MigrationLock
|
|
20
|
+
from swiftpy.database.migrations.migration import Migration, MigrationProtocol
|
|
21
|
+
from swiftpy.database.pool import DatabasePool
|
|
22
|
+
from swiftpy.database.transaction import TransactionConnection, _active_connection
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class MigrationFile:
|
|
27
|
+
"""Represents a discovered migration file on the filesystem."""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
path: Path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class MigrationRecord:
|
|
35
|
+
"""Represents an applied migration record from the database migrations table."""
|
|
36
|
+
|
|
37
|
+
id: int
|
|
38
|
+
migration: str
|
|
39
|
+
batch: int
|
|
40
|
+
applied_at: datetime
|
|
41
|
+
checksum: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _FunctionMigrationAdapter:
|
|
45
|
+
"""Adapts module-level up/down async functions into MigrationProtocol."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, up_fn: Any, down_fn: Any) -> None:
|
|
48
|
+
self._up_fn = up_fn
|
|
49
|
+
self._down_fn = down_fn
|
|
50
|
+
|
|
51
|
+
async def up(self) -> None:
|
|
52
|
+
if callable(self._up_fn):
|
|
53
|
+
res = self._up_fn()
|
|
54
|
+
if inspect.isawaitable(res):
|
|
55
|
+
await res
|
|
56
|
+
|
|
57
|
+
async def down(self) -> None:
|
|
58
|
+
if callable(self._down_fn):
|
|
59
|
+
res = self._down_fn()
|
|
60
|
+
if inspect.isawaitable(res):
|
|
61
|
+
await res
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Migrator:
|
|
65
|
+
"""
|
|
66
|
+
Migration Engine for SwiftPY.
|
|
67
|
+
|
|
68
|
+
Discovers timestamped migration files, tracks applied migrations across batches,
|
|
69
|
+
and runs forward and backward migrations within transactional advisory locks.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
pool: DatabasePool | None = None,
|
|
75
|
+
migrations_dir: Path | str | None = None,
|
|
76
|
+
table_name: str = "migrations",
|
|
77
|
+
lock_timeout: float = 300.0,
|
|
78
|
+
) -> None:
|
|
79
|
+
self.pool: DatabasePool = pool or get_container().resolve(DatabasePool)
|
|
80
|
+
if migrations_dir is None:
|
|
81
|
+
self.migrations_dir: Path = Path.cwd() / "database" / "migrations"
|
|
82
|
+
else:
|
|
83
|
+
self.migrations_dir = Path(migrations_dir)
|
|
84
|
+
self.table_name: str = validate_identifier(table_name)
|
|
85
|
+
self.lock_timeout: float = lock_timeout
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def compute_checksum(file: MigrationFile) -> str:
|
|
89
|
+
"""Compute the SHA-256 hex digest of a migration file's contents."""
|
|
90
|
+
return hashlib.sha256(file.path.read_bytes()).hexdigest()
|
|
91
|
+
|
|
92
|
+
def get_migration_files(self) -> list[MigrationFile]:
|
|
93
|
+
"""Discover all migration Python files in the migrations directory, sorted by timestamp prefix."""
|
|
94
|
+
if not self.migrations_dir.exists():
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
files: list[MigrationFile] = []
|
|
98
|
+
for p in self.migrations_dir.glob("*.py"):
|
|
99
|
+
if p.name.startswith(("_", ".")) or p.name == "__init__.py":
|
|
100
|
+
continue
|
|
101
|
+
files.append(MigrationFile(name=p.stem, path=p))
|
|
102
|
+
|
|
103
|
+
# Natural sort ensures strict timestamp-ordered execution (YYYY_MM_DD_HHMMSS_...)
|
|
104
|
+
files.sort(key=lambda f: f.name)
|
|
105
|
+
return files
|
|
106
|
+
|
|
107
|
+
async def ensure_repository(self, conn: asyncpg.Connection) -> None:
|
|
108
|
+
"""Create the migrations tracking table if it does not already exist."""
|
|
109
|
+
sql = f"""
|
|
110
|
+
CREATE TABLE IF NOT EXISTS "{self.table_name}" (
|
|
111
|
+
"id" SERIAL PRIMARY KEY,
|
|
112
|
+
"migration" VARCHAR(255) NOT NULL UNIQUE,
|
|
113
|
+
"batch" INTEGER NOT NULL,
|
|
114
|
+
"checksum" VARCHAR(64),
|
|
115
|
+
"applied_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
116
|
+
);
|
|
117
|
+
"""
|
|
118
|
+
await conn.execute(sql)
|
|
119
|
+
await conn.execute(
|
|
120
|
+
f'ALTER TABLE "{self.table_name}" ADD COLUMN IF NOT EXISTS "checksum" VARCHAR(64);'
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
async def get_ran_migrations(self, conn: asyncpg.Connection) -> list[str]:
|
|
124
|
+
"""Fetch all migration names that have already been executed."""
|
|
125
|
+
rows = await conn.fetch(
|
|
126
|
+
f'SELECT "migration" FROM "{self.table_name}" ORDER BY "batch" ASC, "id" ASC;'
|
|
127
|
+
)
|
|
128
|
+
return [str(r["migration"]) for r in rows]
|
|
129
|
+
|
|
130
|
+
async def _get_ran_checksums(
|
|
131
|
+
self, conn: asyncpg.Connection
|
|
132
|
+
) -> dict[str, str | None]:
|
|
133
|
+
"""Fetch applied migration names mapped to their recorded SHA-256 checksums."""
|
|
134
|
+
rows = await conn.fetch(
|
|
135
|
+
f'SELECT "migration", "checksum" FROM "{self.table_name}" ORDER BY "batch" ASC, "id" ASC;'
|
|
136
|
+
)
|
|
137
|
+
return {
|
|
138
|
+
str(r["migration"]): (
|
|
139
|
+
str(r["checksum"]) if r["checksum"] is not None else None
|
|
140
|
+
)
|
|
141
|
+
for r in rows
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async def get_last_batch(self, conn: asyncpg.Connection) -> int:
|
|
145
|
+
"""Fetch the highest batch number recorded."""
|
|
146
|
+
val = await conn.fetchval(
|
|
147
|
+
f'SELECT COALESCE(MAX("batch"), 0) FROM "{self.table_name}";'
|
|
148
|
+
)
|
|
149
|
+
return int(val or 0)
|
|
150
|
+
|
|
151
|
+
def resolve_migration_instance(self, file: MigrationFile) -> MigrationProtocol:
|
|
152
|
+
"""Dynamically load and instantiate the migration class from a file."""
|
|
153
|
+
module_name = f"swiftpy_migration_{file.name}"
|
|
154
|
+
spec = importlib.util.spec_from_file_location(module_name, file.path)
|
|
155
|
+
if spec is None or spec.loader is None:
|
|
156
|
+
raise ImportError(f"Cannot load migration file: {file.path}")
|
|
157
|
+
|
|
158
|
+
module = importlib.util.module_from_spec(spec)
|
|
159
|
+
spec.loader.exec_module(module)
|
|
160
|
+
|
|
161
|
+
# 1. Look for a class that inherits from Migration or provides up/down
|
|
162
|
+
migration_cls = self._find_migration_class(module)
|
|
163
|
+
if migration_cls is not None:
|
|
164
|
+
inst = migration_cls()
|
|
165
|
+
if isinstance(inst, MigrationProtocol):
|
|
166
|
+
return inst
|
|
167
|
+
|
|
168
|
+
# 2. Look for module-level up/down async functions
|
|
169
|
+
up_fn = getattr(module, "up", None)
|
|
170
|
+
down_fn = getattr(module, "down", None)
|
|
171
|
+
if callable(up_fn) and callable(down_fn):
|
|
172
|
+
return _FunctionMigrationAdapter(up_fn, down_fn)
|
|
173
|
+
|
|
174
|
+
raise RuntimeError(
|
|
175
|
+
f"Migration file {file.path.name} must define a class with up() and down() methods "
|
|
176
|
+
f"or module-level up() and down() functions."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _find_migration_class(module: ModuleType) -> type[Any] | None:
|
|
181
|
+
"""Find a migration class defined in the loaded module."""
|
|
182
|
+
candidates: list[type[Any]] = []
|
|
183
|
+
|
|
184
|
+
for _, obj in inspect.getmembers(module, inspect.isclass):
|
|
185
|
+
if obj.__module__ != module.__name__:
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
if issubclass(obj, Migration) and obj is not Migration:
|
|
189
|
+
return obj
|
|
190
|
+
|
|
191
|
+
if hasattr(obj, "up") and hasattr(obj, "down"):
|
|
192
|
+
candidates.append(obj)
|
|
193
|
+
|
|
194
|
+
if candidates:
|
|
195
|
+
return candidates[0]
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
async def run(
|
|
199
|
+
self,
|
|
200
|
+
step: int | None = None,
|
|
201
|
+
on_applied: Callable[[str, float], None] | None = None,
|
|
202
|
+
) -> list[str]:
|
|
203
|
+
"""
|
|
204
|
+
Run all pending migrations within a transactional advisory lock.
|
|
205
|
+
Returns the list of migration names applied in this batch.
|
|
206
|
+
"""
|
|
207
|
+
if step is not None and (
|
|
208
|
+
isinstance(step, bool) or not isinstance(step, int) or step < 1
|
|
209
|
+
):
|
|
210
|
+
raise ValueError(f"step must be an integer >= 1, got {step!r}")
|
|
211
|
+
|
|
212
|
+
raw_pool = self.pool.get_pool()
|
|
213
|
+
|
|
214
|
+
async with (
|
|
215
|
+
raw_pool.acquire() as conn,
|
|
216
|
+
MigrationLock(conn, timeout=self.lock_timeout),
|
|
217
|
+
):
|
|
218
|
+
await self.ensure_repository(conn)
|
|
219
|
+
|
|
220
|
+
files = self.get_migration_files()
|
|
221
|
+
ran_checksums = await self._get_ran_checksums(conn)
|
|
222
|
+
ran = list(ran_checksums.keys())
|
|
223
|
+
|
|
224
|
+
# Verify no checksum drift on already-applied migrations
|
|
225
|
+
for file in files:
|
|
226
|
+
if file.name in ran_checksums:
|
|
227
|
+
recorded = ran_checksums[file.name]
|
|
228
|
+
current = self.compute_checksum(file)
|
|
229
|
+
if recorded is not None and recorded != current:
|
|
230
|
+
raise RuntimeError(
|
|
231
|
+
f"Migration checksum drift detected for '{file.name}': "
|
|
232
|
+
f"recorded {recorded}, current file {current}"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
pending = [f for f in files if f.name not in ran_checksums]
|
|
236
|
+
if not pending:
|
|
237
|
+
return []
|
|
238
|
+
|
|
239
|
+
# Detect out-of-order unapplied migrations that sort before an already-applied migration
|
|
240
|
+
if ran:
|
|
241
|
+
max_applied = max(ran)
|
|
242
|
+
for file in pending:
|
|
243
|
+
if file.name < max_applied:
|
|
244
|
+
raise RuntimeError(
|
|
245
|
+
f"Out-of-order unapplied migration '{file.name}' precedes "
|
|
246
|
+
f"already-applied migration '{max_applied}'."
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if step is not None:
|
|
250
|
+
pending = pending[:step]
|
|
251
|
+
|
|
252
|
+
next_batch = (await self.get_last_batch(conn)) + 1
|
|
253
|
+
applied: list[str] = []
|
|
254
|
+
|
|
255
|
+
for file in pending:
|
|
256
|
+
started = time.perf_counter()
|
|
257
|
+
checksum = self.compute_checksum(file)
|
|
258
|
+
instance = self.resolve_migration_instance(file)
|
|
259
|
+
|
|
260
|
+
await self._execute_in_transaction(
|
|
261
|
+
conn,
|
|
262
|
+
partial(
|
|
263
|
+
self._apply_migration_tx,
|
|
264
|
+
instance=instance,
|
|
265
|
+
migration_name=file.name,
|
|
266
|
+
batch=next_batch,
|
|
267
|
+
checksum=checksum,
|
|
268
|
+
),
|
|
269
|
+
)
|
|
270
|
+
duration_ms = (time.perf_counter() - started) * 1000
|
|
271
|
+
applied.append(file.name)
|
|
272
|
+
if on_applied is not None:
|
|
273
|
+
on_applied(file.name, duration_ms)
|
|
274
|
+
|
|
275
|
+
return applied
|
|
276
|
+
|
|
277
|
+
async def rollback(
|
|
278
|
+
self,
|
|
279
|
+
steps: int = 1,
|
|
280
|
+
on_rolled_back: Callable[[str, float], None] | None = None,
|
|
281
|
+
) -> list[str]:
|
|
282
|
+
"""
|
|
283
|
+
Roll back the last batch of migrations (or the last N batches if steps > 1).
|
|
284
|
+
Returns the list of migration names rolled back.
|
|
285
|
+
"""
|
|
286
|
+
if isinstance(steps, bool) or not isinstance(steps, int) or steps < 1:
|
|
287
|
+
raise ValueError(f"steps must be an integer >= 1, got {steps!r}")
|
|
288
|
+
|
|
289
|
+
raw_pool = self.pool.get_pool()
|
|
290
|
+
|
|
291
|
+
async with (
|
|
292
|
+
raw_pool.acquire() as conn,
|
|
293
|
+
MigrationLock(conn, timeout=self.lock_timeout),
|
|
294
|
+
):
|
|
295
|
+
await self.ensure_repository(conn)
|
|
296
|
+
|
|
297
|
+
last_batch = await self.get_last_batch(conn)
|
|
298
|
+
if last_batch <= 0:
|
|
299
|
+
return []
|
|
300
|
+
|
|
301
|
+
min_batch = max(1, last_batch - steps + 1)
|
|
302
|
+
rows = await conn.fetch(
|
|
303
|
+
f'SELECT "migration" FROM "{self.table_name}" '
|
|
304
|
+
f'WHERE "batch" >= $1 ORDER BY "id" DESC;',
|
|
305
|
+
min_batch,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
if not rows:
|
|
309
|
+
return []
|
|
310
|
+
|
|
311
|
+
files_by_name = {f.name: f for f in self.get_migration_files()}
|
|
312
|
+
|
|
313
|
+
# Up-front validation: verify all migration files exist before executing rollbacks
|
|
314
|
+
missing = [
|
|
315
|
+
str(r["migration"])
|
|
316
|
+
for r in rows
|
|
317
|
+
if str(r["migration"]) not in files_by_name
|
|
318
|
+
]
|
|
319
|
+
if missing:
|
|
320
|
+
raise FileNotFoundError(
|
|
321
|
+
f"Cannot roll back, migration files missing on disk: {', '.join(missing)}"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
rolled_back: list[str] = []
|
|
325
|
+
|
|
326
|
+
for row in rows:
|
|
327
|
+
name = str(row["migration"])
|
|
328
|
+
file = files_by_name[name]
|
|
329
|
+
started = time.perf_counter()
|
|
330
|
+
instance = self.resolve_migration_instance(file)
|
|
331
|
+
|
|
332
|
+
await self._execute_in_transaction(
|
|
333
|
+
conn,
|
|
334
|
+
partial(
|
|
335
|
+
self._rollback_migration_tx,
|
|
336
|
+
instance=instance,
|
|
337
|
+
migration_name=name,
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
duration_ms = (time.perf_counter() - started) * 1000
|
|
341
|
+
rolled_back.append(name)
|
|
342
|
+
if on_rolled_back is not None:
|
|
343
|
+
on_rolled_back(name, duration_ms)
|
|
344
|
+
|
|
345
|
+
return rolled_back
|
|
346
|
+
|
|
347
|
+
async def _apply_migration_tx(
|
|
348
|
+
self,
|
|
349
|
+
tx_handle: TransactionConnection,
|
|
350
|
+
*,
|
|
351
|
+
instance: MigrationProtocol,
|
|
352
|
+
migration_name: str,
|
|
353
|
+
batch: int,
|
|
354
|
+
checksum: str,
|
|
355
|
+
) -> None:
|
|
356
|
+
await instance.up()
|
|
357
|
+
await tx_handle.execute(
|
|
358
|
+
f'INSERT INTO "{self.table_name}" ("migration", "batch", "checksum") '
|
|
359
|
+
f"VALUES ($1, $2, $3);",
|
|
360
|
+
migration_name,
|
|
361
|
+
batch,
|
|
362
|
+
checksum,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
async def _rollback_migration_tx(
|
|
366
|
+
self,
|
|
367
|
+
tx_handle: TransactionConnection,
|
|
368
|
+
*,
|
|
369
|
+
instance: MigrationProtocol,
|
|
370
|
+
migration_name: str,
|
|
371
|
+
) -> None:
|
|
372
|
+
await instance.down()
|
|
373
|
+
await tx_handle.execute(
|
|
374
|
+
f'DELETE FROM "{self.table_name}" WHERE "migration" = $1;',
|
|
375
|
+
migration_name,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
@staticmethod
|
|
379
|
+
async def _execute_in_transaction(
|
|
380
|
+
conn: asyncpg.Connection,
|
|
381
|
+
operation: Callable[[TransactionConnection], Awaitable[None]],
|
|
382
|
+
) -> None:
|
|
383
|
+
"""Bind a TransactionConnection handle per migration step and flush on_commit after closing."""
|
|
384
|
+
tx_handle = TransactionConnection(conn)
|
|
385
|
+
token = _active_connection.set(tx_handle)
|
|
386
|
+
committed_callbacks: list[Callable[[], Any]] = []
|
|
387
|
+
try:
|
|
388
|
+
async with tx_handle.transaction(defer_callbacks=True):
|
|
389
|
+
await operation(tx_handle)
|
|
390
|
+
committed_callbacks = tx_handle.drain_commit_callbacks()
|
|
391
|
+
finally:
|
|
392
|
+
tx_handle._close()
|
|
393
|
+
_active_connection.reset(token)
|
|
394
|
+
|
|
395
|
+
if committed_callbacks:
|
|
396
|
+
await TransactionConnection.run_commit_callbacks(committed_callbacks)
|
|
397
|
+
|
|
398
|
+
async def reset(self) -> list[str]:
|
|
399
|
+
"""Roll back all migrations in reverse chronological order."""
|
|
400
|
+
raw_pool = self.pool.get_pool()
|
|
401
|
+
|
|
402
|
+
async with (
|
|
403
|
+
raw_pool.acquire() as conn,
|
|
404
|
+
MigrationLock(conn, timeout=self.lock_timeout),
|
|
405
|
+
):
|
|
406
|
+
await self.ensure_repository(conn)
|
|
407
|
+
|
|
408
|
+
rows = await conn.fetch(
|
|
409
|
+
f'SELECT "migration" FROM "{self.table_name}" ORDER BY "id" DESC;'
|
|
410
|
+
)
|
|
411
|
+
if not rows:
|
|
412
|
+
return []
|
|
413
|
+
|
|
414
|
+
files_by_name = {f.name: f for f in self.get_migration_files()}
|
|
415
|
+
|
|
416
|
+
# Up-front validation
|
|
417
|
+
missing = [
|
|
418
|
+
str(r["migration"])
|
|
419
|
+
for r in rows
|
|
420
|
+
if str(r["migration"]) not in files_by_name
|
|
421
|
+
]
|
|
422
|
+
if missing:
|
|
423
|
+
raise FileNotFoundError(
|
|
424
|
+
f"Cannot roll back, migration files missing on disk: {', '.join(missing)}"
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
rolled_back: list[str] = []
|
|
428
|
+
|
|
429
|
+
for row in rows:
|
|
430
|
+
name = str(row["migration"])
|
|
431
|
+
file = files_by_name[name]
|
|
432
|
+
instance = self.resolve_migration_instance(file)
|
|
433
|
+
|
|
434
|
+
await self._execute_in_transaction(
|
|
435
|
+
conn,
|
|
436
|
+
partial(
|
|
437
|
+
self._rollback_migration_tx,
|
|
438
|
+
instance=instance,
|
|
439
|
+
migration_name=name,
|
|
440
|
+
),
|
|
441
|
+
)
|
|
442
|
+
rolled_back.append(name)
|
|
443
|
+
|
|
444
|
+
return rolled_back
|
|
445
|
+
|
|
446
|
+
async def status(self) -> list[dict[str, Any]]:
|
|
447
|
+
"""Return the execution status of all discovered migration files and applied records."""
|
|
448
|
+
raw_pool = self.pool.get_pool()
|
|
449
|
+
|
|
450
|
+
async with raw_pool.acquire() as conn:
|
|
451
|
+
await self.ensure_repository(conn)
|
|
452
|
+
|
|
453
|
+
rows = await conn.fetch(
|
|
454
|
+
f'SELECT "migration", "batch", "checksum", "applied_at" '
|
|
455
|
+
f'FROM "{self.table_name}" ORDER BY "id" ASC;'
|
|
456
|
+
)
|
|
457
|
+
records = {str(r["migration"]): r for r in rows}
|
|
458
|
+
|
|
459
|
+
files = self.get_migration_files()
|
|
460
|
+
seen_files: set[str] = set()
|
|
461
|
+
statuses: list[dict[str, Any]] = []
|
|
462
|
+
|
|
463
|
+
for f in files:
|
|
464
|
+
seen_files.add(f.name)
|
|
465
|
+
rec = records.get(f.name)
|
|
466
|
+
current_checksum = self.compute_checksum(f)
|
|
467
|
+
recorded_checksum = (
|
|
468
|
+
str(rec["checksum"])
|
|
469
|
+
if rec is not None and rec["checksum"] is not None
|
|
470
|
+
else None
|
|
471
|
+
)
|
|
472
|
+
statuses.append(
|
|
473
|
+
{
|
|
474
|
+
"migration": f.name,
|
|
475
|
+
"ran": rec is not None,
|
|
476
|
+
"batch": rec["batch"] if rec else None,
|
|
477
|
+
"applied_at": rec["applied_at"] if rec else None,
|
|
478
|
+
"checksum": recorded_checksum or current_checksum,
|
|
479
|
+
"checksum_valid": (
|
|
480
|
+
True
|
|
481
|
+
if recorded_checksum is None
|
|
482
|
+
else recorded_checksum == current_checksum
|
|
483
|
+
),
|
|
484
|
+
"missing_file": False,
|
|
485
|
+
}
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
for name, rec in records.items():
|
|
489
|
+
if name in seen_files:
|
|
490
|
+
continue
|
|
491
|
+
statuses.append(
|
|
492
|
+
{
|
|
493
|
+
"migration": name,
|
|
494
|
+
"ran": True,
|
|
495
|
+
"batch": rec["batch"],
|
|
496
|
+
"applied_at": rec["applied_at"],
|
|
497
|
+
"checksum": (
|
|
498
|
+
str(rec["checksum"])
|
|
499
|
+
if rec["checksum"] is not None
|
|
500
|
+
else None
|
|
501
|
+
),
|
|
502
|
+
"checksum_valid": False,
|
|
503
|
+
"missing_file": True,
|
|
504
|
+
}
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
return statuses
|