From d52b8b56b350344c37ba5f303b72628b47826719 Mon Sep 17 00:00:00 2001 From: txoof-bot <337660340+txoof-bot@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:40:49 +0200 Subject: [PATCH 1/4] Plugin id and friendly name (M5 part 3a-0) Each [[plugin]] block now has a fixed `id` (letters, digits, _ and -, at most 40, unique; names the storage folder) and an optional friendly `name` shown by the web interface. The Plugin Library makes the id (type + 8 random hex characters); the name may be empty or the same as another plugin's. `paperpi render --config` takes `--id`; `paperpi list` shows both. Part of #238 Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- docs/decisions/config-format.md | 10 ++- docs/decisions/plugin-interface.md | 2 +- docs/decisions/web-interface.md | 4 +- docs/writing-plugins.md | 4 +- paperpi.example.toml | 8 ++- src/paperpi/cli.py | 21 +++--- src/paperpi/config.py | 53 ++++++++------ src/paperpi/example.py | 58 ++++++++++------ src/paperpi/plugin.py | 28 +++++++- src/paperpi/scheduler.py | 65 ++++++++--------- src/paperpi/web/app.py | 39 ++++++----- src/paperpi/web/config_file.py | 28 ++++---- src/paperpi/web/plugins.py | 91 ++++++++++++++---------- src/paperpi/web/templates/add.html | 4 +- src/paperpi/web/templates/plugins.html | 8 +-- src/paperpi/web/templates/remove.html | 6 +- tests/test_cli.py | 66 +++++++++++------- tests/test_config.py | 70 ++++++++++++------- tests/test_example.py | 55 +++++++++------ tests/test_scheduler.py | 12 ++-- tests/test_storage.py | 14 ++-- tests/test_web.py | 2 +- tests/test_web_config_file.py | 32 ++++----- tests/test_web_password.py | 4 +- tests/test_web_plugins.py | 96 ++++++++++++++++---------- 26 files changed, 465 insertions(+), 319 deletions(-) diff --git a/README.md b/README.md index c81a9b8..6ca3373 100644 --- a/README.md +++ b/README.md @@ -55,13 +55,13 @@ height = 480 mode = "bw" [[plugin]] -name = "Clock" +id = "clock" type = "basic_clock" layout = "time_date" ``` ```bash -uv run paperpi render --config paperpi.toml --name "Clock" # writes clock.png, named after the plugin's name +uv run paperpi render --config paperpi.toml --id clock # writes clock.png, named after the plugin's ID ``` Run `uv run paperpi render --help` for all options. How to write a plugin: [docs/writing-plugins.md](docs/writing-plugins.md). diff --git a/docs/decisions/config-format.md b/docs/decisions/config-format.md index 535b500..6fc6157 100644 --- a/docs/decisions/config-format.md +++ b/docs/decisions/config-format.md @@ -48,6 +48,7 @@ vcom = -1.90 rotation = 0 [[plugin]] +id = "weather_berlin" name = "Weather Berlin" type = "met_no" level = "rotation" @@ -56,7 +57,7 @@ lat = 52.52 lon = 13.40 [[plugin]] -name = "Word Clock" +id = "word_clock" type = "word_clock" level = "rotation" display_time = 255 @@ -64,7 +65,12 @@ display_time = 255 ### Only changed values are stored -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. +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 (`name`) 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-08 (M5 part 3a-0, issue #238), agreed with txoof:* each `[[plugin]]` block has a fixed **`id`** and an optional friendly **`name`**. +- `id` is required and unique (capitals, `-` and `_` don't count). It holds only letters, digits, `_` and `-`, at most 40 characters, so it is safe as a folder name and in the log. It names the plugin's storage folder, so it is not meant to change. The web interface makes one when it adds a plugin: the type and 8 random letters and digits, for example `met_no-3f9a1c2e`. By hand you may choose a readable one, such as `weather_berlin`. A block without an `id` is an error and is not shown; PaperPi never writes one into the file by itself. +- `name` is what the web interface shows ("Weather Berlin"). Any text up to 100 characters, without control characters; it does not have to be unique and can be changed at any time. Without one, the `id` is shown. +- The other choices were renaming the plugin (its storage folder is named after it, so a rename leaves the folder behind or has to move it while the plugin may be updating) and a fixed name. v2 does not read v1 config files, so the key could be renamed freely (global decision 2026-10-07; a later tool to move a v1 `paperpi.ini` is issue #248). *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`. diff --git a/docs/decisions/plugin-interface.md b/docs/decisions/plugin-interface.md index 63b2f91..aecb684 100644 --- a/docs/decisions/plugin-interface.md +++ b/docs/decisions/plugin-interface.md @@ -56,7 +56,7 @@ For sample images and tests, `fetch` is skipped and the sample data goes straigh - 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. - To keep starts quick (important for music plugins, which check every few seconds), PaperPi keeps one ready copy with the common packages already loaded and starts each update from it. A start takes a fraction of a second, even on a Pi 3. -- A plugin can't keep anything in memory between updates. It gets its own folder, `/var/lib/paperpi/plugins//`, for saved files such as downloaded data or the last track played. +- A plugin can't keep anything in memory between updates. It gets its own folder, `/var/lib/paperpi/plugins//`, for saved files such as downloaded data or the last track played. - Time limit per update: a default for all plugins, which can be changed per plugin. The exact numbers and what happens after repeated failures are in the error-handling note (#190) and `plugin-scheduling.md`. ### When a plugin is updated diff --git a/docs/decisions/web-interface.md b/docs/decisions/web-interface.md index 5d9e54a..09ca8e9 100644 --- a/docs/decisions/web-interface.md +++ b/docs/decisions/web-interface.md @@ -23,11 +23,11 @@ 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") and adds the block at the end of Active Plugins. Sample pictures follow in part 3b. +- **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. - **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 name. If the block was moved or renamed 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. +- 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. ### Plugin settings forms diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index fa3d5fb..486194b 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -78,7 +78,7 @@ For tests and sample images, step 2 is skipped and `sample` is drawn instead. So Each setting is a field of the plugin's `Settings` class, with a type, a default and a short help text (`description`). From this one description PaperPi checks the config file, writes the plugin's block for the config file (each setting as a comment with its default and the help text on the line above; for a `Literal` type the allowed values are added unless the help text names each of them as a word of its own), and later builds the web interface's form and the docs. A test uncomments every setting of every plugin's block and checks that it loads, so every default must be a valid value. - 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`. +- 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. @@ -95,7 +95,7 @@ In the config file, the plugin's settings go in its `[[plugin]]` block: ```toml [[plugin]] -name = "Clock" +id = "clock" type = "basic_clock" hours = 12 ``` diff --git a/paperpi.example.toml b/paperpi.example.toml index 5e45764..6339160 100644 --- a/paperpi.example.toml +++ b/paperpi.example.toml @@ -4,7 +4,7 @@ # 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 are not shown until you remove +# more than once, with a different id. 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. @@ -62,6 +62,10 @@ type = "virtual" # basic_clock: The time, and optionally the date, in large text. # Layouts (values for "layout"): time, time_date, small [[plugin]] +# Unique ID of this plugin: letters, digits, _ and -. It names the plugin's storage folder, so +# changing it starts with an empty folder +id = "clock" +# Name shown in the web interface; leave it out to show the ID name = "Clock" type = "basic_clock" # 12-hour (3:45 PM) or 24-hour (15:45) clock @@ -94,6 +98,7 @@ type = "basic_clock" # met_no: The weather for the next 12 hours from met.no: temperature, rain and wind. # Layouts (values for "layout"): hours_12, small, steps_3h, now, portrait_hours, portrait_now [[plugin]] +id = "weather_berlin" name = "Weather Berlin" type = "met_no" # Latitude of the place, e.g. 52.52 (required) @@ -117,6 +122,7 @@ place = "Berlin" # met_no: The weather for the next 12 hours from met.no: temperature, rain and wind. # Layouts (values for "layout"): hours_12, small, steps_3h, now, portrait_hours, portrait_now [[plugin]] +id = "weather_rio" name = "Weather Rio" type = "met_no" # Latitude of the place, e.g. 52.52 (required) diff --git a/src/paperpi/cli.py b/src/paperpi/cli.py index 1a75764..12e9f2b 100644 --- a/src/paperpi/cli.py +++ b/src/paperpi/cli.py @@ -80,7 +80,7 @@ def _parser() -> argparse.ArgumentParser: ) render.add_argument("plugin", nargs="?", help=f"plugin type: {', '.join(plugins.available())}") render.add_argument("--config", type=Path, help="config file to read the plugin from") - render.add_argument("--name", help='with --config: the plugin\'s name, e.g. "Clock"') + render.add_argument("--id", help="with --config: the plugin's ID, e.g. clock") render.add_argument( "--size", help=f'WIDTHxHEIGHT in pixels, e.g. 800x480 (default: {SIZE}, the 9.7" screen)', @@ -224,14 +224,14 @@ def _render(args: argparse.Namespace) -> int: if args.config is not None: if args.plugin: raise UsageError(f"give either a plugin type ({args.plugin!r}) or --config, not both") - if args.name is None: - raise UsageError('with --config, give the plugin\'s name: --name "Clock"') + if args.id is None: + raise UsageError("with --config, give the plugin's ID: --id clock") if args.set or args.size or args.mode: raise UsageError("with --config, settings, size and mode come from the config file") job = _job_from_config(args) else: - if args.name is not None: - raise UsageError("--name only works together with --config") + if args.id is not None: + raise UsageError("--id only works together with --config") if not args.plugin: raise UsageError("which plugin? e.g. paperpi render basic_clock") job = _job_from_options(args) @@ -394,10 +394,11 @@ def _list(args: argparse.Namespace) -> int: except config.ConfigError as error: print(f"paperpi: the config file can't be used:\n{error}", file=sys.stderr) return 1 - rows = [("name", "type", "on", "level", "display", "refresh", "layout", "storage")] + rows = [("id", "name", "type", "on", "level", "display", "refresh", "layout", "storage")] for row in config.plugin_rows(loaded): rows.append( ( + row.id, row.name, row.type, _on_column(row), @@ -490,12 +491,12 @@ def _job_from_config(args: argparse.Namespace) -> _Job: except config.ConfigError as error: raise UsageError(f"the config file can't be used:\n{error}") from None try: - found = loaded.plugin(args.name) + found = loaded.plugin(args.id) except KeyError: - names = ", ".join(repr(p.entry.name) for p in loaded.plugins) or "none" + ids = ", ".join(repr(p.entry.id) for p in loaded.plugins) or "none" raise UsageError( - f"no usable plugin named {args.name!r} in {args.config} (problems are shown " - f"above); plugins without errors: {names}" + f"no usable plugin with ID {args.id!r} in {args.config} (problems are shown " + f"above); plugins without errors: {ids}" ) from None width, height = loaded.display.layout_size return _Job( diff --git a/src/paperpi/config.py b/src/paperpi/config.py index 3e55f10..fc7eb34 100644 --- a/src/paperpi/config.py +++ b/src/paperpi/config.py @@ -287,8 +287,8 @@ def storage_days(self) -> int: @property def folder_name(self) -> str: - """The name of this plugin's storage folder, made from its name.""" - return folder_name(self.entry.name) + """The name of this plugin's storage folder, made from its ID.""" + return folder_name(self.entry.id) @property def missing(self) -> tuple[str, ...]: @@ -320,19 +320,21 @@ class Config: def errors(self) -> list[Problem]: return [p for p in self.problems if p.level == "error"] - def plugin(self, name: str) -> PluginConfig: - """The plugin block called ``name``. Raises ``KeyError`` if there is none.""" + def plugin(self, plugin_id: str) -> PluginConfig: + """The plugin block with ID ``plugin_id``. Raises ``KeyError`` if there is none.""" for plugin in self.plugins: - if plugin.entry.name == name: + if plugin.entry.id == plugin_id: return plugin - raise KeyError(name) + raise KeyError(plugin_id) @dataclass(frozen=True) class PluginRow: """One configured plugin, as the plugin list shows it.""" + id: str name: str + """The name it is shown with: its ``name``, or its ID without one.""" type: str enabled: bool level: str @@ -355,7 +357,8 @@ def plugin_rows(config: Config) -> list[PluginRow]: what is wrong with them.""" return [ PluginRow( - name=p.entry.name, + id=p.entry.id, + name=p.entry.label, type=p.plugin.type, enabled=p.entry.enabled, level=p.entry.level, @@ -370,9 +373,9 @@ def plugin_rows(config: Config) -> list[PluginRow]: ] -def folder_name(name: str) -> str: - """A safe folder name for a plugin name: ``"Weather Berlin"`` -> ``weather-berlin``.""" - return re.sub(r"[^a-z0-9]+", "-", name.casefold()).strip("-") or "plugin" +def folder_name(plugin_id: str) -> str: + """The storage folder name for a plugin ID: ``"Weather_Berlin"`` -> ``weather-berlin``.""" + return re.sub(r"[^a-z0-9]+", "-", plugin_id.casefold()).strip("-") or "plugin" def load(path: Path = CONFIG_FILE, *, state_dir: Path | None = STATE_DIR) -> Config: @@ -676,38 +679,42 @@ def check_plugins(self, blocks: Any) -> list[PluginConfig]: ) blocks = blocks[: limits.PLUGIN_BLOCKS] found: list[PluginConfig] = [] - names: dict[str, int | None] = {} + ids: dict[str, int | None] = {} for index, block in enumerate(blocks): section = ("plugin", index) if not isinstance(block, dict): self.add("error", "each plugin must be a [[plugin]] block", key="plugin") continue - name = block.get("name") - if isinstance(name, str): + plugin_id = block.get("id") + if isinstance(plugin_id, str): # Checked for every block, also broken ones, so fixing one block can't # silently switch off another. - folder = folder_name(name) - if folder in names: - used = names[folder] + folder = folder_name(plugin_id) + if folder in ids: + used = ids[folder] at = f" at line {used}" if used else "" self.add( "error", - f"name {name!r} is already used by the plugin block{at}; " - "names must be different (also ignoring capitals and punctuation)", + f"id {plugin_id!r} is already used by the plugin block{at}; " + "IDs must be different (capitals, - and _ don't count)", section, - "name", - f"[[plugin]] {name!r}", + "id", + f"[[plugin]] {plugin_id!r}", ) continue - names[folder] = self.lines.get(section) + ids[folder] = self.lines.get(section) checked = self.check_plugin(block, section) if checked is not None: found.append(checked) return found def check_plugin(self, block: dict[str, Any], section: tuple) -> PluginConfig | None: - name = block.get("name") - where = f"[[plugin]] {name!r}" if isinstance(name, str) else f"[[plugin]] {section[1] + 1}" + plugin_id = block.get("id") + where = ( + f"[[plugin]] {plugin_id!r}" + if isinstance(plugin_id, str) + else f"[[plugin]] {section[1] + 1}" + ) errors_before = len(self.problems) def failed() -> bool: diff --git a/src/paperpi/example.py b/src/paperpi/example.py index ffc7b7f..aa30bf1 100644 --- a/src/paperpi/example.py +++ b/src/paperpi/example.py @@ -34,19 +34,19 @@ ) from .plugin import Plugin, PluginEntry, is_required -#: The blocks of the example file: name, plugin type and the settings that are set. +#: The blocks of the example file: ID, plugin type and the settings that are set. #: Weather twice, to show that one plugin type can be used more than once. EXAMPLE_PLUGINS = ( - ("Clock", "basic_clock", {}), + ("clock", "basic_clock", {"name": "Clock"}), ( - "Weather Berlin", + "weather_berlin", "met_no", - {"lat": 52.52, "lon": 13.40, "place": "Berlin"}, + {"name": "Weather Berlin", "lat": 52.52, "lon": 13.40, "place": "Berlin"}, ), ( - "Weather Rio", + "weather_rio", "met_no", - {"lat": -22.91, "lon": -43.17, "place": "Rio"}, + {"name": "Weather Rio", "lat": -22.91, "lon": -43.17, "place": "Rio"}, ), ) @@ -57,7 +57,7 @@ # 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 are not shown until you remove +# more than once, with a different id. 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. @@ -75,14 +75,15 @@ def plugin_block( - plugin: Plugin, name: str, values: Mapping[str, Any] | None = None, *, shared: bool = False + plugin: Plugin, plugin_id: str, values: Mapping[str, Any] | None = None, *, shared: bool = False ) -> str: - """One ``[[plugin]]`` block for ``plugin``, called ``name``. + """One ``[[plugin]]`` block for ``plugin``, with ID ``plugin_id``. - ``values`` are the settings that are set (the plugin's own or shared ones); ``None`` - means "not set". Every other setting of the plugin follows as a comment with its - default. With ``shared``, all shared settings (level, display time, ...) are listed too; - otherwise only ``refresh`` and ``layout``, whose defaults depend on the plugin. + ``values`` are the settings that are set (the plugin's own or shared ones, also the + ``name`` it is shown with); ``None`` means "not set". Every other setting of the plugin + follows as a comment with its default. With ``shared``, all shared settings (level, + display time, ...) are listed too; otherwise only ``refresh`` and ``layout``, whose + defaults depend on the plugin. Raises ``ValueError`` for an unknown setting or a value the plugin would not accept, so a config manager never writes a block that is switched off when the file loads. The @@ -92,11 +93,11 @@ def plugin_block( entry = PluginEntry.model_fields values = {k: v for k, v in (values or {}).items() if v is not None} for key in values: - if key in ("name", "type") or key not in own | entry: - known = ", ".join(k for k in own | entry if k not in ("name", "type")) + if key in _FIXED or key not in own | entry: + known = ", ".join(k for k in own | entry if k not in _FIXED) raise ValueError(f"{plugin.type} has no setting {key!r}; it has: {known}") PluginEntry.model_validate( - {"name": name, "type": plugin.type} | {k: v for k, v in values.items() if k in entry} + {"id": plugin_id, "type": plugin.type} | {k: v for k, v in values.items() if k in entry} ) plugin.settings.model_validate({k: v for k, v in values.items() if k in own}) if "layout" in values and values["layout"] not in plugin.layouts: @@ -105,10 +106,19 @@ def plugin_block( *_comment(f"{plugin.type}: {plugin.description}"), *_comment(f'Layouts (values for "layout"): {", ".join(plugin.layouts)}'), "[[plugin]]", - f"name = {_toml(name)}", - f"type = {_toml(plugin.type)}", ] + if shared: + lines += _comment(entry["id"].description or "") + lines.append(f"id = {_toml(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)}") for key, value in values.items(): + if key == "name": + continue lines += _setting(key, (own | entry)[key], value) shown = { "refresh": plugin.refresh, @@ -118,17 +128,21 @@ def plugin_block( } lines += _settings(own, values, {}) if shared: - keys = [k for k in entry if k not in ("name", "type")] + keys = [k for k in entry if k not in (*_FIXED, "name")] lines.append("# --- Settings every plugin has ---") else: keys = ["refresh", "layout"] lines.append("# --- Settings every plugin has (all of them are in the first block) ---") lines += _settings({k: entry[k] for k in keys}, values, shown) text = "\n".join(lines) + "\n" - _check_reads_back(text, {"name": name, "type": plugin.type} | values) + _check_reads_back(text, {"id": plugin_id, "type": plugin.type} | values) return text +#: Settings of a ``[[plugin]]`` block that :func:`plugin_block` takes as its own arguments. +_FIXED = ("id", "type") + + def display_part(values: Mapping[str, Any]) -> str: """The ``[display]`` part, with ``values`` set and every other setting as a comment.""" values = {k: v for k, v in values.items() if v is not None} @@ -160,9 +174,9 @@ def example_config() -> str: display_part({"type": "virtual"}), web_part(), ] - for index, (name, plugin_type, values) in enumerate(EXAMPLE_PLUGINS): + for index, (plugin_id, plugin_type, values) in enumerate(EXAMPLE_PLUGINS): plugin = plugins.load(plugin_type) - parts.append(plugin_block(plugin, name, values, shared=index == 0)) + parts.append(plugin_block(plugin, plugin_id, values, shared=index == 0)) return "\n".join(parts) diff --git a/src/paperpi/plugin.py b/src/paperpi/plugin.py index ca9f2bf..0d4a350 100644 --- a/src/paperpi/plugin.py +++ b/src/paperpi/plugin.py @@ -139,6 +139,10 @@ def is_set(value: Any) -> bool: return value is not None +#: The longest plugin ID (``id`` in a ``[[plugin]]`` block). +ID_LENGTH = 40 + + class PluginEntry(BaseModel): """The settings every ``[[plugin]]`` block in the config file has. @@ -148,7 +152,17 @@ class PluginEntry(BaseModel): model_config = ConfigDict(extra="ignore", frozen=True) - name: str = Field(min_length=1, max_length=100, description="Unique name of this plugin") + id: str = Field( + min_length=1, + max_length=ID_LENGTH, + description="Unique ID of this plugin: letters, digits, _ and -. It names the plugin's " + "storage folder, so changing it starts with an empty folder", + ) + name: str = Field( + "", + max_length=100, + description="Name shown in the web interface; leave it out to show the ID", + ) type: str = Field(description="Which plugin, e.g. basic_clock") enabled: bool = Field(True, description="Show this plugin") level: Literal["alert", "interrupt", "rotation"] = Field( @@ -207,6 +221,13 @@ class PluginEntry(BaseModel): "them). Leave it out to use the plugin's suggestion (shown below)", ) + @field_validator("id") + @classmethod + def _plain_id(cls, plugin_id: str) -> str: + if not re.fullmatch(r"[A-Za-z0-9_-]*", plugin_id): + raise ValueError("must hold only letters, digits, _ and - (e.g. weather_berlin)") + return plugin_id + @field_validator("name") @classmethod def _no_control_characters(cls, name: str) -> str: @@ -217,6 +238,11 @@ def _no_control_characters(cls, name: str) -> str: raise ValueError("must not hold control characters (such as tab or new line)") return name + @property + def label(self) -> str: + """What the web interface calls this plugin: its name, or its ID without one.""" + return self.name.strip() or self.id + #: Names a plugin may not use for its own settings. SHARED_SETTINGS = frozenset(PluginEntry.model_fields) diff --git a/src/paperpi/scheduler.py b/src/paperpi/scheduler.py index 7e8ccde..4bcdcb1 100644 --- a/src/paperpi/scheduler.py +++ b/src/paperpi/scheduler.py @@ -133,7 +133,7 @@ class _Written: seconds: float error: Exception | None new_turn: bool - name: str | None = None + plugin_id: str | None = None """The plugin whose image was written.""" cleared: bool = False """The screen was cleared first (also when the write after it failed).""" @@ -145,7 +145,7 @@ class _Written: @dataclass(frozen=True) class _Dismiss: - name: str + plugin_id: str _STOP = "stop" @@ -179,8 +179,8 @@ class _Slot: when that update has finished, so two updates never use the same storage folder.""" @property - def name(self) -> str: - return self.config.entry.name + def id(self) -> str: + return self.config.entry.id @property def level(self) -> str: @@ -194,7 +194,7 @@ def has_image(self) -> bool: class Scheduler: """Decides what is on ``screen`` and writes it there. - ``state_dir`` holds each plugin's storage folder (``/plugins//``). + ``state_dir`` holds each plugin's storage folder (``/plugins//``). ``reload`` returns a freshly loaded config when :meth:`reload` is called. ``clock``, ``executor``, ``update`` and ``stop_updates`` are for tests; by default updates run in plugin processes with :func:`paperpi.runner.run_update`, at most @@ -260,7 +260,7 @@ def __init__( """The plugin on screen.""" self._turn_start = 0.0 self._last_rotation: str | None = None - """The name of the rotation plugin shown last; the rotation goes on after it.""" + """The ID of the rotation plugin shown last; the rotation goes on after it.""" self._attempted: Image.Image | None = None """The image last sent to the screen (also when the write failed).""" self.write_seconds = 0.0 @@ -298,14 +298,14 @@ def reload(self) -> None: """Ask :meth:`run` to load the config again and apply the changes.""" self._events.put(_RELOAD) - def dismiss(self, name: str) -> None: - """Dismiss the alert of the plugin called ``name`` (from the web interface, M5).""" - self._events.put(_Dismiss(name)) + def dismiss(self, plugin_id: str) -> None: + """Dismiss the alert of the plugin with ID ``plugin_id`` (from the web interface, M5).""" + self._events.put(_Dismiss(plugin_id)) @property def on_screen(self) -> str | None: - """The name of the plugin on screen.""" - return None if self._current is None else self._current.name + """The ID of the plugin on screen.""" + return None if self._current is None else self._current.id def run(self) -> None: """Show plugins until :meth:`stop` is called.""" @@ -351,9 +351,9 @@ def _handle(self, event) -> None: self._written(event, now) elif isinstance(event, _Dismiss): for slot in self._slots: - if slot.name == event.name and slot.level == "alert": + if slot.id == event.plugin_id and slot.level == "alert": slot.dismissed_at = now - log.info("alert %r dismissed", slot.name) + log.info("alert %r dismissed", slot.id) elif event == _RELOAD and self._reload is not None: try: new = self._load_with_time_limit() @@ -378,12 +378,12 @@ def _apply(self, config: Config) -> None: """Use a new config. Plugins whose settings did not change keep their place and image.""" redraw = self._apply_display(config.display) now = self.clock.monotonic() - old = {slot.name: slot for slot in self._slots} + old = {slot.id: slot for slot in self._slots} slots = [] for found in config.plugins: if not found.shown or found.plugin.type == "default": continue - slot = old.get(found.entry.name) + slot = old.get(found.entry.id) if slot is None or redraw or not _same_plugin(slot.config, found): changed = _Slot(found, due=now) if slot is not None: @@ -402,10 +402,10 @@ def _apply(self, config: Config) -> None: self._current = changed slot = changed slots.append(slot) - names = [slot.name for slot in slots] + names = [slot.id for slot in slots] if self._last_rotation is not None and self._last_rotation not in names: # The plugin shown last was removed: go on after the one before it. - old_names = [slot.name for slot in self._slots] + old_names = [slot.id for slot in self._slots] index = old_names.index(self._last_rotation) before = old_names[:index][::-1] + old_names[index + 1 :][::-1] self._last_rotation = next((n for n in before if n in names), None) @@ -522,7 +522,7 @@ def _clean(self, slot: _Slot, folder: Path) -> None: # Here, not in the plugin process: also after a failed or stopped update. clean(folder, slot.config.storage_mb, slot.config.storage_days) except Exception as error: # noqa: BLE001 - a clean-up problem never stops updates - log.warning("cleaning the folder of %r failed: %s", slot.name, error) + log.warning("cleaning the folder of %r failed: %s", slot.id, error) def _finished(self, event: _Finished, now: float) -> None: slot = event.slot @@ -563,14 +563,14 @@ def _finished(self, event: _Finished, now: float) -> None: slot.due = now + limits.LEFT_OUT log.warning( "plugin %r failed %d times in a row; left out for %g minutes: %s", - slot.name, + slot.id, slot.failures, limits.LEFT_OUT / 60, event.error, ) else: slot.due = self._next_update(slot, now) - log.warning("plugin %r failed: %s", slot.name, event.error) + log.warning("plugin %r failed: %s", slot.id, event.error) return slot.failures = 0 if slot is self._current and self._write_failures: @@ -603,7 +603,7 @@ def _alert_active(self, slot: _Slot, now: float) -> bool: slot.expired = True log.warning( "alert %r was active for %g hours and was dismissed", - slot.name, + slot.id, slot.config.entry.alert_max_time / 3600, ) return False @@ -625,7 +625,7 @@ def _show(self, now: float) -> None: # The next plugin takes over before this image would be finished drawing. return if chosen.level == "rotation" and chosen in self._slots: - self._last_rotation = chosen.name + self._last_rotation = chosen.id self._current = chosen paused = self._retry_at is not None if not paused and ( @@ -683,11 +683,11 @@ def _take_turns(self, group: list[_Slot], now: float) -> tuple[_Slot, bool]: return group[0], True if now - self._turn_start < current.config.entry.display_time: return current, False - return self._next(group, current.name), True + return self._next(group, current.id), True def _next(self, group: list[_Slot], after: str | None) -> _Slot: """The first plugin in ``group`` after the one called ``after``, in config order.""" - names = [s.name for s in self._slots] + names = [s.id for s in self._slots] start = names.index(after) + 1 if after in names else 0 order = self._slots[start:] + self._slots[:start] return next(s for s in order if s in group) @@ -745,9 +745,9 @@ def _write(self, chosen: _Slot, new_turn: bool, now: float) -> None: image = image.rotate(-rotation, expand=True) if rotation else image every = self.display.clean_every clean = bool(every) and now - self._last_clean >= every - fast = not clean and self._shown_by == chosen.name + fast = not clean and self._shown_by == chosen.id change, self._change = self._change, None - job = (self.clock.monotonic(), new_turn, chosen.name, fast, clean, change, self._generation) + job = (self.clock.monotonic(), new_turn, chosen.id, fast, clean, change, self._generation) self.writer.submit(self._write_job, image, *job) def _write_job( @@ -755,7 +755,7 @@ def _write_job( image: Image.Image, start: float, new_turn: bool, - name: str, + plugin_id: str, fast: bool, clean: bool, change: Callable[[], None] | None, @@ -774,7 +774,8 @@ def _write_job( except Exception as failed: # noqa: BLE001 - handled in the loop error = failed seconds = self.clock.monotonic() - start - self._events.put(_Written(seconds, error, new_turn, name, cleared, generation=generation)) + written = _Written(seconds, error, new_turn, plugin_id, cleared, generation=generation) + self._events.put(written) def _check_job(self, start: float) -> None: """Runs in the writer thread at start: start the screen, to check that it answers.""" @@ -822,7 +823,7 @@ def _written(self, event: _Written, now: float) -> None: self._last_written = now self.write_seconds = event.seconds if event.generation == self._generation: - self._shown_by = event.name + self._shown_by = event.plugin_id def _wait_time(self, now: float) -> float | None: """Seconds until something is due; ``None`` when only an event can change anything.""" @@ -867,14 +868,14 @@ def _default_config(config: Config) -> PluginConfig: return found plugin = plugins.load("default") # Its own storage folder, also when a user names another plugin "default". - entry = PluginEntry(name="built-in default", type="default") + entry = PluginEntry(id="built-in-default", type="default") return PluginConfig(entry, plugin.settings(), plugin) def _fallback_config() -> PluginConfig: """The fallback clock: basic_clock with one small line of time and date.""" plugin = plugins.load("basic_clock") - entry = PluginEntry(name="built-in clock", type="basic_clock", layout="small") + entry = PluginEntry(id="built-in-clock", type="basic_clock", layout="small") return PluginConfig(entry, plugin.settings(), plugin) @@ -884,7 +885,7 @@ def _splash_config(web_port: int) -> PluginConfig: plugin = plugins.load("splash_screen") # Its time on screen comes from [display] splash_time, not from display_time. A short # time limit: while its first picture is drawn, the screen waits for it. - entry = PluginEntry(name="built-in splash", type="splash_screen", time_limit=30) + entry = PluginEntry(id="built-in-splash", type="splash_screen", time_limit=30) return PluginConfig(entry, plugin.settings(port=web_port), plugin) diff --git a/src/paperpi/web/app.py b/src/paperpi/web/app.py index 2505f23..d0838cf 100644 --- a/src/paperpi/web/app.py +++ b/src/paperpi/web/app.py @@ -27,9 +27,10 @@ import ipaddress from collections.abc import Awaitable, Callable from pathlib import Path +from typing import Annotated from urllib.parse import urlencode, urlsplit -from fastapi import FastAPI, Form, Request +from fastapi import FastAPI, Form, Query, Request from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates @@ -45,6 +46,9 @@ _HERE = Path(__file__).parent #: Pages that work without a log-in (and everything in ``/static/``). _OPEN = ("/setup", "/login") +#: A plugin's ID, sent as ``id`` in a form or in the address. +_FormId = Annotated[str, Form(alias="id")] +_QueryId = Annotated[str, Query(alias="id")] def create_app(auth: Auth, editor: PluginEditor | None = None) -> FastAPI: @@ -155,9 +159,10 @@ def plugin_list(request: Request, status: int = 200, problem: str | None = None) else: rows, problems = found.rows, found.problems done = request.query_params.get("done") - # The name in the address is only shown when it is in the list: a link can't put - # other text on the page. - name = request.query_params.get("name") + # The name is looked up by the ID in the address: a link can't put other text on + # the page. + plugin_id = request.query_params.get("id") + name = next((row.name for row in rows if plugin_id and row.id == plugin_id), None) return page( request, "plugins.html", @@ -166,13 +171,13 @@ def plugin_list(request: Request, status: int = 200, problem: str | None = None) problems=[p for p in dict.fromkeys(problems) if p != problem], problem=problem, done=done if done in ("saved", "added", "removed") else None, - name=name if any(row.name == name for row in rows) else None, + name=name, hand_edits=request.query_params.get("hand") == "1", config_file=auth.config_file, ) - def saved(done: str, hand_edits: bool, name: str = "") -> RedirectResponse: - values = {"done": done} | ({"name": name} if name else {}) + def saved(done: str, hand_edits: bool, plugin_id: str = "") -> RedirectResponse: + values = {"done": done} | ({"id": plugin_id} if plugin_id else {}) query = urlencode(values | ({"hand": "1"} if hand_edits else {})) return RedirectResponse(f"/plugins?{query}", status_code=303) @@ -181,35 +186,35 @@ def plugins_page(request: Request): return plugin_list(request) @app.post("/plugins/{index}/move", response_class=HTMLResponse) - def move(request: Request, index: int, name: str = Form(""), step: str = Form("")): + def move(request: Request, index: int, plugin_id: _FormId = "", step: str = Form("")): if step not in ("up", "down"): return plugin_list(request, 400, "Choose up or down.") try: - hand_edits = editor.move(index, name, -1 if step == "up" else 1) + hand_edits = editor.move(index, plugin_id, -1 if step == "up" else 1) except EditError as error: return plugin_list(request, 409, str(error)) return saved("saved", hand_edits) @app.post("/plugins/{index}/enabled", response_class=HTMLResponse) - def switch(request: Request, index: int, name: str = Form(""), on: str = Form("")): + def switch(request: Request, index: int, plugin_id: _FormId = "", on: str = Form("")): try: - hand_edits = editor.set_enabled(index, name, on == "1") + hand_edits = editor.set_enabled(index, plugin_id, on == "1") except EditError as error: return plugin_list(request, 409, str(error)) return saved("saved", hand_edits) @app.get("/plugins/{index}/remove", response_class=HTMLResponse) - def remove_page(request: Request, index: int, name: str = ""): + def remove_page(request: Request, index: int, plugin_id: _QueryId = ""): try: - row = editor.row(index, name) + row = editor.row(index, plugin_id) except EditError as error: return plugin_list(request, 409, str(error)) return page(request, "remove.html", row=row) @app.post("/plugins/{index}/remove", response_class=HTMLResponse) - def remove(request: Request, index: int, name: str = Form("")): + def remove(request: Request, index: int, plugin_id: _FormId = ""): try: - hand_edits = editor.remove(index, name) + hand_edits = editor.remove(index, plugin_id) except EditError as error: return plugin_list(request, 409, str(error)) return saved("removed", hand_edits) @@ -235,10 +240,10 @@ def add(request: Request, plugin_type: str, name: str = Form("")): if item is None: return add_page(request, plugin_type) try: - hand_edits = editor.add(item, name) + 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, name.strip()) + return saved("added", hand_edits, plugin_id) @app.post("/logout") def logout(): diff --git a/src/paperpi/web/config_file.py b/src/paperpi/web/config_file.py index 0223cec..1b500c4 100644 --- a/src/paperpi/web/config_file.py +++ b/src/paperpi/web/config_file.py @@ -14,7 +14,7 @@ understand (for example a ``[[plugin]]`` line written differently, or a value line that looks like a ``[part]`` line) is never saved wrongly: :class:`UnknownForm` says to change it by hand instead. -- Each change names its block by place and name, so a block that was moved or removed by +- Each change names its block by place and ID, so a block that was moved or removed by hand a moment ago is not changed by mistake (:class:`ChangedMeanwhile`). """ @@ -189,15 +189,15 @@ def plugin_blocks(data: dict[str, Any]) -> list[Any]: return found if isinstance(found, list) else [] -def name_of(block: Any) -> str: - """The name of a loaded plugin block, or ``""`` when it has none (a broken block).""" - name = block.get("name") if isinstance(block, dict) else None - return name if isinstance(name, str) else "" +def id_of(block: Any) -> str: + """The ID of a loaded plugin block, or ``""`` when it has none (a broken block).""" + plugin_id = block.get("id") if isinstance(block, dict) else None + return plugin_id if isinstance(plugin_id, str) else "" -def move(text: str, index: int, name: str, step: int) -> str: +def move(text: str, index: int, plugin_id: str, step: int) -> str: """``text`` with plugin block ``index`` moved one place up (``step=-1``) or down (``1``).""" - data, scan = _check(text, index, name) + data, scan = _check(text, index, plugin_id) other = index + step if step not in (-1, 1) or not 0 <= other < len(scan.blocks): raise EditError("This plugin can't move further.") @@ -216,9 +216,9 @@ def move(text: str, index: int, name: str, step: int) -> str: return _checked("".join(new), data | {"plugin": expected}) -def remove(text: str, index: int, name: str) -> str: +def remove(text: str, index: int, plugin_id: str) -> str: """``text`` without plugin block ``index`` (and the comments just above it).""" - data, scan = _check(text, index, name) + data, scan = _check(text, index, plugin_id) block = scan.blocks[index] new = "".join([*scan.lines[: block.start], *scan.lines[block.end :]]) expected = plugin_blocks(data) @@ -227,13 +227,13 @@ def remove(text: str, index: int, name: str) -> str: return _checked(new, data | {"plugin": expected} if expected else data) -def set_enabled(text: str, index: int, name: str, enabled: bool) -> str: +def set_enabled(text: str, index: int, plugin_id: str, enabled: bool) -> str: """``text`` with plugin block ``index`` switched on or off. Off writes ``enabled = false``; on removes that line, because the file only holds settings that differ from the default. """ - data, scan = _check(text, index, name) + data, scan = _check(text, index, plugin_id) block = scan.blocks[index] lines = _ended(scan.lines) own = [n for n in range(block.header + 1, block.own_end) if n not in scan.in_string] @@ -246,7 +246,7 @@ def set_enabled(text: str, index: int, name: str, enabled: bool) -> str: if at: new[at[0]] = line else: - after = _setting_line(lines, own, "type") or _setting_line(lines, own, "name") + after = _setting_line(lines, own, "type") or _setting_line(lines, own, "id") new.insert((after or block.header) + 1, line) expected = plugin_blocks(data) changed = {k: v for k, v in expected[index].items() if k != "enabled"} @@ -272,13 +272,13 @@ def add(text: str, block_text: str) -> str: return _checked(new, data | {"plugin": [*plugin_blocks(data), *new_block]}) -def _check(text: str, index: int, name: str) -> tuple[dict[str, Any], _Scan]: +def _check(text: str, index: int, plugin_id: str) -> tuple[dict[str, Any], _Scan]: data = load(text) scan = _scan(text) raw = plugin_blocks(data) if len(scan.blocks) != len(raw): raise UnknownForm() - if not 0 <= index < len(raw) or name_of(raw[index]) != name: + if not 0 <= index < len(raw) or id_of(raw[index]) != plugin_id: raise ChangedMeanwhile() return data, scan diff --git a/src/paperpi/web/plugins.py b/src/paperpi/web/plugins.py index 2eab5f7..3d51cc8 100644 --- a/src/paperpi/web/plugins.py +++ b/src/paperpi/web/plugins.py @@ -13,6 +13,7 @@ from __future__ import annotations +import secrets from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path @@ -20,7 +21,7 @@ from pydantic import ValidationError from .. import config, example, plugins -from ..plugin import Plugin, PluginEntry +from ..plugin import ID_LENGTH, Plugin, PluginEntry from . import config_file #: Plugin types the Plugin Library doesn't offer: ``default`` is PaperPi's own message when @@ -34,8 +35,10 @@ class Row: index: int """Its place in the file, counted from 0.""" + id: str + """``""`` when the block has no proper ID.""" name: str - """``""`` when the block has no proper name.""" + """The name it is shown with: its ``name``, or its ID without one.""" type: str enabled: bool missing: tuple[str, ...] = () @@ -127,61 +130,65 @@ def change(self, edit: Callable[[str], str]) -> bool: self._reload() return hand_edits - def move(self, index: int, name: str, step: int) -> bool: - return self.change(lambda text: config_file.move(text, index, name, step)) + def move(self, index: int, plugin_id: str, step: int) -> bool: + return self.change(lambda text: config_file.move(text, index, plugin_id, step)) - def set_enabled(self, index: int, name: str, enabled: bool) -> bool: + def set_enabled(self, index: int, plugin_id: str, enabled: bool) -> bool: def switch(text: str) -> str: - row = _row(_plugin_list(text), index, name) + row = _row(_plugin_list(text), index, plugin_id) if enabled and row.missing: raise config_file.EditError( - f"{name} can't be switched on yet. Fill in these settings first: " + f"{row.name} can't be switched on yet. Fill in these settings first: " f"{', '.join(row.missing)}." ) - return config_file.set_enabled(text, index, name, enabled) + return config_file.set_enabled(text, index, plugin_id, enabled) return self.change(switch) - def remove(self, index: int, name: str) -> bool: - return self.change(lambda text: config_file.remove(text, index, name)) + def remove(self, index: int, plugin_id: str) -> bool: + return self.change(lambda text: config_file.remove(text, index, plugin_id)) - def row(self, index: int, name: str) -> Row: - """The block at ``index``, if it is still called ``name``.""" - return _row(self.plugin_list(), index, name) + 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) - def add(self, item: LibraryItem, name: str) -> bool: - """Add a block for ``item`` called ``name`` at the end of the plugin list. A plugin - with required settings is added switched off. Raises + def add(self, item: LibraryItem, name: str) -> tuple[bool, str]: + """Add a block for ``item``, shown as ``name``, at the end of the plugin list. It + gets a new ID: the type and 8 random letters and digits, e.g. ``met_no-3f9a1c2e``. + A plugin with required settings is added switched off. + + Returns whether hand edits applied too (see :meth:`change`) and the new ID. Raises :class:`config_file.EditError` for a name that can't be used.""" if item.plugin is None: raise config_file.EditError(f"{item.type} can't be added. {item.description}") + plugin = item.plugin name = name.strip() problem = name_problem(name) if problem: raise config_file.EditError(problem) - values = {"enabled": False} if item.plugin.required else {} - block = example.plugin_block(item.plugin, name, values) + values = ({"name": name} if name else {}) | ({"enabled": False} if plugin.required else {}) + made = "" def add(text: str) -> str: - if config.folder_name(name) in _used_names(text): - raise config_file.EditError( - "Another plugin already has this name (capitals and punctuation don't " - "count). Choose another name." - ) - return config_file.add(text, block) + nonlocal made + used = _used_ids(text) + made = new_id(item.type) + while config.folder_name(made) in used: + made = new_id(item.type) + return config_file.add(text, example.plugin_block(plugin, made, values)) - return self.change(add) + return self.change(add), made def suggested_name(self, item: LibraryItem) -> str: - """A name for a new block of ``item`` that is not used yet: "Basic clock", - "Basic clock 2", ...""" + """A name for a new block of ``item`` that no other plugin is shown with yet: + "Basic clock", "Basic clock 2", ...""" try: used = _used_names(config_file.read(self.path)[1]) except config_file.EditError: used = set() base = item.type.replace("_", " ").capitalize() name, number = base, 1 - while config.folder_name(name) in used: + while name.casefold() in used: number += 1 name = f"{base} {number}" return name @@ -219,10 +226,12 @@ def _plugin_list(text: str, source: str = "config") -> PluginList: for p in problems if p.level == "error" and p.line and place.start < p.line <= place.end ) + plugin_id, name = config_file.id_of(block), block.get("name") rows.append( Row( index=index, - name=config_file.name_of(block), + id=plugin_id, + name=(name.strip() or plugin_id) if isinstance(name, str) else plugin_id, type=plugin_type if isinstance(plugin_type, str) else "", enabled=enabled if isinstance(enabled, bool) else True, missing=good.missing if good is not None else (), @@ -233,8 +242,8 @@ def _plugin_list(text: str, source: str = "config") -> PluginList: return PluginList(rows, file_problems) -def _row(found: PluginList, index: int, name: str) -> Row: - if not 0 <= index < len(found.rows) or found.rows[index].name != name: +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() return found.rows[index] @@ -258,23 +267,31 @@ def library_item(plugin_type: str) -> LibraryItem | None: return next((item for item in library() if item.type == plugin_type), None) +def new_id(plugin_type: str) -> str: + """A new plugin ID: ``plugin_type``, ``-`` and 8 random letters and digits.""" + return f"{plugin_type[: ID_LENGTH - 9]}-{secrets.token_hex(4)}" + + def name_problem(name: str) -> str | None: - """Why ``name`` can't be a plugin's name, or ``None`` when it can.""" + """Why ``name`` can't be the name a plugin is shown with, or ``None`` when it can.""" try: - PluginEntry.model_validate({"name": name, "type": "x"}) + PluginEntry.model_validate({"id": "x", "type": "x", "name": name}) except ValidationError as error: found = error.errors()[0] - if found["type"] == "string_too_short": - return "Give the plugin a name." if found["type"] == "string_too_long": return f"The name can have at most {found['ctx']['max_length']} characters." return f"The name {found['msg'].removeprefix('Value error, ')}." return None -def _used_names(text: str) -> set[str]: +def _used_ids(text: str) -> set[str]: blocks = config_file.plugin_blocks(config_file.load(text)) - return {config.folder_name(n) for n in map(config_file.name_of, blocks) if n} + return {config.folder_name(i) for i in map(config_file.id_of, blocks) if i} + + +def _used_names(text: str) -> set[str]: + """The names the plugins are shown with, without capitals.""" + return {row.name.casefold() for row in _plugin_list(text).rows} def _without_place(problem: config.Problem) -> str: diff --git a/src/paperpi/web/templates/add.html b/src/paperpi/web/templates/add.html index e5dbfbc..ab05446 100644 --- a/src/paperpi/web/templates/add.html +++ b/src/paperpi/web/templates/add.html @@ -16,8 +16,8 @@

