diff --git a/CLAUDE.md b/CLAUDE.md index 66044f4..ff16b14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,7 @@ Every PR costs txoof review time. Too many small PRs and too few huge ones both ## Tools and commands - Python 3.13 (the version in Raspberry Pi OS trixie). `uv` installs Python and all packages into `.venv`. - `uv sync`: install. `uv run pytest`: tests. `uv run ruff check .` and `uv run ruff format .`: code style. +- `uv run pytest -n auto` runs the tests on every processor core at once (pytest-xdist; about half the time on the 4-core Pi). While working, run only the tests of the files you changed; run all of them once before the first push of a PR. GitHub runs all of them for every pull request and every merge to main. - Plain `.py` files only. No Jupyter notebooks in the repo. - Tests that need a real display are marked `@pytest.mark.hardware`. They are skipped by default; run them on the Pi with `uv run pytest -m hardware`. diff --git a/README.md b/README.md index d4d5a5d..6edaa07 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 two pages for plugins: **Active Plugins** (switch plugins on or off, move them up or down, remove one) and **Plugin Library** (every plugin that comes with PaperPi; add one at the end of the list). 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 on the same line as `enabled =`. 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; until the settings page follows (issue #238), fill those in in the config file. +`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. - **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 09ca8e9..100b147 100644 --- a/docs/decisions/web-interface.md +++ b/docs/decisions/web-interface.md @@ -24,8 +24,8 @@ In v2 the web interface is the main way to set up and change PaperPi: plugins, s *Added in M5 part 2b (issue #238), agreed with txoof on 2026-10-07.* - **Active Plugins** lists every `[[plugin]]` block in the config file, in file order (the order the plugins take turns), also blocks with errors. Each has: Switch on / Switch off, **Up** and **Down** buttons (no dragging, so no extra JavaScript library; works the same on a phone), and Remove, which asks first. - **Plugin Library** lists every plugin type that comes with PaperPi, with its one-line description, except `default` (PaperPi's own "nothing to show" message) and `debugging` (for testing PaperPi). Choosing one asks for a name (a free one is suggested, for example "Basic clock 2"; it may be left empty) and adds the block at the end of Active Plugins with a new `id` (see `config-format.md`, M5 part 3a-0). Active Plugins shows the name, and the `id` next to the type when it differs, so two plugins with the same name can be told apart. Sample pictures follow in part 3b. -- A plugin with required settings (see `plugin-interface.md`) is added **switched off**, and the add page lists those settings with their help text (for `met_no`: a real email address, as met.no's terms ask). Active Plugins shows "Needs settings: ..." and can't switch it on until they are filled in. -- Each change is saved at once (nothing else in the config file changes: comments stay, and the comments just above a `[[plugin]]` line move and are removed with its block; a comment on the same line as `enabled =` is lost) and PaperPi reloads the file, so it applies at once. +- A plugin with required settings (see `plugin-interface.md`) is added **switched off**, and the add page lists those settings with their help text (for `met_no`: a real email address, as met.no's terms ask). Active Plugins shows "Needs settings: ..." and can't switch it on until they are filled in. *M5 part 3a-2 (agreed with txoof on 2026-10-08):* after adding, the new plugin's settings page opens at once, and each plugin in Active Plugins has a Settings link. The settings page also has a folded "Technical information" part with the plugin's `id`, type and storage folder. +- Each change is saved at once (nothing else in the config file changes: comments stay, and the comments just above a `[[plugin]]` line move and are removed with its block; a comment on the same line as a setting that changes, such as `enabled =`, is lost) and PaperPi reloads the file, so it applies at once. - **Hand edits not applied yet:** a change from the web interface is made to the file as it is on disk, so hand edits stay and apply together with it. The page then says "Your hand edits to the config file were applied too." (The other choice, refusing to save until the hand edits are applied, needs an extra step every time.) - A change names its block by place and `id`. If the block was moved or its `id` changed by hand since the page was shown, nothing is changed and the page says the file changed meanwhile. A file the web interface can't change safely (for example a `[[plugin]]` line written in an unusual way) is never saved wrongly: every change is read back and compared before saving. A change is also refused while the file has a problem that stops PaperPi from using it (PaperPi then runs on the last good copy, so the change would not apply), and when the file would become larger than PaperPi reads. @@ -33,8 +33,10 @@ In v2 the web interface is the main way to set up and change PaperPi: plugins, s - One page handles every plugin. It reads the plugin's settings description (see `plugin-interface.md`) and builds the form from it: number field, dropdown, password field for API keys, and so on. - On Save, the values are checked with the same description. Errors are shown next to the field. A saved change applies at once (see `live-config-reload.md`). -- A setting that needs more than a plain field can name an input type from a small list in the web interface, e.g. `location` (lat/lon lookup), `server_search` (find music servers), `layout_picker`. New input types are added when a plugin needs one. +- A setting that needs more than a plain field can name an input type from a small list in the web interface, e.g. `location` (lat/lon lookup), `server_search` (find music servers), `layout_picker`. New input types are added when a plugin needs one. *Built in M5 part 3a-2:* a plugin names one with `paperpi.plugin.setting(..., helper="location")`; the web interface's list (`paperpi.web.helpers`) holds a function per name that adds its part under the field. A name it doesn't know adds nothing, so the plain field still works. The first helper is `location` (part 3c). - Plugins added later show up without changes to the web code. +- *Agreed with txoof on 2026-10-08 (M5 part 3a):* the page shows the plugin's `name` first, then its own settings, then display time, refresh, layout and level; the other shared settings (time limit, alert and storage settings) are folded under "More settings", which opens by itself when one of them has an error or a value that is not the default. Each field shows its value, or the default when the file doesn't set it. +- How a save works (M5 part 3a-1): only settings that changed are written. An empty field means "the default" (an empty check box: off; an empty list: nothing picked); a value changed to the default is taken out of the file and becomes the comment `# key = default` again (a value left as it is stays, also one written in the file that equals the default), so the help text above it still fits. A new setting takes the place of its `# key = ...` comment line when there is one. If any value has an error, nothing is saved; each error is shown next to its field, with what was typed (never a secret). A secret (such as an API key) is never shown on the page; leaving its field empty keeps the saved one. A setting the form can't show (a group of settings, a list of numbers) is shown as it is in the file, to be changed there. A setting written over several lines in the file is never changed by the page. ### Logging in diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 486194b..37a395c 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -80,8 +80,8 @@ Each setting is a field of the plugin's `Settings` class, with a type, a default - Every setting needs a default. - Don't use the names of the shared settings, which every `[[plugin]]` block already has: `id`, `name`, `type`, `enabled`, `level`, `display_time`, `refresh`, `time_limit`, `layout`, `alert_reminder`, `alert_max_time`. - Use `pydantic.SecretStr` as the type for API keys and passwords. PaperPi then never shows their values in error messages or logs. -- A setting the user must fill in before the plugin can work (a place, an email address, an API key) is made with `paperpi.plugin.setting(..., required=True)` instead of `Field`. It takes the same arguments as `Field`. Its default must be "not set": `None`, empty text or an empty `SecretStr` (text of only spaces also counts as not set). Until every required setting is filled in, the plugin is not shown and not counted as broken; the config check, `paperpi list` and the web interface's Active Plugins page say which settings are missing. The example block marks them "(required)". `setting` is also where later options for a single setting are added, such as a web interface helper that looks up latitude and longitude (M5 part 3c). -- The web interface shows the plugin's `description` in its Plugin Library, and the help text of each required setting on the page that adds the plugin. Write both for someone at home who is not a programmer. +- A setting the user must fill in before the plugin can work (a place, an email address, an API key) is made with `paperpi.plugin.setting(..., required=True)` instead of `Field`. It takes the same arguments as `Field`. Its default must be "not set": `None`, empty text or an empty `SecretStr` (text of only spaces also counts as not set). Until every required setting is filled in, the plugin is not shown and not counted as broken; the config check, `paperpi list` and the web interface's Active Plugins page say which settings are missing. The example block marks them "(required)". `setting` is also where other options for a single setting go: `helper="location"` names a web interface helper shown under the setting's field (the first one, a latitude and longitude lookup, comes in M5 part 3c; see `paperpi.web.helpers`). +- The web interface shows the plugin's `description` in its Plugin Library, and the help text (`description`) of every setting on the plugin's Settings page, next to its field. Write both for someone at home who is not a programmer. The settings page is built from the settings description: a `Literal` becomes a choice list, `bool` a check box, `int`/`float` a number field (with its `ge`, `gt`, `le` and `lt` limits), `str` a text field (with `max_length`), `SecretStr` a password field, and a tuple or list of a `Literal` a list to tick. A setting of another kind is shown as it is in the file, to be changed there. ```python from paperpi.plugin import PluginSettings, setting diff --git a/pyproject.toml b/pyproject.toml index b33c734..0fd89ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dev = [ "httpx2>=2.13.1", "pytest>=8", "pytest-html>=4", + "pytest-xdist>=3.8.0", "ruff>=0.6", ] diff --git a/src/paperpi/example.py b/src/paperpi/example.py index 4856e26..31317dd 100644 --- a/src/paperpi/example.py +++ b/src/paperpi/example.py @@ -113,13 +113,13 @@ def plugin_block( ] if shared: lines += _comment(entry["id"].description or "") - lines.append(f"id = {_toml(plugin_id)}") + lines.append(f"id = {toml_value(plugin_id)}") named = "name" in values if shared: lines += _setting("name", entry["name"], values.get("name", ""), comment=not named) elif named: - lines.append(f"name = {_toml(values['name'])}") - lines.append(f"type = {_toml(plugin.type)}") + lines.append(f"name = {toml_value(values['name'])}") + lines.append(f"type = {toml_value(plugin.type)}") for key, value in values.items(): if key == "name": continue @@ -199,11 +199,11 @@ def _setting(key: str, info: FieldInfo, value: Any, *, comment: bool = False) -> help_text = info.description or key if is_required(info): help_text += " (required)" - choices = _choices(info.annotation) - named = all(re.search(rf"\b{re.escape(str(c))}\b", help_text) for c in choices) - if choices and not named: - help_text += f". One of: {', '.join(_toml(c) for c in choices)}" - line = f"{key} = {_toml(value)}" if value is not None else f"{key} =" + allowed = choices(info.annotation) + named = all(re.search(rf"\b{re.escape(str(c))}\b", help_text) for c in allowed) + if allowed and not named: + help_text += f". One of: {', '.join(toml_value(c) for c in allowed)}" + line = f"{key} = {toml_value(value)}" if value is not None else f"{key} =" return [*_comment(help_text), f"# {line}" if comment else line] @@ -219,7 +219,7 @@ def _check_reads_back(text: str, values: Mapping[str, Any]) -> None: TOML than Python's reader understands; such a block would make the whole file unreadable. """ - expected = {"plugin": [{k: _plain(v) for k, v in values.items()}]} + expected = {"plugin": [{k: plain(v) for k, v in values.items()}]} try: read = tomllib.loads(text) except tomllib.TOMLDecodeError as error: @@ -228,7 +228,7 @@ def _check_reads_back(text: str, values: Mapping[str, Any]) -> None: raise ValueError("can't be written so that PaperPi reads it back the same") -def _choices(annotation: Any) -> tuple: +def choices(annotation: Any) -> tuple: """The allowed values of a ``Literal`` setting (also inside ``... | None``).""" if typing.get_origin(annotation) is Literal: return typing.get_args(annotation) @@ -239,7 +239,7 @@ def _choices(annotation: Any) -> tuple: return () -def _plain(value: Any) -> Any: +def plain(value: Any) -> Any: """``value`` as plain data: text, numbers, true/false, lists and dictionaries.""" if hasattr(value, "get_secret_value"): value = value.get_secret_value() @@ -248,15 +248,15 @@ def _plain(value: Any) -> Any: return to_jsonable_python(value) -def _toml(value: Any) -> str: +def toml_value(value: Any) -> str: """``value`` written as in a TOML file: ``24``, ``true``, ``"Berlin"``, ``[1, 2]``, ``{a = 1}`` (a group of settings on one line).""" - value = _plain(value) + value = plain(value) if isinstance(value, dict): - items = (f"{tomlkit.key(k).as_string()} = {_toml(v)}" for k, v in value.items()) + items = (f"{tomlkit.key(k).as_string()} = {toml_value(v)}" for k, v in value.items()) return "{" + ", ".join(items) + "}" if isinstance(value, list): - return "[" + ", ".join(_toml(v) for v in value) + "]" + return "[" + ", ".join(toml_value(v) for v in value) + "]" if isinstance(value, float) and value.is_integer(): value = int(value) # 120, not 120.0 return tomlkit.item(value).as_string() diff --git a/src/paperpi/plugin.py b/src/paperpi/plugin.py index a4852df..364fc80 100644 --- a/src/paperpi/plugin.py +++ b/src/paperpi/plugin.py @@ -102,11 +102,12 @@ class Settings(PluginSettings): _OPTIONS = "paperpi" -def setting(default: Any, *, required: bool = False, **field: Any) -> Any: +def setting( + default: Any, *, required: bool = False, helper: str | None = None, **field: Any +) -> Any: """A plugin setting: pydantic's ``Field`` (with the same ``description``, ``ge``, ``max_length``, ...) plus what PaperPi needs to know about it. This is the one place a - setting gets PaperPi's own options; later ones (such as a web interface helper that - looks up a place's latitude and longitude, M5 part 3c) are added here too:: + setting gets PaperPi's own options:: lat: float | None = setting(None, required=True, description="Latitude") @@ -114,20 +115,39 @@ def setting(default: Any, *, required: bool = False, **field: Any) -> Any: address, an API key). Its default must be "not set" (see :func:`is_set`). A plugin with a required setting that is not set is not shown; the config check and ``paperpi list`` and the web interface's Active Plugins page say which settings it needs. + + ``helper``: the name of a web interface helper for this setting, shown under its field + on the settings page, for example ``"location"`` (look up a place's latitude and + longitude, M5 part 3c). A name the web interface doesn't know is left out, so the + plain field still works. See ``paperpi.web.helpers``. """ extra = field.pop("json_schema_extra", None) or {} if not isinstance(extra, dict): raise TypeError("setting() takes json_schema_extra only as a dictionary") - if required: - extra = extra | {_OPTIONS: {"required": True}} + options = {"required": True} if required else {} + if helper is not None: + options["helper"] = helper + if options: + extra = extra | {_OPTIONS: options} return Field(default, json_schema_extra=extra or None, **field) -def is_required(info: FieldInfo) -> bool: - """True for a setting made with ``setting(required=True)``.""" +def _options(info: FieldInfo) -> dict[str, Any]: + """PaperPi's own options of a setting made with :func:`setting`.""" extra = info.json_schema_extra options = extra.get(_OPTIONS) if isinstance(extra, dict) else None - return isinstance(options, dict) and bool(options.get("required")) + return options if isinstance(options, dict) else {} + + +def is_required(info: FieldInfo) -> bool: + """True for a setting made with ``setting(required=True)``.""" + return bool(_options(info).get("required")) + + +def helper_of(info: FieldInfo) -> str | None: + """The web interface helper a setting names (``setting(helper=...)``), or ``None``.""" + helper = _options(info).get("helper") + return helper if isinstance(helper, str) else None def is_set(value: Any) -> bool: diff --git a/src/paperpi/web/app.py b/src/paperpi/web/app.py index d0838cf..17852b0 100644 --- a/src/paperpi/web/app.py +++ b/src/paperpi/web/app.py @@ -8,9 +8,11 @@ ``/login`` log in; also says how to reset a forgotten password ``/logout`` log out (a button on every page) ``/plugins`` Active Plugins: the plugins in the config file; switch on or off, move - up or down, remove (``/plugins//...``, n = place in the file, - counted from 0) -``/library`` Plugin Library: every plugin type; ``/library/`` adds one + 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) +``/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 one part of a page without loading the whole page again) ================= ============================================================== @@ -37,10 +39,13 @@ from starlette.concurrency import run_in_threadpool from .. import __version__ +from ..config import folder_name from .auth import COOKIE_SECONDS, Auth, new_cookie from .config_file import EditError +from .forms import Read, fields +from .helpers import helper_html from .password import PasswordError, password_problem -from .plugins import PluginEditor, library, library_item +from .plugins import FormErrors, PluginEditor, library, library_item COOKIE = "paperpi_login" _HERE = Path(__file__).parent @@ -58,7 +63,8 @@ def create_app(auth: Auth, editor: PluginEditor | None = None) -> FastAPI: 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") - templates.env.globals.update(version=__version__, auth=auth) + templates.env.globals.update(version=__version__, auth=auth, helper_html=helper_html) + templates.env.filters["number"] = _number templates.env.filters["sentence"] = lambda text: text[:1].upper() + text[1:] def page(request: Request, template: str, status: int = 200, **values) -> HTMLResponse: @@ -173,7 +179,6 @@ def plugin_list(request: Request, status: int = 200, problem: str | None = None) done=done if done in ("saved", "added", "removed") else None, name=name, hand_edits=request.query_params.get("hand") == "1", - config_file=auth.config_file, ) def saved(done: str, hand_edits: bool, plugin_id: str = "") -> RedirectResponse: @@ -219,6 +224,61 @@ def remove(request: Request, index: int, plugin_id: _FormId = ""): return plugin_list(request, 409, str(error)) return saved("removed", hand_edits) + def settings_page( + request: Request, + index: int, + plugin_id: str, + status: int = 200, + found: Read | None = None, + ) -> HTMLResponse: + try: + plugin, block = editor.settings(index, plugin_id) + row = editor.row(index, plugin_id) + except EditError as error: + return plugin_list(request, 409, str(error)) + errors = found.errors if found else {} + shown = fields(plugin, block, found.sent if found else None, errors) + groups = {g: [f for f in shown if f.group == g] for g in ("name", "own", "common", "more")} + # Errors that belong to no field on the page (a broken id, ...). + other = [m for k, m in errors.items() if k not in {f.key for f in shown}] + done = request.query_params.get("done") + return page( + request, + "settings.html", + status, + row=row, + plugin=plugin, + groups=groups, + more_open=any(f.error or f.changed for f in groups["more"]), + folder=folder_name(row.id), + done=done if done in ("saved", "added") and not found else None, + hand_edits=request.query_params.get("hand") == "1", + problem="; ".join(other) if other else FormErrors.MESSAGE if found else None, + ) + + @app.get("/plugins/{index}/settings", response_class=HTMLResponse) + def settings_form(request: Request, index: int, plugin_id: _QueryId = ""): + return settings_page(request, index, plugin_id) + + @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"} + try: + hand_edits = await run_in_threadpool(editor.save_settings, index, plugin_id, sent) + except FormErrors as error: + return await run_in_threadpool( + settings_page, request, index, plugin_id, 400, error.found + ) + except EditError as error: + return await run_in_threadpool(plugin_list, request, 409, str(error)) + return settings_saved(index, plugin_id, "saved", hand_edits) + + 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) + @app.get("/library", response_class=HTMLResponse) def library_page(request: Request): return page(request, "library.html", items=library()) @@ -243,7 +303,15 @@ def add(request: Request, plugin_type: str, name: str = Form("")): hand_edits, plugin_id = editor.add(item, name) except EditError as error: return add_page(request, plugin_type, 400, name=name, problem=str(error)) - return saved("added", hand_edits, plugin_id) + # On to its settings page, to fill in what it needs (agreed with txoof, M5 part 3a). + try: + rows = editor.plugin_list().rows + except EditError: # the file can't be read a moment later: show what can be shown + rows = [] + index = next((r.index for r in rows if r.id == plugin_id), None) + if index is None: # moved away or removed by hand meanwhile + return saved("added", hand_edits, plugin_id) + return settings_saved(index, plugin_id, "added", hand_edits) @app.post("/logout") def logout(): @@ -254,6 +322,11 @@ def logout(): return app +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) + + def _known_host(host: str) -> bool: """True for an IP address or ``localhost``, with or without a port (see the top).""" if host.startswith("["): # an IPv6 address: [::1]:8080 diff --git a/src/paperpi/web/config_file.py b/src/paperpi/web/config_file.py index 1b500c4..1c3f392 100644 --- a/src/paperpi/web/config_file.py +++ b/src/paperpi/web/config_file.py @@ -4,11 +4,11 @@ file's permissions (and owner, when run as root). Hold :data:`LOCK` from reading to saving, so two changes at the same moment (a plugin and the password) can't undo each other. :func:`saved_here` tells whether a text is the one the web interface saved last. -- The plugin changes (:func:`move`, :func:`remove`, :func:`set_enabled`, :func:`add`) work on - the file's text, one ``[[plugin]]`` block at a time. The comment lines just above a - ``[[plugin]]`` line (with a blank line above them) belong to that block: they move with it - and are removed with it. A comment on the same line as ``enabled =`` is lost when the - plugin is switched on or off. +- The plugin changes (:func:`move`, :func:`remove`, :func:`set_enabled`, :func:`set_settings`, + :func:`add`) work on the file's text, one ``[[plugin]]`` block at a time. The comment lines + just above a ``[[plugin]]`` line (with a blank line above them) belong to that block: they + move with it and are removed with it. A comment at the end of a setting's line (such as + ``enabled =``) is lost when that setting is changed. - Every change is checked before it is saved: PaperPi must read the new text back as exactly the old settings plus the change. A file written in a way these functions don't understand (for example a ``[[plugin]]`` line written differently, or a value line @@ -25,11 +25,14 @@ import stat import threading import tomllib +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path from typing import Any -from .. import config, limits +import tomlkit + +from .. import config, example, limits from ..files import write_atomic #: Held from reading the config file to saving the changed copy. @@ -254,6 +257,119 @@ def set_enabled(text: str, index: int, plugin_id: str, enabled: bool) -> str: return _checked("".join(new), data | {"plugin": expected}) +def set_settings( + text: str, + index: int, + plugin_id: str, + values: Mapping[str, Any], + defaults: Mapping[str, Any] | None = None, +) -> str: + """``text`` with settings of plugin block ``index`` changed: ``values`` maps a setting + to its new value, or to ``None`` to remove it (back to its default). + + - A setting that is in the block gets its new value on the same line (a comment at the + end of that line is lost). + - A new one replaces a ``# key = ...`` comment line, so the help text above it stays. + Without one, ``name`` goes below ``id`` and the others below the block's last setting. + - A removed setting with a value in ``defaults`` becomes the comment ``# key = default`` + (``# key =`` for ``None``), as in the example config; others are taken out. + - A setting written over several lines can't be changed: :class:`UnknownForm`. + """ + defaults = defaults or {} + data, scan = _check(text, index, plugin_id) + block = scan.blocks[index] + lines = _ended(scan.lines) + newline = _newline(lines) + own = [n for n in range(block.header + 1, block.own_end) if n not in scan.in_string] + expected = plugin_blocks(data) + changed = dict(expected[index]) + new: dict[int, str | None] = {} # line -> its new text (None: taken out) + added: list[tuple[str, str]] = [] # (setting, line) with no place in the block yet + for key, value in values.items(): + at = _setting_line(lines, own, re.escape(key)) + if at is not None and not _one_line(lines[at]): + raise UnknownForm() + commented = _commented_setting(lines, own, key) + if value is None: + changed.pop(key, None) + if at is not None: + # Back to "# key = default", unless the block has that comment already. + new[at] = None if commented is not None else _default_line(key, defaults, newline) + continue + changed[key] = example.plain(value) + line = f"{_key(key)} = {example.toml_value(value)}{newline}" + if at is None: + at = commented # a commented-out setting, so its help text above stays + if at is None: + added.append((key, line)) + else: + new[at] = line + + def setting_after(number: int) -> bool: + line = new.get(number, lines[number]) + return line is not None and bool(line.strip()) and not line.lstrip().startswith("#") + + last = max((n for n in own if setting_after(n)), default=block.header) + below: dict[int, list[str]] = {} # line -> new lines to put below it + for key, line in added: + at = _setting_line(lines, own, "id") if key == "name" else None + at = _value_end(lines, last if at is None else at, block.own_end) + below.setdefault(at, []).append(line) + result = [] + for number, line in enumerate(lines): + replaced = new.get(number, line) + if replaced is not None: + result.append(replaced) + result += below.get(number, []) + expected[index] = changed + return _checked("".join(result), data | {"plugin": expected}) + + +def _value_end(lines: list[str], line: int, end: int) -> int: + """The last line of the setting that starts on ``line``: a list or a text written over + several lines ends further down. ``line`` itself when it is not a setting.""" + if not _HEADER.fullmatch(lines[line].rstrip("\r\n")) and not _one_line(lines[line]): + for last in range(line + 1, end): + if _one_line("".join(lines[line : last + 1])): + return last + return line + + +def _commented_setting(lines: list[str], within: list[int], key: str) -> int | None: + """The line of a commented-out ``key``: ``# key = value`` (a setting without the ``#``) + or ``# key =`` (not set), not a help text that starts the same way.""" + for number in within: + line = lines[number].strip() + if not re.match(rf"#\s*{re.escape(key)}\s*=", line): + continue + if _one_line(line[1:]) or re.fullmatch(rf"#\s*{re.escape(key)}\s*=", line): + return number + return None + + +def _key(key: str) -> str: + """``key`` as written in a TOML file (in quotes when it needs them).""" + return tomlkit.key(key).as_string() + + +def _default_line(key: str, defaults: Mapping[str, Any], newline: str) -> str | None: + """The comment that takes the place of a removed setting, or ``None`` for none.""" + if key not in defaults: + return None + default = defaults[key] + shown = "" if default is None else f" {example.toml_value(default)}" + return f"# {_key(key)} ={shown}{newline}" + + +def _one_line(line: str) -> bool: + """True when the setting on ``line`` is complete on that line.""" + try: + tomllib.loads(line) + except tomllib.TOMLDecodeError: + return False + return True + + def add(text: str, block_text: str) -> str: """``text`` with ``block_text`` (one ``[[plugin]]`` block, as written by :func:`paperpi.example.plugin_block`) added after the last plugin block.""" diff --git a/src/paperpi/web/forms.py b/src/paperpi/web/forms.py new file mode 100644 index 0000000..b9d6732 --- /dev/null +++ b/src/paperpi/web/forms.py @@ -0,0 +1,339 @@ +"""The plugin settings form: its fields, made from the plugin's settings description, and +the values a sent form holds, checked the same way as the config file. + +- :func:`fields` lists the settings the form shows, in groups: the plugin's ``name``, its + own settings, the shared ones people change most (:data:`COMMON`), and the rest + ("More settings"). Each field shows the setting's value, or its default when the config + file doesn't set it. ``id`` and ``type`` can't change; ``enabled`` is switched on the + Active Plugins page. +- :func:`read` turns a sent form into the changes to save, or an error per field (then + nothing is saved). An empty field means "the default" (for a check box: off; for a list: + nothing picked). A value changed to its default is taken out of the file, which only holds + the settings that differ from the default (``docs/decisions/config-format.md``). An empty + secret (an API key, ...) keeps the one that is saved, so the page never has to show it. +- Browsers send nothing for a check box that is off or a list with nothing picked. So the + page adds a hidden empty field for each, and a field that is missing from the form is + left as it is. +- A setting of a kind the form can't show (a group of settings, a list of numbers) is shown + as it is in the file, to be changed there. +""" + +from __future__ import annotations + +import re +import types +import typing +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any, Literal + +from pydantic import BaseModel, SecretBytes, SecretStr, ValidationError +from pydantic.fields import FieldInfo + +from .. import example +from ..plugin import Plugin, PluginEntry, helper_of, is_required + +#: The shared settings shown right after the plugin's own ones; the others are folded away +#: under "More settings" (agreed with txoof, M5 part 3a). +COMMON = ("display_time", "refresh", "layout", "level") +#: Settings of a ``[[plugin]]`` block the form doesn't change. +NOT_IN_FORM = ("id", "type", "enabled") + +Kind = Literal["choice", "check", "number", "text", "secret", "choices", "file"] +Group = Literal["name", "own", "common", "more"] + + +@dataclass(frozen=True) +class FormField: + """One setting on the form.""" + + key: str + help: str + kind: Kind + """``choice``: pick one; ``check``: on or off; ``number``; ``text``; ``secret``: text + that is never shown; ``choices``: pick several; ``file``: change it in the file.""" + group: Group + value: str = "" + """The value as text (``""``: not set). ``check``: ``"true"`` or ``"false"``.""" + selected: tuple[str, ...] = () + """``choices``: the ones picked, as text.""" + choices: tuple[str, ...] = () + """``choice`` and ``choices``: every value it may have, as text.""" + values: tuple[Any, ...] = () + """The values of ``choices`` themselves (``12`` for ``"12"``).""" + default: str = "" + """The default, as text (``""``: none).""" + required: bool = False + """The plugin is not shown until this is filled in (see ``paperpi.plugin.setting``).""" + is_set: bool = False + """``secret``: a value is saved.""" + minimum: float | None = None + maximum: float | None = None + whole: bool = False + """``number``: whole numbers only.""" + max_length: int | None = None + error: str = "" + helper: str | None = None + """The web interface helper shown under the field (see :mod:`paperpi.web.helpers`).""" + changed: bool = False + """The config file sets a value that is not the default.""" + + +@dataclass(frozen=True) +class Read: + """What :func:`read` found in a sent form.""" + + changes: dict[str, Any] + """Each setting to change, with its new value; ``None`` takes it out of the file.""" + errors: dict[str, str] = field(default_factory=dict) + """Why a setting's value can't be used, by setting (``""``: not one field).""" + sent: dict[str, Sequence[str]] = field(default_factory=dict) + """The form as sent, to show it again with the errors.""" + + +def defaults(plugin: Plugin) -> dict[str, Any]: + """The value each setting has when the file doesn't set it: the shared settings' and + the plugin's own defaults, and the plugin's suggestions (``refresh``, the first layout, + the storage limits).""" + found = {key: info.default for key, info in _infos(plugin).items()} + return found | { + "refresh": plugin.refresh, + "layout": plugin.default_layout, + "storage_mb": plugin.storage_mb, + "storage_days": plugin.storage_days, + } + + +def fields( + plugin: Plugin, + block: Mapping[str, Any], + sent: Mapping[str, Sequence[str]] | None = None, + errors: Mapping[str, str] | None = None, +) -> list[FormField]: + """The fields of the form for ``plugin``, whose ``[[plugin]]`` block holds ``block``. + With ``sent`` (a form that had ``errors``), they show what was sent.""" + shown = defaults(plugin) + errors = errors or {} + found = [] + for key, info in _infos(plugin).items(): + kind, allowed = _kind(info.annotation) + if key == "layout": + kind, allowed = "choice", tuple(plugin.layouts) + value = block.get(key, shown[key]) + texts = [_text(v) for v in value] if isinstance(value, list | tuple) else [_text(value)] + if kind == "file": + texts = [example.toml_value(value) if value is not None else ""] + if _holds_secret(info.annotation) and texts[0]: + texts = ["(set; it holds a secret, so it is not shown)"] + if sent is not None and key in sent and kind not in ("secret", "file"): + # Without the empty value the page sends for a check box or list. + texts = [t for t in sent[key] if t] or [""] + if kind == "check": + texts = ["true" if texts[-1] not in ("", "false") else "false"] + elif kind == "choices": + texts = [t for t in texts if t] + found.append( + FormField( + key=key, + help=info.description or key, + kind=kind, + group=_group(key, plugin), + value="" if kind in ("secret", "choices") else (texts[-1] if texts else ""), + selected=tuple(texts) if kind == "choices" else (), + choices=tuple(_text(c) for c in allowed), + values=tuple(allowed), + default=_default_text(kind, shown[key], info), + required=is_required(info), + is_set=kind == "secret" and bool(_text(value)), + changed=example.plain(value) != example.plain(shown[key]), + minimum=_limit(info, "ge", "gt"), + maximum=_limit(info, "le", "lt"), + whole=_plain_type(info.annotation) is int, + max_length=_limit(info, "max_length"), + error=errors.get(key, ""), + helper=helper_of(info), + ) + ) + order: list[Group] = ["name", "own", "common", "more"] + return sorted( + found, + key=lambda f: (order.index(f.group), COMMON.index(f.key) if f.group == "common" else 0), + ) + + +def read(plugin: Plugin, block: Mapping[str, Any], form: Mapping[str, Sequence[str]]) -> Read: + """The changes a sent ``form`` makes to the ``[[plugin]]`` block holding ``block``, each + value checked by the plugin's settings description. ``form`` maps a field to the texts + sent for it (several for a ``choices`` field).""" + values: dict[str, Any] = {} + errors: dict[str, str] = {} + sent = {key: list(texts) for key, texts in form.items()} + for item in fields(plugin, block): + # A field that is not in the form stays as it is (see the top of this file). + if item.key not in sent: + continue + texts = [t.strip() for t in sent[item.key]] + if item.kind == "choices": + texts = [t for t in texts if t] + text = texts[-1] if texts else "" + if item.kind == "file" or (item.kind == "secret" and not text): + continue + try: + values[item.key] = _value(item, text, texts) + except ValueError as error: + errors[item.key] = str(error) + shared = {k: v for k, v in values.items() if k in PluginEntry.model_fields} + own = {k: v for k, v in values.items() if k not in shared} + entry = {k: block[k] for k in ("id", "type") if k in block} + _check(PluginEntry, entry | _set(block, shared, PluginEntry), errors) + _check(plugin.settings, _set(block, own, plugin.settings), errors) + if errors: + # Shown again on the page, so without what was typed into a secret field. + secrets = {item.key for item in fields(plugin, block) if item.kind == "secret"} + return Read({}, errors, {k: v for k, v in sent.items() if k not in secrets}) + shown = defaults(plugin) + changes = {} + for key, value in values.items(): + # As plain data: the file gives a list where the form gives a tuple. A value as it + # is in the file stays, also one written there that equals the default. + if key in block and example.plain(value) == example.plain(block[key]): + continue + new = None if example.plain(value) == example.plain(shown[key]) else value + if example.plain(new) != example.plain(block.get(key)): + changes[key] = new + return Read(changes) + + +def _infos(plugin: Plugin) -> dict[str, FieldInfo]: + """Every setting the form shows, with its description.""" + entry = {k: v for k, v in PluginEntry.model_fields.items() if k not in NOT_IN_FORM} + return entry | dict(plugin.settings.model_fields) + + +def _group(key: str, plugin: Plugin) -> Group: + if key == "name": + return "name" + if key in plugin.settings.model_fields: + return "own" + return "common" if key in COMMON else "more" + + +def _kind(annotation: Any) -> tuple[Kind, tuple]: + """The kind of field for a setting of this type, and the values it may have.""" + allowed = example.choices(annotation) + if allowed: + return "choice", allowed + plain = _plain_type(annotation) + if typing.get_origin(plain) in (tuple, list): + inner = typing.get_args(plain) + allowed = example.choices(inner[0]) if inner else () + return ("choices", allowed) if allowed else ("file", ()) + if plain is bool: + return "check", () + if plain in (int, float): + return "number", () + if plain is SecretStr: + return "secret", () + if plain is str: + return "text", () + return "file", () + + +def _plain_type(annotation: Any) -> Any: + """``annotation`` without ``| None``.""" + if isinstance(annotation, types.UnionType) or typing.get_origin(annotation) is typing.Union: + parts = [a for a in typing.get_args(annotation) if a is not type(None)] + return parts[0] if len(parts) == 1 else annotation + return annotation + + +def _limit(info: FieldInfo, *names: str) -> Any: + """The first of the limits ``names`` (``ge``, ``max_length``, ...) the setting has.""" + for part in info.metadata: + for name in names: + found = getattr(part, name, None) + if found is not None: + return found + return None + + +def _default_text(kind: Kind, default: Any, info: FieldInfo) -> str: + """The default as the page shows it next to the field (``""``: none).""" + if kind == "secret" or default is None: + return "" + if kind == "check": + return "on" if default else "off" + if kind == "choices": + return ", ".join(_text(v) for v in default) or "none" + if kind == "file": + return "" if _holds_secret(info.annotation) else example.toml_value(default) + return _text(default) + + +def _holds_secret(annotation: Any) -> bool: + """True when a setting of this type is, or holds, a secret (``SecretStr``).""" + if annotation in (SecretStr, SecretBytes): + return True + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + return any(_holds_secret(f.annotation) for f in annotation.model_fields.values()) + return any(_holds_secret(a) for a in typing.get_args(annotation)) + + +def _text(value: Any) -> str: + """``value`` as the form shows it.""" + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + if isinstance(value, SecretStr): + return value.get_secret_value() + return str(value) + + +def _value(item: FormField, text: str, texts: list[str]) -> Any: + """The value of a field, from the text sent for it. Raises ``ValueError``.""" + if item.kind == "check": + return text not in ("", "false") + if item.kind == "choices": + if any(t not in item.choices for t in texts): + raise ValueError(f"choose from: {', '.join(item.choices)}") + # Each one once, in the order of the choices (a form can't send more). + return tuple(_choice(item, t) for t in item.choices if t in texts) + if not text: + return None + if item.kind == "number": + # Plain digits only: int() and float() also take "1_0", "nan" and other scripts' digits. + pattern = r"-?[0-9]{1,18}" if item.whole else r"-?[0-9]{1,18}(\.[0-9]{1,18})?" + if not re.fullmatch(pattern, text): + raise ValueError("enter a whole number" if item.whole else "enter a number") + return int(text) if item.whole else float(text) + if item.kind == "choice": + if text not in item.choices: + raise ValueError(f"choose one of: {', '.join(item.choices)}") + return _choice(item, text) + return text + + +def _choice(item: FormField, text: str) -> Any: + """The value of a choice from its text, e.g. ``12`` for ``"12"`` (hours).""" + return item.values[item.choices.index(text)] + + +def _set( + block: Mapping[str, Any], values: Mapping[str, Any], model: type[BaseModel] +) -> dict[str, Any]: + """The settings of ``model`` in ``block`` with ``values`` changed (``None``: not set).""" + merged = {k: v for k, v in block.items() if k in model.model_fields} | dict(values) + return {k: v for k, v in merged.items() if v is not None} + + +def _check(model: type[BaseModel], values: Mapping[str, Any], errors: dict[str, str]) -> None: + """Add what ``model`` finds wrong with ``values`` to ``errors``, by setting.""" + try: + model.model_validate(values) + except ValidationError as error: + for found in error.errors(): + key = str(found["loc"][0]) if found["loc"] else "" + errors.setdefault(key, found["msg"].removeprefix("Value error, ")) diff --git a/src/paperpi/web/helpers.py b/src/paperpi/web/helpers.py new file mode 100644 index 0000000..0c2c7c6 --- /dev/null +++ b/src/paperpi/web/helpers.py @@ -0,0 +1,31 @@ +"""Setting helpers: an extra part of the settings page, under one setting's field, for a +setting that needs more than a plain field (``docs/decisions/web-interface.md``). + +A plugin names a helper with ``paperpi.plugin.setting(..., helper="location")``. A helper +here is a function that gets the field (:class:`paperpi.web.forms.FormField`) and returns +the HTML to show under it. A name that is not in :data:`HELPERS` (yet) shows nothing, so +the plain field still works. The first helper, ``location`` (look up a place's latitude +and longitude), comes in M5 part 3c; new ones are added when a plugin needs one. + +The page shows a helper's HTML as it is, and the field's texts come from the config file +or the form. So build the HTML with ``Markup("

