From 440030d6d9400d82b9f3ae526be969f77cdaca69 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 15 Jan 2025 22:59:45 +0100 Subject: [PATCH] Changes: - improve tests - extract values from fields - allow callable parameters --- edgy/testing/factory/base.py | 23 ++++++-- edgy/testing/factory/fields.py | 25 +++++--- edgy/testing/factory/metaclasses.py | 50 +++++++++++----- edgy/testing/factory/types.py | 18 ++++-- edgy/testing/factory/utils.py | 67 +++++++++++++++++++++ tests/factory/test_factory.py | 66 +++++++++++++-------- tests/factory/test_parameters.py | 92 +++++++++++++++++++++++++++++ 7 files changed, 284 insertions(+), 57 deletions(-) create mode 100644 edgy/testing/factory/utils.py create mode 100644 tests/factory/test_parameters.py diff --git a/edgy/testing/factory/base.py b/edgy/testing/factory/base.py index 3a636bdb..b9c4b582 100644 --- a/edgy/testing/factory/base.py +++ b/edgy/testing/factory/base.py @@ -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 @@ -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 diff --git a/edgy/testing/factory/fields.py b/edgy/testing/factory/fields.py index 7ae6bf1d..c9065b1d 100644 --- a/edgy/testing/factory/fields.py +++ b/edgy/testing/factory/fields.py @@ -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 @@ -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"] diff --git a/edgy/testing/factory/metaclasses.py b/edgy/testing/factory/metaclasses.py index c4bf7649..071f69b9 100644 --- a/edgy/testing/factory/metaclasses.py +++ b/edgy/testing/factory/metaclasses.py @@ -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 @@ -71,22 +72,36 @@ 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, @@ -94,6 +109,9 @@ class Meta: "ManyToMany": ManyToManyField_callback, "RefForeignKey": RefForeignKey_callback, "PKField": None, + "ComputedField": None, + "ExcludeField": None, + "PlaceholderField": None, } diff --git a/edgy/testing/factory/types.py b/edgy/testing/factory/types.py index 5eab2a9d..0c24cead 100644 --- a/edgy/testing/factory/types.py +++ b/edgy/testing/factory/types.py @@ -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 @@ -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"]] diff --git a/edgy/testing/factory/utils.py b/edgy/testing/factory/utils.py new file mode 100644 index 00000000..94a291d4 --- /dev/null +++ b/edgy/testing/factory/utils.py @@ -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 diff --git a/tests/factory/test_factory.py b/tests/factory/test_factory.py index d522839b..1c0f3866 100644 --- a/tests/factory/test_factory.py +++ b/tests/factory/test_factory.py @@ -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) @@ -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: @@ -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, {}) diff --git a/tests/factory/test_parameters.py b/tests/factory/test_parameters.py new file mode 100644 index 00000000..75ab6a7e --- /dev/null +++ b/tests/factory/test_parameters.py @@ -0,0 +1,92 @@ +import edgy +from edgy.testing import DatabaseTestClient +from edgy.testing.factory import FactoryField, ModelFactory +from tests.settings import DATABASE_URL + +database = DatabaseTestClient(DATABASE_URL, full_isolation=False) +models = edgy.Registry(database=database) + + +class User(edgy.StrictModel): + id: int = edgy.IntegerField(primary_key=True, autoincrement=True) + name: str = edgy.CharField(max_length=100, null=True) + language: str = edgy.CharField(max_length=200, null=True) + + class Meta: + registry = models + + +class Product(edgy.StrictModel): + id: int = edgy.IntegerField(primary_key=True, autoincrement=True) + name: str = edgy.CharField(max_length=100, null=True) + rating: int = edgy.IntegerField(minimum=1, maximum=5, default=1) + in_stock: bool = edgy.BooleanField(default=False) + user: User = edgy.fields.ForeignKey(User) + + class Meta: + registry = models + name = "products" + + +class Cart(edgy.StrictModel): + products = edgy.fields.ManyToMany(Product) + + class Meta: + registry = models + + +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_with_params(): + class ProductFactory(ModelFactory): + class Meta: + model = Product + + name = FactoryField( + callback=lambda x, y, kwargs: f"edgy{kwargs['count']}", parameters={"count": None} + ) + + old_product = None + for i in range(100): # noqa + product = ProductFactory().build(parameters={"name": {"count": i}}) + assert product.name == f"edgy{i}" + assert product != old_product + old_product = product + + +def test_can_use_field_callback_with_dynamic_params(): + class ProductFactory(ModelFactory): + class Meta: + model = Product + + name = FactoryField( + callback=lambda x, y, kwargs: kwargs["name"], + parameters={"name": lambda field, _1, _2: field.name}, + ) + + product = ProductFactory().build() + assert product.name == "name"