fix(validator): validate all plugin setting types
This commit is contained in:
@@ -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"))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user