{}

").format(...)`` or a template, +never with an f-string: those escape the texts (turn ``<`` into ``<`` and so on), so +typed text can't become part of the page. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from markupsafe import Markup + +from .forms import FormField + +#: The helpers the settings page knows, by name. +HELPERS: dict[str, Callable[[FormField], Markup]] = {} + + +def helper_html(field: FormField) -> Markup: + """The HTML of the helper ``field`` names, or nothing.""" + helper = HELPERS.get(field.helper) if field.helper else None + return helper(field) if helper is not None else Markup("") diff --git a/src/paperpi/web/plugins.py b/src/paperpi/web/plugins.py index 9b551e1..849c24a 100644 --- a/src/paperpi/web/plugins.py +++ b/src/paperpi/web/plugins.py @@ -14,21 +14,32 @@ from __future__ import annotations import secrets -from collections.abc import Callable +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field from pathlib import Path +from typing import Any from pydantic import ValidationError from .. import config, example, plugins from ..plugin import ID_LENGTH, Plugin, PluginEntry, label -from . import config_file +from . import config_file, forms #: 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. HIDDEN = ("default", "debugging") +class FormErrors(config_file.EditError): + """A settings form with values that can't be used; nothing was saved.""" + + MESSAGE = "Nothing was saved: some settings can't be used; see the messages next to them." + + def __init__(self, found: forms.Read): + super().__init__(self.MESSAGE) + self.found = found + + @dataclass(frozen=True) class Row: """One ``[[plugin]]`` block, as the Active Plugins page shows it.""" @@ -107,7 +118,8 @@ def change(self, edit: Callable[[str], str]) -> bool: """Change the file with ``edit`` (old text -> new text), save it and apply it. Returns True when the file had hand edits that were not applied yet; they apply - too. Raises :class:`config_file.EditError` when the change can't be made. + too. When ``edit`` changes nothing, nothing is saved or applied (False). Raises + :class:`config_file.EditError` when the change can't be made. """ with config_file.LOCK: path, text = config_file.read(self.path) @@ -117,6 +129,8 @@ def change(self, edit: Callable[[str], str]) -> bool: and not config_file.saved_here(path, text) ) new = edit(text) + if new == text: # nothing to save or apply + return False try: config.parse(new, path.name) except config.ConfigError as error: @@ -148,6 +162,25 @@ def switch(text: str) -> str: def remove(self, index: int, plugin_id: str) -> bool: return self.change(lambda text: config_file.remove(text, index, plugin_id)) + def settings(self, index: int, plugin_id: str) -> tuple[Plugin, dict[str, Any]]: + """The plugin of block ``index`` (if it still has the ID ``plugin_id``) and the + settings in its block, for the settings form.""" + return _plugin_block(config_file.read(self.path)[1], index, plugin_id) + + def save_settings(self, index: int, plugin_id: str, form: Mapping[str, Sequence[str]]) -> bool: + """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(save) + def row(self, index: int, plugin_id: str) -> Row: """The block at ``index``, if it still has the ID ``plugin_id``.""" return _row(self.plugin_list(), index, plugin_id) @@ -242,6 +275,22 @@ def _plugin_list(text: str, source: str = "config") -> PluginList: return PluginList(rows, file_problems) +def _plugin_block(text: str, index: int, plugin_id: str) -> tuple[Plugin, dict[str, Any]]: + raw = config_file.plugin_blocks(config_file.load(text)) + if not 0 <= index < len(raw) or config_file.id_of(raw[index]) != plugin_id: + raise config_file.ChangedMeanwhile() + plugin_type = raw[index].get("type") + try: + if plugin_type not in plugins.available(): + raise KeyError(plugin_type) + return plugins.load(plugin_type), raw[index] + except Exception: # noqa: BLE001 - a missing or broken plugin can't be set up here + raise config_file.EditError( + f"The settings of {plugin_id} can't be changed here: its plugin type " + f"{plugin_type!r} can't be used. Fix the block in the config file." + ) 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/static/style.css b/src/paperpi/web/static/style.css index 56d7036..2f8450c 100644 --- a/src/paperpi/web/static/style.css +++ b/src/paperpi/web/static/style.css @@ -22,3 +22,14 @@ code { background: #eee; padding: 0 0.2rem; } .buttons { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-top: 0.4rem; } .buttons form { margin: 0; display: flex; gap: 0.5rem; } .library li { margin: 0.5rem 0; } +.settings h2 { font-size: 1.1rem; margin-top: 1.5rem; } +.settings .field { margin: 0.9rem 0; } +.settings .field > label { display: block; margin: 0 0 0.25rem; } +.settings select { font-size: 1rem; padding: 0.3rem; } +.settings label.check { display: flex; gap: 0.4rem; align-items: center; margin: 0.25rem 0; } +.settings label.check input { width: auto; display: inline; } +.settings .wrong input, .settings .wrong select { border: 2px solid #a00; } +.settings .problem { margin: 0.25rem 0 0; } +.hint, .type, .required { color: #555; font-size: 0.9rem; } +.technical dt { font-weight: bold; margin-top: 0.5rem; } +.technical dd { margin-left: 0; } diff --git a/src/paperpi/web/templates/add.html b/src/paperpi/web/templates/add.html index e93adfd..d6d45d3 100644 --- a/src/paperpi/web/templates/add.html +++ b/src/paperpi/web/templates/add.html @@ -11,8 +11,8 @@

