Skip to content

Commit

Permalink
Allow nesting declarations in Faker params.
Browse files Browse the repository at this point in the history
A `factory.Faker()` call may accept extra keywords, as required by its
provider. This change allows using any factory-supported declaration
(`SelfAttribute`, fuzzy, other faker calls).

This is built through a new class, `ParameteredDeclaration`, which holds
all the specific code for lazily evaluating parameters to a declaration.
That class replaces `ParameteredAttribute` in other fields with a
similar behaviour, namely `django.FileField` and `django.ImageField`.

The `ParameteredAttribute` internal class, while similar, is kept, as it
provides a dedicated support for `SubFactory` and might be used for
other declarations relying on a custom factory. Moreover, although part
of the private API, it is already relied upon by other projects [1].

Once `ParameteredDeclaration`' API is stabilised, it could be documented
as a formal extension point for custom declarations.

[1] https://github.com/mvantellingen/wagtail-factories/blob/master/src/wagtail_factories/blocks.py#L20
  • Loading branch information
rbarrois committed Aug 18, 2020
1 parent 901a730 commit f0a4ef0
Show file tree
Hide file tree
Showing 6 changed files with 145 additions and 22 deletions.
6 changes: 4 additions & 2 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
ChangeLog
=========

3.0.2 (unreleased)
3.1.0 (unreleased)
------------------

- Nothing changed yet.
*New:*

- Allow all types of declarations in :class:`factory.Faker` calls - enables references to other faker-defined attributes.


3.0.1 (2020-08-13)
Expand Down
39 changes: 39 additions & 0 deletions docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,43 @@ Faker
>>> user.name
'Lucy Cechtelar'
Some providers accept parameters; they should be passed after the provider name:

.. code-block:: python
class UserFactory(fatory.Factory):
class Meta:
model = User
arrival = factory.Faker(
'date_between_dates',
date_start=datetime.date(2020, 1, 1),
date_end=datetime.date(2020, 5, 31),
)
As with :class:`~factory.SubFactory`, the parameters can be any valid declaration.
This does not apply to the provider name or the locale.

.. code-block:: python
class TripFactory(fatory.Factory):
class Meta:
model = Trip
departure = factory.Faker(
'date',
end_datetime=datetime.date.today(),
)
arrival = factory.Faker(
'date_between_dates',
date_start=factory.SelfAttribute('..departure'),
)
.. note:: When using :class:`~factory.SelfAttribute` or :class:`~factory.LazyAttribute`
in a :class:`factory.Faker` parameter, the current object is the declarations
provided to the :class:`~factory.Faker` declaration; go :ref:`up a level <factory-parent>`
to reach fields of the surrounding :class:`~factory.Factory`, as shown
in the ``SelfAttribute('..xxx')`` example above.

.. attribute:: locale

Expand Down Expand Up @@ -1246,6 +1283,8 @@ That declaration takes a single argument, a dot-delimited path to the attribute
3
.. _factory-parent:

Parents
~~~~~~~

Expand Down
35 changes: 35 additions & 0 deletions factory/declarations.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,39 @@ def generate(self, step, params):
raise NotImplementedError()


class ParameteredDeclaration(BaseDeclaration):
"""A declaration with parameters.
The parameters can be any factory-enabled declaration, and will be resolved
before the call to the user-defined code in `self.generate()`.
Attributes:
defaults (dict): Default values for the parameters; can be overridden
by call-time parameters. Accepts BaseDeclaration subclasses.
"""

def __init__(self, **defaults):
self.defaults = defaults
super().__init__()

def unroll_context(self, instance, step, context):
merged_context = {}
merged_context.update(self.defaults)
merged_context.update(context)
return super().unroll_context(instance, step, merged_context)

def evaluate(self, instance, step, extra):
return self.generate(extra)

def generate(self, params):
"""Generate a value for this declaration.
Args:
params (dict): the parameters, after a factory evaluation.
"""
raise NotImplementedError()


class _FactoryWrapper:
"""Handle a 'factory' arg.
Expand Down Expand Up @@ -375,6 +408,8 @@ class SubFactory(ParameteredAttribute):
"""

EXTEND_CONTAINERS = True
# Whether to align the attribute's sequence counter to the holding
# factory's sequence counter
FORCE_SEQUENCE = False
UNROLL_CONTEXT_BEFORE_EVALUATION = False

Expand Down
7 changes: 2 additions & 5 deletions factory/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ def _after_postgeneration(cls, instance, create, results=None):
instance.save()


class FileField(declarations.ParameteredAttribute):
class FileField(declarations.ParameteredDeclaration):
"""Helper to fill in django.db.models.FileField from a Factory."""

