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

Add ClockEventLoop class with fixture and test (close #95) #96

Closed
Closed
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
50 changes: 50 additions & 0 deletions pytest_asyncio/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,51 @@ def pytest_runtest_setup(item):
)


class ClockEventLoop(asyncio.new_event_loop().__class__):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The line picks up the look class at import time.
But the default loop factory can be changed in runtime by installing a custom policy for example.
I recall many projects that change the loop factory in conftest.py or top-level package's __init__.py.
For example, the PR cannot test clock-event-loop against proactor event loop on Windows even if the policy was explicitly changed.

Honestly, I don't know how to solve the problem in unambiguous way but want to point on the problem.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the problem here, but the solution is not immediately obvious. Let me take a crack at seeing if I can make a function that produces a class to solve this.

"""
A custom event loop that explicitly advances time when requested. Otherwise,
this event loop advances time as expected.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._offset = 0

def time(self):
"""
Return the time according the event loop's clock.

This time is adjusted by the stored offset that allows for advancement
with `advance_time`.
"""
return super().time() + self._offset

async def advance_time(self, seconds):
'''
Advance time by a given offset in seconds.
'''
if seconds < 0:
# cannot go backwards in time, so return immediately
return

# advance the clock by the given offset
self._offset += seconds

# ensure waiting callbacks are run before advancing the clock
await asyncio.sleep(0, loop=self)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't it be before clock advance (line 219) then?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me take a closer look at these sleep calls. I recall as I was writing it that I had difficulty achieving a predictable number of loop iterations to get the test to react as expected. That's how I settled on sleeping 3 times. It is also the reason I ended up using _run_once in the earlier version and not using a coroutine function because it's functionality was more clear (even though it was a private method call).


if seconds > 0:
# Once the clock is adjusted, new tasks may have just been scheduled for running
# in the next pass through the event loop and advance again for the task
# that calls `advance_time`
await asyncio.sleep(0, loop=self)
await asyncio.sleep(0, loop=self)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One should be enough. The point of having two was to have one before clock advance and one after it.



# maps marker to the name of the event loop fixture that will be available
# to marked test functions
_markers_2_fixtures = {
'asyncio': 'event_loop',
'asyncio_clock': 'clock_event_loop',
}


Expand All @@ -204,6 +245,15 @@ def event_loop(request):
loop.close()


@pytest.yield_fixture
def clock_event_loop(request):
"""Create an instance of the default event loop for each test case."""
loop = ClockEventLoop()
asyncio.get_event_loop_policy().set_event_loop(loop)
yield loop
loop.close()


def _unused_tcp_port():
"""Find an unused localhost TCP port from 1024-65535 and return it."""
with contextlib.closing(socket.socket()) as sock:
Expand Down
40 changes: 40 additions & 0 deletions tests/test_simple_35.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,43 @@ async def test_asyncio_marker_method(self, event_loop):
def test_async_close_loop(event_loop):
event_loop.close()
return 'ok'


@pytest.mark.asyncio_clock
async def test_mark_asyncio_clock():
"""
Test that coroutines marked with asyncio_clock are run with a ClockEventLoop
"""
import pytest_asyncio
assert isinstance(asyncio.get_event_loop(), pytest_asyncio.plugin.ClockEventLoop)


def test_clock_loop_loop_fixture(clock_event_loop):
"""
Test that the clock_event_loop fixture returns a proper instance of the loop
"""
import pytest_asyncio
assert isinstance(asyncio.get_event_loop(), pytest_asyncio.plugin.ClockEventLoop)
clock_event_loop.close()
return 'ok'


@pytest.mark.asyncio_clock
async def test_clock_loop_advance_time(clock_event_loop):
"""
Test the sliding time event loop fixture
"""
async def short_nap():
await asyncio.sleep(1)

# create the task
task = clock_event_loop.create_task(short_nap())
assert not task.done()

# start the task
await clock_event_loop.advance_time(0)
assert not task.done()

# process the timeout
await clock_event_loop.advance_time(1)
assert task.done()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make test reliable I tend to await explicitly:

TIMEOUT=1    #define earlier in the file

await asyncio.wait_for(task, TIMEOUT)

(and change short_nap/advance to 10)

I do this because though advance_time marks task's sleep as finished, but the exact number of event loop iterations between this and the actual closing of the task is implementation-dependent.