Skip to content

Commit

Permalink
[clock] Add a centralised Clock, responsible for anything related to …
Browse files Browse the repository at this point in the history
…time

This makes time quite easier to mock during integration tests :-)
  • Loading branch information
Olivier Philippon committed May 17, 2022
1 parent 6669216 commit f5969fb
Show file tree
Hide file tree
Showing 7 changed files with 180 additions and 126 deletions.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ includes = "src"
asyncio_mode = "auto"
testpaths = ["tests"]
markers = [
"integration_test: marks tests as slow integration tests(deselect with '-m \"not integration_test\"')",
"integration_test: marks tests as slow integration tests (deselect with '-m \"not integration_test\"')",
]

[build-system]
Expand Down
2 changes: 1 addition & 1 deletion sandbox/scroll_to_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class Introduction(Widget):
}
"""

def render(self) -> RenderableType:
def render(self, styles) -> RenderableType:
return Text(
"Press keys 0 to 9 to scroll to the Placeholder with that ID.",
justify="center",
Expand Down
9 changes: 5 additions & 4 deletions src/textual/_animator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from dataclasses import dataclass

from . import _clock
from ._easing import DEFAULT_EASING, EASING
from ._timer import Timer
from ._types import MessageTarget
Expand Down Expand Up @@ -179,9 +180,9 @@ def animate(
raise AttributeError(
f"Can't animate attribute {attribute!r} on {obj!r}; attribute does not exist"
)
assert not all(
(duration, speed)
), "An Animation should have a duration OR a speed, received both"
assert (duration is not None and speed is None) or (
duration is None and speed is not None
), "An Animation should have a duration OR a speed"

if final_value is ...:
final_value = value
Expand Down Expand Up @@ -247,4 +248,4 @@ def _get_time(self) -> float:
"""Get the current wall clock time, via the internal Timer."""
# N.B. We could remove this method and always call `self._timer.get_time()` internally,
# but it's handy to have in mocking situations
return self._timer.get_time()
return _clock.get_time()
58 changes: 58 additions & 0 deletions src/textual/_clock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import asyncio
from time import monotonic


"""
A module that serves as the single source of truth for everything time-related in a Textual app.
Having this logic centralised makes it easier to simulate time in integration tests,
by mocking the few functions exposed by this module.
"""


# N.B. This class and its singleton instance have to be hidden APIs because we want to be able to mock time,
# even for Python modules that imported functions such as `get_time` *before* we mocked this internal _Clock.
# (so mocking public APIs such as `get_time` wouldn't affect direct references to then that were done during imports)
class _Clock:
def get_time(self) -> float:
return monotonic()

async def aget_time(self) -> float:
return self.get_time()

async def sleep(self, seconds: float) -> None:
await asyncio.sleep(seconds)


# That's our target for mocking time! :-)
_clock = _Clock()


def get_time() -> float:
"""
Get the current wall clock time.
Returns:
float: the value (in fractional seconds) of a monotonic clock, i.e. a clock that cannot go backwards.
"""
return _clock.get_time()


async def aget_time() -> float:
"""
Asynchronous version of `get_time`. Useful in situations where we want asyncio to be
able to "do things" elsewhere right before we fetch the time.
Returns:
float: the value (in fractional seconds) of a monotonic clock, i.e. a clock that cannot go backwards.
"""
return await _clock.aget_time()


async def sleep(seconds: float) -> None:
"""
Coroutine that completes after a given time (in seconds).
Args:
seconds (float): the duration we should wait for before unblocking the awaiter
"""
return await _clock.sleep(seconds)
27 changes: 7 additions & 20 deletions src/textual/_timer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,19 @@
from asyncio import (
CancelledError,
Event,
sleep,
Task,
)
from time import monotonic
from typing import Awaitable, Callable, Union

from rich.repr import Result, rich_repr

from . import events
from ._callback import invoke
from . import _clock
from ._types import MessageTarget

TimerCallback = Union[Callable[[], Awaitable[None]], Callable[[], None]]

# /!\ This should only be changed in an "integration tests" context, in which we mock time
_TIMERS_CAN_SKIP: bool = True


class EventTargetGone(Exception):
pass
Expand Down Expand Up @@ -106,32 +102,23 @@ def resume(self) -> None:
"""Result a paused timer."""
self._active.set()

@staticmethod
def get_time() -> float:
"""Get the current wall clock time."""
# N.B. This method will likely be a mocking target in integration tests.
return monotonic()

@staticmethod
async def _sleep(duration: float) -> None:
# N.B. This method will likely be a mocking target in integration tests.
await sleep(duration)

async def _run(self) -> None:
"""Run the timer."""
count = 0
_repeat = self._repeat
_interval = self._interval
start = self.get_time()
start = await _clock.aget_time()
try:
while _repeat is None or count <= _repeat:
next_timer = start + ((count + 1) * _interval)
if self._skip and _TIMERS_CAN_SKIP and next_timer < self.get_time():
now = await _clock.aget_time()
if self._skip and next_timer < now:
count += 1
continue
wait_time = max(0, next_timer - self.get_time())
now = await _clock.aget_time()
wait_time = max(0, next_timer - now)
if wait_time:
await self._sleep(wait_time)
await _clock.sleep(wait_time)
count += 1
try:
await self._tick(next_timer=next_timer, count=count)
Expand Down
10 changes: 2 additions & 8 deletions src/textual/message.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from __future__ import annotations

from time import monotonic
from typing import ClassVar

import rich.repr

from . import _clock
from .case import camel_to_snake
from ._types import MessageTarget

Expand Down Expand Up @@ -39,7 +39,7 @@ def __init__(self, sender: MessageTarget) -> None:

self.sender = sender
self.name = camel_to_snake(self.__class__.__name__.replace("Message", ""))
self.time = self._get_time()
self.time = _clock.get_time()
self._forwarded = False
self._no_default_action = False
self._stop_propagation = False
Expand Down Expand Up @@ -99,9 +99,3 @@ def stop(self, stop: bool = True) -> Message:
"""
self._stop_propagation = stop
return self

@staticmethod
def _get_time() -> float:
"""Get the current wall clock time."""
# N.B. This method will likely be a mocking target in integration tests.
return monotonic()
Loading

0 comments on commit f5969fb

Please sign in to comment.