Examples and live coding (#14)

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

@ -24,6 +24,9 @@ repos:
- id: python-no-eval
- id: python-no-log-warn
- id: python-use-type-annotations
- id: rst-backticks
- id: rst-directive-colons
- id: rst-inline-touching-normal
- repo: https://github.com/psf/black
rev: 20.8b1
hooks:

@ -3,6 +3,7 @@ Reference
.. py:currentmodule:: spiel
Decks and Slides
----------------
@ -10,6 +11,8 @@ Decks and Slides
.. autoclass:: Slide
.. autoclass:: Example
Extra Renderables
-----------------

80
poetry.lock generated

@ -141,7 +141,7 @@ python-versions = "*"
[[package]]
name = "hypothesis"
version = "6.8.5"
version = "6.9.2"
description = "A library for property-based testing"
category = "dev"
optional = false
@ -152,8 +152,8 @@ attrs = ">=19.2.0"
sortedcontainers = ">=2.1.0,<3.0.0"
[package.extras]
all = ["black (>=19.10b0)", "click (>=7.0)", "django (>=2.2)", "dpcontracts (>=0.4)", "lark-parser (>=0.6.5)", "libcst (>=0.3.16)", "numpy (>=1.9.0)", "pandas (>=0.25)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "importlib-resources (>=3.3.0)", "importlib-metadata", "backports.zoneinfo (>=0.2.1)", "tzdata (>=2020.4)"]
cli = ["click (>=7.0)", "black (>=19.10b0)"]
all = ["black (>=19.10b0)", "click (>=7.0)", "django (>=2.2)", "dpcontracts (>=0.4)", "lark-parser (>=0.6.5)", "libcst (>=0.3.16)", "numpy (>=1.9.0)", "pandas (>=0.25)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "importlib-resources (>=3.3.0)", "importlib-metadata", "backports.zoneinfo (>=0.2.1)", "tzdata (>=2020.4)"]
cli = ["click (>=7.0)", "black (>=19.10b0)", "rich (>=9.0.0)"]
codemods = ["libcst (>=0.3.16)"]
dateutil = ["python-dateutil (>=1.4)"]
django = ["pytz (>=2014.1)", "django (>=2.2)"]
@ -510,7 +510,7 @@ python-versions = "*"
[[package]]
name = "sphinx"
version = "3.5.3"
version = "3.5.4"
description = "Python documentation generator"
category = "dev"
optional = false
@ -520,7 +520,7 @@ python-versions = ">=3.5"
alabaster = ">=0.7,<0.8"
babel = ">=1.3"
colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""}
docutils = ">=0.12"
docutils = ">=0.12,<0.17"
imagesize = "*"
Jinja2 = ">=2.3"
packaging = "*"
@ -659,7 +659,7 @@ python-versions = ">= 3.5"
[[package]]
name = "typed-ast"
version = "1.4.2"
version = "1.4.3"
description = "a fork of Python 2 and 3 ast modules with type comment support"
category = "dev"
optional = false
@ -830,8 +830,8 @@ filelock = [
{file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"},
]
hypothesis = [
{file = "hypothesis-6.8.5-py3-none-any.whl", hash = "sha256:618787066d1267f421d1dea1ebaedb1648a91f5dcbe3a7755be395f229558c9f"},
{file = "hypothesis-6.8.5.tar.gz", hash = "sha256:fcf6763a6d92f4fe2c83a70666f1af89fc299a876c5f11bb3da96e0ba666aa2f"},
{file = "hypothesis-6.9.2-py3-none-any.whl", hash = "sha256:2da20c8a7d2185045961186cfe8da8bde795c8ca51ec6f9674da01d568f0da82"},
{file = "hypothesis-6.9.2.tar.gz", hash = "sha256:752ad0b7f26ece6a9f3688b1adfb29740fd631476fc4ea2db33b3d331f01ff2f"},
]
idna = [
{file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
@ -1072,8 +1072,8 @@ sortedcontainers = [
{file = "sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"},
]
sphinx = [
{file = "Sphinx-3.5.3-py3-none-any.whl", hash = "sha256:3f01732296465648da43dec8fb40dc451ba79eb3e2cc5c6d79005fd98197107d"},
{file = "Sphinx-3.5.3.tar.gz", hash = "sha256:ce9c228456131bab09a3d7d10ae58474de562a6f79abb3dc811ae401cf8c1abc"},
{file = "Sphinx-3.5.4-py3-none-any.whl", hash = "sha256:2320d4e994a191f4b4be27da514e46b3d6b420f2ff895d064f52415d342461e8"},
{file = "Sphinx-3.5.4.tar.gz", hash = "sha256:19010b7b9fa0dc7756a6e105b2aacd3a80f798af3c25c273be64d7beeb482cb1"},
]
sphinx-autobuild = [
{file = "sphinx-autobuild-2021.3.14.tar.gz", hash = "sha256:de1ca3b66e271d2b5b5140c35034c89e47f263f2cd5db302c9217065f7443f05"},
@ -1155,36 +1155,36 @@ tornado = [
{file = "tornado-6.1.tar.gz", hash = "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791"},
]
typed-ast = [
{file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70"},
{file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487"},
{file = "typed_ast-1.4.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412"},
{file = "typed_ast-1.4.2-cp35-cp35m-win32.whl", hash = "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400"},
{file = "typed_ast-1.4.2-cp35-cp35m-win_amd64.whl", hash = "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606"},
{file = "typed_ast-1.4.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64"},
{file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07"},
{file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc"},
{file = "typed_ast-1.4.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a"},
{file = "typed_ast-1.4.2-cp36-cp36m-win32.whl", hash = "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151"},
{file = "typed_ast-1.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3"},
{file = "typed_ast-1.4.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41"},
{file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f"},
{file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581"},
{file = "typed_ast-1.4.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37"},
{file = "typed_ast-1.4.2-cp37-cp37m-win32.whl", hash = "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd"},
{file = "typed_ast-1.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496"},
{file = "typed_ast-1.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc"},
{file = "typed_ast-1.4.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10"},
{file = "typed_ast-1.4.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea"},
{file = "typed_ast-1.4.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787"},
{file = "typed_ast-1.4.2-cp38-cp38-win32.whl", hash = "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2"},
{file = "typed_ast-1.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937"},
{file = "typed_ast-1.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1"},
{file = "typed_ast-1.4.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6"},
{file = "typed_ast-1.4.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166"},
{file = "typed_ast-1.4.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d"},
{file = "typed_ast-1.4.2-cp39-cp39-win32.whl", hash = "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b"},
{file = "typed_ast-1.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440"},
{file = "typed_ast-1.4.2.tar.gz", hash = "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a"},
{file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"},
{file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"},
{file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"},
{file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"},
{file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"},
{file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"},
{file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"},
{file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"},
{file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"},
{file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"},
{file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"},
{file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"},
{file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"},
{file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"},
{file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"},
{file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"},
{file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"},
{file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"},
{file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"},
{file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"},
{file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"},
{file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"},
{file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"},
{file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"},
{file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"},
{file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"},
{file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"},
{file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"},
{file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"},
{file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"},
]
typer = [
{file = "typer-0.3.2-py3-none-any.whl", hash = "sha256:ba58b920ce851b12a2d790143009fa00ac1d05b3ff3257061ff69dbdfc3d161b"},

@ -27,8 +27,7 @@ classifiers = [
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Topic :: Multimedia :: Graphics :: Presentation",
"Typing :: Typed",
]
"Typing :: Typed"]
authors = ["JoshKarpel <josh.karpel@gmail.com>"]
license = "MIT"
include = ["py.typed", "demo/*"]

@ -1,3 +1,6 @@
from .constants import __version__
from .deck import Deck
from .example import Example, example_panels
from .image import Image
from .slides import Deck, Slide, Triggers
from .slide import Slide
from .triggers import Triggers

@ -0,0 +1,82 @@
from __future__ import annotations
import inspect
import sys
from collections.abc import Collection
from dataclasses import dataclass, field
from textwrap import dedent
from typing import Callable, Iterator, List, Sequence
from .example import Example
from .presentable import Presentable
from .slide import MakeRenderable, Slide
@dataclass
class Deck(Collection):
name: str
slides: List[Presentable] = field(default_factory=list)
def __getitem__(self, idx: int) -> Presentable:
return self.slides[idx]
def __len__(self) -> int:
return len(self.slides)
def __iter__(self) -> Iterator[Presentable]:
return iter(self.slides)
def __contains__(self, obj: object) -> bool:
return obj in self.slides
def add_slides(self, *slides: Presentable) -> Deck:
self.slides.extend(slides)
return self
def slide(
self,
title: str = "",
) -> Callable[[MakeRenderable], Slide]:
def slideify(content: MakeRenderable) -> Slide:
slide = Slide(content=content, title=title)
self.add_slides(slide)
return slide
return slideify
def example(
self,
title: str = "",
command: Sequence[str] = (sys.executable,),
name: str = "example.py",
language: str = "python",
) -> Callable[[Callable], Example]:
def exampleify(example: Callable) -> Example:
ex = Example(
source=get_function_body(example),
title=title,
command=command,
name=name,
language=language,
)
self.add_slides(ex)
return ex
return exampleify
def get_function_body(function: Callable) -> str:
lines, _ = inspect.getsourcelines(function)
prev_indent = None
for idx, line in enumerate(lines):
if prev_indent is None:
prev_indent = count_leading_whitespace(line)
elif count_leading_whitespace(line) > prev_indent:
return dedent("".join(lines[idx:]))
raise ValueError(f"Could not extract function body from {function}")
def count_leading_whitespace(s: str) -> int:
return len(s) - len(s.lstrip())

@ -18,7 +18,7 @@ from rich.style import Style
from rich.syntax import Syntax
from rich.text import Text
from spiel import Deck, Image, Slide, __version__
from spiel import Deck, Image, Slide, __version__, example_panels
SPIEL = "[Spiel](https://github.com/JoshKarpel/spiel)"
RICH = "[Rich](https://rich.readthedocs.io/)"
@ -310,8 +310,6 @@ def watch():
`$ spiel present path/to/deck.py --watch`
If you're on a system without inotify support (e.g., Windows Subsystem for Linux), you may need to use the `--poll` option instead.
When you're ready to present your deck for real, just drop the `--watch` option.
"""
)
return Markdown(markup, justify="center")
@ -341,3 +339,62 @@ def image():
)
return root
@DECK.example(title="Examples")
def examples():
# This is an example that shows how to use random.choice from the standard library.
# The source code is embedded directly into the demo deck file,
# but you could load it from another file if you wanted to.
import random
directions = ["North", "South", "East", "West"]
print("Which way should we go?")
print(random.choice(directions))
@examples.layout
def _(example, triggers):
root = Layout()
extra = (
f"""
## Example Execution is Cached
Now that you've triggered the slide, {SPIEL} will execute the example once and display the output.
The result is cached, so the example is not executed on every frame, like code in normal slide content
functions is.
## Editing Examples
Examples can be modified during the talk.
Press `e` to open your `$EDITOR` on the example code.
Save your changes and exit to come back to the presentation with your updated code.
You can then trigger the example again to run it with the new code.
"""
if len(triggers) > 1
else ""
)
markup = dedent(
f"""\
## Examples
{SPIEL} can display and execute chunks of example code.
Example slides are driven by the trigger system.
Press `t` to execute the example code and display the output.
You can customize the example slide's content by providing a custom `layout` function.
If you don't, you'll get the default layout, which looks like just the right half of this slide.
{extra}
"""
)
markdown = Markdown(markup, justify="center")
root.split_row(Layout(markdown), example_panels(example))
return root

@ -0,0 +1,112 @@
from __future__ import annotations
import shlex
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from subprocess import PIPE, STDOUT, run
from typing import Callable, Optional, Sequence
from rich.align import Align
from rich.console import ConsoleRenderable
from rich.layout import Layout
from rich.panel import Panel
from rich.syntax import Syntax
from rich.text import Text
from .presentable import Presentable
from .triggers import Triggers
@dataclass
class CachedExample:
trigger_number: int
input: str
output: Optional[str]
def example_panels(example: Example) -> ConsoleRenderable:
root = Layout()
root.split_column(
Layout(
Align.center(
Panel(
example.input,
title=example.name,
title_align="left",
expand=False,
)
)
),
Layout(
Align.center(
Panel(
example.output,
title=example.display_command,
title_align="left",
expand=False,
)
if example.output is not None
else Text(" ")
)
),
)
return root
ExampleLayout = Callable[["Example"], ConsoleRenderable]
@dataclass
class Example(Presentable):
source: str = ""
command: Sequence[str] = (sys.executable,)
name: str = "example.py"
language: str = "python"
_layout: ExampleLayout = example_panels
_cache: Optional[CachedExample] = None
def layout(self, function: ExampleLayout) -> ExampleLayout:
self._layout = function # type: ignore
return function
@property
def display_command(self) -> str:
return shlex.join([Path(self.command[0]).stem, *self.command[1:], self.name])
def execute(self) -> str:
with tempfile.TemporaryDirectory() as tmpdir:
dir = Path(tmpdir)
file = dir / self.name
file.write_text(self.source)
result = run([*self.command, file], stdout=PIPE, stderr=STDOUT, text=True)
return result.stdout
@property
def input(self) -> Syntax:
input = (self._cache.input or "") if self._cache is not None else ""
return Syntax(
input,
lexer_name=self.language,
code_width=max(len(line) for line in input.splitlines()),
)
@property
def output(self) -> Optional[Text]:
return (
Text(self._cache.output)
if (self._cache is not None and self._cache.output is not None)
else None
)
def clear_cache(self) -> None:
self._cache = None
def render(self, triggers: Triggers) -> ConsoleRenderable:
if self._cache is None:
self._cache = CachedExample(len(triggers), self.source, None)
elif self._cache.trigger_number != len(triggers):
self._cache = CachedExample(len(triggers), self.source, self.execute())
return self._layout(self, **self.get_render_kwargs(function=self._layout, triggers=triggers)) # type: ignore

@ -1,14 +1,20 @@
from __future__ import annotations
import code
import contextlib
import os
import string
import sys
import termios
from contextlib import contextmanager
from copy import deepcopy
from dataclasses import dataclass
from enum import Enum, unique
from io import UnsupportedOperation
from itertools import product
from pathlib import Path
from typing import (
Any,
Callable,
Iterable,
Iterator,
@ -21,10 +27,13 @@ from typing import (
Union,
)
import typer
from rich.control import Control
from rich.text import Text
from typer import Exit
from .constants import PACKAGE_NAME
from .example import Example
from .exceptions import DuplicateInputHandler
from .modes import Mode
from .state import State
@ -32,27 +41,39 @@ from .state import State
LFLAG = 3
CC = 6
try:
ORIGINAL_TCGETATTR: Optional[List[Any]] = termios.tcgetattr(sys.stdin)
except (UnsupportedOperation, termios.error):
ORIGINAL_TCGETATTR = None
@contextmanager
def no_echo() -> Iterator[None]:
try:
fd = sys.stdin.fileno()
except UnsupportedOperation:
start_no_echo(sys.stdin)
yield
finally:
reset_tty(sys.stdin)
def start_no_echo(stream: TextIO) -> None:
if ORIGINAL_TCGETATTR is None:
return
old = termios.tcgetattr(fd)
mode = deepcopy(ORIGINAL_TCGETATTR)
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)
termios.tcsetattr(stream.fileno(), termios.TCSADRAIN, mode)
def reset_tty(stream: TextIO) -> None:
if ORIGINAL_TCGETATTR is None:
return
termios.tcsetattr(stream.fileno(), termios.TCSADRAIN, ORIGINAL_TCGETATTR)
@unique
@ -314,6 +335,72 @@ def reset_trigger(state: State) -> None:
state.reset_trigger()
@contextlib.contextmanager
def suspend_live(state: State) -> Iterator[None]:
live = state.console._live
if live is None:
yield
return
live.stop()
yield
live.start(refresh=True)
@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.",
)
def edit_example(state: State) -> None:
s = state.current_slide
if isinstance(s, 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]"
@input_handler(
"l",
name="Open 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.",
)
def open_repl(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:
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)
@input_handler(
"p",
help="Toggle profiling information.",

@ -15,9 +15,9 @@ 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.slides import Deck
from spiel.state import State

@ -4,6 +4,7 @@ from pathlib import Path
from textwrap import dedent
from rich.console import Console
from rich.control import Control
from rich.style import Style
from rich.syntax import Syntax
from rich.text import Text
@ -88,6 +89,9 @@ def _present(path: Path, mode: Mode, slide: int, profiling: bool, watch: bool, p
present_deck(state)
except KeyboardInterrupt:
raise Exit(code=0)
finally:
state.console.print(Control.clear())
state.console.print(Control.move_to(0, 0))
@app.command()

@ -10,22 +10,22 @@ from rich.padding import Padding
from rich.panel import Panel
from rich.style import Style
from spiel import Slide
from .constants import TARGET_RPS
from .exceptions import UnknownModeError
from .footer import Footer
from .help import Help
from .input import handle_input, no_echo
from .modes import Mode
from .presentable import Presentable
from .rps import RPSCounter
from .state import State
from .triggers import Triggers
from .utils import clamp, joinify
def render_slide(state: State, slide: Slide) -> ConsoleRenderable:
def render_slide(state: State, slide: Presentable) -> ConsoleRenderable:
return Padding(
slide.render(trigger_times=state.trigger_times),
slide.render(triggers=Triggers(times=tuple(state.trigger_times))),
pad=1,
)
@ -55,7 +55,7 @@ def split_layout_into_deck_grid(root: Layout, state: State) -> Layout:
is_active_slide = slide is state.current_slide
layout.update(
Panel(
slide.render([monotonic()]),
slide.render(triggers=Triggers(times=(monotonic(),))),
title=joinify(" | ", [slide_number, slide.title]),
border_style=Style(
color="bright_cyan" if is_active_slide else None,

@ -0,0 +1,24 @@
import inspect
from dataclasses import dataclass
from typing import Any, Callable, Dict, Mapping
from rich.console import ConsoleRenderable
from .triggers import Triggers
@dataclass
class Presentable: # Why not an ABC? https://github.com/python/mypy/issues/5374
title: str = ""
def render(self, triggers: Triggers) -> ConsoleRenderable:
raise NotImplementedError
def get_render_kwargs(self, function: Callable, triggers: Triggers) -> Mapping[str, Any]:
signature = inspect.signature(function)
kwargs: Dict[str, Any] = {}
if "triggers" in signature.parameters:
kwargs["triggers"] = triggers
return kwargs

@ -0,0 +1,24 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable, Union
from rich.console import ConsoleRenderable
from rich.text import Text
from .presentable import Presentable
from .triggers import Triggers
MakeRenderable = Callable[..., ConsoleRenderable]
RenderableLike = Union[MakeRenderable, ConsoleRenderable]
@dataclass
class Slide(Presentable):
content: RenderableLike = field(default_factory=Text)
def render(self, triggers: Triggers) -> ConsoleRenderable:
if callable(self.content):
return self.content(**self.get_render_kwargs(function=self.content, triggers=triggers))
else:
return self.content

@ -1,84 +0,0 @@
from __future__ import annotations
import inspect
from dataclasses import dataclass, field
from functools import cached_property
from time import monotonic
from typing import Any, Callable, Dict, Iterator, List, Tuple, Union
from rich.console import ConsoleRenderable
from rich.text import Text
MakeRenderable = Callable[..., ConsoleRenderable]
RenderableLike = Union[MakeRenderable, ConsoleRenderable]
@dataclass(frozen=True)
class Triggers:
times: Tuple[float, ...]
now: float = field(default_factory=monotonic)
def __len__(self) -> int:
return len(self.times)
def __getitem__(self, idx: int) -> float:
return self.times[idx]
def __iter__(self) -> Iterator[float]:
return iter(self.times)
@cached_property
def time_since_last_trigger(self) -> float:
return self.now - self.times[-1]
@cached_property
def time_since_first_trigger(self) -> float:
return self.now - self.times[0]
@dataclass
class Slide:
content: RenderableLike = field(default_factory=Text)
title: str = ""
def render(self, trigger_times: List[float]) -> ConsoleRenderable:
if callable(self.content):
signature = inspect.signature(self.content)
kwargs: Dict[str, Any] = {}
if "triggers" in signature.parameters:
kwargs["triggers"] = Triggers(times=tuple(trigger_times))
return self.content(**kwargs)
else:
return self.content
@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 __iter__(self) -> Iterator[Slide]:
yield from self.slides
def add_slides(self, *slides: Slide) -> Deck:
self.slides.extend(slides)
return self
def slide(
self,
title: str = "",
) -> Callable[[MakeRenderable], MakeRenderable]:
def slideify(content: MakeRenderable) -> MakeRenderable:
slide = Slide(content=content, title=title)
self.add_slides(slide)
return content
return slideify

@ -6,8 +6,9 @@ from rich.console import Console
from rich.style import Style
from rich.text import Text
from . import Deck
from .modes import Mode
from .slides import Deck, Slide
from .presentable import Presentable
TextLike = Union[Text, Callable[[], Text]]
@ -41,16 +42,20 @@ class State:
self.reset_trigger()
def next_slide(self, move: int = 1) -> None:
if self.current_slide_idx == len(self.deck) - 1:
return
self.current_slide_idx += move
def previous_slide(self, move: int = 1) -> None:
if self.current_slide_idx == 0:
return
self.current_slide_idx -= move
def jump_to_slide(self, idx: int) -> None:
self.current_slide_idx = idx
@property
def current_slide(self) -> Slide:
def current_slide(self) -> Presentable:
return self.deck[self.current_slide_idx]
@property

@ -0,0 +1,27 @@
from dataclasses import dataclass, field
from functools import cached_property
from time import monotonic
from typing import Iterator, Tuple
@dataclass(frozen=True)
class Triggers:
times: Tuple[float, ...]
now: float = field(default_factory=monotonic)
def __len__(self) -> int:
return len(self.times)
def __getitem__(self, idx: int) -> float:
return self.times[idx]
def __iter__(self) -> Iterator[float]:
return iter(self.times)
@cached_property
def time_since_last_trigger(self) -> float:
return self.now - self.times[-1]
@cached_property
def time_since_first_trigger(self) -> float:
return self.now - self.times[0]

@ -5,7 +5,8 @@ from textwrap import dedent
import pytest
from rich.console import Console
from spiel.slides import Deck, Slide
from spiel import Deck
from spiel.slide import Slide
from spiel.state import State

@ -26,6 +26,7 @@ def test_help(runner: CliRunner) -> None:
def test_help_via_main() -> None:
result = subprocess.run([sys.executable, "-m", PACKAGE_NAME, "--help"])
print(result.stdout)
assert result.returncode == 0

@ -1,4 +1,5 @@
from spiel.slides import Deck, Slide
from spiel import Deck
from spiel.slide import Slide
def test_can_add_slide_to_deck(three_slide_deck: Deck) -> None:

@ -14,9 +14,9 @@ from spiel.state import State
@pytest.mark.parametrize(
"make_slide",
[
lambda: Slide(Text("foobar")),
lambda: Slide(lambda: Text("foobar")),
lambda: Slide(lambda triggers: Text("foobar")),
lambda: Slide(content=Text("foobar")),
lambda: Slide(content=lambda: Text("foobar")),
lambda: Slide(content=lambda triggers: Text("foobar")),
],
)
def test_can_render_slide(

@ -18,6 +18,7 @@ from spiel.input import (
exit,
jump_to_slide,
next_slide,
open_repl,
previous_slide,
slide_mode,
)
@ -58,7 +59,7 @@ def test_kill(three_slide_state: State) -> None:
@given(
input_handlers=st.lists(
st.sampled_from(list(set(INPUT_HANDLERS.values()) - {exit, jump_to_slide}))
st.sampled_from(list(set(INPUT_HANDLERS.values()) - {exit, jump_to_slide, open_repl}))
)
)
@settings(max_examples=1_000 if os.getenv("CI") else 100)
@ -69,7 +70,8 @@ def test_input_sequences_dont_crash(input_handlers: List[InputHandler]) -> None:
name="deck",
slides=[
Slide(
Text(f"This is slide {n + 1}"), title="".join(sample(string.ascii_letters, 30))
content=Text(f"This is slide {n + 1}"),
title="".join(sample(string.ascii_letters, 30)),
)
for n in range(30)
],

@ -6,10 +6,10 @@ from time import sleep
import pytest
from rich.console import Console
from spiel import Deck
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

@ -1,6 +1,6 @@
import pytest
from spiel.slides import Triggers
from spiel import Triggers
@pytest.mark.parametrize(

Loading…
Cancel
Save