Repository: FactoryBoy/factory_boy
Pull request #567 (2019-02-15 03:59:27)

Add support for getter argument in FuzzyChoice
This mimics the behavior of getter in `factory.Iterator` and allows for easy fuzzy choices for Django ChoiceFields.


Patch:
diff --git a/README.rst b/README.rst
index c48d6b9..b8a385c 100644
--- a/README.rst
+++ b/README.rst
@@ -204,8 +204,19 @@ For this, factory_boy relies on the excellent `faker <https://faker.readthedocs.
     <User: Lucy Murray>
 
 
-.. note:: Use of fully randomized data in tests is quickly a problem for reproducing broken builds.
-          To that purpose, factory_boy provides helpers to handle the random seeds it uses.
+Reproducible random values
+""""""""""""""""""""""""""
+
+The use of fully randomized data in tests is quickly a problem for reproducing broken builds.
+To that purpose, factory_boy provides helpers to handle the random seeds it uses, located in the ``factory.random`` module:
+
+.. code-block:: python
+
+    import factory.random
+
+    def setup_test_environment():
+        factory.random.reseed_random('my_awesome_project')
+        # Other setup here
 
 
 Lazy Attributes
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 09f4f83..9cc90d5 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -4,8 +4,12 @@ ChangeLog
 2.11.2 (unreleased)
 -------------------
 
-- Added support for Python 3.7
-- Added support for Django 2.1
+*New:*
+
+    - Added support for Python 3.7
+    - Added support for Django 2.1
+    - Add :attr:`~factory.fuzzy.FuzzyChoice.getter` to :class:`~factory.fuzzy.FuzzyChoice` that mimics
+      the behavior of ``getter`` in :class:`~factory.Iterator`
 
 
 2.11.1 (2018-05-05)
diff --git a/docs/fuzzy.rst b/docs/fuzzy.rst
index 987d2b9..34a174a 100644
--- a/docs/fuzzy.rst
+++ b/docs/fuzzy.rst
@@ -359,32 +359,9 @@ They should inherit from the :class:`BaseFuzzyAttribute` class, and override its
         The method responsible for generating random values.
         *Must* be overridden in subclasses.
 
+    .. warning::
 
-Managing randomness
--------------------
-
-Using :mod:`random` in factories allows to "fuzz" a program efficiently.
-However, it's sometimes required to *reproduce* a failing test.
-
-:mod:`factory.fuzzy` uses a separate instance of :class:`random.Random`,
-and provides a few helpers for this:
-
-.. method:: get_random_state()
-
-    Call :meth:`get_random_state` to retrieve the random generator's current
-    state.
-
-.. method:: set_random_state(state)
-
-    Use :meth:`set_random_state` to set a custom state into the random generator
-    (fetched from :meth:`get_random_state` in a previous run, for instance)
-
-.. method:: reseed_random(seed)
-
-    The :meth:`reseed_random` function allows to load a chosen seed into the random generator.
-
-
-Custom :class:`BaseFuzzyAttribute` subclasses **SHOULD**
-use :obj:`factory.fuzzy._random` as a randomness source; this ensures that
-data they generate can be regenerated using the simple state from
-:meth:`get_random_state`.
+        Custom :class:`BaseFuzzyAttribute` subclasses **MUST**
+        use :obj:`factory.fuzzy._random` as a randomness source; this ensures that
+        data they generate can be regenerated using the simple state from
+        :meth:`get_random_state`.
diff --git a/docs/recipes.rst b/docs/recipes.rst
index 8663158..d278bfa 100644
--- a/docs/recipes.rst
+++ b/docs/recipes.rst
@@ -455,6 +455,56 @@ Forcing the initial value for all projects
         >>> AccountFactory.create()  # Sets up the account number based on the latest uid
         <Account uid=43, name=Test>
 
+.. _recipe-random-management:
+
+Using reproducible randomness
+-----------------------------
+
+Although using random values is great, it can provoke test flakyness.
+factory_boy provides a few helpers for this.
+
+.. note:: Those methods will seed the random engine used in both :class:`factory.Faker` and :mod:`factory.fuzzy` objects.
+
+
+Seeding the random engine
+    The simplest way to manage randomness is to push a selected seed when starting tests:
+
+    .. code-block:: python
+
+        import factory.random
+        # Pass in any value
+        factory.random.reseed_random('my awesome project')
+
+
+Reproducing unseeded tests
+    A project might choose not to use an explicit random seed (for better fuzzing),
+    but still wishes to have reproducible tests.
+
+    For such cases, use a combination of :meth:`factory.random.get_random_state()`
+    and :meth:`factory.random.set_random_state()`.
+
+    Since the random state structure is implementation-specific, we recommand passing it around
+    as a base64-encoded pickle dump.
+
+    .. code-block:: python
+
+        class MyTestRunner:
+
+            def setup_test_environment(self):
+                state = os.environ.get('TEST_RANDOM_STATE')
+                if state:
+                    try:
+                        decoded_state = pickle.loads(base64.b64decode(state.encode('ascii')))
+                    except ValueError:
+                        decoded_state = None
+                if decoded_state:
+                    factory.random.set_random_state(decoded_state)
+                else:
+                    encoded_state = base64.b64encode(pickle.dumps(factory.random.get_random_state()))
+                    print("Current random state: %s" % encoded_state.decode('ascii'))
+                super().setup_test_environment()
+
+
 
 Converting a factory's output to a dict
 ---------------------------------------
@@ -479,13 +529,31 @@ In order to get a dict, we'll just have to swap the model; the easiest way is to
             model = models.User
 
         first_name = factory.Sequence(lambda n: "Agent %03d" % n)
-        username = factory.Faker('username')
+        username = factory.Faker('user_name')
 
 .. code-block:: pycon
 
     >>> factory.build(dict, FACTORY_CLASS=UserFactory)
     {'first_name': "Agent 001", 'username': 'john_doe'}
 
+
+Fuzzying Django model field choices
+-----------------------------------
+
+When defining a :class:`~factory.fuzzy.FuzzyChoice` you can reuse the same choice list from the model field descriptor.
+
+Use the ``getter`` kwarg to select the first element from each choice tuple.
+
+.. code-block:: python
+
+    class UserFactory(factory.Factory):
+        class Meta:
+            model = User
+
+        # CATEGORY_CHOICES is a list of (key, title) tuples
+        category = factory.fuzzy.FuzzyChoice(User.CATEGORY_CHOICES, getter=lambda c: c[0])
+
+
 Django models with `GenericForeignKeys`
 ---------------------------------------
 
diff --git a/docs/reference.rst b/docs/reference.rst
index 0ee461e..7909c65 100644
--- a/docs/reference.rst
+++ b/docs/reference.rst
@@ -311,6 +311,7 @@ Attributes and methods
                 class Meta:
                     abstract = True  # Optional
 
+                @classmethod
                 def _create(cls, model_class, *args, **kwargs):
                     obj = model_class(*args, **kwargs)
                     obj.save()
@@ -1765,7 +1766,7 @@ Decorator
 .. function:: post_generation
 
 A decorator is also provided, decorating a single method accepting the same
-``obj``, ``created``, ``extracted`` and keyword arguments as :class:`PostGeneration`.
+``obj``, ``create``, ``extracted`` and keyword arguments as :class:`PostGeneration`.
 
 
 .. code-block:: python
@@ -1777,7 +1778,7 @@ A decorator is also provided, decorating a single method accepting the same
         login = 'john'
 
         @factory.post_generation
-        def mbox(self, create, extracted, **kwargs):
+        def mbox(obj, create, extracted, **kwargs):
             if not create:
                 return
             path = extracted or os.path.join('/tmp/mbox/', self.login)
@@ -2036,3 +2037,33 @@ extracting instances from them:
     :param int size: Number of instances to generate
     :param kwargs: Declarations to use for the generated factory
     :param FACTORY_CLASS: Alternate base class (instead of :class:`Factory`)
+
+
+Randomness management
+---------------------
+
+.. currentmodule:: factory.random
+
+Using :mod:`random` in factories allows to "fuzz" a program efficiently.
+However, it's sometimes required to *reproduce* a failing test.
+
+:mod:`factory.fuzzy` and :class:`factory.Faker` share a dedicated instance
+of :class:`random.Random`, which can be managed through the :mod:`factory.random` module:
+
+.. method:: get_random_state()
+
+    Call :meth:`get_random_state` to retrieve the random generator's current
+    state.
+    The returned object is implementation-specific.
+
+.. method:: set_random_state(state)
+
+    Use :meth:`set_random_state` to set a custom state into the random generator
+    (fetched from :meth:`get_random_state` in a previous run, for instance)
+
+.. method:: reseed_random(seed)
+
+    The :meth:`reseed_random` function allows to load a chosen seed into the random generator.
+    That seed can be anything accepted by :meth:`random.seed`.
+
+See :ref:`recipe-random-management` for help in using those methods in a test setup.
diff --git a/factory/base.py b/factory/base.py
index ab89db3..145e518 100644
--- a/factory/base.py
+++ b/factory/base.py
@@ -709,7 +709,7 @@ class BaseDictFactory(Factory):
     def _build(cls, model_class, *args, **kwargs):
         if args:
             raise ValueError(
-                "DictFactory %r does not support Meta.inline_args.", cls)
+                "DictFactory %r does not support Meta.inline_args." % cls)
         return model_class(**kwargs)
 
     @classmethod
@@ -731,9 +731,12 @@ class BaseListFactory(Factory):
     def _build(cls, model_class, *args, **kwargs):
         if args:
             raise ValueError(
-                "ListFactory %r does not support Meta.inline_args.", cls)
+                "ListFactory %r does not support Meta.inline_args." % cls)
 
-        values = [v for k, v in sorted(kwargs.items())]
+        # When support for Python <3.6 is dropped sorting will no longer be required
+        # because dictionaries will already be ordered, this can then be changed to:
+        # values = kwargs.values()
+        values = [v for k, v in sorted(kwargs.items(), key=lambda item: int(item[0]))]
         return model_class(values)
 
     @classmethod
diff --git a/factory/fuzzy.py b/factory/fuzzy.py
index dfd962c..ccb95e4 100644
--- a/factory/fuzzy.py
+++ b/factory/fuzzy.py
@@ -116,17 +116,22 @@ class FuzzyChoice(BaseFuzzyAttribute):
     Args:
         choices (iterable): An iterable yielding options; will only be unrolled
             on the first call.
+        getter (callable or None): a function to parse returned values
     """
 
-    def __init__(self, choices, **kwargs):
+    def __init__(self, choices, getter=None, **kwargs):
         self.choices = None
         self.choices_generator = choices
+        self.getter = getter
         super(FuzzyChoice, self).__init__(**kwargs)
 
     def fuzz(self):
         if self.choices is None:
             self.choices = list(self.choices_generator)
-        return random.randgen.choice(self.choices)
+        value = random.randgen.choice(self.choices)
+        if self.getter is None:
+            return value
+        return self.getter(value)
 
 
 class FuzzyInteger(BaseFuzzyAttribute):


Test patch:
diff --git a/tests/test_fuzzy.py b/tests/test_fuzzy.py
index 65426f2..10416c2 100644
--- a/tests/test_fuzzy.py
+++ b/tests/test_fuzzy.py
@@ -76,6 +76,12 @@ class FuzzyChoiceTestCase(unittest.TestCase):
         self.assertIn(res, [1, 2, 3])
         self.assertTrue(opts.unrolled)
 
+    def test_getter(self):
+        options = [('a', 1), ('b', 2), ('c', 3)]
+        d = fuzzy.FuzzyChoice(options, getter=lambda x: x[1])
+        res = utils.evaluate_declaration(d)
+        self.assertIn(res, [1, 2, 3])
+
 
 class FuzzyIntegerTestCase(unittest.TestCase):
     def test_definition(self):
diff --git a/tests/test_using.py b/tests/test_using.py
index f3d8f03..1480922 100644
--- a/tests/test_using.py
+++ b/tests/test_using.py
@@ -2725,6 +2725,15 @@ class ListTestCase(unittest.TestCase):
         o = TestObjectFactory()
         self.assertEqual([1], o.one)
 
+    def test_long_list(self):
+        class TestObjectFactory(factory.Factory):
+            class Meta:
+                model = TestObject
+            one = factory.List(list(range(100)))
+
+        o = TestObjectFactory()
+        self.assertEqual(list(range(100)), o.one)
+
     def test_sequence_list(self):
         class TestObjectFactory(factory.Factory):
             class Meta: