Skip to content

Commit

Permalink
Introduce factory.declaration.Transformer
Browse files Browse the repository at this point in the history
Transforms a value using provided `transform` function. Values coming
from the declaration and values overridden through keywords arguments
are transformed before the generated object attribute is set.

Removes the need to save objects with a post generation hook twice to
the database.

Facilitates overriding Django passwords when instantiating the factory.

Fixes FactoryBoy#316
Fixes FactoryBoy#366
  • Loading branch information
francoisfreitag committed Feb 12, 2021
1 parent ec4211a commit 6003305
Show file tree
Hide file tree
Showing 11 changed files with 250 additions and 4 deletions.
28 changes: 26 additions & 2 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,35 @@ ChangeLog

.. Note for v4.x: don't forget to check "Deprecated" sections for removal.
3.2.1 (unreleased)
3.3.0 (unreleased)
------------------

- Nothing changed yet.
*New:*

- :issue:`366`: Add :class:`factory.django.Password` to generate Django :class:`~django.contrib.auth.models.User`
passwords.

*Deprecated:*

- :class:`~factory.django.DjangoModelFactory` will stop issuing a second call to
:meth:`~django.db.models.Model.save` on the created instance when :ref:`post-generation-hooks` return a value.

To help with the transition, :class:`factory.django.DjangoModelFactory._after_postgeneration` raises a
:class:`DeprecationWarning` when calling :meth:`~django.db.models.Model.save`. Inspect your
:class:`~factory.django.DjangoModelFactory` subclasses:

- If the :meth:`~django.db.models.Model.save` call is not needed after :class:`~factory.PostGeneration`, set
:attr:`factory.django.DjangoOptions.skip_postgeneration_save` to ``True`` in the factory meta.

- Otherwise, the instance has been modified by :class:`~factory.PostGeneration` hooks and needs to be
:meth:`~django.db.models.Model.save`\ d. Either:

- call :meth:`django.db.models.Model.save` in the :class:`~factory.PostGeneration` hook that modifies the
instance, or
- override :class:`~factory.django.DjangoModelFactory._after_postgeneration` to
:meth:`~django.db.models.Model.save` the instance.

*Removed:*

3.2.0 (2020-12-28)
------------------
Expand Down
37 changes: 37 additions & 0 deletions docs/orms.rst
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,47 @@ All factories for a Django :class:`~django.db.models.Model` should use the
>>> john.email # The email value was not updated
"john@example.com"
.. attribute:: skip_postgeneration_save

Transitional option to prevent
:meth:`~factory.django.DjangoModelFactory._after_postgeneration` from
issuing a duplicate call to :meth:`~django.db.models.Model.save` on the
created instance when :class:`factory.PostGeneration` hooks return a
value.


Extra fields
""""""""""""

.. class:: Password

Applies :func:`~django.contrib.auth.hashers.make_password` to the
clear-text argument before to generate the object.

.. method:: __init__(self, password)

:param str password: Default password.

.. code-block:: python
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.User
password = factory.django.Password('pw')
.. code-block:: pycon
>>> from django.contrib.auth.hashers import check_password
>>> # Create user with the default password from the factory.
>>> user = UserFactory.create()
>>> check_password('pw', user.password)
True
>>> # Override user password at call time.
>>> other_user = UserFactory.create(password='other_pw')
>>> check_password('other_pw', other_user.password)
True
.. class:: FileField

