pgsqlasync2fast-fastapi 0.3.0__py3-none-any.whl → 0.4.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.
@@ -50,6 +50,8 @@ from pgsqlasync2fast_fastapi.seeder import (
50
50
  clear_registry,
51
51
  # Main orchestrator
52
52
  seed_all,
53
+ # Shared idempotent insert-if-missing primitive
54
+ insert_if_missing,
53
55
  )
54
56
 
55
57
  __all__ = [
@@ -86,4 +88,6 @@ __all__ = [
86
88
  "clear_registry",
87
89
  # Main orchestrator (seeder)
88
90
  "seed_all",
91
+ # Shared idempotent insert-if-missing primitive
92
+ "insert_if_missing",
89
93
  ]
@@ -1 +1 @@
1
- __version__ = "0.3.0"
1
+ __version__ = "0.4.0"
@@ -32,7 +32,7 @@ import logging
32
32
  from collections import defaultdict
33
33
  from dataclasses import dataclass, field
34
34
  from pathlib import Path
35
- from typing import TYPE_CHECKING, Any
35
+ from typing import TYPE_CHECKING, Any, Literal
36
36
 
37
37
  if TYPE_CHECKING:
38
38
  from sqlalchemy.ext.asyncio import AsyncEngine
@@ -133,25 +133,38 @@ class SeederResult:
133
133
  # ============================================================================
134
134
 
135
135
 
136
- _SEEDER_REGISTRY: list[SeederConfig] = []
136
+ _SEEDER_REGISTRY: dict[tuple[str, str], SeederConfig] = {}
137
137
 
138
138
 
139
- def register_seeder(config: SeederConfig) -> None:
139
+ def register_seeder(
140
+ config: SeederConfig,
141
+ mode: Literal["retain_base", "replace"] = "retain_base",
142
+ ) -> None:
140
143
  """
141
- Register a package's seeder configuration.
144
+ Register a package's seeder configuration, keyed by (connection, package).
142
145
 
143
- Validates that there are no table conflicts between packages using
144
- the same connection name. Two packages cannot seed the same table
145
- in the same connection.
146
+ The registry is a dict keyed by ``(connection_name, package_name)``, so a
147
+ repeated registration of the same key updates that single entry in place
148
+ (idempotent - no duplicate registry entries).
146
149
 
147
- The validation happens at registration time (not execution time) to fail
148
- fast and provide clear error messages.
150
+ Conflict detection only applies to *distinct* packages on the same
151
+ connection: if two different ``(connection, package)`` keys overlap on
152
+ shared manifest tables, ``SeederConflictError`` is raised. Re-registering
153
+ the same key NEVER raises - it is an override/update, not a conflict.
149
154
 
150
155
  Args:
151
- config: SeederConfig with connection_name, manifest_path, and priority
156
+ config: SeederConfig with connection_name, manifest_path, and priority.
157
+ mode: Override behavior when the same key is already registered:
158
+ - ``"retain_base"`` (default, backward-compatible): replace the
159
+ entry's config fields but MERGE the prior entry's manifest
160
+ ``model_classes`` into the new config's set (base tables are
161
+ preserved when an app extends rather than replaces).
162
+ - ``"replace"``: replace the prior entry wholesale.
152
163
 
153
164
  Raises:
154
- SeederConflictError: If tables overlap with an already-registered seeder
165
+ ValueError: If ``mode`` is not one of the supported values.
166
+ SeederConflictError: If a *distinct* package on the same connection
167
+ shares overlapping tables with an already-registered seeder.
155
168
 
156
169
  Example:
157
170
  register_seeder(SeederConfig(
@@ -161,12 +174,21 @@ def register_seeder(config: SeederConfig) -> None:
161
174
  priority=60
162
175
  ))
163
176
  """
164
- # Load manifests to check for conflicts
165
- for existing in _SEEDER_REGISTRY:
166
- if existing.connection_name != config.connection_name:
177
+ if mode not in ("retain_base", "replace"):
178
+ raise ValueError(f"Unsupported register_seeder mode: {mode!r}")
179
+
180
+ key = (config.connection_name, config.package_name)
181
+
182
+ # True conflict: a DIFFERENT package on the same connection overlapping
183
+ # on manifest tables. Same key is an override, never a conflict.
184
+ # The new config's manifest is only loaded when a candidate exists, so a
185
+ # lone registration with a missing manifest still succeeds (as before).
186
+ for (conn, pkg), existing in _SEEDER_REGISTRY.items():
187
+ if conn != config.connection_name:
188
+ continue
189
+ if (conn, pkg) == key:
167
190
  continue
168
191
 
169
- # Same connection - check for table overlap
170
192
  tables_a = set(_load_manifest(existing.manifest_path)["tables"].keys())
171
193
  tables_b = set(_load_manifest(config.manifest_path)["tables"].keys())
172
194
  overlap = tables_a & tables_b
@@ -180,9 +202,20 @@ def register_seeder(config: SeederConfig) -> None:
180
202
  f"on connection '{config.connection_name}'"
181
203
  )
