seedgraph 0.1.1__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.
- seedgraph/__init__.py +118 -0
- seedgraph/boundary.py +47 -0
- seedgraph/exceptions.py +14 -0
- seedgraph/generators.py +327 -0
- seedgraph/graph.py +31 -0
- seedgraph/py.typed +0 -0
- seedgraph/pytest_plugin.py +98 -0
- seedgraph/shape.py +279 -0
- seedgraph/uniqueness.py +59 -0
- seedgraph/verification.py +41 -0
- seedgraph-0.1.1.dist-info/METADATA +204 -0
- seedgraph-0.1.1.dist-info/RECORD +15 -0
- seedgraph-0.1.1.dist-info/WHEEL +4 -0
- seedgraph-0.1.1.dist-info/entry_points.txt +2 -0
- seedgraph-0.1.1.dist-info/licenses/LICENSE +21 -0
seedgraph/__init__.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""seedgraph — referentially-consistent graph seeding for SQLAlchemy models.
|
|
2
|
+
|
|
3
|
+
Declare a shape, get a coherent object graph: written to the session, every FK
|
|
4
|
+
column verified against the key of the row it points at.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from sqlalchemy.orm import DeclarativeBase, Session
|
|
13
|
+
|
|
14
|
+
from seedgraph.boundary import (
|
|
15
|
+
UnattachedParentError,
|
|
16
|
+
check_parents_attached,
|
|
17
|
+
taken_values,
|
|
18
|
+
taken_values_async,
|
|
19
|
+
)
|
|
20
|
+
from seedgraph.exceptions import SeedgraphError
|
|
21
|
+
from seedgraph.generators import (
|
|
22
|
+
FieldGenerator,
|
|
23
|
+
GeneratorMap,
|
|
24
|
+
OverrideMap,
|
|
25
|
+
UniqueValueExhaustedError,
|
|
26
|
+
UnknownGeneratorColumnError,
|
|
27
|
+
UnknownOverrideColumnError,
|
|
28
|
+
UnsupportedPlaceholderError,
|
|
29
|
+
generation_state,
|
|
30
|
+
)
|
|
31
|
+
from seedgraph.graph import Graph
|
|
32
|
+
from seedgraph.shape import (
|
|
33
|
+
AmbiguousParentError,
|
|
34
|
+
AmbiguousShapeKeyError,
|
|
35
|
+
InvalidShapeCountError,
|
|
36
|
+
MissingRequiredParentError,
|
|
37
|
+
UnknownShapeKeyError,
|
|
38
|
+
UnsupportedShapeDirectionError,
|
|
39
|
+
build_graph,
|
|
40
|
+
)
|
|
41
|
+
from seedgraph.uniqueness import UniqueRepair
|
|
42
|
+
from seedgraph.verification import IncoherentGraphError, verify_graph
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
46
|
+
|
|
47
|
+
__version__ = "0.1.1"
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"AmbiguousParentError",
|
|
51
|
+
"AmbiguousShapeKeyError",
|
|
52
|
+
"Graph",
|
|
53
|
+
"IncoherentGraphError",
|
|
54
|
+
"InvalidShapeCountError",
|
|
55
|
+
"MissingRequiredParentError",
|
|
56
|
+
"SeedgraphError",
|
|
57
|
+
"UnattachedParentError",
|
|
58
|
+
"UniqueValueExhaustedError",
|
|
59
|
+
"UnknownGeneratorColumnError",
|
|
60
|
+
"UnknownOverrideColumnError",
|
|
61
|
+
"UnknownShapeKeyError",
|
|
62
|
+
"UnsupportedPlaceholderError",
|
|
63
|
+
"UnsupportedShapeDirectionError",
|
|
64
|
+
"__version__",
|
|
65
|
+
"seed",
|
|
66
|
+
"seed_async",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def seed(
|
|
71
|
+
session: Session,
|
|
72
|
+
model: type[DeclarativeBase],
|
|
73
|
+
/,
|
|
74
|
+
generators: GeneratorMap | None = None,
|
|
75
|
+
overrides: OverrideMap | None = None,
|
|
76
|
+
parents: Sequence[Any] = (),
|
|
77
|
+
**shape: int,
|
|
78
|
+
) -> Graph:
|
|
79
|
+
"""Seed a coherent object graph from the declared shape and return it.
|
|
80
|
+
|
|
81
|
+
The graph is flushed, the database assigns its keys, and every FK column is
|
|
82
|
+
verified against the row it points at. ``generators`` replaces how a column
|
|
83
|
+
generates, ``overrides`` pins a value; both are keyed {Model: {"column": ...}}.
|
|
84
|
+
``parents`` are objects of the session that links of their type point at.
|
|
85
|
+
Raises a ``SeedgraphError`` subclass on any bad declaration or broken link.
|
|
86
|
+
"""
|
|
87
|
+
check_parents_attached(session, parents)
|
|
88
|
+
state = generation_state(session.info)
|
|
89
|
+
objects = build_graph(model, shape, generators=generators, overrides=overrides, state=state, parents=parents)
|
|
90
|
+
repair = UniqueRepair(objects, FieldGenerator(generators, overrides, state))
|
|
91
|
+
while queries := repair.queries():
|
|
92
|
+
repair.reject({column: taken_values(session, column, values) for column, values in queries})
|
|
93
|
+
session.add_all(objects)
|
|
94
|
+
session.flush()
|
|
95
|
+
verify_graph(objects)
|
|
96
|
+
return Graph(objects, model.metadata)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def seed_async(
|
|
100
|
+
session: AsyncSession,
|
|
101
|
+
model: type[DeclarativeBase],
|
|
102
|
+
/,
|
|
103
|
+
generators: GeneratorMap | None = None,
|
|
104
|
+
overrides: OverrideMap | None = None,
|
|
105
|
+
parents: Sequence[Any] = (),
|
|
106
|
+
**shape: int,
|
|
107
|
+
) -> Graph:
|
|
108
|
+
"""Twin of ``seed`` on an AsyncSession: same contract, the flush is awaited."""
|
|
109
|
+
check_parents_attached(session.sync_session, parents)
|
|
110
|
+
state = generation_state(session.sync_session.info)
|
|
111
|
+
objects = build_graph(model, shape, generators=generators, overrides=overrides, state=state, parents=parents)
|
|
112
|
+
repair = UniqueRepair(objects, FieldGenerator(generators, overrides, state))
|
|
113
|
+
while queries := repair.queries():
|
|
114
|
+
repair.reject({column: await taken_values_async(session, column, values) for column, values in queries})
|
|
115
|
+
session.add_all(objects)
|
|
116
|
+
await session.flush()
|
|
117
|
+
verify_graph(objects)
|
|
118
|
+
return Graph(objects, model.metadata)
|
seedgraph/boundary.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Read what already exists at the boundary: provided parents, and the values unique columns already hold."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import Column, select
|
|
9
|
+
from sqlalchemy.orm import Session
|
|
10
|
+
|
|
11
|
+
from seedgraph.exceptions import SeedgraphError
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
15
|
+
|
|
16
|
+
__all__ = ["UnattachedParentError", "check_parents_attached", "taken_values", "taken_values_async"]
|
|
17
|
+
|
|
18
|
+
CHUNK = 500
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UnattachedParentError(SeedgraphError):
|
|
22
|
+
"""A provided parent is neither pending nor persistent in the session seeding the graph."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check_parents_attached(session: Session, parents: Sequence[Any]) -> None:
|
|
26
|
+
"""Refuse any provided parent that the session does not hold, since flushing would insert it silently."""
|
|
27
|
+
for parent in parents:
|
|
28
|
+
if parent not in session:
|
|
29
|
+
raise UnattachedParentError(
|
|
30
|
+
f"parent {type(parent).__name__} is not in the session — add it, or load it, before seeding"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def taken_values(session: Session, column: Column[Any], candidates: Sequence[Any]) -> set[Any]:
|
|
35
|
+
"""Return the candidates the column already holds in the database."""
|
|
36
|
+
taken = set()
|
|
37
|
+
for start in range(0, len(candidates), CHUNK):
|
|
38
|
+
taken.update(session.scalars(select(column).where(column.in_(candidates[start : start + CHUNK]))))
|
|
39
|
+
return taken
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def taken_values_async(session: AsyncSession, column: Column[Any], candidates: Sequence[Any]) -> set[Any]:
|
|
43
|
+
"""Twin of ``taken_values`` on an AsyncSession."""
|
|
44
|
+
taken = set()
|
|
45
|
+
for start in range(0, len(candidates), CHUNK):
|
|
46
|
+
taken.update(await session.scalars(select(column).where(column.in_(candidates[start : start + CHUNK]))))
|
|
47
|
+
return taken
|
seedgraph/exceptions.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""seedgraph exceptions."""
|
|
2
|
+
|
|
3
|
+
__all__ = ["SeedgraphError"]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SeedgraphError(Exception):
|
|
7
|
+
"""Base class for every error seedgraph raises.
|
|
8
|
+
|
|
9
|
+
Constructed with one message string naming the exact shape key, column or
|
|
10
|
+
model at fault.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str) -> None:
|
|
14
|
+
super().__init__(message)
|
seedgraph/generators.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Field generation: a seeded faker, name heuristics, custom overrides, type fallbacks.
|
|
2
|
+
|
|
3
|
+
Its world is (column, fake) -> value. No session, no shape, no graph.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from functools import cache
|
|
8
|
+
from typing import Any, TypeAlias
|
|
9
|
+
|
|
10
|
+
from faker import Faker
|
|
11
|
+
from sqlalchemy import (
|
|
12
|
+
ARRAY,
|
|
13
|
+
Boolean,
|
|
14
|
+
Column,
|
|
15
|
+
Date,
|
|
16
|
+
DateTime,
|
|
17
|
+
Enum,
|
|
18
|
+
Float,
|
|
19
|
+
Integer,
|
|
20
|
+
Interval,
|
|
21
|
+
LargeBinary,
|
|
22
|
+
Numeric,
|
|
23
|
+
String,
|
|
24
|
+
Table,
|
|
25
|
+
Time,
|
|
26
|
+
UniqueConstraint,
|
|
27
|
+
Uuid,
|
|
28
|
+
)
|
|
29
|
+
from sqlalchemy.orm import class_mapper
|
|
30
|
+
from sqlalchemy.types import TypeEngine
|
|
31
|
+
|
|
32
|
+
from seedgraph.exceptions import SeedgraphError
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"UNSET",
|
|
36
|
+
"ColumnGenerator",
|
|
37
|
+
"FieldGenerator",
|
|
38
|
+
"GenerationContext",
|
|
39
|
+
"GenerationState",
|
|
40
|
+
"UniqueValueExhaustedError",
|
|
41
|
+
"UnknownGeneratorColumnError",
|
|
42
|
+
"UnknownOverrideColumnError",
|
|
43
|
+
"UnsupportedPlaceholderError",
|
|
44
|
+
"database_fills",
|
|
45
|
+
"generation_state",
|
|
46
|
+
"is_unique",
|
|
47
|
+
"validate_column_declarations",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
DEFAULT_SEED = 42
|
|
51
|
+
DEFAULT_LOCALE = "en_US"
|
|
52
|
+
SESSION_INFO_KEY = "seedgraph"
|
|
53
|
+
|
|
54
|
+
UNSET: Any = object()
|
|
55
|
+
|
|
56
|
+
MAX_INTEGER = 100
|
|
57
|
+
MAX_NUMERIC_LEFT_DIGITS = 6
|
|
58
|
+
MAX_NUMERIC_RIGHT_DIGITS = 2
|
|
59
|
+
MAX_FLOAT_LEFT_DIGITS = 4
|
|
60
|
+
BINARY_LENGTH = 16
|
|
61
|
+
MAX_ARRAY_ITEMS = 3
|
|
62
|
+
MAX_UNIQUE_ATTEMPTS = 100
|
|
63
|
+
MAX_UNIQUE_INTEGER = 2**31 - 1
|
|
64
|
+
|
|
65
|
+
ColumnGenerator: TypeAlias = Callable[["GenerationContext"], Any]
|
|
66
|
+
ColumnOverride: TypeAlias = ColumnGenerator | Any
|
|
67
|
+
ColumnMap: TypeAlias = dict[str, ColumnOverride]
|
|
68
|
+
GeneratorMap: TypeAlias = dict[type, dict[str, ColumnGenerator]]
|
|
69
|
+
OverrideMap: TypeAlias = dict[type, ColumnMap]
|
|
70
|
+
|
|
71
|
+
COLUMN_HINTS = {
|
|
72
|
+
"name": "name",
|
|
73
|
+
"first_name": "first_name",
|
|
74
|
+
"last_name": "last_name",
|
|
75
|
+
"username": "user_name",
|
|
76
|
+
"user_name": "user_name",
|
|
77
|
+
"email": "email",
|
|
78
|
+
"title": "sentence",
|
|
79
|
+
"body": "paragraph",
|
|
80
|
+
"description": "paragraph",
|
|
81
|
+
"content": "paragraph",
|
|
82
|
+
"phone": "phone_number",
|
|
83
|
+
"phone_number": "phone_number",
|
|
84
|
+
"url": "url",
|
|
85
|
+
"company": "company",
|
|
86
|
+
"city": "city",
|
|
87
|
+
"country": "country",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class UnsupportedPlaceholderError(SeedgraphError):
|
|
92
|
+
"""A NOT NULL column without default carries a type no generator covers."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class UniqueValueExhaustedError(SeedgraphError):
|
|
96
|
+
"""A unique column's generator kept producing values already used or already in the database."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class UnknownGeneratorColumnError(SeedgraphError):
|
|
100
|
+
"""A column declared in generators does not exist on its model."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class UnknownOverrideColumnError(SeedgraphError):
|
|
104
|
+
"""A column declared in overrides does not exist on its model."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_generators(generators: GeneratorMap | None) -> None:
|
|
108
|
+
"""Reject any declared column that matches no column of its declared model."""
|
|
109
|
+
validate_column_declarations(generators, None)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def validate_column_declarations(generators: GeneratorMap | None, overrides: OverrideMap | None) -> None:
|
|
113
|
+
"""Reject any declared column that matches no column of its declared model."""
|
|
114
|
+
errors = (
|
|
115
|
+
(generators, UnknownGeneratorColumnError, "generated"),
|
|
116
|
+
(overrides, UnknownOverrideColumnError, "overridden"),
|
|
117
|
+
)
|
|
118
|
+
for declarations, error, verb in errors:
|
|
119
|
+
for model, columns in (declarations or {}).items():
|
|
120
|
+
existing = {column.key for column in class_mapper(model).local_table.columns}
|
|
121
|
+
for column in columns:
|
|
122
|
+
if column not in existing:
|
|
123
|
+
raise error(f"unknown {verb} column {column!r} for {model.__name__}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class GenerationState:
|
|
127
|
+
"""What generation carries from one seed() to the next on the same session: the faker, the used unique values."""
|
|
128
|
+
|
|
129
|
+
def __init__(self) -> None:
|
|
130
|
+
self.fake: Faker = Faker(locale=DEFAULT_LOCALE)
|
|
131
|
+
self.fake.seed_instance(DEFAULT_SEED)
|
|
132
|
+
self.used: dict[tuple[str, str], set[Any]] = {}
|
|
133
|
+
|
|
134
|
+
def used_values(self, column: Column[Any]) -> set[Any]:
|
|
135
|
+
return self.used.setdefault((column.table.key, column.key), set())
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def generation_state(info: dict[Any, Any]) -> GenerationState:
|
|
139
|
+
"""Return the session's generation state, created on its first seed() from ``session.info``."""
|
|
140
|
+
return info.setdefault(SESSION_INFO_KEY, GenerationState())
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class GenerationContext:
|
|
144
|
+
"""The object handed to custom generators: the seeded fake and the column name."""
|
|
145
|
+
|
|
146
|
+
def __init__(self, fake: Faker, column: str) -> None:
|
|
147
|
+
self.fake: Faker = fake
|
|
148
|
+
self.column: str = column
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class FieldGenerator:
|
|
152
|
+
"""Generate values for eligible columns through one seeded Faker instance."""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
generators: GeneratorMap | None = None,
|
|
157
|
+
overrides: OverrideMap | None = None,
|
|
158
|
+
state: GenerationState | None = None,
|
|
159
|
+
) -> None:
|
|
160
|
+
self._state = state or GenerationState()
|
|
161
|
+
self._fake = self._state.fake
|
|
162
|
+
self._generators: dict[type, dict[str, ColumnGenerator]] = generators or {}
|
|
163
|
+
self._overrides: OverrideMap = overrides or {}
|
|
164
|
+
|
|
165
|
+
def value_for(self, model: type, column: Column[Any]) -> Any:
|
|
166
|
+
"""Return the column's value, or UNSET for a nullable column of a type no provider covers."""
|
|
167
|
+
override = self.override_for(model, column)
|
|
168
|
+
if override is not UNSET:
|
|
169
|
+
return override
|
|
170
|
+
produce = self._producer(model, column)
|
|
171
|
+
if produce is None:
|
|
172
|
+
if column.nullable:
|
|
173
|
+
return UNSET
|
|
174
|
+
raise UnsupportedPlaceholderError(
|
|
175
|
+
f"cannot generate NOT NULL column {column.table.key}.{column.key} of type {column.type}"
|
|
176
|
+
)
|
|
177
|
+
if not is_unique(column):
|
|
178
|
+
return produce()
|
|
179
|
+
used = self._state.used_values(column)
|
|
180
|
+
for _ in range(MAX_UNIQUE_ATTEMPTS):
|
|
181
|
+
value = produce()
|
|
182
|
+
if value not in used:
|
|
183
|
+
used.add(value)
|
|
184
|
+
return value
|
|
185
|
+
raise UniqueValueExhaustedError(
|
|
186
|
+
f"no new value for unique column {column.table.key}.{column.key} after {MAX_UNIQUE_ATTEMPTS}"
|
|
187
|
+
" attempts — widen its generator or declare one in generators"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def is_overridden(self, model: type, column: Column[Any]) -> bool:
|
|
191
|
+
return column.key in self._overrides.get(model, {})
|
|
192
|
+
|
|
193
|
+
def remember(self, column: Column[Any], values: set[Any]) -> None:
|
|
194
|
+
"""Mark values as used so the unique column never produces them again."""
|
|
195
|
+
self._state.used_values(column).update(values)
|
|
196
|
+
|
|
197
|
+
def _producer(self, model: type, column: Column[Any]) -> Callable[[], Any] | None:
|
|
198
|
+
custom = self._generators.get(model)
|
|
199
|
+
if custom is not None and column.key in custom:
|
|
200
|
+
return lambda: custom[column.key](GenerationContext(self._fake, column.key))
|
|
201
|
+
provider = self._provider(column)
|
|
202
|
+
if provider is None:
|
|
203
|
+
return None
|
|
204
|
+
return lambda: _fit(provider(), column.type)
|
|
205
|
+
|
|
206
|
+
def override_for(self, model: type, column: Column[Any]) -> Any:
|
|
207
|
+
"""Return the declared override's resolved value for the column, or UNSET when undeclared."""
|
|
208
|
+
override = self._overrides.get(model)
|
|
209
|
+
if override is None or column.key not in override:
|
|
210
|
+
return UNSET
|
|
211
|
+
return self._resolve(override[column.key], column)
|
|
212
|
+
|
|
213
|
+
def _resolve(self, value: ColumnOverride, column: Column[Any]) -> Any:
|
|
214
|
+
if callable(value):
|
|
215
|
+
return value(GenerationContext(self._fake, column.key))
|
|
216
|
+
return value
|
|
217
|
+
|
|
218
|
+
def _provider(self, column: Column[Any]) -> Callable[[], Any] | None:
|
|
219
|
+
if isinstance(column.type, Integer) and is_unique(column):
|
|
220
|
+
return lambda: self._fake.random_int(min=1, max=MAX_UNIQUE_INTEGER)
|
|
221
|
+
if _is_text(column.type):
|
|
222
|
+
hint = COLUMN_HINTS.get(column.key.lower())
|
|
223
|
+
if hint is not None:
|
|
224
|
+
return getattr(self._fake, hint)
|
|
225
|
+
return self._type_provider(column.type)
|
|
226
|
+
|
|
227
|
+
def _type_provider(self, type_: TypeEngine[Any]) -> Callable[[], Any] | None:
|
|
228
|
+
fake = self._fake
|
|
229
|
+
if isinstance(type_, Enum):
|
|
230
|
+
choices = list(type_.enum_class) if type_.enum_class is not None else list(type_.enums)
|
|
231
|
+
return lambda: fake.random_element(choices)
|
|
232
|
+
if isinstance(type_, Boolean):
|
|
233
|
+
return fake.boolean
|
|
234
|
+
if isinstance(type_, Integer):
|
|
235
|
+
return lambda: fake.random_int(min=0, max=MAX_INTEGER)
|
|
236
|
+
if isinstance(type_, Float):
|
|
237
|
+
return lambda: fake.pyfloat(left_digits=MAX_FLOAT_LEFT_DIGITS, right_digits=MAX_NUMERIC_RIGHT_DIGITS)
|
|
238
|
+
if isinstance(type_, Numeric):
|
|
239
|
+
return self._numeric_provider(type_)
|
|
240
|
+
if isinstance(type_, DateTime):
|
|
241
|
+
return fake.date_time
|
|
242
|
+
if isinstance(type_, Date):
|
|
243
|
+
return fake.date_object
|
|
244
|
+
if isinstance(type_, Time):
|
|
245
|
+
return fake.time_object
|
|
246
|
+
if isinstance(type_, Interval):
|
|
247
|
+
return fake.time_delta
|
|
248
|
+
if isinstance(type_, Uuid):
|
|
249
|
+
return (lambda: fake.uuid4(cast_to=None)) if type_.as_uuid else fake.uuid4
|
|
250
|
+
if isinstance(type_, LargeBinary):
|
|
251
|
+
return lambda: fake.binary(length=BINARY_LENGTH)
|
|
252
|
+
if isinstance(type_, ARRAY):
|
|
253
|
+
return self._array_provider(type_)
|
|
254
|
+
if isinstance(type_, String):
|
|
255
|
+
return fake.sentence
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
def _array_provider(self, type_: ARRAY[Any]) -> Callable[[], Any] | None:
|
|
259
|
+
item = self._type_provider(type_.item_type)
|
|
260
|
+
if item is None:
|
|
261
|
+
return None
|
|
262
|
+
return lambda: [_fit(item(), type_.item_type) for _ in range(self._fake.random_int(1, MAX_ARRAY_ITEMS))]
|
|
263
|
+
|
|
264
|
+
def _numeric_provider(self, type_: Numeric[Any]) -> Callable[[], Any]:
|
|
265
|
+
right = MAX_NUMERIC_RIGHT_DIGITS if type_.scale is None else type_.scale
|
|
266
|
+
left = MAX_NUMERIC_LEFT_DIGITS if type_.precision is None else type_.precision - right
|
|
267
|
+
return lambda: self._fake.pydecimal(left_digits=left, right_digits=right, positive=True)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@cache
|
|
271
|
+
def is_unique(column: Column[Any]) -> bool:
|
|
272
|
+
"""A generated column seedgraph keeps unique alone: flagged, alone in a unique constraint or index, or chosen for one.
|
|
273
|
+
|
|
274
|
+
A key column the database does not fill counts; in a multi-column constraint, the generated column with the widest values is chosen.
|
|
275
|
+
"""
|
|
276
|
+
if column.unique or (column.primary_key and not column.foreign_keys and not database_fills(column)):
|
|
277
|
+
return True
|
|
278
|
+
return any(_carrier(columns) is column for columns in _unique_column_sets(column.table))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _unique_column_sets(table: Table) -> list[list[Column[Any]]]:
|
|
282
|
+
constraints = [list(c.columns) for c in table.constraints if isinstance(c, UniqueConstraint)]
|
|
283
|
+
return constraints + [list(index.columns) for index in table.indexes if index.unique]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
_VALUE_SPACE = ["text", "identifier", "number", "moment"]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _carrier(columns: list[Column[Any]]) -> Column[Any] | None:
|
|
290
|
+
if len(columns) == 1:
|
|
291
|
+
return columns[0]
|
|
292
|
+
candidates = [
|
|
293
|
+
column
|
|
294
|
+
for column in columns
|
|
295
|
+
if not column.foreign_keys and not database_fills(column) and _value_space(column.type) is not None
|
|
296
|
+
]
|
|
297
|
+
return min(candidates, key=lambda column: _VALUE_SPACE.index(_value_space(column.type)), default=None)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _value_space(type_: TypeEngine[Any]) -> str | None:
|
|
301
|
+
if isinstance(type_, (Enum, Boolean)):
|
|
302
|
+
return None
|
|
303
|
+
if isinstance(type_, String):
|
|
304
|
+
return "text"
|
|
305
|
+
if isinstance(type_, (Uuid, LargeBinary)):
|
|
306
|
+
return "identifier"
|
|
307
|
+
if isinstance(type_, (Integer, Numeric)):
|
|
308
|
+
return "number"
|
|
309
|
+
if isinstance(type_, (Date, DateTime, Time, Interval)):
|
|
310
|
+
return "moment"
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def database_fills(column: Column[Any]) -> bool:
|
|
315
|
+
"""The database, or the column's own default, gives the value when seedgraph leaves it empty."""
|
|
316
|
+
return column is column.table.autoincrement_column or column.default is not None or column.server_default is not None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _is_text(type_: TypeEngine[Any]) -> bool:
|
|
320
|
+
return isinstance(type_, String) and not isinstance(type_, Enum)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _fit(value: Any, type_: TypeEngine[Any]) -> Any:
|
|
324
|
+
"""Cut a generated string to the column's declared length."""
|
|
325
|
+
if isinstance(value, str) and _is_text(type_) and type_.length:
|
|
326
|
+
return value[: type_.length].rstrip()
|
|
327
|
+
return value
|
seedgraph/graph.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The result of seed(): the generated objects, grouped by table name."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import MetaData
|
|
7
|
+
from sqlalchemy.orm import class_mapper
|
|
8
|
+
|
|
9
|
+
__all__ = ["Graph"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Graph:
|
|
13
|
+
"""Group the seeded objects by table name, exposed as attributes.
|
|
14
|
+
|
|
15
|
+
Each table of the seeded model's metadata is an attribute holding the list of
|
|
16
|
+
generated objects of that table, empty when the shape built none: ``graph.users``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, objects: Sequence[Any], metadata: MetaData) -> None:
|
|
20
|
+
self._table_names = sorted(table.name for table in metadata.tables.values())
|
|
21
|
+
grouped: dict[str, list[Any]] = {}
|
|
22
|
+
for obj in objects:
|
|
23
|
+
grouped.setdefault(class_mapper(type(obj)).local_table.name, []).append(obj)
|
|
24
|
+
for table_name, group in grouped.items():
|
|
25
|
+
setattr(self, table_name, group)
|
|
26
|
+
|
|
27
|
+
def __getattr__(self, name: str) -> list[Any]:
|
|
28
|
+
if not name.startswith("_") and name in self._table_names:
|
|
29
|
+
return []
|
|
30
|
+
known = ", ".join(self._table_names)
|
|
31
|
+
raise AttributeError(f"the graph has no table {name!r} — known tables: {known}")
|
seedgraph/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""pytest plugin: a fresh sqlite session and a seed callable, served without configuration.
|
|
2
|
+
|
|
3
|
+
Every fixture carries the ``seedgraph_`` prefix, so it never shadows a project's own ``session``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import AsyncIterator, Callable, Iterator, Sequence
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from sqlalchemy import create_engine, event
|
|
13
|
+
from sqlalchemy.orm import Session
|
|
14
|
+
|
|
15
|
+
from seedgraph import Graph, seed, seed_async
|
|
16
|
+
from seedgraph.generators import GeneratorMap, OverrideMap
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
# pytest-asyncio's default strict mode only runs async fixtures declared through its own decorator.
|
|
23
|
+
from pytest_asyncio import fixture as async_fixture
|
|
24
|
+
except ImportError:
|
|
25
|
+
async_fixture = pytest.fixture
|
|
26
|
+
|
|
27
|
+
__all__ = ["seedgraph_agraph", "seedgraph_asession", "seedgraph_graph", "seedgraph_session"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture()
|
|
31
|
+
def seedgraph_session() -> Iterator[Session]:
|
|
32
|
+
"""Serve a fresh in-memory sqlite Session with foreign keys enforced."""
|
|
33
|
+
engine = create_engine("sqlite://")
|
|
34
|
+
|
|
35
|
+
@event.listens_for(engine, "connect")
|
|
36
|
+
def enforce_fk(dbapi_conn: Any, _: Any) -> None:
|
|
37
|
+
dbapi_conn.execute("PRAGMA foreign_keys=ON")
|
|
38
|
+
|
|
39
|
+
with Session(engine) as fresh:
|
|
40
|
+
yield fresh
|
|
41
|
+
engine.dispose()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.fixture()
|
|
45
|
+
def seedgraph_graph(seedgraph_session: Session) -> Callable[..., Graph]:
|
|
46
|
+
"""Serve a seed callable on the fresh session; tables are created on demand."""
|
|
47
|
+
|
|
48
|
+
def make(
|
|
49
|
+
model: type[Any],
|
|
50
|
+
/,
|
|
51
|
+
generators: GeneratorMap | None = None,
|
|
52
|
+
overrides: OverrideMap | None = None,
|
|
53
|
+
parents: Sequence[Any] = (),
|
|
54
|
+
**shape: int,
|
|
55
|
+
) -> Graph:
|
|
56
|
+
model.metadata.create_all(seedgraph_session.get_bind(), checkfirst=True)
|
|
57
|
+
return seed(seedgraph_session, model, generators=generators, overrides=overrides, parents=parents, **shape)
|
|
58
|
+
|
|
59
|
+
return make
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@async_fixture()
|
|
63
|
+
async def seedgraph_asession() -> AsyncIterator[AsyncSession]:
|
|
64
|
+
"""Serve a fresh in-memory aiosqlite AsyncSession with foreign keys enforced."""
|
|
65
|
+
# Imported here: sqlalchemy.ext.asyncio needs greenlet, which only the async extra installs.
|
|
66
|
+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
|
67
|
+
|
|
68
|
+
engine = create_async_engine("sqlite+aiosqlite://")
|
|
69
|
+
|
|
70
|
+
@event.listens_for(engine.sync_engine, "connect")
|
|
71
|
+
def enforce_fk(dbapi_conn: Any, _: Any) -> None:
|
|
72
|
+
dbapi_conn.execute("PRAGMA foreign_keys=ON")
|
|
73
|
+
|
|
74
|
+
async with AsyncSession(engine) as fresh:
|
|
75
|
+
yield fresh
|
|
76
|
+
await engine.dispose()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@async_fixture()
|
|
80
|
+
async def seedgraph_agraph(seedgraph_asession: AsyncSession) -> Callable[..., Any]:
|
|
81
|
+
"""Serve an awaited seed callable on the fresh async session; tables on demand."""
|
|
82
|
+
|
|
83
|
+
async def make(
|
|
84
|
+
model: type[Any],
|
|
85
|
+
/,
|
|
86
|
+
generators: GeneratorMap | None = None,
|
|
87
|
+
overrides: OverrideMap | None = None,
|
|
88
|
+
parents: Sequence[Any] = (),
|
|
89
|
+
**shape: int,
|
|
90
|
+
) -> Graph:
|
|
91
|
+
await seedgraph_asession.run_sync(
|
|
92
|
+
lambda sync_session: model.metadata.create_all(sync_session.get_bind(), checkfirst=True)
|
|
93
|
+
)
|
|
94
|
+
return await seed_async(
|
|
95
|
+
seedgraph_asession, model, generators=generators, overrides=overrides, parents=parents, **shape
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return make
|
seedgraph/shape.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Build the object graph declared by a shape: root count first, children level by level."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import Column, inspect
|
|
7
|
+
from sqlalchemy.orm import DeclarativeBase, class_mapper
|
|
8
|
+
from sqlalchemy.orm.relationships import Relationship
|
|
9
|
+
|
|
10
|
+
from seedgraph.exceptions import SeedgraphError
|
|
11
|
+
from seedgraph.generators import (
|
|
12
|
+
UNSET,
|
|
13
|
+
FieldGenerator,
|
|
14
|
+
GenerationState,
|
|
15
|
+
GeneratorMap,
|
|
16
|
+
OverrideMap,
|
|
17
|
+
UnsupportedPlaceholderError,
|
|
18
|
+
database_fills,
|
|
19
|
+
validate_column_declarations,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AmbiguousParentError",
|
|
24
|
+
"AmbiguousShapeKeyError",
|
|
25
|
+
"InvalidShapeCountError",
|
|
26
|
+
"MissingRequiredParentError",
|
|
27
|
+
"UnknownShapeKeyError",
|
|
28
|
+
"UnsupportedPlaceholderError",
|
|
29
|
+
"UnsupportedShapeDirectionError",
|
|
30
|
+
"build_graph",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
DEFAULT_COUNT = 3
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UnknownShapeKeyError(SeedgraphError):
|
|
37
|
+
"""A shape key matches no relationship of the model at that point of the path."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AmbiguousShapeKeyError(SeedgraphError):
|
|
41
|
+
"""A shape key matches several relationships of the model at that point of the path."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class InvalidShapeCountError(SeedgraphError):
|
|
45
|
+
"""A shape count is not an integer greater than or equal to zero."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UnsupportedShapeDirectionError(SeedgraphError):
|
|
49
|
+
"""A shape key walks a relationship that cannot build children: towards a parent, or view-only."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class MissingRequiredParentError(SeedgraphError):
|
|
53
|
+
"""A required link has no matching ancestor in the branch and was not declared in the shape."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AmbiguousParentError(SeedgraphError):
|
|
57
|
+
"""A link towards a single parent finds several objects of its type among the provided parents."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_graph(
|
|
61
|
+
model: type[DeclarativeBase],
|
|
62
|
+
shape: Mapping[str, int],
|
|
63
|
+
generators: GeneratorMap | None = None,
|
|
64
|
+
overrides: OverrideMap | None = None,
|
|
65
|
+
state: GenerationState | None = None,
|
|
66
|
+
parents: Sequence[Any] = (),
|
|
67
|
+
) -> list[Any]:
|
|
68
|
+
"""Build the declared shape's objects, level by level, and link their parents.
|
|
69
|
+
|
|
70
|
+
A link takes the nearest ancestor of its type in the branch, then the provided parent of that type;
|
|
71
|
+
a required link still unset gets a parent generated once per type and shared, added to the result.
|
|
72
|
+
"""
|
|
73
|
+
counts = dict(shape)
|
|
74
|
+
root_key = model.__name__.lower()
|
|
75
|
+
root_count = counts.pop(root_key, DEFAULT_COUNT)
|
|
76
|
+
_check_count(root_key, root_count)
|
|
77
|
+
tree = _resolve_tree(model, counts)
|
|
78
|
+
validate_column_declarations(generators, overrides)
|
|
79
|
+
objects: list[Any] = []
|
|
80
|
+
generator = FieldGenerator(generators, overrides, state)
|
|
81
|
+
linker = _ParentLinker(generator, objects, parents)
|
|
82
|
+
for _ in range(root_count):
|
|
83
|
+
root = _build_object(model, generator)
|
|
84
|
+
linker.link(root, [])
|
|
85
|
+
objects.append(root)
|
|
86
|
+
_attach_children(root, tree, objects, generator, linker, [])
|
|
87
|
+
return objects
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_tree(model: type[DeclarativeBase], counts: Mapping[str, int]) -> dict[str, Any]:
|
|
91
|
+
"""Resolve every shape key into a navigated tree — errors fire before anything is built."""
|
|
92
|
+
root = {"children": {}}
|
|
93
|
+
for key, count in counts.items():
|
|
94
|
+
_check_count(key, count)
|
|
95
|
+
node = root
|
|
96
|
+
current = model
|
|
97
|
+
for segment in key.split("__"):
|
|
98
|
+
relationship = _resolve_segment(current, segment)
|
|
99
|
+
current = relationship.mapper.class_
|
|
100
|
+
node = node["children"].setdefault(
|
|
101
|
+
relationship.key, {"relationship": relationship, "count": None, "children": {}}
|
|
102
|
+
)
|
|
103
|
+
node["count"] = count
|
|
104
|
+
return root
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _resolve_segment(model: type[DeclarativeBase], segment: str) -> Relationship:
|
|
108
|
+
"""Resolve one shape segment: exact relationship key first, then the model it points at."""
|
|
109
|
+
mapper = class_mapper(model)
|
|
110
|
+
exact = [rel for rel in mapper.relationships if rel.key == segment]
|
|
111
|
+
if exact:
|
|
112
|
+
relationship = exact[0]
|
|
113
|
+
else:
|
|
114
|
+
matches = [
|
|
115
|
+
rel for rel in mapper.relationships
|
|
116
|
+
if rel.mapper.class_.__name__.lower() == segment and not rel.viewonly
|
|
117
|
+
]
|
|
118
|
+
if not matches:
|
|
119
|
+
raise UnknownShapeKeyError(f"unknown shape key {segment!r} for {model.__name__}")
|
|
120
|
+
if len(matches) > 1:
|
|
121
|
+
names = ", ".join(sorted(rel.key for rel in matches))
|
|
122
|
+
raise AmbiguousShapeKeyError(
|
|
123
|
+
f"shape key {segment!r} matches several relationships of {model.__name__}"
|
|
124
|
+
f" — address it by its relationship key instead: {names}"
|
|
125
|
+
)
|
|
126
|
+
relationship = matches[0]
|
|
127
|
+
return _check_relationship(model, segment, relationship)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _check_relationship(model: type[DeclarativeBase], segment: str, relationship: Relationship) -> Relationship:
|
|
131
|
+
walked = f"shape key {segment!r} walks {model.__name__}.{relationship.key}"
|
|
132
|
+
if relationship.viewonly:
|
|
133
|
+
raise UnsupportedShapeDirectionError(f"{walked}, a viewonly relationship — nothing would be written")
|
|
134
|
+
if relationship.direction.name == "MANYTOONE":
|
|
135
|
+
raise UnsupportedShapeDirectionError(
|
|
136
|
+
f"{walked}, which points at a parent — parents are linked or generated on their own,"
|
|
137
|
+
" pass existing ones in parents"
|
|
138
|
+
)
|
|
139
|
+
return relationship
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _attach_children(
|
|
143
|
+
parent: Any,
|
|
144
|
+
node: dict[str, Any],
|
|
145
|
+
objects: list[Any],
|
|
146
|
+
generator: FieldGenerator,
|
|
147
|
+
linker: "_ParentLinker",
|
|
148
|
+
ancestors: list[Any],
|
|
149
|
+
) -> None:
|
|
150
|
+
branch = [*ancestors, parent]
|
|
151
|
+
for child_node in node["children"].values():
|
|
152
|
+
relationship = child_node["relationship"]
|
|
153
|
+
count = DEFAULT_COUNT if child_node["count"] is None else child_node["count"]
|
|
154
|
+
for _ in range(count):
|
|
155
|
+
child = _build_object(relationship.mapper.class_, generator)
|
|
156
|
+
getattr(parent, relationship.key).append(child)
|
|
157
|
+
linker.link(child, branch)
|
|
158
|
+
objects.append(child)
|
|
159
|
+
_attach_children(child, child_node, objects, generator, linker, branch)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class _ParentLinker:
|
|
163
|
+
def __init__(self, generator: FieldGenerator, objects: list[Any], parents: Sequence[Any]) -> None:
|
|
164
|
+
self._generator = generator
|
|
165
|
+
self._objects = objects
|
|
166
|
+
self._provided = _index_parents(parents)
|
|
167
|
+
self._generated: dict[type, Any] = {}
|
|
168
|
+
self._in_progress: list[type] = []
|
|
169
|
+
|
|
170
|
+
def link(self, obj: Any, ancestors: Sequence[Any]) -> None:
|
|
171
|
+
state = inspect(obj)
|
|
172
|
+
for relationship in state.mapper.relationships:
|
|
173
|
+
if relationship.viewonly:
|
|
174
|
+
continue
|
|
175
|
+
if relationship.direction.name == "MANYTOMANY":
|
|
176
|
+
self._share(obj, relationship)
|
|
177
|
+
continue
|
|
178
|
+
if relationship.direction.name != "MANYTOONE":
|
|
179
|
+
continue
|
|
180
|
+
if relationship.key not in state.unloaded and getattr(obj, relationship.key) is not None:
|
|
181
|
+
continue
|
|
182
|
+
parent = self._parent_for(obj, relationship, ancestors)
|
|
183
|
+
if parent is not None:
|
|
184
|
+
setattr(obj, relationship.key, parent)
|
|
185
|
+
|
|
186
|
+
def _parent_for(self, obj: Any, relationship: Relationship, ancestors: Sequence[Any]) -> Any:
|
|
187
|
+
target = relationship.mapper.class_
|
|
188
|
+
required = _is_required(relationship)
|
|
189
|
+
if required:
|
|
190
|
+
for ancestor in reversed(ancestors):
|
|
191
|
+
if type(ancestor) is target:
|
|
192
|
+
return ancestor
|
|
193
|
+
candidates = self._provided.get(target, [])
|
|
194
|
+
if len(candidates) > 1:
|
|
195
|
+
raise AmbiguousParentError(
|
|
196
|
+
f"{type(obj).__name__}.{relationship.key} links one {target.__name__}, but {len(candidates)}"
|
|
197
|
+
" were passed in parents — pass one"
|
|
198
|
+
)
|
|
199
|
+
if candidates:
|
|
200
|
+
return candidates[0]
|
|
201
|
+
if not required:
|
|
202
|
+
return None
|
|
203
|
+
if target in self._generated:
|
|
204
|
+
return self._generated[target]
|
|
205
|
+
if target is type(obj) or target in self._in_progress:
|
|
206
|
+
raise MissingRequiredParentError(self._unreachable(obj, relationship, target))
|
|
207
|
+
return self._generate(target)
|
|
208
|
+
|
|
209
|
+
def _share(self, obj: Any, relationship: Relationship) -> None:
|
|
210
|
+
collection = getattr(obj, relationship.key)
|
|
211
|
+
for shared in self._provided.get(relationship.mapper.class_, []):
|
|
212
|
+
if shared not in collection:
|
|
213
|
+
collection.append(shared)
|
|
214
|
+
|
|
215
|
+
def _generate(self, target: type[DeclarativeBase]) -> Any:
|
|
216
|
+
self._in_progress.append(target)
|
|
217
|
+
parent = _build_object(target, self._generator)
|
|
218
|
+
self.link(parent, [])
|
|
219
|
+
self._in_progress.pop()
|
|
220
|
+
self._generated[target] = parent
|
|
221
|
+
self._objects.append(parent)
|
|
222
|
+
return parent
|
|
223
|
+
|
|
224
|
+
def _unreachable(self, obj: Any, relationship: Relationship, target: type) -> str:
|
|
225
|
+
where = f"{type(obj).__name__}.{relationship.key} requires a {target.__name__}"
|
|
226
|
+
if target is type(obj):
|
|
227
|
+
return (
|
|
228
|
+
f"{where}: the first object of a self-referential branch cannot have a required parent"
|
|
229
|
+
" — make the FK nullable, or pass an existing one in parents"
|
|
230
|
+
)
|
|
231
|
+
loop = " -> ".join(model.__name__ for model in [*self._in_progress, target])
|
|
232
|
+
return f"{where}, but required links form a loop ({loop}) — pass one of them in parents"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _index_parents(parents: Sequence[Any]) -> dict[type, list[Any]]:
|
|
236
|
+
provided: dict[type, list[Any]] = {}
|
|
237
|
+
for parent in parents:
|
|
238
|
+
provided.setdefault(type(parent), []).append(parent)
|
|
239
|
+
return provided
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _is_required(relationship: Relationship) -> bool:
|
|
243
|
+
"""A link is required when any of its local FK columns is NOT NULL."""
|
|
244
|
+
return any(not local_column.nullable for local_column, _ in relationship.local_remote_pairs)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _check_count(key: str, count: object) -> None:
|
|
248
|
+
if not isinstance(count, int) or isinstance(count, bool) or count < 0:
|
|
249
|
+
raise InvalidShapeCountError(f"shape count for {key!r} must be an integer >= 0, got {count!r}")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _build_object(model: type[DeclarativeBase], generator: FieldGenerator) -> Any:
|
|
253
|
+
"""Build one object of the model: generate eligible columns, apply declared overrides elsewhere."""
|
|
254
|
+
obj = model()
|
|
255
|
+
mapper = class_mapper(type(obj))
|
|
256
|
+
for column in mapper.local_table.columns:
|
|
257
|
+
if column.foreign_keys:
|
|
258
|
+
continue
|
|
259
|
+
key = mapper.get_property_by_column(column).key
|
|
260
|
+
if column.primary_key:
|
|
261
|
+
_fill_primary_key(obj, key, model, column, generator)
|
|
262
|
+
continue
|
|
263
|
+
if column.default is not None or column.server_default is not None:
|
|
264
|
+
override = generator.override_for(model, column)
|
|
265
|
+
if override is not UNSET:
|
|
266
|
+
setattr(obj, key, override)
|
|
267
|
+
continue
|
|
268
|
+
value = generator.value_for(model, column)
|
|
269
|
+
if value is not UNSET:
|
|
270
|
+
setattr(obj, key, value)
|
|
271
|
+
return obj
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _fill_primary_key(
|
|
275
|
+
obj: Any, key: str, model: type[DeclarativeBase], column: Column[Any], generator: FieldGenerator
|
|
276
|
+
) -> None:
|
|
277
|
+
if database_fills(column):
|
|
278
|
+
return
|
|
279
|
+
setattr(obj, key, generator.value_for(model, column))
|
seedgraph/uniqueness.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Replace generated unique values the database already holds, round by round, without doing any IO itself."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import Column
|
|
7
|
+
from sqlalchemy.orm import class_mapper
|
|
8
|
+
|
|
9
|
+
from seedgraph.generators import FieldGenerator, UniqueValueExhaustedError, is_unique
|
|
10
|
+
|
|
11
|
+
__all__ = ["UniqueRepair"]
|
|
12
|
+
|
|
13
|
+
MAX_ROUNDS = 10
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UniqueRepair:
|
|
17
|
+
"""Hand out the generated unique values to check, then regenerate those the database reports as taken."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, objects: Sequence[Any], generator: FieldGenerator) -> None:
|
|
20
|
+
self._generator = generator
|
|
21
|
+
self._rounds = 0
|
|
22
|
+
self._slots: list[tuple[Any, Column[Any], str]] = []
|
|
23
|
+
for obj in objects:
|
|
24
|
+
mapper = class_mapper(type(obj))
|
|
25
|
+
for column in mapper.local_table.columns:
|
|
26
|
+
if column.foreign_keys or not is_unique(column):
|
|
27
|
+
continue
|
|
28
|
+
if generator.is_overridden(type(obj), column):
|
|
29
|
+
continue
|
|
30
|
+
attribute = mapper.get_property_by_column(column).key
|
|
31
|
+
if getattr(obj, attribute) is not None:
|
|
32
|
+
self._slots.append((obj, column, attribute))
|
|
33
|
+
|
|
34
|
+
def queries(self) -> list[tuple[Column[Any], list[Any]]]:
|
|
35
|
+
"""Return each column with the values still to check, empty once none is left."""
|
|
36
|
+
if not self._slots:
|
|
37
|
+
return []
|
|
38
|
+
if self._rounds == MAX_ROUNDS:
|
|
39
|
+
_, column, _ = self._slots[0]
|
|
40
|
+
raise UniqueValueExhaustedError(
|
|
41
|
+
f"unique column {column.table.key}.{column.key} still collides with existing rows"
|
|
42
|
+
f" after {MAX_ROUNDS} rounds — widen its generator or declare one in generators"
|
|
43
|
+
)
|
|
44
|
+
self._rounds += 1
|
|
45
|
+
by_column: dict[Column[Any], list[Any]] = {}
|
|
46
|
+
for obj, column, attribute in self._slots:
|
|
47
|
+
by_column.setdefault(column, []).append(getattr(obj, attribute))
|
|
48
|
+
return list(by_column.items())
|
|
49
|
+
|
|
50
|
+
def reject(self, taken: dict[Column[Any], set[Any]]) -> None:
|
|
51
|
+
"""Regenerate every slot whose value is taken; only those are checked again."""
|
|
52
|
+
still = []
|
|
53
|
+
for obj, column, attribute in self._slots:
|
|
54
|
+
value = getattr(obj, attribute)
|
|
55
|
+
if value in taken.get(column, ()):
|
|
56
|
+
self._generator.remember(column, taken[column])
|
|
57
|
+
setattr(obj, attribute, self._generator.value_for(type(obj), column))
|
|
58
|
+
still.append((obj, column, attribute))
|
|
59
|
+
self._slots = still
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""The exit contract of seed(): every set link's FK columns equal the linked object's key."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import Column, inspect
|
|
7
|
+
from sqlalchemy.orm import class_mapper
|
|
8
|
+
|
|
9
|
+
from seedgraph.exceptions import SeedgraphError
|
|
10
|
+
|
|
11
|
+
__all__ = ["IncoherentGraphError", "verify_graph"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class IncoherentGraphError(SeedgraphError):
|
|
15
|
+
"""A link of the seeded graph carries FK values that differ from the linked object's key."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def verify_graph(objects: Sequence[Any]) -> None:
|
|
19
|
+
"""Raise IncoherentGraphError on the first set link whose FK columns disagree with its target."""
|
|
20
|
+
for obj in objects:
|
|
21
|
+
state = inspect(obj)
|
|
22
|
+
for relationship in state.mapper.relationships:
|
|
23
|
+
if relationship.direction.name == "MANYTOMANY" or relationship.key in state.unloaded:
|
|
24
|
+
continue
|
|
25
|
+
value = getattr(obj, relationship.key)
|
|
26
|
+
if value is None:
|
|
27
|
+
continue
|
|
28
|
+
linked = list(value) if relationship.direction.name == "ONETOMANY" else [value]
|
|
29
|
+
for other in linked:
|
|
30
|
+
for local_column, remote_column in relationship.local_remote_pairs:
|
|
31
|
+
local_value = _column_value(obj, local_column)
|
|
32
|
+
remote_value = _column_value(other, remote_column)
|
|
33
|
+
if local_value != remote_value:
|
|
34
|
+
raise IncoherentGraphError(
|
|
35
|
+
f"{type(obj).__name__}.{relationship.key}: {local_column.name}={local_value!r}"
|
|
36
|
+
f" vs {remote_column.name}={remote_value!r}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _column_value(obj: Any, column: Column[Any]) -> Any:
|
|
41
|
+
return getattr(obj, class_mapper(type(obj)).get_property_by_column(column).key)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: seedgraph
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Seed your SQLAlchemy models as a referentially-consistent graph — one call, shared parents, real PKs.
|
|
5
|
+
Project-URL: Homepage, https://github.com/jrachid/seedgraph
|
|
6
|
+
Project-URL: Repository, https://github.com/jrachid/seedgraph
|
|
7
|
+
Project-URL: Issues, https://github.com/jrachid/seedgraph/issues
|
|
8
|
+
Author: Rachid Jeffali
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: fixtures,foreign-keys,orm,seed,sqlalchemy,testing
|
|
12
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
13
|
+
Classifier: Framework :: Pytest
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Database
|
|
19
|
+
Classifier: Topic :: Software Development :: Testing
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: faker>=30
|
|
23
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
24
|
+
Provides-Extra: async
|
|
25
|
+
Requires-Dist: aiosqlite>=0.17; extra == 'async'
|
|
26
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'async'
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: aiosqlite>=0.17; extra == 'dev'
|
|
29
|
+
Requires-Dist: asyncpg>=0.29; extra == 'dev'
|
|
30
|
+
Requires-Dist: greenlet>=3.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
35
|
+
Requires-Dist: testcontainers[postgres]>=4.0; extra == 'dev'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# seedgraph
|
|
39
|
+
|
|
40
|
+
> Seed your SQLAlchemy models as a referentially-consistent graph — one call, shared parents, verified links.
|
|
41
|
+
|
|
42
|
+
**seedgraph fills your test database with a coherent graph of objects, in a single call.** You declare what you want — "3 users, each with 2 posts, each post with 3 comments" — and the library builds the objects, links them, writes them to your session and then verifies every foreign key against the row it points at. Values are realistic and reproducible ("Jose Bishop", not "user-0"), generated by Faker with a fixed seed, valid for each column's type, and unique where the schema says so — even against rows already in the database. You can pin any column, replace how any column is generated, and attach the new graph to rows you already have. It plugs into pytest with no configuration.
|
|
43
|
+
|
|
44
|
+
**Status: pre-alpha.** Every guarantee below is backed by a named test, on SQLite and on PostgreSQL.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Why
|
|
49
|
+
|
|
50
|
+
Every Python team that seeds a relational test database eventually hand-rolls the same plumbing: generate rows, stage commits so primary keys exist, chase those keys into FK columns, repeat for every relationship, and hope the graph stays consistent.
|
|
51
|
+
|
|
52
|
+
After empirically testing the landscape (SQLAlchemy 2.x era, August 2026), none of the existing options does it:
|
|
53
|
+
|
|
54
|
+
| Tool | What you get on a `User ← Post ← Comment` schema |
|
|
55
|
+
|------|---------------------------------------------------|
|
|
56
|
+
| `polyfactory` | Builds related objects, **but every FK column is a random int pointing at nothing**: `post.author_id != post.author.id`. Silent data corruption — tests pass on garbage unless you enable FK enforcement (most don't). |
|
|
57
|
+
| `faker-sqlalchemy` (unmaintained since 2022, pinned to SQLAlchemy 1.x) | `RecursionError` on standard `backref` relationships, on self-referential FKs, and its `overrides` API silently drops FK values. |
|
|
58
|
+
| `sqlalchemyseed` | Seeds data *you already have* (JSON/YAML), doesn't generate. |
|
|
59
|
+
| `sqlseed`, `sowdb` | Solid fillers, but schema-level and flat: "N rows per table". They work from raw SQL schemas, not your models, and can't express a graph shape like *"3 users → 2 posts each → 5 comments per post"*. |
|
|
60
|
+
|
|
61
|
+
The one-line failure seedgraph fixes:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
p = PostFactory.build()
|
|
65
|
+
p.author_id == p.author.id # False. Every FK in the graph is disconnected.
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### "But other libraries do this too, don't they?"
|
|
69
|
+
|
|
70
|
+
They create linked objects. Three differences survive a closer look:
|
|
71
|
+
|
|
72
|
+
**1. A verified exit contract, not just object creation.** factory_boy, pytest-factoryboy or mixer build the graph and stop there. `seed()` flushes the graph, lets the database assign the keys, then walks every link and raises `IncoherentGraphError` if a foreign key disagrees with the row it points at.
|
|
73
|
+
|
|
74
|
+
**2. Coexistence with a populated database.** The database assigns the keys, so seeding on top of existing rows never collides on ids, never desynchronises a PostgreSQL sequence, and stays safe when two sessions seed the same tables at once. Unique columns are checked against the rows already there before anything is written.
|
|
75
|
+
|
|
76
|
+
**3. Determinism wired into pytest.** A new session replays the same values from the same seed; consecutive calls on one session continue the sequence instead of repeating it — inside a two-line fixture.
|
|
77
|
+
|
|
78
|
+
## Quick start
|
|
79
|
+
|
|
80
|
+
Declare a **shape** from a root model; each key walks a one-to-many or many-to-many relationship, by relationship name or by target class name:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from seedgraph import seed
|
|
84
|
+
|
|
85
|
+
graph = seed(session, User, post=2, post__comment=3) # 3 users by default
|
|
86
|
+
|
|
87
|
+
assert len(graph.users) == 3
|
|
88
|
+
assert len(graph.posts) == 6
|
|
89
|
+
post = graph.users[0].posts[0]
|
|
90
|
+
assert post.author_id == graph.users[0].id # real key, assigned by the database
|
|
91
|
+
assert graph.labels == [] # any table of the model, empty if not seeded
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`seed()` returns once the graph is flushed and verified; commit or roll back as your test needs. `seed_async(async_session, ...)` is its twin for an `AsyncSession`.
|
|
95
|
+
|
|
96
|
+
### Existing and missing parents
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
alice = session.get(User, 1)
|
|
100
|
+
graph = seed(session, Post, parents=[alice]) # every post's author is alice; alice is not in graph.users
|
|
101
|
+
|
|
102
|
+
graph = seed(session, Comment) # one Post and one User are generated, shared by all comments
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
A link first takes the nearest ancestor of its type in the shape, then the object of that type passed in `parents` (optional links included). A required link still empty gets **one** generated parent per type, shared by every object that needs it. Several objects of one type are accepted in `parents`; a link towards a single parent refuses to choose between them with `AmbiguousParentError`.
|
|
106
|
+
|
|
107
|
+
### Many-to-many
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
graph = seed(session, Article, article=5, tags=3) # 15 new tags, 3 per article
|
|
111
|
+
graph = seed(session, Article, article=5, parents=[python, sql]) # every article tagged with both existing tags
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
A count keeps its one-to-many meaning: new objects for each parent. Objects passed in `parents` join every many-to-many collection of their type, next to the ones the shape builds. SQLAlchemy writes the association rows itself.
|
|
115
|
+
|
|
116
|
+
### Pinning and generating values
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
graph = seed(
|
|
120
|
+
session,
|
|
121
|
+
User,
|
|
122
|
+
post=2,
|
|
123
|
+
generators={User: {"name": lambda ctx: ctx.fake.first_name()}}, # replace how a column is generated
|
|
124
|
+
overrides={Post: {"title": "Imposed", "subtitle": None}}, # pin a value, None included
|
|
125
|
+
)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`ctx.fake` is the session's seeded Faker; `ctx.column` is the column name. An override can also be a callable taking the same context.
|
|
129
|
+
|
|
130
|
+
### pytest
|
|
131
|
+
|
|
132
|
+
Installing seedgraph registers four fixtures, prefixed so they never shadow your own `session` or `graph`:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
def test_feed(seedgraph_graph):
|
|
136
|
+
graph = seedgraph_graph(User, post=2) # fresh in-memory SQLite, FK enforced, tables created on demand
|
|
137
|
+
assert len(graph.posts) == 6
|
|
138
|
+
|
|
139
|
+
async def test_feed_async(seedgraph_agraph):
|
|
140
|
+
graph = await seedgraph_agraph(User, post=2)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`seedgraph_session` and `seedgraph_asession` expose the sessions behind them. To seed your own database, call `seed()` on your own session.
|
|
144
|
+
|
|
145
|
+
## Guarantees and the tests that prove them
|
|
146
|
+
|
|
147
|
+
| Guarantee | Test |
|
|
148
|
+
|---|---|
|
|
149
|
+
| Every link of the returned graph is verified after flush | `test_verification.py::test_seed_returns_a_graph_already_written_with_real_keys`, `::test_verify_graph_names_the_link_whose_foreign_key_disagrees` |
|
|
150
|
+
| Seeding on top of existing rows keeps PostgreSQL sequences intact | `test_postgres.py::test_the_application_still_inserts_after_a_seed_on_top_of_its_rows` |
|
|
151
|
+
| Two sessions seeding the same tables at once do not collide | `test_postgres.py::test_two_sessions_seeding_the_same_tables_at_once_do_not_collide` |
|
|
152
|
+
| Unique columns skip values already in the database | `test_unique.py::test_a_new_session_on_a_populated_database_skips_the_values_already_taken`, `::test_postgres_rows_from_an_earlier_run_do_not_block_a_new_seed` |
|
|
153
|
+
| Same seed, same values; consecutive calls do not repeat | `test_generators.py::test_a_new_session_replays_the_same_values`, `::test_two_seeds_in_one_session_continue_the_same_faker_sequence` |
|
|
154
|
+
| Generated values fit the column type (enum, length, precision, arrays) | `test_types.py` |
|
|
155
|
+
| Many-to-many shapes build new objects per parent; existing objects are shared | `test_many_to_many.py::test_a_many_to_many_count_builds_new_objects_for_each_parent`, `::test_existing_objects_passed_as_parents_are_shared_by_every_generated_object` |
|
|
156
|
+
| Natural and composite primary keys are generated and never collide | `test_verification.py::test_a_natural_text_key_is_generated`, `::test_natural_keys_skip_the_ones_already_in_the_database`, `::test_a_composite_integer_key_and_its_composite_foreign_key_are_generated` |
|
|
157
|
+
| Multi-column unique constraints hold | `test_unique.py::test_many_rows_under_one_parent_keep_a_multi_column_constraint`, `::test_a_second_session_keeps_a_multi_column_constraint` |
|
|
158
|
+
| Existing rows serve as parents; missing required parents are generated once | `test_parents.py::test_a_parent_already_in_the_database_is_linked_and_left_out_of_the_graph`, `::test_a_child_seeded_alone_gets_one_generated_parent_shared_by_all` |
|
|
159
|
+
| Plugin fixtures live beside a project's own `session` and `graph` | `test_fixtures.py::test_the_prefixed_fixtures_live_beside_a_project_own_session_and_graph` |
|
|
160
|
+
|
|
161
|
+
## Limits
|
|
162
|
+
|
|
163
|
+
- **`seed()` flushes the session.** The keys come from the database; the objects are no longer pending when it returns.
|
|
164
|
+
- **A shape key towards a parent is refused**, as parents are linked or generated on their own; pass existing ones in `parents`. View-only relationships are refused too, since nothing would be written.
|
|
165
|
+
- **Required columns of uncovered types** (JSON, custom `TypeDecorator`, arrays of those) raise `UnsupportedPlaceholderError`; declare a generator for them. Nullable ones are left empty.
|
|
166
|
+
- **A multi-column unique constraint whose generated columns are only booleans or enums** is left to the database. For the others, one generated column is kept unique on its own, which is stricter than the constraint.
|
|
167
|
+
- **An association class whose primary key combines its two foreign keys** holds one row per parent pair: the generated parent is shared, so two rows under the same parent collide. Seed one per parent, or use a many-to-many relationship.
|
|
168
|
+
- **A loop of required links between tables**, or a required link to its own table, cannot be generated; pass one side in `parents`.
|
|
169
|
+
- **Determinism holds for a given Faker version.** Faker may change its data between releases.
|
|
170
|
+
|
|
171
|
+
## Design principles
|
|
172
|
+
|
|
173
|
+
1. **Model-first, not schema-first.** Works from your SQLAlchemy ORM models and relationships.
|
|
174
|
+
2. **Referential consistency is verified, not hoped for.** The database assigns the keys, seedgraph checks every link afterwards.
|
|
175
|
+
3. **Shared parents are the point.** Realistic data shares parents (one author, many posts). One object per FK is not a graph.
|
|
176
|
+
4. **Deterministic.** A new session with the same calls produces the same graph.
|
|
177
|
+
5. **Self-references and mutually referencing tables are normal.** `Category.parent` and tables pointing at each other are supported; only unsatisfiable loops of required links are refused.
|
|
178
|
+
6. **Stop generating at the boundary.** Existing rows are usable as parents; only missing parents get generated.
|
|
179
|
+
|
|
180
|
+
## Roadmap
|
|
181
|
+
|
|
182
|
+
- [x] Shape API (`relation=n`, nesting, shared parents)
|
|
183
|
+
- [x] Custom field generators (Faker under the hood)
|
|
184
|
+
- [x] Overriding specific attributes on generated objects
|
|
185
|
+
- [x] pytest fixture helpers
|
|
186
|
+
- [x] Self-referential and cyclic FKs
|
|
187
|
+
- [x] Async sessions support
|
|
188
|
+
- [x] Database-assigned keys and post-flush verification, PostgreSQL in the test suite
|
|
189
|
+
- [x] Type-valid values, uniqueness against existing rows, existing and generated parents
|
|
190
|
+
- [x] Many-to-many shapes, generated natural keys, multi-column uniqueness, arrays
|
|
191
|
+
- [ ] Publication on PyPI
|
|
192
|
+
|
|
193
|
+
## Installation
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
pip install seedgraph
|
|
197
|
+
pip install "seedgraph[async]" # for seed_async and the async pytest fixtures
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Requires Python 3.11+, SQLAlchemy 2.x and Faker 30+. The async extra adds greenlet (through `sqlalchemy[asyncio]`) and aiosqlite. The PostgreSQL tests of the suite need Docker and are skipped without it.
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
seedgraph/__init__.py,sha256=VrapARKPdcB4TmBftOQlrSCAkUp6MV7wKLLNwwfP3Ng,3918
|
|
2
|
+
seedgraph/boundary.py,sha256=FLb_VZRo3BMSJNFnbuI-AYu6IcPnRCn4STYupeLkZCs,1824
|
|
3
|
+
seedgraph/exceptions.py,sha256=927q_ldHKPWnJUf9kWpJIrO2TvbLTXzHpThk-k8pZI0,333
|
|
4
|
+
seedgraph/generators.py,sha256=Nj7VQo1qTN3fGbNhjsG9IyZWG_-8w5b-agDLQbvAIfQ,12160
|
|
5
|
+
seedgraph/graph.py,sha256=MNpYD-6qgDrFp_XMtTySbY1tPIbbO5m0ErjsfphdD8s,1186
|
|
6
|
+
seedgraph/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
seedgraph/pytest_plugin.py,sha256=Q-aOUbYtOpIDAhfMbuqq76eqEp3edAMdJg_jIvjkWMk,3337
|
|
8
|
+
seedgraph/shape.py,sha256=d_bOCrSjLRnWNUus6dWpjBZyRjmn7kJUxQGYXhKz7bU,11040
|
|
9
|
+
seedgraph/uniqueness.py,sha256=gFJGIVlYOyDJcd0W93ouozT3jZ4CQ0vOPdW8DL8dDz0,2553
|
|
10
|
+
seedgraph/verification.py,sha256=vUXC25zEFzVnPlFmowv4ve0rYJP8Zkqqi80tB7SSDf4,1772
|
|
11
|
+
seedgraph-0.1.1.dist-info/METADATA,sha256=UKHCUz9nQMAThcCGoWOiEcXP_TdSkbSFJvy27WKtpAk,12781
|
|
12
|
+
seedgraph-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
13
|
+
seedgraph-0.1.1.dist-info/entry_points.txt,sha256=T_-BycY7DSgOwrDJtKH8ugZWo2VuLNks9blD6ojB-Ng,47
|
|
14
|
+
seedgraph-0.1.1.dist-info/licenses/LICENSE,sha256=eCHmfhsEWXn7Hlw1UZuEJpp7JTf6UTYRnl1aqZVW_g0,1071
|
|
15
|
+
seedgraph-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rachid Jeffali
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|