diff --git a/docs/changelog.rst b/docs/changelog.rst index a8588d4c..9e1cd0d8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,22 @@ ChangeLog ========= +4.0.0 (unreleased) +------------------ + +Breaking changes +"""""""""""""""" + +:class:`~factory.django.DjangoModelFactory` no longer calls ``.save()`` on the generated object after +:ref:`post-generation-hooks` ran. Save the model instance in your hooks to maintain the previous behavior. +(:issue:`316`) + +*New:* + - :issue:`316`: :class:`~factory.django.DjangoModelFactory` no longer calls ``.save()`` after + :ref:`post-generation-hooks`. + - :issue:`366`: Add :class:`factory.django.Password` to generate Django :class:`~django.contrib.auth.models.User` passwords. + + 3.1.1 (unreleased) ------------------ diff --git a/docs/orms.rst b/docs/orms.rst index 7d95aaf5..749b6ce1 100644 --- a/docs/orms.rst +++ b/docs/orms.rst @@ -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 diff --git a/docs/reference.rst b/docs/reference.rst index a906acdf..4e98fc1a 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -900,6 +900,36 @@ return value of the method: u'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 """""""" @@ -1588,6 +1618,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 """"""""""""""""""""" diff --git a/factory/__init__.py b/factory/__init__.py index 2f627000..b0ada75d 100644 --- a/factory/__init__.py +++ b/factory/__init__.py @@ -26,6 +26,7 @@ Sequence, SubFactory, Trait, + Transformer, ) from .enums import BUILD_STRATEGY, CREATE_STRATEGY, STUB_STRATEGY from .errors import FactoryError diff --git a/factory/builder.py b/factory/builder.py index 09153f76..4a68bc5b 100644 --- a/factory/builder.py +++ b/factory/builder.py @@ -174,6 +174,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 diff --git a/factory/declarations.py b/factory/declarations.py index e54f6dd8..4123cb49 100644 --- a/factory/declarations.py +++ b/factory/declarations.py @@ -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 diff --git a/factory/django.py b/factory/django.py index 5405be71..2003b431 100644 --- a/factory/django.py +++ b/factory/django.py @@ -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 @@ -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): diff --git a/tests/djapp/models.py b/tests/djapp/models.py index 1373c771..f0553e6d 100644 --- a/tests/djapp/models.py +++ b/tests/djapp/models.py @@ -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) diff --git a/tests/test_declarations.py b/tests/test_declarations.py index 3b9cfd1f..c9458ffe 100644 --- a/tests/test_declarations.py +++ b/tests/test_declarations.py @@ -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 = [] diff --git a/tests/test_django.py b/tests/test_django.py index ad68610d..e9f41f04 100644 --- a/tests/test_django.py +++ b/tests/test_django.py @@ -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 @@ -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 @@ -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): diff --git a/tests/test_transformer.py b/tests/test_transformer.py new file mode 100644 index 00000000..00658454 --- /dev/null +++ b/tests/test_transformer.py @@ -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)