pgsqlasync2fast-fastapi 0.4.1__tar.gz → 0.5.0__tar.gz
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.
- {pgsqlasync2fast_fastapi-0.4.1/src/pgsqlasync2fast_fastapi.egg-info → pgsqlasync2fast_fastapi-0.5.0}/PKG-INFO +2 -1
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/pyproject.toml +6 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/__init__.py +17 -0
- pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi/__version__.py +1 -0
- pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi/migrations/__init__.py +49 -0
- pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi/migrations/env.py +120 -0
- pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi/migrations/registry.py +70 -0
- pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi/migrations/runner.py +163 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/skills/SKILL.md +8 -2
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi.egg-info}/PKG-INFO +2 -1
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi.egg-info/SOURCES.txt +5 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi.egg-info/requires.txt +1 -0
- pgsqlasync2fast_fastapi-0.5.0/tests/test_migration_chains.py +411 -0
- pgsqlasync2fast_fastapi-0.4.1/src/pgsqlasync2fast_fastapi/__version__.py +0 -1
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/LICENSE +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/MANIFEST.in +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/README.md +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/docs/.env.example +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/docs/seeder-format.md +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/basic_usage.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/database_creation.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/fastapi_integration.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/multi_database.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/dev/categories.json +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/dev/permissions.json +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/dev/roles.json +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/manifest.json +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/prod/categories.json +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/prod/permissions.json +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/prod/roles.json +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/setup.cfg +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/connection.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/database.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/dependencies.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/seeder.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/settings.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi.egg-info/dependency_links.txt +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi.egg-info/top_level.txt +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/tests/test_completo.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/tests/test_insert_if_missing.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/tests/test_seeder.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/tests/test_seeder_override.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/tests/test_sync_sequence.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pgsqlasync2fast-fastapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Simple and fast PostgreSQL async module for FastAPI with multi-database support
|
|
5
5
|
Author-email: Angel Daniel Sanchez Castillo <angeldaniel.sanchezcastillo@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -50,6 +50,7 @@ Requires-Dist: sqlalchemy>=2.0.0
|
|
|
50
50
|
Requires-Dist: sqlmodel>=0.0.22
|
|
51
51
|
Requires-Dist: asyncpg>=0.29.0
|
|
52
52
|
Requires-Dist: greenlet>=3.0.0
|
|
53
|
+
Requires-Dist: alembic==1.20.0
|
|
53
54
|
Dynamic: license-file
|
|
54
55
|
|
|
55
56
|
# pgsqlasync2fast-fastapi
|
|
@@ -34,6 +34,9 @@ dependencies = [
|
|
|
34
34
|
"sqlmodel>=0.0.22",
|
|
35
35
|
"asyncpg>=0.29.0",
|
|
36
36
|
"greenlet>=3.0.0",
|
|
37
|
+
# Exact pin per the alembic-2fast migration change: deliberate re-pins
|
|
38
|
+
# require a version bump and a full test run. Never use a range here.
|
|
39
|
+
"alembic==1.20.0",
|
|
37
40
|
]
|
|
38
41
|
|
|
39
42
|
[dependency-groups]
|
|
@@ -72,6 +75,9 @@ version = {attr = "pgsqlasync2fast_fastapi.__version__.__version__"}
|
|
|
72
75
|
[tool.setuptools.package-data]
|
|
73
76
|
pgsqlasync2fast_fastapi = [
|
|
74
77
|
"skills/*.md",
|
|
78
|
+
# Shared Alembic env implementation + any future chain data shipped as
|
|
79
|
+
# wheel package data (resolved from the installed release, never a checkout).
|
|
80
|
+
"migrations/**/*.py",
|
|
75
81
|
]
|
|
76
82
|
|
|
77
83
|
[tool.pytest.ini_options]
|
|
@@ -56,6 +56,17 @@ from pgsqlasync2fast_fastapi.seeder import (
|
|
|
56
56
|
sync_table_sequence,
|
|
57
57
|
)
|
|
58
58
|
|
|
59
|
+
# Re-export migration-specific exports
|
|
60
|
+
from pgsqlasync2fast_fastapi.migrations import (
|
|
61
|
+
# Chain spec + runner
|
|
62
|
+
ChainSpec,
|
|
63
|
+
run_migrations,
|
|
64
|
+
# Lane-keyed chain registry
|
|
65
|
+
register_chain,
|
|
66
|
+
get_lane_chains,
|
|
67
|
+
clear_chain_registry,
|
|
68
|
+
)
|
|
69
|
+
|
|
59
70
|
__all__ = [
|
|
60
71
|
# Version
|
|
61
72
|
"__version__",
|
|
@@ -94,4 +105,10 @@ __all__ = [
|
|
|
94
105
|
"insert_if_missing",
|
|
95
106
|
# Shared PostgreSQL sequence re-sync primitive
|
|
96
107
|
"sync_table_sequence",
|
|
108
|
+
# Migration infrastructure (alembic-2fast)
|
|
109
|
+
"ChainSpec",
|
|
110
|
+
"run_migrations",
|
|
111
|
+
"register_chain",
|
|
112
|
+
"get_lane_chains",
|
|
113
|
+
"clear_chain_registry",
|
|
97
114
|
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.0"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pgsqlasync2fast_fastapi.migrations - shared Alembic infrastructure for the
|
|
3
|
+
2fast family.
|
|
4
|
+
|
|
5
|
+
This package ships the shared pieces of the ``alembic-2fast`` migration
|
|
6
|
+
overhaul (Metal ERP change):
|
|
7
|
+
|
|
8
|
+
- ``env.py`` - shared async env implementation (official Alembic async recipe:
|
|
9
|
+
``async_engine_from_config`` + ``NullPool`` + ``dispose()`` per run). Each
|
|
10
|
+
consumer chain dir ships a thin ``env.py`` shim that imports
|
|
11
|
+
``run_migrations_offline`` / ``run_migrations_online`` from here.
|
|
12
|
+
- ``registry.py`` - lane-keyed chain registry (``register_chain`` /
|
|
13
|
+
``get_lane_chains``), same idiom as ``register_seeder``.
|
|
14
|
+
- ``runner.py`` - ``ChainSpec`` and ``run_migrations(connection_name, chains)``:
|
|
15
|
+
reads the URL from manager config only (never ``DatabaseManager._engines``)
|
|
16
|
+
and upgrades each chain to head in declaration order.
|
|
17
|
+
|
|
18
|
+
Consumers resolve chains from the INSTALLED release via alembic-native
|
|
19
|
+
``pkg:path`` resource strings (``coerce_resource_to_filename``), never from a
|
|
20
|
+
repository checkout.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from .env import (
|
|
24
|
+
do_run_migrations,
|
|
25
|
+
run_async_migrations,
|
|
26
|
+
run_migrations_offline,
|
|
27
|
+
run_migrations_online,
|
|
28
|
+
)
|
|
29
|
+
from .registry import (
|
|
30
|
+
clear_chain_registry,
|
|
31
|
+
get_lane_chains,
|
|
32
|
+
register_chain,
|
|
33
|
+
)
|
|
34
|
+
from .runner import ChainSpec, run_migrations
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
# env impl
|
|
38
|
+
"do_run_migrations",
|
|
39
|
+
"run_async_migrations",
|
|
40
|
+
"run_migrations_offline",
|
|
41
|
+
"run_migrations_online",
|
|
42
|
+
# registry
|
|
43
|
+
"clear_chain_registry",
|
|
44
|
+
"get_lane_chains",
|
|
45
|
+
"register_chain",
|
|
46
|
+
# runner
|
|
47
|
+
"ChainSpec",
|
|
48
|
+
"run_migrations",
|
|
49
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared Alembic async env implementation (design D1 of the ``alembic-2fast``
|
|
3
|
+
change).
|
|
4
|
+
|
|
5
|
+
Consumer packages ship a ~12-line ``env.py`` shim inside each chain directory
|
|
6
|
+
that imports ``run_migrations_offline`` / ``run_migrations_online`` from this
|
|
7
|
+
module and dispatches on ``context.is_offline_mode()``. Alembic's
|
|
8
|
+
``env_py_location`` is fixed at ``<script_location>/env.py``, so the shared
|
|
9
|
+
logic lives here and the per-chain shims stay thin.
|
|
10
|
+
|
|
11
|
+
Configuration contract (set programmatically by ``runner._chain_config``):
|
|
12
|
+
|
|
13
|
+
- main option ``script_location`` - ``"<package>:<chain_path>"`` (installed
|
|
14
|
+
release resolution via ``coerce_resource_to_filename``).
|
|
15
|
+
- main option ``sqlalchemy.url`` - the connection URL (config only).
|
|
16
|
+
- main option ``version_table`` - the chain's version table. Alembic 1.20.0
|
|
17
|
+
does NOT transport a ``version_table`` main option into ``MigrationContext``
|
|
18
|
+
(it is read from configure ``opts`` only), so it is passed EXPLICITLY to
|
|
19
|
+
``context.configure(version_table=...)`` below (design D1).
|
|
20
|
+
- attribute ``target_metadata`` - chain-owned ``MetaData`` for autogenerate.
|
|
21
|
+
- attributes ``include_object`` / ``include_name`` - ownership filters from
|
|
22
|
+
``runner._owner_filter`` (design D9).
|
|
23
|
+
|
|
24
|
+
Async pattern (official recipe): a fresh engine per run via
|
|
25
|
+
``async_engine_from_config`` with ``NullPool``, disposed after the run. The
|
|
26
|
+
runner executes each chain via ``asyncio.to_thread(command.upgrade, ...)``, so
|
|
27
|
+
every run owns its own event loop in its own worker thread.
|
|
28
|
+
|
|
29
|
+
IMPORTANT (shared-module reuse): this module is imported ONCE per process
|
|
30
|
+
while Alembic re-executes each chain's ``env.py`` shim on every run. Nothing
|
|
31
|
+
here may bind ``context.config`` at module level - every function resolves
|
|
32
|
+
``context.config`` lazily at call time so each run sees its own ``Config``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import asyncio
|
|
38
|
+
from typing import TYPE_CHECKING, Any
|
|
39
|
+
|
|
40
|
+
from alembic import context
|
|
41
|
+
from sqlalchemy import pool
|
|
42
|
+
from sqlalchemy.engine import Connection
|
|
43
|
+
from sqlalchemy.ext.asyncio import async_engine_from_config
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from alembic.config import Config
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"do_run_migrations",
|
|
50
|
+
"run_async_migrations",
|
|
51
|
+
"run_migrations_offline",
|
|
52
|
+
"run_migrations_online",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _shared_configure_kwargs(config: "Config") -> dict[str, Any]:
|
|
57
|
+
"""Options shared by the offline and online ``configure`` calls."""
|
|
58
|
+
kwargs: dict[str, Any] = {
|
|
59
|
+
"target_metadata": config.attributes.get("target_metadata"),
|
|
60
|
+
"include_object": config.attributes.get("include_object"),
|
|
61
|
+
"include_name": config.attributes.get("include_name"),
|
|
62
|
+
}
|
|
63
|
+
version_table = config.get_main_option("version_table")
|
|
64
|
+
if version_table:
|
|
65
|
+
# Deliberate explicit pass-through: alembic 1.20.0 reads version_table
|
|
66
|
+
# from the MigrationContext opts only, never from main options.
|
|
67
|
+
kwargs["version_table"] = version_table
|
|
68
|
+
return kwargs
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def do_run_migrations(connection: Connection) -> None:
|
|
72
|
+
"""Configure ``context`` for an online run against ``connection``."""
|
|
73
|
+
config = context.config
|
|
74
|
+
context.configure(
|
|
75
|
+
connection=connection,
|
|
76
|
+
**_shared_configure_kwargs(config),
|
|
77
|
+
)
|
|
78
|
+
with context.begin_transaction():
|
|
79
|
+
context.run_migrations()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def run_migrations_offline() -> None:
|
|
83
|
+
"""Render the chain's SQL without a database connection (offline mode)."""
|
|
84
|
+
config = context.config
|
|
85
|
+
kwargs = _shared_configure_kwargs(config)
|
|
86
|
+
kwargs.update(
|
|
87
|
+
{
|
|
88
|
+
"url": config.get_main_option("sqlalchemy.url"),
|
|
89
|
+
"literal_binds": True,
|
|
90
|
+
"dialect_opts": {"paramstyle": "named"},
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
context.configure(**kwargs)
|
|
94
|
+
with context.begin_transaction():
|
|
95
|
+
context.run_migrations()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def run_async_migrations() -> None:
|
|
99
|
+
"""Fresh NullPool async engine, upgraded, disposed - all per run."""
|
|
100
|
+
config = context.config
|
|
101
|
+
connectable = async_engine_from_config(
|
|
102
|
+
config.get_section(config.config_ini_section, {}),
|
|
103
|
+
prefix="sqlalchemy.",
|
|
104
|
+
poolclass=pool.NullPool,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
async with connectable.connect() as connection:
|
|
108
|
+
await connection.run_sync(do_run_migrations)
|
|
109
|
+
|
|
110
|
+
await connectable.dispose()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def run_migrations_online() -> None:
|
|
114
|
+
"""Entry point called by chain shims in online mode.
|
|
115
|
+
|
|
116
|
+
Runs in the worker thread that ``runner.run_migrations`` spawned via
|
|
117
|
+
``asyncio.to_thread``, so no event loop is running here and
|
|
118
|
+
``asyncio.run`` owns a fresh loop (and therefore the engine) per run.
|
|
119
|
+
"""
|
|
120
|
+
asyncio.run(run_async_migrations())
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lane-keyed chain registry (design D6 of the ``alembic-2fast`` change).
|
|
3
|
+
|
|
4
|
+
Mirrors the ``register_seeder`` idiom: consumers register their chain specs at
|
|
5
|
+
import time, the app composes lanes explicitly, and the runner executes them
|
|
6
|
+
in registration order per lane.
|
|
7
|
+
|
|
8
|
+
- AUTH lane orders ``oauth`` -> ``permissions-global`` -> ``tenant2fast-auth``
|
|
9
|
+
(FK-proven); registration order within a lane is preserved.
|
|
10
|
+
- TENANT lane chains (``tenant2fast-rbac``, ``app``) are order-independent;
|
|
11
|
+
callers may still declare an explicit preferred order.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .runner import ChainSpec
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"clear_chain_registry",
|
|
23
|
+
"get_lane_chains",
|
|
24
|
+
"register_chain",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
_CHAIN_REGISTRY: dict[str, list["ChainSpec"]] = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def register_chain(lane: str, chain: "ChainSpec") -> None:
|
|
31
|
+
"""
|
|
32
|
+
Register a chain spec under a lane.
|
|
33
|
+
|
|
34
|
+
Re-registering the same ``(package, script_path)`` key updates that entry
|
|
35
|
+
in place (idempotent, position preserved); a distinct chain appends.
|
|
36
|
+
Order of registration is the order ``get_lane_chains`` returns, which is
|
|
37
|
+
the execution order for ordering-sensitive lanes.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
lane: Lane name, e.g. ``"auth"`` or ``"tenant"``.
|
|
41
|
+
chain: The chain spec to register.
|
|
42
|
+
"""
|
|
43
|
+
chains = _CHAIN_REGISTRY.setdefault(lane, [])
|
|
44
|
+
for index, existing in enumerate(chains):
|
|
45
|
+
if (
|
|
46
|
+
existing.package == chain.package
|
|
47
|
+
and existing.script_path == chain.script_path
|
|
48
|
+
):
|
|
49
|
+
chains[index] = chain
|
|
50
|
+
return
|
|
51
|
+
chains.append(chain)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_lane_chains(lane: str) -> list["ChainSpec"]:
|
|
55
|
+
"""
|
|
56
|
+
Return a defensive copy of the chains registered for a lane.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
lane: Lane name, e.g. ``"auth"`` or ``"tenant"``.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
List of ``ChainSpec`` objects in registration order (empty if none).
|
|
63
|
+
"""
|
|
64
|
+
return list(_CHAIN_REGISTRY.get(lane, []))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def clear_chain_registry() -> None:
|
|
68
|
+
"""Clear all registered chains. Useful for testing."""
|
|
69
|
+
global _CHAIN_REGISTRY
|
|
70
|
+
_CHAIN_REGISTRY = {}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async migration runner for package-owned Alembic chains (design D3 of the
|
|
3
|
+
``alembic-2fast`` change).
|
|
4
|
+
|
|
5
|
+
``run_migrations(connection_name, chains)`` upgrades each chain to ``head``
|
|
6
|
+
in declaration order. Key properties:
|
|
7
|
+
|
|
8
|
+
- URL resolution is CONFIG-ONLY: ``manager.config.get_connection_url(name)``.
|
|
9
|
+
``DatabaseManager._engines`` (pooled app engines) is never read or reused -
|
|
10
|
+
each chain run gets a fresh ``NullPool`` async engine inside env.py that is
|
|
11
|
+
disposed after the run.
|
|
12
|
+
- Each chain runs in a worker thread via ``asyncio.to_thread``, so env.py's
|
|
13
|
+
``asyncio.run`` owns a fresh event loop (and engine) per run - it can never
|
|
14
|
+
collide with the app's lifespan loop.
|
|
15
|
+
- ``script_location`` is an alembic-native ``"<package>:<chain_path>"``
|
|
16
|
+
resource string: Alembic resolves it from the INSTALLED release via
|
|
17
|
+
``coerce_resource_to_filename`` (importlib.resources), never from a
|
|
18
|
+
checkout.
|
|
19
|
+
- Ownership filtering (design D9): ``include_object`` / ``include_name``
|
|
20
|
+
attributes admit only tables (and their child objects) listed in
|
|
21
|
+
``owned_tables``, so autogenerate on a shared ``MetaData`` never proposes
|
|
22
|
+
foreign tables.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Any, Callable, Sequence
|
|
30
|
+
|
|
31
|
+
from alembic import command
|
|
32
|
+
from alembic.config import Config
|
|
33
|
+
from sqlalchemy import MetaData
|
|
34
|
+
|
|
35
|
+
from ..connection import get_manager
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"ChainSpec",
|
|
39
|
+
"run_migrations",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
#: Object types whose ownership derives from their parent table (type names
|
|
43
|
+
#: verified against alembic 1.20.0 ``NameFilterType``).
|
|
44
|
+
_OWNED_PARENT_TYPES = frozenset(
|
|
45
|
+
{
|
|
46
|
+
"index",
|
|
47
|
+
"unique_constraint",
|
|
48
|
+
"foreign_key_constraint",
|
|
49
|
+
"check_constraint",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class ChainSpec:
|
|
56
|
+
"""
|
|
57
|
+
Description of one package-owned Alembic chain shipped inside a wheel.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
package: Import name of the wheel's package, e.g. ``"oauth2fast_fastapi"``.
|
|
61
|
+
script_path: Chain directory inside the package, e.g. ``"migrations/oauth"``.
|
|
62
|
+
version_table: Name of the chain's version table, e.g.
|
|
63
|
+
``"alembic_version_oauth"`` (per-chain, never the default).
|
|
64
|
+
target_metadata: Chain-owned ``MetaData`` for autogenerate comparison.
|
|
65
|
+
owned_tables: Table names this chain owns; the autogenerate ownership
|
|
66
|
+
filter admits only these (design D9).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
package: str
|
|
70
|
+
script_path: str
|
|
71
|
+
version_table: str
|
|
72
|
+
target_metadata: MetaData
|
|
73
|
+
owned_tables: frozenset[str]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _owner_filter(owned_tables: frozenset[str]) -> Callable[..., bool]:
|
|
77
|
+
"""
|
|
78
|
+
Build an ownership filter usable for BOTH alembic include hooks (D9).
|
|
79
|
+
|
|
80
|
+
Alembic calls ``include_object`` with
|
|
81
|
+
``(object_, name, type_, reflected, compare_to)`` and ``include_name``
|
|
82
|
+
with ``(name, type_, parent_names)``; the returned callable dispatches on
|
|
83
|
+
the argument count:
|
|
84
|
+
|
|
85
|
+
- ``type_ == "table"`` -> the table's own name must be owned.
|
|
86
|
+
- index / unique / FK / check constraint -> the object's *parent table*
|
|
87
|
+
must be owned (no cross-lane objects are proposed).
|
|
88
|
+
- anything else -> admitted.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
owned_tables: Table names owned by the chain.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
A callable assigning to ``cfg.attributes["include_object"]`` and
|
|
95
|
+
``cfg.attributes["include_name"]``.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def _filter(*args: Any) -> bool:
|
|
99
|
+
if len(args) == 3: # include_name(name, type_, parent_names)
|
|
100
|
+
name, type_, parent_names = args
|
|
101
|
+
parent_table = None
|
|
102
|
+
else: # include_object(object_, name, type_, reflected, compare_to)
|
|
103
|
+
object_, name, type_, _, _ = args
|
|
104
|
+
parent_table = getattr(object_, "table", None)
|
|
105
|
+
|
|
106
|
+
if type_ == "table":
|
|
107
|
+
return name in owned_tables
|
|
108
|
+
if type_ in _OWNED_PARENT_TYPES:
|
|
109
|
+
if parent_table is not None:
|
|
110
|
+
return parent_table.name in owned_tables
|
|
111
|
+
return parent_names.get("table_name") in owned_tables
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
return _filter
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _chain_config(chain: ChainSpec, url: str) -> Config:
|
|
118
|
+
"""
|
|
119
|
+
Build the programmatic Alembic ``Config`` for one chain run (no ini file).
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
chain: The chain spec to configure.
|
|
123
|
+
url: The connection URL (already resolved from manager config).
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
A ``Config`` whose main options and attributes drive the shared env.
|
|
127
|
+
"""
|
|
128
|
+
cfg = Config()
|
|
129
|
+
cfg.set_main_option(
|
|
130
|
+
"script_location", f"{chain.package}:{chain.script_path}"
|
|
131
|
+
)
|
|
132
|
+
cfg.set_main_option("sqlalchemy.url", url)
|
|
133
|
+
cfg.set_main_option("version_table", chain.version_table)
|
|
134
|
+
cfg.attributes["target_metadata"] = chain.target_metadata
|
|
135
|
+
cfg.attributes["include_object"] = _owner_filter(chain.owned_tables)
|
|
136
|
+
cfg.attributes["include_name"] = _owner_filter(chain.owned_tables)
|
|
137
|
+
return cfg
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def run_migrations(
|
|
141
|
+
connection_name: str, chains: Sequence[ChainSpec]
|
|
142
|
+
) -> None:
|
|
143
|
+
"""
|
|
144
|
+
Upgrade every chain to head for a named connection, in declared order.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
connection_name: Named connection from the manager config (e.g.
|
|
148
|
+
``"auth"``, ``"tenant_1"``).
|
|
149
|
+
chains: Chain specs to apply, in FK-proven order.
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
ValueError: If the connection name is not configured.
|
|
153
|
+
Any exception raised by Alembic's ``command.upgrade`` (fail-fast per
|
|
154
|
+
chain; the caller decides lane error policy).
|
|
155
|
+
"""
|
|
156
|
+
manager = get_manager()
|
|
157
|
+
# Config only - pooled app engines under manager._engines are never used.
|
|
158
|
+
url = manager.config.get_connection_url(connection_name)
|
|
159
|
+
|
|
160
|
+
for chain in chains:
|
|
161
|
+
await asyncio.to_thread(
|
|
162
|
+
command.upgrade, _chain_config(chain, url), "head"
|
|
163
|
+
)
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pgsqlasync2fast-fastapi
|
|
3
|
-
description: "Trigger: working on or with pgsqlasync2fast-fastapi. Multi-database async engine manager for FastAPI: DB_CONNECTIONS config, get_db_session deps, seeder orchestrator, tenant engines. Prevails over the 2fast-handbook base skill for this package."
|
|
3
|
+
description: "Trigger: working on or with pgsqlasync2fast-fastapi. Multi-database async engine manager for FastAPI: DB_CONNECTIONS config, get_db_session deps, seeder orchestrator, tenant engines, shared Alembic migration runner. Prevails over the 2fast-handbook base skill for this package."
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
6
|
author: AngelDanielSanchezCastillo
|
|
7
|
-
version: "2.
|
|
7
|
+
version: "2.2"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
## Purpose
|
|
@@ -33,6 +33,12 @@ engines/session factories keyed by connection name (`default`, `auth`,
|
|
|
33
33
|
reuse this instead of hand-rolling per package (RBAC standardization D2). No
|
|
34
34
|
commit — caller owns the transaction boundary. Also re-exported from the top-level
|
|
35
35
|
`pgsqlasync2fast_fastapi` package.
|
|
36
|
+
- Migration infra (`pgsqlasync2fast_fastapi.migrations`, alembic-2fast change):
|
|
37
|
+
- `ChainSpec(package, script_path, version_table, target_metadata, owned_tables)` — frozen spec of one wheel-shipped chain.
|
|
38
|
+
- `register_chain(lane, chain)` / `get_lane_chains(lane)` / `clear_chain_registry()` — lane-keyed registry, same idiom as `register_seeder` (same-key re-registration updates in place; registration order = execution order).
|
|
39
|
+
- `run_migrations(connection_name, chains)` — async runner per the OFFICIAL alembic recipe: URL config-only (`manager.config.get_connection_url`), each chain upgraded to `head` via `asyncio.to_thread`; shared `migrations/env.py` creates a fresh `async_engine_from_config` + `NullPool` engine and disposes it per run. **NEVER touches `DatabaseManager._engines`** (pooled app engines).
|
|
40
|
+
- env contract: `script_location` = `"<pkg>:<chain_path>"` resource string resolved from the INSTALLED release (alembic `coerce_resource_to_filename`), never a checkout; `version_table` read via `config.get_main_option` and passed EXPLICITLY to `context.configure(version_table=...)` (alembic 1.20.0 does not auto-transport it); include_object/include_name ownership filters admit only `owned_tables` (+ their indexes/constraints).
|
|
41
|
+
- `alembic==1.20.0` pinned EXACTLY in runtime deps (deliberate re-pin rule).
|
|
36
42
|
|
|
37
43
|
## Architecture
|
|
38
44
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pgsqlasync2fast-fastapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Simple and fast PostgreSQL async module for FastAPI with multi-database support
|
|
5
5
|
Author-email: Angel Daniel Sanchez Castillo <angeldaniel.sanchezcastillo@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -50,6 +50,7 @@ Requires-Dist: sqlalchemy>=2.0.0
|
|
|
50
50
|
Requires-Dist: sqlmodel>=0.0.22
|
|
51
51
|
Requires-Dist: asyncpg>=0.29.0
|
|
52
52
|
Requires-Dist: greenlet>=3.0.0
|
|
53
|
+
Requires-Dist: alembic==1.20.0
|
|
53
54
|
Dynamic: license-file
|
|
54
55
|
|
|
55
56
|
# pgsqlasync2fast-fastapi
|
|
@@ -27,9 +27,14 @@ src/pgsqlasync2fast_fastapi.egg-info/SOURCES.txt
|
|
|
27
27
|
src/pgsqlasync2fast_fastapi.egg-info/dependency_links.txt
|
|
28
28
|
src/pgsqlasync2fast_fastapi.egg-info/requires.txt
|
|
29
29
|
src/pgsqlasync2fast_fastapi.egg-info/top_level.txt
|
|
30
|
+
src/pgsqlasync2fast_fastapi/migrations/__init__.py
|
|
31
|
+
src/pgsqlasync2fast_fastapi/migrations/env.py
|
|
32
|
+
src/pgsqlasync2fast_fastapi/migrations/registry.py
|
|
33
|
+
src/pgsqlasync2fast_fastapi/migrations/runner.py
|
|
30
34
|
src/pgsqlasync2fast_fastapi/skills/SKILL.md
|
|
31
35
|
tests/test_completo.py
|
|
32
36
|
tests/test_insert_if_missing.py
|
|
37
|
+
tests/test_migration_chains.py
|
|
33
38
|
tests/test_seeder.py
|
|
34
39
|
tests/test_seeder_override.py
|
|
35
40
|
tests/test_sync_sequence.py
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the shared Alembic migration infrastructure (pgsqlasync2fast-fastapi).
|
|
3
|
+
|
|
4
|
+
This module verifies the runner + chain registry shipped by this package for
|
|
5
|
+
the 2fast-family migration overhaul (Metal ERP change ``alembic-2fast``):
|
|
6
|
+
|
|
7
|
+
1. ``ChainSpec`` — frozen dataclass describing an installed wheel's chain.
|
|
8
|
+
2. ``register_chain`` / ``get_lane_chains`` / ``clear_chain_registry`` —
|
|
9
|
+
lane-keyed chain registry (same idiom as ``register_seeder``).
|
|
10
|
+
3. ``run_migrations(connection_name, chains)`` — per-run async engine via
|
|
11
|
+
``async_engine_from_config`` + ``NullPool`` + ``dispose()`` in the shared
|
|
12
|
+
env; the URL comes from ``manager.config`` only and ``DatabaseManager._engines``
|
|
13
|
+
is NEVER touched.
|
|
14
|
+
4. Shared ``migrations/env.py`` — resolves ``context.config`` lazily per run
|
|
15
|
+
(the module is imported once per process while alembic re-executes each
|
|
16
|
+
chain's env.py shim), reads ``version_table`` via
|
|
17
|
+
``config.get_main_option("version_table")`` and passes it EXPLICITLY to
|
|
18
|
+
``context.configure(version_table=...)``.
|
|
19
|
+
|
|
20
|
+
No PostgreSQL is required: ``command.upgrade`` is mocked and the env contract
|
|
21
|
+
is asserted at the ``context.configure`` level.
|
|
22
|
+
|
|
23
|
+
Run with:
|
|
24
|
+
uv run pytest tests/test_migration_chains.py -v
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from types import SimpleNamespace
|
|
30
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
31
|
+
|
|
32
|
+
import pytest
|
|
33
|
+
from alembic.config import Config
|
|
34
|
+
from contextlib import nullcontext
|
|
35
|
+
from pydantic import SecretStr
|
|
36
|
+
from sqlalchemy import MetaData, pool
|
|
37
|
+
|
|
38
|
+
from pgsqlasync2fast_fastapi.connection import DatabaseManager
|
|
39
|
+
from pgsqlasync2fast_fastapi.migrations import (
|
|
40
|
+
ChainSpec,
|
|
41
|
+
clear_chain_registry,
|
|
42
|
+
get_lane_chains,
|
|
43
|
+
register_chain,
|
|
44
|
+
run_migrations,
|
|
45
|
+
)
|
|
46
|
+
from pgsqlasync2fast_fastapi.settings import DatabaseConnectionSettings, DatabaseSettings
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.fixture(autouse=True)
|
|
50
|
+
def clear_chains():
|
|
51
|
+
"""Clear the chain registry before and after each test."""
|
|
52
|
+
clear_chain_registry()
|
|
53
|
+
yield
|
|
54
|
+
clear_chain_registry()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _chain(
|
|
58
|
+
package: str = "oauth2fast_fastapi",
|
|
59
|
+
script_path: str = "migrations/oauth",
|
|
60
|
+
version_table: str = "alembic_version_oauth",
|
|
61
|
+
owned: tuple[str, ...] = ("users", "roles"),
|
|
62
|
+
) -> ChainSpec:
|
|
63
|
+
return ChainSpec(
|
|
64
|
+
package=package,
|
|
65
|
+
script_path=script_path,
|
|
66
|
+
version_table=version_table,
|
|
67
|
+
target_metadata=MetaData(),
|
|
68
|
+
owned_tables=frozenset(owned),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _manager_with_auth() -> DatabaseManager:
|
|
73
|
+
"""Database manager whose config holds a single 'auth' connection."""
|
|
74
|
+
config = DatabaseSettings(
|
|
75
|
+
connections={
|
|
76
|
+
"auth": DatabaseConnectionSettings(
|
|
77
|
+
host="localhost",
|
|
78
|
+
port=5433,
|
|
79
|
+
username="mig_user",
|
|
80
|
+
password=SecretStr("mig_pass"),
|
|
81
|
+
database="authdb",
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
return DatabaseManager(config)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ============================================================================
|
|
89
|
+
# Registry: register_chain / get_lane_chains / clear_chain_registry
|
|
90
|
+
# ============================================================================
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_register_and_get_lane_chains_preserve_registration_order():
|
|
94
|
+
oauth = _chain()
|
|
95
|
+
permissions = _chain(
|
|
96
|
+
package="permissions2fast_fastapi",
|
|
97
|
+
script_path="migrations/permissions-global",
|
|
98
|
+
version_table="alembic_version_permissions",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
register_chain("auth", oauth)
|
|
102
|
+
register_chain("auth", permissions)
|
|
103
|
+
|
|
104
|
+
assert get_lane_chains("auth") == [oauth, permissions]
|
|
105
|
+
assert get_lane_chains("tenant") == []
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_register_chain_same_key_updates_in_place():
|
|
109
|
+
register_chain("auth", _chain(version_table="alembic_version_oauth"))
|
|
110
|
+
|
|
111
|
+
updated = _chain(version_table="alembic_version_oauth_v2")
|
|
112
|
+
register_chain("auth", updated)
|
|
113
|
+
|
|
114
|
+
chains = get_lane_chains("auth")
|
|
115
|
+
assert chains == [updated] # same position, same key, not duplicated
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_get_lane_chains_returns_defensive_copy():
|
|
119
|
+
register_chain("auth", _chain())
|
|
120
|
+
|
|
121
|
+
chains = get_lane_chains("auth")
|
|
122
|
+
chains.clear()
|
|
123
|
+
|
|
124
|
+
assert len(get_lane_chains("auth")) == 1
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_clear_chain_registry_empties_all_lanes():
|
|
128
|
+
register_chain("auth", _chain())
|
|
129
|
+
register_chain("tenant", _chain(version_table="alembic_version_tenant_auth"))
|
|
130
|
+
|
|
131
|
+
clear_chain_registry()
|
|
132
|
+
|
|
133
|
+
assert get_lane_chains("auth") == []
|
|
134
|
+
assert get_lane_chains("tenant") == []
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_chain_spec_is_frozen():
|
|
138
|
+
chain = _chain()
|
|
139
|
+
with pytest.raises(Exception):
|
|
140
|
+
chain.version_table = "alembic_version_other"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ============================================================================
|
|
144
|
+
# _owner_filter: include_object / include_name ownership semantics
|
|
145
|
+
# ============================================================================
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_owner_filter_admits_only_owned_tables():
|
|
149
|
+
from pgsqlasync2fast_fastapi.migrations.runner import _owner_filter
|
|
150
|
+
|
|
151
|
+
owned = frozenset({"users"})
|
|
152
|
+
owner = _owner_filter(owned)
|
|
153
|
+
|
|
154
|
+
users = MagicMock()
|
|
155
|
+
users.name = "users"
|
|
156
|
+
roles = MagicMock()
|
|
157
|
+
roles.name = "roles"
|
|
158
|
+
|
|
159
|
+
# include_object(object_, name, type_, reflected, compare_to)
|
|
160
|
+
assert owner(users, "users", "table", False, None) is True
|
|
161
|
+
assert owner(roles, "roles", "table", False, None) is False
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_owner_filter_constraints_follow_owning_table():
|
|
165
|
+
from pgsqlasync2fast_fastapi.migrations.runner import _owner_filter
|
|
166
|
+
|
|
167
|
+
owner = _owner_filter(frozenset({"users"}))
|
|
168
|
+
|
|
169
|
+
owned_idx = MagicMock()
|
|
170
|
+
owned_idx.table = MagicMock()
|
|
171
|
+
owned_idx.table.name = "users"
|
|
172
|
+
foreign_idx = MagicMock()
|
|
173
|
+
foreign_idx.table = MagicMock()
|
|
174
|
+
foreign_idx.table.name = "roles"
|
|
175
|
+
|
|
176
|
+
# include_object on index/constraint types dispatches by parent table
|
|
177
|
+
assert owner(owned_idx, "ix_users_a", "index", False, None) is True
|
|
178
|
+
assert owner(foreign_idx, "ix_roles_a", "index", False, None) is False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_owner_filter_include_name_reflected_signature():
|
|
182
|
+
from pgsqlasync2fast_fastapi.migrations.runner import _owner_filter
|
|
183
|
+
|
|
184
|
+
owner = _owner_filter(frozenset({"users"}))
|
|
185
|
+
|
|
186
|
+
# include_name(name, type_, parent_names)
|
|
187
|
+
assert owner("users", "table", {}) is True
|
|
188
|
+
assert owner("roles", "table", {}) is False
|
|
189
|
+
assert owner("ix_users_a", "index", {"table_name": "users"}) is True
|
|
190
|
+
assert owner("ix_roles_a", "index", {"table_name": "roles"}) is False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ============================================================================
|
|
194
|
+
# _chain_config: programmatic alembic Config per chain
|
|
195
|
+
# ============================================================================
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_chain_config_sets_script_location_url_and_version_table():
|
|
199
|
+
from pgsqlasync2fast_fastapi.migrations.runner import _chain_config
|
|
200
|
+
|
|
201
|
+
cfg = _chain_config(_chain(), "postgresql+asyncpg://u:p@h:5432/authdb")
|
|
202
|
+
|
|
203
|
+
assert cfg.get_main_option("script_location") == "oauth2fast_fastapi:migrations/oauth"
|
|
204
|
+
assert cfg.get_main_option("sqlalchemy.url") == "postgresql+asyncpg://u:p@h:5432/authdb"
|
|
205
|
+
assert cfg.get_main_option("version_table") == "alembic_version_oauth"
|
|
206
|
+
assert cfg.attributes["target_metadata"] is not None
|
|
207
|
+
assert callable(cfg.attributes["include_object"])
|
|
208
|
+
assert callable(cfg.attributes["include_name"])
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ============================================================================
|
|
212
|
+
# run_migrations: config-only URL, per-chain async upgrade, no pooled engines
|
|
213
|
+
# ============================================================================
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@pytest.mark.asyncio
|
|
217
|
+
async def test_run_migrations_applies_chains_in_order_with_config_url():
|
|
218
|
+
manager = _manager_with_auth()
|
|
219
|
+
oauth = _chain()
|
|
220
|
+
permissions = _chain(
|
|
221
|
+
package="permissions2fast_fastapi",
|
|
222
|
+
script_path="migrations/permissions-global",
|
|
223
|
+
version_table="alembic_version_permissions",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
with (
|
|
227
|
+
patch("pgsqlasync2fast_fastapi.migrations.runner.get_manager", return_value=manager),
|
|
228
|
+
patch("pgsqlasync2fast_fastapi.migrations.runner.command.upgrade") as mock_upgrade,
|
|
229
|
+
):
|
|
230
|
+
await run_migrations("auth", [oauth, permissions])
|
|
231
|
+
|
|
232
|
+
assert mock_upgrade.call_count == 2
|
|
233
|
+
first_cfg, first_rev = mock_upgrade.call_args_list[0].args
|
|
234
|
+
second_cfg, second_rev = mock_upgrade.call_args_list[1].args
|
|
235
|
+
|
|
236
|
+
assert first_rev == "head"
|
|
237
|
+
assert second_rev == "head"
|
|
238
|
+
assert first_cfg.get_main_option("script_location") == "oauth2fast_fastapi:migrations/oauth"
|
|
239
|
+
assert second_cfg.get_main_option("script_location") == "permissions2fast_fastapi:migrations/permissions-global"
|
|
240
|
+
assert first_cfg.get_main_option("version_table") == "alembic_version_oauth"
|
|
241
|
+
assert second_cfg.get_main_option("version_table") == "alembic_version_permissions"
|
|
242
|
+
|
|
243
|
+
expected_url = "postgresql+asyncpg://mig_user:mig_pass@localhost:5433/authdb"
|
|
244
|
+
assert first_cfg.get_main_option("sqlalchemy.url") == expected_url
|
|
245
|
+
assert second_cfg.get_main_option("sqlalchemy.url") == expected_url
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@pytest.mark.asyncio
|
|
249
|
+
async def test_run_migrations_never_touches_pooled_engines():
|
|
250
|
+
manager = _manager_with_auth()
|
|
251
|
+
|
|
252
|
+
with (
|
|
253
|
+
patch("pgsqlasync2fast_fastapi.migrations.runner.get_manager", return_value=manager),
|
|
254
|
+
patch("pgsqlasync2fast_fastapi.migrations.runner.command.upgrade"),
|
|
255
|
+
):
|
|
256
|
+
await run_migrations("auth", [_chain()])
|
|
257
|
+
|
|
258
|
+
# The pooled engine registry must remain untouched: app traffic keeps its
|
|
259
|
+
# engines, migrations get a fresh NullPool engine created/disposed per run.
|
|
260
|
+
assert manager._engines == {}
|
|
261
|
+
assert manager._session_makers == {}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ============================================================================
|
|
265
|
+
# Shared env: version_table pass-through, NullPool, dispose per run, lazy config
|
|
266
|
+
# ============================================================================
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_env_do_run_migrations_passes_version_table_explicitly(monkeypatch):
|
|
270
|
+
import pgsqlasync2fast_fastapi.migrations.env as env_mod
|
|
271
|
+
|
|
272
|
+
cfg = Config()
|
|
273
|
+
cfg.set_main_option("version_table", "alembic_version_oauth")
|
|
274
|
+
cfg.attributes["target_metadata"] = MetaData()
|
|
275
|
+
include_object = MagicMock(return_value=True)
|
|
276
|
+
include_name = MagicMock(return_value=True)
|
|
277
|
+
cfg.attributes["include_object"] = include_object
|
|
278
|
+
cfg.attributes["include_name"] = include_name
|
|
279
|
+
|
|
280
|
+
captured: dict = {}
|
|
281
|
+
|
|
282
|
+
class FakeContext:
|
|
283
|
+
config = cfg
|
|
284
|
+
|
|
285
|
+
def configure(self, **kwargs):
|
|
286
|
+
captured.update(kwargs)
|
|
287
|
+
|
|
288
|
+
def begin_transaction(self):
|
|
289
|
+
return nullcontext()
|
|
290
|
+
|
|
291
|
+
def run_migrations(self):
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
monkeypatch.setattr(env_mod, "context", FakeContext())
|
|
295
|
+
|
|
296
|
+
env_mod.do_run_migrations(connection=MagicMock())
|
|
297
|
+
|
|
298
|
+
# version_table is passed EXPLICITLY — alembic 1.20.0 does not transport
|
|
299
|
+
# the main option into MigrationContext on its own (design D1).
|
|
300
|
+
assert captured["version_table"] == "alembic_version_oauth"
|
|
301
|
+
assert captured["target_metadata"] is cfg.attributes["target_metadata"]
|
|
302
|
+
assert captured["include_object"] is include_object
|
|
303
|
+
assert captured["include_name"] is include_name
|
|
304
|
+
assert captured["connection"] is not None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_env_offline_configure_uses_url_and_version_table(monkeypatch):
|
|
308
|
+
import pgsqlasync2fast_fastapi.migrations.env as env_mod
|
|
309
|
+
|
|
310
|
+
cfg = Config()
|
|
311
|
+
cfg.set_main_option("sqlalchemy.url", "postgresql+asyncpg://u:p@h:5432/authdb")
|
|
312
|
+
cfg.set_main_option("version_table", "alembic_version_oauth")
|
|
313
|
+
|
|
314
|
+
captured: dict = {}
|
|
315
|
+
|
|
316
|
+
class FakeContext:
|
|
317
|
+
config = cfg
|
|
318
|
+
|
|
319
|
+
def configure(self, **kwargs):
|
|
320
|
+
captured.update(kwargs)
|
|
321
|
+
|
|
322
|
+
def begin_transaction(self):
|
|
323
|
+
return nullcontext()
|
|
324
|
+
|
|
325
|
+
def run_migrations(self):
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
monkeypatch.setattr(env_mod, "context", FakeContext())
|
|
329
|
+
|
|
330
|
+
env_mod.run_migrations_offline()
|
|
331
|
+
|
|
332
|
+
assert captured["url"] == "postgresql+asyncpg://u:p@h:5432/authdb"
|
|
333
|
+
assert captured["version_table"] == "alembic_version_oauth"
|
|
334
|
+
assert captured["literal_binds"] is True
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@pytest.mark.asyncio
|
|
338
|
+
async def test_env_async_uses_nullpool_and_disposes_per_run(monkeypatch):
|
|
339
|
+
import pgsqlasync2fast_fastapi.migrations.env as env_mod
|
|
340
|
+
|
|
341
|
+
cfg = Config()
|
|
342
|
+
cfg.set_main_option("sqlalchemy.url", "postgresql+asyncpg://u:p@h:5432/authdb")
|
|
343
|
+
|
|
344
|
+
connection = MagicMock()
|
|
345
|
+
connection.run_sync = AsyncMock()
|
|
346
|
+
engine = MagicMock()
|
|
347
|
+
# connect() is a SYNC call returning an async context manager, whose
|
|
348
|
+
# __aenter__ yields the connection used by run_sync.
|
|
349
|
+
engine.connect = MagicMock()
|
|
350
|
+
engine.connect.return_value.__aenter__ = AsyncMock(return_value=connection)
|
|
351
|
+
engine.connect.return_value.__aexit__ = AsyncMock(return_value=False)
|
|
352
|
+
engine.dispose = AsyncMock()
|
|
353
|
+
|
|
354
|
+
seen: dict = {}
|
|
355
|
+
|
|
356
|
+
def fake_async_engine_from_config(section, prefix, poolclass):
|
|
357
|
+
seen["prefix"] = prefix
|
|
358
|
+
seen["poolclass"] = poolclass
|
|
359
|
+
return engine
|
|
360
|
+
|
|
361
|
+
monkeypatch.setattr(env_mod, "async_engine_from_config", fake_async_engine_from_config)
|
|
362
|
+
monkeypatch.setattr(env_mod, "context", SimpleNamespace(config=cfg))
|
|
363
|
+
|
|
364
|
+
await env_mod.run_async_migrations()
|
|
365
|
+
|
|
366
|
+
assert seen["prefix"] == "sqlalchemy."
|
|
367
|
+
assert seen["poolclass"] is pool.NullPool
|
|
368
|
+
connection.run_sync.assert_awaited_once()
|
|
369
|
+
engine.dispose.assert_awaited_once()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def test_shared_env_resolves_config_lazily_per_run(monkeypatch):
|
|
373
|
+
"""The shared env module is imported once but each chain run must see its
|
|
374
|
+
own Config — funcions resolve ``context.config`` at call time."""
|
|
375
|
+
import pgsqlasync2fast_fastapi.migrations.env as env_mod
|
|
376
|
+
|
|
377
|
+
captured: list[dict] = []
|
|
378
|
+
|
|
379
|
+
class FakeContext:
|
|
380
|
+
def __init__(self, config):
|
|
381
|
+
self.config = config
|
|
382
|
+
|
|
383
|
+
def configure(self, **kwargs):
|
|
384
|
+
captured.append(kwargs)
|
|
385
|
+
|
|
386
|
+
def begin_transaction(self):
|
|
387
|
+
return nullcontext()
|
|
388
|
+
|
|
389
|
+
def run_migrations(self):
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
oauth_cfg = Config()
|
|
393
|
+
oauth_cfg.set_main_option("sqlalchemy.url", "postgresql+asyncpg://u:p@h:5432/authdb")
|
|
394
|
+
oauth_cfg.set_main_option("version_table", "alembic_version_oauth")
|
|
395
|
+
|
|
396
|
+
app_cfg = Config()
|
|
397
|
+
app_cfg.set_main_option("sqlalchemy.url", "postgresql+asyncpg://u:p@h:5432/appdb")
|
|
398
|
+
app_cfg.set_main_option("version_table", "alembic_version_app")
|
|
399
|
+
|
|
400
|
+
monkeypatch.setattr(env_mod, "context", FakeContext(oauth_cfg))
|
|
401
|
+
env_mod.run_migrations_offline()
|
|
402
|
+
|
|
403
|
+
# Simulate the next chain's run: alembic re-executes the shim with a new
|
|
404
|
+
# Config; the shared module must NOT be bound to the first run's config.
|
|
405
|
+
monkeypatch.setattr(env_mod, "context", FakeContext(app_cfg))
|
|
406
|
+
env_mod.run_migrations_offline()
|
|
407
|
+
|
|
408
|
+
assert captured[0]["version_table"] == "alembic_version_oauth"
|
|
409
|
+
assert captured[0]["url"] == "postgresql+asyncpg://u:p@h:5432/authdb"
|
|
410
|
+
assert captured[1]["version_table"] == "alembic_version_app"
|
|
411
|
+
assert captured[1]["url"] == "postgresql+asyncpg://u:p@h:5432/appdb"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.4.1"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/database_creation.py
RENAMED
|
File without changes
|
{pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/fastapi_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
{pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/dev/categories.json
RENAMED
|
File without changes
|
|
File without changes
|
{pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/dev/roles.json
RENAMED
|
File without changes
|
{pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/manifest.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/prod/roles.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/tests/test_insert_if_missing.py
RENAMED
|
File without changes
|
|
File without changes
|
{pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/tests/test_seeder_override.py
RENAMED
|
File without changes
|
|
File without changes
|