Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ jobs:
python -m pip install --upgrade pip
pip install -e ".[schema]"

- name: Validate plugin config example
run: |
python validation/scripts/validate_plugin_config.py

- name: Validate all example POP objects
run: |
for f in examples/*.json; do
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,22 @@ CrewAI-style runtime roles.
The demo workflow can load registry extensions from builtin modules and
external plugin packages. By default, the runtime looks for a local
`plugin_config.json` file at the repository root if present. An example
template is available at [`plugin_config.example.json`](plugin_config.example.json).
template is available at [`plugin_config.example.json`](plugin_config.example.json),
and the config shape is defined by
[`plugin_config.schema.json`](plugin_config.schema.json).
The example config is also validated in CI so schema drift is caught during PR review.

For temporary overrides, you can also point discovery at external plugin
packages with `POP_PLUGIN_CONFIG_FILE`, `POP_PLUGIN_PACKAGES`, and
`POP_PLUGIN_PACKAGE_PATHS`.

If you use file-based plugin config validation outside this repository,
install the optional schema dependency:

```bash
pip install "pop-persona[schema]"
```

## Persona Layer Diagram

```mermaid
Expand Down
28 changes: 28 additions & 0 deletions demos/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
PLUGIN_PACKAGE_PATHS_ENV = "POP_PLUGIN_PACKAGE_PATHS"
PLUGIN_CONFIG_FILE_ENV = "POP_PLUGIN_CONFIG_FILE"
DEFAULT_PLUGIN_CONFIG_PATH = PROJECT_ROOT / "plugin_config.json"
PLUGIN_CONFIG_SCHEMA_PATH = PROJECT_ROOT / "plugin_config.schema.json"


@dataclass(frozen=True)
Expand Down Expand Up @@ -132,13 +133,40 @@ def load_plugin_config(
payload = json.loads(config_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError("Plugin config must be a JSON object.")
validate_plugin_config_payload(payload)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid hard-failing plugin config when jsonschema is absent

This unconditional call introduces a runtime regression for plugin discovery: any user with a plugin_config.json (or POP_PLUGIN_CONFIG_FILE) now fails immediately if jsonschema is not installed, even though jsonschema is only an optional extra dependency. In a default environment, this blocks all file-based plugin loading paths that previously worked, so discovery should either gracefully skip schema validation when the extra is missing or make the dependency mandatory for this workflow.

Useful? React with 👍 / 👎.


base_dir = config_path.parent
package_paths = normalize_plugin_paths(payload, base_dir)
package_names = normalize_plugin_packages(payload)
return PluginDiscoveryConfig(config_path, package_names, package_paths)


def load_plugin_config_schema() -> dict[str, Any]:
return json.loads(PLUGIN_CONFIG_SCHEMA_PATH.read_text(encoding="utf-8"))


def validation_error_path(error: Any) -> str:
parts = [str(part) for part in getattr(error, "absolute_path", ())]
return ".".join(parts) if parts else "<root>"


def validate_plugin_config_payload(payload: dict[str, Any]) -> None:
try:
import jsonschema
except ModuleNotFoundError as exc:
raise RuntimeError(
"Plugin config validation requires `jsonschema`. "
"Install `pop-persona[schema]` or add `jsonschema` to your environment."
) from exc

schema = load_plugin_config_schema()
try:
jsonschema.validate(instance=payload, schema=schema)
except jsonschema.ValidationError as exc:
path = validation_error_path(exc)
raise ValueError(f"Invalid plugin config at {path}: {exc.message}") from exc


def configure_plugin_search_paths(paths: tuple[Path, ...]) -> tuple[Path, ...]:
configured: list[Path] = []
for path in paths:
Expand Down
1 change: 1 addition & 0 deletions plugin_config.example.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"$schema": "./plugin_config.schema.json",
"plugin_paths": [
{
"path": "/absolute/path/to/pop_plugin_package_root",
Expand Down
55 changes: 55 additions & 0 deletions plugin_config.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/joy7758/persona-object-protocol/plugin_config.schema.json",
"title": "POP Plugin Discovery Config",
"type": "object",
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string",
"minLength": 1
},
"plugin_paths": {
"type": "array",
"items": {
"oneOf": [
{
"type": "string",
"minLength": 1
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"path": {
"type": "string",
"minLength": 1
},
"package_name": {
"type": "string",
"minLength": 1
}
},
"required": [
"path"
]
}
]
}
},
"plugin_packages": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"plugin_package_paths": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
}
}
}
59 changes: 59 additions & 0 deletions validation/scripts/validate_plugin_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
from pathlib import Path
from typing import Any

from jsonschema import Draft202012Validator


ROOT = Path(__file__).resolve().parents[2]
DEFAULT_CONFIG_PATH = ROOT / "plugin_config.example.json"
DEFAULT_SCHEMA_PATH = ROOT / "plugin_config.schema.json"


def load_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))


def main() -> None:
parser = argparse.ArgumentParser(
description="Validate a plugin config file against the repository schema."
)
parser.add_argument(
"config_path",
nargs="?",
type=Path,
default=DEFAULT_CONFIG_PATH,
help="Path to the plugin config file to validate.",
)
parser.add_argument(
"--schema",
type=Path,
default=DEFAULT_SCHEMA_PATH,
help="Path to the plugin config schema file.",
)
args = parser.parse_args()

config_path = args.config_path.resolve()
schema_path = args.schema.resolve()
schema = load_json(schema_path)
payload = load_json(config_path)

validator = Draft202012Validator(schema)
errors = sorted(
validator.iter_errors(payload),
key=lambda error: [str(part) for part in error.absolute_path],
)
if errors:
first_error = errors[0]
path = ".".join(str(part) for part in first_error.absolute_path) or "<root>"
raise ValueError(f"{config_path}: invalid at {path}: {first_error.message}")

print(f"Validated {config_path.relative_to(ROOT)} against {schema_path.relative_to(ROOT)}")


if __name__ == "__main__":
main()
Loading