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 #316
Fixes #366
  • Loading branch information
francoisfreitag committed Dec 4, 2020
1 parent bacfd73 commit 1535959
Show file tree
Hide file tree
Showing 11 changed files with 186 additions and 7 deletions.
17 changes: 17 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ ChangeLog
4.0.0 (unreleased)
------------------

Breaking changes
""""""""""""""""

:class:`~factory.django.DjangoModelFactory` no longer issues a second call to :meth:`~django.db.models.Model.save` on
the created instance when :ref:`post-generation-hooks` return a value. Save the model instance in the
:class:`~factory.PostGeneration` hooks, or override :meth:`factory.django.DjangoModelFactory._after_postgeneration` to
retain the previous behavior.

*New:*

- :issue:`316`: :class:`~factory.django.DjangoModelFactory` no longer calls :meth:`~django.db.models.Model.save()`
after :ref:`post-generation-hooks`.

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

*Removed:*

- :func:`factory.use_strategy()`
Expand All @@ -19,6 +35,7 @@ ChangeLog

:func:`~factory.use_strategy()` will be removed in the next major version.


3.1.1 (unreleased)
------------------

Expand Down
29 changes: 29 additions & 0 deletions docs/orms.rst
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,35 @@ All factories for a Django :class:`~django.db.models.Model` should use the
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 @@ -162,6 +162,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 @@ -87,6 +87,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
11 changes: 5 additions & 6 deletions factory/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import logging
import os

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 @@ -165,12 +166,10 @@ def _create(cls, model_class, *args, **kwargs):
manager = cls._get_manager(model_class)
return manager.create(*args, **kwargs)

@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:
# 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.ParameteredDeclaration):
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
26 changes: 26 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 @@ -492,6 +503,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
46 changes: 46 additions & 0 deletions tests/test_transformer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Copyright: See the LICENSE file.

from unittest import TestCase

from factory import Factory, Transformer


class TransformCounter:
calls_count = 0

@classmethod
def __call__(cls, x):
cls.calls_count += 1
return x.upper()

@classmethod
def reset(cls):
cls.calls_count = 0


transform = TransformCounter()


class Upper:
def __init__(self, name):
self.name = name


class UpperFactory(Factory):
name = Transformer(transform, "value")

class Meta:
model = Upper


class TransformerTest(TestCase):
def setUp(self):
transform.reset()

def test_transform_count(self):
self.assertEqual("VALUE", UpperFactory().name)
self.assertEqual(transform.calls_count, 1)

def test_transform_kwarg(self):
self.assertEqual("TEST", UpperFactory(name="test").name)
self.assertEqual(transform.calls_count, 1)

0 comments on commit 1535959

Please sign in to comment.