182
204
 
183
- _SEEDER_REGISTRY.append(config)
205
+ prior = _SEEDER_REGISTRY.get(key)
206
+
207
+ if prior is None or mode == "replace":
208
+ # No prior entry, or wholesale replace: take the new config as-is.
209
+ _SEEDER_REGISTRY[key] = config
210
+ else: # mode == "retain_base": merge prior base model_classes into the new
211
+ # Preserve prior base tables that the new config does not override.
212
+ merged = {**prior.model_classes, **config.model_classes}
213
+ config.model_classes = merged
214
+ _SEEDER_REGISTRY[key] = config
215
+
184
216
  logger.debug(f"Registered seeder: {config.package_name or config.connection_name} "
185
- f"(priority={config.priority}, is_tenant={config.is_tenant_seeder})")
217
+ f"(priority={config.priority}, is_tenant={config.is_tenant_seeder}, "
218
+ f"mode={mode})")
186
219
 
187
220
 
188
221
  def get_registered_seeders() -> list[SeederConfig]:
@@ -192,13 +225,63 @@ def get_registered_seeders() -> list[SeederConfig]:
192
225
  Returns:
193
226
  List of all registered SeederConfig objects
194
227
  """
195
- return list(_SEEDER_REGISTRY)
228
+ return list(_SEEDER_REGISTRY.values())
196
229
 
197
230
 
198
231
  def clear_registry() -> None:
199
232
  """Clear all registered seeders. Useful for testing."""
200
233
  global _SEEDER_REGISTRY
201
- _SEEDER_REGISTRY = []
234
+ _SEEDER_REGISTRY = {}
235
+
236
+
237
+ # ============================================================================
238
+ # Idempotent insert-if-missing primitive (shared across consumer packages)
239
+ # ============================================================================
240
+
241
+
242
+ async def insert_if_missing(
243
+ session: Any,
244
+ model: type,
245
+ lookup: dict[str, Any],
246
+ defaults: dict[str, Any] | None = None,
247
+ ):
248
+ """
249
+ Insert a row if no row matches the natural-key ``lookup`` fields.
250
+
251
+ This is the shared, reusable idempotent **insert-if-missing** primitive.
252
+ Consumer packages (permissions2fast GLOBAL route seeding, tenants2fast
253
+ TENANT route seeding) reuse it instead of hand-rolling per package.
254
+
255
+ A ``SELECT`` by the ``lookup`` fields decides whether the row already
256
+ exists: if it does, the existing row is returned untouched (idempotent);
257
+ otherwise a new row is built from ``lookup`` merged with ``defaults``,
258
+ added to the session and flushed (no commit — the caller decides the
259
+ transaction boundary).
260
+
261
+ Args:
262
+ session: SQLModel AsyncSession (supports both ``exec`` and ``execute``).
263
+ model: SQLModel table class to query/insert.
264
+ lookup: Mapping of column name -> value that forms the row's natural key.
265
+ defaults: Optional extra column values applied only on insert.
266
+
267
+ Returns:
268
+ The existing (found) or newly created row instance.
269
+ """
270
+ from sqlmodel import select
271
+
272
+ stmt = select(model)
273
+ for col, val in lookup.items():
274
+ stmt = stmt.where(getattr(model, col) == val)
275
+ result = await session.exec(stmt)
276
+ existing = result.first()
277
+ if existing is not None:
278
+ return existing
279
+
280
+ merged = {**lookup, **(defaults or {})}
281
+ row = model(**merged)
282
+ session.add(row)
283
+ await session.flush()
284
+ return row
202
285
 
203
286
 
204
287
  # ============================================================================
@@ -683,8 +766,8 @@ async def seed_all(
683
766
  """
