From 96c2741b3c41133cb06a45d82b10a9c3c18cdb55 Mon Sep 17 00:00:00 2001 From: Marcin Sulikowski Date: Wed, 14 Sep 2022 18:00:22 +0200 Subject: [PATCH 1/2] Clean up asyncio tests We finish the cleanup of `test_asyncio.py` done in c60dd14b05f09c482d4 and in 204647b305 by removing remnants of support of Pythons with no `asyncio` module because all Python interpreters supported by freezegun already support `asyncio`. --- tests/test_asyncio.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 6f6a1a3a..ad4ae10d 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1,15 +1,10 @@ import asyncio import datetime -from textwrap import dedent -from unittest import SkipTest from freezegun import freeze_time -def test_time_freeze_coroutine(): - if not asyncio: - raise SkipTest('asyncio required') - +def test_datetime_in_coroutine(): @freeze_time('1970-01-01') async def frozen_coroutine(): assert datetime.date.today() == datetime.date(1970, 1, 1) From 89d36e2c1ad8de3ef114856c6c5bb9b14a8c00f7 Mon Sep 17 00:00:00 2001 From: Marcin Sulikowski Date: Wed, 14 Sep 2022 19:33:58 +0200 Subject: [PATCH 2/2] Avoid `asyncio.sleep()` hanging forever when time is frozen The following code: async def test(): with freeze_time("2020-01-01"): await asyncio.sleep(0.01) hangs forever since FreezeGun 1.1.0 because 1.1.0 started patching `time.monotonic()` (see #369) which is used internally by `asyncio` event loops to schedule code for execution in the future. This breaks many projects that uses FreezeGun to test asynchronous code. We fix this by changing `freeze_time` to patch asyncio event loop's `time()` method in a way that it uses real monotonic time instead of the frozen one. Note that we couldn't achieve this by adding `asyncio` to `DEFAULT_IGNORE_LIST` in `freezegun/config.py` because any running async code has functions from the `asyncio` module on its stack -- adding `asyncio` to the ignore list would just disable freezing time in any async code. This is why we patch one method of a specific class instead. This change not only fixes `asyncio.sleep()` but also things like `asyncio.get_running_loop().call_later` (for scheduling task execution in the future) which in turn makes things like timeouts work in async code while time is frozen. This may not be desired because some users may expect that execution of events scheduled to happen in the future can be controlled using FreezeGun. However, it's not easy to distinguish between things that users would like to see frozen time and those which should not (like `asyncio.sleep()`) because all of them use the same clock. Therefore, we opt for making all `asyncio` internals not affected by FreezeGun. We also add more tests that verify how FreezeGun interacts with asyncio code, including tests that cover the scenario described in #437 which we aim to fix. Closes #401 Closes #437 --- AUTHORS.rst | 1 + CHANGELOG | 5 ++++ freezegun/api.py | 20 +++++++++++-- tests/test_asyncio.py | 68 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 41c446b9..62e49627 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -20,3 +20,4 @@ Patches and Suggestions - `Lukasz Balcerzak `_ - `Hannes Ljungberg `_ - `staticdev `_ +- `Marcin Sulikowski `_ diff --git a/CHANGELOG b/CHANGELOG index ad4f49a2..43fab75a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,11 @@ Freezegun Changelog =================== +1.3.0 +----- + +* Fixed `asyncio` support to avoid `await asyncio.sleep(1)` hanging forever. + 1.2.2 ----- diff --git a/freezegun/api.py b/freezegun/api.py index dfe4045c..e33b99e8 100644 --- a/freezegun/api.py +++ b/freezegun/api.py @@ -1,5 +1,6 @@ from . import config from ._async import wrap_coroutine +import asyncio import copyreg import dateutil import datetime @@ -726,6 +727,21 @@ def start(self): setattr(module, attribute_name, fake) add_change((module, attribute_name, attribute_value)) + # To avoid breaking `asyncio.sleep()`, let asyncio event loops see real + # monotonic time even though we've just frozen `time.monotonic()` which + # is normally used there. If we didn't do this, `await asyncio.sleep()` + # would be hanging forever breaking many tests that use `freeze_time`. + # + # Note that we cannot statically tell the class of asyncio event loops + # because it is not officially documented and can actually be changed + # at run time using `asyncio.set_event_loop_policy`. That's why we check + # the type by creating a loop here and destroying it immediately. + event_loop = asyncio.new_event_loop() + event_loop.close() + EventLoopClass = type(event_loop) + add_change((EventLoopClass, "time", EventLoopClass.time)) + EventLoopClass.time = lambda self: real_monotonic() + return freeze_factory def stop(self): @@ -739,8 +755,8 @@ def stop(self): datetime.date = real_date copyreg.dispatch_table.pop(real_datetime) copyreg.dispatch_table.pop(real_date) - for module, module_attribute, original_value in self.undo_changes: - setattr(module, module_attribute, original_value) + for module_or_object, attribute, original_value in self.undo_changes: + setattr(module_or_object, attribute, original_value) self.undo_changes = [] # Restore modules loaded after start() diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index ad4ae10d..fe0d10ca 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1,5 +1,6 @@ import asyncio import datetime +import time from freezegun import freeze_time @@ -10,3 +11,70 @@ async def frozen_coroutine(): assert datetime.date.today() == datetime.date(1970, 1, 1) asyncio.run(frozen_coroutine()) + + +def test_freezing_time_in_coroutine(): + """Test calling freeze_time while executing asyncio loop.""" + async def coroutine(): + with freeze_time('1970-01-02'): + assert time.time() == 86400 + with freeze_time('1970-01-03'): + assert time.time() == 86400 * 2 + + asyncio.run(coroutine()) + + +def test_freezing_time_before_running_coroutine(): + """Test calling freeze_time before executing asyncio loop.""" + async def coroutine(): + assert time.time() == 86400 + with freeze_time('1970-01-02'): + asyncio.run(coroutine()) + + +def test_asyncio_sleeping_not_affected_by_freeze_time(): + """Test that asyncio.sleep() is not affected by `freeze_time`. + + This test ensures that despite freezing time using `freeze_time`, + the asyncio event loop can see real monotonic time, which is required + to make things like `asyncio.sleep()` work. + """ + + async def coroutine(): + # Sleeping with time frozen should sleep the expected duration. + before_sleep = time.time() + with freeze_time('1970-01-02'): + await asyncio.sleep(0.05) + assert 0.02 <= time.time() - before_sleep < 0.3 + + # Exiting `freeze_time` the time should not break asyncio sleeping. + before_sleep = time.time() + await asyncio.sleep(0.05) + assert 0.02 <= time.time() - before_sleep < 0.3 + + asyncio.run(coroutine()) + + +def test_asyncio_to_call_later_with_frozen_time(): + """Test that asyncio `loop.call_later` works with frozen time.""" + # `to_call_later` will be called by asyncio event loop and should add + # the Unix timestamp of 1970-01-02 00:00 to the `timestamps` list. + timestamps = [] + def to_call_later(): + timestamps.append(time.time()) + + async def coroutine(): + # Schedule calling `to_call_later` in 100 ms. + asyncio.get_running_loop().call_later(0.1, to_call_later) + + # Sleeping for 10 ms should not result in calling `to_call_later`. + await asyncio.sleep(0.01) + assert timestamps == [] + + # But sleeping more (150 ms in this case) should call `to_call_later` + # and we should see `timestamps` updated. + await asyncio.sleep(0.15) + assert timestamps == [86400] + + with freeze_time('1970-01-02'): + asyncio.run(coroutine())