Add Decks (#2)

pull/3/head
Josh Karpel 3 years ago committed by GitHub
parent 172eb33c19
commit 52de31de1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -3,11 +3,15 @@
branch = True
source =
orate/
spiel/
tests/
[report]
skip_empty = True
show_missing = True
sort = -cover
exclude_lines =
def __repr__
if self\.debug
@ -18,3 +22,6 @@ exclude_lines =
if 0:
if False:
if __name__ == .__main__.:
pragma: debugging
pragma: never runs

@ -11,7 +11,7 @@ jobs:
strategy:
fail-fast: false
matrix:
platform: [ubuntu-latest, macos-latest, windows-latest]
platform: [ubuntu-latest, macos-latest]
python-version: [3.9]
defaults:
run:
@ -45,11 +45,8 @@ jobs:
run: poetry install --no-interaction
- name: Run tests
run: poetry run pytest --cov --cov-report=xml
- name: Check types
run: poetry run mypy
- name: Upload coverage
uses: codecov/codecov-action@v1
with:
env_vars: PLATFORM,PYTHON_VERSION
fail_ci_if_error: true
verbose: true

@ -24,15 +24,6 @@ repos:
- id: python-no-eval
- id: python-no-log-warn
- id: python-use-type-annotations
- repo: https://github.com/Lucas-C/pre-commit-hooks
rev: v1.1.9
hooks:
- id: remove-crlf
- repo: https://github.com/pappasam/toml-sort
rev: v0.18.0
hooks:
- id: toml-sort
args: [--in-place]
- repo: https://github.com/psf/black
rev: 20.8b1
hooks:
@ -41,3 +32,7 @@ repos:
rev: 5.8.0
hooks:
- id: isort
- repo: https://github.com/Lucas-C/pre-commit-hooks
rev: v1.1.9
hooks:
- id: remove-crlf

@ -1,3 +1,3 @@
# orate
# spiel
[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/JoshKarpel/orate/main.svg)](https://results.pre-commit.ci/badge/github/JoshKarpel/orate/main.svg)
[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/JoshKarpel/spiel/main.svg)](https://results.pre-commit.ci/latest/github/JoshKarpel/spiel/main)

@ -0,0 +1,89 @@
import socket
from datetime import datetime
from rich.align import Align
from rich.console import ConsoleRenderable
from rich.layout import Layout
from rich.markdown import Markdown
from rich.style import Style
from rich.text import Text
from spiel import Deck, Slide
DECK = Deck(name="Spiel Demo Deck")
left_markup = """\
## What is Spiel?
[Spiel](https://github.com/JoshKarpel/spiel) is a framework for building slide decks in Python.
Spiel uses [Rich](https://rich.readthedocs.io/) to render slide content.
"""
right_markup = """\
## Why Spiel?
It's fun!
It's weird!
"""
layout = Layout()
left = Layout(
Markdown(
left_markup,
justify="center",
),
ratio=2,
)
buffer = Layout(" ")
right = Layout(
Markdown(
right_markup,
justify="center",
),
ratio=2,
)
layout.split_row(left, buffer, right)
DECK.add_slide(
Slide(
content=layout,
)
)
class Now:
def __rich__(self) -> ConsoleRenderable:
return Align(
Text(
f"Right now, at {datetime.now()}!",
style=Style(color="bright_cyan", bold=True, italic=True),
),
align="center",
)
DECK.add_slide(
Slide(
content=Now(),
)
)
class Where:
def __rich__(self) -> ConsoleRenderable:
return Align(
Text(
f"Right here, at {socket.gethostname()}!",
style=Style(color="bright_cyan", bold=True, italic=True),
),
align="right",
)
DECK.add_slide(
Slide(
content=Where(),
)
)

@ -0,0 +1,14 @@
import string
from random import sample
from rich.text import Text
from spiel import Deck, Slide
DECK = Deck(
name="Many Slides",
slides=[
Slide(Text(f"This is slide {n + 1}"), title="".join(sample(string.ascii_letters, 30)))
for n in range(30)
],
)

@ -1,6 +1,8 @@
[mypy]
files = orate/*.py, tests/*.py
pretty = true
pretty = false
files = spiel/*.py, tests/*.py
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_untyped_defs = true

@ -1,3 +0,0 @@
from .main import app
app()

@ -1,13 +0,0 @@
from pathlib import Path
import typer
from rich.console import Console
from rich.text import Text
app = typer.Typer()
console = Console()
@app.command()
def display(path: Path) -> None:
console.print(Text(str(path), justify="center"))

52
poetry.lock generated

@ -88,6 +88,14 @@ apipkg = ">=1.4"
[package.extras]
testing = ["pre-commit"]
[[package]]
name = "filelock"
version = "3.0.12"
description = "A platform independent file lock."
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "iniconfig"
version = "1.1.1"
@ -214,6 +222,34 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
py = "*"
pytest = ">=3.10"
[[package]]
name = "pytest-mock"
version = "3.5.1"
description = "Thin-wrapper around the mock package for easier use with pytest"
category = "dev"
optional = false
python-versions = ">=3.5"
[package.dependencies]
pytest = ">=5.0"
[package.extras]
dev = ["pre-commit", "tox", "pytest-asyncio"]
[[package]]
name = "pytest-mypy"
version = "0.8.1"
description = "Mypy static type checker plugin for Pytest"
category = "dev"
optional = false
python-versions = ">=3.5"
[package.dependencies]
attrs = ">=19.0"
filelock = ">=3.0"
mypy = {version = ">=0.780", markers = "python_version >= \"3.9\""}
pytest = ">=3.5"
[[package]]
name = "pytest-watch"
version = "4.2.0"
@ -307,7 +343,7 @@ python-versions = "*"
name = "watchdog"
version = "2.0.2"
description = "Filesystem events monitoring"
category = "dev"
category = "main"
optional = false
python-versions = ">=3.6"
@ -317,7 +353,7 @@ watchmedo = ["PyYAML (>=3.10)", "argh (>=0.24.1)"]
[metadata]
lock-version = "1.1"
python-versions = "^3.9"
content-hash = "aa04bd0437cc73dbb4b38767d062a7b9cd4f7ae9fab2ebc2966afe4a5b6285a4"
content-hash = "1c49eed51b797e85e879ea31f75d419230eb0838ecf4e7e1ba6984700768c74f"
[metadata.files]
apipkg = [
@ -405,6 +441,10 @@ execnet = [
{file = "execnet-1.8.0-py2.py3-none-any.whl", hash = "sha256:7a13113028b1e1cc4c6492b28098b3c6576c9dccc7973bfe47b342afadafb2ac"},
{file = "execnet-1.8.0.tar.gz", hash = "sha256:b73c5565e517f24b62dea8a5ceac178c661c4309d3aa0c3e420856c072c411b4"},
]
filelock = [
{file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"},
{file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"},
]
iniconfig = [
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
@ -469,6 +509,14 @@ pytest-forked = [
{file = "pytest-forked-1.3.0.tar.gz", hash = "sha256:6aa9ac7e00ad1a539c41bec6d21011332de671e938c7637378ec9710204e37ca"},
{file = "pytest_forked-1.3.0-py2.py3-none-any.whl", hash = "sha256:dc4147784048e70ef5d437951728825a131b81714b398d5d52f17c7c144d8815"},
]
pytest-mock = [
{file = "pytest-mock-3.5.1.tar.gz", hash = "sha256:a1e2aba6af9560d313c642dae7e00a2a12b022b80301d9d7fc8ec6858e1dd9fc"},
{file = "pytest_mock-3.5.1-py3-none-any.whl", hash = "sha256:379b391cfad22422ea2e252bdfc008edd08509029bcde3c25b2c0bd741e0424e"},
]
pytest-mypy = [
{file = "pytest-mypy-0.8.1.tar.gz", hash = "sha256:1fa55723a4bf1d054fcba1c3bd694215a2a65cc95ab10164f5808afd893f3b11"},
{file = "pytest_mypy-0.8.1-py3-none-any.whl", hash = "sha256:6e68e8eb7ceeb7d1c83a1590912f784879f037b51adfb9c17b95c6b2fc57466b"},
]
pytest-watch = [
{file = "pytest-watch-4.2.0.tar.gz", hash = "sha256:06136f03d5b361718b8d0d234042f7b2f203910d8568f63df2f866b547b3d4b9"},
]

@ -12,7 +12,7 @@ profile = "black"
line_length = 100
[tool.poetry]
name = "orate"
name = "spiel"
version = "0.1.0"
description = "Present slides in your terminal."
authors = ["JoshKarpel <josh.karpel@gmail.com>"]
@ -22,6 +22,7 @@ license = "MIT"
python = "^3.9"
rich = "^10.0.0"
typer = "^0.3.2"
watchdog = "^2.0.2"
[tool.poetry.dev-dependencies]
pytest = "^6.2.2"
@ -29,10 +30,12 @@ pytest-watch = "^4.2.0"
pytest-cov = "^2.11.1"
pytest-xdist = "^2.2.1"
mypy = "^0.812"
pytest-mypy = "^0.8.1"
pytest-mock = "^3.5.1"
[tool.poetry.scripts]
orate = 'orate.main:app'
spiel = 'spiel.main:app'
[tool.pytest.ini_options]
addopts = ['--strict-markers']
testpaths = ["tests"]
addopts = ['--strict-markers', '--mypy']
testpaths = ["tests", "spiel", "examples"]

@ -0,0 +1,2 @@
from .constants import __version__
from .slides import Deck, Slide

@ -0,0 +1,4 @@
from .constants import PACKAGE_NAME
from .main import app
app(prog_name=PACKAGE_NAME)

@ -0,0 +1,6 @@
from importlib import metadata
PACKAGE_NAME = "spiel"
__version__ = metadata.version(PACKAGE_NAME)
DECK = "DECK"

@ -0,0 +1,14 @@
class SpielException(Exception):
pass
class DuplicateInputHandler(SpielException):
pass
class UnknownModeError(SpielException):
pass
class NoDeckFound(SpielException):
pass

@ -0,0 +1,47 @@
from dataclasses import dataclass
from datetime import date
from rich.console import ConsoleRenderable
from rich.style import Style
from rich.table import Column, Table
from spiel.modes import Mode
from spiel.state import Stateful
from spiel.utils import joinify
@dataclass
class Footer(Stateful):
@property
def longest_slide_number_length(self) -> int:
num_slides = len(self.state.deck)
return len(str(num_slides))
def __rich__(self) -> ConsoleRenderable:
grid = Table.grid(
Column(
style=Style(dim=True),
justify="left",
),
Column(
style=Style(dim=True),
justify="center",
),
Column(
style=Style(dim=True),
justify="right",
),
expand=True,
)
grid.add_row(
joinify(
" | ",
[
self.state.deck.name,
self.state.current_slide.title if self.state.mode is Mode.SLIDE else None,
],
),
date.today().isoformat(),
f"[{self.state.current_slide_idx + 1:>0{self.longest_slide_number_length}d} / {len(self.state.deck)}]",
)
return grid

@ -0,0 +1,167 @@
from __future__ import annotations
import sys
import termios
from contextlib import contextmanager
from enum import Enum, unique
from itertools import product
from typing import Callable, Iterator, MutableMapping, NoReturn, Optional, TextIO, Tuple, Union
from .exceptions import DuplicateInputHandler
from .modes import Mode
from .state import State
IFLAG = 0
OFLAG = 1
CFLAG = 2
LFLAG = 3
ISPEED = 4
OSPEED = 5
CC = 6
@contextmanager
def no_echo() -> Iterator[None]:
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
mode = old.copy()
mode[LFLAG] = mode[LFLAG] & ~(termios.ECHO | termios.ICANON)
mode[CC][termios.VMIN] = 1
mode[CC][termios.VTIME] = 0
try:
termios.tcsetattr(fd, termios.TCSADRAIN, mode)
yield
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
@unique
class SpecialCharacters(Enum):
Up = "up"
Down = "down"
Right = "right"
Left = "left"
CtrlUp = "ctrl-up"
CtrlDown = "ctrl-down"
CtrlRight = "ctrl-right"
CtrlLeft = "ctrl-left"
ShiftUp = "shift-up"
ShiftDown = "shift-down"
ShiftRight = "shift-right"
ShiftLeft = "shift-left"
CtrlShiftUp = "ctrl-shift-up"
CtrlShiftDown = "ctrl-shift-down"
CtrlShiftRight = "ctrl-shift-right"
CtrlShiftLeft = "ctrl-shift-left"
Backspace = "backspace"
CtrlSpace = "ctrl-space"
Enter = "enter"
@classmethod
def from_character(cls, character: str) -> SpecialCharacters:
return SPECIAL_CHARACTERS[character]
SPECIAL_CHARACTERS = {
"\x1b[A": SpecialCharacters.Up,
"\x1b[B": SpecialCharacters.Down,
"\x1b[C": SpecialCharacters.Right,
"\x1b[D": SpecialCharacters.Left,
"\x1b[1;5A": SpecialCharacters.CtrlUp,
"\x1b[1;5B": SpecialCharacters.CtrlDown,
"\x1b[1;5C": SpecialCharacters.CtrlRight,
"\x1b[1;5D": SpecialCharacters.CtrlLeft,
"\x1b[1;2A": SpecialCharacters.ShiftUp,
"\x1b[1;2B": SpecialCharacters.ShiftDown,
"\x1b[1;2C": SpecialCharacters.ShiftRight,
"\x1b[1;2D": SpecialCharacters.ShiftLeft,
"\x1b[1;6A": SpecialCharacters.CtrlShiftUp,
"\x1b[1;6B": SpecialCharacters.CtrlShiftDown,
"\x1b[1;6C": SpecialCharacters.CtrlShiftRight,
"\x1b[1;6D": SpecialCharacters.CtrlShiftLeft,
"\x7f": SpecialCharacters.Backspace,
"\x00": SpecialCharacters.CtrlSpace,
"\n": SpecialCharacters.Enter,
}
ARROWS = [
SpecialCharacters.Up,
SpecialCharacters.Down,
SpecialCharacters.Right,
SpecialCharacters.Left,
]
def get_character(stream: TextIO) -> Union[str, SpecialCharacters]:
result = stream.read(1)
if result[-1] == "\x1b":
result += stream.read(2)
if result[-1] == "1":
result += stream.read(3)
try:
return SpecialCharacters.from_character(result)
except KeyError:
return result
Character = Union[str, SpecialCharacters]
InputHandler = Callable[[State], Optional[NoReturn]]
InputHandlerKey = Tuple[Character, Mode]
InputHandlerDecorator = Callable[[InputHandler], InputHandler]
InputHandlers = MutableMapping[InputHandlerKey, InputHandler]
INPUT_HANDLERS: InputHandlers = {} # type: ignore
def handle_input(state: State, stream: TextIO) -> Optional[NoReturn]:
character = get_character(stream)
try:
handler = INPUT_HANDLERS[(character, state.mode)]
except KeyError:
return None
return handler(state)
def action(
*characters: Character,
modes: Optional[Iterator[Mode]] = None,
handlers: InputHandlers = INPUT_HANDLERS,
) -> InputHandlerDecorator:
def decorator(func: InputHandler) -> InputHandler:
for character, mode in product(characters, modes or list(Mode)):
key: InputHandlerKey = (character, mode)
if key in handlers:
raise DuplicateInputHandler(
f"{character} is already registered as an input handler for mode {mode}"
)
handlers[key] = func
return func
return decorator
@action(SpecialCharacters.Right)
def next_slide(state: State) -> None:
state.next_slide()
@action(SpecialCharacters.Left)
def previous_slide(state: State) -> None:
state.previous_slide()
@action("d")
def deck_mode(state: State) -> None:
state.mode = Mode.DECK
@action("s")
def slide_mode(state: State) -> None:
state.mode = Mode.SLIDE

@ -0,0 +1,73 @@
from __future__ import annotations
import importlib.util
import sys
from dataclasses import dataclass
from pathlib import Path
from types import TracebackType
from typing import ContextManager, Optional, Type
from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer
from watchdog.observers.polling import PollingObserver
from spiel.constants import DECK
from spiel.exceptions import NoDeckFound
from spiel.slides import Deck
from spiel.state import State
def load_deck(deck_path: Path) -> Deck:
module_name = "__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.")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module) # type: ignore
try:
return getattr(module, DECK)
except AttributeError as e:
raise NoDeckFound(f"The module at {deck_path} does not have an attribute named {DECK}.")
@dataclass
class DeckReloader(FileSystemEventHandler):
state: State
deck_path: Path
def on_modified(self, event: FileSystemEvent) -> None:
self.state.deck = load_deck(self.deck_path)
def __hash__(self) -> int:
return hash((type(self), id(self)))
@dataclass
class DeckWatcher(ContextManager):
event_handler: FileSystemEventHandler
path: Path
poll: bool = False
observer: Optional[Observer] = None
def __enter__(self) -> DeckWatcher:
self.observer = (PollingObserver if self.poll else Observer)(timeout=0.1)
self.observer.schedule(self.event_handler, str(self.path))
self.observer.start()
return self
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_value: Optional[BaseException],
traceback: Optional[TracebackType],
) -> Optional[bool]:
if self.observer is not None:
self.observer.stop()
self.observer.join()
return None

@ -0,0 +1,42 @@
from contextlib import nullcontext
from pathlib import Path
from rich.console import Console
from rich.text import Text
from typer import Argument, Option, Typer
from spiel.constants import PACKAGE_NAME, __version__
from spiel.load import DeckReloader, DeckWatcher, load_deck
from spiel.present import present_deck
from spiel.state import State
app = Typer()
console = Console()
@app.command()
def present(
path: Path = Argument(..., help="The path to the slide deck file."),
watch: bool = Option(
default=False, help="If enabled, reload the deck when the slide deck file changes."
),
poll: bool = Option(
default=False,
help="If enabled, poll the filesystem for changes (implies --watch). Use this option on systems that don't support file modification notifications.",
),
) -> None:
state = State(deck=load_deck(path))
watcher = (
DeckWatcher(event_handler=DeckReloader(state, path), path=path, poll=poll)
if (watch or poll)
else nullcontext()
)
with watcher:
present_deck(console, state)
@app.command()
def version() -> None:
console.print(Text(f"{PACKAGE_NAME} {__version__}"))

@ -0,0 +1,6 @@
from enum import Enum
class Mode(Enum):
SLIDE = "slide"
DECK = "deck"

@ -0,0 +1,80 @@
import sys
from itertools import islice
from rich.console import Console
from rich.layout import Layout
from rich.live import Live
from rich.panel import Panel
from rich.style import Style
from .exceptions import UnknownModeError
from .footer import Footer
from .input import handle_input, no_echo
from .modes import Mode
from .state import State
from .utils import joinify
def present_deck(console: Console, state: State) -> None:
def get_renderable() -> Layout:
footer = Layout(Footer(state), name="footer", size=1)
current_slide = state.deck[state.current_slide_idx]
body = Layout(name="body", ratio=1)
if state.mode is Mode.SLIDE:
body.update(current_slide.content)
elif state.mode is Mode.DECK:
n = console.size.width // 30
row_of_current_slide = state.current_slide_idx // n
slides = islice(
enumerate(state.deck.slides, start=1),
n * max(0, row_of_current_slide - (n // 2)) if n ** 2 < len(state.deck) else 0,
None,
)
rows = [Layout(name=str(r)) for r in range(n)]
cols = [[Layout(name=f"{r}-{c}") for c in range(n)] for r, _ in enumerate(rows)]
body.split_column(*rows)
for row, layouts in zip(rows, cols):
for layout in layouts:
slide_idx, slide = next(slides, (None, None))
if slide is None:
layout.update("")
else:
is_active_slide = slide is state.current_slide
layout.update(
Panel(
slide.content,
title=joinify(" | ", [slide_idx, slide.title]),
border_style=Style(
color="bright_cyan" if is_active_slide else None,
dim=not is_active_slide,
),
)
)
row.split_row(*layouts)
else:
raise UnknownModeError(f"Unrecognized mode: {state.mode!r}")
root = Layout(name="root")
root.split_column(body, footer)
return root
with no_echo(), Live(
get_renderable=get_renderable,
console=console,
screen=True,
auto_refresh=True,
refresh_per_second=10,
vertical_overflow="visible",
) as live:
try:
while True:
handle_input(state, sys.stdin)
live.refresh()
except Exception:
live.stop()
console.print_exception(show_locals=True)

@ -0,0 +1,30 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List, Union
from rich.console import ConsoleRenderable, RichCast
from rich.text import Text
@dataclass
class Slide:
content: Union[RichCast, ConsoleRenderable] = field(default_factory=Text)
title: str = ""
@dataclass
class Deck:
name: str
slides: List[Slide] = field(default_factory=list)
def __getitem__(self, idx: int) -> Slide:
return self.slides[idx]
def __len__(self) -> int:
return len(self.slides)
def add_slide(self, slide: Slide) -> Deck:
self.slides.append(slide)
return self

@ -0,0 +1,37 @@
from dataclasses import dataclass
from .modes import Mode
from .slides import Deck, Slide
@dataclass
class State:
deck: Deck
_current_slide_idx: int = 0
mode: Mode = Mode.SLIDE
@property
def current_slide_idx(self) -> int:
return self._current_slide_idx
@current_slide_idx.setter
def current_slide_idx(self, idx: int) -> None:
self._current_slide_idx = max(0, min(len(self.deck) - 1, idx))
def next_slide(self, move: int = 1) -> None:
self.current_slide_idx += move
def previous_slide(self, move: int = 1) -> None:
self.current_slide_idx -= move
def jump_to_slide(self, idx: int) -> None:
self.current_slide_idx = idx
@property
def current_slide(self) -> Slide:
return self.deck[self.current_slide_idx]
@dataclass
class Stateful:
state: State

@ -0,0 +1,5 @@
from typing import Any, Iterable, Optional
def joinify(joiner: str, items: Iterable[Optional[Any]]) -> str:
return joiner.join(map(str, filter(None, items)))

@ -1,13 +1,18 @@
from functools import partial
from typing import Callable, List
import sys
import traceback
from pathlib import Path
from textwrap import dedent
from typing import Callable, List, Union
import pytest
from click.testing import Result
from typer.testing import CliRunner
from orate.main import app
from spiel.main import app
from spiel.slides import Deck, Slide
from spiel.state import State
CLI = Callable[[List[str]], Result]
CLI = Callable[[List[Union[str, Path]]], Result]
@pytest.fixture
@ -17,4 +22,57 @@ def runner() -> CliRunner:
@pytest.fixture
def cli(runner: CliRunner) -> CLI:
return partial(runner.invoke, app)
def invoker(args: List[Union[str, Path]]) -> Result:
real_args = [str(arg) for arg in args if arg]
print(real_args)
result = runner.invoke(app, real_args)
print("result:", result)
if result.exc_info is not None:
print("traceback:\n")
exc_type, exc_val, exc_tb = result.exc_info
traceback.print_exception(exc_val, exc_val, exc_tb, file=sys.stdout)
print()
print("exit code:", result.exit_code)
print("output:\n", result.output)
return result
return invoker
@pytest.fixture
def three_slide_deck() -> Deck:
return Deck(name="three-slides", slides=[Slide(), Slide(), Slide()])
@pytest.fixture
def three_slide_state(three_slide_deck: Deck) -> State:
return State(deck=three_slide_deck)
@pytest.fixture
def empty_deck_source() -> str:
return dedent(
"""\
from spiel import Deck
DECK = Deck(name="deck")
"""
)
@pytest.fixture
def empty_file(tmp_path: Path) -> Path:
file = tmp_path / "test_deck.py"
file.touch()
return file
@pytest.fixture
def file_with_empty_deck(empty_file: Path, empty_deck_source: str) -> Path:
empty_file.write_text(empty_deck_source)
return empty_file

@ -1,6 +1,22 @@
import subprocess
import sys
from spiel.constants import PACKAGE_NAME, __version__
from tests.conftest import CLI
def test_help(cli: CLI) -> None:
result = cli(["--help"])
assert result.exit_code == 0
def test_help_via_main() -> None:
result = subprocess.run([sys.executable, "-m", PACKAGE_NAME, "--help"])
assert result.returncode == 0
def test_version(cli: CLI) -> None:
result = cli(["version"])
assert result.exit_code == 0
assert PACKAGE_NAME in result.stdout
assert __version__ in result.stdout

@ -0,0 +1,11 @@
from spiel.slides import Deck, Slide
def test_can_add_slide_to_deck(three_slide_deck: Deck) -> None:
initial_len = len(three_slide_deck)
new_slide = Slide()
three_slide_deck.add_slide(new_slide)
assert len(three_slide_deck) == initial_len + 1
assert three_slide_deck[-1] is new_slide

@ -0,0 +1,29 @@
from spiel.input import deck_mode, next_slide, previous_slide, slide_mode
from spiel.modes import Mode
from spiel.state import State
def test_next_slide_goes_to_next_slide(three_slide_state: State) -> None:
next_slide(three_slide_state)
assert three_slide_state.current_slide is three_slide_state.deck[1]
def test_previous_slide_goes_to_previous_slide(three_slide_state: State) -> None:
three_slide_state.jump_to_slide(2)
previous_slide(three_slide_state)
assert three_slide_state.current_slide is three_slide_state.deck[1]
def test_enter_deck_mode(three_slide_state: State) -> None:
deck_mode(three_slide_state)
assert three_slide_state.mode is Mode.DECK
def test_enter_slide_mode(three_slide_state: State) -> None:
slide_mode(three_slide_state)
assert three_slide_state.mode is Mode.SLIDE

@ -0,0 +1,22 @@
import pytest
from spiel.exceptions import DuplicateInputHandler
from spiel.input import InputHandlers, action
from spiel.state import State
@pytest.fixture
def handlers() -> InputHandlers:
return {} # type: ignore
def test_register_already_registered_raises_error(handlers: InputHandlers) -> None:
@action("a")
def a(state: State) -> None: # pragma: never runs
pass
with pytest.raises(DuplicateInputHandler):
@action("a")
def a(state: State) -> None: # pragma: never runs
pass

@ -0,0 +1,19 @@
from typing import Any, Iterable, Optional
import pytest
from spiel.utils import joinify
@pytest.mark.parametrize(
"joiner, items, expected",
[
(".", ["a", "b"], "a.b"),
(".", ("a", "b"), "a.b"),
(".", iter(["a", "b"]), "a.b"),
(".", iter(["a", "", "b"]), "a.b"),
(".", iter(["a", None, "b"]), "a.b"),
],
)
def test_joinify(joiner: str, items: Iterable[Optional[Any]], expected: str) -> None:
assert joinify(joiner, items) == expected

@ -0,0 +1,56 @@
from pathlib import Path
from textwrap import dedent
from time import sleep
import pytest
from spiel.constants import DECK
from spiel.exceptions import NoDeckFound
from spiel.load import DeckReloader, DeckWatcher, load_deck
from spiel.slides import Deck
from spiel.state import State
def test_loading_from_empty_file_fails(empty_file: Path) -> None:
with pytest.raises(NoDeckFound, match=DECK):
load_deck(empty_file)
def test_loading_from_missing_file_fails(tmp_path: Path) -> None:
missing_file = tmp_path / "no-such-path"
with pytest.raises(FileNotFoundError, match="no-such-path"):
load_deck(missing_file)
def test_can_load_deck_from_valid_file(file_with_empty_deck: Path) -> None:
assert isinstance(load_deck(file_with_empty_deck), Deck)
def test_reloader_triggers_when_file_modified(file_with_empty_deck: Path) -> None:
state = State(load_deck(file_with_empty_deck))
reloader = DeckReloader(state=state, deck_path=file_with_empty_deck)
with DeckWatcher(event_handler=reloader, path=file_with_empty_deck, poll=True):
sleep(0.01)
file_with_empty_deck.write_text(
dedent(
"""\
from spiel import Deck
DECK = Deck(name="modified")
"""
)
)
sleep(0.01)
for attempt in range(10):
if state.deck.name == "modified":
return # test succeeded
sleep(0.1)
assert (
False
), f"Reloader never triggered, current file contents:\n{file_with_empty_deck.read_text()}" # pragma: debugging

@ -0,0 +1,43 @@
from spiel.state import State
def test_initial_state_has_first_slide_current(three_slide_state: State) -> None:
assert three_slide_state.current_slide is three_slide_state.deck[0]
def test_next_from_first_to_second(three_slide_state: State) -> None:
three_slide_state.next_slide()
assert three_slide_state.current_slide is three_slide_state.deck[1]
def test_next_from_first_to_third(three_slide_state: State) -> None:
three_slide_state.next_slide(move=2)
assert three_slide_state.current_slide is three_slide_state.deck[2]
def test_jump_to_third_slide(three_slide_state: State) -> None:
three_slide_state.jump_to_slide(2)
assert three_slide_state.current_slide is three_slide_state.deck[2]
def test_jump_before_beginning_results_in_beginning(three_slide_state: State) -> None:
three_slide_state.jump_to_slide(-5)
assert three_slide_state.current_slide is three_slide_state.deck[0]
def test_jump_past_end_results_in_end(three_slide_state: State) -> None:
three_slide_state.jump_to_slide(len(three_slide_state.deck) + 5)
assert three_slide_state.current_slide is three_slide_state.deck[-1]
def test_next_from_last_slide_stays_put(three_slide_state: State) -> None:
three_slide_state.jump_to_slide(2)
three_slide_state.next_slide()
assert three_slide_state.current_slide is three_slide_state.deck[2]
def test_previous_from_first_slide_stays_put(three_slide_state: State) -> None:
three_slide_state.previous_slide()
assert three_slide_state.current_slide is three_slide_state.deck[0]
Loading…
Cancel
Save