684
767
  result = SeederResult()
685
768
 
686
- # Sort by priority (lower = first)
687
- sorted_seeders = sorted(get_registered_seeders(), key=lambda s: s.priority)
769
+ # Iterate registry values, sorted by priority (lower = first)
770
+ sorted_seeders = sorted(_SEEDER_REGISTRY.values(), key=lambda s: s.priority)
688
771
 
689
772
  for seeder in sorted_seeders:
690
773
  # Apply package filter if specified
@@ -798,4 +881,6 @@ __all__ = [
798
881
  "clear_registry",
799
882
  # Main orchestrator
800
883
  "seed_all",
884
+ # Shared idempotent insert-if-missing primitive
885
+ "insert_if_missing",
801
886
  ]
@@ -0,0 +1,82 @@
1
+ ---
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."
4
+ license: MIT
5
+ metadata:
6
+ author: AngelDanielSanchezCastillo
7
+ version: "2.1"
8
+ ---
9
+
10
+ ## Purpose
11
+
12
+ Connection-management foundation of the 2fast stack: multiple PostgreSQL
13
+ engines/session factories keyed by connection name (`default`, `auth`,
14
+ `business`, `tenant_{id}`), lifecycle, and the generic seeder orchestrator.
15
+
16
+ ## Import quirk
17
+
18
+ - Dist `pgsqlasync2fast-fastapi` → import `pgsqlasync2fast_fastapi` (dash→underscore only).
19
+ - Two "default connection" paths DISAGREE: `get_db_session`/`get_db_engine` default `connection_name="default"` (literal string), while `DatabaseManager.get_engine`/`get_connection` fall back `None → config.default_connection`. Never rely on `"default"`: name your primary `default` or always pass an explicit name via `functools.partial` (oauth2fast pattern: `get_auth_session = partial(get_db_session, connection_name="auth")`).
20
+
21
+ ## Public API
22
+
23
+ - `get_manager(config=None)` — **global singleton**; honors a custom config only on its FIRST call. `get_db_manager` (FastAPI dep) always injects the module `settings`, so per-app config via the dep path is impossible.
24
+ - `get_db_engine(connection_name="default")`, `get_db_session(connection_name="default")` (yields session; commits on success, rolls back on exception, closes in finally).
25
+ - `startup_database()` — **NOT lazy**: eagerly creates and health-checks EVERY configured connection. README's "lazy loading" is wrong.
26
+ - `shutdown_database()` → `manager.close_all()` (disposes all engines).
27
+ - DB ops `database_exists/create_database/drop_database/list_databases` — ALL require a superuser connection (`is_superuser=True`; first configured one wins); use AUTOCOMMIT isolation.
28
+ - Seeder: `SeederConfig`, `register_seeder`, `seed_all`, `get_registered_seeders`, `SeederConflictError`.
29
+ - `insert_if_missing(session, model, lookup, defaults=None)` — **shared idempotent
30
+ insert-if-missing** primitive: SELECT by natural-key `lookup` fields, return the
31
+ existing row if present or insert (merge `lookup`+`defaults`) and flush if absent.
32
+ Consumer packages (permissions2fast GLOBAL routes, tenants2fast TENANT routes)
33
+ reuse this instead of hand-rolling per package (RBAC standardization D2). No
34
+ commit — caller owns the transaction boundary. Also re-exported from the top-level
35
+ `pgsqlasync2fast_fastapi` package.
36
+
37
+ ## Architecture
38
+
39
+ - `DatabaseSettings.connections: dict[str, DatabaseConnectionSettings]` — any lowercase name is a legal key (env `DB_CONNECTIONS__{NAME}__*`).
40
+ - Engine: `create_async_engine(url, echo, pool_size=5, max_overflow=10, pool_timeout=30, pool_recycle=3600, pool_pre_ping=True)` (pre_ping hard-coded). URL: `postgresql+asyncpg://user:pass@host:port/db`.
41
+ - Each engine has a parallel `async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)` (SQLModel session — supports BOTH `session.execute` and `session.exec`).
42
+ - `echo` resolution QUIRK: `conn.echo if conn.echo else global echo` — a connection-level `echo=False` cannot override a global `echo=True`.
43
+
44
+ ## Dynamic/tenant engines (private contract)
45
+
46
+ - There is **no public API to register an engine at runtime**. tenants2fast writes the manager's PRIVATE (`manager._engines`, `manager._session_makers`, `manager.config.connections`) directly. No LRU, no cap — dynamic engines accumulate unboundedly (N tenants × up to 15 connections each). Dispose idle tenant engines explicitly.
47
+ - `get_manager(config)` honors custom config only once; the FastAPI dep always passes module settings.
48
+
49
+ ## Wiring
50
+
51
+ ```python
52
+ from pgsqlasync2fast_fastapi import get_db_session, startup_database, shutdown_database
53
+ # startup(shutdown) events: await startup_database() / await shutdown_database()
54
+ session: AsyncSession = Depends(get_db_session) # or partial with connection_name
55
+ ```
56
+
57
+ Downstream imports (stable contracts): oauth2fast `get_db_session`→`partial(..., connection_name="auth")`; permissions2fast `SeederConfig, register_seeder`; tenants2fast `create_database, drop_database`, `connection.get_manager`, `settings.settings, DatabaseConnectionSettings`.
58
+
59
+ ## Seeder orchestrator
60
+
61
+ - The seeder registry is **keyed by `(connection_name, package_name)`** — re-registering the same key updates that single entry in place (idempotent, no duplicates).
62
+ - `register_seeder(config, mode="retain_base")` is the **override primitive**:
63
+ - `mode="retain_base"` (default, backward-compatible): an existing same-key entry's config fields are replaced but its prior manifest `model_classes` are **merged** into the new config's set — base tables are preserved when an app extends a package seeder.
64
+ - `mode="replace"`: the prior same-key entry is replaced wholesale.
65
+ - Re-registering the same key NEVER raises `SeederConflictError`.
66
+ - Registration-time **table-conflict detection** applies only to *distinct* `(connection, package)` keys: `SeederConflictError` if two different packages on the same connection share a manifest `tables` key.
67
+ - `seed_all(profile, package_filter=None)` sorts registered values by `priority` (LOWER first). **Skips** registered seeders with `is_tenant_seeder=True` and no `seed_fn` (warns — tenant seeding must be driven by `seed_all_tenants`/the tenant package itself).
68
+ - Generic path: topological sort of `depends_on` (cycle → `SeedValidationError`), FK resolution `fk_field_mapping`/`fk_fields`/`rstrip('s')+'_id'`, insert **per row** with explicit `id` → idempotent (SELECT by id, skip if exists, commit per row).
69
+
70
+ ## Settings
71
+
72
+ `DatabaseSettings`, prefix `DB_`, nested `__`, `.env` from **process CWD**. Top: `DB_DEFAULT_CONNECTION` (default "default"), `DB_ECHO` (False). Per connection: `HOST, USERNAME, PASSWORD, DATABASE` (**all required** — import crashes otherwise), `PORT` (5432), `IS_SUPERUSER` (False), `POOL_SIZE` (5), `MAX_OVERFLOW` (10), `POOL_TIMEOUT` (30), `POOL_RECYCLE` (3600), `ECHO` (False). Missing `.env` does NOT crash at import — it yields an empty manager that fails on first `get_engine` (plus a Spanish warning).
73
+
74
+ ## Gotchas
75
+
76
+ - `create_database`/`drop_database` require a configured superuser connection.
77
+ - Engines are disposed only by `shutdown_database`/`close_all` — or by tenants2fast's `dispose_tenant_engine` for individual tenant engines; dropping a tenant DB without disposing first leaks connections.
78
+
79
+ ## Golden rule (inherited)
80
+
81
+ Follow the 2fast-handbook base skill for layout/versioning/naming/README/commits/release.
82
+ Local edits are fine; NEVER bump/publish on your own — prepare the exact command and hand it to the developer.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgsqlasync2fast-fastapi
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -56,6 +56,8 @@ Dynamic: license-file
56
56
 
