Skip to content

Commit

Permalink
Changes:
Browse files Browse the repository at this point in the history
- add more tests
- improve field mappings
- docs
- make model validation customizable
  • Loading branch information
devkral committed Jan 20, 2025
1 parent 1655899 commit 09e179b
Show file tree
Hide file tree
Showing 8 changed files with 266 additions and 34 deletions.
38 changes: 38 additions & 0 deletions docs/testing/model-factory.md
Original file line number Diff line number Diff line change
@@ -1 +1,39 @@
# ModelFactory

A ModelFactory is a faker based model stub generator.

In the first step, building the factory class, you can define via `FactoryField`s customizations of the parameters passed
for the fakers for the model.

The second step, is making an factory instance. Here can values be passed which should be used for the model. They are baked in
the factory instance. But you are able to overwrite them in the last step or to exclude them.

The last step, is building a stub model via `build`. This is an **instance-only** method not like the other build method other model definitions.

In short the lifecycle is like follows:

Factory definition -> Factory instance -> Factory build method -> stubbed Model instance to play with.

## Parametrize

## Build

The central method for factories is `build()`.

## Model Validation

By default a validation is executed if the model can ever succeed in generation. If not an error
is printed but the model still build.
If you dislike this behaviour, you can disable the implicit model validation via:

```python
class UserFactory(ModelFactory, model_validation="none"):
...
```

You have following options:

- `none`: No implicit validation.
- `warn`: Warn for unsound factory/model definitions which produce other errors than pydantic validation errors.
- `error`: Same as warn but reraise the exception instead of warning.
- `pedantic`: Raise even for pydantic validation errors.
4 changes: 2 additions & 2 deletions edgy/core/db/fields/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import ipaddress
import uuid
import warnings
from collections.abc import Callable, Sequence
from collections.abc import Callable
from enum import EnumMeta
from functools import cached_property, partial
from re import Pattern
Expand Down Expand Up @@ -664,7 +664,7 @@ class ChoiceField(FieldFactory):

