Catch un-renderable slide content at runtime (#202)

pull/204/head
Josh Karpel 1 year ago committed by GitHub
parent de3a57d9ce
commit 99961a38b2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -4,6 +4,10 @@
## `0.4.5` | *Unreleased*
### Fixed
- [#202](https://github.com/JoshKarpel/spiel/pull/202) Returning un-renderable content from a slide content function now displays an error instead of crashing Spiel.
### Changed
- [#203](https://github.com/JoshKarpel/spiel/pull/203) The `Image` example in the demo deck is now centered inside its `Panel`.

@ -14,6 +14,7 @@ from rich.console import Console
from textual.app import App
from textual.pilot import Pilot
import spiel.constants
from spiel.app import SpielApp
ROOT_DIR = Path(__file__).resolve().parent.parent
@ -71,7 +72,7 @@ def take_screenshot(name: str, deck_file: Path, size: tuple[int, int], keys: Ite
if __name__ == "__main__":
start_time = monotonic()
demo_deck = ROOT_DIR / "spiel" / "demo" / "demo.py"
demo_deck = spiel.constants.DEMO_FILE
quickstart_deck = ROOT_DIR / "docs" / "examples" / "quickstart.py"
slide_via_decorator = ROOT_DIR / "docs" / "examples" / "slide_via_decorator.py"
slide_loop = ROOT_DIR / "docs" / "examples" / "slide_loop.py"

21
poetry.lock generated

@ -1252,6 +1252,25 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
[package.extras]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
[[package]]
name = "pytest-asyncio"
version = "0.20.3"
description = "Pytest support for asyncio"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "pytest-asyncio-0.20.3.tar.gz", hash = "sha256:83cbf01169ce3e8eb71c6c278ccb0574d1a7a3bb8eaaf5e50e0ad342afb33b36"},
{file = "pytest_asyncio-0.20.3-py3-none-any.whl", hash = "sha256:f129998b209d04fcc65c96fc85c11e5316738358909a8399e93be553d7656442"},
]
[package.dependencies]
pytest = ">=6.1.0"
[package.extras]
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"]
testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"]
[[package]]
name = "pytest-cov"
version = "4.0.0"
@ -1888,4 +1907,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
[metadata]
lock-version = "2.0"
python-versions = ">=3.10,<4"
content-hash = "bf60d897bc813695779b088ad843a789041cdfc39dc376f0aac71a42f3a01cb8"
content-hash = "1ea15a591ae6a2e7f0ca7b4395d5b249f8591085f8918f6643b6ef265b3db5d2"

@ -59,6 +59,7 @@ textual = {extras = ["dev"], version = "==0.4.0"}
mkdocs = ">=1.4"
mkdocs-material = ">=9"
mkdocstrings = {extras = ["python"], version = ">=0.19.0"}
pytest-asyncio = ">=0.20"
[tool.poetry.scripts]
spiel = 'spiel.cli:cli'
@ -78,11 +79,15 @@ all = true
addopts = ["--strict-markers", "--mypy", "-n", "auto"]
testpaths = ["tests", "spiel", "docs"]
markers = ["slow"]
asyncio_mode = "auto"
[tool.mypy]
pretty = false
show_error_codes = true
files = ["spiel/**/*.py", "tests/**/*.py"]
files = ["."]
check_untyped_defs = true
disallow_incomplete_defs = true

@ -79,7 +79,7 @@ class SpielApp(App[None]):
def __init__(
self,
deck_path: Path,
watch_path: Path,
watch_path: Path | None = None,
show_messages: bool = True,
fixed_time: datetime.datetime | None = None,
):
@ -101,6 +101,9 @@ class SpielApp(App[None]):
await self.push_screen("slide")
async def reload(self) -> None:
if self.watch_path is None:
return
log(f"Watching {self.watch_path} for changes")
async for changes in awatch(self.watch_path):
change_msg = "\n ".join([""] + [f"{k.raw_str()}: {v}" for k, v in changes])

@ -426,6 +426,8 @@ def failure() -> RenderableType:
When this happens, Spiel will display the stack trace to help you debug the problem.
Deck reloading will still happen, so you can fix the error without stopping Spiel.
This error display will also be shown if you return slide content that can't be rendered by Rich.
"""
)

@ -5,7 +5,9 @@ from time import monotonic
from rich.box import HEAVY
from rich.console import RenderableType
from rich.errors import NotRenderableError
from rich.panel import Panel
from rich.protocol import is_renderable
from rich.style import Style
from rich.traceback import Traceback
from textual.reactive import reactive
@ -30,8 +32,11 @@ class SlideWidget(SpielWidget):
def render(self) -> RenderableType:
try:
self.remove_class("error")
slide = self.app.deck[self.app.current_slide_idx]
return slide.render(triggers=self.triggers)
r = self.current_slide.render(triggers=self.triggers)
if is_renderable(r):
return r
else:
raise NotRenderableError(f"object {r!r} is not renderable")
except Exception:
self.add_class("error")
et, ev, tr = sys.exc_info()
@ -44,7 +49,7 @@ class SlideWidget(SpielWidget):
traceback=tr,
suppress=(spiel,),
),
title="Slide failed to render",
title="Slide content failed to render",
border_style=Style(bold=True, color="red1"),
box=HEAVY,
)

@ -5,6 +5,8 @@ from typing import TYPE_CHECKING
from textual.reactive import watch
from textual.widget import Widget
from spiel.slide import Slide
if TYPE_CHECKING:
from spiel.app import SpielApp
@ -19,3 +21,7 @@ class SpielWidget(Widget):
def r(self, _: object) -> None:
self.refresh()
@property
def current_slide(self) -> Slide:
return self.app.deck[self.app.current_slide_idx]

@ -0,0 +1,51 @@
from collections.abc import Iterable
from itertools import combinations_with_replacement
import pytest
from hypothesis import given
from hypothesis.strategies import lists, sampled_from
import spiel.constants
from spiel.app import SpielApp
@pytest.fixture
def app() -> SpielApp:
return SpielApp(deck_path=spiel.constants.DEMO_FILE)
KEYS = [
"right",
"left",
"d",
"t",
"enter",
"up",
"down",
"escape",
"question_mark",
]
@pytest.mark.slow
@given(keys=lists(elements=sampled_from(KEYS), max_size=100))
async def test_hammer_on_the_keyboard_long_random(keys: Iterable[str]) -> None:
app = SpielApp(deck_path=spiel.constants.DEMO_FILE)
async with app.run_test() as pilot:
await pilot.press(*keys)
@pytest.mark.slow
@pytest.mark.parametrize("keys", combinations_with_replacement(KEYS, 3))
async def test_hammer_on_the_keyboard_short_exhaustive(app: SpielApp, keys: Iterable[str]) -> None:
async with app.run_test() as pilot:
await pilot.press(*keys)
async def test_advance_through_demo_slides(app: SpielApp) -> None:
async with app.run_test() as pilot:
keys = ("right",) * (len(app.deck) + 1)
await pilot.press(*keys)
assert app.current_slide_idx == len(app.deck) - 1

@ -0,0 +1,89 @@
import pytest
from pytest import FixtureRequest
from pytest_mock import MockerFixture
from rich.console import RenderableType
from rich.panel import Panel
from rich.text import Text
from spiel import Slide
from spiel.widgets.slide import SlideWidget
@pytest.fixture(params=["", Text()])
def slide(request: FixtureRequest) -> Slide:
def content() -> RenderableType:
return request.param
return Slide(content=content)
@pytest.fixture
def error_slide() -> Slide:
def content() -> RenderableType:
raise Exception()
return Slide(content=content)
@pytest.fixture
def unrenderable_slide() -> Slide:
def content() -> None:
return None
return Slide(content=content) # type: ignore[arg-type]
def mock(mocker: MockerFixture, slide: Slide) -> SlideWidget:
sw = SlideWidget()
mocker.patch.object(
type(sw),
"current_slide",
new_callable=mocker.PropertyMock,
return_value=slide,
)
assert sw.current_slide is slide
return sw
def test_render(mocker: MockerFixture, slide: Slide) -> None:
sw = mock(mocker, slide)
assert sw.render() == slide.render(triggers=sw.triggers)
assert "error" not in sw.classes
def test_render_raises_exception(mocker: MockerFixture, error_slide: Slide) -> None:
sw = mock(mocker, error_slide)
error = sw.render()
assert isinstance(error, Panel)
assert error.title == "Slide content failed to render"
assert "error" in sw.classes
def test_render_content_not_renderable(mocker: MockerFixture, unrenderable_slide: Slide) -> None:
sw = mock(mocker, unrenderable_slide)
error = sw.render()
assert isinstance(error, Panel)
assert error.title == "Slide content failed to render"
assert "error" in sw.classes
def test_update_triggers() -> None:
sw = SlideWidget()
initial_triggers = sw.triggers
sw.update_triggers()
assert initial_triggers.now <= sw.triggers.now
assert list(initial_triggers) == list(sw.triggers)
Loading…
Cancel
Save