FactoryBoy / factory_boy

A test fixtures replacement for Python
https://factoryboy.readthedocs.io/
MIT License
3.49k stars 392 forks source link

Iterators and lazy evaluation #344

Open hetsch opened 7 years ago

hetsch commented 7 years ago

I'm currently using the freezegun package and factoryboy together in my unittests, what really worked well until I hit a problem with lazy evaluating timezone.now in combination with @factory.iterator. As far as I understood, the values of iterator fields are evaluated, when the factory class is imported/loaded by another module. This is kind of problematic for me, because I would like to freeze the (system) time so that all the model instances and their datefields (depending on timezone.now) have the exact same timestamp when they are created. Usually I have code like this:

now = timezone.now()

with freeze_time(now):
    # all have model instances and their datetime fields
    # receive the exact same datetime value of "now"
    models = factories.DummyFactory.create_batch(4)

I have a model factory with two iterator fields that should generate four different datetime ranges, similar to this:

# 1st: ranges from past to future
# 2nd: ranges from past to now
# 3rd: ranges from past to past
# 4th: ranges from future to future

@factory.iterator
def starts_on():
    now = timezone.now()
    dates = (
        now - timedelta(days=3),
        now - timedelta(days=3),
        now - timedelta(days=4),
        now + timedelta(days=1)
    )
    for date in dates:
        yield date

@factory.iterator
def ends_on():
    now = timezone.now()
    dates = (
        now + timedelta(days=3),
        now,
        now - timedelta(days=1),
        now + timedelta(days=4)
    )
    for date in dates:
        yield date

The problem is, that now get's evaluated when the whole class is loaded and not when the objects are generated. My question would be if there is any chance to trick the iterator, to actually evaluate now a little bit later, then when the model instances are´ generated by calling create_bulk. I've tried lambda: timezone.now and friends but no luck so far. What I have is this ugly hack based on a reference date (unix epoch) that i can later on (in _generate) replace with the actual timestamp:

@factory.iterator
def starts_on():
    # Take the unix epoch as base and replace it in _generate with the value of "now"
    dates = (
        datetime(1970, 1, 1) - timedelta(days=3),
        datetime(1970, 1, 1) - timedelta(days=3),
        datetime(1970, 1, 1) - timedelta(days=4),
        datetime(1970, 1, 1) + timedelta(days=1)
    )
    for date in dates:
        yield date

@factory.iterator
def ends_on():
    dates = (
        datetime(1970, 1, 1) + timedelta(days=3),
        datetime(1970, 1, 1) + timedelta(days=0),
        datetime(1970, 1, 1) - timedelta(days=1),
        datetime(1970, 1, 1) + timedelta(days=4)
    )
    for date in dates:
        yield date

@classmethod
def _generate(cls, create, attrs):
    unix = datetime(1970, 1, 1)
    # timezone.now() has receives now the datetime that is set by freeze_time(now)
    now = timezone.now()

    # Evaluating the timedelta of starts_on
    starts_on = attrs['starts_on']
    if starts_on < unix:
        starts_on = now - (unix - starts_on)
    else:
        starts_on = now + (starts_on - unix)

    # Evaluating the timedelta of starts_on
    ends_on = attrs['ends_on']
    if ends_on < unix:
        ends_on = now - (unix - ends_on)
    else:
        ends_on = now + (ends_on - unix)

    attrs['starts_on'] = starts_on
    attrs['ends_on'] = ends_on

    return super()._generate(create, attrs)

Sorry for the long post, I hope it is somewhat clear what I mean. I hope you can save me from this wild hocus-pocus.

rbarrois commented 7 years ago

This looks definitely like a bug; it should be caught by https://github.com/FactoryBoy/factory_boy/blob/master/tests/test_using.py#L1652.

If you replace the call to timezone.now() in your code by some "raise ValueError()", does it break during the test loading? Or later?

An option would be that some other factory is calling this iterator before entering your freezegun section; another would be related to the automated cycling of iterators by factory_boy — which reuse the values from the first run on subsequent calls.

hetsch commented 7 years ago

I think it breaks when creating the models through the factory.

@factory.iterator
def starts_on():
    raise ValueError()
    dates = (
        datetime(1970, 1, 1) - timedelta(days=3),
        datetime(1970, 1, 1) - timedelta(days=3),
        datetime(1970, 1, 1) - timedelta(days=4),
        datetime(1970, 1, 1) + timedelta(days=1)
    )
    for date in dates:
        yield date

give me the following error:

Test session starts (platform: darwin, Python 3.6.0, pytest 3.0.6, pytest-sugar 0.8.0)
rootdir: /Volumes/Data/Users/hetsch/Documents/twoshoes-core, inifile: setup.cfg
plugins: xdist-1.15.0, sugar-0.8.0, django-3.1.2, cov-2.4.0

―――――――――――――――――――――――――――――――――― ERROR at setup of TestTimeFramedMixin.test_defaults ―――――――――――――――――――――――――――――――――――
Traceback (most recent call last):
File "/Volumes/Data/Users/hetsch/Documents/twoshoes-core/tests/tests/test_models.py", line 179, in models
    models = factories.TimeFramedMixinFactory.create_batch(4)
File "/usr/local/var/pyenv/versions/3.6.0/envs/core-py3.6.0/lib/python3.6/site-packages/factory/base.py", line 635, in create_batch
    return [cls.create(**kwargs) for _ in range(size)]
