diff options
author | Alexey Stepanov <penguinolog@users.noreply.github.com> | 2023-04-21 15:12:51 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-04-21 15:12:51 +0200 |
commit | 350ee5c47ff565d3b0d25b1c3cbddbfb2a0a2a4f (patch) | |
tree | 78625ff9d88095e21fb2e972bfbdb767c6e7fc11 | |
parent | a53fd346d22e67d5e42c29a4d5c3498d598443c9 (diff) | |
download | urwid-350ee5c47ff565d3b0d25b1c3cbddbfb2a0a2a4f.tar.gz |
[BREAKING CHANGE] Fixes: #90 Remove idle emulation from asyncio event loop (#541)
* [BREAKING CHANGE] Fix: #90 Remove idle emulation from asyncio event loop
Re-implement abandoned PR #418
* Fix "not hashable `AttrSpec`" and it's instance creation price (use `__slots__`)
`AttrSpec` instances may be created in huge amount,
with slots this process consume less resources.
* `Terminal` is always created with event loop,
if not provided -> `SelectEventLoop` is used
* Fixed `TornadoEventLoop` & `AsyncioEventLoop` logic
(Tornado IOLoop is asyncio based)
For extra details see original PR.
* Make `AttrSpec` immutable and hash-reusable
* Update IDLE callback comment
* Update urwid/display_common.py
Co-authored-by: Ian Ward <ian@excess.org>
---------
Co-authored-by: Aleksei Stepanov <alekseis@nvidia.com>
Co-authored-by: Ian Ward <ian@excess.org>
-rwxr-xr-x | urwid/display_common.py | 118 | ||||
-rw-r--r-- | urwid/event_loop/asyncio_loop.py | 83 | ||||
-rw-r--r-- | urwid/event_loop/tornado_loop.py | 77 | ||||
-rw-r--r-- | urwid/tests/test_event_loops.py | 5 | ||||
-rw-r--r-- | urwid/vterm.py | 18 |
5 files changed, 196 insertions, 105 deletions
diff --git a/urwid/display_common.py b/urwid/display_common.py index 30ece96..afbf60f 100755 --- a/urwid/display_common.py +++ b/urwid/display_common.py @@ -36,7 +36,7 @@ from urwid import signals from urwid.util import StoppingContext, int_scale if typing.TYPE_CHECKING: - from typing_extensions import Literal + from typing_extensions import Literal, Self # for replacing unprintable bytes with '?' UNPRINTABLE_TRANS_TABLE = b"?" * 32 + bytes(range(32, 256)) @@ -487,6 +487,8 @@ class AttrSpecError(Exception): class AttrSpec: + __slots__ = ("__value",) + def __init__(self, fg: str, bg: str, colors: Literal[1, 16, 88, 256, 16777216] = 256) -> None: """ fg -- a string containing a comma-separated foreground color @@ -545,70 +547,102 @@ class AttrSpec: """ if colors not in (1, 16, 88, 256, 2**24): raise AttrSpecError(f'invalid number of colors ({colors:d}).') - self._value = 0 | _HIGH_88_COLOR * (colors == 88) | _HIGH_TRUE_COLOR * (colors == 2**24) - self.foreground = fg - self.background = bg + self.__value = 0 | _HIGH_88_COLOR * (colors == 88) | _HIGH_TRUE_COLOR * (colors == 2 ** 24) + self.__set_foreground(fg) + self.__set_background(bg) if self.colors > colors: raise AttrSpecError( f'foreground/background ({fg!r}/{bg!r}) ' f'require more colors than have been specified ({colors:d}).' ) + def copy_modified( + self, + fg: str | None = None, + bg: str | None = None, + colors: Literal[1, 16, 88, 256, 16777216] | None = None, + ) -> Self: + if fg is None: + foreground = self.foreground + else: + foreground = fg + + if bg is None: + background = self.background + else: + background = bg + + if colors is None: + new_colors = self.colors + else: + new_colors = colors + + return self.__class__(foreground, background, new_colors) + + def __hash__(self) -> int: + """Instance is immutable and hashable.""" + return hash((self.__class__, self.__value)) + + @property + def _value(self) -> int: + """Read-only value access.""" + return self.__value + @property def foreground_basic(self) -> bool: - return self._value & _FG_BASIC_COLOR != 0 + return self.__value & _FG_BASIC_COLOR != 0 @property def foreground_high(self) -> bool: - return self._value & _FG_HIGH_COLOR != 0 + return self.__value & _FG_HIGH_COLOR != 0 @property def foreground_true(self) -> bool: - return self._value & _FG_TRUE_COLOR != 0 + return self.__value & _FG_TRUE_COLOR != 0 @property def foreground_number(self) -> int: - return self._value & _FG_COLOR_MASK + return self.__value & _FG_COLOR_MASK @property def background_basic(self) -> bool: - return self._value & _BG_BASIC_COLOR != 0 + return self.__value & _BG_BASIC_COLOR != 0 @property def background_high(self) -> bool: - return self._value & _BG_HIGH_COLOR != 0 + return self.__value & _BG_HIGH_COLOR != 0 @property def background_true(self) -> bool: - return self._value & _BG_TRUE_COLOR != 0 + return self.__value & _BG_TRUE_COLOR != 0 @property def background_number(self) -> int: - return (self._value & _BG_COLOR_MASK) >> _BG_SHIFT + return (self.__value & _BG_COLOR_MASK) >> _BG_SHIFT @property def italics(self) -> bool: - return self._value & _ITALICS != 0 + return self.__value & _ITALICS != 0 @property def bold(self) -> bool: - return self._value & _BOLD != 0 + return self.__value & _BOLD != 0 @property def underline(self) -> bool: - return self._value & _UNDERLINE != 0 + return self.__value & _UNDERLINE != 0 @property def blink(self) -> bool: - return self._value & _BLINK != 0 + return self.__value & _BLINK != 0 @property def standout(self) -> bool: - return self._value & _STANDOUT != 0 + return self.__value & _STANDOUT != 0 @property def strikethrough(self) -> bool: - return self._value & _STRIKETHROUGH != 0 + return self.__value & _STRIKETHROUGH != 0 @property def colors(self) -> int: @@ -617,13 +651,13 @@ class AttrSpec: Returns 256, 88, 16 or 1. """ - if self._value & _HIGH_88_COLOR: + if self.__value & _HIGH_88_COLOR: return 88 - if self._value & (_BG_HIGH_COLOR | _FG_HIGH_COLOR): + if self.__value & (_BG_HIGH_COLOR | _FG_HIGH_COLOR): return 256 - if self._value & (_BG_TRUE_COLOR | _FG_TRUE_COLOR): + if self.__value & (_BG_TRUE_COLOR | _FG_TRUE_COLOR): return 2**24 - if self._value & (_BG_BASIC_COLOR | _FG_BASIC_COLOR): + if self.__value & (_BG_BASIC_COLOR | _FG_BASIC_COLOR): return 16 return 1 @@ -671,8 +705,7 @@ class AttrSpec: + ',strikethrough' * self.strikethrough ) - @foreground.setter - def foreground(self, foreground: str) -> None: + def __set_foreground(self, foreground: str) -> None: color = None flags = 0 # handle comma-separated foreground @@ -691,10 +724,10 @@ class AttrSpec: elif part in _BASIC_COLORS: scolor = _BASIC_COLORS.index(part) flags |= _FG_BASIC_COLOR - elif self._value & _HIGH_88_COLOR: + elif self.__value & _HIGH_88_COLOR: scolor = _parse_color_88(part) flags |= _FG_HIGH_COLOR - elif self._value & _HIGH_TRUE_COLOR: + elif self.__value & _HIGH_TRUE_COLOR: scolor = _parse_color_true(part) flags |= _FG_TRUE_COLOR else: @@ -708,7 +741,7 @@ class AttrSpec: color = scolor if color is None: color = 0 - self._value = (self._value & ~_FG_MASK) | color | flags + self.__value = (self.__value & ~_FG_MASK) | color | flags def _foreground(self) -> str: warnings.warn( @@ -719,15 +752,6 @@ class AttrSpec: ) return self.foreground - def _set_foreground(self, foreground: str) -> None: - warnings.warn( - f"Method `{self.__class__.__name__}._set_foreground` is deprecated, " - f"please use property `{self.__class__.__name__}.foreground`", - DeprecationWarning, - stacklevel=2, - ) - self.foreground = foreground - @property def background(self) -> str: """Return the background color.""" @@ -735,24 +759,23 @@ class AttrSpec: return 'default' if self.background_basic: return _BASIC_COLORS[self.background_number] - if self._value & _HIGH_88_COLOR: + if self.__value & _HIGH_88_COLOR: return _color_desc_88(self.background_number) if self.colors == 2**24: return _color_desc_true(self.background_number) return _color_desc_256(self.background_number) - @background.setter - def background(self, background: str) -> None: + def __set_background(self, background: str) -> None: flags = 0 if background in ('', 'default'): color = 0 elif background in _BASIC_COLORS: color = _BASIC_COLORS.index(background) flags |= _BG_BASIC_COLOR - elif self._value & _HIGH_88_COLOR: + elif self.__value & _HIGH_88_COLOR: color = _parse_color_88(background) flags |= _BG_HIGH_COLOR - elif self._value & _HIGH_TRUE_COLOR: + elif self.__value & _HIGH_TRUE_COLOR: color = _parse_color_true(background) flags |= _BG_TRUE_COLOR else: @@ -760,7 +783,7 @@ class AttrSpec: flags |= _BG_HIGH_COLOR if color is None: raise AttrSpecError(f"Unrecognised color specification in background ({background!r})") - self._value = (self._value & ~_BG_MASK) | (color << _BG_SHIFT) | flags + self.__value = (self.__value & ~_BG_MASK) | (color << _BG_SHIFT) | flags def _background(self) -> str: warnings.warn( @@ -771,15 +794,6 @@ class AttrSpec: ) return self.background - def _set_background(self, background: str) -> None: - warnings.warn( - f"Method `{self.__class__.__name__}._set_background` is deprecated, " - f"please use property `{self.__class__.__name__}.background`", - DeprecationWarning, - stacklevel=2, - ) - self.background = background - def get_rgb_values(self): """ Return (fg_red, fg_green, fg_blue, bg_red, bg_green, bg_blue) color @@ -817,7 +831,7 @@ class AttrSpec: return vals + _COLOR_VALUES_256[self.background_number] def __eq__(self, other: typing.Any) -> bool: - return isinstance(other, AttrSpec) and self._value == other._value + return isinstance(other, AttrSpec) and self.__value == other.__value def __ne__(self, other: typing.Any) -> bool: return not self == other diff --git a/urwid/event_loop/asyncio_loop.py b/urwid/event_loop/asyncio_loop.py index e8f1522..fce9472 100644 --- a/urwid/event_loop/asyncio_loop.py +++ b/urwid/event_loop/asyncio_loop.py @@ -24,11 +24,18 @@ from __future__ import annotations import asyncio +import functools import typing from collections.abc import Callable from .abstract_loop import EventLoop, ExitMainLoop +if typing.TYPE_CHECKING: + from typing_extensions import ParamSpec + + _Spec = ParamSpec("_Spec") + _T = typing.TypeVar("_T") + __all__ = ("AsyncioEventLoop",) @@ -36,11 +43,17 @@ class AsyncioEventLoop(EventLoop): """ Event loop based on the standard library ``asyncio`` module. + .. note:: + If you make any changes to the urwid state outside of it + handling input or responding to alarms (for example, from asyncio.Task + running in background), and wish the screen to be + redrawn, you must call :meth:`MainLoop.draw_screen` method of the + main loop manually. + A good way to do this:: + asyncio.get_event_loop().call_soon(main_loop.draw_screen) """ _we_started_event_loop = False - _idle_emulation_delay = 1.0/30 # a short time (in seconds) - def __init__(self, *, loop: asyncio.AbstractEventLoop | None = None, **kwargs) -> None: if loop: self._loop: asyncio.AbstractEventLoop = loop @@ -49,6 +62,33 @@ class AsyncioEventLoop(EventLoop): self._exc: BaseException | None = None + self._idle_asyncio_handle: asyncio.TimerHandle | None = None + self._idle_handle: int = 0 + self._idle_callbacks: dict[int, Callable[[], typing.Any]] = {} + + def _also_call_idle(self, callback: Callable[_Spec, _T]) -> Callable[_Spec, _T]: + """ + Wrap the callback to also call _entering_idle. + """ + + @functools.wraps(callback) + def wrapper(*args: _Spec.args, **kwargs: _Spec.kwargs) -> _T: + if not self._idle_asyncio_handle: + self._idle_asyncio_handle = self._loop.call_later(0, self._entering_idle) + return callback(*args, **kwargs) + + return wrapper + + def _entering_idle(self) -> None: + """ + Call all the registered idle callbacks. + """ + try: + for callback in self._idle_callbacks.values(): + callback() + finally: + self._idle_asyncio_handle = None + def alarm(self, seconds: float | int, callback: Callable[[], typing.Any]) -> asyncio.TimerHandle: """ Call callback() a given time from now. No parameters are @@ -59,7 +99,7 @@ class AsyncioEventLoop(EventLoop): seconds -- time in seconds to wait before calling callback callback -- function to call from event loop """ - return self._loop.call_later(seconds, callback) + return self._loop.call_later(seconds, self._also_call_idle(callback)) def remove_alarm(self, handle) -> bool: """ @@ -86,7 +126,7 @@ class AsyncioEventLoop(EventLoop): fd -- file descriptor to watch for input callback -- function to call when input is available """ - self._loop.add_reader(fd, callback) + self._loop.add_reader(fd, self._also_call_idle(callback)) return fd def remove_watch_file(self, handle: int) -> bool: @@ -97,38 +137,41 @@ class AsyncioEventLoop(EventLoop): """ return self._loop.remove_reader(handle) - def enter_idle(self, callback: Callable[[], typing.Any]) -> list[asyncio.TimerHandle]: + def enter_idle(self, callback: Callable[[], typing.Any]) -> int: """ Add a callback for entering idle. - Returns a handle that may be passed to remove_idle() + Returns a handle that may be passed to remove_enter_idle() """ # XXX there's no such thing as "idle" in most event loops; this fakes - # it the same way as Twisted, by scheduling the callback to be called - # repeatedly - mutable_handle: list[asyncio.TimerHandle] = [] - - def faux_idle_callback(): - callback() - mutable_handle[0] = self._loop.call_later(self._idle_emulation_delay, faux_idle_callback) + # it by adding extra callback to the timer and file watch callbacks. + self._idle_handle += 1 + self._idle_callbacks[self._idle_handle] = callback + return self._idle_handle - mutable_handle.append(self._loop.call_later(self._idle_emulation_delay, faux_idle_callback)) - - return mutable_handle - - def remove_enter_idle(self, handle) -> bool: + def remove_enter_idle(self, handle: int) -> bool: """ Remove an idle callback. Returns True if the handle was removed. """ - # `handle` is just a list containing the current actual handle - return self.remove_alarm(handle[0]) + try: + del self._idle_callbacks[handle] + except KeyError: + return False + return True def _exception_handler(self, loop: asyncio.AbstractEventLoop, context): exc = context.get('exception') if exc: loop.stop() + + if self._idle_asyncio_handle: + # clean it up to prevent old callbacks + # from messing things up if loop is restarted + self._idle_asyncio_handle.cancel() + self._idle_asyncio_handle = None + if not isinstance(exc, ExitMainLoop): # Store the exc_info so we can re-raise after the loop stops self._exc = exc diff --git a/urwid/event_loop/tornado_loop.py b/urwid/event_loop/tornado_loop.py index 72f4635..0d9a5b3 100644 --- a/urwid/event_loop/tornado_loop.py +++ b/urwid/event_loop/tornado_loop.py @@ -47,25 +47,49 @@ class TornadoEventLoop(EventLoop): """ This is an Urwid-specific event loop to plug into its MainLoop. It acts as an adaptor for Tornado's IOLoop which does all heavy lifting except idle-callbacks. - - Notice, since Tornado has no concept of idle callbacks we - monkey patch ioloop._impl.poll() function to be able to detect - potential idle periods. """ - _idle_emulation_delay = 1.0 / 30 # a short time (in seconds) def __init__(self, loop: ioloop.IOLoop | None = None) -> None: if loop: self._loop: ioloop.IOLoop = loop else: - self._loop = ioloop.IOLoop.instance() + self._loop = ioloop.IOLoop.current() # TODO(Aleksei): Switch to the syncio.EventLoop as tornado >= 6.0 ! self._pending_alarms: dict[object, int] = {} self._watch_handles: dict[int, int] = {} # {<watch_handle> : <file_descriptor>} self._max_watch_handle: int = 0 self._exc: BaseException | None = None + self._idle_asyncio_handle: object | None = None + self._idle_handle: int = 0 + self._idle_callbacks: dict[int, Callable[[], typing.Any]] = {} + + def _also_call_idle(self, callback: Callable[_Spec, _T]) -> Callable[_Spec, _T]: + """ + Wrap the callback to also call _entering_idle. + """ + + @functools.wraps(callback) + def wrapper(*args: _Spec.args, **kwargs: _Spec.kwargs) -> _T: + if not self._idle_asyncio_handle: + self._idle_asyncio_handle = self._loop.call_later(0, self._entering_idle) + return callback(*args, **kwargs) + + return wrapper + + def _entering_idle(self) -> None: + """ + Call all the registered idle callbacks. + """ + try: + for callback in self._idle_callbacks.values(): + callback() + finally: + self._idle_asyncio_handle = None + def alarm(self, seconds: float | int, callback: Callable[[], typing.Any]): + @self._also_call_idle + @functools.wraps(callback) def wrapped() -> None: try: del self._pending_alarms[handle] @@ -77,7 +101,7 @@ class TornadoEventLoop(EventLoop): self._pending_alarms[handle] = 1 return handle - def remove_alarm(self, handle) -> bool: + def remove_alarm(self, handle: object) -> bool: self._loop.remove_timeout(handle) try: del self._pending_alarms[handle] @@ -87,6 +111,7 @@ class TornadoEventLoop(EventLoop): return True def watch_file(self, fd: int, callback: Callable[[], _T]) -> int: + @self._also_call_idle def handler(_fd: int, _events: int) -> None: self.handle_exit(callback)() @@ -104,34 +129,29 @@ class TornadoEventLoop(EventLoop): self._loop.remove_handler(fd) return True - def enter_idle(self, callback: Callable[[], typing.Any]) -> list[object]: + def enter_idle(self, callback: Callable[[], typing.Any]) -> int: """ Add a callback for entering idle. Returns a handle that may be passed to remove_idle() """ # XXX there's no such thing as "idle" in most event loops; this fakes - # it the same way as Twisted, by scheduling the callback to be called - # repeatedly - mutable_handle: list[object] = [] - - def faux_idle_callback(): - callback() - mutable_handle[0] = self._loop.call_later(self._idle_emulation_delay, faux_idle_callback) - - mutable_handle.append(self._loop.call_later(self._idle_emulation_delay, faux_idle_callback)) - - # asyncio used as backend, real type comes from asyncio_loop.call_later - return mutable_handle + # it by adding extra callback to the timer and file watch callbacks. + self._idle_handle += 1 + self._idle_callbacks[self._idle_handle] = callback + return self._idle_handle - def remove_enter_idle(self, handle) -> bool: + def remove_enter_idle(self, handle: int) -> bool: """ Remove an idle callback. Returns True if the handle was removed. """ - # `handle` is just a list containing the current actual handle - return self.remove_alarm(handle[0]) + try: + del self._idle_callbacks[handle] + except KeyError: + return False + return True def handle_exit(self, f: Callable[_Spec, _T]) -> Callable[_Spec, _T | Literal[False]]: @functools.wraps(f) @@ -139,10 +159,17 @@ class TornadoEventLoop(EventLoop): try: return f(*args, **kwargs) except ExitMainLoop: - self._loop.stop() + pass # handled later except Exception as exc: self._exc = exc - self._loop.stop() + + if self._idle_asyncio_handle: + # clean it up to prevent old callbacks + # from messing things up if loop is restarted + self._loop.remove_timeout(self._idle_asyncio_handle) + self._idle_asyncio_handle = None + + self._loop.stop() return False return wrapper diff --git a/urwid/tests/test_event_loops.py b/urwid/tests/test_event_loops.py index fac7f72..b8ce257 100644 --- a/urwid/tests/test_event_loops.py +++ b/urwid/tests/test_event_loops.py @@ -71,12 +71,13 @@ class EventLoopTestMixin: if self._expected_idle_handle is not None: self.assertEqual(idle_handle, 1) evl.run() + self.assertTrue("waiting" in out, out) self.assertTrue("hello" in out, out) self.assertTrue("clean exit" in out, out) handle = evl.watch_file(rd, exit_clean) del out[:] evl.run() - self.assertEqual(out, ["clean exit"]) + self.assertEqual(["clean exit"], out) self.assertTrue(evl.remove_watch_file(handle)) handle = evl.alarm(0, exit_error) self.assertRaises(ZeroDivisionError, evl.run) @@ -208,7 +209,7 @@ class AsyncioEventLoopTest(unittest.TestCase, EventLoopTestMixin): result = 1 / 0 # Simulate error in coroutine return result - asyncio.ensure_future(error_coro()) + asyncio.ensure_future(error_coro(), loop=asyncio.get_event_loop_policy().get_event_loop()) self.assertRaises(ZeroDivisionError, evl.run) diff --git a/urwid/vterm.py b/urwid/vterm.py index 486fc01..6922c7f 100644 --- a/urwid/vterm.py +++ b/urwid/vterm.py @@ -48,6 +48,7 @@ from urwid.canvas import Canvas from urwid.display_common import _BASIC_COLORS, AttrSpec, RealTerminal from urwid.escape import ALT_DEC_SPECIAL_CHARS, DEC_SPECIAL_CHARS from urwid.widget import BOX, Widget +from urwid import event_loop if typing.TYPE_CHECKING: from typing_extensions import Literal @@ -1123,13 +1124,15 @@ class TermCanvas(Canvas): fg = None else: fg = self.attrspec.foreground_number - if fg >= 8: fg -= 8 + if fg >= 8: + fg -= 8 if 'default' in self.attrspec.background: bg = None else: bg = self.attrspec.background_number - if bg >= 8: bg -= 8 + if bg >= 8: + bg -= 8 for attr in ('bold', 'underline', 'blink', 'standout'): if not getattr(self.attrspec, attr): @@ -1154,10 +1157,10 @@ class TermCanvas(Canvas): attrs = [fg.strip() for fg in attrspec.foreground.split(',')] if 'standout' in attrs and undo: attrs.remove('standout') - attrspec.foreground = ','.join(attrs) + attrspec = attrspec.copy_modified(fg=','.join(attrs)) elif 'standout' not in attrs and not undo: attrs.append('standout') - attrspec.foreground = ','.join(attrs) + attrspec = attrspec.copy_modified(fg=','.join(attrs)) return attrspec def reverse_video(self, undo: bool = False) -> None: @@ -1366,7 +1369,7 @@ class Terminal(Widget): self, command: Sequence[str] | None, env: Mapping[str, str] | Iterable[Sequence[str]] | None = None, - main_loop=None, + main_loop: event_loop.EventLoop | None = None, escape_sequence: str | None = None, encoding: str = 'utf-8', ): @@ -1418,7 +1421,10 @@ class Terminal(Widget): self.term_modes = TermModes() - self.main_loop = main_loop + if main_loop is not None: + self.main_loop = main_loop + else: + self.main_loop = event_loop.SelectEventLoop() self.master = None self.pid = None |