Add {{ item.type }}

{% endif %}
- + Cancel
diff --git a/src/paperpi/web/templates/plugins.html b/src/paperpi/web/templates/plugins.html index 8557afd..240abb3 100644 --- a/src/paperpi/web/templates/plugins.html +++ b/src/paperpi/web/templates/plugins.html @@ -23,12 +23,12 @@

Active Plugins

    {% for row in rows %}
  1. -
    {{ row.name or "(no name)" }} {{ row.type }}
    +
    {{ row.name or "(no id)" }} {{ row.type }}{% if row.id and row.id != row.name %} ยท {{ row.id }}{% endif %}
    {{ row.status }}
    {% for error in row.errors %}
    {{ error }}
    {% endfor %}
    - + {% if row.enabled %} {% else %} @@ -37,11 +37,11 @@

    Active Plugins

    {% endif %}
    - +
    - Remove + Remove
  2. {% endfor %} diff --git a/src/paperpi/web/templates/remove.html b/src/paperpi/web/templates/remove.html index 31d0e04..5c9abd0 100644 --- a/src/paperpi/web/templates/remove.html +++ b/src/paperpi/web/templates/remove.html @@ -2,11 +2,11 @@ {% set logged_in = true %} {% block title %}Remove {{ row.name }} ยท PaperPi{% endblock %} {% block main %} -

    Remove {{ row.name or "(no name)" }}?

    -

    This removes the {{ row.type }} plugin {{ row.name or "(no name)" }} +

    Remove {{ row.name or "(no id)" }}?

    +

    This removes the {{ row.type }} plugin {{ row.name or "(no id)" }} and all its settings from the config file. To only stop showing it, switch it off instead.

    - + Cancel
    diff --git a/tests/test_cli.py b/tests/test_cli.py index 7f8e827..9287f7b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -55,7 +55,7 @@ def test_render_from_config(tmp_path): ) ) out = tmp_path / "c.png" - assert render("--config", str(cfg), "--name", "Clock", "-o", str(out)) == 0 + assert render("--config", str(cfg), "--id", "Clock", "-o", str(out)) == 0 assert Image.open(out).size == (300, 400) # drawn for a screen turned on its side assert not (tmp_path / "paperpi.last-good.toml").exists() @@ -67,7 +67,7 @@ def test_render_from_config_with_color_off(tmp_path, mode, image_mode): GOOD.replace('type = "virtual"', f'type = "virtual"\nmode = "{mode}"\ncolor = false') ) out = tmp_path / "c.png" - assert render("--config", str(cfg), "--name", "Clock", "-o", str(out)) == 0 + assert render("--config", str(cfg), "--id", "Clock", "-o", str(out)) == 0 assert Image.open(out).mode == image_mode @@ -86,7 +86,7 @@ def test_render_from_config_with_color_off(tmp_path, mode, image_mode): "unknown layout 'huge'; choose from: time, time_date", ), (["basic_clock", "--time-limit", "0"], "--time-limit must be above 0"), - (["--config", "x.toml"], "give the plugin's name"), + (["--config", "x.toml"], "give the plugin's ID"), ], ) def test_render_usage_errors(args, message, capsys): @@ -97,17 +97,17 @@ def test_render_usage_errors(args, message, capsys): def test_render_config_problems(tmp_path, capsys): cfg = tmp_path / "paperpi.toml" cfg.write_text(GOOD + "hours = 13\n") - assert render("--config", str(cfg), "--name", "Clock") == 2 + assert render("--config", str(cfg), "--id", "Clock") == 2 err = capsys.readouterr().err - assert "no usable plugin named 'Clock'" in err + assert "no usable plugin with ID 'Clock'" in err cfg.write_text("config_version = 1\n") - assert render("--config", str(cfg), "--name", "Clock") == 2 + assert render("--config", str(cfg), "--id", "Clock") == 2 assert "a [display] part is needed" in capsys.readouterr().err def test_render_options_not_allowed_with_config(tmp_path, capsys): - args = ["--config", "x.toml", "--name", "Clock", "--size", "10x10"] + args = ["--config", "x.toml", "--id", "Clock", "--size", "10x10"] assert render(*args) == 2 assert "come from the config file" in capsys.readouterr().err @@ -175,14 +175,14 @@ def save(*args): monkeypatch.setattr(config, "_save_last_good", save) cfg = tmp_path / "paperpi.toml" cfg.write_text(GOOD) - assert render("--config", str(cfg), "--name", "Clock", "-o", str(tmp_path / "c.png")) == 0 + assert render("--config", str(cfg), "--id", "Clock", "-o", str(tmp_path / "c.png")) == 0 @pytest.mark.parametrize( ("args", "message"), [ - (["basic_clock", "--name", "Clock"], "--name only works together with --config"), - (["basic_clock", "--config", "x.toml", "--name", "C"], "not both"), + (["basic_clock", "--id", "Clock"], "--id only works together with --config"), + (["basic_clock", "--config", "x.toml", "--id", "C"], "not both"), ], ) def test_render_name_and_config_mistakes(args, message, capsys): @@ -210,7 +210,7 @@ def test_run_shows_plugins_reloads_and_stops(tmp_path, stop): 'config_version = 1\n[display]\ntype = "virtual"\nwidth = 200\nheight = 100\n' "splash_time = 0\n" # no minute-long splash screen first f'[web]\naddress = "127.0.0.1"\nport = {port}\n' - '[[plugin]]\nname = "Test"\ntype = "debugging"\nrefresh = 5\n' + '[[plugin]]\nid = "Test"\ntype = "debugging"\nrefresh = 5\n' ) state = tmp_path / "state" out = state / "screen" # the default for --out @@ -279,7 +279,7 @@ def test_run_shows_plugins_reloads_and_stops(tmp_path, stop): IT8951_CONFIG = ( 'config_version = 1\n[display]\ntype = "it8951"\nmodel = "9.7"\nvcom = -1.90\n' - 'max_refresh = 2\n{extra}[[plugin]]\nname = "Test"\ntype = "debugging"\n' + 'max_refresh = 2\n{extra}[[plugin]]\nid = "Test"\ntype = "debugging"\n' ) @@ -432,7 +432,7 @@ def test_run_cleans_every_plugin_folder_at_start(tmp_path, monkeypatch): cfg = tmp_path / "paperpi.toml" cfg.write_text( 'config_version = 1\n[display]\ntype = "virtual"\n' - '[[plugin]]\nname = "Off"\ntype = "basic_clock"\nenabled = false\n' + '[[plugin]]\nid = "Off"\ntype = "basic_clock"\nenabled = false\n' ) old = tmp_path / "plugins" / "off" / "old.json" old.parent.mkdir(parents=True) @@ -449,8 +449,8 @@ def test_run_counts_only_the_plugins_that_are_shown(tmp_path, monkeypatch, capsy 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' + '[[plugin]]\nid = "Clock"\ntype = "basic_clock"\n' + '[[plugin]]\nid = "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 @@ -461,13 +461,21 @@ 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' + '[[plugin]]\nid = "Weather"\ntype = "met_no"\n' + '[[plugin]]\nid = "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" + assert lines[1].split()[:7] == [ + "Weather", + "Weather", + "met_no", + "needs", + "lat,", + "lon,", + "email", + ] + assert lines[2].split()[:4] == ["Off", "Off", "met_no", "no"] # switched off: no "needs" def test_render_live_says_which_required_settings_are_missing(capsys): @@ -481,7 +489,7 @@ def test_list_shows_no_age_limit(tmp_path, capsys): cfg = tmp_path / "paperpi.toml" cfg.write_text( 'config_version = 1\n[display]\ntype = "virtual"\n' - '[[plugin]]\nname = "Photos"\ntype = "basic_clock"\nstorage_days = 0\n' + '[[plugin]]\nid = "Photos"\ntype = "basic_clock"\nstorage_days = 0\n' ) assert main(["list", "--config", str(cfg)]) == 0 assert capsys.readouterr().out.splitlines()[1].endswith("500 MB, no age limit") @@ -491,19 +499,27 @@ def test_list_shows_the_plugins_as_used(capsys): example = Path(__file__).parent.parent / "paperpi.example.toml" assert main(["list", "--config", str(example)]) == 0 lines = capsys.readouterr().out.splitlines() - header = ["name", "type", "on", "level", "display", "refresh", "layout", "storage"] + header = ["id", "name", "type", "on", "level", "display", "refresh", "layout", "storage"] 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 ") + row = "clock Clock basic_clock yes rotation 120 s 60 s time 500 MB, 30 d" + assert lines[1].split() == row.split() + assert lines[3].startswith("weather_rio Weather Rio ") # Not shown until the user fills in their email address. - assert lines[2].split()[:5] == ["Weather", "Berlin", "met_no", "needs", "email"] + assert lines[2].split()[:6] == [ + "weather_berlin", + "Weather", + "Berlin", + "met_no", + "needs", + "email", + ] def test_list_says_how_many_problems(tmp_path, capsys): cfg = tmp_path / "paperpi.toml" cfg.write_text( 'config_version = 1\n[display]\ntype = "virtual"\n' - '[[plugin]]\nname = "Clock"\ntype = "basic_clock"\nhours = 13\n' + '[[plugin]]\nid = "Clock"\ntype = "basic_clock"\nhours = 13\n' ) assert main(["list", "--config", str(cfg)]) == 0 out = capsys.readouterr().out diff --git a/tests/test_config.py b/tests/test_config.py index c1b72b1..477c33f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -16,7 +16,7 @@ type = "virtual" [[plugin]] -name = "Clock" +id = "Clock" type = "basic_clock" """ @@ -44,7 +44,7 @@ def test_good_file_uses_defaults(): assert cfg.display.size == (1200, 825) assert cfg.display.screen_mode == ScreenMode.gray(16) (clock,) = cfg.plugins - assert clock.entry.name == "Clock" + assert clock.entry.id == "Clock" assert clock.entry.enabled assert clock.entry.level == "rotation" assert clock.entry.time_limit == limits.PLUGIN_UPDATE @@ -65,7 +65,7 @@ def test_settings_are_read(): mode = "7color" rotation = 90 [[plugin]] - name = "Clock" + id = "Clock" type = "basic_clock" enabled = false level = "interrupt" @@ -94,8 +94,8 @@ def test_web_part_with_a_password(): def test_same_plugin_type_twice_with_different_names(): - cfg = parse(GOOD + '\n[[plugin]]\nname = "Clock 12"\ntype = "basic_clock"\nhours = 12\n') - assert [p.entry.name for p in cfg.plugins] == ["Clock", "Clock 12"] + cfg = parse(GOOD + '\n[[plugin]]\nid = "Clock_12"\ntype = "basic_clock"\nhours = 12\n') + assert [p.entry.id for p in cfg.plugins] == ["Clock", "Clock_12"] @pytest.mark.parametrize( @@ -256,7 +256,7 @@ def test_a_virtual_screen_ignores_model_and_vcom(): def test_plugin_problems_are_not_checked_while_the_file_is_wrong(): - messages = errors_of('config_version = 1\n[[plugin]]\nname = "x"\ntype = "nope"\n') + messages = errors_of('config_version = 1\n[[plugin]]\nid = "x"\ntype = "nope"\n') assert len(messages) == 1 @@ -265,9 +265,9 @@ def test_plugin_problems_are_not_checked_while_the_file_is_wrong(): def test_wrong_plugin_value_leaves_only_that_plugin_out(): cfg = parse( - GOOD + '\n[[plugin]]\nname = "Bad"\ntype = "basic_clock"\nhours = 13\n', "paperpi.toml" + GOOD + '\n[[plugin]]\nid = "Bad"\ntype = "basic_clock"\nhours = 13\n', "paperpi.toml" ) - assert [p.entry.name for p in cfg.plugins] == ["Clock"] + assert [p.entry.id for p in cfg.plugins] == ["Clock"] assert problems(cfg) == [ "paperpi.toml line 13 [[plugin]] 'Bad': hours: Input should be 12 or 24 (got 13)" ] @@ -284,11 +284,11 @@ def test_unknown_plugin_type_suggests_a_name(): ) -def test_missing_name_and_type(): +def test_missing_id_and_type(): cfg = parse("config_version = 1\n[display]\ntype = 'virtual'\n[[plugin]]\nlevel = 'alert'\n") assert cfg.plugins == [] assert problems(cfg) == [ - "config line 4 [[plugin]] 1: name: required setting is missing", + "config line 4 [[plugin]] 1: id: required setting is missing", "config line 4 [[plugin]] 1: type: required setting is missing", ] @@ -314,18 +314,36 @@ def test_wrong_time_limit(value): assert "time_limit:" in problems(cfg)[0] -@pytest.mark.parametrize("other", ["Clock", "clock", "CLOCK!"]) -def test_names_must_be_different(other): - cfg = parse(GOOD + f'\n[[plugin]]\nname = "{other}"\ntype = "basic_clock"\n') - assert [p.entry.name for p in cfg.plugins] == ["Clock"] +@pytest.mark.parametrize("other", ["Clock", "clock", "CLOCK"]) +def test_ids_must_be_different(other): + cfg = parse(GOOD + f'\n[[plugin]]\nid = "{other}"\ntype = "basic_clock"\n') + assert [p.entry.id for p in cfg.plugins] == ["Clock"] assert problems(cfg) == [ - f"config line 11 [[plugin]] '{other}': name '{other}' is already used by the plugin " - "block at line 6; names must be different (also ignoring capitals and punctuation)" + f"config line 11 [[plugin]] '{other}': id '{other}' is already used by the plugin " + "block at line 6; IDs must be different (capitals, - and _ don't count)" + ] + + +def test_id_holds_only_letters_digits_and_dashes(): + cfg = parse(GOOD.replace('id = "Clock"', 'id = "Weather Berlin"')) + assert cfg.plugins == [] + assert problems(cfg) == [ + "config line 7 [[plugin]] 'Weather Berlin': id: Value error, must hold only letters, " + "digits, _ and - (e.g. weather_berlin) (got 'Weather Berlin')" + ] + + +def test_name_is_shown_and_the_id_without_one(): + cfg = parse(GOOD + '\n[[plugin]]\nid = "big"\nname = "Big clock"\ntype = "basic_clock"\n') + assert [p.entry.label for p in cfg.plugins] == ["Clock", "Big clock"] + assert [(r.id, r.name) for r in config.plugin_rows(cfg)] == [ + ("Clock", "Clock"), + ("big", "Big clock"), ] def test_single_brackets_for_plugin(): - cfg = parse('config_version = 1\n[display]\ntype = "virtual"\n[plugin]\nname = "x"\n') + cfg = parse('config_version = 1\n[display]\ntype = "virtual"\n[plugin]\nid = "x"\n') assert cfg.plugins == [] assert problems(cfg) == ["config line 4: write [[plugin]] (two brackets) above each plugin"] @@ -342,7 +360,7 @@ def test_unknown_settings_are_warnings_with_suggestions(): type = "virtual" rotaton = 90 [[plugin]] - name = "Clock" + id = "Clock" type = "basic_clock" houres = 12 dispaly_time = 30 @@ -445,7 +463,7 @@ def test_broken_file_uses_last_good_copy(files): cfg = load(path, state_dir=state) assert cfg.from_last_good assert cfg.text == GOOD # the text in use: the last good copy's - assert [p.entry.name for p in cfg.plugins] == ["Clock"] + assert [p.entry.id for p in cfg.plugins] == ["Clock"] assert problems(cfg) == [ "paperpi.toml line 2: not valid TOML: Expected ']' at the end of a table declaration " "(at line 2, column 9)", @@ -526,7 +544,7 @@ def test_strange_toml_is_an_error_not_a_crash(text): def test_too_many_plugin_blocks(): - block = '\n[[plugin]]\nname = "Clock {n}"\ntype = "basic_clock"\n' + block = '\n[[plugin]]\nid = "Clock_{n}"\ntype = "basic_clock"\n' text = GOOD + "".join(block.format(n=n) for n in range(limits.PLUGIN_BLOCKS + 5)) cfg = parse(text) assert len(cfg.plugins) == limits.PLUGIN_BLOCKS @@ -543,8 +561,8 @@ def load(plugin_type, package=None): monkeypatch.setattr(config.plugins, "load", load) monkeypatch.setattr(config.plugins, "available", lambda *a: ["basic_clock", "broken"]) - cfg = parse(GOOD + '\n[[plugin]]\nname = "Broken"\ntype = "broken"\n') - assert [p.entry.name for p in cfg.plugins] == ["Clock"] + cfg = parse(GOOD + '\n[[plugin]]\nid = "Broken"\ntype = "broken"\n') + assert [p.entry.id for p in cfg.plugins] == ["Clock"] assert problems(cfg) == [ "config line 12 [[plugin]] 'Broken': the plugin itself is broken: " "ModuleNotFoundError: No module named 'requests'" @@ -586,8 +604,8 @@ def test_long_values_are_shortened_in_messages(): def test_duplicate_name_is_found_even_if_the_first_block_is_broken(): - first = '[[plugin]]\nname = "Clock"\ntype = "basic_clock"\nhours = 13\n' - second = '\n[[plugin]]\nname = "Clock"\ntype = "basic_clock"\n' + first = '[[plugin]]\nid = "Clock"\ntype = "basic_clock"\nhours = 13\n' + second = '\n[[plugin]]\nid = "Clock"\ntype = "basic_clock"\n' cfg = parse('config_version = 1\n[display]\ntype = "virtual"\n' + first + second) assert cfg.plugins == [] assert len(problems(cfg, "error")) == 2 @@ -599,7 +617,7 @@ def test_line_numbers_after_a_multi_line_string(): [display] type = "virtual" [[plugin]] - name = "Clock" + id = "Clock" type = "basic_clock" note = """ hours = 1 @@ -687,7 +705,7 @@ def test_hint_when_the_fallback_clock_is_switched_off(): [display] type = "virtual" [[plugin]] -name = "Weather" +id = "Weather" type = "met_no" lat = 52.52 lon = 13.40 diff --git a/tests/test_example.py b/tests/test_example.py index 916d13b..475d0f9 100644 --- a/tests/test_example.py +++ b/tests/test_example.py @@ -28,15 +28,15 @@ def test_example_file_works_as_it_is(): # 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'") + 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"), - ("Weather Rio", "met_no"), + assert [p.entry.id for p in loaded.plugins if p.shown] == ["clock"] + assert [(p.entry.id, p.entry.label, p.plugin.type) for p in loaded.plugins] == [ + ("clock", "Clock", "basic_clock"), + ("weather_berlin", "Weather Berlin", "met_no"), + ("weather_rio", "Weather Rio", "met_no"), ] - assert loaded.plugin("Weather Rio").settings.place == "Rio" + assert loaded.plugin("weather_rio").settings.place == "Rio" def uncomment(text): @@ -60,8 +60,8 @@ def test_every_default_shown_is_a_valid_setting(plugin_type): assert found.refresh == plugin.refresh assert found.layout == plugin.default_layout assert (found.storage_mb, found.storage_days) == (plugin.storage_mb, plugin.storage_days) - skip = {"name", "type", "refresh", "layout", "storage_mb", "storage_days"} - default = PluginEntry(name="Test", type=plugin_type) + skip = {"id", "type", "refresh", "layout", "storage_mb", "storage_days"} + default = PluginEntry(id="Test", type=plugin_type) assert found.entry.model_dump(exclude=skip) == default.model_dump(exclude=skip) @@ -83,27 +83,28 @@ def test_block_lists_every_setting_of_the_plugin(plugin_type): assert "# refresh =" in block and "# layout =" in block assert "display_time" not in block # the other shared settings only with shared=True full = plugin_block(plugin, "Test", shared=True) - for key in set(PluginEntry.model_fields) - {"name", "type"}: + for key in set(PluginEntry.model_fields) - {"id", "type"}: assert re.search(rf"^# {key} =", full, re.MULTILINE), key def test_block_sets_the_values_given_and_says_what_each_setting_is(): clock = plugins.load("basic_clock") - block = plugin_block(clock, 'Clock "big"', {"hours": 12, "refresh": 120.0, "layout": None}) - assert 'name = "Clock \\"big\\""' in block + values = {"name": 'Clock "big"', "hours": 12, "refresh": 120.0, "layout": None} + block = plugin_block(clock, "big", values) + assert '[[plugin]]\nid = "big"\nname = "Clock \\"big\\""\ntype = "basic_clock"\n' in block assert "\n# 12-hour (3:45 PM) or 24-hour (15:45) clock\nhours = 12\n" in block assert "\nrefresh = 120\n" in block # 120, not 120.0 assert "# refresh =" not in block # set, so not repeated as a comment assert '# layout = "time"' in block # None: not set, shown with its default - assert config.parse(HEAD + block).plugin('Clock "big"').settings.hours == 12 + assert config.parse(HEAD + block).plugin("big").settings.hours == 12 @pytest.mark.parametrize( ("values", "message"), [ ({"hours": 13}, "Input should be 12 or 24"), - ({"houres": 12}, "basic_clock has no setting 'houres'; it has: hours, enabled"), - ({"name": "Other"}, "has no setting 'name'"), + ({"houres": 12}, "basic_clock has no setting 'houres'; it has: hours, name, enabled"), + ({"id": "Other"}, "has no setting 'id'"), ({"display_time": -1}, "greater than 0"), ({"layout": "nope"}, "basic_clock has no layout 'nope'"), ], @@ -116,17 +117,21 @@ def test_block_refuses_values_the_plugin_would_not_accept(values, message): def test_block_refuses_what_paperpi_could_not_read_back(): # tomlkit writes the control character ESC in a form Python's TOML reader doesn't know. with pytest.raises(ValueError, match="control characters"): - plugin_block(plugins.load("basic_clock"), "B\x1bad") + plugin_block(plugins.load("basic_clock"), "b", {"name": "B\x1bad"}) # Characters Python counts as line breaks, which the web interface's line counting skips. for name in ["a\u2028b", "a\u2029b", "a\x85b"]: with pytest.raises(ValueError, match="control characters"): - plugin_block(plugins.load("basic_clock"), name) + plugin_block(plugins.load("basic_clock"), "b", {"name": name}) + with pytest.raises(ValueError, match="only letters, digits"): + plugin_block(plugins.load("basic_clock"), "B\x1bad") with pytest.raises(ValueError, match="reads it back"): plugin_block(plugins.load("met_no"), "Weather", {"place": "B\x1bad"}) def test_config_file_refuses_control_characters_in_names(): - loaded = config.parse(HEAD + '[[plugin]]\nname = "A\\u001b[31m"\ntype = "basic_clock"\n') + loaded = config.parse( + HEAD + '[[plugin]]\nid = "a"\nname = "A\\u001b[31m"\ntype = "basic_clock"\n' + ) assert loaded.plugins == [] assert "control characters" in loaded.problems[0].message @@ -184,16 +189,20 @@ def test_choices_are_listed_when_the_help_does_not_name_them(): def test_plugin_rows(): text = ( HEAD - + '[[plugin]]\nname = "Clock"\ntype = "basic_clock"\n' - + '[[plugin]]\nname = "Big"\ntype = "basic_clock"\nenabled = false\nrefresh = 300\n' + + '[[plugin]]\nid = "Clock"\ntype = "basic_clock"\n' + + '[[plugin]]\nid = "Big"\ntype = "basic_clock"\nenabled = false\nrefresh = 300\n' + 'layout = "time_date"\ndisplay_time = 90\nlevel = "interrupt"\n' + "storage_mb = 20000\nstorage_days = 0\n" - + '[[plugin]]\nname = "Broken"\ntype = "basic_clock"\nhours = 13\n' + + '[[plugin]]\nid = "Broken"\ntype = "basic_clock"\nhours = 13\n' ) rows = config.plugin_rows(config.parse(text)) assert 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), + config.PluginRow( + "Clock", "Clock", "basic_clock", True, "rotation", 120, 60, "time", 500, 30 + ), + config.PluginRow( + "Big", "Big", "basic_clock", False, "interrupt", 90, 300, "time_date", 20000, 0 + ), ] diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index bdd312e..fbb8096 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -218,19 +218,19 @@ def make_config(*blocks, display=""): def rotation(name, display_time=100, refresh=30): - return f'name = "{name}"\nlevel = "rotation"\ndisplay_time = {display_time}\n' + ( + return f'id = "{name}"\nlevel = "rotation"\ndisplay_time = {display_time}\n' + ( f"refresh = {refresh}" ) def interrupt(name, refresh=5): - return f'name = "{name}"\nlevel = "interrupt"\nrefresh = {refresh}\ndisplay_time = 100' + return f'id = "{name}"\nlevel = "interrupt"\nrefresh = {refresh}\ndisplay_time = 100' def alert(name, refresh=10, **extra): """An alert plugin block; ``extra`` are more settings, e.g. ``alert_reminder=300``.""" lines = "".join(f"\n{key} = {value}" for key, value in extra.items()) - return f'name = "{name}"\nlevel = "alert"\nrefresh = {refresh}{lines}' + return f'id = "{name}"\nlevel = "alert"\nrefresh = {refresh}{lines}' def between(start, end, inside, outside="nothing"): @@ -519,7 +519,7 @@ def test_default_is_only_updated_when_the_count_changes(tmp_path): def test_default_block_in_the_config_is_used_and_not_rotated(tmp_path): - sim = Sim(tmp_path, rotation("a"), 'name = "fallback"\ntype = "default"') + sim = Sim(tmp_path, rotation("a"), 'id = "fallback"\ntype = "default"') sim.plan(a=lambda t: "A" if t < 50 else "fail") sim.run(until=200) assert sim.shown == ["A", "default 1/1"] @@ -832,7 +832,7 @@ def test_splash_comes_back_when_every_plugin_is_switched_off(tmp_path): def test_on_the_minute_plugin_updates_just_after_the_minute_changes(tmp_path): - sim = Sim(tmp_path, 'name = "clock"\ntype = "basic_clock"\nlevel = "rotation"') + sim = Sim(tmp_path, 'id = "clock"\ntype = "basic_clock"\nlevel = "rotation"') sim.clock.start = datetime(2026, 10, 5, 10, 0, 20) sim.run(until=200) # Started at 10:00:20; then at 10:01:01, 10:02:01 and 10:03:01 (each takes 1 s). @@ -1012,7 +1012,7 @@ def test_switching_type_on_reload_keeps_the_driver_settings_until_the_next_start sim.run(until=10) # E.g. type = "virtual" for a test, and vcom removed: nothing applies before the next start. text = 'config_version = 1\n[display]\ntype = "it8951"\nmodel = "9.7"\nvcom = -2.0\n' - new = config.parse(text + '[[plugin]]\nname = "a"\ntype = "debugging"\n') + new = config.parse(text + '[[plugin]]\nid = "a"\ntype = "debugging"\n') sim.next_config = make_config(rotation("a")) sim.next_config.display = new.display sim.scheduler.reload() diff --git a/tests/test_storage.py b/tests/test_storage.py index 0f8cca0..4555012 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -85,7 +85,7 @@ def test_time_limit_stops_the_clean_up(tmp_path, caplog): def test_clean_all_also_cleans_plugins_that_are_switched_off(tmp_path): loaded = config.parse( 'config_version = 1\n[display]\ntype = "virtual"\n' - '[[plugin]]\nname = "Off"\ntype = "basic_clock"\nenabled = false\n' + '[[plugin]]\nid = "Off"\ntype = "basic_clock"\nenabled = false\n' ) old = make(tmp_path / "plugins" / "off", "old.json", 10, 400) storage.clean_all(loaded.plugins, tmp_path) @@ -125,7 +125,7 @@ def broken(path): def test_storage_settings_are_checked(setting, ok): loaded = config.parse( 'config_version = 1\n[display]\ntype = "virtual"\n' - f'[[plugin]]\nname = "Clock"\ntype = "basic_clock"\n{setting}\n' + f'[[plugin]]\nid = "Clock"\ntype = "basic_clock"\n{setting}\n' ) assert (not loaded.errors) is ok @@ -137,10 +137,10 @@ def test_setting_wins_over_the_plugin_suggestion(): from paperpi.config import PluginConfig plugin = replace(plugins.load("basic_clock"), storage_mb=20_000, storage_days=0) - entry = PluginEntry(name="Photos", type="basic_clock") + entry = PluginEntry(id="Photos", type="basic_clock") found = PluginConfig(entry, plugin.settings(), plugin) assert (found.storage_mb, found.storage_days) == (20_000, 0) - entry = PluginEntry(name="Photos", type="basic_clock", storage_mb=100, storage_days=7) + entry = PluginEntry(id="Photos", type="basic_clock", storage_mb=100, storage_days=7) found = PluginConfig(entry, plugin.settings(), plugin) assert (found.storage_mb, found.storage_days) == (100, 7) @@ -238,8 +238,8 @@ def test_folders_no_block_uses_are_named_but_not_cleaned(tmp_path, caplog): def test_clean_all_uses_each_blocks_own_values(tmp_path): loaded = config.parse( 'config_version = 1\n[display]\ntype = "virtual"\n' - '[[plugin]]\nname = "Keep"\ntype = "basic_clock"\nenabled = false\nstorage_days = 0\n' - '[[plugin]]\nname = "Small"\ntype = "basic_clock"\nstorage_mb = 1\n' + '[[plugin]]\nid = "Keep"\ntype = "basic_clock"\nenabled = false\nstorage_days = 0\n' + '[[plugin]]\nid = "Small"\ntype = "basic_clock"\nstorage_mb = 1\n' ) kept = make(tmp_path / "plugins" / "keep", "ancient.json", 10, 400) for name, days in [("a", 2), ("b", 1)]: @@ -337,7 +337,7 @@ def test_second_low_disk_period_is_warned_about_again(caplog): def test_highest_storage_settings(setting, ok): loaded = config.parse( 'config_version = 1\n[display]\ntype = "virtual"\n' - f'[[plugin]]\nname = "Clock"\ntype = "basic_clock"\n{setting}\n' + f'[[plugin]]\nid = "Clock"\ntype = "basic_clock"\n{setting}\n' ) assert (not loaded.errors) is ok diff --git a/tests/test_web.py b/tests/test_web.py index 9efd2be..4e34cdd 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -18,7 +18,7 @@ "# my screen\n" '[display]\ntype = "virtual"\n' "[[plugin]]\n" - 'name = "Clock"\ntype = "basic_clock" # the first plugin\n' + 'id = "Clock"\ntype = "basic_clock" # the first plugin\n' ) diff --git a/tests/test_web_config_file.py b/tests/test_web_config_file.py index d5f6baa..c493b0f 100644 --- a/tests/test_web_config_file.py +++ b/tests/test_web_config_file.py @@ -13,13 +13,13 @@ # the clock in the kitchen [[plugin]] -name = "Clock" +id = "Clock" type = "basic_clock" # refresh = 60 # words [[plugin]] -name = "Words" +id = "Words" type = "word_clock" enabled = true # for now [plugin.extra] @@ -31,7 +31,7 @@ def names(text): - return [b["name"] for b in tomllib.loads(text).get("plugin", [])] + return [b["id"] for b in tomllib.loads(text).get("plugin", [])] def test_blocks_start_at_their_comments(): @@ -47,7 +47,7 @@ def test_blocks_start_at_their_comments(): def test_moving_a_block_takes_its_comments_and_parts_along(): moved = config_file.move(CONFIG, 1, "Words", -1) assert names(moved) == ["Words", "Clock"] - assert moved.index("# words") < moved.index('name = "Words"') < moved.index("[plugin.extra]") + assert moved.index("# words") < moved.index('id = "Words"') < moved.index("[plugin.extra]") assert moved.index("[plugin.extra]") < moved.index("# the clock in the kitchen") assert tomllib.loads(moved)["plugin"][0]["extra"] == {"a": 1} assert tomllib.loads(moved)["web"] == {"port": 8081} @@ -91,7 +91,7 @@ def test_adding_a_block_after_the_last_plugin(): block = example.plugin_block(plugins.load("dec_binary_clock"), "Dots", {}) added = config_file.add(CONFIG, block) assert names(added) == ["Clock", "Words", "Dots"] - assert added.index('name = "Dots"') < added.index("[web]") + assert added.index('id = "Dots"') < added.index("[web]") assert tomllib.loads(added)["web"] == {"port": 8081} # A file without plugins: at the end. empty = 'config_version = 1\n[display]\ntype = "virtual"' @@ -110,7 +110,7 @@ def test_files_it_doesnt_understand_are_not_changed(): text = CONFIG.replace('type = "virtual"', 'type = "virtual"\nnote = """\n[[plugin]]\n"""') assert names(config_file.remove(text, 0, "Clock")) == ["Words"] # ... and a header written in another way is not found, so nothing is changed. - quoted = CONFIG.replace('[[plugin]]\nname = "Words"', '[["plugin"]]\nname = "Words"') + quoted = CONFIG.replace('[[plugin]]\nid = "Words"', '[["plugin"]]\nid = "Words"') with pytest.raises(EditError, match="config file itself"): config_file.move(quoted, 0, "Clock", 1) with pytest.raises(EditError, match="not valid TOML"): @@ -130,12 +130,8 @@ def test_saving_keeps_the_permissions(tmp_path): def test_comments_right_after_a_setting_stay_with_the_block_above(): - text = ( - '[[plugin]]\nname = "A"\ntype = "x"\n# refresh = 60\n[[plugin]]\nname = "B"\ntype = "y"\n' - ) - assert ( - config_file.remove(text, 1, "B") == '[[plugin]]\nname = "A"\ntype = "x"\n# refresh = 60\n' - ) + text = '[[plugin]]\nid = "A"\ntype = "x"\n# refresh = 60\n[[plugin]]\nid = "B"\ntype = "y"\n' + assert config_file.remove(text, 1, "B") == '[[plugin]]\nid = "A"\ntype = "x"\n# refresh = 60\n' def test_windows_line_endings_are_kept(): @@ -154,7 +150,7 @@ def test_windows_line_endings_are_kept(): def test_a_file_without_a_last_line_break(): text = CONFIG.removesuffix("\n") assert tomllib.loads(config_file.move(text, 1, "Words", -1))["web"] == {"port": 8081} - last = '[[plugin]]\nname = "A"\ntype = "x"' + last = '[[plugin]]\nid = "A"\ntype = "x"' off = config_file.set_enabled(last, 0, "A", False) assert tomllib.loads(off)["plugin"][0]["enabled"] is False @@ -166,13 +162,13 @@ def test_other_parts_between_blocks_stay_where_they_are(): moved = config_file.move(text, 1, "Words", -1) assert names(moved) == ["Words", "Clock"] # The blocks swap places; [web] stays between them. - assert moved.index('name = "Words"') < moved.index("[web]") < moved.index('name = "Clock"') + assert moved.index('id = "Words"') < moved.index("[web]") < moved.index('id = "Clock"') assert tomllib.loads(moved)["web"] == {"port": 8081} def test_enabled_in_a_part_or_a_string_is_not_the_switch(): text = ( - '[[plugin]]\nname = "A"\nnote = """\nenabled = true\n"""\ntype = "x"\n' + '[[plugin]]\nid = "A"\nnote = """\nenabled = true\n"""\ntype = "x"\n' "[plugin.extra]\nenabled = true\n" ) off = config_file.set_enabled(text, 0, "A", False) @@ -181,16 +177,16 @@ def test_enabled_in_a_part_or_a_string_is_not_the_switch(): assert data["note"] == "enabled = true\n" assert config_file.set_enabled(off, 0, "A", True) == text # Only a "type" inside a string: the new line goes right below [[plugin]]. - hidden = '[[plugin]]\nnote = """\ntype = "y"\n"""\nname = "B"\n' + hidden = '[[plugin]]\nnote = """\ntype = "y"\n"""\nid = "B"\n' assert tomllib.loads(config_file.set_enabled(hidden, 0, "B", False))["plugin"][0] == { "note": 'type = "y"\n', - "name": "B", + "id": "B", "enabled": False, } def test_unicode_line_breaks_inside_a_value_are_not_line_breaks(): - text = CONFIG.replace('name = "Words"', 'name = "Words"\nplace = "aโ€จb\x85c"') + text = CONFIG.replace('id = "Words"', 'id = "Words"\nplace = "aโ€จb\x85c"') assert names(config_file.remove(text, 0, "Clock")) == ["Words"] off = config_file.set_enabled(text, 1, "Words", False) assert tomllib.loads(off)["plugin"][1]["place"] == "aโ€จb\x85c" diff --git a/tests/test_web_password.py b/tests/test_web_password.py index 38ae864..638684a 100644 --- a/tests/test_web_password.py +++ b/tests/test_web_password.py @@ -13,7 +13,7 @@ "# my screen\n" '[display]\ntype = "virtual"\n' "[[plugin]]\n" - 'name = "Clock"\ntype = "basic_clock" # the first plugin\n' + 'id = "Clock"\ntype = "basic_clock" # the first plugin\n' ) @@ -129,7 +129,7 @@ def test_saving_into_a_web_part_written_by_hand(cfg): loaded = config.parse(cfg.read_text()) assert (loaded.web.port, loaded.web.password_hash) == (9000, "scrypt:one") assert "port = 9000 # mine" in cfg.read_text() - assert [p.entry.name for p in loaded.plugins] == ["Clock"] + assert [p.entry.id for p in loaded.plugins] == ["Clock"] @pytest.mark.parametrize( diff --git a/tests/test_web_plugins.py b/tests/test_web_plugins.py index 6c3fee9..f86a65e 100644 --- a/tests/test_web_plugins.py +++ b/tests/test_web_plugins.py @@ -1,3 +1,4 @@ +import re import tomllib import urllib.request @@ -8,6 +9,7 @@ from paperpi.plugin import PluginDefinitionError from paperpi.web import auth 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.plugins import PluginEditor @@ -18,18 +20,18 @@ # the clock in the kitchen [[plugin]] -name = "Clock" +id = "Clock" type = "basic_clock" [[plugin]] -name = "Weather" +id = "Weather" type = "met_no" lat = 52.52 lon = 13.40 enabled = false [[plugin]] -name = "Broken" +id = "Broken" type = "basic_clock" refresh = 1 """ @@ -68,7 +70,7 @@ def blocks(cfg): def names(cfg): - return [b["name"] for b in blocks(cfg)] + return [b["id"] for b in blocks(cfg)] def test_active_plugins_shows_every_block_with_its_state(client): @@ -85,37 +87,37 @@ def test_active_plugins_shows_every_block_with_its_state(client): def test_moving_a_plugin_saves_and_applies_at_once(client, cfg, reloads): - response = client.post("/plugins/0/move", data={"name": "Clock", "step": "down"}) + response = client.post("/plugins/0/move", data={"id": "Clock", "step": "down"}) assert response.status_code == 303 assert response.headers["location"].startswith("/plugins?done=saved") assert names(cfg) == ["Weather", "Clock", "Broken"] - assert '# the clock in the kitchen\n[[plugin]]\nname = "Clock"' in cfg.read_text() + assert '# the clock in the kitchen\n[[plugin]]\nid = "Clock"' in cfg.read_text() assert reloads == [1] page = client.get(response.headers["location"]) assert "Saved. PaperPi applies the change now." in page.text assert "hand edits" not in page.text - client.post("/plugins/1/move", data={"name": "Clock", "step": "up"}) + client.post("/plugins/1/move", data={"id": "Clock", "step": "up"}) assert names(cfg) == ["Clock", "Weather", "Broken"] def test_switching_off_and_on(client, cfg): - client.post("/plugins/0/enabled", data={"name": "Clock", "on": "0"}) + client.post("/plugins/0/enabled", data={"id": "Clock", "on": "0"}) assert blocks(cfg)[0]["enabled"] is False - client.post("/plugins/0/enabled", data={"name": "Clock", "on": "1"}) + client.post("/plugins/0/enabled", data={"id": "Clock", "on": "1"}) assert "enabled" not in blocks(cfg)[0] def test_a_plugin_with_missing_settings_cant_be_switched_on(client, cfg, reloads): - response = client.post("/plugins/1/enabled", data={"name": "Weather", "on": "1"}) + response = client.post("/plugins/1/enabled", data={"id": "Weather", "on": "1"}) assert response.status_code == 409 assert "Fill in these settings first: email." in response.text assert cfg.read_text() == CONFIG and not reloads def test_removing_asks_first(client, cfg): - page = client.get("/plugins/0/remove", params={"name": "Clock"}) + page = client.get("/plugins/0/remove", params={"id": "Clock"}) assert "Remove Clock?" in page.text and cfg.read_text() == CONFIG - response = client.post("/plugins/0/remove", data={"name": "Clock"}) + response = client.post("/plugins/0/remove", data={"id": "Clock"}) assert response.headers["location"] == "/plugins?done=removed" assert names(cfg) == ["Weather", "Broken"] assert "kitchen" not in cfg.read_text() @@ -124,21 +126,21 @@ def test_removing_asks_first(client, cfg): def test_a_list_changed_by_hand_meanwhile_is_not_changed(client, cfg, reloads): # The page showed Clock first, but it was removed by hand since then. - cfg.write_text(CONFIG.replace('name = "Clock"', 'name = "Kitchen"')) - response = client.post("/plugins/0/remove", data={"name": "Clock"}) + cfg.write_text(CONFIG.replace('id = "Clock"', 'id = "Kitchen"')) + response = client.post("/plugins/0/remove", data={"id": "Clock"}) assert response.status_code == 409 and "changed after this page was opened" in response.text assert names(cfg) == ["Kitchen", "Weather", "Broken"] and not reloads def test_hand_edits_are_kept_and_the_page_says_so(client, cfg, editor): - cfg.write_text(CONFIG + '\n[[plugin]]\nname = "Words"\ntype = "word_clock"\n') - response = client.post("/plugins/0/move", data={"name": "Clock", "step": "down"}) + cfg.write_text(CONFIG + '\n[[plugin]]\nid = "Words"\ntype = "word_clock"\n') + response = client.post("/plugins/0/move", data={"id": "Clock", "step": "down"}) assert "hand=1" in response.headers["location"] assert names(cfg) == ["Weather", "Clock", "Broken", "Words"] page = client.get(response.headers["location"]) assert "Your hand edits to the config file were applied too." in page.text # The next change, before PaperPi reloaded: the file holds only the web's own change. - response = client.post("/plugins/1/move", data={"name": "Clock", "step": "up"}) + response = client.post("/plugins/1/move", data={"id": "Clock", "step": "up"}) assert "hand=1" not in response.headers["location"] @@ -153,29 +155,49 @@ def test_adding_a_plugin(client, cfg, reloads): form = client.get("/library/word_clock").text assert 'value="Word clock"' in form # a name that is not used yet response = client.post("/library/word_clock", data={"name": " Words "}) - assert response.headers["location"] == "/plugins?done=added&name=Words" - assert blocks(cfg)[-1] == {"name": "Words", "type": "word_clock"} + 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 reloads == [1] # The next one gets another name. client.post("/library/word_clock", data={"name": "Word clock"}) assert 'value="Word clock 2"' in client.get("/library/word_clock").text +def test_names_may_be_empty_or_the_same(client, cfg): + client.post("/library/word_clock", data={"name": ""}) + client.post("/library/word_clock", data={"name": "Clock"}) + first, second = blocks(cfg)[-2:] + assert "name" not in first and second["name"] == "Clock" + assert first["id"] != second["id"] + page = client.get("/plugins").text + assert f"{first['id']}" in page # without a name: the ID + assert f"word_clock ยท {second['id']}" in page # the ID tells the two Clocks apart + + +def test_a_new_id_is_never_one_in_use(client, cfg, monkeypatch): + made = iter(["CLOCK", "Weather", "word_clock-1"]) # the first two are used + monkeypatch.setattr(web_plugins, "new_id", lambda plugin_type: next(made)) + client.post("/library/word_clock", data={"name": "Words"}) + assert blocks(cfg)[-1]["id"] == "word_clock-1" + + def test_a_plugin_with_required_settings_is_added_switched_off(client, cfg): form = client.get("/library/met_no").text assert "real email address" in form and "added switched off" in form client.post("/library/met_no", data={"name": "Weather Rio"}) - assert blocks(cfg)[-1] == {"name": "Weather Rio", "type": "met_no", "enabled": False} - assert config.parse(cfg.read_text()).plugin("Weather Rio").missing == ("lat", "lon", "email") + added = blocks(cfg)[-1] + assert added == {"id": added["id"], "name": "Weather Rio", "type": "met_no", "enabled": False} + assert config.parse(cfg.read_text()).plugin(added["id"]).missing == ("lat", "lon", "email") @pytest.mark.parametrize( ("name", "problem"), [ - ("", "Give the plugin a name."), ("x" * 101, "at most 100 characters"), ("a\tb", "must not hold control characters"), - ("clock!", "Another plugin already has this name"), ], ) def test_names_that_cant_be_used(client, cfg, name, problem): @@ -193,7 +215,7 @@ def test_a_broken_file_is_shown_not_changed(client, cfg): cfg.write_text("[[plugin]\n") page = client.get("/plugins") assert page.status_code == 500 and "The config file is not valid TOML" in page.text - assert client.post("/plugins/0/move", data={"name": "", "step": "up"}).status_code == 409 + assert client.post("/plugins/0/move", data={"id": "", "step": "up"}).status_code == 409 assert cfg.read_text() == "[[plugin]\n" @@ -202,7 +224,7 @@ def test_the_plugin_pages_need_a_log_in(cfg): client = TestClient(create_app(paperpi_auth), follow_redirects=False, base_url=PI) for path in ["/plugins", "/library", "/library/basic_clock", "/plugins/0/remove"]: assert client.get(path).headers["location"] == "/setup" - assert client.post("/plugins/0/remove", data={"name": "Clock"}).status_code == 303 + assert client.post("/plugins/0/remove", data={"id": "Clock"}).status_code == 303 assert cfg.read_text() == CONFIG @@ -218,7 +240,7 @@ def test_a_change_in_the_running_web_interface_reloads_paperpi(cfg, reloads): try: request = urllib.request.Request( f"http://127.0.0.1:{web.port}/plugins/0/enabled", - data=b"name=Clock&on=0", + data=b"id=Clock&on=0", headers={"Origin": f"http://127.0.0.1:{web.port}"}, ) with urllib.request.urlopen(request, timeout=30) as page: @@ -280,7 +302,7 @@ def load(plugin_type, *args): def test_a_repeated_name_shows_the_second_block_as_not_used(client, cfg): - cfg.write_text(CONFIG + '\n[[plugin]]\nname = "Clock"\ntype = "word_clock"\n') + cfg.write_text(CONFIG + '\n[[plugin]]\nid = "Clock"\ntype = "word_clock"\n') page = client.get("/plugins").text assert page.count("Not used: has errors") == 2 # Broken and the second Clock assert "is already used by the plugin block" in page @@ -291,16 +313,16 @@ def test_a_change_is_refused_while_paperpi_cant_use_the_file(client, cfg, reload cfg.write_text(broken) page = client.get("/plugins") assert "unknown screen type" in page.text and "Unknown" in page.text - response = client.post("/plugins/0/move", data={"name": "Clock", "step": "down"}) + response = client.post("/plugins/0/move", data={"id": "Clock", "step": "down"}) assert response.status_code == 409 and "must be fixed first" in response.text assert cfg.read_text() == broken and not reloads def test_every_change_needs_a_log_in_and_a_form_from_paperpi(cfg): forms = { - "/plugins/0/move": {"name": "Clock", "step": "down"}, - "/plugins/0/enabled": {"name": "Clock", "on": "0"}, - "/plugins/0/remove": {"name": "Clock"}, + "/plugins/0/move": {"id": "Clock", "step": "down"}, + "/plugins/0/enabled": {"id": "Clock", "on": "0"}, + "/plugins/0/remove": {"id": "Clock"}, "/library/word_clock": {"name": "Words"}, } stored = web_password.hash_password("correct horse") @@ -320,17 +342,19 @@ def test_every_change_needs_a_log_in_and_a_form_from_paperpi(cfg): def test_odd_requests(client, cfg): - response = client.post("/plugins/0/move", data={"name": "Clock", "step": "sideways"}) + response = client.post("/plugins/0/move", data={"id": "Clock", "step": "sideways"}) assert response.status_code == 400 and cfg.read_text() == CONFIG - assert client.get("/plugins/0/remove", params={"name": "Kitchen"}).status_code == 409 - # Text in the address is only shown when it names a plugin in the list. + assert client.get("/plugins/0/remove", params={"id": "Kitchen"}).status_code == 409 + # Only the ID of a plugin in the list is taken from the address, not text to show. page = client.get("/plugins", params={"done": "added", "name": "Your password was reset"}) assert "Your password was reset" not in page.text and "Plugin added." in page.text - page = client.get("/plugins", params={"done": "added", "name": "Clock"}) + page = client.get("/plugins", params={"done": "added", "id": "Your password was reset"}) + assert "Your password was reset" not in page.text and "Plugin added." in page.text + page = client.get("/plugins", params={"done": "added", "id": "Clock"}) assert "Added Clock." in page.text def test_the_file_keeps_its_permissions(client, cfg): cfg.chmod(0o640) - client.post("/plugins/0/move", data={"name": "Clock", "step": "down"}) + client.post("/plugins/0/move", data={"id": "Clock", "step": "down"}) assert cfg.stat().st_mode & 0o777 == 0o640 From 87d70ba3739ee6e113ea2b1b855e4007c3c72a3f Mon Sep 17 00:00:00 2001 From: txoof-bot <337660340+txoof-bot@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:49:37 +0200 Subject: [PATCH 2/4] Review fixes: id rules, built-in ids kept, docs and plugin READMEs Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/decisions/config-format.md | 2 +- docs/decisions/errors-and-time-limits.md | 2 +- docs/decisions/plugin-interface.md | 2 +- paperpi.example.toml | 3 +- src/paperpi/config.py | 25 +++++++-- src/paperpi/example.py | 8 +-- src/paperpi/plugin.py | 23 ++++++-- src/paperpi/plugins/basic_clock/README.md | 1 + src/paperpi/plugins/debugging/README.md | 3 +- .../plugins/dec_binary_clock/README.md | 1 + src/paperpi/plugins/met_no/README.md | 1 + src/paperpi/plugins/moon_phase/README.md | 3 +- src/paperpi/plugins/newyorker/README.md | 1 + src/paperpi/plugins/splash_screen/README.md | 1 + src/paperpi/plugins/system_info/README.md | 1 + src/paperpi/plugins/word_clock/README.md | 1 + src/paperpi/plugins/xkcd_comic/README.md | 1 + src/paperpi/scheduler.py | 14 ++--- src/paperpi/storage.py | 2 +- src/paperpi/web/plugins.py | 6 +-- src/paperpi/web/templates/add.html | 2 +- tests/test_config.py | 53 ++++++++++++++++++- tests/test_web_plugins.py | 11 ++-- 24 files changed, 133 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 6ca3373..d4d5a5d 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ 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 (`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. +`paperpi list` shows the plugins of a config file, one line each, in the order of the file: id, name (the id when it has none), 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 diff --git a/docs/decisions/config-format.md b/docs/decisions/config-format.md index 6fc6157..8f4acbb 100644 --- a/docs/decisions/config-format.md +++ b/docs/decisions/config-format.md @@ -68,7 +68,7 @@ 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 (`name`) 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-08 (M5 part 3a-0, issue #238), agreed with txoof:* each `[[plugin]]` block has a fixed **`id`** and an optional friendly **`name`**. -- `id` is required and unique (capitals, `-` and `_` don't count). It holds only letters, digits, `_` and `-`, at most 40 characters, so it is safe as a folder name and in the log. It names the plugin's storage folder, so it is not meant to change. The web interface makes one when it adds a plugin: the type and 8 random letters and digits, for example `met_no-3f9a1c2e`. By hand you may choose a readable one, such as `weather_berlin`. A block without an `id` is an error and is not shown; PaperPi never writes one into the file by itself. +- `id` is required and unique (capitals don't count, and `-` is the same as `_`). It holds only letters, digits, `_` and `-`, at most 40 characters, so it is safe as a folder name and in the log. It names the plugin's storage folder, so it is not meant to change. The web interface makes one when it adds a plugin: the type and 8 random letters and digits, for example `met_no-3f9a1c2e`. By hand you may choose a readable one, such as `weather_berlin`. A block without an `id` is an error and is not shown; PaperPi never adds a missing `id` to a block that is already in the file. - `name` is what the web interface shows ("Weather Berlin"). Any text up to 100 characters, without control characters; it does not have to be unique and can be changed at any time. Without one, the `id` is shown. - The other choices were renaming the plugin (its storage folder is named after it, so a rename leaves the folder behind or has to move it while the plugin may be updating) and a fixed name. v2 does not read v1 config files, so the key could be renamed freely (global decision 2026-10-07; a later tool to move a v1 `paperpi.ini` is issue #248). diff --git a/docs/decisions/errors-and-time-limits.md b/docs/decisions/errors-and-time-limits.md index 88ad6a7..4e44a42 100644 --- a/docs/decisions/errors-and-time-limits.md +++ b/docs/decisions/errors-and-time-limits.md @@ -84,7 +84,7 @@ As in `plugin-scheduling.md`: a failed update is skipped and retried at the plug - PaperPi applies them at start (to every plugin block, also the ones switched off; at most 30 s in all) and after an update of the plugin (at most once every 5 minutes per plugin), in the main process, at most about 10 s each (checked between files). When a folder can't be read fully in that time, or holds more than 100,000 files, old files are still removed, but not files for the size limit, because the oldest ones may be in the part not read; a warning says so. - "Changed" is the newer of the file's modification time and its status-change time (set by Linux when the file is made, renamed or copied in), so a copied file that kept an old date is not removed at once. A file that is only read keeps getting older: a plugin that must keep one (for example a login token) writes it again within `storage_days`, or suggests `storage_days = 0`. - Nothing outside the plugin's folder is ever removed: a plugin folder that is itself a link is not cleaned, links inside it are not followed or removed, a drive mounted inside it is skipped, and every step goes through open folder handles opened without following links, so a folder swapped for a link during the clean-up can't lead outside. The size counted is the space the files use on the disk. Empty subfolders are removed. Problems (a folder that can't be read, a file that can't be removed) are logged as one line per clean-up. -- Folders in `plugins/` that no `[[plugin]]` block uses (for example after a plugin was renamed) are not cleaned, because they may hold photos; they are named in the log at start, to be removed by hand. +- Folders in `plugins/` that no `[[plugin]]` block uses (for example after a plugin's `id` was changed) are not cleaned, because they may hold photos; they are named in the log at start, to be removed by hand. - No limit for all plugins together. Instead PaperPi keeps at least 2 GB free on the disk: below that, plugins are told (`low_disk`) not to save more, and a warning goes to the log when the disk becomes low, again at most every 10 minutes while it stays low, and once when 100 MB more is free again (so a value around 2 GB doesn't give two lines at every update) (the disk is checked before each update). PaperPi removes nothing else (agreed with txoof on 2026-10-06), because the space may be used by something outside PaperPi (`freeze-prevention.md`). Free disk is in the health report. ## Open questions diff --git a/docs/decisions/plugin-interface.md b/docs/decisions/plugin-interface.md index aecb684..3464a76 100644 --- a/docs/decisions/plugin-interface.md +++ b/docs/decisions/plugin-interface.md @@ -26,7 +26,7 @@ What v1 does badly: ### What a plugin is -A folder in `src/paperpi/plugins//` with: +A folder in `src/paperpi/plugins//` with: | Part | What it is | |---|---| diff --git a/paperpi.example.toml b/paperpi.example.toml index 6339160..d12babe 100644 --- a/paperpi.example.toml +++ b/paperpi.example.toml @@ -62,7 +62,8 @@ type = "virtual" # basic_clock: The time, and optionally the date, in large text. # Layouts (values for "layout"): time, time_date, small [[plugin]] -# Unique ID of this plugin: letters, digits, _ and -. It names the plugin's storage folder, so +# Required. Unique ID of this plugin: up to 40 letters, digits, _ and - (when checking that it is +# unique, capitals don't count and - is the same as _). It names the plugin's storage folder, so # changing it starts with an empty folder id = "clock" # Name shown in the web interface; leave it out to show the ID diff --git a/src/paperpi/config.py b/src/paperpi/config.py index fc7eb34..e529282 100644 --- a/src/paperpi/config.py +++ b/src/paperpi/config.py @@ -259,7 +259,7 @@ class PluginConfig: """One checked ``[[plugin]]`` block.""" entry: PluginEntry - """The shared settings: name, type, level, ...""" + """The shared settings: id, name, type, level, ...""" settings: PluginSettings """The plugin's own settings.""" plugin: Plugin @@ -321,9 +321,11 @@ def errors(self) -> list[Problem]: return [p for p in self.problems if p.level == "error"] def plugin(self, plugin_id: str) -> PluginConfig: - """The plugin block with ID ``plugin_id``. Raises ``KeyError`` if there is none.""" + """The plugin block with ID ``plugin_id`` (capitals don't count, and - is the same + as _, as when IDs are checked to be different). Raises ``KeyError`` if there is + none.""" for plugin in self.plugins: - if plugin.entry.id == plugin_id: + if folder_name(plugin.entry.id) == folder_name(plugin_id): return plugin raise KeyError(plugin_id) @@ -373,6 +375,11 @@ def plugin_rows(config: Config) -> list[PluginRow]: ] +#: The start of the IDs of PaperPi's own plugins (the fallback clock, ...), which a +#: ``[[plugin]]`` block can't use, so they always have their own storage folder. +BUILT_IN = "built-in" + + def folder_name(plugin_id: str) -> str: """The storage folder name for a plugin ID: ``"Weather_Berlin"`` -> ``weather-berlin``.""" return re.sub(r"[^a-z0-9]+", "-", plugin_id.casefold()).strip("-") or "plugin" @@ -696,7 +703,17 @@ def check_plugins(self, blocks: Any) -> list[PluginConfig]: self.add( "error", f"id {plugin_id!r} is already used by the plugin block{at}; " - "IDs must be different (capitals, - and _ don't count)", + "IDs must be different (capitals don't count, and - is the same as _)", + section, + "id", + f"[[plugin]] {plugin_id!r}", + ) + continue + if folder.startswith(BUILT_IN): + self.add( + "error", + f"id {plugin_id!r}: IDs starting with {BUILT_IN!r} are kept for " + "PaperPi's own plugins", section, "id", f"[[plugin]] {plugin_id!r}", diff --git a/src/paperpi/example.py b/src/paperpi/example.py index aa30bf1..4856e26 100644 --- a/src/paperpi/example.py +++ b/src/paperpi/example.py @@ -74,6 +74,10 @@ } +#: Settings of a ``[[plugin]]`` block that :func:`plugin_block` takes as its own arguments. +_FIXED = ("id", "type") + + def plugin_block( plugin: Plugin, plugin_id: str, values: Mapping[str, Any] | None = None, *, shared: bool = False ) -> str: @@ -139,10 +143,6 @@ def plugin_block( return text -#: Settings of a ``[[plugin]]`` block that :func:`plugin_block` takes as its own arguments. -_FIXED = ("id", "type") - - def display_part(values: Mapping[str, Any]) -> str: """The ``[display]`` part, with ``values`` set and every other setting as a comment.""" values = {k: v for k, v in values.items() if v is not None} diff --git a/src/paperpi/plugin.py b/src/paperpi/plugin.py index 0d4a350..a4852df 100644 --- a/src/paperpi/plugin.py +++ b/src/paperpi/plugin.py @@ -141,6 +141,9 @@ def is_set(value: Any) -> bool: #: The longest plugin ID (``id`` in a ``[[plugin]]`` block). ID_LENGTH = 40 +#: Characters a plugin's name may not hold: control characters, the ones Python counts +#: as line breaks, and the invisible ones that change the direction of the text after them. +_HIDDEN_CHARACTERS = r"[\x00-\x1f\x7f-\x9f\u2028\u2029\u200e\u200f\u202a-\u202e\u2066-\u2069]" class PluginEntry(BaseModel): @@ -155,8 +158,9 @@ class PluginEntry(BaseModel): id: str = Field( min_length=1, max_length=ID_LENGTH, - description="Unique ID of this plugin: letters, digits, _ and -. It names the plugin's " - "storage folder, so changing it starts with an empty folder", + description="Required. Unique ID of this plugin: up to 40 letters, digits, _ and - " + "(when checking that it is unique, capitals don't count and - is the same as _). " + "It names the plugin's storage folder, so changing it starts with an empty folder", ) name: str = Field( "", @@ -226,6 +230,8 @@ class PluginEntry(BaseModel): def _plain_id(cls, plugin_id: str) -> str: if not re.fullmatch(r"[A-Za-z0-9_-]*", plugin_id): raise ValueError("must hold only letters, digits, _ and - (e.g. weather_berlin)") + if plugin_id and not re.search(r"[A-Za-z0-9]", plugin_id): + raise ValueError("needs at least one letter or digit") return plugin_id @field_validator("name") @@ -233,15 +239,22 @@ def _plain_id(cls, plugin_id: str) -> str: def _no_control_characters(cls, name: str) -> str: # They can't be written back to the file reliably, and would change the terminal's # output in ``paperpi list``. - # Also the characters Python counts as line breaks (U+0085, U+2028, U+2029). - if re.search(r"[\x00-\x1f\x7f-\x9f\u2028\u2029]", name): + # Also the characters Python counts as line breaks, and the invisible ones that change + # the direction of the text after them (see _HIDDEN_CHARACTERS). + if re.search(_HIDDEN_CHARACTERS, name): raise ValueError("must not hold control characters (such as tab or new line)") return name @property def label(self) -> str: """What the web interface calls this plugin: its name, or its ID without one.""" - return self.name.strip() or self.id + return label(self.name, self.id) + + +def label(name: str, plugin_id: str) -> str: + """What a plugin is called on the web pages: ``name``, or ``plugin_id`` when the name is + empty (or only spaces).""" + return name.strip() or plugin_id #: Names a plugin may not use for its own settings. diff --git a/src/paperpi/plugins/basic_clock/README.md b/src/paperpi/plugins/basic_clock/README.md index a6a8357..db16535 100644 --- a/src/paperpi/plugins/basic_clock/README.md +++ b/src/paperpi/plugins/basic_clock/README.md @@ -22,6 +22,7 @@ In the config file (the rest of the file is shown in the main [README](../../../ ```toml [[plugin]] +id = "clock" name = "Clock" type = "basic_clock" layout = "time_date" diff --git a/src/paperpi/plugins/debugging/README.md b/src/paperpi/plugins/debugging/README.md index 71bbbde..9b1459c 100644 --- a/src/paperpi/plugins/debugging/README.md +++ b/src/paperpi/plugins/debugging/README.md @@ -2,7 +2,7 @@ A plugin for testing PaperPi itself. It can crash, hang, and switch between the states "nothing", "ready" and "alert" in a fixed pattern. It needs no network. -Every update adds 1 to a count kept in the file `count` in its storage folder: `plugins//` in PaperPi's state folder (`/var/lib/paperpi` unless `paperpi run --state-dir` says otherwise). The folder name is made from the plugin's name, e.g. `test-alert` for "Test alert". The count decides what the update does, so the same settings always give the same pattern. +Every update adds 1 to a count kept in the file `count` in its storage folder: `plugins//` in PaperPi's state folder (`/var/lib/paperpi` unless `paperpi run --state-dir` says otherwise). The folder name is made from the plugin's `id`, e.g. `test-alert` for `test_alert`. The count decides what the update does, so the same settings always give the same pattern. ## Layouts @@ -28,6 +28,7 @@ An alert every 3rd update, and a crash every 5th: ```toml [[plugin]] +id = "test_alert" name = "Test alert" type = "debugging" level = "alert" diff --git a/src/paperpi/plugins/dec_binary_clock/README.md b/src/paperpi/plugins/dec_binary_clock/README.md index e5a7b7c..d697f52 100644 --- a/src/paperpi/plugins/dec_binary_clock/README.md +++ b/src/paperpi/plugins/dec_binary_clock/README.md @@ -36,6 +36,7 @@ In the config file: ```toml [[plugin]] +id = "binary_clock" name = "Binary Clock" type = "dec_binary_clock" ``` diff --git a/src/paperpi/plugins/met_no/README.md b/src/paperpi/plugins/met_no/README.md index 0216931..228be11 100644 --- a/src/paperpi/plugins/met_no/README.md +++ b/src/paperpi/plugins/met_no/README.md @@ -63,6 +63,7 @@ In the config file (the rest of the file is shown in the main [README](../../../ ```toml [[plugin]] +id = "weather_berlin" name = "Weather Berlin" type = "met_no" lat = 52.52 diff --git a/src/paperpi/plugins/moon_phase/README.md b/src/paperpi/plugins/moon_phase/README.md index a7b4386..35ae22f 100644 --- a/src/paperpi/plugins/moon_phase/README.md +++ b/src/paperpi/plugins/moon_phase/README.md @@ -14,7 +14,7 @@ On a day the moon doesn't rise or set (this happens about once a month, because ## How often it asks met.no -met.no gives one answer per place and day. The plugin saves it in its storage folder (PaperPi's own folder for this plugin's files, `/var/lib/paperpi/plugins//`, where `` is made from the plugin's `name` in the config file: `name = "Moon"` gives `moon`) and asks again only on the next day, or after a change of `lat` or `lon`. If met.no can't be reached, the update fails as usual and PaperPi tries again at the next update. It sends your email address (only to met.no) as contact and rounds the coordinates to 4 decimals, as met.no's [terms of service](https://api.met.no/doc/TermsOfService) ask. +met.no gives one answer per place and day. The plugin saves it in its storage folder (PaperPi's own folder for this plugin's files, `/var/lib/paperpi/plugins//`, where `` is made from the plugin's `id` in the config file: `id = "moon"` gives `moon`) and asks again only on the next day, or after a change of `lat` or `lon`. If met.no can't be reached, the update fails as usual and PaperPi tries again at the next update. It sends your email address (only to met.no) as contact and rounds the coordinates to 4 decimals, as met.no's [terms of service](https://api.met.no/doc/TermsOfService) ask. ## Layouts @@ -37,6 +37,7 @@ In the config file (the rest of the file is shown in the main [README](../../../ ```toml [[plugin]] +id = "moon" name = "Moon" type = "moon_phase" lat = 52.52 diff --git a/src/paperpi/plugins/newyorker/README.md b/src/paperpi/plugins/newyorker/README.md index 7c3d098..04bfd80 100644 --- a/src/paperpi/plugins/newyorker/README.md +++ b/src/paperpi/plugins/newyorker/README.md @@ -34,6 +34,7 @@ In the config file (the rest of the file is shown in the main [README](../../../ ```toml [[plugin]] +id = "new_yorker" name = "New Yorker" type = "newyorker" layout = "comic_caption" # optional; without it: comic_caption_date diff --git a/src/paperpi/plugins/splash_screen/README.md b/src/paperpi/plugins/splash_screen/README.md index d78a962..5cbea94 100644 --- a/src/paperpi/plugins/splash_screen/README.md +++ b/src/paperpi/plugins/splash_screen/README.md @@ -32,6 +32,7 @@ To put it in the rotation (not needed for the splash at start): ```toml [[plugin]] +id = "splash_screen" name = "Splash Screen" type = "splash_screen" ``` diff --git a/src/paperpi/plugins/system_info/README.md b/src/paperpi/plugins/system_info/README.md index 36af4c0..eb02926 100644 --- a/src/paperpi/plugins/system_info/README.md +++ b/src/paperpi/plugins/system_info/README.md @@ -39,6 +39,7 @@ In the config file (the rest of the file is shown in the main [README](../../../ ```toml [[plugin]] +id = "system" name = "System" type = "system_info" layout = "portrait" diff --git a/src/paperpi/plugins/word_clock/README.md b/src/paperpi/plugins/word_clock/README.md index 85a0a58..905fd4f 100644 --- a/src/paperpi/plugins/word_clock/README.md +++ b/src/paperpi/plugins/word_clock/README.md @@ -31,6 +31,7 @@ In the config file (the rest of the file is shown in the main [README](../../../ ```toml [[plugin]] +id = "word_clock" name = "Word Clock" type = "word_clock" display_time = 255 diff --git a/src/paperpi/plugins/xkcd_comic/README.md b/src/paperpi/plugins/xkcd_comic/README.md index 1053be9..1524b9a 100644 --- a/src/paperpi/plugins/xkcd_comic/README.md +++ b/src/paperpi/plugins/xkcd_comic/README.md @@ -34,6 +34,7 @@ In the config file (the rest of the file is shown in the main [README](../../../ ```toml [[plugin]] +id = "xkcd" name = "xkcd" type = "xkcd_comic" layout = "comic_title" # optional; without it: comic_title_alttext diff --git a/src/paperpi/scheduler.py b/src/paperpi/scheduler.py index 4bcdcb1..0e6827f 100644 --- a/src/paperpi/scheduler.py +++ b/src/paperpi/scheduler.py @@ -402,13 +402,13 @@ def _apply(self, config: Config) -> None: self._current = changed slot = changed slots.append(slot) - names = [slot.id for slot in slots] - if self._last_rotation is not None and self._last_rotation not in names: + ids = [slot.id for slot in slots] + if self._last_rotation is not None and self._last_rotation not in ids: # The plugin shown last was removed: go on after the one before it. - old_names = [slot.id for slot in self._slots] - index = old_names.index(self._last_rotation) - before = old_names[:index][::-1] + old_names[index + 1 :][::-1] - self._last_rotation = next((n for n in before if n in names), None) + old_ids = [slot.id for slot in self._slots] + index = old_ids.index(self._last_rotation) + before = old_ids[:index][::-1] + old_ids[index + 1 :][::-1] + self._last_rotation = next((i for i in before if i in ids), None) self._slots = slots default = _default_config(config) if redraw or not _same_plugin(self._default.config, default): @@ -867,7 +867,7 @@ def _default_config(config: Config) -> PluginConfig: if found.plugin.type == "default": return found plugin = plugins.load("default") - # Its own storage folder, also when a user names another plugin "default". + # Its own storage folder: a [[plugin]] block can't have an ID starting with "built-in". entry = PluginEntry(id="built-in-default", type="default") return PluginConfig(entry, plugin.settings(), plugin) diff --git a/src/paperpi/storage.py b/src/paperpi/storage.py index df7afba..be9311d 100644 --- a/src/paperpi/storage.py +++ b/src/paperpi/storage.py @@ -1,7 +1,7 @@ """Size and age limits for the plugins' storage folders, and the low-disk check. See "Saved files" in ``docs/decisions/errors-and-time-limits.md``. Each plugin has its own -folder (``/plugins//``) with two limits, set per plugin (``storage_mb``, +folder (``/plugins//``) with two limits, set per plugin (``storage_mb``, ``storage_days``) or suggested by the plugin: - Files not changed for more than ``storage_days`` days are removed (0: they are kept). diff --git a/src/paperpi/web/plugins.py b/src/paperpi/web/plugins.py index 3d51cc8..9b551e1 100644 --- a/src/paperpi/web/plugins.py +++ b/src/paperpi/web/plugins.py @@ -21,7 +21,7 @@ from pydantic import ValidationError from .. import config, example, plugins -from ..plugin import ID_LENGTH, Plugin, PluginEntry +from ..plugin import ID_LENGTH, Plugin, PluginEntry, label from . import config_file #: Plugin types the Plugin Library doesn't offer: ``default`` is PaperPi's own message when @@ -207,7 +207,7 @@ def _plugin_list(text: str, source: str = "config") -> PluginList: file_problems = [str(p) for p in error.problems if p.level == "error"] else: problems = loaded.problems - # By the line of its [[plugin]] line: two blocks may have the same name (then + # By the line of its [[plugin]] line: two blocks may have the same ID (then # PaperPi uses only the first). checked = {p.line: p for p in loaded.plugins if p.line is not None} known = len(found) == len(raw) and not file_problems @@ -231,7 +231,7 @@ def _plugin_list(text: str, source: str = "config") -> PluginList: Row( index=index, id=plugin_id, - name=(name.strip() or plugin_id) if isinstance(name, str) else plugin_id, + name=label(name if isinstance(name, str) else "", plugin_id), type=plugin_type if isinstance(plugin_type, str) else "", enabled=enabled if isinstance(enabled, bool) else True, missing=good.missing if good is not None else (), diff --git a/src/paperpi/web/templates/add.html b/src/paperpi/web/templates/add.html index ab05446..e93adfd 100644 --- a/src/paperpi/web/templates/add.html +++ b/src/paperpi/web/templates/add.html @@ -16,7 +16,7 @@

    Add {{ item.type }}

    {% endif %}
    -