Expand Down
31 changes: 31 additions & 0 deletions docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,36 @@ return value of the method:
'joel@example.com'
Transformer
"""""""""""

.. class:: Transformer(transform, value)

A :class:`Transformer` applies a ``transform`` function to the provided value
before to set the transformed value on the generated object.

It expects two arguments:

- ``transform``: function taking the value as parameter and returning the
transformed value,
- ``value``: the default value.

.. code-block:: python
class UpperFactory(Factory):
name = Transformer(lambda x: x.upper(), "Joe")
class Meta:
model = Upper
.. code-block:: pycon
>>> UpperFactory().name
'JOE'
>>> UpperFactory(name="John").name
'JOHN'
Sequence
""""""""

Expand Down Expand Up @@ -1592,6 +1622,7 @@ apply the effects of one or the other declaration:
defined in the :attr:`~Factory.Params` section of your factory to
handle the computation.

.. _post-generation-hooks:

Post-generation hooks
"""""""""""""""""""""
Expand Down
1 change: 1 addition & 0 deletions factory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
Sequence,
SubFactory,
Trait,
Transformer,
)
from .enums import BUILD_STRATEGY, CREATE_STRATEGY, STUB_STRATEGY
from .errors import FactoryError
Expand Down
6 changes: 5 additions & 1 deletion factory/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import collections

from . import enums, errors, utils
from . import declarations, enums, errors, utils

DeclarationWithContext = collections.namedtuple(
'DeclarationWithContext',
Expand Down Expand Up @@ -156,6 +156,10 @@ def parse_declarations(decls, base_pre=None, base_post=None):
# Set it as `key__`
magic_key = post_declarations.join(k, '')
extra_post[magic_key] = v
elif k in pre_declarations and isinstance(
pre_declarations[k].declaration, declarations.Transformer
):
extra_maybenonpost[k] = pre_declarations[k].declaration.function(v)
else:
extra_maybenonpost[k] = v

Expand Down
16 changes: 16 additions & 0 deletions factory/declarations.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,22 @@ def evaluate(self, instance, step, extra):
return self.function(instance)


class Transformer(LazyFunction):
"""Transform value using given function.
Attributes:
transform (function): returns the transformed value.
value: passed as the first argument to the transform function.
"""

def __init__(self, transform, value, *args, **kwargs):
super().__init__(transform, *args, **kwargs)
self.value = value

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


class _UNSPECIFIED:
pass

Expand Down
20 changes: 19 additions & 1 deletion factory/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
import io
import logging
import os
import warnings

from django.contrib.auth.hashers import make_password
from django.core import files as django_files
from django.db import IntegrityError

Expand Down Expand Up @@ -47,6 +49,7 @@ def _build_default_options(self):
return super()._build_default_options() + [
base.OptionDefault('django_get_or_create', (), inherit=True),
base.OptionDefault('database', DEFAULT_DB_ALIAS, inherit=True),
base.OptionDefault('skip_postgeneration_save', False, inherit=True),
]

def _get_counter_reference(self):
Expand Down Expand Up @@ -165,14 +168,29 @@ def _create(cls, model_class, *args, **kwargs):
manager = cls._get_manager(model_class)
return manager.create(*args, **kwargs)

# DEPRECATED. Remove this override with the next major release.
@classmethod
def _after_postgeneration(cls, instance, create, results=None):
"""Save again the instance if creating and at least one hook ran."""
if create and results:
if create and results and not cls._meta.skip_postgeneration_save:
warnings.warn(
f"{cls.__name__}._after_postgeneration will stop saving the instance "
"after postgeneration hooks in the next major release.\n"
"If the save call is extraneous, set skip_postgeneration_save=True "
f"in the {cls.__name__}.Meta.\n"
"To keep saving the instance, move the save call to your "
"postgeneration hooks or override _after_postgeneration.",
DeprecationWarning,
)
# Some post-generation hooks ran, and may have modified us.
instance.save()


class Password(declarations.Transformer):
def __init__(self, password, *args, **kwargs):
super().__init__(make_password, password, *args, **kwargs)


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

Expand Down
4 changes: 4 additions & 0 deletions tests/djapp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ class WithDefaultValue(models.Model):
foo = models.CharField(max_length=20, default='')


class WithPassword(models.Model):
pw = models.CharField(max_length=128)


WITHFILE_UPLOAD_TO = 'django'
WITHFILE_UPLOAD_DIR = os.path.join(settings.MEDIA_ROOT, WITHFILE_UPLOAD_TO)

Expand Down
6 changes: 6 additions & 0 deletions tests/test_declarations.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ def test_getter(self):
self.assertEqual(3, utils.evaluate_declaration(it, force_sequence=3))


class TransformerTestCase(unittest.TestCase):
def test_transform(self):
t = declarations.Transformer(lambda x: x.upper(), 'foo')
self.assertEqual("FOO", utils.evaluate_declaration(t))


class PostGenerationDeclarationTestCase(unittest.TestCase):
def test_post_generation(self):
call_params = []
Expand Down
59 changes: 59 additions & 0 deletions tests/test_django.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import django
from django import test as django_test
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.db.models import signals
from django.test import utils as django_test_utils

Expand Down Expand Up @@ -97,6 +98,16 @@ class Meta:
model = models.ConcreteGrandSon


PASSWORD = 's0_s3cr3t'


class WithPasswordFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.WithPassword

pw = factory.django.Password(password=PASSWORD)


class WithFileFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.WithFile
Expand Down Expand Up @@ -414,6 +425,9 @@ class PointedRelatedFactory(PointedFactory):
factory_related_name='pointed',
)