def __new__( # type: ignore
cls,
choices: Optional[Sequence[Union[tuple[str, str], tuple[str, int]]]] = None,
choices: enum.Enum,
**kwargs: Any,
) -> BaseFieldType:
kwargs = {
Expand Down
14 changes: 8 additions & 6 deletions edgy/testing/factory/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Any, Literal
from collections.abc import Container
from typing import TYPE_CHECKING, Any

from edgy import Model

Expand Down Expand Up @@ -36,8 +37,10 @@ def build(
self,
*,
faker: Faker | None = None,
parameters: dict[str, dict[str, Any] | FactoryCallback | Literal[False]] | None = None,
parameters: dict[str, dict[str, Any] | FactoryCallback] | None = None,
overwrites: dict[str, Any] | None = None,
exclude: Container[str] = (),
save_after: bool = False,
) -> Model:
"""
When this function is called, automacally will perform the
Expand Down Expand Up @@ -68,12 +71,9 @@ def build(
overwrites = {}
values = {}
for name, field in self.meta.fields.items():
if name in overwrites or name in self.__kwargs__ or field.exclude:
if name in overwrites or name in exclude 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,
Expand All @@ -97,4 +97,6 @@ def build(
result.database = self.database
if getattr(self, "__using_schema__", None) is not None:
result.__using_schema__ = self.__using_schema__
if save_after:
return result.save()
return result
61 changes: 50 additions & 11 deletions edgy/testing/factory/metaclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

from collections.abc import Callable
from inspect import getmro, isclass
from typing import TYPE_CHECKING, Any, ClassVar, cast
from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast

import monkay
from pydantic import ValidationError

from edgy.core.db.models import Model
from edgy.core.terminal import Print
Expand All @@ -15,6 +16,8 @@
from .utils import edgy_field_param_extractor

if TYPE_CHECKING:
import enum

from faker import Faker

from edgy.core.connection import Registry
Expand All @@ -26,6 +29,12 @@
terminal = Print()


def ChoiceField_callback(field: FactoryField, faker: Faker, kwargs: dict[str, Any]) -> Any:
choices: enum.Enum = field.owner.meta.model.meta.fields[field.name].choices

return faker.enum(choices)


def ForeignKey_callback(field: FactoryField, faker: Faker, kwargs: dict[str, Any]) -> Any:
from .base import ModelFactory

Expand Down Expand Up @@ -79,26 +88,42 @@ class Meta:
"BigIntegerField": edgy_field_param_extractor(
"random_number", remapping={"gt": ("min", lambda x: x - 1), "lt": ("max", lambda x: x + 1)}
),
"SmallIntegerField": edgy_field_param_extractor(
"random_int", remapping={"gt": ("min", lambda x: x - 1), "lt": ("max", lambda x: x + 1)}
),
"DecimalField": edgy_field_param_extractor(
"pydecimal",
remapping={
# TODO: find better definition
"gt": ("min", lambda x: x - 0.0000000001),
"lt": ("max", lambda x: x + 0.0000000001),
},
),
"FloatField": edgy_field_param_extractor(
"pyfloat",
remapping={
# TODO: find better definition
"gt": ("min", lambda x: x - 0.0000000001),
"lt": ("max", lambda x: x + 0.0000000001),
},
),
"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,
"ImageField": edgy_field_param_extractor(
"binary", remapping={"max_length": ("length", lambda x: x)}
),
"FileField": edgy_field_param_extractor("binary"),
"ChoiceField": ChoiceField_callback,
"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"),
Expand All @@ -109,9 +134,13 @@ class Meta:
"ManyToManyField": ManyToManyField_callback,
"ManyToMany": ManyToManyField_callback,
"RefForeignKey": RefForeignKey_callback,
"PKField": None,
# special fields without mapping, they need a custom user defined logic
"CompositeField": None,
"ComputedField": None,
"PKField": None,
# can't hold a value
"ExcludeField": None,
# private. Used by other fields to save a private value.
"PlaceholderField": None,
}

Expand Down Expand Up @@ -156,6 +185,7 @@ def __new__(
bases: tuple[type, ...],
attrs: dict[str, Any],
meta_info_class: type[MetaInfo] = MetaInfo,
model_validation: Literal["none", "warn", "error", "pedantic"] = "warn",
**kwargs: Any,
) -> type[ModelFactory]:
# has parents
Expand Down Expand Up @@ -261,5 +291,14 @@ def __new__(
for field in fields.values():
field.owner = new_class
# validate
new_class().build()
if model_validation != "none":
try:
new_class().build(save_after=False)
except ValidationError as exc:
if model_validation == "pedantic":
raise exc
except Exception as exc:
if model_validation == "error" or model_validation == "pedantic":
raise exc
terminal.write_warning(f'Could not build a sample model instance: "{exc!r}".')
return new_class
2 changes: 1 addition & 1 deletion edgy/testing/factory/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@
],
Any,
]
FactoryParameters = dict[str, Any | FactoryParameterCallback]
FactoryParameters = dict[str, Union[Any, FactoryParameterCallback]]
FactoryCallback = Callable[["FactoryField", "Faker", dict[str, Any]], Any]
FactoryFieldType = Union[str, "BaseFieldType", type["BaseFieldType"]]
5 changes: 3 additions & 2 deletions edgy/testing/factory/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@


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


Expand Down
58 changes: 46 additions & 12 deletions tests/factory/test_factory.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import enum

import pytest
from pydantic import ValidationError

import edgy
from edgy.testing import DatabaseTestClient
Expand All @@ -10,6 +13,11 @@
models = edgy.Registry(database=database)


class ProductType(enum.Enum):
real = "real"
virtual = "virtual"


class User(edgy.StrictModel):
id: int = edgy.IntegerField(primary_key=True, autoincrement=True)
name: str = edgy.CharField(max_length=100, null=True)
Expand All @@ -25,6 +33,7 @@ class Product(edgy.StrictModel):
rating: int = edgy.IntegerField(minimum=1, maximum=5, default=1)
in_stock: bool = edgy.BooleanField(default=False)
user: User = edgy.fields.ForeignKey(User)
type: ProductType = edgy.fields.ChoiceField(choices=ProductType, default=ProductType.virtual)

class Meta:
registry = models
Expand All @@ -48,6 +57,16 @@ class Meta:
assert UserFactory.meta.registry == models


def test_can_generate_factory_by_string():
class UserFactory(ModelFactory):
class Meta:
model = "tests.factory.test_factory.User"

assert UserFactory.meta.model == User
assert UserFactory.meta.abstract is False
assert UserFactory.meta.registry == models


def test_can_generate_overwrite_and_exclude():
class UserFactory(ModelFactory):
class Meta:
Expand Down Expand Up @@ -78,6 +97,17 @@ class Meta:
assert product.user is user
assert product.database == database

# now strip User
user = UserFactory().build(exclude={"name", "language"})
# currently the behaviour is to set the defaults later when saving to the db
# this can change in future
assert getattr(user, "name", None) is None
assert getattr(user, "language", None) is None

# now strip Product and cause an error
with pytest.raises(ValidationError):
ProductFactory().build(exclude={"user"})


def test_can_use_field_callback():
class ProductFactory(ModelFactory):
Expand All @@ -94,21 +124,15 @@ class Meta:
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

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

for field_name in edgy.fields.__all__:
field_type_name = getattr(edgy.fields, field_name).__name__
if (
Expand All @@ -118,14 +142,24 @@ class Meta:
):
continue
assert field_type_name in DEFAULT_MAPPING
if field_type_name not in {
if field_type_name in {
"ForeignKey",
"OneToOneField",
"OneToOne",
"ManyToManyField",
"ManyToMany",
"RefForeignKey",
}:
DEFAULT_MAPPING[field_type_name](
ProductFactory.meta.fields["user"], ProductFactory.meta.faker, {}
)
elif field_type_name == "ChoiceField":
DEFAULT_MAPPING[field_type_name](
ProductFactory.meta.fields["type"], ProductFactory.meta.faker, {}
)
elif field_type_name == "RefForeignKey":
pass
# FIXME: provide test
else:
callback = DEFAULT_MAPPING[field_type_name]
if callback:
callback(UserFactory.meta.fields["name"], UserFactory.meta.faker, {})
Loading

0 comments on commit 09e179b

Please sign in to comment.