fix(validator): validate all plugin setting types

This commit is contained in:
Lemmy
2026-07-26 19:57:25 -04:00
parent a8578a44b9
commit 4d1d17f39a
2 changed files with 137 additions and 12 deletions
@@ -307,6 +307,73 @@ Configure the update interval in plugin settings.
self.assertTrue(any("## Settings" in error for error in self.validate_readme(without_settings))) self.assertTrue(any("## Settings" in error for error in self.validate_readme(without_settings)))
class SettingTypeTests(unittest.TestCase):
TRANSLATIONS = {"settings": {"value": {"label": "Value"}}}
def validate_setting(self, setting: dict, plugin_api: object = 6) -> list[str]:
validator = validate_plugins.Validator(Path("/repo"))
validator.validate_settings(
Path("/repo/example/plugin.toml"),
self.TRANSLATIONS,
[{"key": "value", "label_key": "settings.value.label", **setting}],
"setting",
plugin_api,
)
return validator.errors
def test_setting_type_catalog_matches_shell_schema(self) -> None:
self.assertEqual(
validate_plugins.SETTING_TYPES,
{
"string",
"string_list",
"string_map",
"bool",
"int",
"double",
"select",
"file",
"folder",
"glyph",
"color",
},
)
def test_accepts_double_with_numeric_bounds(self) -> None:
self.assertEqual(
self.validate_setting(
{"type": "double", "default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05}
),
[],
)
def test_rejects_invalid_double_default(self) -> None:
errors = self.validate_setting({"type": "double", "default": "fast"})
self.assertTrue(any("default must be a finite number" in error for error in errors))
def test_rejects_invalid_double_range(self) -> None:
errors = self.validate_setting(
{"type": "double", "default": 0.5, "min": 1.0, "max": 0.0}
)
self.assertTrue(any("min must be less than or equal to max" in error for error in errors))
def test_accepts_string_map(self) -> None:
self.assertEqual(
self.validate_setting(
{"type": "string_map", "default": {"eDP-1": "laptop", "DP-1": "monitor"}}
),
[],
)
def test_rejects_non_string_map_value(self) -> None:
errors = self.validate_setting({"type": "string_map", "default": {"eDP-1": 1}})
self.assertTrue(any("default.eDP-1 must be a string" in error for error in errors))
def test_string_map_requires_plugin_api_6(self) -> None:
errors = self.validate_setting({"type": "string_map", "default": {}}, plugin_api=5)
self.assertTrue(any("string_map requires plugin_api >= 6" in error for error in errors))
class WidgetActionsTests(unittest.TestCase): class WidgetActionsTests(unittest.TestCase):
def validate_actions(self, entry: dict, plugin_api: object = 14) -> list[str]: def validate_actions(self, entry: dict, plugin_api: object = 14) -> list[str]:
validator = validate_plugins.Validator(Path("/repo")) validator = validate_plugins.Validator(Path("/repo"))
+70 -12
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import math
import re import re
import sys import sys
import tomllib import tomllib
@@ -109,7 +110,19 @@ ROOT_STRING_FIELDS = (
ROOT_ARRAY_FIELDS = ("dependencies", "tags") ROOT_ARRAY_FIELDS = ("dependencies", "tags")
ENTRY_TYPES = ("widget", "panel", "shortcut", "desktop_widget", "launcher_provider", "service") ENTRY_TYPES = ("widget", "panel", "shortcut", "desktop_widget", "launcher_provider", "service")
SETTING_OWNER_TYPES = ("widget", "panel", "desktop_widget", "launcher_provider") SETTING_OWNER_TYPES = ("widget", "panel", "desktop_widget", "launcher_provider")
SETTING_TYPES = {"string", "string_list", "bool", "glyph", "select", "folder", "file", "int", "color"} SETTING_TYPES = {
"string",
"string_list",
"string_map",
"bool",
"glyph",
"select",
"folder",
"file",
"int",
"double",
"color",
}
PANEL_PLACEMENTS = {"attached", "floating"} PANEL_PLACEMENTS = {"attached", "floating"}
PANEL_KEYBOARD_FOCUS = {"on_demand", "exclusive", "none"} PANEL_KEYBOARD_FOCUS = {"on_demand", "exclusive", "none"}
WIDGET_GESTURES = { WIDGET_GESTURES = {
@@ -171,6 +184,7 @@ SETTING_FIELDS = {
"description_key", "description_key",
"default", "default",
"options", "options",
"extensions",
"min", "min",
"max", "max",
"step", "step",
@@ -885,6 +899,7 @@ class Validator:
translations, translations,
entry["setting"], entry["setting"],
f"{context}.setting", f"{context}.setting",
manifest.get("plugin_api"),
) )
if entry_count == 0: if entry_count == 0:
@@ -907,8 +922,23 @@ class Validator:
return return
default = setting["default"] default = setting["default"]
if setting_type in {"string", "folder", "file"} and not isinstance(default, str): if setting_type in {"string", "folder", "file"}:
self.add_context_error(manifest_path, context, "default must be a string") if not isinstance(default, str):
self.add_context_error(manifest_path, context, "default must be a string")
elif setting_type == "double":
if not is_number(default) or not math.isfinite(default):
self.add_context_error(manifest_path, context, "default must be a finite number")
elif setting_type == "string_map":
if not isinstance(default, dict):
self.add_context_error(manifest_path, context, "default must be a table of string values")
else:
for key, value in default.items():
if not isinstance(value, str):
self.add_context_error(
manifest_path,
context,
f"default.{key} must be a string",
)
elif setting_type in {"glyph", "color"} and not is_non_empty_string(default): elif setting_type in {"glyph", "color"} and not is_non_empty_string(default):
self.add_context_error(manifest_path, context, "default must be a non-empty string") self.add_context_error(manifest_path, context, "default must be a non-empty string")
elif setting_type == "string_list": elif setting_type == "string_list":
@@ -983,18 +1013,24 @@ class Validator:
return values return values
def validate_int_bounds(self, manifest_path: Path, context: str, setting: dict[str, Any]) -> None: def validate_numeric_bounds(self, manifest_path: Path, context: str, setting: dict[str, Any]) -> None:
setting_type = setting.get("type") setting_type = setting.get("type")
bound_values: dict[str, int] = {} bound_values: dict[str, int | float] = {}
for field in ("min", "max", "step"): for field in ("min", "max", "step"):
if field not in setting: if field not in setting:
continue continue
value = setting[field] value = setting[field]
if setting_type != "int": if setting_type not in {"int", "double"}:
self.add_context_error(manifest_path, context, f"{field} is only valid for int settings") self.add_context_error(
elif not is_int(value): manifest_path,
context,
f"{field} is only valid for int or double settings",
)
elif setting_type == "int" and not is_int(value):
self.add_context_error(manifest_path, context, f"{field} must be an integer") self.add_context_error(manifest_path, context, f"{field} must be an integer")
elif setting_type == "double" and (not is_number(value) or not math.isfinite(value)):
self.add_context_error(manifest_path, context, f"{field} must be a finite number")
else: else:
bound_values[field] = value bound_values[field] = value
@@ -1005,7 +1041,10 @@ class Validator:
self.add_context_error(manifest_path, context, "step must be greater than zero") self.add_context_error(manifest_path, context, "step must be greater than zero")
default = setting.get("default") default = setting.get("default")
if setting_type == "int" and is_int(default): valid_default = (setting_type == "int" and is_int(default)) or (
setting_type == "double" and is_number(default) and math.isfinite(default)
)
if valid_default:
if "min" in bound_values and default < bound_values["min"]: if "min" in bound_values and default < bound_values["min"]:
self.add_context_error(manifest_path, context, "default must be greater than or equal to min") self.add_context_error(manifest_path, context, "default must be greater than or equal to min")
if "max" in bound_values and default > bound_values["max"]: if "max" in bound_values and default > bound_values["max"]:
@@ -1049,6 +1088,7 @@ class Validator:
translations: Any | None, translations: Any | None,
settings: Any, settings: Any,
context_prefix: str, context_prefix: str,
plugin_api: Any,
) -> None: ) -> None:
if not isinstance(settings, list): if not isinstance(settings, list):
self.add_context_error(manifest_path, context_prefix, "must be an array of tables") self.add_context_error(manifest_path, context_prefix, "must be an array of tables")
@@ -1100,10 +1140,22 @@ class Validator:
option_values = self.validate_options(manifest_path, translations, context, setting) option_values = self.validate_options(manifest_path, translations, context, setting)
if setting_type in SETTING_TYPES: if setting_type in SETTING_TYPES:
self.validate_default(manifest_path, context, setting_type, setting, option_values) self.validate_default(manifest_path, context, setting_type, setting, option_values)
self.validate_numeric_bounds(manifest_path, context, setting)
self.validate_int_bounds(manifest_path, context, setting) if setting_type == "string_map" and (not is_int(plugin_api) or plugin_api < 6):
self.add_context_error(manifest_path, context, "string_map requires plugin_api >= 6")
self.validate_visible_when(manifest_path, context, setting.get("visible_when")) self.validate_visible_when(manifest_path, context, setting.get("visible_when"))
if "extensions" in setting:
if setting_type != "file":
self.add_context_error(manifest_path, context, "extensions is only valid for file settings")
self.validate_string_list(
manifest_path,
context,
"extensions",
setting["extensions"],
allow_empty=True,
)
if "advanced" in setting and not isinstance(setting["advanced"], bool): if "advanced" in setting and not isinstance(setting["advanced"], bool):
self.add_context_error(manifest_path, context, "advanced must be a bool") self.add_context_error(manifest_path, context, "advanced must be a bool")
@@ -1315,7 +1367,13 @@ class Validator:
self.validate_no_symlinks(manifest_path, plugin_dir) self.validate_no_symlinks(manifest_path, plugin_dir)
if "setting" in manifest: if "setting" in manifest:
self.validate_settings(manifest_path, translations, manifest["setting"], "setting") self.validate_settings(
manifest_path,
translations,
manifest["setting"],
"setting",
manifest.get("plugin_api"),
)
self.validate_entries(manifest_path, manifest, translations) self.validate_entries(manifest_path, manifest, translations)