rowsmyth 0.1.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.
- rowsmyth/__init__.py +16 -0
- rowsmyth/_base.py +185 -0
- rowsmyth/_builder.py +147 -0
- rowsmyth/_dataset.py +117 -0
- rowsmyth/_execute.py +208 -0
- rowsmyth/_spark.py +60 -0
- rowsmyth/_variant.py +25 -0
- rowsmyth/_version.py +24 -0
- rowsmyth/py.typed +0 -0
- rowsmyth-0.1.0.dist-info/METADATA +674 -0
- rowsmyth-0.1.0.dist-info/RECORD +13 -0
- rowsmyth-0.1.0.dist-info/WHEEL +4 -0
- rowsmyth-0.1.0.dist-info/licenses/LICENSE +21 -0
rowsmyth/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
from ._base import TableSpecMixin, declarative_base
|
|
4
|
+
from ._builder import FactoryBuilder
|
|
5
|
+
from ._dataset import Dataset
|
|
6
|
+
from ._variant import variant
|
|
7
|
+
|
|
8
|
+
__version__ = version("rowsmyth")
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Dataset",
|
|
11
|
+
"FactoryBuilder",
|
|
12
|
+
"TableSpecMixin",
|
|
13
|
+
"__version__",
|
|
14
|
+
"declarative_base",
|
|
15
|
+
"variant",
|
|
16
|
+
]
|
rowsmyth/_base.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
6
|
+
from sqlalchemy.orm import registry as _registry
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import MetaData
|
|
12
|
+
|
|
13
|
+
from ._builder import FactoryBuilder
|
|
14
|
+
from ._dataset import Dataset
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# SQLAlchemy's declared_attr only works for instance-level access, so we use a
|
|
18
|
+
# custom descriptor to expose table metadata as class-level read-only properties.
|
|
19
|
+
class _classproperty: # noqa: N801
|
|
20
|
+
def __init__(self, func: Callable[..., Any]) -> None:
|
|
21
|
+
self.func = func
|
|
22
|
+
|
|
23
|
+
def __get__(self, obj: Any, cls: type | None = None) -> Any:
|
|
24
|
+
return self.func(cls if cls is not None else type(obj))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TableSpecMixin:
|
|
28
|
+
__variants__: dict[str, Callable[..., Any]]
|
|
29
|
+
|
|
30
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
31
|
+
super().__init_subclass__(**kwargs)
|
|
32
|
+
cls.__variants__ = {
|
|
33
|
+
name: val
|
|
34
|
+
for name, val in vars(cls).items()
|
|
35
|
+
if callable(val) and getattr(val, "_is_variant", False)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@_classproperty
|
|
39
|
+
def __comment__(cls) -> str | None: # noqa: N805
|
|
40
|
+
return cls.__table__.comment # ty: ignore[unresolved-attribute]
|
|
41
|
+
|
|
42
|
+
@_classproperty
|
|
43
|
+
def __table_info__(cls) -> dict[str, Any]: # noqa: N805
|
|
44
|
+
return cls.__table__.info # ty: ignore[unresolved-attribute]
|
|
45
|
+
|
|
46
|
+
@_classproperty
|
|
47
|
+
def __column_info__(cls) -> dict[str, dict[str, Any]]: # noqa: N805
|
|
48
|
+
return {
|
|
49
|
+
prop.key: col.info
|
|
50
|
+
for prop in cls.__mapper__.column_attrs # ty: ignore[unresolved-attribute]
|
|
51
|
+
for col in prop.columns
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@_classproperty
|
|
55
|
+
def __expectations__(cls) -> dict[str, str]: # noqa: N805
|
|
56
|
+
from sqlalchemy import CheckConstraint
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
c.name: str(c.sqltext)
|
|
60
|
+
for c in cls.__table__.constraints # ty: ignore[unresolved-attribute]
|
|
61
|
+
if isinstance(c, CheckConstraint) and c.name is not None
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@_classproperty
|
|
65
|
+
def __spark_schema__(cls) -> Any: # noqa: N805
|
|
66
|
+
from ._spark import to_spark_schema
|
|
67
|
+
|
|
68
|
+
return to_spark_schema(cls) # ty: ignore[invalid-argument-type]
|
|
69
|
+
|
|
70
|
+
def __repr__(self) -> str:
|
|
71
|
+
attrs = ", ".join(
|
|
72
|
+
f"{c.key}={getattr(self, c.key)!r}"
|
|
73
|
+
for c in self.__mapper__.columns # ty: ignore[unresolved-attribute]
|
|
74
|
+
)
|
|
75
|
+
return f"{self.__class__.__name__}({attrs})"
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def generators(cls) -> dict[Any, Any]:
|
|
79
|
+
"""Override to supply factory-boy declarations for column generation.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
A dict mapping column attributes (or string names) to factory-boy
|
|
83
|
+
declarations (e.g. ``factory.Faker(...)``). Keys can be the column
|
|
84
|
+
attribute itself (``cls.name``) or a plain string (``"name"``).
|
|
85
|
+
|
|
86
|
+
Example:
|
|
87
|
+
>>> TableSpecMixin.generators()
|
|
88
|
+
{}
|
|
89
|
+
"""
|
|
90
|
+
return {}
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def factory(cls, count: int = 1, max_count: int | None = None) -> FactoryBuilder:
|
|
94
|
+
"""Return a FactoryBuilder for this model.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
count: Exact number of instances to generate, or minimum when
|
|
98
|
+
``max_count`` is also provided.
|
|
99
|
+
max_count: Upper bound for a random count in
|
|
100
|
+
``[count, max_count]`` inclusive. ``None`` means exact.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
A ``FactoryBuilder`` ready to be configured with ``.has()``,
|
|
104
|
+
``.mix()``, ``.where()`` and ``.create()``.
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
>>> # User.factory(10) # exactly 10
|
|
108
|
+
>>> # User.factory(1, 5) # random 1-5 per parent
|
|
109
|
+
>>> # User.factory(5).create() # generate and persist
|
|
110
|
+
""" # doctest: +SKIP
|
|
111
|
+
from ._builder import FactoryBuilder
|
|
112
|
+
|
|
113
|
+
return FactoryBuilder(cls, count, max_count)
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def dataset(cls, *builders: FactoryBuilder) -> Dataset:
|
|
117
|
+
"""Return a Dataset generating one row per registered model.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
*builders: Optional ``FactoryBuilder`` overrides for specific
|
|
121
|
+
models. Models without an override get one instance using
|
|
122
|
+
their default generators.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
A ``Dataset`` whose ``.create()`` returns a dict keyed by table
|
|
126
|
+
name, values are lists of persisted model instances.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
TypeError: If any argument is not a ``FactoryBuilder``.
|
|
130
|
+
ValueError: If a builder's model is not registered to this Base.
|
|
131
|
+
|
|
132
|
+
Example:
|
|
133
|
+
>>> # result = Base.dataset(User.factory(10)).create()
|
|
134
|
+
>>> # result["users"] # -> list of 10 User instances
|
|
135
|
+
""" # doctest: +SKIP
|
|
136
|
+
from ._builder import FactoryBuilder
|
|
137
|
+
from ._dataset import Dataset
|
|
138
|
+
|
|
139
|
+
base_models = {mapper.class_ for mapper in cls.registry.mappers} # ty: ignore[unresolved-attribute]
|
|
140
|
+
for b in builders:
|
|
141
|
+
if not isinstance(b, FactoryBuilder):
|
|
142
|
+
msg = f"Expected a FactoryBuilder, got {type(b).__name__}"
|
|
143
|
+
raise TypeError(msg)
|
|
144
|
+
if b.model not in base_models:
|
|
145
|
+
msg = f"{b.model.__name__} is not registered to this Base."
|
|
146
|
+
raise ValueError(msg)
|
|
147
|
+
overrides = {b.model: b for b in builders}
|
|
148
|
+
return Dataset(cls, overrides)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def declarative_base(
|
|
152
|
+
metadata: MetaData | None = None,
|
|
153
|
+
type_annotation_map: dict[Any, Any] | None = None,
|
|
154
|
+
registry: _registry | None = None,
|
|
155
|
+
) -> type[TableSpecMixin]:
|
|
156
|
+
"""Create a new SQLAlchemy declarative base with rowsmyth capabilities.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
metadata: Optional ``MetaData`` instance shared by all models that
|
|
160
|
+
subclass the returned base. A new ``MetaData`` is created if
|
|
161
|
+
not provided.
|
|
162
|
+
type_annotation_map: Optional mapping of Python types to SQLAlchemy
|
|
163
|
+
``TypeEngine`` classes or instances, used by ``Mapped[]``
|
|
164
|
+
annotations.
|
|
165
|
+
registry: Optional pre-existing ``registry`` instance. When provided,
|
|
166
|
+
models using this base share the mapper registry with other bases
|
|
167
|
+
that reference the same registry.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
A new declarative base class with ``TableSpecMixin`` mixed in.
|
|
171
|
+
|
|
172
|
+
Example:
|
|
173
|
+
>>> from rowsmyth import declarative_base
|
|
174
|
+
>>> Base = declarative_base()
|
|
175
|
+
>>> issubclass(Base, TableSpecMixin)
|
|
176
|
+
True
|
|
177
|
+
"""
|
|
178
|
+
attrs: dict[str, Any] = {}
|
|
179
|
+
if metadata is not None:
|
|
180
|
+
attrs["metadata"] = metadata
|
|
181
|
+
if type_annotation_map is not None:
|
|
182
|
+
attrs["type_annotation_map"] = type_annotation_map
|
|
183
|
+
if registry is not None:
|
|
184
|
+
attrs["registry"] = registry
|
|
185
|
+
return type("Base", (TableSpecMixin, DeclarativeBase), attrs)
|
rowsmyth/_builder.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FactoryBuilder:
|
|
8
|
+
"""Fluent builder for generating and persisting model instances.
|
|
9
|
+
|
|
10
|
+
Obtain via ``Model.factory(count)``. Chain configuration methods,
|
|
11
|
+
then call ``.create()`` to generate and persist to an in-memory database.
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
>>> # users = User.factory(10).has(Order.factory(1, 3)).create()
|
|
15
|
+
""" # doctest: +SKIP
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self, model: type, count: int = 1, max_count: int | None = None
|
|
19
|
+
) -> None:
|
|
20
|
+
self.model = model
|
|
21
|
+
self.min_count = count
|
|
22
|
+
self.max_count = max_count
|
|
23
|
+
self._children: list[tuple[FactoryBuilder, str | None]] = []
|
|
24
|
+
self._mix: dict[str, float] = {}
|
|
25
|
+
self._overrides: dict[str, Any] = {}
|
|
26
|
+
self._seed: int | None = None
|
|
27
|
+
|
|
28
|
+
def has(self, *builders: FactoryBuilder, via: str | None = None) -> FactoryBuilder:
|
|
29
|
+
"""Attach child builders whose rows are created for each parent instance.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
*builders: ``FactoryBuilder`` instances for child models.
|
|
33
|
+
via: Relationship attribute name on the child model to use when
|
|
34
|
+
wiring the FK. Only required when the child has multiple
|
|
35
|
+
relationships pointing to the same parent model.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
``self`` for chaining.
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
>>> # User.factory(5).has(Order.factory(1, 3))
|
|
42
|
+
""" # doctest: +SKIP
|
|
43
|
+
for builder in builders:
|
|
44
|
+
self._children.append((builder, via))
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def mix(self, **proportions: float) -> FactoryBuilder:
|
|
48
|
+
"""Set variant proportions for this builder.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
**proportions: Variant name to proportion in ``(0, 1]``. Total
|
|
52
|
+
must not exceed 1.0. The remainder (``1 - sum``) is the
|
|
53
|
+
proportion of un-varied (default) instances.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
``self`` for chaining.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
ValueError: If proportions sum to more than 1.0, or a named
|
|
60
|
+
variant is not defined on the model.
|
|
61
|
+
|
|
62
|
+
Example:
|
|
63
|
+
>>> # User.factory(100).mix(admin=0.05, premium=0.20)
|
|
64
|
+
""" # doctest: +SKIP
|
|
65
|
+
for name, proportion in proportions.items():
|
|
66
|
+
if proportion <= 0.0:
|
|
67
|
+
msg = f"Proportion for '{name}' must be > 0, got {proportion}"
|
|
68
|
+
raise ValueError(msg)
|
|
69
|
+
total = sum(proportions.values())
|
|
70
|
+
if total > 1.0:
|
|
71
|
+
msg = f"Proportions sum to {total:.2f}, must be ≤ 1.0"
|
|
72
|
+
raise ValueError(msg)
|
|
73
|
+
for name in proportions:
|
|
74
|
+
if name not in self.model.__variants__: # ty: ignore[unresolved-attribute]
|
|
75
|
+
msg = (
|
|
76
|
+
f"Variant '{name}' not defined on {self.model.__name__}. "
|
|
77
|
+
f"Defined: {list(self.model.__variants__)}" # ty: ignore[unresolved-attribute]
|
|
78
|
+
)
|
|
79
|
+
raise ValueError(msg)
|
|
80
|
+
self._mix = proportions
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
def _resolve_count(self) -> int:
|
|
84
|
+
if self.max_count is None:
|
|
85
|
+
return self.min_count
|
|
86
|
+
return random.randint(self.min_count, self.max_count)
|
|
87
|
+
|
|
88
|
+
def _pick_variant(self) -> str | None:
|
|
89
|
+
if not self._mix:
|
|
90
|
+
return None
|
|
91
|
+
r = random.random()
|
|
92
|
+
cumulative = 0.0
|
|
93
|
+
for name, proportion in self._mix.items():
|
|
94
|
+
cumulative += proportion
|
|
95
|
+
if r < cumulative:
|
|
96
|
+
return name
|
|
97
|
+
# Remainder proportion (1 - sum) maps to no variant. When proportions
|
|
98
|
+
# sum to exactly 1.0, floating-point imprecision may leave a tiny gap
|
|
99
|
+
# here for a negligible fraction of calls - that's acceptable.
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
def random_seed(self, value: int) -> FactoryBuilder:
|
|
103
|
+
"""Fix the random seed for reproducible output.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
value: Integer seed passed to both ``random`` and ``Faker``.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
``self`` for chaining.
|
|
110
|
+
|
|
111
|
+
Example:
|
|
112
|
+
>>> # User.factory(10).random_seed(42).create()
|
|
113
|
+
""" # doctest: +SKIP
|
|
114
|
+
self._seed = value
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def where(self, overrides: dict[Any, Any]) -> FactoryBuilder:
|
|
118
|
+
"""Apply fixed column overrides to every generated instance.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
overrides: Dict mapping column attributes (or string names) to
|
|
122
|
+
fixed values. These take precedence over generators and variants.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
``self`` for chaining.
|
|
126
|
+
|
|
127
|
+
Example:
|
|
128
|
+
>>> # User.factory(5).where({User.tier: "premium"})
|
|
129
|
+
""" # doctest: +SKIP
|
|
130
|
+
self._overrides.update(overrides)
|
|
131
|
+
return self
|
|
132
|
+
|
|
133
|
+
def create(self) -> list[Any]:
|
|
134
|
+
"""Generate and persist all instances to an in-memory SQLite database.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
List of root model instances. Child instances (added via ``.has()``)
|
|
138
|
+
are persisted and accessible via SQLAlchemy relationships but are
|
|
139
|
+
not included in the returned list.
|
|
140
|
+
|
|
141
|
+
Example:
|
|
142
|
+
>>> # users = User.factory(10).has(Order.factory(2)).create()
|
|
143
|
+
>>> # len(users) # -> 10
|
|
144
|
+
""" # doctest: +SKIP
|
|
145
|
+
from ._execute import execute_builder
|
|
146
|
+
|
|
147
|
+
return execute_builder(self, self._overrides, self._seed)
|
rowsmyth/_dataset.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import graphlib
|
|
4
|
+
import random
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.orm import MANYTOONE
|
|
8
|
+
|
|
9
|
+
from ._execute import (
|
|
10
|
+
_build_fk_dependency_graph,
|
|
11
|
+
_resolve_variant,
|
|
12
|
+
_seed_random,
|
|
13
|
+
build_session,
|
|
14
|
+
make_factory,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Dataset:
|
|
19
|
+
"""Generates one instance per registered model, respecting FK order.
|
|
20
|
+
|
|
21
|
+
Obtain via ``Base.dataset(*builders)``. Use when you need a minimal
|
|
22
|
+
coherent dataset across all tables rather than a deep hierarchy rooted
|
|
23
|
+
at one model.
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
>>> # result = Base.dataset(User.factory(5), Order.factory(10)).create()
|
|
27
|
+
>>> # result["users"] # -> 5 User instances
|
|
28
|
+
>>> # result["orders"] # -> 10 Order instances
|
|
29
|
+
""" # doctest: +SKIP
|
|
30
|
+
|
|
31
|
+
def __init__(self, base: type, overrides: dict[type, Any] | None = None) -> None:
|
|
32
|
+
self._base = base
|
|
33
|
+
self._overrides: dict[type, Any] = overrides or {}
|
|
34
|
+
self._seed: int | None = None
|
|
35
|
+
|
|
36
|
+
def random_seed(self, value: int) -> Dataset:
|
|
37
|
+
"""Fix the random seed for reproducible output.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
value: Integer seed passed to both ``random`` and ``Faker``.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
``self`` for chaining.
|
|
44
|
+
"""
|
|
45
|
+
self._seed = value
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def create(self) -> dict[str, list[Any]]:
|
|
49
|
+
"""Generate and persist one instance per registered model.
|
|
50
|
+
|
|
51
|
+
Instances are created in FK dependency order (parents before children).
|
|
52
|
+
FK columns are wired automatically by sampling randomly from the parent pool.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Dict keyed by table name mapping to lists of persisted instances.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValueError: If a parent model has 0 instances but a child model
|
|
59
|
+
has an FK pointing to it.
|
|
60
|
+
"""
|
|
61
|
+
from ._builder import FactoryBuilder
|
|
62
|
+
|
|
63
|
+
if self._seed is not None:
|
|
64
|
+
_seed_random(self._seed)
|
|
65
|
+
|
|
66
|
+
builders = []
|
|
67
|
+
for mapper in self._base.registry.mappers: # ty: ignore[unresolved-attribute]
|
|
68
|
+
model = mapper.class_
|
|
69
|
+
builders.append(self._overrides.get(model, FactoryBuilder(model, 1)))
|
|
70
|
+
|
|
71
|
+
ordered = self._topo_sort(builders)
|
|
72
|
+
models = {b.model for b in ordered}
|
|
73
|
+
session = build_session(models)
|
|
74
|
+
factories = {m: make_factory(m, session) for m in models}
|
|
75
|
+
|
|
76
|
+
pool: dict[type, list[Any]] = {}
|
|
77
|
+
result: dict[str, list[Any]] = {}
|
|
78
|
+
|
|
79
|
+
for builder in ordered:
|
|
80
|
+
instances: list[Any] = []
|
|
81
|
+
for _ in range(builder._resolve_count()):
|
|
82
|
+
fk_overrides: dict[str, Any] = {}
|
|
83
|
+
for rel in builder.model.__mapper__.relationships:
|
|
84
|
+
if rel.direction is not MANYTOONE:
|
|
85
|
+
continue
|
|
86
|
+
related = rel.mapper.class_
|
|
87
|
+
if related not in pool:
|
|
88
|
+
continue
|
|
89
|
+
if not pool[related]:
|
|
90
|
+
model_name = builder.model.__name__
|
|
91
|
+
related_name = related.__name__
|
|
92
|
+
msg = (
|
|
93
|
+
f"Cannot wire {model_name}.{rel.key}: "
|
|
94
|
+
f"{related_name} has 0 instances in the dataset. "
|
|
95
|
+
f"Use {related_name}.factory(0) only for models "
|
|
96
|
+
f"with no FK dependents."
|
|
97
|
+
)
|
|
98
|
+
raise ValueError(msg)
|
|
99
|
+
fk_overrides[rel.key] = random.choice(pool[related])
|
|
100
|
+
|
|
101
|
+
inst_overrides = _resolve_variant(builder)
|
|
102
|
+
inst_overrides.update(fk_overrides)
|
|
103
|
+
|
|
104
|
+
instance = factories[builder.model](**inst_overrides)
|
|
105
|
+
instances.append(instance)
|
|
106
|
+
|
|
107
|
+
pool[builder.model] = instances
|
|
108
|
+
result[builder.model.__tablename__] = instances
|
|
109
|
+
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
def _topo_sort(self, builders: list[Any]) -> list[Any]:
|
|
113
|
+
model_to_builder = {b.model: b for b in builders}
|
|
114
|
+
graph = _build_fk_dependency_graph(builders)
|
|
115
|
+
ts = graphlib.TopologicalSorter(graph)
|
|
116
|
+
ordered_models = list(ts.static_order())
|
|
117
|
+
return [model_to_builder[m] for m in ordered_models if m in model_to_builder]
|
rowsmyth/_execute.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import graphlib
|
|
5
|
+
import random
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from factory.alchemy import SQLAlchemyModelFactory
|
|
9
|
+
from sqlalchemy import Table, create_engine
|
|
10
|
+
from sqlalchemy.orm import MANYTOONE, Session
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ._builder import FactoryBuilder
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _seed_random(seed: int) -> None:
|
|
17
|
+
from faker import Faker
|
|
18
|
+
|
|
19
|
+
random.seed(seed)
|
|
20
|
+
Faker.seed(seed)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check_cycles(builder: FactoryBuilder) -> None:
|
|
24
|
+
graph: dict[type, set[type]] = {}
|
|
25
|
+
|
|
26
|
+
def _collect(b: FactoryBuilder) -> None:
|
|
27
|
+
if b.model in graph:
|
|
28
|
+
return
|
|
29
|
+
graph[b.model] = set()
|
|
30
|
+
for child_builder, _ in b._children:
|
|
31
|
+
graph[b.model].add(child_builder.model)
|
|
32
|
+
_collect(child_builder)
|
|
33
|
+
|
|
34
|
+
_collect(builder)
|
|
35
|
+
ts = graphlib.TopologicalSorter(graph)
|
|
36
|
+
ts.prepare()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _build_fk_dependency_graph(builders: list[FactoryBuilder]) -> dict[type, set[type]]:
|
|
40
|
+
model_set = {b.model for b in builders}
|
|
41
|
+
return {
|
|
42
|
+
b.model: {
|
|
43
|
+
rel.mapper.class_
|
|
44
|
+
for rel in b.model.__mapper__.relationships # ty: ignore[unresolved-attribute]
|
|
45
|
+
if rel.direction is MANYTOONE
|
|
46
|
+
and rel.mapper.class_ in model_set
|
|
47
|
+
and rel.mapper.class_ is not b.model
|
|
48
|
+
}
|
|
49
|
+
for b in builders
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@functools.cache
|
|
54
|
+
def _get_default_builders(model: type) -> dict[str, FactoryBuilder]:
|
|
55
|
+
from ._builder import FactoryBuilder
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
getattr(key, "key", key): val
|
|
59
|
+
for key, val in model.generators().items() # ty: ignore[unresolved-attribute]
|
|
60
|
+
if isinstance(val, FactoryBuilder)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def collect_models(builder: FactoryBuilder) -> set[type]:
|
|
65
|
+
models: set[type] = set()
|
|
66
|
+
|
|
67
|
+
def _collect(b: FactoryBuilder) -> None:
|
|
68
|
+
models.add(b.model)
|
|
69
|
+
for child_builder, _ in b._children:
|
|
70
|
+
_collect(child_builder)
|
|
71
|
+
for default_builder in _get_default_builders(b.model).values():
|
|
72
|
+
_collect(default_builder)
|
|
73
|
+
|
|
74
|
+
_collect(builder)
|
|
75
|
+
return models
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def collect_all_tables(models: set[type]) -> set[Table]:
|
|
79
|
+
seen: set[Table] = {m.__table__ for m in models} # ty: ignore[unresolved-attribute]
|
|
80
|
+
queue: list[Table] = list(seen)
|
|
81
|
+
while queue:
|
|
82
|
+
table = queue.pop()
|
|
83
|
+
for fk in table.foreign_key_constraints:
|
|
84
|
+
for col in fk.elements:
|
|
85
|
+
ref_table = col.column.table
|
|
86
|
+
if ref_table not in seen:
|
|
87
|
+
seen.add(ref_table)
|
|
88
|
+
queue.append(ref_table)
|
|
89
|
+
return seen
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _resolve_variant(builder: FactoryBuilder) -> dict[str, Any]:
|
|
93
|
+
variant_name = builder._pick_variant()
|
|
94
|
+
if not variant_name:
|
|
95
|
+
return {}
|
|
96
|
+
raw = builder.model.__variants__[variant_name](builder.model) # ty: ignore[unresolved-attribute]
|
|
97
|
+
return _resolve_attr_names(raw)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _resolve_attr_names(d: dict[Any, Any]) -> dict[str, Any]:
|
|
101
|
+
result: dict[str, Any] = {}
|
|
102
|
+
for key, val in d.items():
|
|
103
|
+
attr_name = getattr(key, "key", key)
|
|
104
|
+
result[attr_name] = val
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_session(models: set[type]) -> Session:
|
|
109
|
+
tables = collect_all_tables(models)
|
|
110
|
+
metadata = next(iter(models)).__table__.metadata # ty: ignore[unresolved-attribute]
|
|
111
|
+
engine = create_engine("sqlite:///:memory:")
|
|
112
|
+
metadata.create_all(engine, tables=list(tables))
|
|
113
|
+
return Session(engine)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def make_factory(model: type, session: Session) -> type[SQLAlchemyModelFactory]:
|
|
117
|
+
from ._builder import FactoryBuilder
|
|
118
|
+
|
|
119
|
+
raw_generators = {
|
|
120
|
+
k: v
|
|
121
|
+
for k, v in model.generators().items() # ty: ignore[unresolved-attribute]
|
|
122
|
+
if not isinstance(v, FactoryBuilder)
|
|
123
|
+
}
|
|
124
|
+
generators = _resolve_attr_names(raw_generators)
|
|
125
|
+
meta = type(
|
|
126
|
+
"Meta",
|
|
127
|
+
(),
|
|
128
|
+
{
|
|
129
|
+
"model": model,
|
|
130
|
+
"sqlalchemy_session": session,
|
|
131
|
+
"sqlalchemy_session_persistence": "commit",
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
attrs: dict[str, Any] = {"Meta": meta, **generators}
|
|
135
|
+
return type(f"{model.__name__}Factory", (SQLAlchemyModelFactory,), attrs)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@functools.cache
|
|
139
|
+
def find_relationship(
|
|
140
|
+
child_model: type, parent_model: type, via: str | None = None
|
|
141
|
+
) -> str:
|
|
142
|
+
if via is not None:
|
|
143
|
+
return via
|
|
144
|
+
matches = [
|
|
145
|
+
rel.key
|
|
146
|
+
for rel in child_model.__mapper__.relationships # ty: ignore[unresolved-attribute]
|
|
147
|
+
if rel.mapper.class_ is parent_model
|
|
148
|
+
]
|
|
149
|
+
if not matches:
|
|
150
|
+
msg = (
|
|
151
|
+
f"No relationship from {child_model.__name__} to {parent_model.__name__}. "
|
|
152
|
+
f"Define one via SQLAlchemy relationship() or pass via= to .has()"
|
|
153
|
+
)
|
|
154
|
+
raise ValueError(msg)
|
|
155
|
+
if len(matches) > 1:
|
|
156
|
+
msg = (
|
|
157
|
+
f"Ambiguous - found {matches} on {child_model.__name__} pointing to "
|
|
158
|
+
f"{parent_model.__name__}. Pass via='rel_name' to .has()"
|
|
159
|
+
)
|
|
160
|
+
raise ValueError(msg)
|
|
161
|
+
return matches[0]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _create_instance(
|
|
165
|
+
builder: FactoryBuilder,
|
|
166
|
+
extra_overrides: dict[str, Any],
|
|
167
|
+
factories: dict[type, type[SQLAlchemyModelFactory]],
|
|
168
|
+
) -> Any:
|
|
169
|
+
inst_overrides = _resolve_variant(builder)
|
|
170
|
+
inst_overrides.update(extra_overrides)
|
|
171
|
+
|
|
172
|
+
default_builders = _get_default_builders(builder.model)
|
|
173
|
+
for rel in builder.model.__mapper__.relationships: # ty: ignore[unresolved-attribute]
|
|
174
|
+
if rel.direction is not MANYTOONE:
|
|
175
|
+
continue
|
|
176
|
+
if rel.key not in inst_overrides and rel.key in default_builders:
|
|
177
|
+
inst_overrides[rel.key] = _create_instance(
|
|
178
|
+
default_builders[rel.key], {}, factories
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
instance = factories[builder.model](**inst_overrides)
|
|
182
|
+
|
|
183
|
+
for child_builder, via in builder._children:
|
|
184
|
+
rel_key = find_relationship(child_builder.model, builder.model, via)
|
|
185
|
+
for _ in range(child_builder._resolve_count()):
|
|
186
|
+
_create_instance(child_builder, {rel_key: instance}, factories)
|
|
187
|
+
|
|
188
|
+
return instance
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def execute_builder(
|
|
192
|
+
builder: FactoryBuilder,
|
|
193
|
+
overrides: dict[Any, Any],
|
|
194
|
+
seed: int | None = None,
|
|
195
|
+
) -> list[Any]:
|
|
196
|
+
if seed is not None:
|
|
197
|
+
_seed_random(seed)
|
|
198
|
+
|
|
199
|
+
check_cycles(builder)
|
|
200
|
+
models = collect_models(builder)
|
|
201
|
+
session = build_session(models)
|
|
202
|
+
factories = {m: make_factory(m, session) for m in models}
|
|
203
|
+
root_overrides = _resolve_attr_names(overrides)
|
|
204
|
+
|
|
205
|
+
return [
|
|
206
|
+
_create_instance(builder, root_overrides, factories)
|
|
207
|
+
for _ in range(builder._resolve_count())
|
|
208
|
+
]
|