freezegun icon indicating copy to clipboard operation
freezegun copied to clipboard

How to freeze datetime for a dataclass field

Open GlenNicholls opened this issue 2 years ago • 4 comments

I'm not sure if I'm doing something wrong here, but this is a dataclass I haven't been able to freeze time for:

import datetime
from dataclasses import dataclass, field

@dataclass
class A:
  timestamp: datetime.datetime = field(default_factory=datetime.datetime.now)

Now, I am trying to freeze time using the following pytest

import datetime
from freezegun import freeze_time

@freeze_time("2022-01-14")
def test_A():
  a = A()
  assert a.timestamp == datetime.datetime(2022,1,14)

This fails because a.timestamp is not frozen. However, if I change my dataclass like so, it seems to work fine:

@dataclass
class A:
  timestamp: Optional[datetime.datetime] = None

  def __post_init__():
    if self.timestamp is None:
      self.timestamp = datetime.datetime.now()

Any suggestions about how to fix this?

GlenNicholls avatar Apr 04 '22 21:04 GlenNicholls

The reason this is occurring I believe has to do with the way default_factory works.

When the python file is first imported, and before freezegun would have touched it, default_factory is bound to datetime.datetime.now. Hence, freezegun's patch won't catch a usage like that because the default_factory is already bound. The reason your __post_init__ examples works correctly is because freezegun will have already patched datetime.datetime.now at the module level, and the calling code will reference the imported module with freezegun's changes applied to it.

As for how to fix it, I don't know. You could try using the time-machine library for that use case. It actually has a note in its README on how it solves problems like this, where datetime-related functions are hidden inside of class level attributes (in this case not exactly, but close enough to use as an example).

MicahLyle avatar Apr 05 '22 02:04 MicahLyle

Thank you for the insightful response, that definitely helps clear up the confusion! It looks like time-machine fixes the issue I was seeing and I'm able to use field(default_factory=datetime.now).

GlenNicholls avatar Apr 05 '22 21:04 GlenNicholls

One workaround you could apply is;

@dataclass
class A:
  timestamp: datetime.datetime = field(default_factory=lambda: datetime.datetime.now())

By doing this, datetime.datetime.now is not going to be referenced immediately during module load time. Instead, the one level higher order anonymous lambda function will be initialized, then whenever an instance is initialized. datetime.now will be looked up; hence mocking provided by freezegun will work as expected. I think that is simpler than post_init.

hozkok avatar May 24 '22 18:05 hozkok

I frequently have the same issue with SQLAlchemy models.

class Model(Base):
    ...
    created_at = Column(DateTime, default=datetime.utcnow)
    ...

I fix it like this:

def _now():
    return datetime.utcnow()

class Model(Base):
    ...
    created_at = Column(DateTime, default=_now)
    ...

wilbertom avatar Apr 20 '23 11:04 wilbertom