File "/usr/local/var/pyenv/versions/3.6.0/envs/core-py3.6.0/lib/python3.6/site-packages/factory/base.py", line 635, in <listcomp>
    return [cls.create(**kwargs) for _ in range(size)]
File "/usr/local/var/pyenv/versions/3.6.0/envs/core-py3.6.0/lib/python3.6/site-packages/factory/base.py", line 622, in create
    attrs = cls.attributes(create=True, extra=kwargs)
File "/usr/local/var/pyenv/versions/3.6.0/envs/core-py3.6.0/lib/python3.6/site-packages/factory/base.py", line 453, in attributes
    force_sequence=force_sequence,
File "/usr/local/var/pyenv/versions/3.6.0/envs/core-py3.6.0/lib/python3.6/site-packages/factory/containers.py", line 324, in build
    return stub.__fill__()
File "/usr/local/var/pyenv/versions/3.6.0/envs/core-py3.6.0/lib/python3.6/site-packages/factory/containers.py", line 83, in __fill__
    res[attr] = getattr(self, attr)
File "/usr/local/var/pyenv/versions/3.6.0/envs/core-py3.6.0/lib/python3.6/site-packages/factory/containers.py", line 108, in __getattr__
    val = val.evaluate(self, self.__containers)
File "/usr/local/var/pyenv/versions/3.6.0/envs/core-py3.6.0/lib/python3.6/site-packages/factory/containers.py", line 227, in evaluate
    containers=containers,
File "/usr/local/var/pyenv/versions/3.6.0/envs/core-py3.6.0/lib/python3.6/site-packages/factory/declarations.py", line 195, in evaluate
    value = next(iter(self.iterator))
File "/usr/local/var/pyenv/versions/3.6.0/envs/core-py3.6.0/lib/python3.6/site-packages/factory/utils.py", line 158, in __iter__
    value = next(self.iterator)
File "/Volumes/Data/Users/hetsch/Documents/twoshoes-core/tests/factories.py", line 149, in starts_on
    raise ValueError()
ValueError
                                                                                                            25% ██▌       

―――――――――――――――――――――――――――――――――――― ERROR at setup of TestTimeFramedMixin.test_start ――――――――――――――――――――――――――――――――――――
Traceback (most recent call last):
File "/Volumes/Data/Users/hetsch/Documents/twoshoes-core/tests/tests/test_models.py", line 179, in models
    models = factories.TimeFramedMixinFactory.create_batch(4)
File "/usr/local/var/pyenv/versions/3.6.0/envs/core-py3.6.0/lib/python3.6/site-packages/factory/base.py", line 635, in create_batch
    return [cls.create(**kwargs) for _ in range(size)]
File "/usr/local/var/pyenv/versions/3.6.0/envs/core-py3.6.0/lib/python3.6/site-packages/factory/base.py", line 635, in <listcomp>
    return [cls.create(**kwargs) for _ in range(size)]
File "/usr/local/var/pyenv/versions/3.6.0/envs/core-py3.6.0/lib/python3.6/site-packages/factory/base.py", line 622, in create
    attrs = cls.attributes(create=True, extra=kwargs)
File "/usr/local/var/pyenv/versions/3.6.0/envs/core-py3.6.0/lib/python3.6/site-packages/factory/base.py", line 453, in attributes
    force_sequence=force_sequence,
File "/usr/local/var/pyenv/versions/3.6.0/envs/core-py3.6.0/lib/python3.6/site-packages/factory/containers.py", line 324, in build
    return stub.__fill__()
File "/usr/local/var/pyenv/versions/3.6.0/envs/core-py3.6.0/lib/python3.6/site-packages/factory/containers.py", line 83, in __fill__
    res[attr] = getattr(self, attr)
File "/usr/local/var/pyenv/versions/3.6.0/envs/core-py3.6.0/lib/python3.6/site-packages/factory/containers.py", line 108, in __getattr__
    val = val.evaluate(self, self.__containers)
File "/usr/local/var/pyenv/versions/3.6.0/envs/core-py3.6.0/lib/python3.6/site-packages/factory/containers.py", line 227, in evaluate
    containers=containers,
File "/usr/local/var/pyenv/versions/3.6.0/envs/core-py3.6.0/lib/python3.6/site-packages/factory/declarations.py", line 195, in evaluate
    value = next(iter(self.iterator))
File "/usr/local/var/pyenv/versions/3.6.0/envs/core-py3.6.0/lib/python3.6/site-packages/factory/utils.py", line 158, in __iter__
    value = next(self.iterator)
StopIteration
rbarrois commented 7 years ago

@hetsch OK, found it — see the related commit. Basically, factory_boy saves values from the iterator on the first "run", and reuse them afterwards.

This is required, since Python doesn't allow to rewind generators...

For your case, an option would be the following. Alternatively, we could try to change the behavior of factory.iterator to be less surprising :)


def unrolled_iterator(func):
    return factory.Iterator(func(), cycle=False)

@unrolled_iterator
def starts_on():
    while True:
        now = timezone.now()
        dates = (
            now - timedelta(days=3),
            now - timedelta(days=3),
            now - timedelta(days=4),
            now + timedelta(days=1)
        )
        for date in dates:
            yield date

@unrolled_iterator
def ends_on():
    while True:
        now = timezone.now()
        dates = (
            now + timedelta(days=3),
            now,
            now - timedelta(days=1),
            now + timedelta(days=4)
        )
        for date in dates:
            yield date