pgsqlasync2fast-fastapi 0.3.1__tar.gz → 0.4.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 (38) hide show
  1. pgsqlasync2fast_fastapi-0.4.0/MANIFEST.in +9 -0
  2. {pgsqlasync2fast_fastapi-0.3.1/src/pgsqlasync2fast_fastapi.egg-info → pgsqlasync2fast_fastapi-0.4.0}/PKG-INFO +1 -1
  3. pgsqlasync2fast_fastapi-0.4.0/docs/.env.example +50 -0
  4. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/docs/seeder-format.md +39 -3
  5. pgsqlasync2fast_fastapi-0.4.0/examples/basic_usage.py +58 -0
  6. pgsqlasync2fast_fastapi-0.4.0/examples/database_creation.py +93 -0
  7. pgsqlasync2fast_fastapi-0.4.0/examples/fastapi_integration.py +151 -0
  8. pgsqlasync2fast_fastapi-0.4.0/examples/multi_database.py +70 -0
  9. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/pyproject.toml +1 -0
  10. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi/__init__.py +4 -0
  11. pgsqlasync2fast_fastapi-0.4.0/src/pgsqlasync2fast_fastapi/__version__.py +1 -0
  12. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi/seeder.py +106 -21
  13. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi/skills/SKILL.md +15 -3
  14. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0/src/pgsqlasync2fast_fastapi.egg-info}/PKG-INFO +1 -1
  15. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi.egg-info/SOURCES.txt +8 -1
  16. pgsqlasync2fast_fastapi-0.4.0/tests/test_insert_if_missing.py +92 -0
  17. pgsqlasync2fast_fastapi-0.4.0/tests/test_seeder_override.py +263 -0
  18. pgsqlasync2fast_fastapi-0.3.1/MANIFEST.in +0 -4
  19. pgsqlasync2fast_fastapi-0.3.1/src/pgsqlasync2fast_fastapi/__version__.py +0 -1
  20. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/LICENSE +0 -0
  21. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/README.md +0 -0
  22. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/examples/seeders/dev/categories.json +0 -0
  23. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/examples/seeders/dev/permissions.json +0 -0
  24. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/examples/seeders/dev/roles.json +0 -0
  25. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/examples/seeders/manifest.json +0 -0
  26. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/examples/seeders/prod/categories.json +0 -0
  27. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/examples/seeders/prod/permissions.json +0 -0
  28. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/examples/seeders/prod/roles.json +0 -0
  29. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/setup.cfg +0 -0
  30. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi/connection.py +0 -0
  31. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi/database.py +0 -0
  32. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi/dependencies.py +0 -0
  33. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi/settings.py +0 -0
  34. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi.egg-info/dependency_links.txt +0 -0
  35. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi.egg-info/requires.txt +0 -0
  36. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi.egg-info/top_level.txt +0 -0
  37. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/tests/test_completo.py +0 -0
  38. {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/tests/test_seeder.py +0 -0
@@ -0,0 +1,9 @@
1
+ include README.md
2
+ include LICENSE
3
+ recursive-include docs *.md *.example
4
+ recursive-include examples *.py *.example *.json
5
+ recursive-include src/pgsqlasync2fast_fastapi *.py *.html *.json *.md
6
+
7
+ global-exclude __pycache__
8
+ global-exclude *.py[co]
9
+ global-exclude .DS_Store
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgsqlasync2fast-fastapi
3
- Version: 0.3.1
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
@@ -0,0 +1,50 @@
1
+ # ============================================
2
+ # PostgreSQL Database Configuration
3
+ # ============================================
4
+
5
+ # ===== Connection DEFAULT (principal) =====
6
+ # Esta es la conexión por defecto que se usará cuando no especifiques ninguna
7
+ DB_CONNECTIONS__DEFAULT__HOST=localhost
8
+ DB_CONNECTIONS__DEFAULT__PORT=5432
9
+ DB_CONNECTIONS__DEFAULT__USERNAME=myuser
10
+ DB_CONNECTIONS__DEFAULT__PASSWORD=mypassword
11
+ DB_CONNECTIONS__DEFAULT__DATABASE=mydb
12
+ DB_CONNECTIONS__DEFAULT__IS_SUPERUSER=false
13
+
14
+ # Pool settings (opcional, estos son los valores por defecto)
15
+ DB_CONNECTIONS__DEFAULT__POOL_SIZE=5
16
+ DB_CONNECTIONS__DEFAULT__MAX_OVERFLOW=10
17
+ DB_CONNECTIONS__DEFAULT__POOL_TIMEOUT=30
18
+ DB_CONNECTIONS__DEFAULT__POOL_RECYCLE=3600
19
+
20
+ # Echo mode para esta conexión (opcional)
21
+ DB_CONNECTIONS__DEFAULT__ECHO=false
22
+
23
+ # ===== Connection BUSINESS (ejemplo de segunda conexión) =====
24
+ # Para usar esta conexión, especifica connection_name="business" en tus dependencias
25
+ DB_CONNECTIONS__BUSINESS__HOST=localhost
26
+ DB_CONNECTIONS__BUSINESS__PORT=5432
27
+ DB_CONNECTIONS__BUSINESS__USERNAME=business_user
28
+ DB_CONNECTIONS__BUSINESS__PASSWORD=business_password
29
+ DB_CONNECTIONS__BUSINESS__DATABASE=business_db
30
+ DB_CONNECTIONS__BUSINESS__IS_SUPERUSER=false
31
+
32
+ # ===== Connection ADMIN (ejemplo con superusuario) =====
33
+ # Esta conexión tiene privilegios de superusuario para crear/eliminar bases de datos
34
+ DB_CONNECTIONS__ADMIN__HOST=localhost
35
+ DB_CONNECTIONS__ADMIN__PORT=5432
36
+ DB_CONNECTIONS__ADMIN__USERNAME=postgres
37
+ DB_CONNECTIONS__ADMIN__PASSWORD=postgres_password
38
+ DB_CONNECTIONS__ADMIN__DATABASE=postgres
39
+ DB_CONNECTIONS__ADMIN__IS_SUPERUSER=true
40
+
41
+ # ============================================
42
+ # General Settings
43
+ # ============================================
44
+
45
+ # Conexión por defecto cuando no se especifica ninguna
46
+ # Opciones: "default", "business", "admin", o cualquier otra que hayas configurado
47
+ DB_DEFAULT_CONNECTION=default
48
+
49
+ # Echo mode global (puede ser sobrescrito por cada conexión)
50
+ DB_ECHO=false
@@ -148,25 +148,61 @@ register_seeder(SeederConfig(
148
148
 
149
149
  ## Conflict Detection
150
150
 
151
- When registering a seeder, the system detects table conflicts.
151
+ When registering a seeder, the system detects table conflicts between **distinct** packages.
152
152
 
153
153
  ### Rule
154
154
 
155
- Two packages CANNOT seed the same table in the same connection.
155
+ Two DIFFERENT packages (`(connection_name, package_name)` keys) CANNOT seed the same table in the same connection. Re-registering the same key is an override, never a conflict.
156
156
 
157
157
  ### Error
158
158
 
159
- If conflicts are detected, `SeederConflictError` is raised:
159
+ If a distinct package's tables overlap an already-registered package on the same connection, `SeederConflictError` is raised:
160
160
  ```
161
161
  Tables {'roles'} conflict between 'pkg-a' and 'pkg-b' on connection 'auth'
162
162
  ```
163
163
 
164
+ ## Override & Idempotency (single-entry)
165
+
166
+ The registry is keyed by `(connection_name, package_name)`, so registering the same package+connection repeatedly updates **one single entry** — no duplicate registry entries.
167
+
168
+ `register_seeder(config, mode=...)` supports two override behaviors:
169
+
170
+ | `mode` | Behavior |
171
+ |--------|----------|
172
+ | `"retain_base"` (default) | Replaces the entry's config fields but **merges** the prior entry's manifest `model_classes` into the new config's set. Base tables are preserved when an app extends a package seeder (e.g. keeps permissions2fast base tables while adding route tables). |
173
+ | `"replace"` | Replaces the prior entry wholesale. |
174
+
175
+ Re-registering the **same** `(connection, package)` key never raises `SeederConflictError`. Only a **distinct** package on the same connection with overlapping tables raises it.
176
+
177
+ ### Example
178
+
179
+ ```python
180
+ # Base permissions2fast seeder auto-registered on "auth"
181
+ register_seeder(SeederConfig(
182
+ connection_name="auth",
183
+ manifest_path="permissions2fast_fastapi/seeders/manifest.json",
184
+ package_name="permissions2fast-fastapi",
185
+ priority=60,
186
+ ))
187
+
188
+ # App extends it by ADDING route tables, keeping the base manifest set
189
+ register_seeder(SeederConfig(
190
+ connection_name="auth",
191
+ manifest_path="app/rbac_route_manifest.json",
192
+ package_name="permissions2fast-fastapi", # same key -> override
193
+ model_classes={"routes": RouteModel},
194
+ ), mode="retain_base")
195
+ ```
196
+
197
+ The result is a single `permissions2fast-fastapi` entry whose `model_classes` contain both the base `roles`/`permissions` models and the new `routes` model.
198
+
164
199
  ## Idempotency
165
200
 
166
201
  The seeder is idempotent - running multiple times produces the same result.
167
202
 
168
203
  - Rows with existing IDs are skipped
169
204
  - Only new rows are inserted
205
+ - Registry registration is idempotent per key - no duplicate entries
170
206
 
171
207
  This allows safe re-runs without duplicating data.
172
208
 
@@ -0,0 +1,58 @@
1
+ """
2
+ Basic usage example for pgsqlasync2fast-fastapi
3
+
4
+ This example demonstrates:
5
+ - Basic database connection
6
+ - Executing queries
7
+ - Using sessions
8
+ """
9
+
10
+ import asyncio
11
+ from sqlalchemy import text
12
+ from pgsqlasync2fast_fastapi import get_manager, settings
13
+
14
+
15
+ async def main():
16
+ """Main function demonstrating basic usage."""
17
+
18
+ # Get the database manager
19
+ manager = get_manager()
20
+
21
+ print("📊 Basic PostgreSQL Async Usage Example")
22
+ print("=" * 50)
23
+
24
+ # List configured connections
25
+ print(f"\n✅ Configured connections: {manager.list_connections()}")
26
+
27
+ # Get a session for the default connection
28
+ print(f"\n🔌 Connecting to default database...")
29
+ session = await manager.get_session()
30
+
31
+ try:
32
+ # Execute a simple query
33
+ result = await session.execute(text("SELECT version()"))
34
+ version = result.scalar()
35
+ print(f"✅ PostgreSQL version: {version}")
36
+
37
+ # Execute another query
38
+ result = await session.execute(text("SELECT current_database()"))
39
+ db_name = result.scalar()
40
+ print(f"✅ Current database: {db_name}")
41
+
42
+ # Commit the session
43
+ await session.commit()
44
+
45
+ except Exception as e:
46
+ print(f"❌ Error: {e}")
47
+ await session.rollback()
48
+ finally:
49
+ await session.close()
50
+
51
+ # Cleanup
52
+ print("\n🔌 Closing connections...")
53
+ await manager.close_all()
54
+ print("✅ Done!")
55
+
56
+
57
+ if __name__ == "__main__":
58
+ asyncio.run(main())
@@ -0,0 +1,93 @@
1
+ """
2
+ Database creation example for pgsqlasync2fast-fastapi
3
+
4
+ This example demonstrates:
5
+ - Checking if a database exists
6
+ - Creating new databases
7
+ - Listing all databases
8
+ - Dropping databases
9
+
10
+ IMPORTANT: This requires a connection configured with is_superuser=true
11
+ """
12
+
13
+ import asyncio
14
+ from pgsqlasync2fast_fastapi import (
15
+ database_exists,
16
+ create_database,
17
+ drop_database,
18
+ list_databases,
19
+ get_manager,
20
+ )
21
+
22
+
23
+ async def main():
24
+ """Main function demonstrating database creation utilities."""
25
+
26
+ print("📊 Database Creation Example")
27
+ print("=" * 50)
28
+
29
+ # Check if we have a superuser connection
30
+ manager = get_manager()
31
+ superuser_conn = manager.config.get_superuser_connection_name()
32
+
33
+ if not superuser_conn:
34
+ print("❌ No superuser connection configured!")
35
+ print(" Please configure a connection with IS_SUPERUSER=true")
36
+ print(" Example: DB_CONNECTIONS__ADMIN__IS_SUPERUSER=true")
37
+ return
38
+
39
+ print(f"✅ Using superuser connection: '{superuser_conn}'")
40
+
41
+ # List all existing databases
42
+ print("\n📋 Listing all databases...")
43
+ databases = await list_databases()
44
+ for db in databases:
45
+ print(f" - {db}")
46
+
47
+ # Test database name
48
+ test_db_name = "test_created_db"
49
+
50
+ # Check if test database exists
51
+ print(f"\n🔍 Checking if '{test_db_name}' exists...")
52
+ exists = await database_exists(test_db_name)
53
+ print(f" {'✅ Exists' if exists else '❌ Does not exist'}")
54
+
55
+ # Create the database if it doesn't exist
56
+ if not exists:
57
+ print(f"\n🔨 Creating database '{test_db_name}'...")
58
+ created = await create_database(test_db_name)
59
+ if created:
60
+ print(f" ✅ Database '{test_db_name}' created successfully!")
61
+ else:
62
+ print(f" ❌ Failed to create database")
63
+ else:
64
+ print(f"\n⚠️ Database '{test_db_name}' already exists, skipping creation")
65
+
66
+ # Verify it exists now
67
+ print(f"\n🔍 Verifying '{test_db_name}' exists...")
68
+ exists = await database_exists(test_db_name)
69
+ print(f" {'✅ Confirmed' if exists else '❌ Not found'}")
70
+
71
+ # List databases again to show the new one
72
+ print("\n📋 Listing all databases (after creation)...")
73
+ databases = await list_databases()
74
+ for db in databases:
75
+ marker = " ← NEW" if db == test_db_name else ""
76
+ print(f" - {db}{marker}")
77
+
78
+ # Optional: Drop the test database
79
+ print(f"\n🗑️ Cleaning up: dropping '{test_db_name}'...")
80
+ dropped = await drop_database(test_db_name, force=True)
81
+ if dropped:
82
+ print(f" ✅ Database '{test_db_name}' dropped successfully!")
83
+ else:
84
+ print(f" ❌ Failed to drop database")
85
+
86
+ # Cleanup
87
+ print("\n🔌 Closing connections...")
88
+ await manager.close_all()
89
+ print("✅ Done!")
90
+
91
+
92
+ if __name__ == "__main__":
93
+ asyncio.run(main())
@@ -0,0 +1,151 @@
1
+ """
2
+ FastAPI integration example for pgsqlasync2fast-fastapi
3
+
4
+ This example demonstrates:
5
+ - FastAPI application setup
6
+ - Dependency injection
7
+ - Startup/shutdown events
8
+ - Using multiple database connections in endpoints
9
+ """
10
+
11
+ from functools import partial
12
+ from fastapi import FastAPI, Depends
13
+ from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine
14
+ from sqlalchemy import text
15
+
16
+ from pgsqlasync2fast_fastapi import (
17
+ get_db_session,
18
+ get_db_engine,
19
+ get_db_manager,
20
+ startup_database,
21
+ shutdown_database,
22
+ DatabaseManager,
23
+ )
24
+
25
+ # Create FastAPI app
26
+ app = FastAPI(
27
+ title="PostgreSQL Async Example",
28
+ description="Example FastAPI application with pgsqlasync2fast-fastapi"
29
+ )
30
+
31
+
32
+ @app.on_event("startup")
33
+ async def startup():
34
+ """Initialize database connections on startup."""
35
+ await startup_database()
36
+
37
+
38
+ @app.on_event("shutdown")
39
+ async def shutdown():
40
+ """Close database connections on shutdown."""
41
+ await shutdown_database()
42
+
43
+
44
+ @app.get("/")
45
+ async def root():
46
+ """Root endpoint."""
47
+ return {
48
+ "message": "PostgreSQL Async FastAPI Example",
49
+ "endpoints": [
50
+ "/health",
51
+ "/connections",
52
+ "/db/info",
53
+ "/db/version",
54
+ ]
55
+ }
56
+
57
+
58
+ @app.get("/health")
59
+ async def health_check(engine: AsyncEngine = Depends(get_db_engine)):
60
+ """Health check endpoint using default database."""
61
+ try:
62
+ async with engine.connect() as conn:
63
+ await conn.execute(text("SELECT 1"))
64
+ return {"status": "healthy", "database": "default"}
65
+ except Exception as e:
66
+ return {"status": "unhealthy", "error": str(e)}
67
+
68
+
69
+ @app.get("/connections")
70
+ async def list_connections(manager: DatabaseManager = Depends(get_db_manager)):
71
+ """List all configured database connections."""
72
+ connections = manager.list_connections()
73
+
74
+ connection_info = []
75
+ for conn_name in connections:
76
+ is_super = manager.is_superuser_connection(conn_name)
77
+ connection_info.append({
78
+ "name": conn_name,
79
+ "is_superuser": is_super
80
+ })
81
+
82
+ return {
83
+ "total": len(connections),
84
+ "connections": connection_info
85
+ }
86
+
87
+
88
+ @app.get("/db/info")
89
+ async def get_db_info(session: AsyncSession = Depends(get_db_session)):
90
+ """Get database information using default connection."""
91
+ try:
92
+ # Get database name
93
+ result = await session.execute(text("SELECT current_database()"))
94
+ db_name = result.scalar()
95
+
96
+ # Get current user
97
+ result = await session.execute(text("SELECT current_user"))
98
+ user = result.scalar()
99
+
100
+ # Get server version
101
+ result = await session.execute(text("SELECT version()"))
102
+ version = result.scalar()
103
+
104
+ return {
105
+ "database": db_name,
106
+ "user": user,
107
+ "version": version
108
+ }
109
+ except Exception as e:
110
+ return {"error": str(e)}
111
+
112
+
113
+ @app.get("/db/version")
114
+ async def get_version(session: AsyncSession = Depends(get_db_session)):
115
+ """Get PostgreSQL version."""
116
+ result = await session.execute(text("SELECT version()"))
117
+ version = result.scalar()
118
+ return {"version": version}
119
+
120
+
121
+ # Example: Using a specific database connection
122
+ # Create a dependency for a specific connection (e.g., "business")
123
+ get_business_session = partial(get_db_session, connection_name="business")
124
+
125
+
126
+ @app.get("/business/info")
127
+ async def get_business_info(session: AsyncSession = Depends(get_business_session)):
128
+ """Get database information using business connection."""
129
+ try:
130
+ result = await session.execute(text("SELECT current_database()"))
131
+ db_name = result.scalar()
132
+
133
+ result = await session.execute(text("SELECT current_user"))
134
+ user = result.scalar()
135
+
136
+ return {
137
+ "connection": "business",
138
+ "database": db_name,
139
+ "user": user
140
+ }
141
+ except Exception as e:
142
+ return {"error": str(e)}
143
+
144
+
145
+ if __name__ == "__main__":
146
+ import uvicorn
147
+
148
+ print("🚀 Starting FastAPI application...")
149
+ print("📖 API docs available at: http://localhost:8000/docs")
150
+
151
+ uvicorn.run(app, host="0.0.0.0", port=8000)
@@ -0,0 +1,70 @@
1
+ """
2
+ Multi-database example for pgsqlasync2fast-fastapi
3
+
4
+ This example demonstrates:
5
+ - Configuring multiple database connections
6
+ - Switching between databases
7
+ - Using different connections simultaneously
8
+ """
9
+
10
+ import asyncio
11
+ from sqlalchemy import text
12
+ from pgsqlasync2fast_fastapi import get_manager
13
+
14
+
15
+ async def main():
16
+ """Main function demonstrating multi-database usage."""
17
+
18
+ manager = get_manager()
19
+
20
+ print("📊 Multi-Database Usage Example")
21
+ print("=" * 50)
22
+
23
+ # List all configured connections
24
+ connections = manager.list_connections()
25
+ print(f"\n✅ Available connections: {connections}")
26
+
27
+ # Demonstrate using multiple connections
28
+ for conn_name in connections:
29
+ print(f"\n🔌 Connecting to '{conn_name}' database...")
30
+
31
+ try:
32
+ # Health check
33
+ is_healthy = await manager.health_check(conn_name)
34
+ if not is_healthy:
35
+ print(f"❌ Connection '{conn_name}' is not healthy")
36
+ continue
37
+
38
+ # Get a session for this connection
39
+ session = await manager.get_session(conn_name)
40
+
41
+ try:
42
+ # Get database info
43
+ result = await session.execute(text("SELECT current_database()"))
44
+ db_name = result.scalar()
45
+
46
+ result = await session.execute(text("SELECT current_user"))
47
+ user = result.scalar()
48
+
49
+ is_super = manager.is_superuser_connection(conn_name)
50
+ super_flag = " (SUPERUSER)" if is_super else ""
51
+
52
+ print(f"✅ Database: {db_name}")
53
+ print(f"✅ User: {user}{super_flag}")
54
+
55
+ await session.commit()
56
+
57
+ finally:
58
+ await session.close()
59
+
60
+ except Exception as e:
61
+ print(f"❌ Error with connection '{conn_name}': {e}")
62
+
63
+ # Cleanup
64
+ print("\n🔌 Closing all connections...")
65
+ await manager.close_all()
66
+ print("✅ Done!")
67
+
68
+
69
+ if __name__ == "__main__":
70
+ asyncio.run(main())
@@ -49,6 +49,7 @@ test = [
49
49
  "pytest-asyncio>=0.21.0",
50
50
  "pytest-cov>=4.0.0",
51
51
  "httpx>=0.24.0",
52
+ "aiosqlite",
52
53
  ]
53
54
 
54
55
  [project.urls]
@@ -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
  ]
@@ -0,0 +1 @@
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
  ]
@@ -4,7 +4,7 @@ description: "Trigger: working on or with pgsqlasync2fast-fastapi. Multi-databas
4
4
  license: MIT
5
5
  metadata:
6
6
  author: AngelDanielSanchezCastillo
7
- version: "1.0"
7
+ version: "2.1"
8
8
  ---
9
9
 
10
10
  ## Purpose
@@ -26,6 +26,13 @@ engines/session factories keyed by connection name (`default`, `auth`,
26
26
  - `shutdown_database()` → `manager.close_all()` (disposes all engines).
27
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
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.
29
36
 
30
37
  ## Architecture
31
38
 
@@ -51,8 +58,13 @@ Downstream imports (stable contracts): oauth2fast `get_db_session`→`partial(..
51
58
 
52
59
  ## Seeder orchestrator
53
60
 
54
- - `register_seeder` does registration-time **table-conflict detection** per connection (`SeederConflictError` if two seeders on the same connection share a manifest `tables` key).
55
- - `seed_all(profile, package_filter=None)` sorts 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).
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).
56
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).
57
69
 
58
70
  ## Settings
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgsqlasync2fast-fastapi
3
- Version: 0.3.1
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
@@ -2,7 +2,12 @@ LICENSE
2
2
  MANIFEST.in
3
3
  README.md
4
4
  pyproject.toml
5
+ docs/.env.example
5
6
  docs/seeder-format.md
7
+ examples/basic_usage.py
8
+ examples/database_creation.py
9
+ examples/fastapi_integration.py
10
+ examples/multi_database.py
6
11
  examples/seeders/manifest.json
7
12
  examples/seeders/dev/categories.json
8
13
  examples/seeders/dev/permissions.json
@@ -24,4 +29,6 @@ src/pgsqlasync2fast_fastapi.egg-info/requires.txt
24
29
  src/pgsqlasync2fast_fastapi.egg-info/top_level.txt
25
30
  src/pgsqlasync2fast_fastapi/skills/SKILL.md
26
31
  tests/test_completo.py
27
- tests/test_seeder.py
32
+ tests/test_insert_if_missing.py
33
+ tests/test_seeder.py
34
+ tests/test_seeder_override.py
@@ -0,0 +1,92 @@
1
+ """
2
+ Tests for the reusable idempotent `insert_if_missing` primitive
3
+ (pgsqlasync2fast-fastapi).
4
+
5
+ This is the shared insert-if-missing-by-natural-key helper that consumer
6
+ packages (permissions2fast GLOBAL routes, tenants2fast TENANT routes) reuse
7
+ instead of hand-rolling per package (RBAC standardization decision D2).
8
+
9
+ Run with:
10
+ cd /Volumes/Desarrollo/Repos/Github/pgsqlasync2fast-fastapi \
11
+ && uv run pytest tests/test_insert_if_missing.py -v
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import pytest
17
+ from sqlalchemy import BigInteger
18
+ from sqlalchemy.ext.asyncio import create_async_engine
19
+ from sqlalchemy.ext.compiler import compiles
20
+ from sqlmodel import Field, SQLModel
21
+ from sqlmodel.ext.asyncio.session import AsyncSession
22
+
23
+
24
+ @compiles(BigInteger, "sqlite")
25
+ def _compile_bigint_sqlite(type_, compiler, **kw):
26
+ return "INTEGER"
27
+
28
+
29
+ DB_URL = "sqlite+aiosqlite:///:memory:"
30
+
31
+
32
+ class AFruit(SQLModel, table=True):
33
+ """Minimal SQLModel table to exercise insert_if_missing."""
34
+
35
+ __tablename__ = "afruits"
36
+
37
+ name: str = Field(primary_key=True)
38
+ color: str = Field(default="")
39
+
40
+
41
+ async def _engine():
42
+ engine = create_async_engine(DB_URL, echo=False)
43
+ async with engine.begin() as conn:
44
+ await conn.run_sync(SQLModel.metadata.create_all)
45
+ return engine
46
+
47
+
48
+ @pytest.mark.asyncio
49
+ async def test_insert_if_missing_inserts_new_row():
50
+ """A row that matches no natural key is inserted."""
51
+ from pgsqlasync2fast_fastapi.seeder import insert_if_missing
52
+
53
+ engine = await _engine()
54
+ async with AsyncSession(engine) as session:
55
+ row = await insert_if_missing(
56
+ session, AFruit, lookup={"name": "apple"}, defaults={"color": "red"}
57
+ )
58
+ await session.commit()
59
+
60
+ # Re-read via a fresh query to confirm persistence
61
+ from sqlmodel import select
62
+
63
+ stored = (await session.exec(select(AFruit).where(AFruit.name == "apple"))).one()
64
+ assert stored.color == "red"
65
+ assert row.name == "apple"
66
+ await engine.dispose()
67
+
68
+
69
+ @pytest.mark.asyncio
70
+ async def test_insert_if_missing_is_idempotent():
71
+ """Calling twice with the same natural key returns the existing row."""
72
+ from pgsqlasync2fast_fastapi.seeder import insert_if_missing
73
+
74
+ engine = await _engine()
75
+ async with AsyncSession(engine) as session:
76
+ first = await insert_if_missing(
77
+ session, AFruit, lookup={"name": "pear"}, defaults={"color": "green"}
78
+ )
79
+ await session.commit()
80
+
81
+ second = await insert_if_missing(
82
+ session, AFruit, lookup={"name": "pear"}, defaults={"color": "green"}
83
+ )
84
+ await session.commit()
85
+
86
+ assert second is first # same object instance returned (cached in session identity map)
87
+ # Only one row exists
88
+ from sqlmodel import select
89
+
90
+ rows = (await session.exec(select(AFruit))).all()
91
+ assert len(rows) == 1
92
+ await engine.dispose()
@@ -0,0 +1,263 @@
1
+ """
2
+ Tests for the seeder override primitive (pgsqlasync2fast-fastapi).
3
+
4
+ Covers the keyed (connection_name, package_name) registry + register_seeder(mode=)
5
+ override primitive:
6
+ - H1: an override can ADD new tables to an existing (connection, package) entry
7
+ - H2: an override can OVERWRITE an existing (connection, package) entry wholesale
8
+ - E1: a distinct (connection, package) on the same connection STILL raises
9
+ SeederConflictError on table overlap
10
+ - T1: repeated registration of the same key is idempotent (single entry)
11
+
12
+ Run with: cd /Volumes/Desarrollo/Repos/Github/pgsqlasync2fast-fastapi \\
13
+ && uv run pytest tests/test_seeder_override.py -v
14
+ """
15
+
16
+ import json
17
+ import tempfile
18
+ from pathlib import Path
19
+
20
+ import pytest
21
+
22
+ from pgsqlasync2fast_fastapi.seeder import (
23
+ SeederConfig,
24
+ SeederConflictError,
25
+ register_seeder,
26
+ get_registered_seeders,
27
+ clear_registry,
28
+ )
29
+
30
+
31
+ @pytest.fixture(autouse=True)
32
+ def clear_seeders():
33
+ """Clear the seeder registry before and after each test."""
34
+ clear_registry()
35
+ yield
36
+ clear_registry()
37
+
38
+
39
+ @pytest.fixture
40
+ def base_manifest():
41
+ """Build a temp manifest seeding base tables on the 'auth' connection."""
42
+ with tempfile.TemporaryDirectory() as tmpdir:
43
+ manifest_dir = Path(tmpdir) / "seeders"
44
+ dev_dir = manifest_dir / "dev"
45
+ manifest_dir.mkdir()
46
+ dev_dir.mkdir()
47
+
48
+ manifest = {
49
+ "tables": {
50
+ "roles": {"file": "roles.json"},
51
+ "permissions": {"file": "permissions.json"},
52
+ },
53
+ "load_order": ["roles", "permissions"],
54
+ }
55
+ manifest_path = manifest_dir / "manifest.json"
56
+ with open(manifest_path, "w") as f:
57
+ json.dump(manifest, f)
58
+ with open(dev_dir / "roles.json", "w") as f:
59
+ json.dump([{"id": 1, "name": "Admin"}], f)
60
+ with open(dev_dir / "permissions.json", "w") as f:
61
+ json.dump([{"id": 1, "name": "read:users"}], f)
62
+
63
+ yield (str(manifest_path), manifest, dev_dir)
64
+
65
+
66
+ def make_config(manifest_path, package, **kwargs) -> SeederConfig:
67
+ """Build a SeederConfig for the given manifest and package."""
68
+ return SeederConfig(
69
+ connection_name="auth",
70
+ manifest_path=manifest_path,
71
+ package_name=package,
72
+ model_classes=kwargs.pop("model_classes", {}),
73
+ **kwargs,
74
+ )
75
+
76
+
77
+ # ============================================================================
78
+ # T1 — Idempotency: same key registered twice -> single entry
79
+ # ============================================================================
80
+
81
+
82
+ class TestOverrideIdempotency:
83
+ def test_same_key_twice_single_entry(self, base_manifest):
84
+ manifest_path, _, dev_dir = base_manifest
85
+
86
+ # New manifest that ADDS a table (extends the base manifest set)
87
+ extended = {
88
+ "tables": {
89
+ "roles": {"file": "roles.json"},
90
+ "permissions": {"file": "permissions.json"},
91
+ "routes": {"file": "routes.json"},
92
+ },
93
+ "load_order": ["roles", "permissions", "routes"],
94
+ }
95
+ ext_path = Path(manifest_path).parent / "manifest_override.json"
96
+ with open(ext_path, "w") as f:
97
+ json.dump(extended, f)
98
+ with open(Path(manifest_path).parent / "dev" / "routes.json", "w") as f:
99
+ json.dump([{"id": 1, "path": "/admin", "method": "GET"}], f)
100
+
101
+ base = make_config(manifest_path, "pkg-a",
102
+ model_classes={"roles": object, "permissions": object})
103
+ override = make_config(str(ext_path), "pkg-a",
104
+ model_classes={"roles": object, "routes": object})
105
+
106
+ # Register the same key twice with the default retain_base mode
107
+ register_seeder(base)
108
+ register_seeder(override)
109
+
110
+ seeders = get_registered_seeders()
111
+ assert len(seeders) == 1
112
+ assert seeders[0].package_name == "pkg-a"
113
+
114
+
115
+ # ============================================================================
116
+ # H1 — retain_base ADD: base tables preserved, new tables added to model set
117
+ # ============================================================================
118
+
119
+
120
+ class TestOverrideAdd:
121
+ def test_retain_base_merges_prior_model_classes(self, base_manifest):
122
+ manifest_path, _, _ = base_manifest
123
+
124
+ # Override manifest introduces a NEW table 'routes'
125
+ ext_manifest = {
126
+ "tables": {
127
+ "roles": {"file": "roles.json"},
128
+ "permissions": {"file": "permissions.json"},
129
+ "routes": {"file": "routes.json"},
130
+ },
131
+ "load_order": ["roles", "permissions", "routes"],
132
+ }
133
+ ext_path = Path(manifest_path).parent / "manifest_override.json"
134
+ with open(ext_path, "w") as f:
135
+ json.dump(ext_manifest, f)
136
+ with open(Path(manifest_path).parent / "dev" / "routes.json", "w") as f:
137
+ json.dump([{"id": 1, "path": "/admin"}], f)
138
+
139
+ class RoleModel:
140
+ pass
141
+
142
+ class RouteModel:
143
+ pass
144
+
145
+ base = make_config(manifest_path, "pkg-a",
146
+ model_classes={"roles": RoleModel, "permissions": object})
147
+ override = make_config(str(ext_path), "pkg-a",
148
+ model_classes={"roles": RoleModel, "routes": RouteModel})
149
+
150
+ register_seeder(base)
151
+ register_seeder(override)
152
+
153
+ seeders = get_registered_seeders()
154
+ assert len(seeders) == 1
155
+ entry = seeders[0]
156
+ # Extends rather than replaces: the base 'permissions' model is preserved
157
+ assert "permissions" in entry.model_classes
158
+ # And the new 'routes' model is present
159
+ assert entry.model_classes["routes"] is RouteModel
160
+
161
+
162
+ # ============================================================================
163
+ # H2 — replace: prior entry replaced wholesale
164
+ # ============================================================================
165
+
166
+
167
+ class TestOverrideReplace:
168
+ def test_replace_overwrites_wholesale(self, base_manifest):
169
+ manifest_path, _, _ = base_manifest
170
+
171
+ ext_manifest = {
172
+ "tables": {
173
+ "roles": {"file": "roles.json"},
174
+ "permissions": {"file": "permissions.json"},
175
+ "routes": {"file": "routes.json"},
176
+ },
177
+ "load_order": ["roles", "permissions", "routes"],
178
+ }
179
+ ext_path = Path(manifest_path).parent / "manifest_override.json"
180
+ with open(ext_path, "w") as f:
181
+ json.dump(ext_manifest, f)
182
+ with open(Path(manifest_path).parent / "dev" / "routes.json", "w") as f:
183
+ json.dump([{"id": 1, "path": "/admin"}], f)
184
+
185
+ class RouteModel:
186
+ pass
187
+
188
+ base = make_config(manifest_path, "pkg-a",
189
+ model_classes={"roles": object, "permissions": object})
190
+ override = make_config(str(ext_path), "pkg-a",
191
+ model_classes={"routes": RouteModel}, priority=90)
192
+
193
+ register_seeder(base)
194
+ # SeederConflictError must NOT be raised for same-key replace
195
+ register_seeder(override, mode="replace")
196
+
197
+ seeders = get_registered_seeders()
198
+ assert len(seeders) == 1
199
+ entry = seeders[0]
200
+ # Wholesale replace: only the new model set survives
201
+ assert "routes" in entry.model_classes
202
+ assert "permissions" not in entry.model_classes
203
+ assert entry.priority == 90
204
+
205
+
206
+ # ============================================================================
207
+ # E1 — True conflict: distinct package, same connection, overlapping tables
208
+ # ============================================================================
209
+
210
+
211
+ class TestOverrideConflict:
212
+ def test_distinct_package_same_connection_still_conflicts(self, base_manifest):
213
+ manifest_path, _, _ = base_manifest
214
+
215
+ # A second, DIFFERENT package on the same 'auth' connection that
216
+ # overlaps on the 'roles' table -> true conflict
217
+ conflict_manifest = {
218
+ "tables": {"roles": {"file": "roles.json"}},
219
+ "load_order": ["roles"],
220
+ }
221
+ conflict_path = Path(manifest_path).parent / "manifest_conflict.json"
222
+ with open(conflict_path, "w") as f:
223
+ json.dump(conflict_manifest, f)
224
+
225
+ register_seeder(make_config(manifest_path, "pkg-a"))
226
+
227
+ with pytest.raises(SeederConflictError) as exc_info:
228
+ register_seeder(make_config(str(conflict_path), "pkg-b"))
229
+
230
+ assert "roles" in str(exc_info.value)
231
+ assert "pkg-a" in str(exc_info.value)
232
+ assert "pkg-b" in str(exc_info.value)
233
+
234
+
235
+ # ============================================================================
236
+ # Sanity: registry remains keyed -> distinct keys stay separate entries
237
+ # ============================================================================
238
+
239
+
240
+ class TestOverrideKeying:
241
+ def test_distinct_keys_two_entries(self, base_manifest):
242
+ manifest_path, _, dev_dir = base_manifest
243
+
244
+ other = {
245
+ "tables": {"roles": {"file": "roles.json"}},
246
+ "load_order": ["roles"],
247
+ }
248
+ other_path = Path(manifest_path).parent / "manifest_other.json"
249
+ with open(other_path, "w") as f:
250
+ json.dump(other, f)
251
+ with open(dev_dir / "roles.json", "w") as f:
252
+ json.dump([{"id": 1, "name": "Admin"}], f)
253
+
254
+ # Distinct package on a DIFFERENT connection -> no conflict, two entries
255
+ register_seeder(make_config(manifest_path, "pkg-a"))
256
+ register_seeder(SeederConfig(
257
+ connection_name="business",
258
+ manifest_path=str(other_path),
259
+ package_name="pkg-b",
260
+ ))
261
+
262
+ seeders = get_registered_seeders()
263
+ assert len(seeders) == 2
@@ -1,4 +0,0 @@
1
- include LICENSE
2
- include README.md
3
- recursive-include docs *.md
4
- recursive-include examples/seeders *.json
@@ -1 +0,0 @@
1
- __version__ = "0.3.1"