Add {{ item.type }}

    {% for key, help in item.required %}
  • {{ key }}: {{ help }}
  • {% endfor %}
-

Until the settings page is ready (a later version), type them into the config file on - the Pi ({{ auth.config_file }}), then switch the plugin on in Active Plugins.

+

Its settings page opens next: fill them in there, then switch the plugin on in Active + Plugins.

{% endif %}
diff --git a/src/paperpi/web/templates/plugins.html b/src/paperpi/web/templates/plugins.html index 240abb3..ab4449f 100644 --- a/src/paperpi/web/templates/plugins.html +++ b/src/paperpi/web/templates/plugins.html @@ -14,11 +14,6 @@

Active Plugins

{% for found in problems %}

{{ found|sentence }}

{% endfor %}

The plugins that are switched on take turns on the screen, in this order. Add a plugin from the Plugin Library.

-{% if rows | selectattr("missing") | list %} -

Some plugins need settings before they are shown. Until the settings page is -ready (a later version), type them into the config file on the Pi: -{{ config_file }}.

-{% endif %} {% if not rows and not problems %}

No plugins yet.

{% endif %}
    {% for row in rows %} @@ -41,6 +36,7 @@

    Active Plugins

    + {% if row.id %}Settings{% endif %} Remove diff --git a/src/paperpi/web/templates/settings.html b/src/paperpi/web/templates/settings.html new file mode 100644 index 0000000..cf8b1b4 --- /dev/null +++ b/src/paperpi/web/templates/settings.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} +{% set logged_in = true %} +{% block title %}Settings of {{ row.name }} ยท PaperPi{% endblock %} + +{% macro field(f) %} +
    + {% if f.kind == "check" %} + {# A browser sends nothing for a box that is off, so "false" goes first. #} + + + {% else %} + + {% if f.kind == "choice" %} + + {% elif f.kind == "choices" %} + {# A browser sends nothing for a list with nothing picked, so an empty value goes first. #} + +
    + {% for choice in f.choices %} + + {% endfor %} +
    + {% elif f.kind == "number" %} + {# A phone's number keys often have no minus, so only for numbers that can't be below 0. #} + = 0 %} + inputmode="{{ 'numeric' if f.whole else 'decimal' }}"{% endif %}> + {% elif f.kind == "text" %} + + {% elif f.kind == "secret" %} + + {% else %} +

    {{ f.value or "(not set)" }} + Change this one in the config file.

    + {% endif %} + {% if f.kind not in ("secret", "file") %} +
    + {%- if f.kind == "number" and (f.minimum is not none or f.maximum is not none) %} + {%- if f.minimum is not none %}From {{ f.minimum|number }}{% endif %} + {%- if f.maximum is not none %} up to {{ f.maximum|number }}{% endif %}. {% endif %} + {%- if f.default %}Default: {{ f.default }}.{% elif f.kind != "choices" %}Empty: not set.{% endif -%} +
    + {% endif %} + {% endif %} + {% if f.error %}

    {{ f.error|sentence }}

    {% endif %} + {{ helper_html(f) }} +
    +{% endmacro %} + +{% block main %} +

    {{ row.name }}

    +

    {{ row.type }}: {{ plugin.description }}

    +{% if done %} +

    + {%- if done == "added" %}Added. Fill in its settings here, then Save. + {%- else %}Saved. PaperPi applies the change now.{% endif %} + {%- if hand_edits %} Your hand edits to the config file were applied too.{% endif %}

    +{% endif %} +{% if problem %}

    {{ problem|sentence }}

    {% endif %} +{% if row.missing %} +

    Not shown until these settings are filled in: {{ row.missing|join(", ") }}. +{%- if not row.enabled %} Then switch it on in Active Plugins.{% endif %}

    +{% endif %} +{% for found in row.errors %}

    {{ found }}

    {% endfor %} + +
    + + {% for f in groups.name %}{{ field(f) }}{% endfor %} + {% if groups.own %} +

    Settings of this plugin

    + {% for f in groups.own %}{{ field(f) }}{% endfor %} + {% endif %} +

    When and how it is shown

    + {% for f in groups.common %}{{ field(f) }}{% endfor %} + + More settings + {% for f in groups.more %}{{ field(f) }}{% endfor %} + + + + +
    + Technical information +
    +
    ID
    {{ row.id }} (fixed; it names the storage folder)
    +
    Type
    {{ row.type }}
    +
    Storage folder
    plugins/{{ folder }}/ in PaperPi's state folder
    +
    Config file
    {{ auth.config_file }}, plugin block {{ row.index + 1 }}
    +
    +
    +{% endblock %} diff --git a/tests/test_web_config_file.py b/tests/test_web_config_file.py index c493b0f..510c2d2 100644 --- a/tests/test_web_config_file.py +++ b/tests/test_web_config_file.py @@ -204,3 +204,124 @@ def test_a_file_too_large_for_paperpi_is_not_saved(tmp_path, monkeypatch): config_file.write(path, CONFIG) assert config_file.saved_here(path, CONFIG) assert not config_file.saved_here(path, CONFIG + "#\n") + + +# --- Changing settings ------------------------------------------------------------------------ + + +def test_setting_a_value_uses_the_commented_line_and_keeps_its_help(): + new = config_file.set_settings(CONFIG, 0, "Clock", {"refresh": 120, "hours": 12}) + assert '# the clock in the kitchen\n[[plugin]]\nid = "Clock"\n' in new + assert 'type = "basic_clock"\nrefresh = 120\nhours = 12\n' in new + assert tomllib.loads(new)["plugin"][0] == { + "id": "Clock", + "type": "basic_clock", + "refresh": 120, + "hours": 12, + } + + +def test_changing_a_value_keeps_its_place_and_loses_the_comment_on_its_line(): + new = config_file.set_settings(CONFIG, 1, "Words", {"enabled": False}) + assert 'type = "word_clock"\nenabled = false\n[plugin.extra]' in new + + +def test_a_name_goes_below_the_id(): + new = config_file.set_settings(CONFIG, 1, "Words", {"name": 'Words "big"'}) + assert '[[plugin]]\nid = "Words"\nname = "Words \\"big\\""\ntype = "word_clock"' in new + + +def test_removing_a_value_shows_its_default_as_a_comment_or_takes_the_line_out(): + text = CONFIG.replace("# refresh = 60", "refresh = 120\nhours = 12") + new = config_file.set_settings( + text, 0, "Clock", {"refresh": None, "hours": None}, {"refresh": 60} + ) + assert 'type = "basic_clock"\n# refresh = 60\n\n# words' in new + assert tomllib.loads(new)["plugin"][0] == {"id": "Clock", "type": "basic_clock"} + # None as the default: "# key =", as in the example config. + new = config_file.set_settings(text, 0, "Clock", {"hours": None}, {"hours": None}) + assert "refresh = 120\n# hours =\n" in new + + +def test_new_settings_go_below_the_last_one_that_stays(): + text = CONFIG.replace("# refresh = 60", "refresh = 120\n# help for hours\nhours = 12") + new = config_file.set_settings(text, 0, "Clock", {"hours": None, "display_time": 30}) + assert "refresh = 120\ndisplay_time = 30\n# help for hours\n\n# words" in new + + +def test_settings_in_a_part_or_a_string_are_not_changed(): + text = CONFIG.replace("a = 1", "a = 1\nlevel = 'alert'").replace( + "enabled = true # for now", 'note = """\nlevel = "x"\n"""' + ) + new = config_file.set_settings(text, 1, "Words", {"level": "interrupt"}) + block = tomllib.loads(new)["plugin"][1] + assert block["level"] == "interrupt" and block["extra"]["level"] == "alert" + assert block["note"] == 'level = "x"\n' + + +def test_a_value_over_several_lines_is_not_changed(): + text = CONFIG.replace("# refresh = 60", "hours = [\n 12,\n]") + with pytest.raises(config_file.UnknownForm): + config_file.set_settings(text, 0, "Clock", {"hours": 24}) + + +def test_settings_of_a_block_changed_meanwhile_are_not_changed(): + with pytest.raises(ChangedMeanwhile): + config_file.set_settings(CONFIG, 0, "Words", {"hours": 12}) + + +def test_windows_line_breaks_are_kept_when_settings_change(): + text = CONFIG.replace("\n", "\r\n") + new = config_file.set_settings(text, 0, "Clock", {"refresh": 120, "name": "Kitchen"}) + assert "\n" not in new.replace("\r\n", "") + + +def test_a_quoted_key_in_the_file_is_not_changed_wrongly(): + text = CONFIG.replace("# refresh = 60", '"hours" = 24') + with pytest.raises(config_file.UnknownForm): + config_file.set_settings(text, 0, "Clock", {"hours": 12}) + + +def test_a_key_that_starts_like_another_is_another(): + text = CONFIG.replace("# refresh = 60", "latitude = 1") + new = config_file.set_settings(text, 0, "Clock", {"lat": 2}) + assert tomllib.loads(new)["plugin"][0] | {} == { + "id": "Clock", + "type": "basic_clock", + "latitude": 1, + "lat": 2, + } + + +def test_a_key_that_needs_quotes_gets_them(): + new = config_file.set_settings(CONFIG, 0, "Clock", {"my key": 1}) + assert '"my key" = 1\n' in new + + +def test_removing_a_value_with_its_comment_there_already_adds_no_second_one(): + text = CONFIG.replace("# refresh = 60", "# hours = 24\nhours = 12") + new = config_file.set_settings(text, 0, "Clock", {"hours": None}, {"hours": 24}) + assert new.count("# hours = 24") == 1 and "hours = 12" not in new + + +def test_a_help_text_that_starts_like_a_setting_stays(): + text = CONFIG.replace("# refresh = 60", "# hours = 12 or 24 (default 24)") + new = config_file.set_settings(text, 0, "Clock", {"hours": 12}) + assert "# hours = 12 or 24 (default 24)\n" in new and "\nhours = 12\n" in new + + +def test_a_not_set_comment_takes_the_value(): + text = CONFIG.replace("# refresh = 60", "# help for lat\n# lat =") + new = config_file.set_settings(text, 0, "Clock", {"lat": 2}) + assert "# help for lat\nlat = 2\n" in new + + +def test_windows_line_breaks_keep_the_values(): + text = CONFIG.replace("\n", "\r\n") + new = config_file.set_settings(text, 0, "Clock", {"refresh": 120, "name": "Kitchen"}) + assert tomllib.loads(new)["plugin"][0] == { + "id": "Clock", + "name": "Kitchen", + "type": "basic_clock", + "refresh": 120, + } diff --git a/tests/test_web_forms.py b/tests/test_web_forms.py new file mode 100644 index 0000000..7b51808 --- /dev/null +++ b/tests/test_web_forms.py @@ -0,0 +1,227 @@ +from typing import Literal + +import pytest +from pydantic import Field, SecretStr + +from paperpi import plugins +from paperpi.plugin import Plugin, PluginSettings, ready, setting +from paperpi.web import forms + +WEATHER = {"id": "w", "type": "met_no", "lat": 52.52, "display_time": 60} +T = {"id": "t", "type": "test"} + + +def by_key(found): + return {f.key: f for f in found} + + +class Settings(PluginSettings): + token: SecretStr = Field(SecretStr(""), description="API key") + words: tuple[Literal["a", "b", "c"], ...] = Field(("a",), description="Words") + part: dict[str, int] = Field({"x": 1}, description="A group of settings") + loud: bool = Field(False, description="Loud") + + +TEST = Plugin( + type="test", + description="For the form tests", + settings=Settings, + layouts={"one": {"column": [{"name": "t", "type": "text"}]}}, + fetch=lambda context: ready(None), + draw=lambda data, context: {}, + sample=None, + refresh=60, +) + + +def test_fields_are_in_groups_in_the_agreed_order(): + found = forms.fields(plugins.load("met_no"), WEATHER) + assert [f.key for f in found] == [ + "name", + *["lat", "lon", "place", "email", "temperature", "rain"], + *["display_time", "refresh", "layout", "level"], + *["time_limit", "alert_reminder", "alert_max_time", "storage_mb", "storage_days"], + ] + assert [f.group for f in found].count("more") == 5 + assert "id" not in by_key(found) and "enabled" not in by_key(found) + + +def test_fields_show_the_value_or_else_the_default(): + found = by_key(forms.fields(plugins.load("met_no"), WEATHER)) + assert (found["lat"].value, found["lat"].required) == ("52.52", True) + assert (found["lat"].minimum, found["lat"].maximum, found["lat"].whole) == (-90, 90, False) + assert found["lon"].value == "" # not set, no default + assert (found["display_time"].value, found["display_time"].default) == ("60", "120") + assert found["refresh"].value == found["refresh"].default == "1800" # the suggestion + assert found["layout"].choices == tuple(plugins.load("met_no").layouts) + assert found["temperature"].choices == ("C", "F") + assert found["email"].max_length == 200 + assert found["storage_mb"].whole + + +def test_kinds_of_fields(): + found = by_key(forms.fields(TEST, {"token": "s3cret", "words": ["b", "c"]})) + assert found["token"].kind == "secret" and found["token"].is_set + assert found["token"].value == "" and found["token"].default == "" # never on the page + assert (found["words"].kind, found["words"].selected) == ("choices", ("b", "c")) + assert (found["part"].kind, found["part"].value) == ("file", "{x = 1}") + assert (found["loud"].kind, found["loud"].value) == ("check", "false") + assert found["level"].kind == "choice" + + +def test_reading_a_form_gives_only_the_changes(): + found = forms.read( + plugins.load("met_no"), + WEATHER, + { + "name": ["Weather"], + "lat": ["52.52"], # the same + "lon": [" 13.4 "], + "temperature": ["F"], + "display_time": ["120"], # the default: out of the file + "refresh": ["1800"], # the suggestion: not set, as before + "layout": ["small"], + }, + ) + assert found.errors == {} + assert found.changes == { + "name": "Weather", + "lon": 13.4, + "temperature": "F", + "display_time": None, + "layout": "small", + } + + +def test_an_empty_field_means_the_default(): + found = forms.read(plugins.load("met_no"), WEATHER, {"lat": [""], "display_time": [""]}) + assert found.changes == {"lat": None, "display_time": None} + + +def test_fields_left_out_of_the_form_stay_as_they_are(): + found = forms.read(plugins.load("met_no"), WEATHER, {}) + assert found.changes == {} and found.errors == {} + + +def test_choices_are_read_as_their_values(): + found = forms.read( + plugins.load("basic_clock"), {"id": "c", "type": "basic_clock"}, {"hours": ["12"]} + ) + assert found.changes == {"hours": 12} + + +@pytest.mark.parametrize( + ("form", "errors"), + [ + ({"lat": ["91"]}, {"lat": "Input should be less than or equal to 90"}), + ({"lon": ["east"]}, {"lon": "enter a number"}), + ({"storage_mb": ["1.5"]}, {"storage_mb": "enter a whole number"}), + ({"temperature": ["K"]}, {"temperature": "choose one of: C, F"}), + ( + {"layout": ["big"]}, + {"layout": "choose one of: " + ", ".join(plugins.load("met_no").layouts)}, + ), + ( + {"email": ["not an address"]}, + {"email": "String should match pattern '^$|^[^@\\s]+@[^@\\s]+$'"}, + ), + ( + {"name": ["a\tb"]}, + {"name": "must not hold control characters (such as tab or new line)"}, + ), + ( + {"lat": ["91"], "lon": ["x"]}, + {"lat": "Input should be less than or equal to 90", "lon": "enter a number"}, + ), + ], +) +def test_values_that_cant_be_used_give_an_error_each_and_no_changes(form, errors): + found = forms.read(plugins.load("met_no"), WEATHER, form) + assert found.errors == errors and found.changes == {} + assert found.sent == form + + +def test_a_form_with_errors_shows_what_was_sent(): + sent = {"lat": ["91"], "temperature": ["F"]} + found = by_key(forms.fields(plugins.load("met_no"), WEATHER, sent, {"lat": "too far"})) + assert (found["lat"].value, found["lat"].error) == ("91", "too far") + assert found["temperature"].value == "F" + assert found["display_time"].value == "60" # not sent: as in the file + + +def test_an_empty_secret_keeps_the_saved_one(): + assert forms.read(TEST, T | {"token": "s3cret"}, {"token": [""]}).changes == {} + assert forms.read(TEST, T | {"token": "s3cret"}, {"token": ["new"]}).changes == {"token": "new"} + + +def test_check_boxes_and_lists_with_the_empty_value_the_page_sends(): + # The page sends "false" before each check box and "" before each list. + block = T | {"loud": True, "words": ["b"]} + found = forms.read(TEST, block, {"loud": ["false"], "words": ["", "a"]}) + assert found.changes == {"loud": None, "words": None} # both back to their defaults + found = forms.read(TEST, T, {"loud": ["false", "true"], "words": ["", "b", "c"]}) + assert found.changes == {"loud": True, "words": ("b", "c")} + assert forms.read(TEST, block, {"words": [""]}).changes == {"words": ()} # none + assert forms.read(TEST, T, {"words": ["d"]}).errors == {"words": "choose from: a, b, c"} + + +def test_settings_of_a_kind_the_form_cant_show_are_never_changed(): + assert forms.read(TEST, T | {"part": {"x": 2}}, {"part": ["{x = 3}"]}).changes == {} + + +def test_required_settings_can_be_left_empty(): + # The plugin then waits for them (see paperpi.plugin.setting); the form says so. + class Needs(PluginSettings): + key: str = setting("", required=True, description="Key") + + plugin = Plugin(**{**TEST.__dict__, "settings": Needs}) + found = forms.read(plugin, T | {"key": "abc"}, {"key": [""]}) + assert found.errors == {} and found.changes == {"key": None} + + +def test_defaults_include_the_plugins_suggestions(): + weather = plugins.load("met_no") + shown = forms.defaults(weather) + assert shown["refresh"] == weather.refresh and shown["layout"] == weather.default_layout + assert shown["display_time"] == 120 and shown["lat"] is None + + +def test_unchanged_values_are_no_change(): + # The file gives a list and whole numbers; the form a tuple and text. + block = T | {"words": ["b", "c"], "loud": True} + form = {"words": ["", "b", "c"], "loud": ["false", "true"], "display_time": ["60"]} + assert forms.read(TEST, block | {"display_time": 60.0}, form).changes == {} + + +def test_a_list_picks_each_choice_once(): + found = forms.read(TEST, T, {"words": ["", "c", "a", "c", "a"]}) + assert found.changes == {"words": ("a", "c")} + + +@pytest.mark.parametrize( + "text", ["1_0", "nan", "inf", "1e3", "\N{ARABIC-INDIC DIGIT ONE}", "1" * 19, "0x10"] +) +def test_numbers_are_plain_digits(text): + found = forms.read(plugins.load("met_no"), WEATHER, {"display_time": [text]}) + assert found.errors == {"display_time": "enter a number"} + + +def test_a_form_shown_again_keeps_check_boxes_and_lists_as_sent(): + sent = {"loud": ["false"], "words": ["", "c"]} + found = by_key(forms.fields(TEST, T | {"loud": True}, sent, {"words": "x"})) + assert found["loud"].value == "false" + assert found["words"].selected == ("c",) + found = by_key(forms.fields(TEST, T | {"loud": True}, {"words": [""]}, {"words": "x"})) + assert found["loud"].value == "true" # not sent: as saved + assert found["words"].selected == () + + +def test_a_secret_is_not_sent_back_with_errors(): + found = forms.read(TEST, T, {"token": ["NEWSECRET"], "words": ["d"]}) + assert found.errors and "token" not in found.sent + assert "NEWSECRET" not in repr(found) + + +def test_a_default_written_in_the_file_stays_until_it_is_changed(): + block = WEATHER | {"display_time": 120} + assert forms.read(plugins.load("met_no"), block, {"display_time": ["120"]}).changes == {} diff --git a/tests/test_web_plugins.py b/tests/test_web_plugins.py index 8b9d38b..6e15d1a 100644 --- a/tests/test_web_plugins.py +++ b/tests/test_web_plugins.py @@ -11,6 +11,7 @@ from paperpi.web import password as web_password from paperpi.web import plugins as web_plugins from paperpi.web.app import create_app +from paperpi.web.config_file import EditError from paperpi.web.plugins import PluginEditor CONFIG = """\ @@ -158,8 +159,9 @@ def test_adding_a_plugin(client, cfg, reloads): added = blocks(cfg)[-1] assert re.fullmatch(r"word_clock-[0-9a-f]{8}", added["id"]) assert added == {"id": added["id"], "name": "Words", "type": "word_clock"} - assert response.headers["location"] == f"/plugins?done=added&id={added['id']}" - assert "Added Words." in client.get(response.headers["location"]).text + assert response.headers["location"] == f"/plugins/3/settings?id={added['id']}&done=added" + page = client.get(response.headers["location"]).text + assert "

    Words

    " in page and "Added. Fill in its settings here" in page assert reloads == [1] # The next one gets another name. client.post("/library/word_clock", data={"name": "Word clock"}) @@ -363,3 +365,67 @@ def test_the_file_keeps_its_permissions(client, cfg): cfg.chmod(0o640) client.post("/plugins/0/move", data={"id": "Clock", "step": "down"}) assert cfg.stat().st_mode & 0o777 == 0o640 + + +# --- Settings --------------------------------------------------------------------------------- + + +def test_saving_settings_changes_only_what_changed_and_applies_at_once(editor, cfg, reloads): + plugin, block = editor.settings(1, "Weather") + assert plugin.type == "met_no" and block["lat"] == 52.52 + form = {"lat": ["52.52"], "lon": ["13.5"], "email": ["me@example.org"], "display_time": [""]} + assert editor.save_settings(1, "Weather", form) is False # no hand edits + assert blocks(cfg)[1] == { + "id": "Weather", + "type": "met_no", + "lat": 52.52, + "lon": 13.5, + "enabled": False, + "email": "me@example.org", + } + assert reloads == [1] + assert "# the clock in the kitchen" in cfg.read_text() + + +def test_settings_with_errors_save_nothing(editor, cfg, reloads): + with pytest.raises(web_plugins.FormErrors) as raised: + editor.save_settings(1, "Weather", {"lat": ["91"], "lon": ["13.5"]}) + assert raised.value.found.errors == {"lat": "Input should be less than or equal to 90"} + assert raised.value.found.sent == {"lat": ["91"], "lon": ["13.5"]} + assert cfg.read_text() == CONFIG and reloads == [] + + +def test_settings_of_a_block_moved_meanwhile_are_not_saved(editor, cfg): + with pytest.raises(EditError, match="changed"): + editor.save_settings(0, "Weather", {"lat": ["1"]}) + with pytest.raises(EditError, match="changed"): + editor.settings(5, "Weather") + assert cfg.read_text() == CONFIG + + +def test_settings_of_an_unknown_plugin_type_cant_be_changed(editor, cfg): + cfg.write_text(CONFIG.replace('type = "basic_clock"\n\n', 'type = "nothing"\n\n', 1)) + with pytest.raises(EditError, match="its plugin type 'nothing' can't be used"): + editor.settings(0, "Clock") + + +def test_saving_the_same_form_again_changes_nothing(editor, cfg, reloads): + form = {"lon": ["13.5"], "temperature": ["F"]} + editor.save_settings(1, "Weather", form) + saved = cfg.read_text() + assert editor.save_settings(1, "Weather", form) is False + assert cfg.read_text() == saved and reloads == [1] + + +def test_a_list_over_several_lines_doesnt_stop_other_changes(editor, cfg): + cfg.write_text( + CONFIG + '\n[[plugin]]\nid = "Test"\ntype = "debugging"\nstates = [\n "ready",\n]\n' + ) + editor.save_settings(3, "Test", {"states": ["", "ready"], "text": ["hello"]}) + assert blocks(cfg)[3]["text"] == "hello" + + +def test_settings_of_an_unknown_plugin_type_cant_be_saved(editor, cfg): + cfg.write_text(CONFIG.replace('type = "basic_clock"\n\n', 'type = "nothing"\n\n', 1)) + with pytest.raises(EditError, match="its plugin type 'nothing' can't be used"): + editor.save_settings(0, "Clock", {"hours": ["12"]}) diff --git a/tests/test_web_settings.py b/tests/test_web_settings.py new file mode 100644 index 0000000..cade852 --- /dev/null +++ b/tests/test_web_settings.py @@ -0,0 +1,253 @@ +import tomllib +from typing import Literal + +import pytest +from fastapi.testclient import TestClient +from markupsafe import Markup +from pydantic import Field, SecretStr + +from paperpi import config +from paperpi.plugin import Plugin, PluginSettings, helper_of, is_required, ready, setting +from paperpi.web import auth, forms, helpers +from paperpi.web import plugins as web_plugins +from paperpi.web.app import create_app +from paperpi.web.plugins import PluginEditor + +CONFIG = """\ +config_version = 1 +[display] +type = "virtual" + +[[plugin]] +id = "Clock" +type = "basic_clock" + +[[plugin]] +id = "Weather" +name = "Weather Berlin" +type = "met_no" +lat = 52.52 +lon = 13.40 +enabled = false + +[[plugin]] +id = "Comic" +type = "xkcd_comic" +time_limit = 30 +""" + +# The test client opens the pages as a phone on the home network would: by IP address. +PI = "http://192.168.1.20:8080" + + +@pytest.fixture +def cfg(tmp_path): + path = tmp_path / "paperpi.toml" + path.write_text(CONFIG) + return path + + +@pytest.fixture +def reloads(): + return [] + + +@pytest.fixture +def client(cfg, reloads): + editor = PluginEditor(cfg, lambda: reloads.append(1)) + editor.loaded(CONFIG) + paperpi_auth = auth.Auth(cfg, config.WebSettings(login=False)) + return TestClient(create_app(paperpi_auth, editor), follow_redirects=False, base_url=PI) + + +def blocks(cfg): + return tomllib.loads(cfg.read_text())["plugin"] + + +def test_active_plugins_link_to_each_settings_page(client): + page = client.get("/plugins").text + assert 'Settings' in page + assert "type them into the config file" not in page + + +def test_the_page_shows_the_settings_in_their_groups(client): + page = client.get("/plugins/1/settings", params={"id": "Weather"}).text + assert "

    Weather Berlin

    " in page + assert page.index('name="name"') < page.index("Settings of this plugin") + assert page.index('name="lat"') < page.index("When and how it is shown") + assert page.index('name="display_time"') < page.index("More settings") + assert page.index("More settings") < page.index('name="storage_mb"') + assert 'value="52.52"' in page and "(required)" in page + assert "Not shown until these settings are filled in: email." in page + assert "
    \n More settings" in page # folded: all defaults + assert "Weather (fixed; it names the storage folder)" in page + assert "plugins/weather/" in page + + +def test_more_settings_open_by_themselves_when_one_is_not_the_default(client): + page = client.get("/plugins/2/settings", params={"id": "Comic"}).text + assert "
    \n More settings" in page + + +def test_saving_changes_the_file_and_applies_at_once(client, cfg, reloads): + form = {"id": "Weather", "email": "me@example.org", "lon": "13.5", "display_time": "120"} + response = client.post("/plugins/1/settings", data=form) + assert response.status_code == 303 + assert response.headers["location"] == "/plugins/1/settings?id=Weather&done=saved" + assert blocks(cfg)[1] == { + "id": "Weather", + "name": "Weather Berlin", + "type": "met_no", + "lat": 52.52, + "lon": 13.5, + "enabled": False, + "email": "me@example.org", + } + assert reloads == [1] + page = client.get(response.headers["location"]).text + assert "Saved. PaperPi applies the change now." in page + assert "Not shown until" not in page # email is set now + + +def test_values_that_cant_be_used_save_nothing_and_are_shown_again(client, cfg, reloads): + response = client.post( + "/plugins/1/settings", data={"id": "Weather", "lat": "91", "temperature": "F"} + ) + assert response.status_code == 400 + page = response.text + assert "Nothing was saved" in page + assert 'value="91"' in page and "Input should be less than or equal to 90" in page + assert '' in page + assert "level" in page and "Input should be" in page # the config check's message + + +def test_a_block_of_an_unknown_type_or_place_has_no_settings_page(client, cfg): + cfg.write_text(CONFIG.replace('type = "basic_clock"', 'type = "nothing"')) + response = client.get("/plugins/0/settings", params={"id": "Clock"}) + assert response.status_code == 409 and "its plugin type" in response.text + for index in (-1, 9): + assert client.get(f"/plugins/{index}/settings", params={"id": "Clock"}).status_code == 409 + + +def test_the_page_says_when_hand_edits_were_applied_too(client): + page = client.get("/plugins/0/settings", params={"id": "Clock", "done": "saved", "hand": "1"}) + assert "Your hand edits to the config file were applied too." in page.text diff --git a/uv.lock b/uv.lock index 8182597..2e4fc5c 100644 --- a/uv.lock +++ b/uv.lock @@ -69,6 +69,15 @@ it8951 = [ { name = "spidev" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "fastapi" version = "0.142.2" @@ -323,6 +332,7 @@ dev = [ { name = "httpx2" }, { name = "pytest" }, { name = "pytest-html" }, + { name = "pytest-xdist" }, { name = "ruff" }, ] @@ -344,6 +354,7 @@ dev = [ { name = "httpx2", specifier = ">=2.13.1" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-html", specifier = ">=4" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "ruff", specifier = ">=0.6" }, ] @@ -540,6 +551,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl", hash = "sha256:c8e0844db684ee1c798cfa38908d20d67d0463ecb6137c72e91f418558dd5f4b", size = 11428, upload-time = "2024-02-12T19:38:42.531Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-multipart" version = "0.0.32"