DEFAULT_FILENAME = 'example.dat'
Expand Down Expand Up @@ -219,11 +219,8 @@ def _make_content(self, params):
filename = params.get('filename', default_filename)
return filename, content

def generate(self, step, params):
def generate(self, params):
"""Fill in the field."""
# Recurse into a DictFactory: allows users to have some params depending
# on others.
params = step.recurse(base.DictFactory, params, force_sequence=step.sequence)
filename, content = self._make_content(params)
return django_files.File(content.file, filename)

Expand Down
24 changes: 10 additions & 14 deletions factory/faker.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class Meta:
from . import declarations


class Faker(declarations.BaseDeclaration):
class Faker(declarations.ParameteredDeclaration):
"""Wrapper for 'faker' values.
Args:
Expand All @@ -36,20 +36,16 @@ class Faker(declarations.BaseDeclaration):
>>> foo = factory.Faker('name')
"""
def __init__(self, provider, **kwargs):
super().__init__()
locale = kwargs.pop('locale', None)
self.provider = provider
self.provider_kwargs = kwargs
self.locale = kwargs.pop('locale', None)

def generate(self, extra_kwargs=None):
kwargs = {}
kwargs.update(self.provider_kwargs)
kwargs.update(extra_kwargs or {})
subfaker = self._get_faker(self.locale)
return subfaker.format(self.provider, **kwargs)

def evaluate(self, instance, step, extra):
return self.generate(extra)
super().__init__(
locale=locale,
**kwargs)

def generate(self, params):
locale = params.pop('locale')
subfaker = self._get_faker(locale)
return subfaker.format(self.provider, **params)

_FAKER_REGISTRY = {}
_DEFAULT_LOCALE = faker.config.DEFAULT_LOCALE
Expand Down
56 changes: 55 additions & 1 deletion tests/test_faker.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Copyright: See the LICENSE file.

import collections
import datetime
import random
import unittest

Expand All @@ -17,6 +19,16 @@ def format(self, provider, **kwargs):
return self.expected[provider]


class AdvancedMockFaker:
def __init__(self, handlers):
self.handlers = handlers
self.random = random.Random()

def format(self, provider, **kwargs):
handler = self.handlers[provider]
return handler(**kwargs)


class FakerTests(unittest.TestCase):
def setUp(self):
self._real_fakers = factory.Faker._FAKER_REGISTRY
Expand All @@ -30,10 +42,15 @@ def _setup_mock_faker(self, locale=None, **definitions):
locale = factory.Faker._DEFAULT_LOCALE
factory.Faker._FAKER_REGISTRY[locale] = MockFaker(definitions)

def _setup_advanced_mock_faker(self, locale=None, **handlers):
if locale is None:
locale = factory.Faker._DEFAULT_LOCALE
factory.Faker._FAKER_REGISTRY[locale] = AdvancedMockFaker(handlers)

def test_simple_biased(self):
self._setup_mock_faker(name="John Doe")
faker_field = factory.Faker('name')
self.assertEqual("John Doe", faker_field.generate())
self.assertEqual("John Doe", faker_field.generate({'locale': None}))

def test_full_factory(self):
class Profile:
Expand Down Expand Up @@ -114,3 +131,40 @@ def smiley(self):
face = FaceFactory()
self.assertEqual(":)", face.smiley)
self.assertEqual("(:", face.french_smiley)

def test_faker_customization(self):
"""Factory declarations in Faker parameters should be accepted."""
Trip = collections.namedtuple('Trip', ['departure', 'transfer', 'arrival'])

may_4th = datetime.date(1977, 5, 4)
may_25th = datetime.date(1977, 5, 25)
october_19th = datetime.date(1977, 10, 19)

class TripFactory(factory.Factory):
class Meta:
model = Trip

departure = may_4th
arrival = may_25th
transfer = factory.Faker(
'date_between_dates',
start_date=factory.SelfAttribute('..departure'),
end_date=factory.SelfAttribute('..arrival'),
)

def fake_select_date(start_date, end_date):
"""Fake date_between_dates."""
# Ensure that dates have been transfered from the factory
# to Faker parameters.
self.assertEqual(start_date, may_4th)
self.assertEqual(end_date, may_25th)
return october_19th

self._setup_advanced_mock_faker(
date_between_dates=fake_select_date,
)

trip = TripFactory()
self.assertEqual(may_4th, trip.departure)
self.assertEqual(october_19th, trip.transfer)
self.assertEqual(may_25th, trip.arrival)

0 comments on commit f0a4ef0

Please sign in to comment.