Skip to content

Commit

Permalink
Changes:
Browse files Browse the repository at this point in the history
- improve tests
- extract values from fields
- allow callable parameters
  • Loading branch information
devkral committed Jan 15, 2025
1 parent 45d986b commit 440030d
Show file tree
Hide file tree
Showing 7 changed files with 284 additions and 57 deletions.
23 changes: 19 additions & 4 deletions edgy/testing/factory/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ def build(
self,
*,
faker: Faker | None = None,
parameters: dict[str, dict[str, Any] | FactoryCallback] | None = None,
overwrites: dict | None = None,
parameters: dict[str, dict[str, Any] | FactoryCallback | False] | None = None,
overwrites: dict[str, Any] | None = None,
) -> Model:
"""
When this function is called, automacally will perform the
Expand Down Expand Up @@ -70,12 +70,27 @@ def build(
if name in overwrites or name in self.__kwargs__ or field.exclude:
continue
current_parameters_or_callback = parameters.get(name)
# exclude field on the fly by passing False in parameters and don't have it in kwargs
if current_parameters_or_callback is False:
continue
if callable(current_parameters_or_callback):
values[name] = current_parameters_or_callback(field, faker, field.parameters)
values[name] = current_parameters_or_callback(
field,
faker,
field.get_parameters(faker=faker),
)
else:
values[name] = field(faker=faker, parameters=current_parameters_or_callback)
values[name] = field(
faker=faker,
parameters=field.get_parameters(
faker=faker,
parameters=current_parameters_or_callback,
),
)

values.update(self.__kwargs__)
values.update(overwrites)

result = self.meta.model(**values)
if getattr(self, "database", None) is not None:
result.database = self.database
Expand Down
25 changes: 18 additions & 7 deletions edgy/testing/factory/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@ def get_callback(self) -> FactoryCallback:
self._callback = self.owner.meta.mappings[self.get_field_type()]
return self._callback

def get_parameters(
self,
*,
faker: Faker,
parameters: FactoryParameters | None = None,
) -> dict[str, Any]:
current_parameters: FactoryParameters = {}
for parameter_dict in [parameters or {}, self.parameters]:
for name, parameter in parameter_dict.items():
if name not in current_parameters:
if callable(parameter) and not isclass(parameter):
current_parameters[name] = parameter(self, faker, name)
else:
current_parameters[name] = parameter
return current_parameters

@property
def field_type(self) -> str:
return self._field_type
Expand Down Expand Up @@ -82,13 +98,8 @@ def __copy__(self) -> FactoryField:
_copy.original_name = self.original_name
return _copy

def __call__(self, *, faker: Faker, parameters: FactoryParameters | None = None) -> Any:
current_parameters: FactoryParameters = {}
if self.parameters:
current_parameters.update(self.parameters)
if parameters:
current_parameters.update(parameters)
return self.get_callback()(self, faker, current_parameters)
def __call__(self, *, faker: Faker, parameters: FactoryParameters) -> Any:
return self.get_callback()(self, faker, parameters)


__all__ = ["FactoryField"]
50 changes: 34 additions & 16 deletions edgy/testing/factory/metaclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from edgy.utils.compat import is_class_and_subclass

from .fields import FactoryField
from .utils import edgy_field_param_extractor

if TYPE_CHECKING:
from faker import Faker
Expand Down Expand Up @@ -71,29 +72,46 @@ class Meta:


DEFAULT_MAPPING: dict[str, FactoryCallback | None] = {
"IntegerField": lambda field, faker, kwargs: faker.random_int(**kwargs),
"BigIntegerField": lambda field, faker, kwargs: faker.random_number(**kwargs),
"BooleanField": lambda field, faker, kwargs: faker.boolean(**kwargs),
"CharField": lambda field, faker, kwargs: faker.name(**kwargs),
"DateField": lambda field, faker, kwargs: faker.date(**kwargs),
"DateTimeField": lambda field, faker, kwargs: faker.date_time(**kwargs),
"DecimalField": lambda field, faker, kwargs: faker.pyfloat(**kwargs),
"DurationField": lambda field, faker, kwargs: faker.time(**kwargs),
"EmailField": lambda field, faker, kwargs: faker.email(**kwargs),
"FloatField": lambda field, faker, kwargs: faker.pyfloat(**kwargs),
"IPAddressField": lambda field, faker, kwargs: faker.ipv4(**kwargs),
"PasswordField": lambda field, faker, kwargs: faker.ipv4(**kwargs),
"SmallIntegerField": lambda field, faker, kwargs: faker.random_int(**kwargs),
"TextField": lambda field, faker, kwargs: faker.text(**kwargs),
"TimeField": lambda field, faker, kwargs: faker.time(**kwargs),
"UUIDField": lambda field, faker, kwargs: faker.uuid4(**kwargs),
"IntegerField": edgy_field_param_extractor(
"random_int", remapping={"gt": ("min", lambda x: x - 1), "lt": ("max", lambda x: x + 1)}
),
"BigIntegerField": edgy_field_param_extractor(
"random_number", remapping={"gt": ("min", lambda x: x - 1), "lt": ("max", lambda x: x + 1)}
),
"BooleanField": edgy_field_param_extractor("boolean"),
"URLField": edgy_field_param_extractor("uri"),
# FIXME: find a good integration strategy
"ImageField": None,
"FileField": None,
"ChoiceField": None,
"CompositeField": None,
"CharField": edgy_field_param_extractor("name"),
"DateField": edgy_field_param_extractor("date"),
"DateTimeField": edgy_field_param_extractor("date_time"),
"DecimalField": edgy_field_param_extractor("pyfloat"),
"DurationField": edgy_field_param_extractor("time"),
"EmailField": edgy_field_param_extractor("email"),
"BinaryField": edgy_field_param_extractor(
"binary", remapping={"max_length": ("length", lambda x: x)}
),
"FloatField": edgy_field_param_extractor("pyfloat"),
"IPAddressField": edgy_field_param_extractor("ipv4"),
"PasswordField": edgy_field_param_extractor("ipv4"),
"SmallIntegerField": edgy_field_param_extractor("random_int"),
"TextField": edgy_field_param_extractor("text"),
"TimeField": edgy_field_param_extractor("time"),
"UUIDField": edgy_field_param_extractor("uuid4"),
"JSONField": edgy_field_param_extractor("json"),
"ForeignKey": ForeignKey_callback,
"OneToOneField": ForeignKey_callback,
"OneToOne": ForeignKey_callback,
"ManyToManyField": ManyToManyField_callback,
"ManyToMany": ManyToManyField_callback,
"RefForeignKey": RefForeignKey_callback,
"PKField": None,
"ComputedField": None,
"ExcludeField": None,
"PlaceholderField": None,
}


Expand Down
18 changes: 14 additions & 4 deletions edgy/testing/factory/types.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Union

if TYPE_CHECKING:
from faker import Faker
Expand All @@ -9,6 +11,14 @@
from .fields import FactoryField


FactoryParameters = dict[str, Any]
FactoryCallback = Callable[[FactoryField, Faker, FactoryParameters], Any]
FactoryFieldType = str | BaseFieldType | type[BaseFieldType]
FactoryParameterCallback = Callable[
[
"FactoryField",
"Faker",
str,
],
Any,
]
FactoryParameters = dict[str, Any | FactoryParameterCallback]
FactoryCallback = Callable[["FactoryField", "Faker", "FactoryParameters"], Any]
FactoryFieldType = Union[str, "BaseFieldType", type["BaseFieldType"]]
67 changes: 67 additions & 0 deletions edgy/testing/factory/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from __future__ import annotations

from collections.abc import Callable
from inspect import isclass
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from faker import Faker

from .fields import FactoryField
from .types import FactoryCallback, FactoryParameterCallback


PYDANTIC_FIELD_PARAMETERS: dict[str, tuple[str, Callable[[Any], Any]]] = {
"ge": ("min", lambda x: x),
"le": ("max", lambda x: x),
"multiple_of": ("step", lambda x: x),
}


def edgy_field_param_extractor(
factory_fn: FactoryCallback | str,
*,
remapping: dict[str, tuple[str, Callable[[Any], Any]] | None] | None = None,
defaults: dict[str, Any | FactoryParameterCallback] | None = None,
) -> Callable[FactoryCallback, Any]:
remapping = remapping or {}
remapping = {**PYDANTIC_FIELD_PARAMETERS, **remapping}
if isinstance(factory_fn, str):
factory_name = factory_fn
factory_fn = lambda field, faker, kwargs: getattr(faker, factory_name)(**kwargs) # noqa

def mapper_fn(field: FactoryField, faker: Faker, kwargs: dict[str, Any]) -> Any:
edgy_field = field.owner.meta.model.meta.fields[field.name]
for attr, mapper in remapping.items():
if mapper is None:
continue
if getattr(edgy_field, attr, None) is not None:
kwargs.setdefault(mapper[0], mapper[1](getattr(edgy_field, attr)))
if defaults:
for name, value in defaults.items():
if name not in kwargs:
if callable(value) and not isclass(value):
value = value(field, faker, name)
kwargs[name] = value
return factory_fn(field, faker, kwargs)

return mapper_fn


def default_wrapper(
factory_fn: FactoryCallback | str,
defaults: dict[str, Any],
) -> Callable[FactoryCallback, Any | FactoryParameterCallback]:
"""A simplified edgy_field_param_extractor."""
if isinstance(factory_fn, str):
factory_fn = lambda field, faker, kwargs: getattr(faker, factory_fn)(**kwargs) # noqa

def mapper_fn(field: FactoryField, faker: Faker, kwargs: dict[str, Any]) -> Any:
for name, value in defaults.items():
if name not in kwargs:
if callable(value) and not isclass(value):
value = value(field, faker, name)
kwargs[name] = value
return factory_fn(field, faker, kwargs)

return mapper_fn
66 changes: 40 additions & 26 deletions tests/factory/test_factory.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import pytest

import edgy
from edgy.testing import DatabaseTestClient
from edgy.testing.factory import FactoryField, ModelFactory
from edgy.testing.factory.metaclasses import DEFAULT_MAPPING
from tests.settings import DATABASE_URL

database = DatabaseTestClient(DATABASE_URL, full_isolation=False)
Expand Down Expand Up @@ -76,32 +79,6 @@ class Meta:
assert product.database == database


def test_can_generate_and_parametrize():
class CartFactory(ModelFactory):
class Meta:
model = Cart

cart = CartFactory().build(parameters={"products": {"min": 50, "max": 50}})
assert len(cart.products.refs) == 50

cart = CartFactory().build(parameters={"products": {"min": 10, "max": 50}})
assert len(cart.products.refs) >= 10 and len(cart.products.refs) <= 50


def test_can_use_field_parameters():
class CartFactory(ModelFactory):
class Meta:
model = Cart

products = FactoryField(parameters={"min": 50, "max": 50})

cart = CartFactory().build()
assert len(cart.products.refs) == 50

cart = CartFactory().build(parameters={"products": {"min": 10, "max": 10}})
assert len(cart.products.refs) == 10


def test_can_use_field_callback():
class ProductFactory(ModelFactory):
class Meta:
Expand All @@ -115,3 +92,40 @@ class Meta:
assert product.name == "edgy"
assert product != old_product
old_product = product


def test_verify_fail_when_default_broken():
with pytest.raises(KeyError):

class ProductFactory(ModelFactory):
class Meta:
model = Product

name = FactoryField(callback=lambda x, y, kwargs: f"edgy{kwargs['count']}")


def test_mapping():
class UserFactory(ModelFactory):
class Meta:
model = User

for field_name in edgy.fields.__all__:
field_type_name = getattr(edgy.fields, field_name).__name__
if (
"Mixin" in field_type_name
or field_type_name == "BaseField"
or field_type_name == "BaseFieldType"
):
continue
assert field_type_name in DEFAULT_MAPPING
if field_type_name not in {
"ForeignKey",
"OneToOneField",
"OneToOne",
"ManyToManyField",
"ManyToMany",
"RefForeignKey",
}:
callback = DEFAULT_MAPPING[field_type_name]
if callback:
callback(UserFactory.meta.fields["name"], UserFactory.meta.faker, {})
Loading

0 comments on commit 440030d

Please sign in to comment.