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.
Files changed (43) hide show
  1. {pgsqlasync2fast_fastapi-0.4.1/src/pgsqlasync2fast_fastapi.egg-info → pgsqlasync2fast_fastapi-0.5.0}/PKG-INFO +2 -1
  2. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/pyproject.toml +6 -0
  3. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/__init__.py +17 -0
  4. pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi/__version__.py +1 -0
  5. pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi/migrations/__init__.py +49 -0
  6. pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi/migrations/env.py +120 -0
  7. pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi/migrations/registry.py +70 -0
  8. pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi/migrations/runner.py +163 -0
  9. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/skills/SKILL.md +8 -2
  10. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi.egg-info}/PKG-INFO +2 -1
  11. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi.egg-info/SOURCES.txt +5 -0
  12. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi.egg-info/requires.txt +1 -0
  13. pgsqlasync2fast_fastapi-0.5.0/tests/test_migration_chains.py +411 -0
  14. pgsqlasync2fast_fastapi-0.4.1/src/pgsqlasync2fast_fastapi/__version__.py +0 -1
  15. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/LICENSE +0 -0
  16. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/MANIFEST.in +0 -0
  17. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/README.md +0 -0
  18. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/docs/.env.example +0 -0
  19. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/docs/seeder-format.md +0 -0
  20. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/basic_usage.py +0 -0
  21. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/database_creation.py +0 -0
  22. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/fastapi_integration.py +0 -0
  23. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/multi_database.py +0 -0
  24. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/dev/categories.json +0 -0
  25. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/dev/permissions.json +0 -0
  26. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/dev/roles.json +0 -0
  27. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/manifest.json +0 -0
  28. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/prod/categories.json +0 -0
  29. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/prod/permissions.json +0 -0
  30. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/prod/roles.json +0 -0
  31. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/setup.cfg +0 -0
  32. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/connection.py +0 -0
  33. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/database.py +0 -0
  34. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/dependencies.py +0 -0
  35. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/seeder.py +0 -0
  36. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/settings.py +0 -0
  37. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi.egg-info/dependency_links.txt +0 -0
  38. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi.egg-info/top_level.txt +0 -0
  39. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/tests/test_completo.py +0 -0
  40. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/tests/test_insert_if_missing.py +0 -0
  41. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/tests/test_seeder.py +0 -0
  42. {pgsqlasync2fast_fastapi-0.4.1 → pgsqlasync2fast_fastapi-0.5.0}/tests/test_seeder_override.py +0 -0
  43. {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.4.1
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.1"
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.4.1
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
@@ -5,3 +5,4 @@ sqlalchemy>=2.0.0
5
5
  sqlmodel>=0.0.22
6
6
  asyncpg>=0.29.0
7
7
  greenlet>=3.0.0
8
+ alembic==1.20.0
@@ -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"