ci(validate): proper key format detection
This commit is contained in:
@@ -125,6 +125,82 @@ class PluginConfigAccessorTests(unittest.TestCase):
|
|||||||
self.assertEqual(validate_plugins.obsolete_config_accessors(source), [])
|
self.assertEqual(validate_plugins.obsolete_config_accessors(source), [])
|
||||||
|
|
||||||
|
|
||||||
|
class TranslationKeyTests(unittest.TestCase):
|
||||||
|
def test_accepts_valid_keys(self) -> None:
|
||||||
|
for key in (
|
||||||
|
"settings.translation_language.label",
|
||||||
|
"settings.translation_language.options.zh-hans",
|
||||||
|
"settings.translation_language.options.en",
|
||||||
|
"a.b-c.d_e.f0",
|
||||||
|
):
|
||||||
|
with self.subTest(key=key):
|
||||||
|
self.assertTrue(validate_plugins.is_valid_translation_key(key))
|
||||||
|
|
||||||
|
def test_rejects_invalid_keys(self) -> None:
|
||||||
|
for key in (
|
||||||
|
"settings.options.zh-Hans", # uppercase segment
|
||||||
|
"settings.options.zh-Hant",
|
||||||
|
"settings.Label",
|
||||||
|
"settings._leading", # leading underscore in a segment
|
||||||
|
"settings..options", # empty segment
|
||||||
|
"",
|
||||||
|
):
|
||||||
|
with self.subTest(key=key):
|
||||||
|
self.assertFalse(validate_plugins.is_valid_translation_key(key))
|
||||||
|
|
||||||
|
def test_walks_nested_keys_and_reports_full_paths(self) -> None:
|
||||||
|
translations = {
|
||||||
|
"settings": {
|
||||||
|
"translation_language": {
|
||||||
|
"options": {"zh-Hans": "Simplified", "zh-Hant": "Traditional", "en": "English"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
validate_plugins.invalid_translation_keys(translations),
|
||||||
|
[
|
||||||
|
"settings.translation_language.options.zh-Hans",
|
||||||
|
"settings.translation_language.options.zh-Hant",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_keys(self, translations: object) -> list[str]:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
plugin_dir = Path(directory) / "example"
|
||||||
|
plugin_dir.mkdir()
|
||||||
|
validator = validate_plugins.Validator(Path(directory))
|
||||||
|
validator.validate_translation_keys(plugin_dir, translations)
|
||||||
|
return validator.errors
|
||||||
|
|
||||||
|
def test_reports_bad_json_keys_without_a_reference(self) -> None:
|
||||||
|
errors = self.validate_keys({"settings": {"options": {"zh-Hans": "Simplified"}}})
|
||||||
|
self.assertEqual(len(errors), 1)
|
||||||
|
self.assertIn("settings.options.zh-Hans", errors[0])
|
||||||
|
self.assertIn("invalid translation key format", errors[0])
|
||||||
|
|
||||||
|
def test_accepts_valid_json_keys(self) -> None:
|
||||||
|
self.assertEqual(self.validate_keys({"settings": {"options": {"zh-hans": "Simplified"}}}), [])
|
||||||
|
|
||||||
|
def validate_reference(self, label_key: object) -> list[str]:
|
||||||
|
validator = validate_plugins.Validator(Path("/repo"))
|
||||||
|
validator.validate_translation_key(
|
||||||
|
Path("/repo/example/plugin.toml"),
|
||||||
|
{label_key: "x"} if isinstance(label_key, str) else {},
|
||||||
|
"setting[0]",
|
||||||
|
"label_key",
|
||||||
|
label_key,
|
||||||
|
)
|
||||||
|
return validator.errors
|
||||||
|
|
||||||
|
def test_rejects_badly_formatted_reference(self) -> None:
|
||||||
|
errors = self.validate_reference("settings.options.zh-Hans")
|
||||||
|
self.assertEqual(len(errors), 1)
|
||||||
|
self.assertIn("is not a valid translation key", errors[0])
|
||||||
|
|
||||||
|
def test_accepts_well_formatted_existing_reference(self) -> None:
|
||||||
|
self.assertEqual(self.validate_reference("settings.options.zh-hans"), [])
|
||||||
|
|
||||||
|
|
||||||
class ReadmeTests(unittest.TestCase):
|
class ReadmeTests(unittest.TestCase):
|
||||||
MANIFEST = {
|
MANIFEST = {
|
||||||
"id": "me/example",
|
"id": "me/example",
|
||||||
|
|||||||
@@ -67,6 +67,14 @@ ID_SEGMENT_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
|||||||
# Names the website reserves for its own routes; a plugin folder cannot take one.
|
# Names the website reserves for its own routes; a plugin folder cannot take one.
|
||||||
RESERVED_NAMES = {"license", "readme", "index", "api", "admin", "static", "assets"}
|
RESERVED_NAMES = {"license", "readme", "index", "api", "admin", "static", "assets"}
|
||||||
|
|
||||||
|
# The store flattens every plugin's translations under its id and rejects keys that are not
|
||||||
|
# lowercase. A dotted key is a path of segments; each segment allows a-z, 0-9, dashes, and
|
||||||
|
# underscores, but an underscore may not lead a segment. Uppercase (e.g. "zh-Hans") is out.
|
||||||
|
TRANSLATION_KEY_SEGMENT_RE = re.compile(r"[a-z0-9-][a-z0-9_-]*")
|
||||||
|
TRANSLATION_KEY_RULE = (
|
||||||
|
"keys must be lowercase and contain only a-z, 0-9, dots, dashes, and non-leading underscores"
|
||||||
|
)
|
||||||
|
|
||||||
# Files every published plugin ships: the site renders the README as the plugin page and
|
# Files every published plugin ships: the site renders the README as the plugin page and
|
||||||
# the thumbnail as its card, and the English catalog backs every label_key.
|
# the thumbnail as its card, and the English catalog backs every label_key.
|
||||||
REQUIRED_PLUGIN_FILES = ("README.md", "thumbnail.webp", "translations/en.json")
|
REQUIRED_PLUGIN_FILES = ("README.md", "thumbnail.webp", "translations/en.json")
|
||||||
@@ -354,6 +362,25 @@ def webp_dimensions(header: bytes) -> tuple[int, int] | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_translation_key(key: str) -> bool:
|
||||||
|
"""True if a dotted translation key obeys the store's lowercase key rule."""
|
||||||
|
parts = key.split(".")
|
||||||
|
return all(TRANSLATION_KEY_SEGMENT_RE.fullmatch(part) for part in parts)
|
||||||
|
|
||||||
|
|
||||||
|
def invalid_translation_keys(node: Any, prefix: str = "") -> list[str]:
|
||||||
|
"""Return dotted paths in a translations tree whose own segment breaks the key rule."""
|
||||||
|
invalid: list[str] = []
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
return invalid
|
||||||
|
for key, value in node.items():
|
||||||
|
path = f"{prefix}.{key}" if prefix else key
|
||||||
|
if not isinstance(key, str) or not is_valid_translation_key(key):
|
||||||
|
invalid.append(path)
|
||||||
|
invalid.extend(invalid_translation_keys(value, path))
|
||||||
|
return invalid
|
||||||
|
|
||||||
|
|
||||||
def has_key_path(data: Any, dotted_key: str) -> bool:
|
def has_key_path(data: Any, dotted_key: str) -> bool:
|
||||||
if isinstance(data, dict) and dotted_key in data:
|
if isinstance(data, dict) and dotted_key in data:
|
||||||
return True
|
return True
|
||||||
@@ -419,6 +446,14 @@ class Validator:
|
|||||||
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")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not is_valid_translation_key(value):
|
||||||
|
self.add_context_error(
|
||||||
|
manifest_path,
|
||||||
|
context,
|
||||||
|
f"{field} '{value}' is not a valid translation key: {TRANSLATION_KEY_RULE}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if translations is None:
|
if translations is None:
|
||||||
self.add_context_error(
|
self.add_context_error(
|
||||||
manifest_path,
|
manifest_path,
|
||||||
@@ -950,6 +985,20 @@ class Validator:
|
|||||||
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")
|
||||||
|
|
||||||
|
def validate_translation_keys(self, plugin_dir: Path, translations: Any | None) -> None:
|
||||||
|
# The store rejects the whole plugin if any translation key is not lowercase, so catch
|
||||||
|
# bad keys (e.g. "zh-Hans") here even when no label_key references them.
|
||||||
|
if not isinstance(translations, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
invalid = invalid_translation_keys(translations)
|
||||||
|
if invalid:
|
||||||
|
path = plugin_dir / "translations" / "en.json"
|
||||||
|
self.add_error(
|
||||||
|
path,
|
||||||
|
f"invalid translation key format: {', '.join(invalid)}; {TRANSLATION_KEY_RULE}",
|
||||||
|
)
|
||||||
|
|
||||||
def validate_required_files(self, manifest_path: Path, plugin_dir: Path) -> None:
|
def validate_required_files(self, manifest_path: Path, plugin_dir: Path) -> None:
|
||||||
for required in REQUIRED_PLUGIN_FILES:
|
for required in REQUIRED_PLUGIN_FILES:
|
||||||
if not (plugin_dir / required).is_file():
|
if not (plugin_dir / required).is_file():
|
||||||
@@ -1136,6 +1185,7 @@ class Validator:
|
|||||||
translations = self.load_english_translations(plugin_dir)
|
translations = self.load_english_translations(plugin_dir)
|
||||||
|
|
||||||
self.validate_root_fields(manifest_path, manifest)
|
self.validate_root_fields(manifest_path, manifest)
|
||||||
|
self.validate_translation_keys(plugin_dir, translations)
|
||||||
self.validate_required_files(manifest_path, plugin_dir)
|
self.validate_required_files(manifest_path, plugin_dir)
|
||||||
self.validate_thumbnail(manifest_path, plugin_dir)
|
self.validate_thumbnail(manifest_path, plugin_dir)
|
||||||
self.validate_readme(plugin_dir, manifest)
|
self.validate_readme(plugin_dir, manifest)
|
||||||
|
|||||||
Reference in New Issue
Block a user