diff --git a/CLAUDE.md b/CLAUDE.md index 66044f4..ff16b14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,7 @@ Every PR costs txoof review time. Too many small PRs and too few huge ones both ## Tools and commands - Python 3.13 (the version in Raspberry Pi OS trixie). `uv` installs Python and all packages into `.venv`. - `uv sync`: install. `uv run pytest`: tests. `uv run ruff check .` and `uv run ruff format .`: code style. +- `uv run pytest -n auto` runs the tests on every processor core at once (pytest-xdist; about half the time on the 4-core Pi). While working, run only the tests of the files you changed; run all of them once before the first push of a PR. GitHub runs all of them for every pull request and every merge to main. - Plain `.py` files only. No Jupyter notebooks in the repo. - Tests that need a real display are marked `@pytest.mark.hardware`. They are skipped by default; run them on the Pi with `uv run pytest -m hardware`. diff --git a/docs/decisions/web-interface.md b/docs/decisions/web-interface.md index 09ca8e9..56699c4 100644 --- a/docs/decisions/web-interface.md +++ b/docs/decisions/web-interface.md @@ -35,6 +35,8 @@ In v2 the web interface is the main way to set up and change PaperPi: plugins, s - On Save, the values are checked with the same description. Errors are shown next to the field. A saved change applies at once (see `live-config-reload.md`). - A setting that needs more than a plain field can name an input type from a small list in the web interface, e.g. `location` (lat/lon lookup), `server_search` (find music servers), `layout_picker`. New input types are added when a plugin needs one. - Plugins added later show up without changes to the web code. +- *Agreed with txoof on 2026-10-08 (M5 part 3a):* the page shows the plugin's `name` first, then its own settings, then display time, refresh, layout and level; the other shared settings (time limit, alert and storage settings) are folded under "More settings", which opens by itself when one of them has an error or a value that is not the default. Each field shows its value, or the default when the file doesn't set it. +- How a save works (M5 part 3a-1): only settings that changed are written. An empty field means "the default" (an empty check box: off; an empty list: nothing picked); a value changed to the default is taken out of the file and becomes the comment `# key = default` again (a value left as it is stays, also one written in the file that equals the default), so the help text above it still fits. A new setting takes the place of its `# key = ...` comment line when there is one. If any value has an error, nothing is saved; each error is shown next to its field, with what was typed (never a secret). A secret (such as an API key) is never shown on the page; leaving its field empty keeps the saved one. A setting the form can't show (a group of settings, a list of numbers) is shown as it is in the file, to be changed there. A setting written over several lines in the file is never changed by the page. ### Logging in diff --git a/pyproject.toml b/pyproject.toml index cd44bf3..1ccb9b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dev = [ "httpx2>=2.13.1", "pytest>=8", "pytest-html>=4", + "pytest-xdist>=3.8.0", "ruff>=0.6", ] diff --git a/src/paperpi/example.py b/src/paperpi/example.py index 4856e26..31317dd 100644 --- a/src/paperpi/example.py +++ b/src/paperpi/example.py @@ -113,13 +113,13 @@ def plugin_block( ] if shared: lines += _comment(entry["id"].description or "") - lines.append(f"id = {_toml(plugin_id)}") + lines.append(f"id = {toml_value(plugin_id)}") named = "name" in values if shared: lines += _setting("name", entry["name"], values.get("name", ""), comment=not named) elif named: - lines.append(f"name = {_toml(values['name'])}") - lines.append(f"type = {_toml(plugin.type)}") + lines.append(f"name = {toml_value(values['name'])}") + lines.append(f"type = {toml_value(plugin.type)}") for key, value in values.items(): if key == "name": continue @@ -199,11 +199,11 @@ def _setting(key: str, info: FieldInfo, value: Any, *, comment: bool = False) -> help_text = info.description or key if is_required(info): help_text += " (required)" - choices = _choices(info.annotation) - named = all(re.search(rf"\b{re.escape(str(c))}\b", help_text) for c in choices) - if choices and not named: - help_text += f". One of: {', '.join(_toml(c) for c in choices)}" - line = f"{key} = {_toml(value)}" if value is not None else f"{key} =" + allowed = choices(info.annotation) + named = all(re.search(rf"\b{re.escape(str(c))}\b", help_text) for c in allowed) + if allowed and not named: + help_text += f". One of: {', '.join(toml_value(c) for c in allowed)}" + line = f"{key} = {toml_value(value)}" if value is not None else f"{key} =" return [*_comment(help_text), f"# {line}" if comment else line] @@ -219,7 +219,7 @@ def _check_reads_back(text: str, values: Mapping[str, Any]) -> None: TOML than Python's reader understands; such a block would make the whole file unreadable. """ - expected = {"plugin": [{k: _plain(v) for k, v in values.items()}]} + expected = {"plugin": [{k: plain(v) for k, v in values.items()}]} try: read = tomllib.loads(text) except tomllib.TOMLDecodeError as error: @@ -228,7 +228,7 @@ def _check_reads_back(text: str, values: Mapping[str, Any]) -> None: raise ValueError("can't be written so that PaperPi reads it back the same") -def _choices(annotation: Any) -> tuple: +def choices(annotation: Any) -> tuple: """The allowed values of a ``Literal`` setting (also inside ``... | None``).""" if typing.get_origin(annotation) is Literal: return typing.get_args(annotation) @@ -239,7 +239,7 @@ def _choices(annotation: Any) -> tuple: return () -def _plain(value: Any) -> Any: +def plain(value: Any) -> Any: """``value`` as plain data: text, numbers, true/false, lists and dictionaries.""" if hasattr(value, "get_secret_value"): value = value.get_secret_value() @@ -248,15 +248,15 @@ def _plain(value: Any) -> Any: return to_jsonable_python(value) -def _toml(value: Any) -> str: +def toml_value(value: Any) -> str: """``value`` written as in a TOML file: ``24``, ``true``, ``"Berlin"``, ``[1, 2]``, ``{a = 1}`` (a group of settings on one line).""" - value = _plain(value) + value = plain(value) if isinstance(value, dict): - items = (f"{tomlkit.key(k).as_string()} = {_toml(v)}" for k, v in value.items()) + items = (f"{tomlkit.key(k).as_string()} = {toml_value(v)}" for k, v in value.items()) return "{" + ", ".join(items) + "}" if isinstance(value, list): - return "[" + ", ".join(_toml(v) for v in value) + "]" + return "[" + ", ".join(toml_value(v) for v in value) + "]" if isinstance(value, float) and value.is_integer(): value = int(value) # 120, not 120.0 return tomlkit.item(value).as_string() diff --git a/src/paperpi/web/config_file.py b/src/paperpi/web/config_file.py index 1b500c4..1c3f392 100644 --- a/src/paperpi/web/config_file.py +++ b/src/paperpi/web/config_file.py @@ -4,11 +4,11 @@ file's permissions (and owner, when run as root). Hold :data:`LOCK` from reading to saving, so two changes at the same moment (a plugin and the password) can't undo each other. :func:`saved_here` tells whether a text is the one the web interface saved last. -- The plugin changes (:func:`move`, :func:`remove`, :func:`set_enabled`, :func:`add`) work on - the file's text, one ``[[plugin]]`` block at a time. The comment lines just above a - ``[[plugin]]`` line (with a blank line above them) belong to that block: they move with it - and are removed with it. A comment on the same line as ``enabled =`` is lost when the - plugin is switched on or off. +- The plugin changes (:func:`move`, :func:`remove`, :func:`set_enabled`, :func:`set_settings`, + :func:`add`) work on the file's text, one ``[[plugin]]`` block at a time. The comment lines + just above a ``[[plugin]]`` line (with a blank line above them) belong to that block: they + move with it and are removed with it. A comment at the end of a setting's line (such as + ``enabled =``) is lost when that setting is changed. - Every change is checked before it is saved: PaperPi must read the new text back as exactly the old settings plus the change. A file written in a way these functions don't understand (for example a ``[[plugin]]`` line written differently, or a value line @@ -25,11 +25,14 @@ import stat import threading import tomllib +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path from typing import Any -from .. import config, limits +import tomlkit + +from .. import config, example, limits from ..files import write_atomic #: Held from reading the config file to saving the changed copy. @@ -254,6 +257,119 @@ def set_enabled(text: str, index: int, plugin_id: str, enabled: bool) -> str: return _checked("".join(new), data | {"plugin": expected}) +def set_settings( + text: str, + index: int, + plugin_id: str, + values: Mapping[str, Any], + defaults: Mapping[str, Any] | None = None, +) -> str: + """``text`` with settings of plugin block ``index`` changed: ``values`` maps a setting + to its new value, or to ``None`` to remove it (back to its default). + + - A setting that is in the block gets its new value on the same line (a comment at the + end of that line is lost). + - A new one replaces a ``# key = ...`` comment line, so the help text above it stays. + Without one, ``name`` goes below ``id`` and the others below the block's last setting. + - A removed setting with a value in ``defaults`` becomes the comment ``# key = default`` + (``# key =`` for ``None``), as in the example config; others are taken out. + - A setting written over several lines can't be changed: :class:`UnknownForm`. + """ + defaults = defaults or {} + data, scan = _check(text, index, plugin_id) + block = scan.blocks[index] + lines = _ended(scan.lines) + newline = _newline(lines) + own = [n for n in range(block.header + 1, block.own_end) if n not in scan.in_string] + expected = plugin_blocks(data) + changed = dict(expected[index]) + new: dict[int, str | None] = {} # line -> its new text (None: taken out) + added: list[tuple[str, str]] = [] # (setting, line) with no place in the block yet + for key, value in values.items(): + at = _setting_line(lines, own, re.escape(key)) + if at is not None and not _one_line(lines[at]): + raise UnknownForm() + commented = _commented_setting(lines, own, key) + if value is None: + changed.pop(key, None) + if at is not None: + # Back to "# key = default", unless the block has that comment already. + new[at] = None if commented is not None else _default_line(key, defaults, newline) + continue + changed[key] = example.plain(value) + line = f"{_key(key)} = {example.toml_value(value)}{newline}" + if at is None: + at = commented # a commented-out setting, so its help text above stays + if at is None: + added.append((key, line)) + else: + new[at] = line + + def setting_after(number: int) -> bool: + line = new.get(number, lines[number]) + return line is not None and bool(line.strip()) and not line.lstrip().startswith("#") + + last = max((n for n in own if setting_after(n)), default=block.header) + below: dict[int, list[str]] = {} # line -> new lines to put below it + for key, line in added: + at = _setting_line(lines, own, "id") if key == "name" else None + at = _value_end(lines, last if at is None else at, block.own_end) + below.setdefault(at, []).append(line) + result = [] + for number, line in enumerate(lines): + replaced = new.get(number, line) + if replaced is not None: + result.append(replaced) + result += below.get(number, []) + expected[index] = changed + return _checked("".join(result), data | {"plugin": expected}) + + +def _value_end(lines: list[str], line: int, end: int) -> int: + """The last line of the setting that starts on ``line``: a list or a text written over + several lines ends further down. ``line`` itself when it is not a setting.""" + if not _HEADER.fullmatch(lines[line].rstrip("\r\n")) and not _one_line(lines[line]): + for last in range(line + 1, end): + if _one_line("".join(lines[line : last + 1])): + return last + return line + + +def _commented_setting(lines: list[str], within: list[int], key: str) -> int | None: + """The line of a commented-out ``key``: ``# key = value`` (a setting without the ``#``) + or ``# key =`` (not set), not a help text that starts the same way.""" + for number in within: + line = lines[number].strip() + if not re.match(rf"#\s*{re.escape(key)}\s*=", line): + continue + if _one_line(line[1:]) or re.fullmatch(rf"#\s*{re.escape(key)}\s*=", line): + return number + return None + + +def _key(key: str) -> str: + """``key`` as written in a TOML file (in quotes when it needs them).""" + return tomlkit.key(key).as_string() + + +def _default_line(key: str, defaults: Mapping[str, Any], newline: str) -> str | None: + """The comment that takes the place of a removed setting, or ``None`` for none.""" + if key not in defaults: + return None + default = defaults[key] + shown = "" if default is None else f" {example.toml_value(default)}" + return f"# {_key(key)} ={shown}{newline}" + + +def _one_line(line: str) -> bool: + """True when the setting on ``line`` is complete on that line.""" + try: + tomllib.loads(line) + except tomllib.TOMLDecodeError: + return False + return True + + def add(text: str, block_text: str) -> str: """``text`` with ``block_text`` (one ``[[plugin]]`` block, as written by :func:`paperpi.example.plugin_block`) added after the last plugin block.""" diff --git a/src/paperpi/web/forms.py b/src/paperpi/web/forms.py new file mode 100644 index 0000000..85df4db --- /dev/null +++ b/src/paperpi/web/forms.py @@ -0,0 +1,309 @@ +"""The plugin settings form: its fields, made from the plugin's settings description, and +the values a sent form holds, checked the same way as the config file. + +- :func:`fields` lists the settings the form shows, in groups: the plugin's ``name``, its + own settings, the shared ones people change most (:data:`COMMON`), and the rest + ("More settings"). Each field shows the setting's value, or its default when the config + file doesn't set it. ``id`` and ``type`` can't change; ``enabled`` is switched on the + Active Plugins page. +- :func:`read` turns a sent form into the changes to save, or an error per field (then + nothing is saved). An empty field means "the default" (for a check box: off; for a list: + nothing picked). A value changed to its default is taken out of the file, which only holds + the settings that differ from the default (``docs/decisions/config-format.md``). An empty + secret (an API key, ...) keeps the one that is saved, so the page never has to show it. +- Browsers send nothing for a check box that is off or a list with nothing picked. So the + page adds a hidden empty field for each, and a field that is missing from the form is + left as it is. +- A setting of a kind the form can't show (a group of settings, a list of numbers) is shown + as it is in the file, to be changed there. +""" + +from __future__ import annotations + +import re +import types +import typing +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any, Literal + +from pydantic import BaseModel, SecretStr, ValidationError +from pydantic.fields import FieldInfo + +from .. import example +from ..plugin import Plugin, PluginEntry, is_required + +#: The shared settings shown right after the plugin's own ones; the others are folded away +#: under "More settings" (agreed with txoof, M5 part 3a). +COMMON = ("display_time", "refresh", "layout", "level") +#: Settings of a ``[[plugin]]`` block the form doesn't change. +NOT_IN_FORM = ("id", "type", "enabled") + +Kind = Literal["choice", "check", "number", "text", "secret", "choices", "file"] +Group = Literal["name", "own", "common", "more"] + + +@dataclass(frozen=True) +class FormField: + """One setting on the form.""" + + key: str + help: str + kind: Kind + """``choice``: pick one; ``check``: on or off; ``number``; ``text``; ``secret``: text + that is never shown; ``choices``: pick several; ``file``: change it in the file.""" + group: Group + value: str = "" + """The value as text (``""``: not set). ``check``: ``"true"`` or ``"false"``.""" + selected: tuple[str, ...] = () + """``choices``: the ones picked, as text.""" + choices: tuple[str, ...] = () + """``choice`` and ``choices``: every value it may have, as text.""" + values: tuple[Any, ...] = () + """The values of ``choices`` themselves (``12`` for ``"12"``).""" + default: str = "" + """The default, as text (``""``: none).""" + required: bool = False + """The plugin is not shown until this is filled in (see ``paperpi.plugin.setting``).""" + is_set: bool = False + """``secret``: a value is saved.""" + minimum: float | None = None + maximum: float | None = None + whole: bool = False + """``number``: whole numbers only.""" + max_length: int | None = None + error: str = "" + + +@dataclass(frozen=True) +class Read: + """What :func:`read` found in a sent form.""" + + changes: dict[str, Any] + """Each setting to change, with its new value; ``None`` takes it out of the file.""" + errors: dict[str, str] = field(default_factory=dict) + """Why a setting's value can't be used, by setting (``""``: not one field).""" + sent: dict[str, Sequence[str]] = field(default_factory=dict) + """The form as sent, to show it again with the errors.""" + + +def defaults(plugin: Plugin) -> dict[str, Any]: + """The value each setting has when the file doesn't set it: the shared settings' and + the plugin's own defaults, and the plugin's suggestions (``refresh``, the first layout, + the storage limits).""" + found = {key: info.default for key, info in _infos(plugin).items()} + return found | { + "refresh": plugin.refresh, + "layout": plugin.default_layout, + "storage_mb": plugin.storage_mb, + "storage_days": plugin.storage_days, + } + + +def fields( + plugin: Plugin, + block: Mapping[str, Any], + sent: Mapping[str, Sequence[str]] | None = None, + errors: Mapping[str, str] | None = None, +) -> list[FormField]: + """The fields of the form for ``plugin``, whose ``[[plugin]]`` block holds ``block``. + With ``sent`` (a form that had ``errors``), they show what was sent.""" + shown = defaults(plugin) + errors = errors or {} + found = [] + for key, info in _infos(plugin).items(): + kind, allowed = _kind(info.annotation) + if key == "layout": + kind, allowed = "choice", tuple(plugin.layouts) + value = block.get(key, shown[key]) + texts = [_text(v) for v in value] if isinstance(value, list | tuple) else [_text(value)] + if kind == "file": + texts = [example.toml_value(value) if value is not None else ""] + if sent is not None and key in sent and kind not in ("secret", "file"): + # Without the empty value the page sends for a check box or list. + texts = [t for t in sent[key] if t] or [""] + if kind == "check": + texts = ["true" if texts[-1] not in ("", "false") else "false"] + elif kind == "choices": + texts = [t for t in texts if t] + found.append( + FormField( + key=key, + help=info.description or key, + kind=kind, + group=_group(key, plugin), + value="" if kind in ("secret", "choices") else (texts[-1] if texts else ""), + selected=tuple(texts) if kind == "choices" else (), + choices=tuple(_text(c) for c in allowed), + values=tuple(allowed), + default="" if kind == "secret" else _text(shown[key]), + required=is_required(info), + is_set=kind == "secret" and bool(_text(value)), + minimum=_limit(info, "ge", "gt"), + maximum=_limit(info, "le", "lt"), + whole=_plain_type(info.annotation) is int, + max_length=_limit(info, "max_length"), + error=errors.get(key, ""), + ) + ) + order: list[Group] = ["name", "own", "common", "more"] + return sorted( + found, + key=lambda f: (order.index(f.group), COMMON.index(f.key) if f.group == "common" else 0), + ) + + +def read(plugin: Plugin, block: Mapping[str, Any], form: Mapping[str, Sequence[str]]) -> Read: + """The changes a sent ``form`` makes to the ``[[plugin]]`` block holding ``block``, each + value checked by the plugin's settings description. ``form`` maps a field to the texts + sent for it (several for a ``choices`` field).""" + values: dict[str, Any] = {} + errors: dict[str, str] = {} + sent = {key: list(texts) for key, texts in form.items()} + for item in fields(plugin, block): + # A field that is not in the form stays as it is (see the top of this file). + if item.key not in sent: + continue + texts = [t.strip() for t in sent[item.key]] + if item.kind == "choices": + texts = [t for t in texts if t] + text = texts[-1] if texts else "" + if item.kind == "file" or (item.kind == "secret" and not text): + continue + try: + values[item.key] = _value(item, text, texts) + except ValueError as error: + errors[item.key] = str(error) + shared = {k: v for k, v in values.items() if k in PluginEntry.model_fields} + own = {k: v for k, v in values.items() if k not in shared} + entry = {k: block[k] for k in ("id", "type") if k in block} + _check(PluginEntry, entry | _set(block, shared, PluginEntry), errors) + _check(plugin.settings, _set(block, own, plugin.settings), errors) + if errors: + # Shown again on the page, so without what was typed into a secret field. + secrets = {item.key for item in fields(plugin, block) if item.kind == "secret"} + return Read({}, errors, {k: v for k, v in sent.items() if k not in secrets}) + shown = defaults(plugin) + changes = {} + for key, value in values.items(): + # As plain data: the file gives a list where the form gives a tuple. A value as it + # is in the file stays, also one written there that equals the default. + if key in block and example.plain(value) == example.plain(block[key]): + continue + new = None if example.plain(value) == example.plain(shown[key]) else value + if example.plain(new) != example.plain(block.get(key)): + changes[key] = new + return Read(changes) + + +def _infos(plugin: Plugin) -> dict[str, FieldInfo]: + """Every setting the form shows, with its description.""" + entry = {k: v for k, v in PluginEntry.model_fields.items() if k not in NOT_IN_FORM} + return entry | dict(plugin.settings.model_fields) + + +def _group(key: str, plugin: Plugin) -> Group: + if key == "name": + return "name" + if key in plugin.settings.model_fields: + return "own" + return "common" if key in COMMON else "more" + + +def _kind(annotation: Any) -> tuple[Kind, tuple]: + """The kind of field for a setting of this type, and the values it may have.""" + allowed = example.choices(annotation) + if allowed: + return "choice", allowed + plain = _plain_type(annotation) + if typing.get_origin(plain) in (tuple, list): + inner = typing.get_args(plain) + allowed = example.choices(inner[0]) if inner else () + return ("choices", allowed) if allowed else ("file", ()) + if plain is bool: + return "check", () + if plain in (int, float): + return "number", () + if plain is SecretStr: + return "secret", () + if plain is str: + return "text", () + return "file", () + + +def _plain_type(annotation: Any) -> Any: + """``annotation`` without ``| None``.""" + if isinstance(annotation, types.UnionType) or typing.get_origin(annotation) is typing.Union: + parts = [a for a in typing.get_args(annotation) if a is not type(None)] + return parts[0] if len(parts) == 1 else annotation + return annotation + + +def _limit(info: FieldInfo, *names: str) -> Any: + """The first of the limits ``names`` (``ge``, ``max_length``, ...) the setting has.""" + for part in info.metadata: + for name in names: + found = getattr(part, name, None) + if found is not None: + return found + return None + + +def _text(value: Any) -> str: + """``value`` as the form shows it.""" + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + if isinstance(value, SecretStr): + return value.get_secret_value() + return str(value) + + +def _value(item: FormField, text: str, texts: list[str]) -> Any: + """The value of a field, from the text sent for it. Raises ``ValueError``.""" + if item.kind == "check": + return text not in ("", "false") + if item.kind == "choices": + if any(t not in item.choices for t in texts): + raise ValueError(f"choose from: {', '.join(item.choices)}") + # Each one once, in the order of the choices (a form can't send more). + return tuple(_choice(item, t) for t in item.choices if t in texts) + if not text: + return None + if item.kind == "number": + # Plain digits only: int() and float() also take "1_0", "nan" and other scripts' digits. + pattern = r"-?[0-9]{1,18}" if item.whole else r"-?[0-9]{1,18}(\.[0-9]{1,18})?" + if not re.fullmatch(pattern, text): + raise ValueError("enter a whole number" if item.whole else "enter a number") + return int(text) if item.whole else float(text) + if item.kind == "choice": + if text not in item.choices: + raise ValueError(f"choose one of: {', '.join(item.choices)}") + return _choice(item, text) + return text + + +def _choice(item: FormField, text: str) -> Any: + """The value of a choice from its text, e.g. ``12`` for ``"12"`` (hours).""" + return item.values[item.choices.index(text)] + + +def _set( + block: Mapping[str, Any], values: Mapping[str, Any], model: type[BaseModel] +) -> dict[str, Any]: + """The settings of ``model`` in ``block`` with ``values`` changed (``None``: not set).""" + merged = {k: v for k, v in block.items() if k in model.model_fields} | dict(values) + return {k: v for k, v in merged.items() if v is not None} + + +def _check(model: type[BaseModel], values: Mapping[str, Any], errors: dict[str, str]) -> None: + """Add what ``model`` finds wrong with ``values`` to ``errors``, by setting.""" + try: + model.model_validate(values) + except ValidationError as error: + for found in error.errors(): + key = str(found["loc"][0]) if found["loc"] else "" + errors.setdefault(key, found["msg"].removeprefix("Value error, ")) diff --git a/src/paperpi/web/plugins.py b/src/paperpi/web/plugins.py index 9b551e1..07009fa 100644 --- a/src/paperpi/web/plugins.py +++ b/src/paperpi/web/plugins.py @@ -14,21 +14,30 @@ from __future__ import annotations import secrets -from collections.abc import Callable +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field from pathlib import Path +from typing import Any from pydantic import ValidationError from .. import config, example, plugins from ..plugin import ID_LENGTH, Plugin, PluginEntry, label -from . import config_file +from . import config_file, forms #: Plugin types the Plugin Library doesn't offer: ``default`` is PaperPi's own message when #: nothing else can be shown, and ``debugging`` is for testing PaperPi. HIDDEN = ("default", "debugging") +class FormErrors(config_file.EditError): + """A settings form with values that can't be used; nothing was saved.""" + + def __init__(self, found: forms.Read): + super().__init__("Some settings can't be used; see the messages next to them.") + self.found = found + + @dataclass(frozen=True) class Row: """One ``[[plugin]]`` block, as the Active Plugins page shows it.""" @@ -107,7 +116,8 @@ def change(self, edit: Callable[[str], str]) -> bool: """Change the file with ``edit`` (old text -> new text), save it and apply it. Returns True when the file had hand edits that were not applied yet; they apply - too. Raises :class:`config_file.EditError` when the change can't be made. + too. When ``edit`` changes nothing, nothing is saved or applied (False). Raises + :class:`config_file.EditError` when the change can't be made. """ with config_file.LOCK: path, text = config_file.read(self.path) @@ -117,6 +127,8 @@ def change(self, edit: Callable[[str], str]) -> bool: and not config_file.saved_here(path, text) ) new = edit(text) + if new == text: # nothing to save or apply + return False try: config.parse(new, path.name) except config.ConfigError as error: @@ -148,6 +160,25 @@ def switch(text: str) -> str: def remove(self, index: int, plugin_id: str) -> bool: return self.change(lambda text: config_file.remove(text, index, plugin_id)) + def settings(self, index: int, plugin_id: str) -> tuple[Plugin, dict[str, Any]]: + """The plugin of block ``index`` (if it still has the ID ``plugin_id``) and the + settings in its block, for the settings form.""" + return _plugin_block(config_file.read(self.path)[1], index, plugin_id) + + def save_settings(self, index: int, plugin_id: str, form: Mapping[str, Sequence[str]]) -> bool: + """Save a sent settings form (see :func:`forms.read`) into block ``index``. Raises + :class:`FormErrors` when a value can't be used, so nothing is saved.""" + + def save(text: str) -> str: + plugin, block = _plugin_block(text, index, plugin_id) + found = forms.read(plugin, block, form) + if found.errors: + raise FormErrors(found) + changes, shown = found.changes, forms.defaults(plugin) + return config_file.set_settings(text, index, plugin_id, changes, shown) + + return self.change(save) + def row(self, index: int, plugin_id: str) -> Row: """The block at ``index``, if it still has the ID ``plugin_id``.""" return _row(self.plugin_list(), index, plugin_id) @@ -242,6 +273,22 @@ def _plugin_list(text: str, source: str = "config") -> PluginList: return PluginList(rows, file_problems) +def _plugin_block(text: str, index: int, plugin_id: str) -> tuple[Plugin, dict[str, Any]]: + raw = config_file.plugin_blocks(config_file.load(text)) + if not 0 <= index < len(raw) or config_file.id_of(raw[index]) != plugin_id: + raise config_file.ChangedMeanwhile() + plugin_type = raw[index].get("type") + try: + if plugin_type not in plugins.available(): + raise KeyError(plugin_type) + return plugins.load(plugin_type), raw[index] + except Exception: # noqa: BLE001 - a missing or broken plugin can't be set up here + raise config_file.EditError( + f"The settings of {plugin_id} can't be changed here: its plugin type " + f"{plugin_type!r} can't be used. Fix the block in the config file." + ) from None + + def _row(found: PluginList, index: int, plugin_id: str) -> Row: if not 0 <= index < len(found.rows) or found.rows[index].id != plugin_id: raise config_file.ChangedMeanwhile() diff --git a/tests/test_web_config_file.py b/tests/test_web_config_file.py index c493b0f..510c2d2 100644 --- a/tests/test_web_config_file.py +++ b/tests/test_web_config_file.py @@ -204,3 +204,124 @@ def test_a_file_too_large_for_paperpi_is_not_saved(tmp_path, monkeypatch): config_file.write(path, CONFIG) assert config_file.saved_here(path, CONFIG) assert not config_file.saved_here(path, CONFIG + "#\n") + + +# --- Changing settings ------------------------------------------------------------------------ + + +def test_setting_a_value_uses_the_commented_line_and_keeps_its_help(): + new = config_file.set_settings(CONFIG, 0, "Clock", {"refresh": 120, "hours": 12}) + assert '# the clock in the kitchen\n[[plugin]]\nid = "Clock"\n' in new + assert 'type = "basic_clock"\nrefresh = 120\nhours = 12\n' in new + assert tomllib.loads(new)["plugin"][0] == { + "id": "Clock", + "type": "basic_clock", + "refresh": 120, + "hours": 12, + } + + +def test_changing_a_value_keeps_its_place_and_loses_the_comment_on_its_line(): + new = config_file.set_settings(CONFIG, 1, "Words", {"enabled": False}) + assert 'type = "word_clock"\nenabled = false\n[plugin.extra]' in new + + +def test_a_name_goes_below_the_id(): + new = config_file.set_settings(CONFIG, 1, "Words", {"name": 'Words "big"'}) + assert '[[plugin]]\nid = "Words"\nname = "Words \\"big\\""\ntype = "word_clock"' in new + + +def test_removing_a_value_shows_its_default_as_a_comment_or_takes_the_line_out(): + text = CONFIG.replace("# refresh = 60", "refresh = 120\nhours = 12") + new = config_file.set_settings( + text, 0, "Clock", {"refresh": None, "hours": None}, {"refresh": 60} + ) + assert 'type = "basic_clock"\n# refresh = 60\n\n# words' in new + assert tomllib.loads(new)["plugin"][0] == {"id": "Clock", "type": "basic_clock"} + # None as the default: "# key =", as in the example config. + new = config_file.set_settings(text, 0, "Clock", {"hours": None}, {"hours": None}) + assert "refresh = 120\n# hours =\n" in new + + +def test_new_settings_go_below_the_last_one_that_stays(): + text = CONFIG.replace("# refresh = 60", "refresh = 120\n# help for hours\nhours = 12") + new = config_file.set_settings(text, 0, "Clock", {"hours": None, "display_time": 30}) + assert "refresh = 120\ndisplay_time = 30\n# help for hours\n\n# words" in new + + +def test_settings_in_a_part_or_a_string_are_not_changed(): + text = CONFIG.replace("a = 1", "a = 1\nlevel = 'alert'").replace( + "enabled = true # for now", 'note = """\nlevel = "x"\n"""' + ) + new = config_file.set_settings(text, 1, "Words", {"level": "interrupt"}) + block = tomllib.loads(new)["plugin"][1] + assert block["level"] == "interrupt" and block["extra"]["level"] == "alert" + assert block["note"] == 'level = "x"\n' + + +def test_a_value_over_several_lines_is_not_changed(): + text = CONFIG.replace("# refresh = 60", "hours = [\n 12,\n]") + with pytest.raises(config_file.UnknownForm): + config_file.set_settings(text, 0, "Clock", {"hours": 24}) + + +def test_settings_of_a_block_changed_meanwhile_are_not_changed(): + with pytest.raises(ChangedMeanwhile): + config_file.set_settings(CONFIG, 0, "Words", {"hours": 12}) + + +def test_windows_line_breaks_are_kept_when_settings_change(): + text = CONFIG.replace("\n", "\r\n") + new = config_file.set_settings(text, 0, "Clock", {"refresh": 120, "name": "Kitchen"}) + assert "\n" not in new.replace("\r\n", "") + + +def test_a_quoted_key_in_the_file_is_not_changed_wrongly(): + text = CONFIG.replace("# refresh = 60", '"hours" = 24') + with pytest.raises(config_file.UnknownForm): + config_file.set_settings(text, 0, "Clock", {"hours": 12}) + + +def test_a_key_that_starts_like_another_is_another(): + text = CONFIG.replace("# refresh = 60", "latitude = 1") + new = config_file.set_settings(text, 0, "Clock", {"lat": 2}) + assert tomllib.loads(new)["plugin"][0] | {} == { + "id": "Clock", + "type": "basic_clock", + "latitude": 1, + "lat": 2, + } + + +def test_a_key_that_needs_quotes_gets_them(): + new = config_file.set_settings(CONFIG, 0, "Clock", {"my key": 1}) + assert '"my key" = 1\n' in new + + +def test_removing_a_value_with_its_comment_there_already_adds_no_second_one(): + text = CONFIG.replace("# refresh = 60", "# hours = 24\nhours = 12") + new = config_file.set_settings(text, 0, "Clock", {"hours": None}, {"hours": 24}) + assert new.count("# hours = 24") == 1 and "hours = 12" not in new + + +def test_a_help_text_that_starts_like_a_setting_stays(): + text = CONFIG.replace("# refresh = 60", "# hours = 12 or 24 (default 24)") + new = config_file.set_settings(text, 0, "Clock", {"hours": 12}) + assert "# hours = 12 or 24 (default 24)\n" in new and "\nhours = 12\n" in new + + +def test_a_not_set_comment_takes_the_value(): + text = CONFIG.replace("# refresh = 60", "# help for lat\n# lat =") + new = config_file.set_settings(text, 0, "Clock", {"lat": 2}) + assert "# help for lat\nlat = 2\n" in new + + +def test_windows_line_breaks_keep_the_values(): + text = CONFIG.replace("\n", "\r\n") + new = config_file.set_settings(text, 0, "Clock", {"refresh": 120, "name": "Kitchen"}) + assert tomllib.loads(new)["plugin"][0] == { + "id": "Clock", + "name": "Kitchen", + "type": "basic_clock", + "refresh": 120, + } diff --git a/tests/test_web_forms.py b/tests/test_web_forms.py new file mode 100644 index 0000000..7b51808 --- /dev/null +++ b/tests/test_web_forms.py @@ -0,0 +1,227 @@ +from typing import Literal + +import pytest +from pydantic import Field, SecretStr + +from paperpi import plugins +from paperpi.plugin import Plugin, PluginSettings, ready, setting +from paperpi.web import forms + +WEATHER = {"id": "w", "type": "met_no", "lat": 52.52, "display_time": 60} +T = {"id": "t", "type": "test"} + + +def by_key(found): + return {f.key: f for f in found} + + +class Settings(PluginSettings): + token: SecretStr = Field(SecretStr(""), description="API key") + words: tuple[Literal["a", "b", "c"], ...] = Field(("a",), description="Words") + part: dict[str, int] = Field({"x": 1}, description="A group of settings") + loud: bool = Field(False, description="Loud") + + +TEST = Plugin( + type="test", + description="For the form tests", + settings=Settings, + layouts={"one": {"column": [{"name": "t", "type": "text"}]}}, + fetch=lambda context: ready(None), + draw=lambda data, context: {}, + sample=None, + refresh=60, +) + + +def test_fields_are_in_groups_in_the_agreed_order(): + found = forms.fields(plugins.load("met_no"), WEATHER) + assert [f.key for f in found] == [ + "name", + *["lat", "lon", "place", "email", "temperature", "rain"], + *["display_time", "refresh", "layout", "level"], + *["time_limit", "alert_reminder", "alert_max_time", "storage_mb", "storage_days"], + ] + assert [f.group for f in found].count("more") == 5 + assert "id" not in by_key(found) and "enabled" not in by_key(found) + + +def test_fields_show_the_value_or_else_the_default(): + found = by_key(forms.fields(plugins.load("met_no"), WEATHER)) + assert (found["lat"].value, found["lat"].required) == ("52.52", True) + assert (found["lat"].minimum, found["lat"].maximum, found["lat"].whole) == (-90, 90, False) + assert found["lon"].value == "" # not set, no default + assert (found["display_time"].value, found["display_time"].default) == ("60", "120") + assert found["refresh"].value == found["refresh"].default == "1800" # the suggestion + assert found["layout"].choices == tuple(plugins.load("met_no").layouts) + assert found["temperature"].choices == ("C", "F") + assert found["email"].max_length == 200 + assert found["storage_mb"].whole + + +def test_kinds_of_fields(): + found = by_key(forms.fields(TEST, {"token": "s3cret", "words": ["b", "c"]})) + assert found["token"].kind == "secret" and found["token"].is_set + assert found["token"].value == "" and found["token"].default == "" # never on the page + assert (found["words"].kind, found["words"].selected) == ("choices", ("b", "c")) + assert (found["part"].kind, found["part"].value) == ("file", "{x = 1}") + assert (found["loud"].kind, found["loud"].value) == ("check", "false") + assert found["level"].kind == "choice" + + +def test_reading_a_form_gives_only_the_changes(): + found = forms.read( + plugins.load("met_no"), + WEATHER, + { + "name": ["Weather"], + "lat": ["52.52"], # the same + "lon": [" 13.4 "], + "temperature": ["F"], + "display_time": ["120"], # the default: out of the file + "refresh": ["1800"], # the suggestion: not set, as before + "layout": ["small"], + }, + ) + assert found.errors == {} + assert found.changes == { + "name": "Weather", + "lon": 13.4, + "temperature": "F", + "display_time": None, + "layout": "small", + } + + +def test_an_empty_field_means_the_default(): + found = forms.read(plugins.load("met_no"), WEATHER, {"lat": [""], "display_time": [""]}) + assert found.changes == {"lat": None, "display_time": None} + + +def test_fields_left_out_of_the_form_stay_as_they_are(): + found = forms.read(plugins.load("met_no"), WEATHER, {}) + assert found.changes == {} and found.errors == {} + + +def test_choices_are_read_as_their_values(): + found = forms.read( + plugins.load("basic_clock"), {"id": "c", "type": "basic_clock"}, {"hours": ["12"]} + ) + assert found.changes == {"hours": 12} + + +@pytest.mark.parametrize( + ("form", "errors"), + [ + ({"lat": ["91"]}, {"lat": "Input should be less than or equal to 90"}), + ({"lon": ["east"]}, {"lon": "enter a number"}), + ({"storage_mb": ["1.5"]}, {"storage_mb": "enter a whole number"}), + ({"temperature": ["K"]}, {"temperature": "choose one of: C, F"}), + ( + {"layout": ["big"]}, + {"layout": "choose one of: " + ", ".join(plugins.load("met_no").layouts)}, + ), + ( + {"email": ["not an address"]}, + {"email": "String should match pattern '^$|^[^@\\s]+@[^@\\s]+$'"}, + ), + ( + {"name": ["a\tb"]}, + {"name": "must not hold control characters (such as tab or new line)"}, + ), + ( + {"lat": ["91"], "lon": ["x"]}, + {"lat": "Input should be less than or equal to 90", "lon": "enter a number"}, + ), + ], +) +def test_values_that_cant_be_used_give_an_error_each_and_no_changes(form, errors): + found = forms.read(plugins.load("met_no"), WEATHER, form) + assert found.errors == errors and found.changes == {} + assert found.sent == form + + +def test_a_form_with_errors_shows_what_was_sent(): + sent = {"lat": ["91"], "temperature": ["F"]} + found = by_key(forms.fields(plugins.load("met_no"), WEATHER, sent, {"lat": "too far"})) + assert (found["lat"].value, found["lat"].error) == ("91", "too far") + assert found["temperature"].value == "F" + assert found["display_time"].value == "60" # not sent: as in the file + + +def test_an_empty_secret_keeps_the_saved_one(): + assert forms.read(TEST, T | {"token": "s3cret"}, {"token": [""]}).changes == {} + assert forms.read(TEST, T | {"token": "s3cret"}, {"token": ["new"]}).changes == {"token": "new"} + + +def test_check_boxes_and_lists_with_the_empty_value_the_page_sends(): + # The page sends "false" before each check box and "" before each list. + block = T | {"loud": True, "words": ["b"]} + found = forms.read(TEST, block, {"loud": ["false"], "words": ["", "a"]}) + assert found.changes == {"loud": None, "words": None} # both back to their defaults + found = forms.read(TEST, T, {"loud": ["false", "true"], "words": ["", "b", "c"]}) + assert found.changes == {"loud": True, "words": ("b", "c")} + assert forms.read(TEST, block, {"words": [""]}).changes == {"words": ()} # none + assert forms.read(TEST, T, {"words": ["d"]}).errors == {"words": "choose from: a, b, c"} + + +def test_settings_of_a_kind_the_form_cant_show_are_never_changed(): + assert forms.read(TEST, T | {"part": {"x": 2}}, {"part": ["{x = 3}"]}).changes == {} + + +def test_required_settings_can_be_left_empty(): + # The plugin then waits for them (see paperpi.plugin.setting); the form says so. + class Needs(PluginSettings): + key: str = setting("", required=True, description="Key") + + plugin = Plugin(**{**TEST.__dict__, "settings": Needs}) + found = forms.read(plugin, T | {"key": "abc"}, {"key": [""]}) + assert found.errors == {} and found.changes == {"key": None} + + +def test_defaults_include_the_plugins_suggestions(): + weather = plugins.load("met_no") + shown = forms.defaults(weather) + assert shown["refresh"] == weather.refresh and shown["layout"] == weather.default_layout + assert shown["display_time"] == 120 and shown["lat"] is None + + +def test_unchanged_values_are_no_change(): + # The file gives a list and whole numbers; the form a tuple and text. + block = T | {"words": ["b", "c"], "loud": True} + form = {"words": ["", "b", "c"], "loud": ["false", "true"], "display_time": ["60"]} + assert forms.read(TEST, block | {"display_time": 60.0}, form).changes == {} + + +def test_a_list_picks_each_choice_once(): + found = forms.read(TEST, T, {"words": ["", "c", "a", "c", "a"]}) + assert found.changes == {"words": ("a", "c")} + + +@pytest.mark.parametrize( + "text", ["1_0", "nan", "inf", "1e3", "\N{ARABIC-INDIC DIGIT ONE}", "1" * 19, "0x10"] +) +def test_numbers_are_plain_digits(text): + found = forms.read(plugins.load("met_no"), WEATHER, {"display_time": [text]}) + assert found.errors == {"display_time": "enter a number"} + + +def test_a_form_shown_again_keeps_check_boxes_and_lists_as_sent(): + sent = {"loud": ["false"], "words": ["", "c"]} + found = by_key(forms.fields(TEST, T | {"loud": True}, sent, {"words": "x"})) + assert found["loud"].value == "false" + assert found["words"].selected == ("c",) + found = by_key(forms.fields(TEST, T | {"loud": True}, {"words": [""]}, {"words": "x"})) + assert found["loud"].value == "true" # not sent: as saved + assert found["words"].selected == () + + +def test_a_secret_is_not_sent_back_with_errors(): + found = forms.read(TEST, T, {"token": ["NEWSECRET"], "words": ["d"]}) + assert found.errors and "token" not in found.sent + assert "NEWSECRET" not in repr(found) + + +def test_a_default_written_in_the_file_stays_until_it_is_changed(): + block = WEATHER | {"display_time": 120} + assert forms.read(plugins.load("met_no"), block, {"display_time": ["120"]}).changes == {} diff --git a/tests/test_web_plugins.py b/tests/test_web_plugins.py index 8b9d38b..ae6cae1 100644 --- a/tests/test_web_plugins.py +++ b/tests/test_web_plugins.py @@ -11,6 +11,7 @@ from paperpi.web import password as web_password from paperpi.web import plugins as web_plugins from paperpi.web.app import create_app +from paperpi.web.config_file import EditError from paperpi.web.plugins import PluginEditor CONFIG = """\ @@ -363,3 +364,67 @@ def test_the_file_keeps_its_permissions(client, cfg): cfg.chmod(0o640) client.post("/plugins/0/move", data={"id": "Clock", "step": "down"}) assert cfg.stat().st_mode & 0o777 == 0o640 + + +# --- Settings --------------------------------------------------------------------------------- + + +def test_saving_settings_changes_only_what_changed_and_applies_at_once(editor, cfg, reloads): + plugin, block = editor.settings(1, "Weather") + assert plugin.type == "met_no" and block["lat"] == 52.52 + form = {"lat": ["52.52"], "lon": ["13.5"], "email": ["me@example.org"], "display_time": [""]} + assert editor.save_settings(1, "Weather", form) is False # no hand edits + assert blocks(cfg)[1] == { + "id": "Weather", + "type": "met_no", + "lat": 52.52, + "lon": 13.5, + "enabled": False, + "email": "me@example.org", + } + assert reloads == [1] + assert "# the clock in the kitchen" in cfg.read_text() + + +def test_settings_with_errors_save_nothing(editor, cfg, reloads): + with pytest.raises(web_plugins.FormErrors) as raised: + editor.save_settings(1, "Weather", {"lat": ["91"], "lon": ["13.5"]}) + assert raised.value.found.errors == {"lat": "Input should be less than or equal to 90"} + assert raised.value.found.sent == {"lat": ["91"], "lon": ["13.5"]} + assert cfg.read_text() == CONFIG and reloads == [] + + +def test_settings_of_a_block_moved_meanwhile_are_not_saved(editor, cfg): + with pytest.raises(EditError, match="changed"): + editor.save_settings(0, "Weather", {"lat": ["1"]}) + with pytest.raises(EditError, match="changed"): + editor.settings(5, "Weather") + assert cfg.read_text() == CONFIG + + +def test_settings_of_an_unknown_plugin_type_cant_be_changed(editor, cfg): + cfg.write_text(CONFIG.replace('type = "basic_clock"\n\n', 'type = "nothing"\n\n', 1)) + with pytest.raises(EditError, match="its plugin type 'nothing' can't be used"): + editor.settings(0, "Clock") + + +def test_saving_the_same_form_again_changes_nothing(editor, cfg, reloads): + form = {"lon": ["13.5"], "temperature": ["F"]} + editor.save_settings(1, "Weather", form) + saved = cfg.read_text() + assert editor.save_settings(1, "Weather", form) is False + assert cfg.read_text() == saved and reloads == [1] + + +def test_a_list_over_several_lines_doesnt_stop_other_changes(editor, cfg): + cfg.write_text( + CONFIG + '\n[[plugin]]\nid = "Test"\ntype = "debugging"\nstates = [\n "ready",\n]\n' + ) + editor.save_settings(3, "Test", {"states": ["", "ready"], "text": ["hello"]}) + assert blocks(cfg)[3]["text"] == "hello" + + +def test_settings_of_an_unknown_plugin_type_cant_be_saved(editor, cfg): + cfg.write_text(CONFIG.replace('type = "basic_clock"\n\n', 'type = "nothing"\n\n', 1)) + with pytest.raises(EditError, match="its plugin type 'nothing' can't be used"): + editor.save_settings(0, "Clock", {"hours": ["12"]}) diff --git a/uv.lock b/uv.lock index a460884..868e556 100644 --- a/uv.lock +++ b/uv.lock @@ -69,6 +69,15 @@ it8951 = [ { name = "spidev" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "fastapi" version = "0.142.2" @@ -323,6 +332,7 @@ dev = [ { name = "httpx2" }, { name = "pytest" }, { name = "pytest-html" }, + { name = "pytest-xdist" }, { name = "ruff" }, ] @@ -344,6 +354,7 @@ dev = [ { name = "httpx2", specifier = ">=2.13.1" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-html", specifier = ">=4" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "ruff", specifier = ">=0.6" }, ] @@ -540,6 +551,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl", hash = "sha256:c8e0844db684ee1c798cfa38908d20d67d0463ecb6137c72e91f418558dd5f4b", size = 11428, upload-time = "2024-02-12T19:38:42.531Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-multipart" version = "0.0.32"