Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce factory.declaration.Transformer #623

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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