diff --git a/README.md b/README.md index bf2a5b0..ac337c9 100644 --- a/README.md +++ b/README.md @@ -68,14 +68,14 @@ Run `uv run paperpi render --help` for all options. How to write a plugin: [docs ### A config file -`paperpi example-config` prints an example config file that works as it is: a virtual screen, a clock, and the weather in Berlin and Rio. Every other setting is a comment with its default and a short help text. [paperpi.example.toml](paperpi.example.toml) is the same text. Before you use the weather blocks, put your own email address in `email` (met.no asks for it). `-o` doesn't replace a file that is already there, unless you add `--force`. +`paperpi example-config` prints an example config file that loads without errors: a virtual screen, a clock, and weather blocks for Berlin and Rio. Every other setting is a comment with its default and a short help text. [paperpi.example.toml](paperpi.example.toml) is the same text. The weather blocks are not shown until you remove the `#` in front of `email` and fill in your own, real email address: met.no's terms of service ask for it, so the example leaves it empty. Until then `paperpi list` shows them as "needs email". `-o` doesn't replace a file that is already there, unless you add `--force`. ```bash uv run paperpi example-config -o paperpi.toml uv run paperpi list --config paperpi.toml ``` -`paperpi list` shows the plugins of a config file, one line each, in the order of the file: name, type, on or off, level, display time, refresh, layout and storage (the ones used: the setting, or else the plugin's suggestion or first layout; storage is the size limit and the age limit, for example `500 MB, 30 d`, or `500 MB, no age limit`). Anything wrong with the file is shown first; a block with an error is left out of the list. +`paperpi list` shows the plugins of a config file, one line each, in the order of the file: name, type, on (`yes`, `no`, or `needs ` when a required setting is missing), level, display time, refresh, layout and storage (the ones used: the setting, or else the plugin's suggestion or first layout; storage is the size limit and the age limit, for example `500 MB, 30 d`, or `500 MB, no age limit`). Anything wrong with the file is shown first; a block with an error is left out of the list. ``` name type on level display refresh layout @@ -124,7 +124,7 @@ vcom = -1.90 Then start `paperpi run` as above, as a user in the `spi` and `gpio` groups (the first user on Raspberry Pi OS already is; otherwise `sudo usermod -aG spi,gpio $USER`, then log in again). A new image from the plugin already on screen is a fast refresh of only the changed area. Another plugin gets a full refresh, and so does every 5th fast refresh in a row (`max_refresh = 4`). If the screen does not answer at start, the error is in the log and PaperPi keeps running and tries again. -At start, PaperPi first shows its name, version and the address of its web interface (with a QR code to open it on a phone) for a minute, while the plugins get their first pictures. `splash_time` in `[display]` sets how long, in seconds (`0`: not at all). With no plugin switched on, for example right after installing, it stays on screen until one is, so the address to set PaperPi up is always there. +At start, PaperPi first shows its name, version and the address of its web interface (with a QR code to open it on a phone) for a minute, while the plugins get their first pictures. `splash_time` in `[display]` sets how long, in seconds (`0`: not at all). With no plugin ready to show, for example right after installing, it stays on screen until one is, so the address to set PaperPi up is always there. When no plugin has anything to show (e.g. no music is playing), a small clock is shown at the bottom of the screen, so you can tell the screen still works. `fallback_clock = false` in `[display]` switches it off, which is not recommended. diff --git a/docs/decisions/config-format.md b/docs/decisions/config-format.md index a097d56..684602b 100644 --- a/docs/decisions/config-format.md +++ b/docs/decisions/config-format.md @@ -66,6 +66,8 @@ display_time = 255 The file only holds settings that differ from the default. This keeps it short and easy to fix by hand. When a later version improves a default, you get it automatically. The web interface shows every setting with its default filled in. `paperpi.example.toml` is generated from the program (`paperpi example-config`), so it is always up to date; a test checks the copy in the repository. It is short and works as it is: `[display]` with a virtual screen, a clock, and the weather in Berlin and in Rio (the same plugin type twice). Every setting it doesn't set is a comment with its default, and its help text on the line above. The first `[[plugin]]` block lists all the settings every plugin has; the other blocks list only `refresh` and `layout`, because their defaults depend on the plugin. `storage_mb` and `storage_days` can also differ per plugin, but most plugins keep the defaults, so they are only in the first block; `paperpi list` shows the values used (M4 issue #224). The copy in the repository is not edited by hand: it is made again with `paperpi example-config -o paperpi.example.toml --force`. Agreed with txoof on 2026-10-06: the example does not list every plugin. Adding and removing plugin blocks is the job of a config manager (the web interface, M5), which builds a block the same way (`paperpi.example.plugin_block`). That function refuses unknown settings and values the plugin would not accept, and checks that PaperPi reads the block back as written. Plugin names may not hold control characters (such as tab, new line or the terminal's ESC), because they can't be written back to the file reliably. +*Update 2026-10-07 (M5 part 2a, issue #238):* the example no longer fills in a made-up email address, because met.no's terms of service ask for a real one. It loads without errors, but the weather blocks are not shown, and the check warns, until you fill in `email`. + ### Checking the file Each part of the config (display, web, each plugin type) is described in code as a list of settings, each with a type, a default and a short help text. The `pydantic` package does this. From the same description we get: @@ -82,6 +84,7 @@ What happens when something is wrong: | File can't be read at all, or the `display` / `web` part is wrong | Runs on the **last good** copy, a copy that is saved each time the config loads correctly. A warning on the screen and in the web interface names the wrong line. | | ...and there is no last good copy (first install) | Shows an error screen with a QR code (a square barcode a phone camera can scan) that opens the web interface. | | One plugin block is wrong (missing value, text where a number belongs) | Only that plugin is switched off. Everything else runs. The web interface shows which setting is wrong. | +| A switched-on plugin is missing a required setting (e.g. `email`, see `plugin-interface.md`) | The plugin is not shown and not counted as broken. A warning names the missing settings; `paperpi list` shows "needs …". | | Unknown setting name, e.g. `lattitude` | Warning only: "unknown setting, did you mean `latitude`?" | The web interface only accepts valid values, so most of these mistakes can only happen through hand edits. diff --git a/docs/decisions/live-config-reload.md b/docs/decisions/live-config-reload.md index 8007690..c88ffec 100644 --- a/docs/decisions/live-config-reload.md +++ b/docs/decisions/live-config-reload.md @@ -38,6 +38,7 @@ What is on screen only lasts until the next cycle anyway, so this is kept simple - A changed plugin that is on screen is updated and redrawn right away, so the user sees the result. - A plugin that is removed or switched off while on screen: rotation moves on to the next plugin. - A new plugin joins the end of the rotation. +- *(M5 part 2a)* A plugin that is missing a required setting is treated as switched off: filling the setting in and reloading starts it (at its place in the file), and emptying it takes the plugin out of the rotation. **Update 2026-10-05 (M4, issue #205):** plugins take turns in the order of the config file, so a new plugin takes the place where it is in the file (the end, when it is added at the end). Until reloading screen settings is built (M4 issue #222, part 5b), changed screen settings take effect at the next start, with a warning in the log. *(Built in part 5b; see the table below.)* @@ -48,7 +49,7 @@ What is on screen only lasts until the next cycle anyway, so this is kept simple | `max_refresh` (fast refreshes before a full one), `vcom` | At once. The screen helper process (see `display-driver-interface.md`) starts again with the new values; the next write is full. | | Rotation, colour on/off | At once. Every plugin draws again at its new size; the screen keeps its picture until the new images are ready. Colour is only offered for screens that can show colour. There is no mirror setting (yet). | | Cleaning interval (`clean_every`), what happens on exit (`on_exit`), the fallback clock | At once. | -| `splash_time` | Only matters at start: a change during the start splash sets when it ends; a reload never shows the splash again. (With no plugin switched on the splash is shown whatever it says.) | +| `splash_time` | Only matters at start: a change during the start splash sets when it ends; a reload never shows the splash again. (With no plugin ready to show the splash is shown whatever it says.) | | `type`, `model` (virtual screen: `width`, `height`, `mode`) | At the next start of PaperPi. | *Update (M5 part 1, issue #238):* `[web]` settings. `login` and `password_hash` apply at a reload (so `paperpi reset-password` followed by a reload works without a restart); `enabled`, `address` and `port` apply at the next start, and a reload says so in the log. diff --git a/docs/decisions/plugin-interface.md b/docs/decisions/plugin-interface.md index 60f0b73..fb87614 100644 --- a/docs/decisions/plugin-interface.md +++ b/docs/decisions/plugin-interface.md @@ -50,6 +50,8 @@ The plugin never talks to the screen. Only the scheduler does. For sample images and tests, `fetch` is skipped and the sample data goes straight to `draw`. How to write a plugin: `docs/writing-plugins.md`. +**Update 2026-10-07 (M5 part 2a, issue #238, agreed with txoof):** a plugin can mark a setting as **required** with `setting(..., required=True)`, for settings it can't work without, such as an API key. The first ones are `lat`, `lon` and `email` of `met_no` and `moon_phase`: met.no's terms of service ask for a real email address, and a user should not start these plugins without giving one. A plugin that is missing a required setting can be in the config file and switched on, but it is **not shown and not counted as broken**: the config check gives a warning naming the missing settings, `paperpi list` shows "needs email" in its "on" column, and the web interface (part 2b) shows "Needs settings" in place of the on switch. The example config no longer fills in a made-up email address. `setting` is the one standard place for a setting's own options; the input helpers of `web-interface.md` (such as `location`, which looks up latitude and longitude; part 3c) will be options of `setting` too. + ### How a plugin is run - Every update runs in a **new, short-lived process**. When the update is done, the process exits and its memory is given back. A plugin that hangs or crashes is stopped without affecting PaperPi or the other plugins. diff --git a/docs/decisions/plugin-scheduling.md b/docs/decisions/plugin-scheduling.md index 40ecd6f..51f81ce 100644 --- a/docs/decisions/plugin-scheduling.md +++ b/docs/decisions/plugin-scheduling.md @@ -69,7 +69,7 @@ To avoid it, one single part of PaperPi (the scheduler) decides what is shown an - A plugin that fails (crash, time limit reached, data source down) is skipped. The rotation moves to the next plugin, and the failed one is tried again at its next refresh. - After 3 failures in a row, the plugin is left out for 30 minutes and the web interface shows a warning. If its first update after that fails too, it is left out for another 30 minutes at once; one good update ends this. -- If nothing else can be shown because plugins are failing, or no plugin is switched on and the splash screen fails (see "First start" below), the `default` plugin is shown. The scheduler tells it how many plugins are failing, and it shows e.g. "3 of 4 plugins are not working. See the web interface for more information." with a QR code (a square barcode a phone camera can scan) that opens the web interface. +- If nothing else can be shown because plugins are failing, or no plugin is ready to show and the splash screen fails (see "First start" below), the `default` plugin is shown. The scheduler tells it how many plugins are failing, and it shows e.g. "3 of 4 plugins are not working. See the web interface for more information." with a QR code (a square barcode a phone camera can scan) that opens the web interface. - `default` and `splash_screen` are normal plugins. The scheduler passes the failure status to `default`, starts it only when it is needed, and never puts it in the rotation. The numbers 3 and 30 minutes are defaults. Time limits and watchdog rules are decided in the error-handling note (#190). @@ -91,12 +91,12 @@ Added 2026-10-05 (M4, issue #205), agreed with txoof. Code: `src/paperpi/schedul - **Taking turns:** several alerts, or several interrupts, take turns for their `display_time` each. - **Alert settings per plugin:** `alert_reminder` and `alert_max_time` are in each `[[plugin]]` block, like `display_time`. They only matter for level `alert`. - **Failures:** a failed update is tried again at the plugin's next refresh. One good update sets the count of failures back to 0. A plugin that has nothing to show ("nothing") is skipped, which is not a failure. -- **`default`:** shown when nothing else can be shown and at least one plugin is failing, or when no plugin is switched on and the splash screen fails. If `default` itself fails, the screen also keeps its picture, and the error goes to the log. The QR code comes with the web interface (M5). PaperPi always has a `default` plugin, also when the config has no block for it. +- **`default`:** shown when nothing else can be shown and at least one plugin is failing, or when no plugin is ready to show and the splash screen fails. If `default` itself fails, the screen also keeps its picture, and the error goes to the log. The QR code comes with the web interface (M5). PaperPi always has a `default` plugin, also when the config has no block for it. - **Fallback clock:** when no plugin has anything to show and none is failing (e.g. only a music plugin, and no music, or an alert has just ended), a small clock is shown: `basic_clock` with its `small` layout, time and date on one line at the bottom, updated every minute. An empty or unchanging screen can't be told apart from a broken one; a clock that changes every minute shows that PaperPi works. It waits until every plugin has reported once, so it doesn't flash up at start, and it never shows an out-of-date time. It can be switched off with `fallback_clock = false` in `[display]`, which is strongly discouraged: the config check shows a hint, and the screen then keeps its last picture. Agreed with txoof 2026-10-05, after the code review. - **On the minute:** the update starts 1 second after the minute changes. This is the only place where the wall-clock time (the time of day, which can jump when it is corrected) is used; every duration uses the monotonic clock (a clock that only counts forward). - **Start:** the screen is not touched until the first image is ready. - **Splash screen at start:** the `splash_screen` plugin is updated first and shown for `[display] splash_time` seconds (default 60; `0`: not at all; v1 had `splash = True`) with PaperPi's name, version, web address and a QR code, while the other plugins update in the background; then the normal choice starts. Alerts and interrupts wait until it ends. If its update fails, the normal choice starts at once. A config reload does not show it again. It is a normal plugin, so start-up needs no drawing code of its own. -- **First start (no plugin switched on):** the first start after installing has only the default config, with no plugins; the user sets PaperPi up in the web interface. Whenever no plugin is switched on, the splash screen stays on screen (whatever `splash_time` says), with a new picture every hour, until a config reload switches one on. If it fails, `default` says that no plugin is switched on. So the default config written by the installer (M6) has no `[[plugin]]` blocks. Agreed with txoof 2026-10-07. +- **First start (no plugin ready to show):** the first start after installing has only the default config, with no plugins; the user sets PaperPi up in the web interface. Whenever no plugin is ready to show, the splash screen stays on screen (whatever `splash_time` says), with a new picture every hour, until a config reload switches one on. If it fails, `default` says that no plugin is ready to show. So the default config written by the installer (M6) has no `[[plugin]]` blocks. Agreed with txoof 2026-10-07. - **Config reload** (see `live-config-reload.md`): on the reload signal the config file is read again. Plugins whose settings did not change keep their place and image. A changed plugin keeps its old image on screen until its new one is ready. A broken file is not applied. Which screen settings apply at once and which at the next start: see the table in `live-config-reload.md` (built in M4 part 5b). Plugins take turns in the order of the new file. When the plugin on screen is removed, the rotation goes on with the one after it. - **Stopping:** updates that have not started are dropped, and running ones are stopped at once, so stopping never waits for a hanging plugin's time limit. - **Screen write fails:** it is tried again at the next update of the plugin on screen; when the screen watchdog pauses writes (`errors-and-time-limits.md`), at the end of the pause. The error is logged once, and a line is logged when writes work again. diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 604964a..1a86ee3 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -80,6 +80,15 @@ 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: `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 and `paperpi list` (and from M5 part 2b the web interface) 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). + + ```python + from paperpi.plugin import PluginSettings, setting + + + class Settings(PluginSettings): + lat: float | None = setting(None, required=True, ge=-90, le=90, description="Latitude") + ``` In the config file, the plugin's settings go in its `[[plugin]]` block: @@ -133,7 +142,7 @@ data = answer.json() ``` `get` takes: -- `contact`: an email or web address, added to the User-Agent header (the line in every request that names the program), so the service knows whom to ask about problems. Some services, like met.no, require it. Give your plugin a setting for it, as `met_no` does with `email`. +- `contact`: an email or web address, added to the User-Agent header (the line in every request that names the program), so the service knows whom to ask about problems. Some services, like met.no, require it. Give your plugin a setting for it, as `met_no` does with `email`, and make it required with `setting(..., required=True)`. - `headers=`: more headers to send, for example an API key. They are sent only to the server of `url`: if that server redirects to another one, they are left out. - `if_modified_since=`: the `last_modified` of an earlier answer. If nothing changed since, `answer.not_modified` is `True` and `answer.body` is empty. Save `last_modified` (it can be `None` when the server didn't send one) and the data in `context.storage`, so the next update can ask. - `max_bytes=`: a higher size limit, for example for large images. diff --git a/paperpi.example.toml b/paperpi.example.toml index 315ea96..5e45764 100644 --- a/paperpi.example.toml +++ b/paperpi.example.toml @@ -4,8 +4,10 @@ # The file only needs the settings that differ from the default. Every other setting is # shown as a comment with its default: remove the # in front of it to change it. # Each [[plugin]] block is one plugin on the screen; the same plugin type can be used -# more than once, with a different name. The weather blocks need your own email address -# in "email": met.no asks every program for a way to contact its user. +# more than once, with a different name. The weather blocks are not shown until you remove +# the # in front of "email" and fill in your own, real email address: met.no asks every +# program for a way to contact its user. Settings marked "(required)" must be filled in +# before a plugin is shown. config_version = 1 @@ -20,7 +22,7 @@ type = "virtual" # screen looks like a broken one) # fallback_clock = true # At start, show PaperPi's version and web address this many seconds before the plugins (0: not at -# all). With no plugin switched on, it is shown until one is +# all). With no plugin ready to show, it is shown until one is # splash_time = 60 # Virtual screen only: width in pixels # width = 1200 @@ -100,8 +102,8 @@ lat = 52.52 lon = 13.4 # Name shown on the screen, e.g. Berlin (else lat, lon) place = "Berlin" -# Your email address, sent only to met.no (required: met.no asks for it) -email = "you@example.com" +# Your own, real email address, sent only to met.no (their terms of service ask for one) (required) +# email = "" # Degrees Celsius or Fahrenheit. One of: "C", "F" # temperature = "C" # Rain in millimetres or inches. One of: "mm", "inch" @@ -123,8 +125,8 @@ lat = -22.91 lon = -43.17 # Name shown on the screen, e.g. Berlin (else lat, lon) place = "Rio" -# Your email address, sent only to met.no (required: met.no asks for it) -email = "you@example.com" +# Your own, real email address, sent only to met.no (their terms of service ask for one) (required) +# email = "" # Degrees Celsius or Fahrenheit. One of: "C", "F" # temperature = "C" # Rain in millimetres or inches. One of: "mm", "inch" diff --git a/src/paperpi/cli.py b/src/paperpi/cli.py index eea5693..678b272 100644 --- a/src/paperpi/cli.py +++ b/src/paperpi/cli.py @@ -146,7 +146,9 @@ def _parser() -> argparse.ArgumentParser: "Show the plugins of a config file, one line each, in the order of the file. " "Anything wrong with the file is shown first; a block with an error is not in the " "list. Refresh and layout are the ones used: the setting, or else the plugin's " - "suggested refresh and its first layout." + 'suggested refresh and its first layout. The "on" column says "needs ' + '" for a plugin that is switched on but not shown, because a required ' + "setting is missing." ), ) listing.add_argument( @@ -237,6 +239,10 @@ def _render(args: argparse.Namespace) -> int: if layout not in job.plugin.layouts: known = ", ".join(job.plugin.layouts) raise UsageError(f"unknown layout {layout!r}; choose from: {known}") + missing = job.plugin.missing(job.settings) + if args.live and missing: + sets = " ".join(f"--set {k}=..." for k in missing) + raise UsageError(f"{job.plugin.type} needs these required settings: {sets}") time_limit = job.time_limit if args.time_limit is None else args.time_limit if not 0 < time_limit <= limits.PLUGIN_UPDATE_MAX: raise UsageError(f"--time-limit must be above 0 and at most {limits.PLUGIN_UPDATE_MAX:g}") @@ -324,7 +330,7 @@ def load() -> config.Config: print(f"web interface on port {web.port}") try: with screen: - count = sum(1 for p in loaded.plugins if p.entry.enabled and p.plugin.type != "default") + count = sum(1 for p in loaded.plugins if p.shown and p.plugin.type != "default") where = f"images in {out}" if display.type == "virtual" else f"screen {display.type}" print( f"showing {count} plugin{'' if count == 1 else 's'}; {where}; " @@ -389,7 +395,7 @@ def _list(args: argparse.Namespace) -> int: ( row.name, row.type, - "yes" if row.enabled else "no", + _on_column(row), row.level, f"{row.display_time:g} s", f"{row.refresh:g} s", @@ -411,6 +417,13 @@ def _list(args: argparse.Namespace) -> int: return 0 +def _on_column(row: config.PluginRow) -> str: + """The "on" column of ``paperpi list``.""" + if not row.enabled: + return "no" + return f"needs {', '.join(row.missing)}" if row.missing else "yes" + + def _example_config(args: argparse.Namespace) -> int: text = example.example_config() if args.output is None: diff --git a/src/paperpi/config.py b/src/paperpi/config.py index 1df2bad..33d1382 100644 --- a/src/paperpi/config.py +++ b/src/paperpi/config.py @@ -107,7 +107,7 @@ class DisplaySettings(BaseModel): ge=0, le=3600, description="At start, show PaperPi's version and web address this many seconds " - "before the plugins (0: not at all). With no plugin switched on, it is shown until " + "before the plugins (0: not at all). With no plugin ready to show, it is shown until " "one is", ) width: int | None = Field( @@ -290,6 +290,17 @@ def folder_name(self) -> str: """The name of this plugin's storage folder, made from its name.""" return folder_name(self.entry.name) + @property + def missing(self) -> tuple[str, ...]: + """Required settings that are not set yet (see :func:`paperpi.plugin.setting`). + Until they are, the plugin is not shown.""" + return self.plugin.missing(self.settings) + + @property + def shown(self) -> bool: + """Switched on, and every required setting is set.""" + return self.entry.enabled and not self.missing + @dataclass class Config: @@ -332,6 +343,8 @@ class PluginRow: """As used: the setting, or the plugin's suggestion.""" storage_days: int """As used: the setting, or the plugin's suggestion (0 = files are kept).""" + missing: tuple[str, ...] = () + """Required settings that are not set yet; until they are, the plugin is not shown.""" def plugin_rows(config: Config) -> list[PluginRow]: @@ -349,6 +362,7 @@ def plugin_rows(config: Config) -> list[PluginRow]: layout=p.layout, storage_mb=p.storage_mb, storage_days=p.storage_days, + missing=p.missing, ) for p in config.plugins ] @@ -748,6 +762,15 @@ def failed() -> bool: return None checked = PluginConfig(entry, settings, plugin, self.lines.get(section)) + if entry.enabled and checked.missing: + names = ", ".join(checked.missing) + self.add( + "warning", + f"not shown until these required settings are filled in: {names}", + section, + next((k for k in checked.missing if k in block), None), + where, + ) if entry.level == "rotation" and checked.refresh == entry.display_time: key = "refresh" if "refresh" in block else "display_time" self.add( diff --git a/src/paperpi/example.py b/src/paperpi/example.py index 07dae93..ffc7b7f 100644 --- a/src/paperpi/example.py +++ b/src/paperpi/example.py @@ -32,7 +32,7 @@ DisplaySettings, WebSettings, ) -from .plugin import Plugin, PluginEntry +from .plugin import Plugin, PluginEntry, is_required #: The blocks of the example file: name, plugin type and the settings that are set. #: Weather twice, to show that one plugin type can be used more than once. @@ -41,12 +41,12 @@ ( "Weather Berlin", "met_no", - {"lat": 52.52, "lon": 13.40, "place": "Berlin", "email": "you@example.com"}, + {"lat": 52.52, "lon": 13.40, "place": "Berlin"}, ), ( "Weather Rio", "met_no", - {"lat": -22.91, "lon": -43.17, "place": "Rio", "email": "you@example.com"}, + {"lat": -22.91, "lon": -43.17, "place": "Rio"}, ), ) @@ -57,8 +57,10 @@ # The file only needs the settings that differ from the default. Every other setting is # shown as a comment with its default: remove the # in front of it to change it. # Each [[plugin]] block is one plugin on the screen; the same plugin type can be used -# more than once, with a different name. The weather blocks need your own email address -# in "email": met.no asks every program for a way to contact its user. +# more than once, with a different name. The weather blocks are not shown until you remove +# the # in front of "email" and fill in your own, real email address: met.no asks every +# program for a way to contact its user. Settings marked "(required)" must be filled in +# before a plugin is shown. """ #: Settings whose default is "empty", but that mean a known value; shown with that value. @@ -181,6 +183,8 @@ def _settings( def _setting(key: str, info: FieldInfo, value: Any, *, comment: bool = False) -> list[str]: """The help line and the ``key = value`` line of one setting.""" 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: diff --git a/src/paperpi/plugin.py b/src/paperpi/plugin.py index 11196ea..d17589f 100644 --- a/src/paperpi/plugin.py +++ b/src/paperpi/plugin.py @@ -29,6 +29,7 @@ from epdlib import Layout, ScreenMode from PIL import Image from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic.fields import FieldInfo from . import limits @@ -90,12 +91,54 @@ class Settings(PluginSettings): The same description checks the config file, builds the web interface's forms (M5) and the docs. A plugin may not use the names of the shared settings - (:data:`SHARED_SETTINGS`) for its own. + (:data:`SHARED_SETTINGS`) for its own. Use :func:`setting` instead of ``Field`` for a + setting PaperPi must know more about, such as one the user has to fill in. """ model_config = ConfigDict(extra="ignore", frozen=True) +#: The key under which :func:`setting` keeps PaperPi's own options in a pydantic field. +_OPTIONS = "paperpi" + + +def setting(default: Any, *, required: bool = False, **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:: + + lat: float | None = setting(None, required=True, description="Latitude") + + ``required``: the plugin can't work until the user fills it in (a place, an email + 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 from M5 part 2b the web interface) say which settings it needs. + """ + 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}} + 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)``.""" + extra = info.json_schema_extra + options = extra.get(_OPTIONS) if isinstance(extra, dict) else None + return isinstance(options, dict) and bool(options.get("required")) + + +def is_set(value: Any) -> bool: + """False for "not set": ``None``, empty text (also only spaces) or an empty secret.""" + if hasattr(value, "get_secret_value"): + value = value.get_secret_value() + if isinstance(value, str | bytes): + return bool(value.strip()) + return value is not None + + class PluginEntry(BaseModel): """The settings every ``[[plugin]]`` block in the config file has. @@ -191,7 +234,7 @@ class PluginsStatus: failing: int """Plugins that failed their last update, or are left out after failing again and again.""" total: int - """Plugins that are switched on.""" + """Plugins that are ready to show: switched on, with all their required settings.""" @dataclass(frozen=True) @@ -260,9 +303,15 @@ def __post_init__(self) -> None: if clash: problems.append(f"settings use the names of shared settings: {', '.join(clash)}") try: - self.settings() + defaults = self.settings() except ValueError: problems.append("every setting needs a default") + else: + if self.missing(defaults) != self.required: + problems.append( + 'a required setting\'s default must be "not set" (None, "" or an empty ' + "secret)" + ) if not self.layouts: problems.append("needs at least one layout") if not limits.SHORTEST_REFRESH <= self.refresh <= limits.LONGEST_SETTING: @@ -281,6 +330,15 @@ def __post_init__(self) -> None: def default_layout(self) -> str: return next(iter(self.layouts)) + @property + def required(self) -> tuple[str, ...]: + """The settings the user has to fill in (see :func:`setting`).""" + return tuple(k for k, info in self.settings.model_fields.items() if is_required(info)) + + def missing(self, settings: PluginSettings) -> tuple[str, ...]: + """The required settings that are not set in ``settings``.""" + return tuple(k for k in self.required if not is_set(getattr(settings, k))) + def layout( self, name: str, settings: PluginSettings, colors: tuple[str, str] | None = None ) -> Layout: diff --git a/src/paperpi/plugins/default/README.md b/src/paperpi/plugins/default/README.md index 0624963..f056b51 100644 --- a/src/paperpi/plugins/default/README.md +++ b/src/paperpi/plugins/default/README.md @@ -1,6 +1,6 @@ # default -Shown when nothing else can be shown because plugins are failing, or because no plugin is switched on and the splash screen (which normally shows then, see `splash_screen`) fails. The scheduler tells it how many plugins are not working, and it shows e.g. "3 of 4 plugins are not working. See the web interface for more information." It needs no network. +Shown when nothing else can be shown because plugins are failing, or because no plugin is ready to show (switched on, with all its required settings filled in) and the splash screen (which normally shows then, see `splash_screen`) fails. The scheduler tells it how many plugins are not working, and it shows e.g. "3 of 4 plugins are not working. See the web interface for more information." It needs no network. PaperPi always has this plugin, also when the config file has no block for it. A `[[plugin]]` block with `type = "default"` changes its settings; it never takes part in the rotation. The QR code that opens the web interface comes with the web interface (M5). diff --git a/src/paperpi/plugins/default/__init__.py b/src/paperpi/plugins/default/__init__.py index 44ca331..4f5e622 100644 --- a/src/paperpi/plugins/default/__init__.py +++ b/src/paperpi/plugins/default/__init__.py @@ -1,5 +1,5 @@ -"""default: shown when nothing else can be: plugins are failing, or none are switched on -and the splash screen fails. +"""default: shown when nothing else can be: plugins are failing, or none is ready to show +(switched on, with all its required settings filled in) and the splash screen fails. The scheduler tells it how many plugins are failing (``context.status``). The QR code that opens the web interface is added with the web interface (M5). @@ -20,7 +20,7 @@ def fetch(context: Context): def message(status: PluginsStatus) -> str: if status.total == 0: - return "No plugins are switched on." + return "No plugins are ready to show." noun = "plugin is" if status.total == 1 else "plugins are" return f"{status.failing} of {status.total} {noun} not working." diff --git a/src/paperpi/plugins/met_no/README.md b/src/paperpi/plugins/met_no/README.md index 96dc301..0216931 100644 --- a/src/paperpi/plugins/met_no/README.md +++ b/src/paperpi/plugins/met_no/README.md @@ -50,7 +50,7 @@ Every layout shows the place: the `place` setting, or the coordinates when it is |---|---|---| | `lat` | none, required | latitude of the place, e.g. `52.52` | | `lon` | none, required | longitude of the place, e.g. `13.40` | -| `email` | none, required | your email address, sent only to met.no. met.no requires contact details from every program, so it can ask before blocking one that misbehaves | +| `email` | none, required | your own, real email address, sent only to met.no. met.no's terms of service require contact details from every program, so it can ask before blocking one that misbehaves | | `place` | `""` | name shown at the top, e.g. `"Berlin"`. Without it, the coordinates are shown ("52.52, 13.40") | | `temperature` | `"C"` | `"C"` (Celsius) or `"F"` (Fahrenheit) | | `rain` | `"mm"` | `"mm"` or `"inch"` | @@ -68,11 +68,11 @@ type = "met_no" lat = 52.52 lon = 13.40 place = "Berlin" -email = "you@example.com" +email = "you@example.com" # put your own, real address here layout = "steps_3h" # optional; without it: hours_12 ``` -Try it without a screen: `uv run paperpi render met_no --set place=Berlin` (sample data), or with real data: `uv run paperpi render met_no --live --set lat=52.52 --set lon=13.40 --set email=you@example.com`. +Try it without a screen: `uv run paperpi render met_no --set place=Berlin` (sample data), or with real data: `uv run paperpi render met_no --live --set lat=52.52 --set lon=13.40 --set email=you@example.com` (put your own address in place of `you@example.com`). If lat, lon or email is missing, the plugin is not shown; the config check and `paperpi list` say which one. The screen shows "Data: MET Norway" next to the "Updated" time, as met.no's data licence asks. The weather data is from [MET Norway](https://www.met.no/en) (the Norwegian Meteorological Institute), under the [Creative Commons 4.0 BY International](https://creativecommons.org/licenses/by/4.0/) licence. The weather icons are met.no's own, from [github.com/metno/weathericons](https://github.com/metno/weathericons), under the MIT licence ([`icons/LICENSE`](icons/LICENSE)). diff --git a/src/paperpi/plugins/met_no/__init__.py b/src/paperpi/plugins/met_no/__init__.py index 3b61e71..6069cf3 100644 --- a/src/paperpi/plugins/met_no/__init__.py +++ b/src/paperpi/plugins/met_no/__init__.py @@ -16,7 +16,7 @@ from ... import webrequest from ...files import write_atomic -from ...plugin import Context, Plugin, PluginSettings, ready +from ...plugin import Context, Plugin, PluginSettings, ready, setting from . import barbs, forecast from .forecast import Forecast, Hour from .layouts import HOURS, LAYOUTS @@ -35,20 +35,22 @@ class Settings(PluginSettings): - lat: float | None = Field( - None, ge=-90, le=90, description="Latitude of the place, e.g. 52.52 (required)" + lat: float | None = setting( + None, required=True, ge=-90, le=90, description="Latitude of the place, e.g. 52.52" ) - lon: float | None = Field( - None, ge=-180, le=180, description="Longitude of the place, e.g. 13.40 (required)" + lon: float | None = setting( + None, required=True, ge=-180, le=180, description="Longitude of the place, e.g. 13.40" ) place: str = Field( "", max_length=60, description="Name shown on the screen, e.g. Berlin (else lat, lon)" ) - email: str = Field( + email: str = setting( "", + required=True, max_length=200, pattern=r"^$|^[^@\s]+@[^@\s]+$", - description="Your email address, sent only to met.no (required: met.no asks for it)", + description="Your own, real email address, sent only to met.no (their terms of " + "service ask for one)", ) temperature: Literal["C", "F"] = Field("C", description="Degrees Celsius or Fahrenheit") rain: Literal["mm", "inch"] = Field("mm", description="Rain in millimetres or inches") diff --git a/src/paperpi/plugins/moon_phase/README.md b/src/paperpi/plugins/moon_phase/README.md index c40daac..a7b4386 100644 --- a/src/paperpi/plugins/moon_phase/README.md +++ b/src/paperpi/plugins/moon_phase/README.md @@ -29,7 +29,7 @@ met.no gives one answer per place and day. The plugin saves it in its storage fo |---|---|---| | `lat` | none, required | latitude of the place, e.g. `52.52` | | `lon` | none, required | longitude of the place, e.g. `13.40` | -| `email` | none, required | your email address, sent only to met.no. met.no requires contact details from every program, so it can ask before blocking one that misbehaves | +| `email` | none, required | your own, real email address, sent only to met.no. met.no's terms of service require contact details from every program, so it can ask before blocking one that misbehaves | It suggests a refresh every 20 minutes. @@ -41,11 +41,11 @@ name = "Moon" type = "moon_phase" lat = 52.52 lon = 13.40 -email = "you@example.com" +email = "you@example.com" # put your own, real address here layout = "moon_only" # optional; without it: moon_data ``` -Try it without a screen: `uv run paperpi render moon_phase` (sample data), or with real data: `uv run paperpi render moon_phase --live --set lat=52.52 --set lon=13.40 --set email=you@example.com`. +Try it without a screen: `uv run paperpi render moon_phase` (sample data), or with real data: `uv run paperpi render moon_phase --live --set lat=52.52 --set lon=13.40 --set email=you@example.com` (put your own address in place of `you@example.com`). If lat, lon or email is missing, the plugin is not shown; the config check and `paperpi list` say which one. The moon data is from [MET Norway](https://www.met.no/en), under the [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/) licence (CC BY 4.0): anyone may use the data, as long as they say where it came from. The `moon_data` layout shows "Data: MET Norway", as the licence asks. The moon pictures are by [NASA's Scientific Visualization Studio](https://svs.gsfc.nasa.gov/4955) (credit: NASA's Scientific Visualization Studio). NASA's pictures are generally not protected by copyright in the United States and may be used freely, as long as NASA is credited and the use does not suggest that NASA endorses PaperPi ([NASA's media usage guidelines](https://www.nasa.gov/nasa-brand-center/images-and-media/)). They are the same files as in PaperPi v1. The font is Anton, under the SIL Open Font License ([`fonts/Anton-OFL.txt`](../../fonts/Anton-OFL.txt)), in PaperPi's shared fonts folder. diff --git a/src/paperpi/plugins/moon_phase/__init__.py b/src/paperpi/plugins/moon_phase/__init__.py index f19bc63..065f7b9 100644 --- a/src/paperpi/plugins/moon_phase/__init__.py +++ b/src/paperpi/plugins/moon_phase/__init__.py @@ -10,11 +10,10 @@ from zoneinfo import ZoneInfo from PIL import Image -from pydantic import Field from ... import webrequest from ...files import write_atomic -from ...plugin import Context, Plugin, PluginSettings, ready +from ...plugin import Context, Plugin, PluginSettings, ready, setting from .layouts import LAYOUTS log = logging.getLogger(__name__) @@ -42,17 +41,19 @@ class Settings(PluginSettings): - lat: float | None = Field( - None, ge=-90, le=90, description="Latitude of the place, e.g. 52.52 (required)" + lat: float | None = setting( + None, required=True, ge=-90, le=90, description="Latitude of the place, e.g. 52.52" ) - lon: float | None = Field( - None, ge=-180, le=180, description="Longitude of the place, e.g. 13.40 (required)" + lon: float | None = setting( + None, required=True, ge=-180, le=180, description="Longitude of the place, e.g. 13.40" ) - email: str = Field( + email: str = setting( "", + required=True, max_length=200, pattern=r"^$|^[^@\s]+@[^@\s]+$", - description="Your email address, sent only to met.no (required: met.no asks for it)", + description="Your own, real email address, sent only to met.no (their terms of " + "service ask for one)", ) diff --git a/src/paperpi/plugins/splash_screen/README.md b/src/paperpi/plugins/splash_screen/README.md index bf51cf8..d78a962 100644 --- a/src/paperpi/plugins/splash_screen/README.md +++ b/src/paperpi/plugins/splash_screen/README.md @@ -2,7 +2,7 @@ Shows PaperPi's name, its version, the address of its web interface and a QR code with that address, plus the address of PaperPi's web page (https://github.com/txoof/PaperPi). Ported from v1. -PaperPi shows it **once at start**, before the other plugins, for `[display] splash_time` seconds (default 60, at most 3600; `0`: not at all). Meanwhile the other plugins get their first pictures. **With no plugin switched on**, for example the first start after installing with the default config, it stays on screen (with a new picture every hour) until a plugin is switched on, so the web address to set PaperPi up is always there. It is a plugin like any other, so start-up needs no drawing code of its own. You can also put it in the rotation with a `[[plugin]]` block, but that is not what it is meant for. +PaperPi shows it **once at start**, before the other plugins, for `[display] splash_time` seconds (default 60, at most 3600; `0`: not at all). Meanwhile the other plugins get their first pictures. **With no plugin ready to show**, for example the first start after installing with the default config, it stays on screen (with a new picture every hour) until a plugin is ready to show, so the web address to set PaperPi up is always there. It is a plugin like any other, so start-up needs no drawing code of its own. You can also put it in the rotation with a `[[plugin]]` block, but that is not what it is meant for. What it shows: @@ -26,7 +26,7 @@ The port is the web interface's port, `[web] port` (default 8080); like the web ## Settings -One of its own, `port` (default 8080): the port in the addresses, only for a splash you add to the rotation yourself; the splash at start always uses `[web] port`. Plus the settings every plugin has (see the main [README](../../../../README.md)). It suggests a refresh every hour, which matters while no plugin is switched on and in the rotation: it then picks up a new IP address. +One of its own, `port` (default 8080): the port in the addresses, only for a splash you add to the rotation yourself; the splash at start always uses `[web] port`. Plus the settings every plugin has (see the main [README](../../../../README.md)). It suggests a refresh every hour, which matters while no plugin is ready to show and in the rotation: it then picks up a new IP address. To put it in the rotation (not needed for the splash at start): diff --git a/src/paperpi/plugins/splash_screen/__init__.py b/src/paperpi/plugins/splash_screen/__init__.py index 1584a50..751682d 100644 --- a/src/paperpi/plugins/splash_screen/__init__.py +++ b/src/paperpi/plugins/splash_screen/__init__.py @@ -1,8 +1,8 @@ """splash_screen: PaperPi's name, version and web address, shown at start and while no -plugin is switched on. +plugin is ready to show. The scheduler shows this plugin first when PaperPi starts, for ``[display] splash_time`` -seconds (default 60), then the other plugins. With no plugin switched on (e.g. the first +seconds (default 60), then the other plugins. With no plugin ready to show (e.g. the first start after installing), it stays on screen until one is. It is a plugin like any other so start-up needs no special drawing code; a ``[[plugin]]`` block can also put it in the rotation, but that is not what it is meant for. @@ -171,7 +171,7 @@ def draw(about: About, context: Context) -> dict: PLUGIN = Plugin( type="splash_screen", description="PaperPi's name, version and web address; shown at start, and while no plugin " - "is switched on.", + "is ready to show.", settings=Settings, layouts=LAYOUTS, fetch=fetch, diff --git a/src/paperpi/scheduler.py b/src/paperpi/scheduler.py index eadeb69..7e8ccde 100644 --- a/src/paperpi/scheduler.py +++ b/src/paperpi/scheduler.py @@ -40,8 +40,9 @@ - A failed update is skipped and tried again at the next refresh. After :data:`~paperpi.limits.FAILURES_BEFORE_LEFT_OUT` failures in a row the plugin is left out for :data:`~paperpi.limits.LEFT_OUT` seconds. When nothing can be shown because plugins - fail (or no plugin is switched on and the splash screen fails, see below), the - ``default`` plugin says so. When no plugin has + fail, or no plugin is ready to show (switched on, with all its required settings filled + in) and the splash screen fails (see below), the ``default`` plugin says so. When no + plugin has anything to show and none fail, a small fallback clock is shown (``[display] fallback_clock``), so an empty screen is never mistaken for a broken one. - At start, the ``splash_screen`` plugin (name, version and web address) is updated first @@ -49,9 +50,9 @@ background; then the normal choice starts. Alerts and interrupts wait until it ends. When its update fails, the normal choice starts at once. A config reload does not show it again. -- When no plugin is switched on (e.g. the first start after installing, with the default +- When no plugin is ready to show (e.g. the first start after installing, with the default config), the splash screen stays on screen until one is, so the web address is there to - set PaperPi up. If it fails, the ``default`` plugin says that no plugin is switched on. + set PaperPi up. If it fails, the ``default`` plugin says that no plugin is ready to show. One loop (:meth:`Scheduler.run`) makes every decision, in one thread, so two decisions can never happen at the same moment. Finished updates and screen writes, "stop", "reload" and @@ -252,7 +253,7 @@ def __init__( self._web_port = config.web.port """The web interface's port: it only changes at the next start (WEB_NEXT_START).""" self._splash = _Slot(_splash_config(self._web_port)) - """The splash screen: at start, and while no plugin is switched on.""" + """The splash screen: at start, and while no plugin is ready to show.""" self._starting = config.display.splash_time > 0 """The splash screen is still to be shown (or being shown) at start.""" self._current: _Slot | None = None @@ -380,7 +381,7 @@ def _apply(self, config: Config) -> None: old = {slot.name: slot for slot in self._slots} slots = [] for found in config.plugins: - if not found.entry.enabled or found.plugin.type == "default": + if not found.shown or found.plugin.type == "default": continue slot = old.get(found.entry.name) if slot is None or redraw or not _same_plugin(slot.config, found): @@ -656,7 +657,7 @@ def _choose(self, now: float) -> tuple[_Slot | None, bool]: return self._choose_idle(now), True def _choose_splash(self, now: float) -> tuple[_Slot | None, bool] | None: - """The splash screen at start, or while no plugin is switched on; ``None`` when + """The splash screen at start, or while no plugin is ready to show; ``None`` when it is not (or no longer) shown.""" splash = self._splash if not splash.running and (not splash.reported or splash.due <= now): @@ -669,7 +670,7 @@ def _choose_splash(self, now: float) -> tuple[_Slot | None, bool] | None: return None, False # back after a while: never show an old address return splash, True if not self._slots: - return splash, False # until a plugin is switched on + return splash, False # until a plugin is ready to show if now - self._turn_start < self.display.splash_time: return splash, False self._starting = False @@ -694,7 +695,7 @@ def _next(self, group: list[_Slot], after: str | None) -> _Slot: def _choose_idle(self, now: float) -> _Slot | None: """No plugin has anything to show. - When plugins fail, or none are switched on (and the splash screen failed): the + When plugins fail, or none is ready to show (and the splash screen failed): the ``default`` plugin, which says so. Otherwise (e.g. only a music plugin, and no music) the fallback clock, so the screen still changes every minute and can be told apart from a broken one. @@ -878,7 +879,7 @@ def _fallback_config() -> PluginConfig: def _splash_config(web_port: int) -> PluginConfig: - """The splash screen shown at start, and while no plugin is switched on. It shows the + """The splash screen shown at start, and while no plugin is ready to show. It shows the web interface's address, with the port the web interface started with.""" plugin = plugins.load("splash_screen") # Its time on screen comes from [display] splash_time, not from display_time. A short diff --git a/tests/test_cli.py b/tests/test_cli.py index d5c3763..5ca0db1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -444,6 +444,39 @@ def test_run_cleans_every_plugin_folder_at_start(tmp_path, monkeypatch): assert not old.exists() +def test_run_counts_only_the_plugins_that_are_shown(tmp_path, monkeypatch, capsys): + monkeypatch.setattr(cli.Scheduler, "run", lambda self: None) + cfg = tmp_path / "paperpi.toml" + cfg.write_text( + 'config_version = 1\n[display]\ntype = "virtual"\n' + '[[plugin]]\nname = "Clock"\ntype = "basic_clock"\n' + '[[plugin]]\nname = "Weather"\ntype = "met_no"\nlat = 1\nlon = 2\n' + ) + args = ["run", "--no-web", "--config", str(cfg), "--state-dir", str(tmp_path)] + assert main([*args, "--health-file", str(tmp_path / "health")]) == 0 + assert "showing 1 plugin;" in capsys.readouterr().out + + +def test_list_says_which_required_settings_are_missing(tmp_path, capsys): + cfg = tmp_path / "paperpi.toml" + cfg.write_text( + 'config_version = 1\n[display]\ntype = "virtual"\n' + '[[plugin]]\nname = "Weather"\ntype = "met_no"\n' + '[[plugin]]\nname = "Off"\ntype = "met_no"\nenabled = false\n' + ) + assert main(["list", "--config", str(cfg)]) == 0 + lines = capsys.readouterr().out.splitlines() + assert lines[1].split()[:6] == ["Weather", "met_no", "needs", "lat,", "lon,", "email"] + assert lines[2].split()[:3] == ["Off", "met_no", "no"] # switched off: no "needs" + + +def test_render_live_says_which_required_settings_are_missing(capsys): + assert main(["render", "met_no", "--live", "--set", "lat=1"]) == 2 + assert "met_no needs these required settings: --set lon=... --set email=..." in ( + capsys.readouterr().err + ) + + def test_list_shows_no_age_limit(tmp_path, capsys): cfg = tmp_path / "paperpi.toml" cfg.write_text( @@ -462,6 +495,8 @@ def test_list_shows_the_plugins_as_used(capsys): assert lines[0].split() == header assert lines[1].split() == "Clock basic_clock yes rotation 120 s 60 s time 500 MB, 30 d".split() assert lines[3].startswith("Weather Rio ") + # Not shown until the user fills in their email address. + assert lines[2].split()[:5] == ["Weather", "Berlin", "met_no", "needs", "email"] def test_list_says_how_many_problems(tmp_path, capsys): diff --git a/tests/test_config.py b/tests/test_config.py index 8e71bec..3d0a56d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -679,3 +679,50 @@ def test_hint_when_the_fallback_clock_is_switched_off(): assert len(cfg.plugins) == 1 [hint] = problems(cfg, "hint") assert "fallback_clock = false is not recommended" in hint + + +WEATHER = """\ +config_version = 1 +[display] +type = "virtual" +[[plugin]] +name = "Weather" +type = "met_no" +lat = 52.52 +lon = 13.40 +""" + + +def test_plugin_without_its_required_settings_is_not_shown(): + loaded = parse(WEATHER) + weather = loaded.plugin("Weather") + assert weather.missing == ("email",) + assert weather.entry.enabled and not weather.shown + assert [(p.level, p.message, p.line) for p in loaded.problems] == [ + ("warning", "not shown until these required settings are filled in: email", 4) + ] + assert config.plugin_rows(loaded)[0].missing == ("email",) + + +def test_required_setting_warning_points_at_the_setting_when_it_is_there(): + loaded = parse(WEATHER.replace("lat = 52.52\nlon = 13.40\n", 'lat = 52.52\nemail = ""\n')) + assert loaded.plugin("Weather").missing == ("lon", "email") + assert [(p.message, p.line) for p in loaded.problems] == [ + ("not shown until these required settings are filled in: lon, email", 8) + ] + # An empty value for a number is no "not set": it is an error, as before. + loaded = parse(WEATHER.replace("lat = 52.52\nlon = 13.40\n", 'lon = 13.40\nlat = ""\n')) + assert [(p.level, p.line) for p in loaded.problems] == [("error", 8)] # "" is no number + + +def test_plugin_with_its_required_settings_is_shown(): + loaded = parse(WEATHER + 'email = "me@example.com"\n') + assert loaded.problems == [] + assert loaded.plugin("Weather").shown + + +def test_switched_off_plugin_without_required_settings_gives_no_warning(): + loaded = parse(WEATHER + "enabled = false\n") + assert loaded.problems == [] + weather = loaded.plugin("Weather") + assert weather.missing == ("email",) and not weather.shown diff --git a/tests/test_debugging.py b/tests/test_debugging.py index 6f8b32a..26f2e2e 100644 --- a/tests/test_debugging.py +++ b/tests/test_debugging.py @@ -59,7 +59,7 @@ def test_delay(tmp_path): [ (3, 4, "3 of 4 plugins are not working."), (1, 1, "1 of 1 plugin is not working."), - (0, 0, "No plugins are switched on."), + (0, 0, "No plugins are ready to show."), ], ) def test_default_message(failing, total, text): diff --git a/tests/test_example.py b/tests/test_example.py index b31014d..b2c2284 100644 --- a/tests/test_example.py +++ b/tests/test_example.py @@ -25,7 +25,12 @@ def test_example_file_is_up_to_date(): def test_example_file_works_as_it_is(): loaded = config.parse(EXAMPLE_FILE.read_text()) - assert loaded.problems == [] + # The weather blocks wait for the user's own email address (met.no's terms). + assert [(p.level, p.message, p.where) for p in loaded.problems] == [ + ("warning", "not shown until these required settings are filled in: email", where) + for where in ("[[plugin]] 'Weather Berlin'", "[[plugin]] 'Weather Rio'") + ] + assert [p.entry.name for p in loaded.plugins if p.shown] == ["Clock"] assert [(p.entry.name, p.plugin.type) for p in loaded.plugins] == [ ("Clock", "basic_clock"), ("Weather Berlin", "met_no"), @@ -46,7 +51,10 @@ def test_every_default_shown_is_a_valid_setting(plugin_type): plugin = plugins.load(plugin_type) block = plugin_block(plugin, "Test", shared=True) loaded = config.parse(HEAD + uncomment(block)) - assert [p.level for p in loaded.problems if p.level != "hint"] == [] + waits = [f"not shown until these required settings are filled in: {', '.join(plugin.required)}"] + assert [p.message for p in loaded.problems if p.level != "hint"] == ( + waits if plugin.required else [] + ) found = loaded.plugin("Test") assert found.settings == plugin.settings() assert found.refresh == plugin.refresh @@ -60,7 +68,8 @@ def test_every_default_shown_is_a_valid_setting(plugin_type): def test_every_default_in_the_example_is_valid(): # Also the [display] part. loaded = config.parse(uncomment(example_config())) - assert [p for p in loaded.problems if p.level != "hint"] == [] + waiting = "not shown until these required settings are filled in: email" + assert [p.message for p in loaded.problems if p.level != "hint"] == [waiting, waiting] assert loaded.display.size == config.DisplaySettings(type="virtual").size @@ -182,3 +191,10 @@ def test_plugin_rows(): config.PluginRow("Clock", "basic_clock", True, "rotation", 120, 60, "time", 500, 30), config.PluginRow("Big", "basic_clock", False, "interrupt", 90, 300, "time_date", 20000, 0), ] + + +def test_block_marks_the_required_settings(): + block = plugin_block(plugins.load("met_no"), "Weather", {"lat": 1, "lon": 2}) + assert "# Latitude of the place, e.g. 52.52 (required)\nlat = 1\n" in block + assert 'terms of service ask for one) (required)\n# email = ""\n' in block + assert "Berlin (else lat, lon)\n# place" in block # not required diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 11e53cb..924fa03 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -2,7 +2,7 @@ import pytest from epdlib import ScreenMode -from pydantic import Field +from pydantic import Field, SecretBytes, SecretStr from paperpi import plugins from paperpi.plugin import ( @@ -14,7 +14,10 @@ PluginSettings, State, draw_update, + is_required, + is_set, ready, + setting, ) @@ -59,6 +62,10 @@ class NotSettings: pass +class RequiredWithValue(PluginSettings): + email: str = setting("me@example.com", required=True) + + @pytest.mark.parametrize( ("changes", "message"), [ @@ -66,6 +73,7 @@ class NotSettings: ({"settings": NotSettings}, "subclass of PluginSettings"), ({"settings": Clash}, "names of shared settings: name, refresh"), ({"settings": NoDefault}, "every setting needs a default"), + ({"settings": RequiredWithValue}, 'a required setting\'s default must be "not set"'), ({"layouts": {}}, "at least one layout"), ({"refresh": 0}, "refresh must be between 5 and 604800 seconds"), ({"refresh": 4.9}, "refresh must be between 5 and 604800 seconds"), @@ -177,3 +185,67 @@ def test_loader_unknown_type(): def test_loader_broken_plugins(plugin_type, message): with pytest.raises(PluginDefinitionError, match=message): plugins.load(plugin_type, "tests.fake_plugins") + + +class Needs(PluginSettings): + place: str = setting("", required=True, max_length=10, description="Where") + lat: float | None = setting(None, required=True, ge=-90, le=90) + key: SecretStr = setting(SecretStr(""), required=True) + word: str = setting("hi") + + +def test_setting_is_a_field_with_paperpis_options(): + fields = Needs.model_fields + assert [k for k, info in fields.items() if is_required(info)] == ["place", "lat", "key"] + assert not is_required(fields["word"]) + assert fields["place"].description == "Where" + with pytest.raises(ValueError, match="at most 10 characters"): + Needs(place="far too long a place") + with pytest.raises(ValueError, match="less than or equal to 90"): + Needs(lat=91) + + +@pytest.mark.parametrize( + ("value", "expected"), + [(None, False), ("", False), (" ", False), (SecretStr(""), False), (SecretStr(" "), False), + (SecretBytes(b""), False), (b"", False), ("x", True), (0, True), (0.0, True), + (SecretStr("k"), True), (False, True)], +) # fmt: skip +def test_is_set(value, expected): + assert is_set(value) is expected + + +def test_setting_keeps_other_schema_extras(): + class Extra(PluginSettings): + word: str = setting("", required=True, json_schema_extra={"examples": ["hi"]}) + other: str = setting("", json_schema_extra={"paperpi": "wrong shape"}) + + info = Extra.model_fields["word"] + assert info.json_schema_extra == {"examples": ["hi"], "paperpi": {"required": True}} + assert is_required(info) + assert not is_required(Extra.model_fields["other"]) # no crash on a wrong shape + + +def test_setting_needs_a_default(): + with pytest.raises(TypeError): + setting(description="no default") + + +def test_built_in_plugins_need_no_settings(): + # PaperPi runs these with their defaults (the screen shown when nothing else can be). + for plugin_type in ("basic_clock", "default"): + assert plugins.load(plugin_type).required == () + + +def test_missing_lists_the_required_settings_that_are_not_set(): + plugin = make(settings=Needs) + assert plugin.required == ("place", "lat", "key") + assert plugin.missing(Needs()) == ("place", "lat", "key") + assert plugin.missing(Needs(place="Rio", lat=0, key=SecretStr("k"))) == () + assert make().required == () + + +@pytest.mark.parametrize("plugin_type", ["met_no", "moon_phase"]) +def test_met_no_plugins_need_a_place_and_an_email_address(plugin_type): + # met.no's terms of service ask for a real way to contact the user. + assert plugins.load(plugin_type).required == ("lat", "lon", "email") diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index eb46852..bdd312e 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -536,7 +536,7 @@ def test_screen_keeps_its_picture_when_default_fails(tmp_path, caplog): assert "the default plugin failed" in caplog.text -def test_default_says_when_no_plugin_is_switched_on_and_the_splash_fails(tmp_path): +def test_default_says_when_no_plugin_is_ready_and_the_splash_fails(tmp_path): sim = Sim(tmp_path, rotation("a") + "\nenabled = false") sim.plan(**{"built-in-splash": "fail"}) sim.run(until=7300) @@ -544,6 +544,45 @@ def test_default_says_when_no_plugin_is_switched_on_and_the_splash_fails(tmp_pat assert sim.updates.times("built-in-splash") == [1, 3602, 7203] # once an hour +def test_plugin_without_its_required_settings_is_not_updated(tmp_path): + weather = rotation("w") + '\ntype = "met_no"\nlat = 1\nlon = 2' + sim = Sim(tmp_path, rotation("a"), weather) + sim.plan(a="A", w="W") + sim.run(until=100) + assert sim.updates.times("w") == [] + assert sim.shown == ["A"] + # Filling in the email address (a reload) starts it. + sim.next_config = make_config(rotation("a"), weather + '\nemail = "me@example.com"') + sim.at(100, sim.scheduler.reload) + sim.run(until=150) + assert sim.updates.times("w")[0] == 101 + + +def test_reload_without_a_required_setting_takes_the_plugin_off_screen(tmp_path): + weather = rotation("w") + '\ntype = "met_no"\nlat = 1\nlon = 2' + sim = Sim(tmp_path, rotation("a"), weather + '\nemail = "me@example.com"') + sim.plan(a="A", w="W") + sim.run(until=150) # w is on screen since 101 + sim.next_config = make_config(rotation("a"), weather) + sim.scheduler.reload() + sim.run(until=300) + assert sim.writes[:3] == [(1, "A"), (101, "W"), (150, "A")] + assert [t for t in sim.updates.times("w") if t >= 150] == [] + + +def test_a_plugin_waiting_for_its_settings_keeps_the_splash_screen_on(tmp_path): + # Switched on, but not ready to show: like no plugin at all, the splash screen (with the + # web address to fill in the settings) stays; if it fails, the default plugin says so. + waiting = rotation("w") + '\ntype = "met_no"\nlat = 1\nlon = 2' + sim = Sim(tmp_path, waiting) + sim.run(until=100) + assert sim.shown == ["BUILT-IN-SPLASH"] + sim = Sim(tmp_path, waiting) + sim.plan(**{"built-in-splash": "fail"}) + sim.run(until=100) + assert sim.shown == ["default 0/0"] # "No plugins are ready to show." + + def clock_labels(t): """A plan for the fallback clock: a new picture every minute.""" return f"clock {int(t // 60)}" @@ -773,7 +812,7 @@ def test_first_start_shows_the_splash_until_a_plugin_is_switched_on(tmp_path): sim.plan(**{SPLASH: lambda t: f"splash {int(t // 3600)}"}) sim.run(until=7300) assert sim.shown == ["splash 0", "splash 1", "splash 2"] # new picture every hour - # ... until a plugin is switched on. + # ... until a plugin is ready to show. sim.next_config = make_config(rotation("a")) sim.at(7400, sim.scheduler.reload) sim.run(until=7500)