class Meta:
skip_postgeneration_save = True

class PointerExtraFactory(PointerFactory):
pointed__foo = 'extra_new_foo'

Expand All @@ -430,6 +444,9 @@ class Params:
)
)

class Meta:
skip_postgeneration_save = True

cls.PointedFactory = PointedFactory
cls.PointerFactory = PointerFactory
cls.PointedRelatedFactory = PointedRelatedFactory
Expand Down Expand Up @@ -492,6 +509,21 @@ def test_create_pointed_related_with_trait(self):
self.assertEqual(pointed.pointer.bar, 'with_trait')


class DjangoPasswordTestCase(django_test.TestCase):
def test_build(self):
u = WithPasswordFactory.build()
self.assertTrue(check_password(PASSWORD, u.pw))

def test_build_with_kwargs(self):
password = 'V3R¥.S€C®€T'
u = WithPasswordFactory.build(pw=password)
self.assertTrue(check_password(password, u.pw))

def test_create(self):
u = WithPasswordFactory.create()
self.assertTrue(check_password(PASSWORD, u.pw))


class DjangoFileFieldTestCase(django_test.TestCase):

def tearDown(self):
Expand Down Expand Up @@ -909,6 +941,7 @@ def test_class_decorator_with_subfactory(self):
class WithSignalsDecoratedFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.WithSignals
skip_postgeneration_save = True

@factory.post_generation
def post(obj, create, extracted, **kwargs):
Expand Down Expand Up @@ -995,6 +1028,7 @@ def test_class_decorator_with_muted_related_factory(self):
class UndecoratedFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.PointerModel
skip_postgeneration_save = True
pointed = factory.RelatedFactory(self.WithSignalsDecoratedFactory)

UndecoratedFactory()
Expand All @@ -1015,3 +1049,28 @@ class Meta:
# Our CustomManager will remove the 'arg=' argument,
# invalid for the actual model.
ObjFactory.create(arg='invalid')


class DjangoModelFactoryDuplicateSaveDeprecationTest(django_test.TestCase):
class StandardFactoryWithPost(StandardFactory):
@factory.post_generation
def post_action(obj, create, extracted, **kwargs):
return 3

def test_create_warning(self):
with self.assertWarns(DeprecationWarning) as cm:
self.StandardFactoryWithPost.create()

[msg] = cm.warning.args
self.assertEqual(
msg,
"StandardFactoryWithPost._after_postgeneration will stop saving the "
"instance after postgeneration hooks in the next major release.\n"
"If the save call is extraneous, set skip_postgeneration_save=True in the "
"StandardFactoryWithPost.Meta.\n"
"To keep saving the instance, move the save call to your postgeneration "
"hooks or override _after_postgeneration.",
)

def test_build_no_warning(self):
self.StandardFactoryWithPost.build()
Loading

0 comments on commit 6003305

Please sign in to comment.