diff --git a/CLAUDE.md b/CLAUDE.md index ff16b14..06be10a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,7 @@ Every PR costs txoof review time. Too many small PRs and too few huge ones both - **Aim for 300–600 changed lines** (code, tests and docs together; images don't count). Above about 800 lines, split the work: 800 is a guideline and an upper bound, so a PR may go a little over it, but never much. Below that, keep a task in one PR (see "One PR per task" above). - **Each PR does one thing that works on its own**, with its tests and docs. `main` is never left half-built. - **No PR for one small change** (a typo, a one-line rule). Put it in the next related PR. +- **Every PR is based on `main`.** No stacked PRs (a PR based on another PR's branch): they cost extra merges and conflict fixes. Split a task into parts that each go straight to `main`, one after the other. - **Plan the split before coding.** Show the planned PRs to txoof together with the design questions. One issue may need several PRs; list them in the issue. For example, the scheduler (+1925 lines in #206) could have been four PRs: the new plugins and settings; the scheduler core with its tests (which use a pretend clock); failures, the fallback plugins and config reload; the `paperpi run` command and README. - **Review fixes:** small fixes go into the same PR. Fixes that add a new feature go into a follow-up PR, so the first one doesn't keep growing. - See the size with `git diff --shortstat origin/main...HEAD`. diff --git a/README.md b/README.md index 6edaa07..8d94ee5 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ uv run paperpi run --config paperpi.toml --out screen/ --state-dir state/ --heal ### The web interface -`paperpi run` also starts the web interface, on port 8080. Open it by the Pi's IP address on a phone or computer on the same home network, for example `http://192.168.1.20:8080` (your router's list of devices shows the Pi's address; on the Pi, `hostname -I` prints it). Names such as `paperpi.local` are refused for now, for safety (see [docs/decisions/web-interface.md](docs/decisions/web-interface.md)). It has three pages for plugins: **Active Plugins** (switch plugins on or off, move them up or down, remove one, open its settings), **Plugin Library** (every plugin that comes with PaperPi; add one at the end of the list) and each plugin's **Settings** (every setting with its help text and default; errors are shown next to the field). A change there is saved in the config file and applies at once. Comments in the file stay, except the comments of a plugin you remove and a comment written after a setting, on the same line, when the web interface changes that setting. If the config file changed since the page was opened, is written in a way the web interface can't change safely, or has a problem that stops PaperPi from using it, nothing is saved and the page says so. A plugin that needs settings (such as `met_no`, which needs a place and your email address) is added switched off, and its Settings page opens to fill them in. +`paperpi run` also starts the web interface, on port 8080. Open it by the Pi's IP address on a phone or computer on the same home network, for example `http://192.168.1.20:8080` (your router's list of devices shows the Pi's address; on the Pi, `hostname -I` prints it). Names such as `paperpi.local` are refused for now, for safety (see [docs/decisions/web-interface.md](docs/decisions/web-interface.md)). It has three pages for plugins: **Active Plugins** (switch plugins on or off, move them up or down, remove one, open its settings), **Plugin Library** (every plugin that comes with PaperPi; add one at the end of the list) and each plugin's **Settings** (every setting with its help text and default; errors are shown next to the field; **Preview** draws the plugin with the settings in the form before you save them, at your screen's size, without touching the screen; if the real data can't be drawn, it shows sample data and says why). A change there is saved in the config file and applies at once. Comments in the file stay, except the comments of a plugin you remove and a comment written after a setting, on the same line, when the web interface changes that setting. If the config file changed since the page was opened, is written in a way the web interface can't change safely, or has a problem that stops PaperPi from using it, nothing is saved and the page says so. A plugin that needs settings (such as `met_no`, which needs a place and your email address) is added switched off, and its Settings page opens to fill them in. - **First visit:** the first person to open it sets the web password (at least 8 characters). The browser then stays logged in for a year, until **Log out**. Log out only logs out that browser; a new password logs out every browser. - **Forgotten password:** you need access to the Pi itself (a keyboard and screen, or SSH). diff --git a/docs/decisions/web-interface.md b/docs/decisions/web-interface.md index 100b147..2c020e9 100644 --- a/docs/decisions/web-interface.md +++ b/docs/decisions/web-interface.md @@ -45,7 +45,7 @@ In v2 the web interface is the main way to set up and change PaperPi: plugins, s - No limit on wrong password attempts. *Update (M5, issue #238, agreed with txoof on 2026-10-07):* -- The web server runs **inside `paperpi run`**, in its own thread, not as a separate program. It can tell the scheduler directly to apply a change, it needs one Python process fewer (about 40 MB), and there is one program to install. Previews will draw in a separate process with a time limit, like normal updates, so a slow or broken plugin can't stop the web pages or the screen. If the web interface can't start (port taken), the log says why and the screen keeps running. +- The web server runs **inside `paperpi run`**, in its own thread, not as a separate program. It can tell the scheduler directly to apply a change, it needs one Python process fewer (about 40 MB), and there is one program to install. Previews draw in a separate process with a time limit, like normal updates, so a slow or broken plugin can't stop the web pages or the screen. If the web interface can't start (port taken), the log says why and the screen keeps running. - The password is stored as a **scrypt** hash, which is built into Python (about 0.1 s and 16 MB of memory per check on a Pi; checks run one at a time; no limit on wrong tries, agreed again on 2026-10-07). The log-in cookie is signed with that hash, so a new password logs out every browser. - Packages: FastAPI, uvicorn (the web server that runs FastAPI), jinja2 (page templates) and python-multipart (reads forms). - **Log-in can be switched off:** `login = false` in `[web]`. Then anyone on the home network can change the settings; the config check gives a hint and the home page says so. @@ -60,7 +60,14 @@ In v2 the web interface is the main way to set up and change PaperPi: plugins, s ### Preview images - **Home page:** a copy of what is on the e-paper screen now, with the plugin's name and when it was drawn. It updates by itself when the screen changes. -- **Plugin settings page:** a **Preview** button draws the plugin with the settings in the form, before saving, using the PNG driver (see `display-driver-interface.md`). The real screen is not touched. If the plugin's data source can't be reached, the preview uses the plugin's sample data and says so. +- **Plugin settings page:** a **Preview** button draws the plugin with the settings in the form, before saving, in a separate process, as normal updates are drawn. The real screen is not touched. If the plugin's data source can't be reached, the preview uses the plugin's sample data and says so. +- *Update (M5 part 3b-1, agreed with txoof on 2026-10-09):* + - The preview draws the block as Save would write it (the form's values applied to a copy of the file text), at the screen's size and for the screen's type from `[display]`. It runs in its own process with a time limit, as normal updates do, in a new, empty storage folder that is deleted afterwards, so it can't change the plugin's saved files. + - Real data may take the plugin's own time limit, but at most 45 seconds. When it fails, takes longer, has nothing to show right now, or a required setting is still empty, the preview draws the plugin's sample data (at most 10 seconds) and says why. + - One preview is drawn at a time; a second one meanwhile is asked to wait. + - The picture is sent inside the page (a `data:` address), so it is never saved as a file. The pages allow such pictures (`img-src 'self' data:`). + - For later plugins: an empty secret field (such as an API key) keeps the saved secret, also in a preview. A plugin that has both a secret and a web address the user can change must not send the saved secret to a changed address: then the secret must be typed again (in a preview and when saving), or the plugin allows only its own servers. Network errors in the preview should then say only "could not reach the server", so the preview can't be used to look around the home network. No plugin has such an address yet (checked in the security review of part 3b-1); this matters for e.g. the Dutch public transport plugin. + - There is no "send to display" button on the settings page. A **Show on screen now** button, which starts a saved plugin's turn at once, comes with the home page in part 4. ### Other pages diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 37a395c..3125e76 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -59,7 +59,7 @@ PLUGIN = Plugin( 3. `draw(data, context)` returns a dictionary with a value for each block of the layout, for example `{"time": "10:42"}` (or a `Drawn`, see "Moving blocks and colours"). PaperPi draws the layout with epdlib. 4. The image goes back to PaperPi and the process exits. -For tests and sample images, step 2 is skipped and `sample` is drawn instead. So every plugin can draw an image without network access, and `draw` must work with the sample data. +For tests and sample images, step 2 is skipped and `sample` is drawn instead. So every plugin can draw an image without network access, and `draw` must work with the sample data. The web interface's Preview also draws the sample data when the real data fails, takes too long, has nothing to show or still needs settings, so make the sample look like real data. `context` holds: diff --git a/src/paperpi/cli.py b/src/paperpi/cli.py index 12e9f2b..6d48fdd 100644 --- a/src/paperpi/cli.py +++ b/src/paperpi/cli.py @@ -343,6 +343,8 @@ def load() -> config.Config: ) scheduler.run() reports.stopping() + if web is not None: + web.stop_previews() # before the clear below, which can take 30 s # Only after a stop that was asked for (Ctrl+C, systemctl stop, shutdown or # reboot): after an error, the restart draws the picture again anyway. A second # stop signal ends the clear at once. diff --git a/src/paperpi/limits.py b/src/paperpi/limits.py index 808bb51..56b181e 100644 --- a/src/paperpi/limits.py +++ b/src/paperpi/limits.py @@ -11,6 +11,13 @@ #: Highest time limit a user may set for one plugin update. PLUGIN_UPDATE_MAX = 600.0 +#: Longest a web interface preview may wait for a plugin's real data (the plugin's own time +#: limit, if that is shorter). Then the preview draws the sample data instead. +PREVIEW_LIVE = 45.0 + +#: Longest drawing a plugin's sample data may take for a preview. +PREVIEW_SAMPLE = 10.0 + #: How long a plugin process may take to exit after it has handed over its image. PLUGIN_EXIT = 5.0 diff --git a/src/paperpi/web/app.py b/src/paperpi/web/app.py index 17852b0..3e91083 100644 --- a/src/paperpi/web/app.py +++ b/src/paperpi/web/app.py @@ -10,7 +10,8 @@ ``/plugins`` Active Plugins: the plugins in the config file; switch on or off, move up or down, remove, settings (``/plugins//...?id=``, n = place in the file, counted from 0, and the plugin's id to check it is - still there) + still there); ``/plugins//preview`` draws the settings form's + values without saving them (part of the settings page) ``/library`` Plugin Library: every plugin type; ``/library/`` adds one and opens its settings ``/static/...`` the style sheet and htmx (a small JavaScript file that updates @@ -27,6 +28,7 @@ from __future__ import annotations import ipaddress +import logging from collections.abc import Awaitable, Callable from pathlib import Path from typing import Annotated @@ -46,8 +48,10 @@ from .helpers import helper_html from .password import PasswordError, password_problem from .plugins import FormErrors, PluginEditor, library, library_item +from .preview import Busy, Previewer, PreviewFailed COOKIE = "paperpi_login" +log = logging.getLogger(__name__) _HERE = Path(__file__).parent #: Pages that work without a log-in (and everything in ``/static/``). _OPEN = ("/setup", "/login") @@ -56,10 +60,13 @@ _QueryId = Annotated[str, Query(alias="id")] -def create_app(auth: Auth, editor: PluginEditor | None = None) -> FastAPI: - """The web interface, using ``auth`` for the password and log-in and ``editor`` to - change the plugins in the config file.""" +def create_app( + auth: Auth, editor: PluginEditor | None = None, previewer: Previewer | None = None +) -> FastAPI: + """The web interface, using ``auth`` for the password and log-in, ``editor`` to + change the plugins in the config file and ``previewer`` to draw previews.""" editor = editor or PluginEditor(auth.config_file) + previewer = previewer or Previewer() app = FastAPI(title="PaperPi", docs_url=None, redoc_url=None, openapi_url=None) app.mount("/static", StaticFiles(directory=_HERE / "static"), name="static") templates = Jinja2Templates(directory=_HERE / "templates") @@ -91,9 +98,9 @@ async def refused_or_page( path = request.url.path if auth.login and path not in _OPEN and not path.startswith("/static/"): if auth.password_hash is None: - return RedirectResponse("/setup", status_code=303) + return _go_to(request, "/setup") if not auth.cookie_ok(request.cookies.get(COOKIE)): - return RedirectResponse("/login", status_code=303) + return _go_to(request, "/login") return await call_next(request) @app.middleware("http") @@ -101,8 +108,10 @@ async def check(request: Request, call_next: Callable[[Request], Awaitable[Respo response = await refused_or_page(request, call_next) path = request.url.path response.headers["X-Frame-Options"] = "DENY" + # Previews are pictures sent inside the page (data: addresses), never saved. response.headers["Content-Security-Policy"] = ( - "default-src 'self'; frame-ancestors 'none'; form-action 'self'; base-uri 'none'" + "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; " + "form-action 'self'; base-uri 'none'" ) response.headers["Referrer-Policy"] = "same-origin" if not path.startswith("/static/"): @@ -262,9 +271,7 @@ def settings_form(request: Request, index: int, plugin_id: _QueryId = ""): @app.post("/plugins/{index}/settings", response_class=HTMLResponse) async def save_settings(request: Request, index: int): - form = await request.form() - plugin_id = str(form.get("id", "")) - sent = {k: [str(v) for v in form.getlist(k)] for k in form if k != "id"} + plugin_id, sent = _settings_form(await request.form()) try: hand_edits = await run_in_threadpool(editor.save_settings, index, plugin_id, sent) except FormErrors as error: @@ -275,6 +282,24 @@ async def save_settings(request: Request, index: int): return await run_in_threadpool(plugin_list, request, 409, str(error)) return settings_saved(index, plugin_id, "saved", hand_edits) + @app.post("/plugins/{index}/preview", response_class=HTMLResponse) + async def preview(request: Request, index: int): + # Only a part of the settings page (htmx), so every answer is a 200: htmx doesn't + # show the others. + plugin_id, sent = _settings_form(await request.form()) + try: + job = await run_in_threadpool(editor.preview_job, index, plugin_id, sent) + drawn = await run_in_threadpool(previewer.draw, job) + except FormErrors as error: + return page(request, "preview.html", errors=error.found.errors) + except (EditError, Busy, PreviewFailed) as error: + return page(request, "preview.html", problem=str(error)) + except Exception: # noqa: BLE001 - e.g. no plugin process could be started + log.exception("preview of %s", plugin_id) + problem = "The preview could not be drawn. PaperPi's log has the details." + return page(request, "preview.html", problem=problem) + return page(request, "preview.html", drawn=drawn, job=job) + def settings_saved(index: int, plugin_id: str, done: str, hand_edits: bool): values = {"id": plugin_id, "done": done} | ({"hand": "1"} if hand_edits else {}) return RedirectResponse(f"/plugins/{index}/settings?{urlencode(values)}", 303) @@ -322,6 +347,20 @@ def logout(): return app +def _go_to(request: Request, path: str) -> Response: + """Send the browser to ``path``. For a part of a page that htmx asked for, htmx opens + the whole page (else it would show the page inside the part).""" + if request.headers.get("hx-request") == "true": + return Response(status_code=204, headers={"HX-Redirect": path}) + return RedirectResponse(path, status_code=303) + + +def _settings_form(form) -> tuple[str, dict[str, list[str]]]: + """The plugin ID and the settings of a sent settings form.""" + plugin_id = str(form.get("id", "")) + return plugin_id, {k: [str(v) for v in form.getlist(k)] for k in form if k != "id"} + + def _number(value: float) -> str: """A number as the pages show it: ``604800``, not ``604800.0``.""" return str(int(value)) if float(value).is_integer() else str(value) diff --git a/src/paperpi/web/plugins.py b/src/paperpi/web/plugins.py index 849c24a..4c7c0c2 100644 --- a/src/paperpi/web/plugins.py +++ b/src/paperpi/web/plugins.py @@ -9,6 +9,8 @@ - A change is refused while the file has a problem that stops PaperPi from using it (such as a wrong ``[display]``): PaperPi would keep running on the last good copy, so the change would not apply. +- A preview (:meth:`PluginEditor.preview_job`) makes the same change to a copy of the file + text, so it draws the settings as Save would write them, and saves nothing. """ from __future__ import annotations @@ -23,7 +25,7 @@ from .. import config, example, plugins from ..plugin import ID_LENGTH, Plugin, PluginEntry, label -from . import config_file, forms +from . import config_file, forms, preview #: Plugin types the Plugin Library doesn't offer: ``default`` is PaperPi's own message when #: nothing else can be shown, and ``debugging`` is for testing PaperPi. @@ -131,14 +133,7 @@ def change(self, edit: Callable[[str], str]) -> bool: new = edit(text) if new == text: # nothing to save or apply return False - try: - config.parse(new, path.name) - except config.ConfigError as error: - found = "; ".join(str(p) for p in error.problems if p.level == "error") - raise config_file.EditError( - "The config file has a problem that must be fixed first, in the file " - f"itself: {found}" - ) from None + _parse(new, path.name) config_file.write(path, new) if self._reload is not None: self._reload() @@ -171,15 +166,32 @@ def save_settings(self, index: int, plugin_id: str, form: Mapping[str, Sequence[ """Save a sent settings form (see :func:`forms.read`) into block ``index``. Raises :class:`FormErrors` when a value can't be used, so nothing is saved.""" - def save(text: str) -> str: - plugin, block = _plugin_block(text, index, plugin_id) - found = forms.read(plugin, block, form) - if found.errors: - raise FormErrors(found) - changes, shown = found.changes, forms.defaults(plugin) - return config_file.set_settings(text, index, plugin_id, changes, shown) + return self.change(lambda text: _with_settings(text, index, plugin_id, form)) - return self.change(save) + def preview_job( + self, index: int, plugin_id: str, form: Mapping[str, Sequence[str]] + ) -> preview.Job: + """What a preview of block ``index`` draws with the settings of a sent form: the + block as Save would write it, and the screen. Nothing is saved. Raises + :class:`FormErrors` when a value can't be used.""" + path, text = config_file.read(self.path) + new = _with_settings(text, index, plugin_id, form) + checked = _parse(new, path.name) + # By the line of its [[plugin]] line: another block may have the same ID. + place = config_file.blocks(new)[index] + found = next((p for p in checked.plugins if p.line == place.header + 1), None) + if found is None: + problems = "; ".join( + _without_place(p) + for p in checked.errors + if p.line and place.start < p.line <= place.end + ) + raise config_file.EditError( + "This plugin can't be drawn. Its settings in the config file have a problem: " + f"{problems or 'unknown'}" + ) + width, height = checked.display.layout_size + return preview.Job(found, width, height, checked.display.screen_mode) def row(self, index: int, plugin_id: str) -> Row: """The block at ``index``, if it still has the ID ``plugin_id``.""" @@ -291,6 +303,28 @@ def _plugin_block(text: str, index: int, plugin_id: str) -> tuple[Plugin, dict[s ) from None +def _with_settings(text: str, index: int, plugin_id: str, form: Mapping[str, Sequence[str]]) -> str: + """``text`` with the settings of a sent form in block ``index``. Raises + :class:`FormErrors` when a value can't be used.""" + plugin, block = _plugin_block(text, index, plugin_id) + found = forms.read(plugin, block, form) + if found.errors: + raise FormErrors(found) + changes, shown = found.changes, forms.defaults(plugin) + return config_file.set_settings(text, index, plugin_id, changes, shown) + + +def _parse(text: str, source: str) -> config.Config: + """Check changed config file text, as PaperPi would load it.""" + try: + return config.parse(text, source) + except config.ConfigError as error: + found = "; ".join(str(p) for p in error.problems if p.level == "error") + raise config_file.EditError( + f"The config file has a problem that must be fixed first, in the file itself: {found}" + ) from None + + def _row(found: PluginList, index: int, plugin_id: str) -> Row: if not 0 <= index < len(found.rows) or found.rows[index].id != plugin_id: raise config_file.ChangedMeanwhile() diff --git a/src/paperpi/web/preview.py b/src/paperpi/web/preview.py new file mode 100644 index 0000000..a4494e9 --- /dev/null +++ b/src/paperpi/web/preview.py @@ -0,0 +1,170 @@ +"""Previews: draw a plugin with the settings in its form, before they are saved. + +See ``docs/decisions/web-interface.md``. A preview runs one update in its own process with a +time limit, as the scheduler does (:func:`paperpi.runner.run_update`), at the screen's size +and for the screen's type. The real screen is not touched. Each preview gets a new, empty +storage folder that is deleted afterwards, so a preview can't change the plugin's saved +files. + +A preview first tries the plugin's real data, for at most :data:`~paperpi.limits.PREVIEW_LIVE` +seconds (or the plugin's own time limit, if that is shorter). When that fails, takes too long +or has nothing to show, or a required setting is missing, it draws the plugin's sample data +instead and says why. + +Only one preview is drawn at a time: each one starts a Python process and may wait for a +web server, and a phone may press the button more than once. A second preview meanwhile gets +:class:`Busy`. +""" + +from __future__ import annotations + +import base64 +import io +import logging +import tempfile +import threading +import time +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +from epdlib import ScreenMode + +from .. import limits +from ..config import PluginConfig +from ..plugin import Context, State +from ..runner import PluginFailed, PluginTimeout, UpdateResult, run_update + +log = logging.getLogger(__name__) + +#: Runs one update: ``update(plugin_type, context, sample=..., time_limit=...)``, as +#: :func:`paperpi.runner.run_update`. Tests pass a quick fake. +Update = Callable[..., UpdateResult] + + +class Busy(RuntimeError): + """Another preview is being drawn now.""" + + +class PreviewFailed(RuntimeError): + """Neither the real data nor the sample data could be drawn.""" + + +@dataclass(frozen=True) +class Job: + """What to draw: one checked plugin block, as Save would write it, and the screen.""" + + found: PluginConfig + width: int + height: int + mode: ScreenMode + + +@dataclass(frozen=True) +class Preview: + """A drawn preview.""" + + png: bytes + sample: bool + """Drawn from the plugin's sample data, not real data.""" + note: str + """Why the sample data was drawn (empty for real data).""" + alert: bool + """The plugin said its picture is an alert.""" + seconds: float + """How long drawing took, in all.""" + + @property + def data_url(self) -> str: + """The picture as a ``data:`` address, to show it in the page itself.""" + return "data:image/png;base64," + base64.b64encode(self.png).decode("ascii") + + +class Previewer: + """Draws previews, one at a time.""" + + def __init__(self, update: Update = run_update): + self._update = update + self._lock = threading.Lock() + self._stopped = threading.Event() + + def stop(self) -> None: + """PaperPi stops: start no more plugin processes for previews. (A running one is + stopped by :func:`paperpi.runner.stop_all`.)""" + self._stopped.set() + + def draw(self, job: Job) -> Preview: + """Draw ``job``. Raises :class:`Busy` while another preview is drawn and + :class:`PreviewFailed` when nothing could be drawn. Can take up to about + :data:`~paperpi.limits.PREVIEW_LIVE` + :data:`~paperpi.limits.PREVIEW_SAMPLE` + seconds, so call it from a worker thread.""" + if not self._lock.acquire(blocking=False): + raise Busy("Another preview is being drawn. Wait a moment, then try again.") + try: + self._check_stopped() + with tempfile.TemporaryDirectory(prefix="paperpi-preview-") as storage: + return self._draw(job, Path(storage)) + finally: + self._lock.release() + + def _check_stopped(self) -> None: + if self._stopped.is_set(): + raise PreviewFailed("PaperPi is stopping, so no preview can be drawn now.") + + def _draw(self, job: Job, storage: Path) -> Preview: + found = job.found + plugin_type = found.plugin.type + context = Context(found.settings, job.width, job.height, job.mode, storage, found.layout) + start = time.monotonic() + failed = False # the real data failed (not: it wasn't tried, or had nothing) + if found.missing: + note = f"Real data needs these settings first: {', '.join(found.missing)}." + else: + time_limit = min(found.entry.time_limit, limits.PREVIEW_LIVE) + try: + result = self._update(plugin_type, context, sample=False, time_limit=time_limit) + except PluginTimeout as error: + note = f"The real data took longer than {time_limit:g} seconds." + failed = True + _log_details(found, error) + except PluginFailed as error: + note = f"The real data could not be drawn: {_sentence(error.reason)}" + failed = True + _log_details(found, error) + else: + if result.state is not State.NOTHING and result.image is not None: + return _preview(result, sample=False, note="", start=start) + note = "The plugin has nothing to show right now, so the screen would skip it." + self._check_stopped() # e.g. stop_all ended the real data's process + try: + result = self._update( + plugin_type, context, sample=True, time_limit=limits.PREVIEW_SAMPLE + ) + except PluginFailed as error: + _log_details(found, error) + either = " either" if failed else "" + raise PreviewFailed( + f"{note} The sample data could not be drawn{either}: {_sentence(error.reason)}" + ) from None + if result.image is None: + raise PreviewFailed(f"{note} The sample data gives no picture.") + return _preview(result, sample=True, note=note, start=start) + + +def _preview(result: UpdateResult, *, sample: bool, note: str, start: float) -> Preview: + png = io.BytesIO() + result.image.save(png, format="PNG") + alert = result.state is State.ALERT + return Preview(png.getvalue(), sample, note, alert, time.monotonic() - start) + + +def _sentence(text: str) -> str: + """``text`` ending with a full stop, so another sentence can follow.""" + text = text.rstrip() + return text if text.endswith((".", "!", "?")) else text + "." + + +def _log_details(found: PluginConfig, error: PluginFailed) -> None: + log.info("preview of %s: %s", found.entry.id, error) + if error.details: + log.debug("%s", error.details) diff --git a/src/paperpi/web/server.py b/src/paperpi/web/server.py index 0bbd127..7006830 100644 --- a/src/paperpi/web/server.py +++ b/src/paperpi/web/server.py @@ -19,6 +19,7 @@ from .app import create_app from .auth import Auth from .plugins import PluginEditor +from .preview import Previewer log = logging.getLogger(__name__) @@ -38,6 +39,7 @@ def __init__( ): self.auth = auth self.editor = editor + self.previewer = Previewer() self.settings = settings """The settings it was started with; a change of address or port needs a restart.""" self._socket = sock @@ -45,7 +47,7 @@ def __init__( """The port it listens on (useful with ``port = 0`` in tests).""" self._server = uvicorn.Server( uvicorn.Config( - create_app(auth, self.editor), + create_app(auth, self.editor, self.previewer), log_config=None, # PaperPi's own logging stays as it is log_level="warning", access_log=False, @@ -78,7 +80,12 @@ def use(self, loaded: config.Config) -> None: def running(self) -> bool: return self._thread.is_alive() + def stop_previews(self) -> None: + """Start no more previews: PaperPi is stopping (see :meth:`Previewer.stop`).""" + self.previewer.stop() + def stop(self) -> None: + self.stop_previews() self._server.should_exit = True self._thread.join(STOP_SECONDS) if self._thread.is_alive(): diff --git a/src/paperpi/web/static/style.css b/src/paperpi/web/static/style.css index 2f8450c..0c450fb 100644 --- a/src/paperpi/web/static/style.css +++ b/src/paperpi/web/static/style.css @@ -33,3 +33,8 @@ code { background: #eee; padding: 0 0.2rem; } .hint, .type, .required { color: #555; font-size: 0.9rem; } .technical dt { font-weight: bold; margin-top: 0.5rem; } .technical dd { margin-left: 0; } +.htmx-indicator { display: none; } +.htmx-request.htmx-indicator { display: block; color: #555; } +.preview { margin: 1rem 0; } +.preview img { display: block; max-width: 100%; height: auto; border: 1px solid #999; } +.preview figcaption { color: #555; font-size: 0.9rem; margin-top: 0.4rem; } diff --git a/src/paperpi/web/templates/preview.html b/src/paperpi/web/templates/preview.html new file mode 100644 index 0000000..e7ffad4 --- /dev/null +++ b/src/paperpi/web/templates/preview.html @@ -0,0 +1,23 @@ +{#- Only a part of the settings page: htmx puts it into
. -#} +{% if errors %} +

Fix these settings first, then try again:

+
    + {% for key, message in errors.items() %} +
  • {% if key %}{{ key }}: {% endif %}{{ message }}
  • + {% endfor %} +
+{% elif problem %} +

{{ problem|sentence }}

+{% else %} +
+ Preview of the picture on the screen +
+ {%- if drawn.sample %}Sample data, not real data. {{ drawn.note }} + {%- else %}Real data.{% endif %} + {%- if drawn.alert %} Shown as an alert.{% endif %} + Drawn in {{ "%.1f"|format(drawn.seconds) }} s at the screen's size, {{ job.width }} x + {{ job.height }} pixels. Not saved yet. Press Save to keep these settings. +
+
+{% endif %} diff --git a/src/paperpi/web/templates/settings.html b/src/paperpi/web/templates/settings.html index cf8b1b4..7b1228a 100644 --- a/src/paperpi/web/templates/settings.html +++ b/src/paperpi/web/templates/settings.html @@ -94,8 +94,14 @@

When and how it is shown

+ {# htmx sends this form's values; the answer replaces the picture below. #} + Back to Active Plugins
+

Drawing the preview. This can take about a + minute.

+
diff --git a/tests/test_web_preview.py b/tests/test_web_preview.py new file mode 100644 index 0000000..1559ff8 --- /dev/null +++ b/tests/test_web_preview.py @@ -0,0 +1,319 @@ +"""The Preview button on a plugin's settings page (M5 part 3b-1).""" + +import base64 +import html +import io +import re +import threading + +import pytest +from epdlib import ScreenMode +from fastapi.testclient import TestClient +from PIL import Image + +from paperpi import config, limits +from paperpi.plugin import State +from paperpi.runner import PluginFailed, PluginTimeout, UpdateResult, run_update +from paperpi.web import auth +from paperpi.web.app import create_app +from paperpi.web.plugins import PluginEditor +from paperpi.web.preview import Previewer, PreviewFailed + +from .test_web_settings import CONFIG, PI +from .test_web_settings import kinds as kinds # a fixture + + +class FakeUpdate: + """Stands in for the plugin process: answers each update from ``answers`` in turn (a + State, or an error to raise) and notes what it was asked.""" + + def __init__(self, *answers): + self.answers = list(answers) or [State.READY] + self.calls = [] + + def __call__(self, plugin_type, context, *, sample, time_limit): + self.calls.append((plugin_type, context, sample, time_limit)) + assert context.storage.is_dir() and not any(context.storage.iterdir()) + answer = self.answers.pop(0) + if isinstance(answer, Exception): + raise answer + image = Image.new("L", (context.width, context.height), 255 if sample else 0) + return UpdateResult(answer, None if answer is State.NOTHING else image, 0.5) + + +@pytest.fixture +def cfg(tmp_path): + path = tmp_path / "paperpi.toml" + path.write_text(CONFIG) + return path + + +@pytest.fixture +def reloads(): + return [] + + +def make_client(cfg, reloads, update): + editor = PluginEditor(cfg, lambda: reloads.append(1)) + editor.loaded(CONFIG) + paperpi_auth = auth.Auth(cfg, config.WebSettings(login=False)) + app = create_app(paperpi_auth, editor, Previewer(update)) + return TestClient(app, follow_redirects=False, base_url=PI) + + +def preview(client, index, plugin_id, **form): + response = client.post(f"/plugins/{index}/preview", data={"id": plugin_id, **form}) + assert response.status_code == 200 # htmx only shows answers with 200 + return html.unescape(response.text) + + +def picture(page): + found = re.search(r'src="data:image/png;base64,([^"]+)"', page) + return Image.open(io.BytesIO(base64.b64decode(found.group(1)))) + + +def test_the_settings_page_has_a_preview_button(cfg, reloads): + page = make_client(cfg, reloads, FakeUpdate()).get("/plugins/0/settings?id=Clock").text + assert 'hx-post="/plugins/0/preview"' in page and 'hx-target="#preview"' in page + assert '
lat" in page + assert update.calls == [] + + +def test_a_block_that_changed_meanwhile_is_not_drawn(cfg, reloads): + update = FakeUpdate() + page = preview(make_client(cfg, reloads, update), 0, "Weather") + assert "changed" in page and update.calls == [] + + +def test_the_screen_size_and_type_come_from_the_display_settings(cfg, reloads): + cfg.write_text( + CONFIG.replace( + 'type = "virtual"', + 'type = "virtual"\nwidth = 800\nheight = 480\nrotation = 90\nmode = "gray4"', + ) + ) + update = FakeUpdate() + page = preview(make_client(cfg, reloads, update), 0, "Clock") + context = update.calls[0][1] + assert (context.width, context.height, context.mode) == (480, 800, ScreenMode.gray(4)) + assert picture(page).size == (480, 800) + + +def test_a_config_file_paperpi_cant_use_is_not_drawn(cfg, reloads): + cfg.write_text(CONFIG.replace('type = "virtual"', 'type = "nope"')) + update = FakeUpdate() + page = preview(make_client(cfg, reloads, update), 0, "Clock") + assert "must be fixed first" in page and update.calls == [] + + +def test_of_two_blocks_with_the_same_id_the_right_one_is_drawn(cfg, reloads): + # PaperPi leaves out the second one ("used twice"): it can't be drawn, and says why. + cfg.write_text(CONFIG + '\n[[plugin]]\nid = "clock"\ntype = "word_clock"\n') + update = FakeUpdate() + page = preview(make_client(cfg, reloads, update), 3, "clock") + assert ( + "This plugin can't be drawn" in page + and "already used by the plugin block at line 5" in page + ) + assert update.calls == [] + + +def test_only_the_problems_of_this_block_are_listed(cfg, reloads): + cfg.write_text(CONFIG + '\n[[plugin]]\nid = "Broken"\ntype = "no_such_type"\n') + page = preview(make_client(cfg, reloads, FakeUpdate()), 0, "Clock") + assert "