57
57
  Simple and fast PostgreSQL async module for FastAPI with multi-database support and automatic database creation.
58
58
 
59
+ > 📖 **Conventions reference**: this package follows the [2fast-handbook](https://github.com/AngelDanielSanchezCastillo/2fast-handbook) for ecosystem conventions (structure, versioning, README, commits, release).
60
+
59
61
  ## Features
60
62
 
61
63
  - ✅ **Multiple Database Connections**: Configure and manage multiple PostgreSQL databases
@@ -0,0 +1,13 @@
1
+ pgsqlasync2fast_fastapi/__init__.py,sha256=rfPB2UJsmImEeCR-FfZ3Gg0cfuVLc4bymp-A1gi5Do4,2320
2
+ pgsqlasync2fast_fastapi/__version__.py,sha256=42STGor_9nKYXumfeV5tiyD_M8VdcddX7CEexmibPBk,22
3
+ pgsqlasync2fast_fastapi/connection.py,sha256=vyR3SLpLTENYGKWkEoou0ud5IwUOJ9ksfkysUW5tz-I,6168
4
+ pgsqlasync2fast_fastapi/database.py,sha256=t6i8V4Bbec0sPoWYsBPScSqQpde1tVkqGx0CBRnAupk,8856
5
+ pgsqlasync2fast_fastapi/dependencies.py,sha256=jgX14S6h6i4Uk6edMDGh-wiDpxTlB16qvYMlIm1QofQ,4444
6
+ pgsqlasync2fast_fastapi/seeder.py,sha256=Pl9xEkgqOcOlZgFAHuTGgPUSCZ-Mt-M7wvYJZ6VLM98,31122
7
+ pgsqlasync2fast_fastapi/settings.py,sha256=9SAmEVtotKJYlbTjI4qkKBjEOghTTtaICAFBBrR-eHk,6678
8
+ pgsqlasync2fast_fastapi/skills/SKILL.md,sha256=Yqge3ydNnZXO5hN5Ac4aynCG0hn-WYPCy_nkzToUDsM,6787
9
+ pgsqlasync2fast_fastapi-0.4.0.dist-info/licenses/LICENSE,sha256=CkISX1hNEwxxrPTOXet3IYMEH28Bn7SoUyKniRjg68I,1086
10
+ pgsqlasync2fast_fastapi-0.4.0.dist-info/METADATA,sha256=rA3ZltgZR83lOfPOidK2yKxpOZxX1KbGL50cP5LqMys,10105
11
+ pgsqlasync2fast_fastapi-0.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
12
+ pgsqlasync2fast_fastapi-0.4.0.dist-info/top_level.txt,sha256=7pllDm9nlFGWKJDtG5zukxqdADqjZddU-GzaQJPLMVc,24
13
+ pgsqlasync2fast_fastapi-0.4.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (84.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,12 +0,0 @@
1
- pgsqlasync2fast_fastapi/__init__.py,sha256=zGV8J18n9bis4KiqEuMZyG31w_lSnVenuxhk355IZIA,2168
2
- pgsqlasync2fast_fastapi/__version__.py,sha256=VrXpHDu3erkzwl_WXrqINBm9xWkcyUy53IQOj042dOs,22
3
- pgsqlasync2fast_fastapi/connection.py,sha256=vyR3SLpLTENYGKWkEoou0ud5IwUOJ9ksfkysUW5tz-I,6168
4
- pgsqlasync2fast_fastapi/database.py,sha256=t6i8V4Bbec0sPoWYsBPScSqQpde1tVkqGx0CBRnAupk,8856
5
- pgsqlasync2fast_fastapi/dependencies.py,sha256=jgX14S6h6i4Uk6edMDGh-wiDpxTlB16qvYMlIm1QofQ,4444
6
- pgsqlasync2fast_fastapi/seeder.py,sha256=wRBAhiUsIn6pDlMq63q1HilgV8JHo8FuYUJ3xL0FPwo,27388
7
- pgsqlasync2fast_fastapi/settings.py,sha256=9SAmEVtotKJYlbTjI4qkKBjEOghTTtaICAFBBrR-eHk,6678
8
- pgsqlasync2fast_fastapi-0.3.0.dist-info/licenses/LICENSE,sha256=CkISX1hNEwxxrPTOXet3IYMEH28Bn7SoUyKniRjg68I,1086
9
- pgsqlasync2fast_fastapi-0.3.0.dist-info/METADATA,sha256=6jRDeCgNZmaQT0j5bbPJjKb4rOb-HCrIBvIiUnqaEy8,9889
10
- pgsqlasync2fast_fastapi-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
- pgsqlasync2fast_fastapi-0.3.0.dist-info/top_level.txt,sha256=7pllDm9nlFGWKJDtG5zukxqdADqjZddU-GzaQJPLMVc,24
12
- pgsqlasync2fast_fastapi-0.3.0.dist-info/RECORD,,