mirror of
https://github.com/vintasoftware/django-ai-plugins.git
synced 2026-09-14 16:38:37 +08:00
feat: add canonical plugin catalog validation
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"repository": "https://github.com/vintasoftware/django-ai-plugins",
|
||||
"defaults": {
|
||||
"author": {
|
||||
"name": "Vinta Software",
|
||||
"url": "https://www.vinta.com.br/"
|
||||
},
|
||||
"homepage": "https://github.com/vintasoftware/django-ai-plugins",
|
||||
"license": "MIT",
|
||||
"category": "Developer Tools"
|
||||
},
|
||||
"marketplaces": {
|
||||
"claude": {
|
||||
"name": "django-ai-plugins",
|
||||
"display_name": "Django AI Plugins"
|
||||
},
|
||||
"codex": {
|
||||
"name": "vinta-django-ai-plugin",
|
||||
"display_name": "Vinta Django AI"
|
||||
},
|
||||
"cursor": {
|
||||
"name": "django-ai-plugins",
|
||||
"display_name": "Django AI Plugins"
|
||||
}
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"id": "django-expert",
|
||||
"version": "1.0.0",
|
||||
"description": "Comprehensive Django backend development guidelines and best practices. Expert guidance for models, views, templates, DRF, testing, and deployment.",
|
||||
"package": "plugins/django-expert",
|
||||
"capability": {
|
||||
"kind": "skill",
|
||||
"package_path": "skills/SKILL.md"
|
||||
},
|
||||
"hosts": [
|
||||
"claude",
|
||||
"codex",
|
||||
"cursor",
|
||||
"opencode",
|
||||
"agent-skills"
|
||||
],
|
||||
"keywords": [
|
||||
"django",
|
||||
"backend",
|
||||
"drf",
|
||||
"python"
|
||||
],
|
||||
"interface": {
|
||||
"display_name": "Django Expert",
|
||||
"short_description": "Django backend guidance.",
|
||||
"long_description": "Expert Django backend development guidance covering models, views, DRF, testing, security, performance, and deployment.",
|
||||
"default_prompts": [
|
||||
"Create a Django model for user profiles.",
|
||||
"Build a DRF endpoint with filtering and pagination.",
|
||||
"Review this queryset for N+1 issues."
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "django-celery-expert",
|
||||
"version": "1.0.0",
|
||||
"description": "Django and Celery best practices for asynchronous task processing. Expert guidance for task design, worker configuration, monitoring, error handling, and production deployment.",
|
||||
"package": "plugins/django-celery-expert",
|
||||
"capability": {
|
||||
"kind": "skill",
|
||||
"package_path": "skills/SKILL.md"
|
||||
},
|
||||
"hosts": [
|
||||
"claude",
|
||||
"codex",
|
||||
"cursor",
|
||||
"opencode",
|
||||
"agent-skills"
|
||||
],
|
||||
"keywords": [
|
||||
"django",
|
||||
"celery",
|
||||
"async",
|
||||
"tasks"
|
||||
],
|
||||
"interface": {
|
||||
"display_name": "Django Celery Expert",
|
||||
"short_description": "Celery patterns for Django.",
|
||||
"long_description": "Expert guidance for background task design, worker configuration, retries, scheduling, observability, and production Celery deployments in Django projects.",
|
||||
"default_prompts": [
|
||||
"Create a Celery task with retry and backoff.",
|
||||
"Set up Celery Beat for scheduled reports.",
|
||||
"Review this task for idempotency risks."
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cdrf-expert",
|
||||
"version": "1.0.0",
|
||||
"description": "Expert guidance for Django REST Framework class-based views using Classy DRF (cdrf.co). Helps choose the right DRF generic/viewset class and override methods safely.",
|
||||
"package": "plugins/cdrf-expert",
|
||||
"capability": {
|
||||
"kind": "skill",
|
||||
"package_path": "skills/SKILL.md"
|
||||
},
|
||||
"hosts": [
|
||||
"claude",
|
||||
"codex",
|
||||
"cursor",
|
||||
"opencode",
|
||||
"agent-skills"
|
||||
],
|
||||
"keywords": [
|
||||
"django-rest-framework",
|
||||
"drf",
|
||||
"cdrf",
|
||||
"api"
|
||||
],
|
||||
"interface": {
|
||||
"display_name": "CDRF Expert",
|
||||
"short_description": "DRF class and override guidance.",
|
||||
"long_description": "Helps choose the correct Django REST Framework class-based view, trace request lifecycle and MRO, and select the safest override hook.",
|
||||
"default_prompts": [
|
||||
"Should this be APIView or ModelViewSet?",
|
||||
"Which hook should I override: create or perform_create?",
|
||||
"Trace this DRF method through the MRO."
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "django-safe-migration",
|
||||
"version": "1.0.0",
|
||||
"description": "Write, review, and rewrite Django migrations for PostgreSQL with zero-downtime guarantees. Covers two-file splits, AddIndexConcurrently, FK NOT VALID + VALIDATE, db_default for NOT NULL columns, and RunPython safety rules.",
|
||||
"package": "plugins/django-safe-migration",
|
||||
"capability": {
|
||||
"kind": "skill",
|
||||
"package_path": "skills/django-safe-migration/SKILL.md"
|
||||
},
|
||||
"hosts": [
|
||||
"claude",
|
||||
"codex",
|
||||
"cursor",
|
||||
"opencode",
|
||||
"agent-skills"
|
||||
],
|
||||
"keywords": [
|
||||
"django",
|
||||
"migrations",
|
||||
"postgresql",
|
||||
"zero-downtime"
|
||||
],
|
||||
"interface": {
|
||||
"display_name": "Django Safe Migration",
|
||||
"short_description": "Zero-downtime Django migrations.",
|
||||
"long_description": "Guidance for writing, reviewing, and rewriting Django migrations for PostgreSQL rolling deploys.",
|
||||
"default_prompts": [
|
||||
"Review this migration for zero-downtime safety."
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"codex": {
|
||||
"website_url": "https://github.com/vintasoftware/django-safe-migration-plugin",
|
||||
"privacy_policy_url": "https://www.vinta.com.br/privacy-policy",
|
||||
"terms_of_service_url": "https://github.com/vintasoftware/django-safe-migration-plugin/blob/main/LICENSE"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "django-reviewer",
|
||||
"version": "1.0.0",
|
||||
"description": "Reviews and refines Django/Python code for clarity, consistency, and maintainability while preserving all functionality. Applies Django best practices, PEP 8, and project standards.",
|
||||
"package": "plugins/django-reviewer",
|
||||
"capability": {
|
||||
"kind": "hybrid",
|
||||
"package_path": "agents/django-reviewer.md"
|
||||
},
|
||||
"hosts": [
|
||||
"claude",
|
||||
"codex",
|
||||
"cursor",
|
||||
"opencode",
|
||||
"agent-skills"
|
||||
],
|
||||
"keywords": [
|
||||
"django",
|
||||
"code-review",
|
||||
"python",
|
||||
"agent"
|
||||
],
|
||||
"interface": {
|
||||
"display_name": "Django Reviewer",
|
||||
"short_description": "Django and Python code review.",
|
||||
"long_description": "Code review focused on Django and Python clarity, consistency, maintainability, and common ORM or DRF anti-patterns.",
|
||||
"default_prompts": [
|
||||
"Review my recent Django changes for anti-patterns.",
|
||||
"Check this queryset for performance regressions.",
|
||||
"Refine this serializer without changing behavior."
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the canonical plugin catalog and generated distribution metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CATALOG_PATH = ROOT / "plugins" / "catalog.json"
|
||||
MARKETPLACE_PATHS = {
|
||||
"claude": ROOT / ".claude-plugin" / "marketplace.json",
|
||||
"codex": ROOT / ".agents" / "plugins" / "marketplace.json",
|
||||
}
|
||||
REQUIRED_PLUGIN_FIELDS = {
|
||||
"id",
|
||||
"version",
|
||||
"description",
|
||||
"package",
|
||||
"capability",
|
||||
"hosts",
|
||||
"keywords",
|
||||
"interface",
|
||||
}
|
||||
ALLOWED_CAPABILITY_KINDS = {"skill", "agent", "hybrid"}
|
||||
ALLOWED_HOSTS = {"claude", "codex", "cursor", "opencode", "agent-skills"}
|
||||
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
|
||||
|
||||
|
||||
class ValidationFailure(RuntimeError):
|
||||
"""Raised when an input file cannot be parsed for validation."""
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text())
|
||||
except FileNotFoundError as error:
|
||||
raise ValidationFailure(f"{path}: file not found") from error
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValidationFailure(
|
||||
f"{path}: invalid JSON at line {error.lineno}, column {error.colno}"
|
||||
) from error
|
||||
if not isinstance(value, dict):
|
||||
raise ValidationFailure(f"{path}: root value must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_relative_path(value: object) -> PurePosixPath | None:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
path = PurePosixPath(value)
|
||||
if path.is_absolute() or ".." in path.parts or "." in path.parts:
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def _plugin_label(plugin: object, index: int) -> str:
|
||||
if isinstance(plugin, dict) and isinstance(plugin.get("id"), str):
|
||||
return f"plugin '{plugin['id']}'"
|
||||
return f"plugin at index {index}"
|
||||
|
||||
|
||||
def validate_catalog(catalog: dict[str, Any], root: Path = ROOT) -> list[str]:
|
||||
errors: list[str] = []
|
||||
if catalog.get("schema_version") != 1:
|
||||
errors.append("catalog schema_version must be 1")
|
||||
|
||||
plugins = catalog.get("plugins")
|
||||
if not isinstance(plugins, list) or not plugins:
|
||||
return errors + ["catalog plugins must be a non-empty array"]
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
for index, plugin in enumerate(plugins):
|
||||
label = _plugin_label(plugin, index)
|
||||
if not isinstance(plugin, dict):
|
||||
errors.append(f"{label} must be an object")
|
||||
continue
|
||||
|
||||
missing = sorted(REQUIRED_PLUGIN_FIELDS - plugin.keys())
|
||||
for field in missing:
|
||||
errors.append(f"{label} is missing required field '{field}'")
|
||||
|
||||
plugin_id = plugin.get("id")
|
||||
if not isinstance(plugin_id, str) or not plugin_id:
|
||||
errors.append(f"{label} has an invalid id")
|
||||
elif plugin_id in seen_ids:
|
||||
errors.append(f"duplicate plugin id '{plugin_id}'")
|
||||
else:
|
||||
seen_ids.add(plugin_id)
|
||||
|
||||
version = plugin.get("version")
|
||||
if not isinstance(version, str) or not SEMVER.fullmatch(version):
|
||||
errors.append(f"{label} has invalid version '{version}'")
|
||||
|
||||
description = plugin.get("description")
|
||||
if not isinstance(description, str) or not description.strip():
|
||||
errors.append(f"{label} has invalid description")
|
||||
|
||||
package_value = plugin.get("package")
|
||||
package_relative = _safe_relative_path(package_value)
|
||||
package_root: Path | None = None
|
||||
if package_relative is None:
|
||||
errors.append(f"{label} has unsafe package path '{package_value}'")
|
||||
else:
|
||||
package_root = root.joinpath(*package_relative.parts)
|
||||
if not package_root.is_dir():
|
||||
errors.append(f"{label} references unknown package root '{package_value}'")
|
||||
|
||||
capability = plugin.get("capability")
|
||||
if not isinstance(capability, dict):
|
||||
errors.append(f"{label} capability must be an object")
|
||||
else:
|
||||
kind = capability.get("kind")
|
||||
if kind not in ALLOWED_CAPABILITY_KINDS:
|
||||
errors.append(f"{label} has unsupported capability kind '{kind}'")
|
||||
surface_value = capability.get("package_path")
|
||||
surface_relative = _safe_relative_path(surface_value)
|
||||
if surface_relative is None:
|
||||
errors.append(f"{label} has unsafe capability path '{surface_value}'")
|
||||
elif package_root is not None and package_root.is_dir():
|
||||
surface = package_root.joinpath(*surface_relative.parts)
|
||||
if surface.is_symlink() or not surface.is_file():
|
||||
errors.append(
|
||||
f"{label} advertises no usable surface at "
|
||||
f"'{package_value}/{surface_value}'"
|
||||
)
|
||||
|
||||
hosts = plugin.get("hosts")
|
||||
if not isinstance(hosts, list) or not hosts:
|
||||
errors.append(f"{label} hosts must be a non-empty array")
|
||||
else:
|
||||
unknown_hosts = sorted(
|
||||
host for host in hosts if not isinstance(host, str) or host not in ALLOWED_HOSTS
|
||||
)
|
||||
for host in unknown_hosts:
|
||||
errors.append(f"{label} has unsupported host '{host}'")
|
||||
if len(hosts) != len(set(host for host in hosts if isinstance(host, str))):
|
||||
errors.append(f"{label} has duplicate hosts")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _marketplace_source(entry: dict[str, Any]) -> object:
|
||||
source = entry.get("source")
|
||||
if isinstance(source, dict):
|
||||
return source.get("path")
|
||||
return source
|
||||
|
||||
|
||||
def validate_marketplace(
|
||||
catalog: dict[str, Any], marketplace: dict[str, Any], target: str
|
||||
) -> list[str]:
|
||||
catalog_plugins = {
|
||||
plugin["id"]: plugin
|
||||
for plugin in catalog.get("plugins", [])
|
||||
if isinstance(plugin, dict) and isinstance(plugin.get("id"), str)
|
||||
}
|
||||
entries = marketplace.get("plugins")
|
||||
if not isinstance(entries, list):
|
||||
return [f"{target} marketplace plugins must be an array"]
|
||||
|
||||
errors: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict) or not isinstance(entry.get("name"), str):
|
||||
errors.append(f"{target} marketplace contains an invalid plugin record")
|
||||
continue
|
||||
plugin_id = entry["name"]
|
||||
if plugin_id in seen:
|
||||
errors.append(f"{target} marketplace has duplicate plugin '{plugin_id}'")
|
||||
continue
|
||||
seen.add(plugin_id)
|
||||
plugin = catalog_plugins.get(plugin_id)
|
||||
if plugin is None:
|
||||
errors.append(
|
||||
f"{target} marketplace has orphan plugin '{plugin_id}' "
|
||||
"(generator-owned record)"
|
||||
)
|
||||
continue
|
||||
|
||||
expected_source = f"./{plugin['package']}"
|
||||
source = _marketplace_source(entry)
|
||||
if source != expected_source:
|
||||
errors.append(
|
||||
f"{target} marketplace plugin '{plugin_id}' source is '{source}', "
|
||||
f"expected '{expected_source}'"
|
||||
)
|
||||
if "description" in entry and entry["description"] != plugin["description"]:
|
||||
errors.append(
|
||||
f"{target} marketplace plugin '{plugin_id}' description differs "
|
||||
"from catalog"
|
||||
)
|
||||
|
||||
for plugin_id in catalog_plugins:
|
||||
if plugin_id not in seen:
|
||||
errors.append(f"{target} marketplace is missing plugin '{plugin_id}'")
|
||||
return errors
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--catalog-only",
|
||||
action="store_true",
|
||||
help="validate canonical catalog and package surfaces without generated outputs",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_args(argv)
|
||||
try:
|
||||
catalog = load_json(CATALOG_PATH)
|
||||
except ValidationFailure as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
errors = validate_catalog(catalog, ROOT)
|
||||
if not arguments.catalog_only:
|
||||
for target, path in MARKETPLACE_PATHS.items():
|
||||
try:
|
||||
marketplace = load_json(path)
|
||||
except ValidationFailure as error:
|
||||
errors.append(str(error))
|
||||
continue
|
||||
errors.extend(validate_marketplace(catalog, marketplace, target))
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
scope = "catalog and package surfaces" if arguments.catalog_only else "repository"
|
||||
print(f"Validated {len(catalog['plugins'])} plugins ({scope}).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"name": "django-expert",
|
||||
"source": "./plugins/django-expert",
|
||||
"description": "Drifted description"
|
||||
},
|
||||
{
|
||||
"name": "django-celery-expert",
|
||||
"source": "./plugins/django-celery-expert",
|
||||
"description": "Django and Celery best practices for asynchronous task processing. Expert guidance for task design, worker configuration, monitoring, error handling, and production deployment."
|
||||
},
|
||||
{
|
||||
"name": "cdrf-expert",
|
||||
"source": "./plugins/cdrf-expert",
|
||||
"description": "Expert guidance for Django REST Framework class-based views using Classy DRF (cdrf.co). Helps choose the right DRF generic/viewset class and override methods safely."
|
||||
},
|
||||
{
|
||||
"name": "django-reviewer",
|
||||
"source": "./plugins/django-reviewer",
|
||||
"description": "Reviews and refines Django/Python code for clarity, consistency, and maintainability while preserving all functionality. Applies Django best practices, PEP 8, and project standards."
|
||||
},
|
||||
{
|
||||
"name": "orphan-plugin",
|
||||
"source": "./plugins/orphan-plugin",
|
||||
"description": "Not declared in the catalog."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CATALOG_PATH = ROOT / "plugins" / "catalog.json"
|
||||
VALIDATOR_PATH = ROOT / "scripts" / "validate_plugins.py"
|
||||
FIXTURES = ROOT / "tests" / "fixtures" / "catalog"
|
||||
|
||||
|
||||
def load_validator():
|
||||
spec = importlib.util.spec_from_file_location("validate_plugins", VALIDATOR_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
validator = load_validator()
|
||||
|
||||
|
||||
class PluginCatalogTests(unittest.TestCase):
|
||||
def load_catalog(self):
|
||||
return json.loads(CATALOG_PATH.read_text())
|
||||
|
||||
def test_repository_catalog_contains_the_five_stable_plugins(self):
|
||||
catalog = self.load_catalog()
|
||||
|
||||
errors = validator.validate_catalog(catalog, ROOT)
|
||||
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(
|
||||
[plugin["id"] for plugin in catalog["plugins"]],
|
||||
[
|
||||
"django-expert",
|
||||
"django-celery-expert",
|
||||
"cdrf-expert",
|
||||
"django-safe-migration",
|
||||
"django-reviewer",
|
||||
],
|
||||
)
|
||||
|
||||
def test_catalog_rejects_duplicate_ids_and_unknown_roots(self):
|
||||
catalog = self.load_catalog()
|
||||
duplicate = copy.deepcopy(catalog["plugins"][0])
|
||||
duplicate["package"] = "plugins/does-not-exist"
|
||||
catalog["plugins"].append(duplicate)
|
||||
|
||||
errors = validator.validate_catalog(catalog, ROOT)
|
||||
|
||||
self.assertTrue(any("duplicate plugin id 'django-expert'" in error for error in errors))
|
||||
self.assertTrue(any("plugins/does-not-exist" in error for error in errors))
|
||||
|
||||
def test_catalog_rejects_missing_metadata_invalid_versions_and_kinds(self):
|
||||
catalog = self.load_catalog()
|
||||
plugin = catalog["plugins"][0]
|
||||
del plugin["description"]
|
||||
plugin["version"] = "next"
|
||||
plugin["capability"]["kind"] = "workflow"
|
||||
|
||||
errors = validator.validate_catalog(catalog, ROOT)
|
||||
|
||||
self.assertTrue(any("description" in error for error in errors))
|
||||
self.assertTrue(any("version 'next'" in error for error in errors))
|
||||
self.assertTrue(any("capability kind 'workflow'" in error for error in errors))
|
||||
|
||||
def test_catalog_rejects_advertised_plugin_without_executable_surface(self):
|
||||
catalog = self.load_catalog()
|
||||
plugin = catalog["plugins"][0]
|
||||
plugin["capability"]["package_path"] = "skills/missing/SKILL.md"
|
||||
|
||||
errors = validator.validate_catalog(catalog, ROOT)
|
||||
|
||||
self.assertTrue(any("usable surface" in error for error in errors))
|
||||
|
||||
def test_marketplace_validation_reports_missing_extra_and_metadata_drift(self):
|
||||
catalog = self.load_catalog()
|
||||
marketplace = json.loads(
|
||||
(FIXTURES / "marketplaces" / "invalid-claude-marketplace.json").read_text()
|
||||
)
|
||||
|
||||
errors = validator.validate_marketplace(catalog, marketplace, "claude")
|
||||
|
||||
self.assertTrue(any("missing plugin 'django-safe-migration'" in error for error in errors))
|
||||
self.assertTrue(any("orphan plugin 'orphan-plugin'" in error for error in errors))
|
||||
self.assertTrue(any("django-expert" in error and "description" in error for error in errors))
|
||||
|
||||
def test_removing_catalog_record_reports_only_generated_orphans(self):
|
||||
catalog = self.load_catalog()
|
||||
removed = catalog["plugins"].pop()
|
||||
marketplace = {
|
||||
"plugins": [
|
||||
{
|
||||
"name": plugin["id"],
|
||||
"source": f"./{plugin['package']}",
|
||||
"description": plugin["description"],
|
||||
}
|
||||
for plugin in catalog["plugins"]
|
||||
]
|
||||
+ [
|
||||
{
|
||||
"name": removed["id"],
|
||||
"source": f"./{removed['package']}",
|
||||
"description": removed["description"],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
errors = validator.validate_marketplace(catalog, marketplace, "claude")
|
||||
|
||||
self.assertEqual(
|
||||
errors,
|
||||
[
|
||||
"claude marketplace has orphan plugin 'django-reviewer' "
|
||||
"(generator-owned record)"
|
||||
],
|
||||
)
|
||||
self.assertTrue((ROOT / removed["package"]).is_dir())
|
||||
|
||||
def test_load_json_reports_malformed_input(self):
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
path = Path(temporary_directory) / "bad.json"
|
||||
path.write_text("{")
|
||||
|
||||
with self.assertRaisesRegex(validator.ValidationFailure, "invalid JSON"):
|
||||
validator.load_json(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user