pgsqlasync2fast-fastapi 0.2.0__py3-none-any.whl → 0.3.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.
- pgsqlasync2fast_fastapi/__init__.py +52 -12
- pgsqlasync2fast_fastapi/__version__.py +1 -1
- pgsqlasync2fast_fastapi/seeder.py +801 -0
- {pgsqlasync2fast_fastapi-0.2.0.dist-info → pgsqlasync2fast_fastapi-0.3.0.dist-info}/METADATA +1 -1
- pgsqlasync2fast_fastapi-0.3.0.dist-info/RECORD +12 -0
- pgsqlasync2fast_fastapi-0.2.0.dist-info/RECORD +0 -11
- {pgsqlasync2fast_fastapi-0.2.0.dist-info → pgsqlasync2fast_fastapi-0.3.0.dist-info}/WHEEL +0 -0
- {pgsqlasync2fast_fastapi-0.2.0.dist-info → pgsqlasync2fast_fastapi-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {pgsqlasync2fast_fastapi-0.2.0.dist-info → pgsqlasync2fast_fastapi-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
"""
|
|
2
|
-
pgsqlasync2fast-fastapi -
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
2
|
+
pgsqlasync2fast-fastapi - PostgreSQL async extensions for FastAPI
|
|
3
|
+
|
|
4
|
+
This package provides database management and seeding capabilities.
|
|
5
|
+
|
|
6
|
+
The seeder module provides multi-package JSON seeder orchestration.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
from pgsqlasync2fast_fastapi import seed_all, register_seeder, SeederConfig
|
|
10
|
+
|
|
11
|
+
# Register a package's seeder
|
|
12
|
+
register_seeder(SeederConfig(
|
|
13
|
+
connection_name="auth",
|
|
14
|
+
manifest_path="path/to/manifest.json",
|
|
15
|
+
package_name="my-package",
|
|
16
|
+
priority=50
|
|
17
|
+
))
|
|
18
|
+
|
|
19
|
+
# Execute all seeders
|
|
20
|
+
result = await seed_all("dev")
|
|
14
21
|
"""
|
|
15
22
|
|
|
16
23
|
from .__version__ import __version__
|
|
@@ -25,6 +32,26 @@ from .dependencies import (
|
|
|
25
32
|
)
|
|
26
33
|
from .settings import DatabaseConnectionSettings, DatabaseSettings, settings
|
|
27
34
|
|
|
35
|
+
# Import seeder module
|
|
36
|
+
from pgsqlasync2fast_fastapi import seeder
|
|
37
|
+
|
|
38
|
+
# Re-export seeder-specific exports
|
|
39
|
+
from pgsqlasync2fast_fastapi.seeder import (
|
|
40
|
+
# Dataclasses
|
|
41
|
+
SeederConfig,
|
|
42
|
+
SeederResult,
|
|
43
|
+
# Exceptions
|
|
44
|
+
SeederException,
|
|
45
|
+
SeederConflictError,
|
|
46
|
+
SeedValidationError,
|
|
47
|
+
# Registry functions
|
|
48
|
+
register_seeder,
|
|
49
|
+
get_registered_seeders,
|
|
50
|
+
clear_registry,
|
|
51
|
+
# Main orchestrator
|
|
52
|
+
seed_all,
|
|
53
|
+
)
|
|
54
|
+
|
|
28
55
|
__all__ = [
|
|
29
56
|
# Version
|
|
30
57
|
"__version__",
|
|
@@ -46,4 +73,17 @@ __all__ = [
|
|
|
46
73
|
"create_database",
|
|
47
74
|
"drop_database",
|
|
48
75
|
"list_databases",
|
|
76
|
+
# Dataclasses (seeder)
|
|
77
|
+
"SeederConfig",
|
|
78
|
+
"SeederResult",
|
|
79
|
+
# Exceptions (seeder)
|
|
80
|
+
"SeederException",
|
|
81
|
+
"SeederConflictError",
|
|
82
|
+
"SeedValidationError",
|
|
83
|
+
# Registry functions (seeder)
|
|
84
|
+
"register_seeder",
|
|
85
|
+
"get_registered_seeders",
|
|
86
|
+
"clear_registry",
|
|
87
|
+
# Main orchestrator (seeder)
|
|
88
|
+
"seed_all",
|
|
49
89
|
]
|
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pgsqlasync2fast_fastapi - Seeder Module
|
|
3
|
+
|
|
4
|
+
Multi-package JSON seeder orchestrator that executes seeders from multiple packages
|
|
5
|
+
following the standard seeder format with manifest.json per package.
|
|
6
|
+
|
|
7
|
+
This module provides the foundation for the seeder standard, including:
|
|
8
|
+
- SeederConfig: Configuration dataclass for package seeders
|
|
9
|
+
- register_seeder(): Register a package's seeder with conflict detection
|
|
10
|
+
- seed_all(): Execute all registered seeders in dependency order
|
|
11
|
+
- Idempotent seeding: rows are skipped if they already exist by ID
|
|
12
|
+
|
|
13
|
+
Example usage:
|
|
14
|
+
from pgsqlasync2fast_fastapi import seed_all, register_seeder, SeederConfig
|
|
15
|
+
|
|
16
|
+
# Register a package's seeder
|
|
17
|
+
register_seeder(SeederConfig(
|
|
18
|
+
connection_name="auth",
|
|
19
|
+
manifest_path="path/to/manifest.json",
|
|
20
|
+
package_name="my-package",
|
|
21
|
+
priority=50
|
|
22
|
+
))
|
|
23
|
+
|
|
24
|
+
# Execute all seeders
|
|
25
|
+
result = await seed_all("dev")
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import logging
|
|
32
|
+
from collections import defaultdict
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import TYPE_CHECKING, Any
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ============================================================================
|
|
44
|
+
# Exceptions
|
|
45
|
+
# ============================================================================
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SeederException(Exception):
|
|
49
|
+
"""Base exception for seeder errors."""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SeederConflictError(SeederException):
|
|
54
|
+
"""
|
|
55
|
+
Raised when two packages define overlapping tables in the same connection.
|
|
56
|
+
|
|
57
|
+
This prevents silent data corruption where two packages might try to seed
|
|
58
|
+
the same table with different data.
|
|
59
|
+
|
|
60
|
+
Example:
|
|
61
|
+
Package A and Package B both try to seed the "roles" table.
|
|
62
|
+
This would cause unpredictable behavior - which data wins?
|
|
63
|
+
SeederConflictError is raised to prevent this.
|
|
64
|
+
"""
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SeedValidationError(SeederException):
|
|
69
|
+
"""
|
|
70
|
+
Raised when FK references point to non-existent IDs or data is invalid.
|
|
71
|
+
|
|
72
|
+
This ensures data integrity before attempting any inserts.
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
A permission row references category_id=999, but no category
|
|
76
|
+
with id=999 exists in the data. This is caught before any inserts.
|
|
77
|
+
"""
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ============================================================================
|
|
82
|
+
# Dataclasses
|
|
83
|
+
# ============================================================================
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class SeederConfig:
|
|
88
|
+
"""
|
|
89
|
+
Configuration for a package's seeder.
|
|
90
|
+
|
|
91
|
+
Attributes:
|
|
92
|
+
connection_name: Name of the database connection to use
|
|
93
|
+
manifest_path: Path to the package's manifest.json
|
|
94
|
+
is_tenant_seeder: If True, this seeder seeds individual tenants
|
|
95
|
+
priority: Lower values seed first (default 50)
|
|
96
|
+
package_name: Name for conflict error messages and filtering
|
|
97
|
+
seed_fn: Async function that executes seeding (for complex seeders like tenants)
|
|
98
|
+
model_classes: Dict mapping table_name -> SQLModel class for inserts
|
|
99
|
+
fk_field_mapping: Optional FK field name mapping for JSON-to-model conversion
|
|
100
|
+
e.g., {"permissions": {"category_id": "permission_category_id"}}
|
|
101
|
+
"""
|
|
102
|
+
connection_name: str
|
|
103
|
+
manifest_path: str
|
|
104
|
+
is_tenant_seeder: bool = False
|
|
105
|
+
priority: int = 50
|
|
106
|
+
package_name: str = ""
|
|
107
|
+
seed_fn: Any = None # Async function: async def seed(profile) -> dict
|
|
108
|
+
model_classes: dict[str, type] = field(default_factory=dict) # table_name -> Model class
|
|
109
|
+
fk_field_mapping: dict[str, dict[str, str]] = field(default_factory=dict) # table -> {json_field: model_field}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class SeederResult:
|
|
114
|
+
"""
|
|
115
|
+
Result of a seed_all operation.
|
|
116
|
+
|
|
117
|
+
Attributes:
|
|
118
|
+
seeded_packages: List of package names that were seeded
|
|
119
|
+
tables_seeded: Total number of tables processed
|
|
120
|
+
rows_seeded: Total number of rows inserted
|
|
121
|
+
errors: List of error messages (empty if all succeeded)
|
|
122
|
+
skipped: List of tables that were skipped (already existed)
|
|
123
|
+
"""
|
|
124
|
+
seeded_packages: list[str] = field(default_factory=list)
|
|
125
|
+
tables_seeded: int = 0
|
|
126
|
+
rows_seeded: int = 0
|
|
127
|
+
errors: list[str] = field(default_factory=list)
|
|
128
|
+
skipped: list[str] = field(default_factory=list)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ============================================================================
|
|
132
|
+
# Registry
|
|
133
|
+
# ============================================================================
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
_SEEDER_REGISTRY: list[SeederConfig] = []
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def register_seeder(config: SeederConfig) -> None:
|
|
140
|
+
"""
|
|
141
|
+
Register a package's seeder configuration.
|
|
142
|
+
|
|
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
|
+
|
|
147
|
+
The validation happens at registration time (not execution time) to fail
|
|
148
|
+
fast and provide clear error messages.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
config: SeederConfig with connection_name, manifest_path, and priority
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
SeederConflictError: If tables overlap with an already-registered seeder
|
|
155
|
+
|
|
156
|
+
Example:
|
|
157
|
+
register_seeder(SeederConfig(
|
|
158
|
+
connection_name="auth",
|
|
159
|
+
manifest_path="permissions2fast_fastapi/seeders/manifest.json",
|
|
160
|
+
package_name="permissions2fast-fastapi",
|
|
161
|
+
priority=60
|
|
162
|
+
))
|
|
163
|
+
"""
|
|
164
|
+
# Load manifests to check for conflicts
|
|
165
|
+
for existing in _SEEDER_REGISTRY:
|
|
166
|
+
if existing.connection_name != config.connection_name:
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
# Same connection - check for table overlap
|
|
170
|
+
tables_a = set(_load_manifest(existing.manifest_path)["tables"].keys())
|
|
171
|
+
tables_b = set(_load_manifest(config.manifest_path)["tables"].keys())
|
|
172
|
+
overlap = tables_a & tables_b
|
|
173
|
+
|
|
174
|
+
if overlap:
|
|
175
|
+
pkg_a = existing.package_name or "unknown"
|
|
176
|
+
pkg_b = config.package_name or "unknown"
|
|
177
|
+
raise SeederConflictError(
|
|
178
|
+
f"Tables {overlap} conflict between "
|
|
179
|
+
f"'{pkg_a}' and '{pkg_b}' "
|
|
180
|
+
f"on connection '{config.connection_name}'"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
_SEEDER_REGISTRY.append(config)
|
|
184
|
+
logger.debug(f"Registered seeder: {config.package_name or config.connection_name} "
|
|
185
|
+
f"(priority={config.priority}, is_tenant={config.is_tenant_seeder})")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def get_registered_seeders() -> list[SeederConfig]:
|
|
189
|
+
"""
|
|
190
|
+
Return a copy of the seeder registry.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
List of all registered SeederConfig objects
|
|
194
|
+
"""
|
|
195
|
+
return list(_SEEDER_REGISTRY)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def clear_registry() -> None:
|
|
199
|
+
"""Clear all registered seeders. Useful for testing."""
|
|
200
|
+
global _SEEDER_REGISTRY
|
|
201
|
+
_SEEDER_REGISTRY = []
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ============================================================================
|
|
205
|
+
# Manifest and Data Loading
|
|
206
|
+
# ============================================================================
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _load_manifest(manifest_path: str) -> dict[str, Any]:
|
|
210
|
+
"""
|
|
211
|
+
Load and parse a manifest.json file.
|
|
212
|
+
|
|
213
|
+
The manifest is the source of truth for:
|
|
214
|
+
- Which tables this package seeds
|
|
215
|
+
- Which JSON file contains each table's data
|
|
216
|
+
- Dependencies between tables (for load order)
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
manifest_path: Path to manifest.json
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Parsed manifest dictionary with 'tables' and optional 'load_order' keys
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
FileNotFoundError: If manifest doesn't exist
|
|
226
|
+
json.JSONDecodeError: If manifest is invalid JSON
|
|
227
|
+
|
|
228
|
+
Example manifest.json:
|
|
229
|
+
{
|
|
230
|
+
"tables": {
|
|
231
|
+
"categories": {"file": "categories.json"},
|
|
232
|
+
"roles": {"file": "roles.json"},
|
|
233
|
+
"permissions": {"file": "permissions.json", "depends_on": ["categories"]}
|
|
234
|
+
},
|
|
235
|
+
"load_order": ["categories", "roles", "permissions"]
|
|
236
|
+
}
|
|
237
|
+
"""
|
|
238
|
+
path = Path(manifest_path)
|
|
239
|
+
if not path.exists():
|
|
240
|
+
raise FileNotFoundError(f"Manifest not found: {manifest_path}")
|
|
241
|
+
|
|
242
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
243
|
+
return json.load(f)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _load_table_data(table_name: str, manifest_path: str, profile: str) -> list[dict[str, Any]]:
|
|
247
|
+
"""
|
|
248
|
+
Load table data from a JSON file in the profile folder.
|
|
249
|
+
|
|
250
|
+
Each table has its own JSON file containing rows with explicit IDs.
|
|
251
|
+
The file is looked up in: {manifest_dir}/{profile}/{file}
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
table_name: Name of the table (used to find the file in manifest)
|
|
255
|
+
manifest_path: Path to the package's manifest.json
|
|
256
|
+
profile: Profile folder name (e.g., "dev", "prod")
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
List of row dictionaries with explicit IDs
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
FileNotFoundError: If the JSON file doesn't exist
|
|
263
|
+
json.JSONDecodeError: If JSON is invalid
|
|
264
|
+
SeedValidationError: If data is not a list
|
|
265
|
+
|
|
266
|
+
Example roles.json:
|
|
267
|
+
[
|
|
268
|
+
{"id": 1, "name": "Admin", "description": "Administrator role"},
|
|
269
|
+
{"id": 2, "name": "User", "description": "Regular user role"}
|
|
270
|
+
]
|
|
271
|
+
"""
|
|
272
|
+
manifest = _load_manifest(manifest_path)
|
|
273
|
+
table_config = manifest["tables"].get(table_name, {})
|
|
274
|
+
file_name = table_config.get("file", f"{table_name}.json")
|
|
275
|
+
|
|
276
|
+
# Find the JSON file in the profile folder
|
|
277
|
+
manifest_dir = Path(manifest_path).parent
|
|
278
|
+
json_path = manifest_dir / profile / file_name
|
|
279
|
+
|
|
280
|
+
if not json_path.exists():
|
|
281
|
+
logger.warning(
|
|
282
|
+
f"Seed data not found for table '{table_name}' "
|
|
283
|
+
f"in profile '{profile}': {json_path}"
|
|
284
|
+
)
|
|
285
|
+
return []
|
|
286
|
+
|
|
287
|
+
with open(json_path, "r", encoding="utf-8") as f:
|
|
288
|
+
data = json.load(f)
|
|
289
|
+
|
|
290
|
+
if not isinstance(data, list):
|
|
291
|
+
raise SeedValidationError(
|
|
292
|
+
f"Table '{table_name}' data must be a list, got {type(data).__name__}"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
return data
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# Default FK field naming convention: singularize table name + "_id"
|
|
299
|
+
# e.g., "categories" -> "category_id", "roles" -> "role_id"
|
|
300
|
+
_DEFAULT_FK_MAPPING: dict[str, str] = {
|
|
301
|
+
"categories": "category_id",
|
|
302
|
+
"roles": "role_id",
|
|
303
|
+
"permissions": "permission_id",
|
|
304
|
+
"routes": "route_id",
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
# Package can override FK field mapping via manifest
|
|
308
|
+
# manifest can have: "fk_fields": {"categories": "category_id", ...}
|
|
309
|
+
_FK_FIELD_MAPPING: dict[str, dict[str, str]] = {}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _get_fk_field(table_name: str, manifest: dict[str, Any] | None = None) -> str:
|
|
313
|
+
"""
|
|
314
|
+
Get the FK field name for a given table.
|
|
315
|
+
|
|
316
|
+
The FK field is looked up in this order:
|
|
317
|
+
1. Package-level override in manifest's fk_fields
|
|
318
|
+
2. _FK_FIELD_MAPPING (if set by package)
|
|
319
|
+
3. Default convention: table_name.rstrip('s') + "_id"
|
|
320
|
+
|
|
321
|
+
This allows packages to override the default naming for:
|
|
322
|
+
- Irregular plurals (e.g., "categories" -> "category_id")
|
|
323
|
+
- Non-English table names
|
|
324
|
+
- Explicit foreign key naming conventions
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
table_name: Plural name of the table (e.g., "categories")
|
|
328
|
+
manifest: Optional manifest dict to check for fk_fields override
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
FK field name (e.g., "category_id")
|
|
332
|
+
"""
|
|
333
|
+
# Check manifest first
|
|
334
|
+
if manifest:
|
|
335
|
+
fk_fields = manifest.get("fk_fields", {})
|
|
336
|
+
if table_name in fk_fields:
|
|
337
|
+
return fk_fields[table_name]
|
|
338
|
+
|
|
339
|
+
# Check global mapping
|
|
340
|
+
if table_name in _FK_FIELD_MAPPING:
|
|
341
|
+
return _FK_FIELD_MAPPING[table_name]
|
|
342
|
+
|
|
343
|
+
# Check default mapping
|
|
344
|
+
if table_name in _DEFAULT_FK_MAPPING:
|
|
345
|
+
return _DEFAULT_FK_MAPPING[table_name]
|
|
346
|
+
|
|
347
|
+
# Fallback to simple convention
|
|
348
|
+
return f"{table_name.rstrip('s')}_id"
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ============================================================================
|
|
352
|
+
# Validation
|
|
353
|
+
# ============================================================================
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _validate_fk_references(
|
|
357
|
+
table_name: str,
|
|
358
|
+
rows: list[dict[str, Any]],
|
|
359
|
+
manifest_path: str,
|
|
360
|
+
profile: str,
|
|
361
|
+
loaded_tables: dict[str, list[dict[str, Any]]]
|
|
362
|
+
) -> None:
|
|
363
|
+
"""
|
|
364
|
+
Validate that all FK references in rows point to existing IDs.
|
|
365
|
+
|
|
366
|
+
This is called BEFORE any inserts to prevent partial data or
|
|
367
|
+
FK constraint violations.
|
|
368
|
+
|
|
369
|
+
The FK resolution uses configurable naming convention:
|
|
370
|
+
- Check manifest's fk_fields override first
|
|
371
|
+
- Then _DEFAULT_FK_MAPPING (e.g., "categories" -> "category_id")
|
|
372
|
+
- Finally fallback to simple rstrip('s') + "_id"
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
table_name: Name of the table being validated
|
|
376
|
+
rows: List of row dictionaries
|
|
377
|
+
manifest_path: Path to manifest.json
|
|
378
|
+
profile: Profile folder
|
|
379
|
+
loaded_tables: Dict of table_name -> list of loaded rows
|
|
380
|
+
|
|
381
|
+
Raises:
|
|
382
|
+
SeedValidationError: If a referenced ID doesn't exist
|
|
383
|
+
|
|
384
|
+
Example:
|
|
385
|
+
If permissions.json has {"name": "read", "category_id": 1}
|
|
386
|
+
and categories.json doesn't have any row with id=1,
|
|
387
|
+
this raises SeedValidationError.
|
|
388
|
+
"""
|
|
389
|
+
manifest = _load_manifest(manifest_path)
|
|
390
|
+
table_config = manifest["tables"].get(table_name, {})
|
|
391
|
+
depends_on = table_config.get("depends_on", [])
|
|
392
|
+
|
|
393
|
+
for row in rows:
|
|
394
|
+
row_id = row.get("id")
|
|
395
|
+
if row_id is None:
|
|
396
|
+
raise SeedValidationError(
|
|
397
|
+
f"Table '{table_name}' row missing 'id' field: {row}"
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
# Check each dependency
|
|
401
|
+
for dep_table in depends_on:
|
|
402
|
+
# Get FK field name using configurable mapping
|
|
403
|
+
fk_field = _get_fk_field(dep_table, manifest)
|
|
404
|
+
fk_value = row.get(fk_field)
|
|
405
|
+
|
|
406
|
+
if fk_value is not None:
|
|
407
|
+
# Find the dependency table's data
|
|
408
|
+
dep_data = loaded_tables.get(dep_table, [])
|
|
409
|
+
dep_ids = {dep_row.get("id") for dep_row in dep_data}
|
|
410
|
+
|
|
411
|
+
if fk_value not in dep_ids:
|
|
412
|
+
raise SeedValidationError(
|
|
413
|
+
f"Table '{table_name}' row id={row_id} references "
|
|
414
|
+
f"non-existent {dep_table} id={fk_value} "
|
|
415
|
+
f"(FK field: {fk_field})"
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# ============================================================================
|
|
420
|
+
# Topological Sort for Load Order
|
|
421
|
+
# ============================================================================
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _resolve_load_order(manifest: dict[str, Any]) -> list[str]:
|
|
425
|
+
"""
|
|
426
|
+
Resolve the correct load order using topological sort.
|
|
427
|
+
|
|
428
|
+
Tables are sorted by:
|
|
429
|
+
1. Explicit depends_on relationships (tables dependencies first)
|
|
430
|
+
2. If no dependencies, use load_order from manifest
|
|
431
|
+
3. If no load_order either, use table name alphabetical
|
|
432
|
+
|
|
433
|
+
Uses Kahn's algorithm for topological sorting with cycle detection.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
manifest: Parsed manifest dictionary with 'tables' key
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
List of table names in correct load order
|
|
440
|
+
|
|
441
|
+
Raises:
|
|
442
|
+
SeedValidationError: If circular dependencies are detected
|
|
443
|
+
|
|
444
|
+
Example:
|
|
445
|
+
If permissions depends_on categories, categories appears before permissions
|
|
446
|
+
in the returned list.
|
|
447
|
+
"""
|
|
448
|
+
tables = manifest.get("tables", {})
|
|
449
|
+
explicit_order = manifest.get("load_order", [])
|
|
450
|
+
|
|
451
|
+
# Build adjacency list for dependency graph
|
|
452
|
+
# graph[table] = set of tables that table depends on
|
|
453
|
+
graph: dict[str, set[str]] = {}
|
|
454
|
+
in_degree: dict[str, int] = {}
|
|
455
|
+
|
|
456
|
+
for table_name in tables:
|
|
457
|
+
table_config = tables[table_name]
|
|
458
|
+
deps = table_config.get("depends_on", [])
|
|
459
|
+
graph[table_name] = set(deps)
|
|
460
|
+
in_degree[table_name] = 0
|
|
461
|
+
|
|
462
|
+
# Calculate in-degrees (how many tables depend on each table)
|
|
463
|
+
for table_name in tables:
|
|
464
|
+
for dep in graph[table_name]:
|
|
465
|
+
if dep in in_degree:
|
|
466
|
+
in_degree[table_name] += 1
|
|
467
|
+
|
|
468
|
+
# Kahn's algorithm for topological sort
|
|
469
|
+
# Start with nodes that have no dependencies
|
|
470
|
+
queue = [t for t in tables if in_degree[t] == 0]
|
|
471
|
+
sorted_tables = []
|
|
472
|
+
|
|
473
|
+
while queue:
|
|
474
|
+
# Sort queue to ensure deterministic order (alphabetical by name)
|
|
475
|
+
queue.sort()
|
|
476
|
+
current = queue.pop(0)
|
|
477
|
+
sorted_tables.append(current)
|
|
478
|
+
|
|
479
|
+
# Reduce in-degree for all tables that depend on current
|
|
480
|
+
for table_name in tables:
|
|
481
|
+
if current in graph[table_name]:
|
|
482
|
+
in_degree[table_name] -= 1
|
|
483
|
+
if in_degree[table_name] == 0:
|
|
484
|
+
queue.append(table_name)
|
|
485
|
+
|
|
486
|
+
# Check for circular dependencies
|
|
487
|
+
if len(sorted_tables) != len(tables):
|
|
488
|
+
remaining = set(tables.keys()) - set(sorted_tables)
|
|
489
|
+
raise SeedValidationError(
|
|
490
|
+
f"Circular dependency detected involving tables: {remaining}"
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
# If explicit order exists, merge it with dependency order
|
|
494
|
+
# Tables not in explicit_order get appended at the end
|
|
495
|
+
if explicit_order:
|
|
496
|
+
final_order = []
|
|
497
|
+
for table in explicit_order:
|
|
498
|
+
if table in tables and table not in final_order:
|
|
499
|
+
final_order.append(table)
|
|
500
|
+
for table in sorted_tables:
|
|
501
|
+
if table not in final_order:
|
|
502
|
+
final_order.append(table)
|
|
503
|
+
return final_order
|
|
504
|
+
|
|
505
|
+
return sorted_tables
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ============================================================================
|
|
509
|
+
# Core Seeding Logic
|
|
510
|
+
# ============================================================================
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
async def _seed_table_idempotent(
|
|
514
|
+
session: Any,
|
|
515
|
+
table_name: str,
|
|
516
|
+
rows: list[dict[str, Any]],
|
|
517
|
+
model_class: type | None = None
|
|
518
|
+
) -> tuple[int, int]:
|
|
519
|
+
"""
|
|
520
|
+
Seed a table idempotently by checking if rows exist by ID.
|
|
521
|
+
|
|
522
|
+
This function is idempotent - running it multiple times with the same
|
|
523
|
+
data produces the same result (no duplicates).
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
session: SQLModel AsyncSession
|
|
527
|
+
table_name: Name of the table (for logging)
|
|
528
|
+
rows: List of row dictionaries with explicit IDs
|
|
529
|
+
model_class: SQLModel class for the table (optional for custom handling)
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
Tuple of (rows_inserted, rows_skipped)
|
|
533
|
+
"""
|
|
534
|
+
from sqlmodel import select
|
|
535
|
+
|
|
536
|
+
rows_inserted = 0
|
|
537
|
+
rows_skipped = 0
|
|
538
|
+
|
|
539
|
+
for row in rows:
|
|
540
|
+
row_id = row.get("id")
|
|
541
|
+
if row_id is None:
|
|
542
|
+
logger.warning(f"Skipping row without ID in table '{table_name}'")
|
|
543
|
+
rows_skipped += 1
|
|
544
|
+
continue
|
|
545
|
+
|
|
546
|
+
# Check if row already exists by ID
|
|
547
|
+
if model_class is not None:
|
|
548
|
+
result = await session.exec(
|
|
549
|
+
select(model_class).where(model_class.id == row_id)
|
|
550
|
+
)
|
|
551
|
+
existing = result.one_or_none()
|
|
552
|
+
else:
|
|
553
|
+
# Fallback: assume table has 'id' column
|
|
554
|
+
existing = None
|
|
555
|
+
|
|
556
|
+
if existing is not None:
|
|
557
|
+
logger.debug(f"Skipping existing row id={row_id} in table '{table_name}'")
|
|
558
|
+
rows_skipped += 1
|
|
559
|
+
continue
|
|
560
|
+
|
|
561
|
+
# Insert new row
|
|
562
|
+
try:
|
|
563
|
+
if model_class is not None:
|
|
564
|
+
obj = model_class(**row)
|
|
565
|
+
session.add(obj)
|
|
566
|
+
await session.commit()
|
|
567
|
+
rows_inserted += 1
|
|
568
|
+
logger.debug(f"Inserted row id={row_id} in table '{table_name}'")
|
|
569
|
+
else:
|
|
570
|
+
# Cannot insert without model class - skip
|
|
571
|
+
logger.warning(f"Cannot insert without model class for table '{table_name}'")
|
|
572
|
+
rows_skipped += 1
|
|
573
|
+
except Exception as e:
|
|
574
|
+
await session.rollback()
|
|
575
|
+
logger.error(f"Failed to insert row in table '{table_name}': {e}")
|
|
576
|
+
raise
|
|
577
|
+
|
|
578
|
+
return rows_inserted, rows_skipped
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
async def _seed_table_idempotent_generic(
|
|
582
|
+
session: Any,
|
|
583
|
+
table_name: str,
|
|
584
|
+
rows: list[dict[str, Any]],
|
|
585
|
+
model_class: type,
|
|
586
|
+
fk_field_mapping: dict[str, dict[str, str]] | None = None
|
|
587
|
+
) -> tuple[int, int]:
|
|
588
|
+
"""
|
|
589
|
+
Generic idempotent seed function for packages that use the orchestrator.
|
|
590
|
+
|
|
591
|
+
This is used when the package doesn't provide its own seed_fn.
|
|
592
|
+
The orchestrator handles all the seeding logic using the model_classes
|
|
593
|
+
provided in the SeederConfig.
|
|
594
|
+
|
|
595
|
+
Args:
|
|
596
|
+
session: SQLModel AsyncSession
|
|
597
|
+
table_name: Name of the table (for logging)
|
|
598
|
+
rows: List of row dictionaries with explicit IDs
|
|
599
|
+
model_class: SQLModel class for the table
|
|
600
|
+
fk_field_mapping: Optional mapping for FK field names (JSON -> model)
|
|
601
|
+
|
|
602
|
+
Returns:
|
|
603
|
+
Tuple of (rows_inserted, rows_skipped)
|
|
604
|
+
"""
|
|
605
|
+
from sqlmodel import select
|
|
606
|
+
|
|
607
|
+
rows_inserted = 0
|
|
608
|
+
rows_skipped = 0
|
|
609
|
+
|
|
610
|
+
# Get FK field mapping for this table if provided
|
|
611
|
+
table_fk_map = (fk_field_mapping or {}).get(table_name, {})
|
|
612
|
+
|
|
613
|
+
for row in rows:
|
|
614
|
+
# Apply FK field mapping if needed (e.g., JSON has "category_id" but model uses "permission_category_id")
|
|
615
|
+
if table_fk_map:
|
|
616
|
+
row = {table_fk_map.get(k, k): v for k, v in row.items()}
|
|
617
|
+
|
|
618
|
+
row_id = row.get("id")
|
|
619
|
+
if row_id is None:
|
|
620
|
+
logger.warning(f"Skipping row without ID in table '{table_name}'")
|
|
621
|
+
rows_skipped += 1
|
|
622
|
+
continue
|
|
623
|
+
|
|
624
|
+
# Check if row already exists by ID
|
|
625
|
+
result = await session.exec(
|
|
626
|
+
select(model_class).where(model_class.id == row_id)
|
|
627
|
+
)
|
|
628
|
+
existing = result.one_or_none()
|
|
629
|
+
|
|
630
|
+
if existing is not None:
|
|
631
|
+
logger.debug(f"Skipping existing row id={row_id} in table '{table_name}'")
|
|
632
|
+
rows_skipped += 1
|
|
633
|
+
continue
|
|
634
|
+
|
|
635
|
+
# Insert new row
|
|
636
|
+
try:
|
|
637
|
+
obj = model_class(**row)
|
|
638
|
+
session.add(obj)
|
|
639
|
+
await session.commit()
|
|
640
|
+
rows_inserted += 1
|
|
641
|
+
logger.debug(f"Inserted row id={row_id} in table '{table_name}'")
|
|
642
|
+
except Exception as e:
|
|
643
|
+
await session.rollback()
|
|
644
|
+
logger.error(f"Failed to insert row in table '{table_name}': {e}")
|
|
645
|
+
raise
|
|
646
|
+
|
|
647
|
+
return rows_inserted, rows_skipped
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
# ============================================================================
|
|
651
|
+
# Main Orchestrator
|
|
652
|
+
# ============================================================================
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
async def seed_all(
|
|
656
|
+
profile: str,
|
|
657
|
+
package_filter: list[str] | None = None
|
|
658
|
+
) -> SeederResult:
|
|
659
|
+
"""
|
|
660
|
+
Execute all registered seeders in dependency order.
|
|
661
|
+
|
|
662
|
+
Seeders are executed in priority order (lower priority first).
|
|
663
|
+
Each package's tables are loaded in dependency order based on
|
|
664
|
+
the topological sort of depends_on relationships.
|
|
665
|
+
|
|
666
|
+
Args:
|
|
667
|
+
profile: Profile folder to use (e.g., "dev", "prod")
|
|
668
|
+
package_filter: Optional list of package names to include.
|
|
669
|
+
If None, all registered seeders run.
|
|
670
|
+
|
|
671
|
+
Returns:
|
|
672
|
+
SeederResult with counts and any errors
|
|
673
|
+
|
|
674
|
+
Raises:
|
|
675
|
+
Any exceptions from manifest loading or data validation are propagated.
|
|
676
|
+
|
|
677
|
+
Example:
|
|
678
|
+
# Seed all packages with dev profile
|
|
679
|
+
result = await seed_all("dev")
|
|
680
|
+
|
|
681
|
+
# Seed only permissions2fast-fastapi
|
|
682
|
+
result = await seed_all("dev", package_filter=["permissions2fast-fastapi"])
|
|
683
|
+
"""
|
|
684
|
+
result = SeederResult()
|
|
685
|
+
|
|
686
|
+
# Sort by priority (lower = first)
|
|
687
|
+
sorted_seeders = sorted(get_registered_seeders(), key=lambda s: s.priority)
|
|
688
|
+
|
|
689
|
+
for seeder in sorted_seeders:
|
|
690
|
+
# Apply package filter if specified
|
|
691
|
+
if package_filter and seeder.package_name not in package_filter:
|
|
692
|
+
continue
|
|
693
|
+
|
|
694
|
+
try:
|
|
695
|
+
pkg_name = seeder.package_name or seeder.connection_name
|
|
696
|
+
logger.info(f"Seeding package: {pkg_name}")
|
|
697
|
+
|
|
698
|
+
# If package provides its own seed_fn, delegate to it
|
|
699
|
+
if seeder.seed_fn is not None:
|
|
700
|
+
logger.debug(f"Delegating to package's seed_fn for {pkg_name}")
|
|
701
|
+
seed_result = await seeder.seed_fn(profile)
|
|
702
|
+
# Merge result
|
|
703
|
+
result.seeded_packages.append(pkg_name)
|
|
704
|
+
result.errors.extend(seed_result.get("errors", []))
|
|
705
|
+
result.tables_seeded += seed_result.get("tables_seeded", 0)
|
|
706
|
+
result.rows_seeded += seed_result.get("rows_seeded", 0)
|
|
707
|
+
logger.info(f"Completed seeding via package fn: {pkg_name}")
|
|
708
|
+
continue
|
|
709
|
+
|
|
710
|
+
# Generic seeding for packages without seed_fn
|
|
711
|
+
# (Tenant seeders handle this differently - they use their own seed_all_tenants)
|
|
712
|
+
if seeder.is_tenant_seeder:
|
|
713
|
+
logger.warning(f"Tenant seeder {pkg_name} has no seed_fn - skipping (use seed_all_tenants directly)")
|
|
714
|
+
continue
|
|
715
|
+
|
|
716
|
+
# Get database session for this connection
|
|
717
|
+
from pgsqlasync2fast_fastapi.connection import get_manager
|
|
718
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
719
|
+
|
|
720
|
+
manager = get_manager()
|
|
721
|
+
engine = manager.get_engine(seeder.connection_name)
|
|
722
|
+
|
|
723
|
+
async with AsyncSession(engine) as session:
|
|
724
|
+
# Load manifest
|
|
725
|
+
manifest = _load_manifest(seeder.manifest_path)
|
|
726
|
+
|
|
727
|
+
# Resolve load order using topological sort
|
|
728
|
+
table_order = _resolve_load_order(manifest)
|
|
729
|
+
|
|
730
|
+
# Track loaded tables for FK validation
|
|
731
|
+
loaded_tables: dict[str, list[dict[str, Any]]] = {}
|
|
732
|
+
|
|
733
|
+
for table_name in table_order:
|
|
734
|
+
rows = _load_table_data(table_name, seeder.manifest_path, profile)
|
|
735
|
+
if not rows:
|
|
736
|
+
continue
|
|
737
|
+
|
|
738
|
+
# Store for FK validation of dependent tables
|
|
739
|
+
loaded_tables[table_name] = rows
|
|
740
|
+
|
|
741
|
+
# Validate FK references before inserting any data
|
|
742
|
+
_validate_fk_references(
|
|
743
|
+
table_name, rows, seeder.manifest_path, profile, loaded_tables
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
# Seed each table using model classes from seeder config
|
|
747
|
+
for table_name in table_order:
|
|
748
|
+
rows = loaded_tables.get(table_name, [])
|
|
749
|
+
if not rows:
|
|
750
|
+
continue
|
|
751
|
+
|
|
752
|
+
model_class = seeder.model_classes.get(table_name)
|
|
753
|
+
if model_class is None:
|
|
754
|
+
logger.warning(f"No model class for table '{table_name}', skipping")
|
|
755
|
+
continue
|
|
756
|
+
|
|
757
|
+
# Use generic seed logic with model classes and FK mapping
|
|
758
|
+
inserted, skipped = await _seed_table_idempotent_generic(
|
|
759
|
+
session, table_name, rows, model_class,
|
|
760
|
+
fk_field_mapping=seeder.fk_field_mapping
|
|
761
|
+
)
|
|
762
|
+
result.rows_seeded += inserted
|
|
763
|
+
result.tables_seeded += 1
|
|
764
|
+
|
|
765
|
+
logger.info(f"Completed seeding: {pkg_name}")
|
|
766
|
+
|
|
767
|
+
except FileNotFoundError as e:
|
|
768
|
+
error_msg = f"Failed to seed {seeder.package_name or seeder.connection_name}: {e}"
|
|
769
|
+
logger.error(error_msg)
|
|
770
|
+
result.errors.append(error_msg)
|
|
771
|
+
except SeedValidationError as e:
|
|
772
|
+
error_msg = f"Validation error in {seeder.package_name or seeder.connection_name}: {e}"
|
|
773
|
+
logger.error(error_msg)
|
|
774
|
+
result.errors.append(error_msg)
|
|
775
|
+
except Exception as e:
|
|
776
|
+
error_msg = f"Failed to seed {seeder.package_name or seeder.connection_name}: {e}"
|
|
777
|
+
logger.error(error_msg)
|
|
778
|
+
result.errors.append(error_msg)
|
|
779
|
+
|
|
780
|
+
return result
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
# ============================================================================
|
|
784
|
+
# Exports
|
|
785
|
+
# ============================================================================
|
|
786
|
+
|
|
787
|
+
__all__ = [
|
|
788
|
+
# Dataclasses
|
|
789
|
+
"SeederConfig",
|
|
790
|
+
"SeederResult",
|
|
791
|
+
# Exceptions
|
|
792
|
+
"SeederException",
|
|
793
|
+
"SeederConflictError",
|
|
794
|
+
"SeedValidationError",
|
|
795
|
+
# Registry functions
|
|
796
|
+
"register_seeder",
|
|
797
|
+
"get_registered_seeders",
|
|
798
|
+
"clear_registry",
|
|
799
|
+
# Main orchestrator
|
|
800
|
+
"seed_all",
|
|
801
|
+
]
|
{pgsqlasync2fast_fastapi-0.2.0.dist-info → pgsqlasync2fast_fastapi-0.3.0.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pgsqlasync2fast-fastapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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,12 @@
|
|
|
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,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
pgsqlasync2fast_fastapi/__init__.py,sha256=TYLh-ERPDG9HGvoQbFcy3Kwj7EyoVlibZC_vAHMEvqY,1351
|
|
2
|
-
pgsqlasync2fast_fastapi/__version__.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,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/settings.py,sha256=9SAmEVtotKJYlbTjI4qkKBjEOghTTtaICAFBBrR-eHk,6678
|
|
7
|
-
pgsqlasync2fast_fastapi-0.2.0.dist-info/licenses/LICENSE,sha256=CkISX1hNEwxxrPTOXet3IYMEH28Bn7SoUyKniRjg68I,1086
|
|
8
|
-
pgsqlasync2fast_fastapi-0.2.0.dist-info/METADATA,sha256=FJibseebbvRLouGwUQm4ufrHxAvbbUIJtj3Ry9fDJns,9889
|
|
9
|
-
pgsqlasync2fast_fastapi-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
-
pgsqlasync2fast_fastapi-0.2.0.dist-info/top_level.txt,sha256=7pllDm9nlFGWKJDtG5zukxqdADqjZddU-GzaQJPLMVc,24
|
|
11
|
-
pgsqlasync2fast_fastapi-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
{pgsqlasync2fast_fastapi-0.2.0.dist-info → pgsqlasync2fast_fastapi-0.3.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{pgsqlasync2fast_fastapi-0.2.0.dist-info → pgsqlasync2fast_fastapi-0.3.0.dist-info}/top_level.txt
RENAMED
|
File without changes
|