Repository: FactoryBoy/factory_boy
Pull request #772 (2020-08-14 08:40:50)

Allow nesting declarations in Faker params.
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


Patch:
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 4d4a6f7..7329988 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -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)
diff --git a/docs/reference.rst b/docs/reference.rst
index b5f2bbe..d255f6e 100644
--- a/docs/reference.rst
+++ b/docs/reference.rst
@@ -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
 
@@ -1246,6 +1283,8 @@ That declaration takes a single argument, a dot-delimited path to the attribute
     3
 
 
+.. _factory-parent:
+
 Parents
 ~~~~~~~
 
diff --git a/factory/declarations.py b/factory/declarations.py
index 32d4b62..9ea91bb 100644
--- a/factory/declarations.py
+++ b/factory/declarations.py
@@ -331,6 +331,39 @@ class ParameteredAttribute(BaseDeclaration):
         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.
 
@@ -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
 
diff --git a/factory/django.py b/factory/django.py
index 8261a73..5405be7 100644
--- a/factory/django.py
+++ b/factory/django.py
@@ -173,7 +173,7 @@ class DjangoModelFactory(base.Factory):
             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'
@@ -219,11 +219,8 @@ class FileField(declarations.ParameteredAttribute):
         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)
 
diff --git a/factory/faker.py b/factory/faker.py
index 6f10b78..1c9e28a 100644
--- a/factory/faker.py
+++ b/factory/faker.py
@@ -21,7 +21,7 @@ import faker.config
 from . import declarations
 
 
-class Faker(declarations.BaseDeclaration):
+class Faker(declarations.ParameteredDeclaration):
     """Wrapper for 'faker' values.
 
     Args:
@@ -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


Test patch:
diff --git a/tests/test_faker.py b/tests/test_faker.py
index 30cd5a5..fb9b721 100644
--- a/tests/test_faker.py
+++ b/tests/test_faker.py
@@ -1,5 +1,7 @@
 # Copyright: See the LICENSE file.
 
+import collections
+import datetime
 import random
 import unittest
 
@@ -17,6 +19,16 @@ class MockFaker:
         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
@@ -30,10 +42,15 @@ class FakerTests(unittest.TestCase):
             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:
@@ -114,3 +131,40 @@ class FakerTests(unittest.TestCase):
         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)