Add nbterm support and options (#22)

pull/30/head
Josh Karpel 3 years ago committed by GitHub
parent 862f523397
commit b37497490d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -41,5 +41,5 @@ Spiel's image itself inherits from the [Python base image](https://hub.docker.co
## Supported Systems
Spiel relies on underlying terminal mechanisms that are only available on POSIX systems (e.g., Linux and MacOS).
Spiel currently relies on underlying terminal mechanisms that are only available on POSIX systems (e.g., Linux and MacOS).
If you're on Windows, you can use the [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/) to run Spiel.

692
poetry.lock generated

File diff suppressed because it is too large Load Diff

@ -13,7 +13,7 @@ line_length = 100
[tool.poetry]
name = "spiel"
version = "0.1.2"
version = "0.2.0"
description = "A framework for building and presenting richly-styled presentations in your terminal using Python."
readme="README.md"
homepage="https://github.com/JoshKarpel/spiel"
@ -39,6 +39,10 @@ typer = "^0.3.2"
watchdog = "^2.0.2"
pendulum = "^2.1.2"
Pillow = "^8.2.0"
ipython = "^7.23.0"
ipykernel = "^5.5.3"
nbterm = "^0.0.7"
toml = "^0.10.2"
[tool.poetry.dev-dependencies]
pytest = "^6.2.2"

@ -2,5 +2,8 @@ from .constants import __version__
from .deck import Deck
from .example import Example, example_panels
from .image import Image
from .notebooks import notebook
from .options import Options
from .repls import repl
from .slide import Slide
from .triggers import Triggers

@ -1,3 +1,4 @@
import os
import sys
from importlib import metadata
@ -6,6 +7,8 @@ __version__ = metadata.version(PACKAGE_NAME)
__rich_version__ = metadata.version("rich")
__python_version__ = ".".join(map(str, sys.version_info))
DECK = "DECK"
DECK = "deck"
TARGET_RPS = 30
EDITOR = os.getenv("EDITOR", "not set")

@ -4,8 +4,9 @@ import inspect
import sys
from collections.abc import Collection
from dataclasses import dataclass, field
from pathlib import Path
from textwrap import dedent
from typing import Callable, Iterator, List, Sequence
from typing import Callable, Iterator, List, Optional, Sequence
from .example import Example
from .presentable import Presentable
@ -36,9 +37,14 @@ class Deck(Collection):
def slide(
self,
title: str = "",
notebook: Optional[Path] = None,
) -> Callable[[MakeRenderable], Slide]:
def slideify(content: MakeRenderable) -> Slide:
slide = Slide(content=content, title=title)
slide = Slide(
title=title,
notebook=notebook,
content=content,
)
self.add_slides(slide)
return slide
@ -50,11 +56,13 @@ class Deck(Collection):
command: Sequence[str] = (sys.executable,),
name: str = "example.py",
language: str = "python",
notebook: Optional[Path] = None,
) -> Callable[[Callable], Example]:
def exampleify(example: Callable) -> Example:
ex = Example(
source=get_function_body(example),
title=title,
notebook=notebook,
source=get_function_body(example),
command=command,
name=name,
language=language,
@ -73,9 +81,9 @@ def get_function_body(function: Callable) -> str:
if prev_indent is None:
prev_indent = count_leading_whitespace(line)
elif count_leading_whitespace(line) > prev_indent:
return dedent("".join(lines[idx:]))
lines = lines[idx:]
raise ValueError(f"Could not extract function body from {function}")
return dedent("".join(lines))
def count_leading_whitespace(s: str) -> int:

@ -19,18 +19,19 @@ from rich.style import Style
from rich.syntax import Syntax
from rich.text import Text
from spiel import Deck, Image, Slide, __version__, example_panels
from spiel import Deck, Image, Options, Slide, __version__, example_panels
deck = Deck(name=f"Spiel Demo Deck (v{__version__})")
options = Options()
SPIEL = "[Spiel](https://github.com/JoshKarpel/spiel)"
RICH = "[Rich](https://rich.readthedocs.io/)"
DECK = Deck(name=f"Spiel Demo Deck (v{__version__})")
NBTERM = "[nbterm](https://github.com/davidbrochart/nbterm)"
THIS_DIR = Path(__file__).resolve().parent
@DECK.slide(title="What is Spiel?")
@deck.slide(title="What is Spiel?")
def what():
upper_left_markup = dedent(
f"""\
@ -112,7 +113,7 @@ def what():
return root
@DECK.slide(title="Decks and Slides")
@deck.slide(title="Decks and Slides")
def code():
markup = dedent(
f"""\
@ -152,7 +153,7 @@ def code():
return root
@DECK.slide(title="Dynamic Content")
@deck.slide(title="Dynamic Content")
def dynamic():
home = Path.home()
width = shutil.get_terminal_size().columns
@ -201,7 +202,7 @@ def dynamic():
)
@DECK.slide(title="Triggers")
@deck.slide(title="Triggers")
def triggers(triggers):
info = Markdown(
dedent(
@ -285,7 +286,7 @@ def triggers(triggers):
return RenderGroup(info, fun, ball if len(triggers) > 2 else Text(""))
@DECK.slide(title="Views")
@deck.slide(title="Views")
def grid():
markup = dedent(
"""\
@ -300,7 +301,7 @@ def grid():
return Markdown(markup, justify="center")
@DECK.slide(title="Watch Mode")
@deck.slide(title="Watch Mode")
def watch():
markup = dedent(
f"""\
@ -316,7 +317,7 @@ def watch():
return Markdown(markup, justify="center")
@DECK.slide(title="Displaying Images")
@deck.slide(title="Displaying Images")
def image():
markup = dedent(
f"""\
@ -342,7 +343,7 @@ def image():
return root
@DECK.example(title="Examples")
@deck.example(title="Examples")
def examples():
# This is an example that shows how to use random.choice from the standard library.
@ -399,3 +400,67 @@ def _(example, triggers):
root.split_row(Layout(markdown), example_panels(example))
return root
@deck.slide(title="Live Coding with the REPL")
def notebooks():
markup = dedent(
f"""\
## Live Coding: REPL
Sometimes an static example,
or even an example that you're editing and running multiple times,
just isn't interactive enough.
To provide a more interactive experience,
{SPIEL} lets you open an IPython REPL on any slide by pressing `i`.
When you exit the REPL (by pressing `ctrl-d` or executing `exit`),
you'll be back at the same point in your presentation.
The state of the REPL is not persistent between invocations
(it will be completely fresh every time you enter it).
"""
)
return Markdown(markup, justify="center")
@deck.slide(title="Live Coding with Notebooks", notebook=THIS_DIR / "notebook.ipynb")
def notebooks():
markup = dedent(
f"""\
## Live Coding: Notebooks
For a more persistent live-coding experience than a REPL,
you can open a Jupyter Notebook via {NBTERM}
by pressing `n`.
Each slide has a notebook attached to it.
The notebook kernel will be restarted between invocations,
but changes made to the notebook contents
will persist throughout your presentation.
By default, the notebook will initially be blank,
but you can also provide a path to a notebook on disk to initialize from.
This slide has a small example notebook attached to it - try it out!
"""
)
return Markdown(markup, justify="center")
@deck.slide(title="Options")
def notebooks():
markup = dedent(
f"""\
## Options
{SPIEL} has a variety of options that can be adjusted at runtime.
For example,
profiling information can be displayed in the footer to help you debug a slides that is rendering too slowly.
To see your current options, press `p`.
From that mode you can edit your options by pressing `e`.
"""
)
return Markdown(markup, justify="center")

@ -0,0 +1,57 @@
{
"cells": [
{
"cell_type": "markdown",
"source": [
"Welcome to the demo notebook!\n",
"\n",
"Not much to see here...\n",
"\n",
"This is powered by nbterm (https://github.com/davidbrochart/nbterm).\n",
"See their `README` for usage."
],
"metadata": {
"collapsed": false,
"pycharm": {
"name": "#%% md\n"
}
}
},
{
"cell_type": "code",
"execution_count": null,
"outputs": [],
"source": [
"print('foobar')\n",
"2 ** 10"
],
"metadata": {
"collapsed": false,
"pycharm": {
"name": "#%%\n"
}
}
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 2
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython2",
"version": "2.7.6"
}
},
"nbformat": 4,
"nbformat_minor": 0
}

@ -12,3 +12,7 @@ class UnknownModeError(SpielException):
class NoDeckFound(SpielException):
pass
class InvalidOptionValue(SpielException):
pass

@ -6,10 +6,10 @@ from rich.style import Style
from rich.table import Column, Table
from rich.text import Text
from spiel.modes import Mode
from spiel.rps import RPSCounter
from spiel.state import State
from spiel.utils import drop_nones, joinify
from .modes import Mode
from .rps import RPSCounter
from .state import State
from .utils import drop_nones, joinify
@dataclass
@ -37,7 +37,7 @@ class Footer:
style=Style(dim=True),
justify="right",
)
if self.state.profiling
if self.state.options.profiling
else None,
Column(
style=Style(dim=True),
@ -66,7 +66,7 @@ class Footer:
),
self.state.message,
Text(f"{self.rps_counter.renders_per_second() :.2f} RPS")
if self.state.profiling
if self.state.options.profiling
else None,
now().format("YYYY-MM-DD hh:mm A"),
Text(

@ -8,10 +8,10 @@ from rich.style import Style
from rich.table import Column, Table
from rich.text import Text
from spiel.constants import PACKAGE_NAME, __python_version__, __rich_version__, __version__
from spiel.input import INPUT_HANDLER_HELP, SpecialCharacters, has_ipython
from spiel.modes import Mode
from spiel.state import State
from .constants import PACKAGE_NAME, __python_version__, __rich_version__, __version__
from .input import INPUT_HANDLER_HELP, SpecialCharacters
from .modes import Mode
from .state import State
@dataclass
@ -54,8 +54,8 @@ class Help:
return Padding(
RenderGroup(
Align(action_table, align="center"),
Align(version_details(self.state.console), align="center"),
Align.center(action_table),
Align.center(version_details(self.state.console)),
),
pad=(0, 1),
)
@ -93,11 +93,4 @@ def version_details(console: Console) -> ConsoleRenderable:
end_section=True,
)
repl = "IPython" if has_ipython() else "builtin"
table.add_row(
"REPL",
Text(repl, style=Style(color="green" if repl == "IPython" else None)),
end_section=True,
)
return table

@ -12,7 +12,7 @@ from rich.segment import Segment
from rich.style import Style
from rich.text import Text
from spiel.utils import chunks
from .utils import chunks
class ImageSize(NamedTuple):

@ -1,8 +1,7 @@
from __future__ import annotations
import code
import contextlib
import os
import inspect
import string
import sys
import termios
@ -13,6 +12,7 @@ from enum import Enum, unique
from io import UnsupportedOperation
from itertools import product
from pathlib import Path
from textwrap import dedent
from typing import (
Any,
Callable,
@ -30,12 +30,16 @@ from typing import (
import typer
from rich.control import Control
from rich.text import Text
from toml import TomlDecodeError
from typer import Exit
from .constants import PACKAGE_NAME
from .constants import EDITOR, PACKAGE_NAME
from .example import Example
from .exceptions import DuplicateInputHandler
from .exceptions import DuplicateInputHandler, InvalidOptionValue
from .modes import Mode
from .notebooks import NOTEBOOKS
from .options import Options
from .repls import REPLS
from .state import State
LFLAG = 3
@ -182,6 +186,10 @@ def handle_input(
return handler(state)
def normalize_help(help: str) -> str:
return dedent(help).replace("\n", " ").strip()
def input_handler(
*characters: Character,
modes: Optional[Iterable[Mode]] = None,
@ -191,10 +199,12 @@ def input_handler(
) -> InputHandlerDecorator:
target_modes = list(modes or list(Mode))
def decorator(func: InputHandler) -> InputHandler:
def registrar(func: InputHandler) -> InputHandler:
for character, mode in product(characters, target_modes):
key: InputHandlerKey = (character, mode)
if key in handlers:
# Don't allow duplicate handlers to be registered inside this module,
# but DO let end-users register them.
if key in handlers and inspect.getmodule(func) == inspect.getmodule(input_handler):
raise DuplicateInputHandler(
f"{character} is already registered as an input handler for mode {mode}"
)
@ -203,7 +213,7 @@ def input_handler(
INPUT_HANDLER_HELP.append(
InputHandlerHelpInfo(
name=name or " ".join(word.capitalize() for word in func.__name__.split("_")),
help=help,
help=normalize_help(help),
characters=characters,
modes=target_modes,
)
@ -211,7 +221,7 @@ def input_handler(
return func
return decorator
return registrar
NOT_HELP = [Mode.SLIDE, Mode.DECK]
@ -241,6 +251,39 @@ def deck_mode(state: State) -> None:
state.mode = Mode.DECK
@input_handler(
"p",
help=f"Enter {Mode.OPTIONS} mode.",
)
def options_mode(state: State) -> None:
state.mode = Mode.OPTIONS
@input_handler(
"e",
modes=[Mode.OPTIONS],
help=f"Open your $EDITOR ([bold]{EDITOR}[/bold]) to edit options (as TOML).",
)
def edit_options(state: State) -> None:
with suspend_live(state):
new_toml = state.options.as_toml()
while True:
new_toml = _clean_toml(typer.edit(text=new_toml, extension=".toml", require_save=False))
try:
state.options = Options.from_toml(new_toml)
return
except TomlDecodeError as e:
new_toml = f"{new_toml}\n\n# Parse Error: {e}\n"
except InvalidOptionValue as e:
new_toml = f"{new_toml}\n\n# Invalid Option Value: {e}\n"
except Exception as e:
new_toml = f"{new_toml}\n\n# Error: {e}\n"
def _clean_toml(s: str) -> str:
return "\n".join(line for line in s.splitlines() if (line and not line.startswith("#")))
@input_handler(
SpecialCharacters.Right,
"f",
@ -282,7 +325,11 @@ def down_grid_row(state: State) -> None:
@input_handler(
"j",
modes=NOT_HELP,
help="Press the action key, then a slide number (e.g., [bold]17[/bold]), then press [bold]enter[/bold], to jump to that slide.",
help="""\
Press the action key, then a slide number (e.g., [bold]17[/bold]), then press [bold]enter[/bold], to jump to that slide.
If the slide number is unambiguous, the jump will happen without needing to press [bold]enter[/bold]
(e.g., you enter [bold]3[/bold] and there are only [bold]8[/bold] slides).
""",
)
def jump_to_slide(state: State) -> None:
slide_number = ""
@ -351,34 +398,23 @@ def suspend_live(state: State) -> Iterator[None]:
@input_handler(
"e",
modes=[Mode.SLIDE],
help=f"Open your $EDITOR ([bold]{os.getenv('EDITOR', 'not set')}[/bold]) on the source of an [bold]Example[/bold] slide. If the current slide is not an [bold]Example[/bold], do nothing.",
help=f"Open your $EDITOR ([bold]{EDITOR}[/bold]) on the source of an [bold]Example[/bold] slide. If the current slide is not an [bold]Example[/bold], do nothing.",
)
def edit_example(state: State) -> None:
s = state.current_slide
if isinstance(s, Example):
example = state.current_slide
if isinstance(example, Example):
with suspend_live(state):
s.source = typer.edit(text=s.source, extension=Path(s.name).suffix, require_save=False)
s.clear_cache()
def has_ipython() -> bool:
try:
import IPython
return True
except ImportError:
return False
def has_ipython_help_message() -> str:
return "[green]it is[/green]" if has_ipython() else "[red]it is not[/red]"
example.source = typer.edit(
text=example.source, extension=Path(example.name).suffix, require_save=False
)
example.clear_cache()
@input_handler(
"l",
name="Open REPL",
"i",
name="Start REPL",
modes=NOT_HELP,
help=f"Open your REPL. Uses [bold]IPython[/bold] if it is installed ({has_ipython_help_message()}), otherwise the standard Python REPL.",
help=f"Start an [link=https://ipython.readthedocs.io/en/stable/overview.html]IPython REPL[/link].",
)
def open_repl(state: State) -> None:
with suspend_live(state):
@ -387,26 +423,27 @@ def open_repl(state: State) -> None:
state.console.print(Control.move_to(0, 0))
try:
import IPython
from traitlets.config import Config
c = Config()
c.InteractiveShellEmbed.colors = "Neutral"
IPython.embed(config=c)
except ImportError:
code.InteractiveConsole().interact()
start_no_echo(sys.stdin)
REPLS[state.options.repl]()
finally:
start_no_echo(sys.stdin)
@input_handler(
"p",
help="Toggle profiling information.",
"n",
name="Open Notebook",
modes=NOT_HELP,
help=f"Open a Jupyter Notebook in your terminal using [link=https://github.com/davidbrochart/nbterm]nbterm[/link].",
)
def toggle_profiling(state: State) -> None:
state.toggle_profiling()
def open_notebook(state: State) -> None:
with suspend_live(state):
reset_tty(sys.stdin)
state.console.print(Control.clear())
state.console.print(Control.move_to(0, 0))
try:
NOTEBOOKS[state.options.notebook](state)
finally:
start_no_echo(sys.stdin)
@input_handler(

@ -15,10 +15,10 @@ from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer
from watchdog.observers.polling import PollingObserver
from spiel import Deck
from spiel.constants import DECK
from spiel.exceptions import NoDeckFound
from spiel.state import State
from .constants import DECK
from .deck import Deck
from .exceptions import NoDeckFound
from .state import State
def load_deck(deck_path: Path) -> Deck:
@ -26,7 +26,9 @@ def load_deck(deck_path: Path) -> Deck:
spec = importlib.util.spec_from_file_location(module_name, deck_path)
if spec is None:
raise FileNotFoundError(f"{deck_path} does not appear to be an importable Python module.")
raise FileNotFoundError(
f"{deck_path.resolve()} does not appear to be an importable Python module."
)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
@ -34,7 +36,7 @@ def load_deck(deck_path: Path) -> Deck:
try:
return getattr(module, DECK)
except AttributeError as e:
except AttributeError:
raise NoDeckFound(f"The module at {deck_path} does not have an attribute named {DECK}.")

@ -10,12 +10,13 @@ from rich.syntax import Syntax
from rich.text import Text
from typer import Argument, Exit, Option, Typer
from spiel.constants import PACKAGE_NAME, __version__
from spiel.help import version_details
from spiel.load import DeckReloader, DeckWatcher, load_deck
from spiel.modes import Mode
from spiel.present import present_deck
from spiel.state import State
from .constants import PACKAGE_NAME, __version__
from .help import version_details
from .load import DeckReloader, DeckWatcher, load_deck
from .modes import Mode
from .options import Options
from .present import present_deck
from .state import State
THIS_DIR = Path(__file__).resolve().parent
@ -49,10 +50,6 @@ def present(
default=1,
help="The slide number to start the presentation on.",
),
profiling: bool = Option(
default=False,
help="Whether to start presenting with profiling information enabled.",
),
watch: bool = Option(
default=False,
help="If enabled, reload the deck when the slide deck file changes.",
@ -65,14 +62,23 @@ def present(
"""
Present a deck.
"""
_present(path=path, mode=mode, slide=slide, profiling=profiling, watch=watch, poll=poll)
_present(path=path, mode=mode, slide=slide, watch=watch, poll=poll)
def _present(path: Path, mode: Mode, slide: int, profiling: bool, watch: bool, poll: bool) -> None:
def _present(path: Path, mode: Mode, slide: int, watch: bool, poll: bool) -> None:
console = Console()
options = Options()
try:
deck = load_deck(path)
except FileNotFoundError as e:
console.print(Text(f"Error: {e}", style=Style(color="red")))
raise Exit(code=1)
state = State(
console=Console(),
deck=load_deck(path),
profiling=profiling,
console=console,
deck=deck,
options=options,
)
state.mode = mode
@ -85,7 +91,7 @@ def _present(path: Path, mode: Mode, slide: int, profiling: bool, watch: bool, p
)
try:
with watcher:
with state, watcher:
present_deck(state)
except KeyboardInterrupt:
raise Exit(code=0)
@ -130,7 +136,7 @@ def present_demo() -> None:
"""
Present the demo deck.
"""
_present(path=DEMO_SOURCE, mode=Mode.SLIDE, slide=0, profiling=False, watch=False, poll=False)
_present(path=DEMO_SOURCE, mode=Mode.SLIDE, slide=0, watch=False, poll=False)
@demo.command()

@ -6,3 +6,4 @@ class Mode(str, Enum):
SLIDE = "slide"
DECK = "deck"
HELP = "help"
OPTIONS = "options"

@ -0,0 +1,30 @@
from typing import TYPE_CHECKING, Callable, MutableMapping
if TYPE_CHECKING:
from .state import State
from nbterm import Notebook
NotebookExecutor = Callable[["State"], None]
NOTEBOOKS: MutableMapping[str, NotebookExecutor] = {}
def notebook(name: str) -> Callable[[NotebookExecutor], NotebookExecutor]:
def registrar(executor: NotebookExecutor) -> NotebookExecutor:
NOTEBOOKS[name] = executor
return executor
return registrar
@notebook("nbterm")
def nbterm(state: "State") -> None:
save_path = state.tmp_dir / f"{id(state.current_slide)}.ipynb"
nb = Notebook(state.current_slide.notebook or save_path)
state.current_slide.notebook = save_path
nb.show()
nb.save(save_path)

@ -0,0 +1,69 @@
from dataclasses import asdict, dataclass, fields
from pathlib import Path
from typing import Any, Mapping
import toml
from rich.align import Align
from rich.console import ConsoleRenderable
from rich.padding import Padding
from rich.table import Column, Table
from .constants import PACKAGE_NAME
from .exceptions import InvalidOptionValue
from .notebooks import NOTEBOOKS
from .repls import REPLS
@dataclass
class Options:
profiling: bool = False
repl: str = "ipython"
notebook: str = "nbterm"
def __post_init__(self) -> None:
if self.repl not in REPLS:
raise InvalidOptionValue(f"repl must be one of: {set(REPLS.keys())}")
if self.notebook not in NOTEBOOKS:
raise InvalidOptionValue(f"notebook must be one of: {set(NOTEBOOKS.keys())}")
def as_dict(self) -> Mapping[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, d: Mapping[str, Any]) -> "Options":
fields_by_name = {field.name: field for field in fields(cls)}
only_valid = {k: fields_by_name[k].type(v) for k, v in d.items() if k in fields_by_name}
return cls(**only_valid)
def as_toml(self) -> str:
return toml.dumps({PACKAGE_NAME: self.as_dict()})
@classmethod
def from_toml(cls, t: str) -> "Options":
return cls.from_dict(toml.loads(t).get(PACKAGE_NAME, {}))
def save(self, path: Path) -> Path:
path.write_text(self.as_toml())
return path
@classmethod
def load(cls, path: Path) -> "Options":
return cls.from_toml(path.read_text())
def __rich__(self) -> ConsoleRenderable:
table = Table(
Column("Option"),
Column("Type"),
Column("Value"),
)
fields_by_name = {field.name: field for field in fields(self)}
for key, value in self.as_dict().items():
table.add_row(key, fields_by_name[key].type.__name__, str(value))
return Padding(
Align.center(table),
pad=(0, 1),
)

@ -83,6 +83,8 @@ def present_deck(state: State) -> None:
split_layout_into_deck_grid(body, state)
elif state.mode is Mode.HELP:
body.update(help)
elif state.mode is Mode.OPTIONS:
body.update(state.options)
else: # pragma: unreachable
raise UnknownModeError(f"Unrecognized mode: {state.mode!r}")

@ -1,6 +1,7 @@
import inspect
from dataclasses import dataclass
from typing import Any, Callable, Dict, Mapping
from pathlib import Path
from typing import Any, Callable, Dict, Mapping, Optional
from rich.console import ConsoleRenderable
@ -10,6 +11,7 @@ from .triggers import Triggers
@dataclass
class Presentable: # Why not an ABC? https://github.com/python/mypy/issues/5374
title: str = ""
notebook: Optional[Path] = None
def render(self, triggers: Triggers) -> ConsoleRenderable:
raise NotImplementedError

@ -0,0 +1,30 @@
import code
from typing import Callable, MutableMapping
import IPython
from traitlets.config import Config
REPLExecutor = Callable[[], None]
REPLS: MutableMapping[str, REPLExecutor] = {}
def repl(name: str) -> Callable[[REPLExecutor], REPLExecutor]:
def registrar(executor: REPLExecutor) -> REPLExecutor:
REPLS[name] = executor
return executor
return registrar
@repl("builtin")
def builtin() -> None:
code.InteractiveConsole().interact()
@repl("ipython")
def ipython() -> None:
c = Config()
c.InteractiveShellEmbed.colors = "Neutral"
IPython.embed(config=c)

@ -2,7 +2,7 @@ from collections import deque
from time import monotonic
from typing import Deque, Optional
from spiel.constants import TARGET_RPS
from .constants import TARGET_RPS
class RPSCounter:

@ -1,13 +1,21 @@
from __future__ import annotations
from dataclasses import dataclass, field
from functools import cached_property
from pathlib import Path
from tempfile import TemporaryDirectory
from time import monotonic
from typing import Callable, List, Union
from types import TracebackType
from typing import Callable, List, Optional, Type, Union
from rich.console import Console
from rich.style import Style
from rich.text import Text
from . import Deck
from .constants import PACKAGE_NAME
from .deck import Deck
from .modes import Mode
from .options import Options
from .presentable import Presentable
TextLike = Union[Text, Callable[[], Text]]
@ -17,11 +25,11 @@ TextLike = Union[Text, Callable[[], Text]]
class State:
console: Console
deck: Deck
options: Options
_current_slide_idx: int = 0
_mode: Mode = Mode.SLIDE
_message: TextLike = Text("")
trigger_times: List[float] = field(default_factory=list)
profiling: bool = False
@property
def mode(self) -> Mode:
@ -88,6 +96,21 @@ class State:
self.trigger_times.clear()
self.trigger()
def toggle_profiling(self) -> bool:
self.profiling = not self.profiling
return self.profiling
@cached_property
def _tmp_dir(self) -> TemporaryDirectory:
return TemporaryDirectory(prefix=f"{PACKAGE_NAME}-")
@cached_property
def tmp_dir(self) -> Path:
return Path(self._tmp_dir.name)
def __enter__(self) -> State:
return self
def __exit__(
self,
exctype: Optional[Type[BaseException]],
excinst: Optional[BaseException],
exctb: Optional[TracebackType],
) -> None:
self._tmp_dir.cleanup()

@ -5,7 +5,8 @@ from textwrap import dedent
import pytest
from rich.console import Console
from spiel import Deck
from spiel import Deck, Options
from spiel.constants import DECK
from spiel.slide import Slide
from spiel.state import State
@ -32,17 +33,24 @@ def console(output: StringIO) -> Console:
@pytest.fixture
def three_slide_state(console: Console, three_slide_deck: Deck) -> State:
return State(console=console, deck=three_slide_deck)
def three_slide_options() -> Options:
return Options()
@pytest.fixture
def three_slide_state(
console: Console, three_slide_deck: Deck, three_slide_options: Options
) -> State:
return State(console=console, deck=three_slide_deck, options=three_slide_options)
@pytest.fixture
def empty_deck_source() -> str:
return dedent(
"""\
f"""\
from spiel import Deck
DECK = Deck(name="deck")
{DECK} = Deck(name="deck")
"""
)

@ -37,6 +37,13 @@ def test_version(runner: CliRunner) -> None:
assert __version__ in result.stdout
def test_plain_version(runner: CliRunner) -> None:
result = runner.invoke(app, ["version", "--plain"])
assert result.exit_code == 0
assert __version__ in result.stdout
def test_clean_keyboard_interrupt(runner: CliRunner, mocker: MockFixture) -> None:
mock = mocker.patch("spiel.main.present_deck", MagicMock(side_effect=KeyboardInterrupt()))
@ -46,6 +53,12 @@ def test_clean_keyboard_interrupt(runner: CliRunner, mocker: MockFixture) -> Non
assert result.exit_code == 0
def test_present_deck_on_missing_file(runner: CliRunner, tmp_path: Path) -> None:
result = runner.invoke(app, ["present", str(tmp_path / "missing.py")])
assert result.exit_code == 1
@pytest.mark.parametrize("mode", list(Mode))
@pytest.mark.parametrize("stdin", ["", "s", "d", "h", "p"])
def test_display_demo_deck(runner: CliRunner, mode: Mode, stdin: str) -> None:

@ -1,4 +1,7 @@
import pytest
from spiel import Deck
from spiel.deck import get_function_body
from spiel.slide import Slide
@ -14,3 +17,20 @@ def test_can_add_slide_to_deck(three_slide_deck: Deck) -> None:
def test_iterate_yields_deck_slides(three_slide_deck: Deck) -> None:
assert list(iter(three_slide_deck)) == three_slide_deck.slides
def test_deck_contains_its_slides(three_slide_deck: Deck) -> None:
for slide in three_slide_deck:
assert slide in three_slide_deck
def test_get_function_body() -> None:
def foo() -> None: # pragma: never runs
...
assert get_function_body(foo) == "...\n"
def test_get_function_body_raises_on_function_with_no_source() -> None:
with pytest.raises(TypeError):
get_function_body(str)

@ -10,14 +10,17 @@ from rich.console import Console
from rich.text import Text
from typer import Exit
from spiel import Deck, Slide
from spiel import Deck, Options, Slide
from spiel.input import (
INPUT_HANDLERS,
InputHandler,
deck_mode,
edit_example,
edit_options,
exit,
jump_to_slide,
next_slide,
open_notebook,
open_repl,
previous_slide,
slide_mode,
@ -57,12 +60,22 @@ def test_kill(three_slide_state: State) -> None:
exit(three_slide_state)
@given(
input_handlers=st.lists(
st.sampled_from(list(set(INPUT_HANDLERS.values()) - {exit, jump_to_slide, open_repl}))
TESTABLE_INPUT_HANDLERS = list(
set(INPUT_HANDLERS.values()).difference(
{
exit,
jump_to_slide,
open_repl,
open_notebook,
edit_options,
edit_example,
}
)
)
@settings(max_examples=1_000 if os.getenv("CI") else 100)
@given(input_handlers=st.lists(st.sampled_from(TESTABLE_INPUT_HANDLERS)))
@settings(max_examples=2_000 if os.getenv("CI") else 200)
def test_input_sequences_dont_crash(input_handlers: List[InputHandler]) -> None:
state = State(
console=Console(),
@ -76,6 +89,7 @@ def test_input_sequences_dont_crash(input_handlers: List[InputHandler]) -> None:
for n in range(30)
],
),
options=Options(),
)
for input_handler in input_handlers:

@ -4,36 +4,11 @@ import pytest
from pytest_mock import MockFixture
from rich.console import Console
from spiel.exceptions import DuplicateInputHandler
from spiel.input import (
SPECIAL_CHARACTERS,
InputHandlers,
SpecialCharacters,
get_character,
handle_input,
input_handler,
)
from spiel.input import SPECIAL_CHARACTERS, SpecialCharacters, get_character, handle_input
from spiel.modes import Mode
from spiel.state import State
@pytest.fixture
def handlers() -> InputHandlers:
return {} # type: ignore
def test_register_already_registered_raises_error(handlers: InputHandlers) -> None:
@input_handler("a", help="")
def a(state: State) -> None: # pragma: never runs
pass
with pytest.raises(DuplicateInputHandler):
@input_handler("a", help="")
def a(state: State) -> None: # pragma: never runs
pass
@pytest.mark.parametrize("input, expected", SPECIAL_CHARACTERS.items())
def test_get_character_recognizes_special_characters(
input: str, expected: SpecialCharacters

@ -6,7 +6,7 @@ from time import sleep
import pytest
from rich.console import Console
from spiel import Deck
from spiel import Deck, Options
from spiel.constants import DECK
from spiel.exceptions import NoDeckFound
from spiel.load import DeckReloader, DeckWatcher, load_deck
@ -34,7 +34,7 @@ def test_reloader_triggers_when_file_modified(
console: Console,
output: StringIO,
) -> None:
state = State(console=Console(), deck=load_deck(file_with_empty_deck))
state = State(console=Console(), deck=load_deck(file_with_empty_deck), options=Options())
reloader = DeckReloader(state=state, deck_path=file_with_empty_deck)
with DeckWatcher(event_handler=reloader, path=file_with_empty_deck, poll=True):
@ -42,10 +42,10 @@ def test_reloader_triggers_when_file_modified(
file_with_empty_deck.write_text(
dedent(
"""\
f"""\
from spiel import Deck
DECK = Deck(name="modified")
{DECK} = Deck(name="modified")
"""
)
)
@ -69,7 +69,7 @@ def test_reloader_captures_error_in_message(
console: Console,
output: StringIO,
) -> None:
state = State(console=Console(), deck=load_deck(file_with_empty_deck))
state = State(console=Console(), deck=load_deck(file_with_empty_deck), options=Options())
reloader = DeckReloader(state=state, deck_path=file_with_empty_deck)
with DeckWatcher(event_handler=reloader, path=file_with_empty_deck, poll=True):
@ -77,10 +77,10 @@ def test_reloader_captures_error_in_message(
file_with_empty_deck.write_text(
dedent(
"""\
f"""\
from spiel import Deck
DECK = Deck(name="modified")
{DECK} = Deck(name="modified")
foobar
"""
)

@ -0,0 +1,56 @@
from typing import Any
import pytest
from _pytest.tmpdir import TempPathFactory
from hypothesis import given, infer
from hypothesis import strategies as st
from hypothesis.strategies import SearchStrategy
from rich.console import Console
from spiel import Options
from spiel.exceptions import InvalidOptionValue
from spiel.notebooks import NOTEBOOKS
from spiel.repls import REPLS
def valid_options() -> SearchStrategy[Options]:
return st.builds(
Options,
profiling=infer,
repl=st.sampled_from(list(REPLS.keys())),
notebook=st.sampled_from(list(NOTEBOOKS.keys())),
)
@given(o=valid_options())
def test_round_trip_to_dict(o: Options) -> None:
assert o == Options.from_dict(o.as_dict())
@given(o=valid_options())
def test_round_trip_to_toml(o: Options) -> None:
assert o == Options.from_toml(o.as_toml())
@given(o=valid_options())
def test_round_trip_to_file(o: Options, tmp_path_factory: TempPathFactory) -> None:
dir = tmp_path_factory.mktemp(basename="options-roundtrip")
path = dir / "options.toml"
assert o == Options.load(o.save(path))
def test_can_render_options(console: Console, three_slide_options: Options) -> None:
console.print(three_slide_options)
@pytest.mark.parametrize(
"key, value",
[
("repl", "foobar"),
("notebook", "foobar"),
],
)
def test_reject_invalid_option_values(key: str, value: Any) -> None:
with pytest.raises(InvalidOptionValue):
Options(**{key: value})

@ -3,7 +3,7 @@ from rich.console import Console
from rich.style import Style
from rich.text import Text
from spiel import Deck
from spiel import Deck, Options
from spiel.state import State, TextLike
@ -61,9 +61,9 @@ def test_previous_from_first_slide_stays_put(three_slide_state: State) -> None:
(120, 4),
],
)
def test_deck_grid_width(width: int, expected: int, three_slide_deck: Deck) -> None:
def test_deck_grid_width(width: int, expected: int) -> None:
console = Console(width=width)
state = State(console=console, deck=three_slide_deck)
state = State(console=console, deck=Deck(name="deck"), options=Options())
assert state.deck_grid_width == expected
@ -94,3 +94,9 @@ def test_clear_message(three_slide_state: State) -> None:
three_slide_state.clear_message()
assert three_slide_state.message == Text("")
def test_tmp_dir_lifecycle(three_slide_state: State) -> None:
with three_slide_state:
assert three_slide_state.tmp_dir.exists()
assert not three_slide_state.tmp_dir.exists()

Loading…
Cancel
Save