ci(validate): added launcher prefix validation + list of allowed tags
This commit is contained in:
@@ -0,0 +1,69 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
VALIDATOR_PATH = Path(__file__).with_name("validate-plugins.py")
|
||||||
|
SPEC = importlib.util.spec_from_file_location("validate_plugins", VALIDATOR_PATH)
|
||||||
|
assert SPEC is not None and SPEC.loader is not None
|
||||||
|
validate_plugins = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(validate_plugins)
|
||||||
|
|
||||||
|
|
||||||
|
class LauncherPrefixTests(unittest.TestCase):
|
||||||
|
def validate_prefix(self, prefix: str) -> list[str]:
|
||||||
|
validator = validate_plugins.Validator(Path("/repo"))
|
||||||
|
validator.validate_launcher_fields(
|
||||||
|
Path("/repo/example/plugin.toml"),
|
||||||
|
"launcher_provider[0]",
|
||||||
|
{"prefix": prefix},
|
||||||
|
)
|
||||||
|
return validator.errors
|
||||||
|
|
||||||
|
def test_accepts_lowercase_ascii_letters(self) -> None:
|
||||||
|
self.assertEqual(self.validate_prefix("bla"), [])
|
||||||
|
|
||||||
|
def test_rejects_leading_symbol(self) -> None:
|
||||||
|
self.assertNotEqual(self.validate_prefix("/bla"), [])
|
||||||
|
|
||||||
|
def test_rejects_uppercase_letters(self) -> None:
|
||||||
|
self.assertNotEqual(self.validate_prefix("Bla"), [])
|
||||||
|
|
||||||
|
def test_rejects_digits(self) -> None:
|
||||||
|
self.assertNotEqual(self.validate_prefix("bla2"), [])
|
||||||
|
|
||||||
|
def test_rejects_other_symbols(self) -> None:
|
||||||
|
self.assertNotEqual(self.validate_prefix("bla-bla"), [])
|
||||||
|
|
||||||
|
|
||||||
|
class AllowedTagsTests(unittest.TestCase):
|
||||||
|
def validate_tags(self, tags: object) -> list[str]:
|
||||||
|
validator = validate_plugins.Validator(Path("/repo"))
|
||||||
|
validator.validate_tags(Path("/repo/example/plugin.toml"), tags)
|
||||||
|
return validator.errors
|
||||||
|
|
||||||
|
def test_accepts_every_allowed_tag(self) -> None:
|
||||||
|
self.assertEqual(self.validate_tags(sorted(validate_plugins.ALLOWED_TAGS)), [])
|
||||||
|
|
||||||
|
def test_rejects_unknown_tag(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
self.validate_tags(["utility", "unknown"]),
|
||||||
|
[
|
||||||
|
"example/plugin.toml: root: "
|
||||||
|
"tags[1] 'unknown' is not an allowed tag"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_wrong_case(self) -> None:
|
||||||
|
self.assertNotEqual(self.validate_tags(["Utility"]), [])
|
||||||
|
|
||||||
|
def test_retains_string_list_validation(self) -> None:
|
||||||
|
errors = self.validate_tags(["utility", "utility", ""])
|
||||||
|
self.assertTrue(any("duplicate 'utility'" in error for error in errors))
|
||||||
|
self.assertTrue(any("tags[2] must be a non-empty string" in error for error in errors))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -13,6 +13,45 @@ from typing import Any
|
|||||||
|
|
||||||
DEFAULT_ROOT = Path(__file__).resolve().parents[2]
|
DEFAULT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
||||||
|
LAUNCHER_PREFIX_RE = re.compile(r"^[a-z]+$")
|
||||||
|
ALLOWED_TAGS = {
|
||||||
|
"ai",
|
||||||
|
"animation",
|
||||||
|
"audio",
|
||||||
|
"bar",
|
||||||
|
"clock",
|
||||||
|
"countdown",
|
||||||
|
"demo",
|
||||||
|
"desktop",
|
||||||
|
"development",
|
||||||
|
"emoticon",
|
||||||
|
"fun",
|
||||||
|
"gaming",
|
||||||
|
"hardware",
|
||||||
|
"hyprland",
|
||||||
|
"indicator",
|
||||||
|
"labwc",
|
||||||
|
"language",
|
||||||
|
"launcher",
|
||||||
|
"mangowc",
|
||||||
|
"media",
|
||||||
|
"music",
|
||||||
|
"network",
|
||||||
|
"niri",
|
||||||
|
"panel",
|
||||||
|
"privacy",
|
||||||
|
"productivity",
|
||||||
|
"recording",
|
||||||
|
"service",
|
||||||
|
"shortcut",
|
||||||
|
"sway",
|
||||||
|
"system",
|
||||||
|
"theming",
|
||||||
|
"time",
|
||||||
|
"utility",
|
||||||
|
"video",
|
||||||
|
"wallpaper",
|
||||||
|
}
|
||||||
|
|
||||||
# An id segment must be a lowercase flat identifier: the part after the "/" is also the
|
# An id segment must be a lowercase flat identifier: the part after the "/" is also the
|
||||||
# plugin's directory here, its export directory on disk, and its slug on the website.
|
# plugin's directory here, its export directory on disk, and its slug on the website.
|
||||||
@@ -312,6 +351,19 @@ class Validator:
|
|||||||
self.add_context_error(manifest_path, context, f"{field} contains duplicate '{item}'")
|
self.add_context_error(manifest_path, context, f"{field} contains duplicate '{item}'")
|
||||||
seen.add(item)
|
seen.add(item)
|
||||||
|
|
||||||
|
def validate_tags(self, manifest_path: Path, value: Any) -> None:
|
||||||
|
self.validate_string_list(manifest_path, "root", "tags", value, allow_empty=False)
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return
|
||||||
|
|
||||||
|
for index, tag in enumerate(value):
|
||||||
|
if is_non_empty_string(tag) and tag not in ALLOWED_TAGS:
|
||||||
|
self.add_context_error(
|
||||||
|
manifest_path,
|
||||||
|
"root",
|
||||||
|
f"tags[{index}] '{tag}' is not an allowed tag",
|
||||||
|
)
|
||||||
|
|
||||||
def validate_root_fields(self, manifest_path: Path, manifest: dict[str, Any]) -> None:
|
def validate_root_fields(self, manifest_path: Path, manifest: dict[str, Any]) -> None:
|
||||||
unknown = sorted(set(manifest) - ROOT_FIELDS)
|
unknown = sorted(set(manifest) - ROOT_FIELDS)
|
||||||
for field in unknown:
|
for field in unknown:
|
||||||
@@ -337,7 +389,7 @@ class Validator:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if "tags" in manifest:
|
if "tags" in manifest:
|
||||||
self.validate_string_list(manifest_path, "root", "tags", manifest["tags"], allow_empty=False)
|
self.validate_tags(manifest_path, manifest["tags"])
|
||||||
|
|
||||||
if "deprecated" in manifest and not isinstance(manifest["deprecated"], bool):
|
if "deprecated" in manifest and not isinstance(manifest["deprecated"], bool):
|
||||||
self.add_error(manifest_path, "root field 'deprecated' must be a bool")
|
self.add_error(manifest_path, "root field 'deprecated' must be a bool")
|
||||||
@@ -394,6 +446,14 @@ class Validator:
|
|||||||
if field in entry and not is_non_empty_string(entry[field]):
|
if field in entry and not is_non_empty_string(entry[field]):
|
||||||
self.add_context_error(manifest_path, context, f"{field} must be a non-empty string")
|
self.add_context_error(manifest_path, context, f"{field} must be a non-empty string")
|
||||||
|
|
||||||
|
prefix = entry.get("prefix")
|
||||||
|
if is_non_empty_string(prefix) and not LAUNCHER_PREFIX_RE.fullmatch(prefix):
|
||||||
|
self.add_context_error(
|
||||||
|
manifest_path,
|
||||||
|
context,
|
||||||
|
"prefix must contain only lowercase letters (a-z), without a leading symbol",
|
||||||
|
)
|
||||||
|
|
||||||
if "include_in_global_search" in entry and not isinstance(entry["include_in_global_search"], bool):
|
if "include_in_global_search" in entry and not isinstance(entry["include_in_global_search"], bool):
|
||||||
self.add_context_error(
|
self.add_context_error(
|
||||||
manifest_path,
|
manifest_path,
|
||||||
|
|||||||
@@ -23,3 +23,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Validate plugin manifests
|
- name: Validate plugin manifests
|
||||||
run: python3 .github/workflows/validate-plugins.py
|
run: python3 .github/workflows/validate-plugins.py
|
||||||
|
|
||||||
|
- name: Test plugin validator
|
||||||
|
run: python3 -m unittest discover -s .github/workflows -p 'test_*.py'
|
||||||
|
|||||||
Reference in New Issue
Block a user