149 lines
6.6 KiB
Python
149 lines
6.6 KiB
Python
"""Plugin-contributed Automation Blueprints (``ctx.register_automation_blueprint``).
|
|
|
|
A plugin hands over the same fields an in-repo :class:`~cron.blueprint_catalog.AutomationBlueprint`
|
|
carries; :func:`build_plugin_blueprint` validates them (bad input raises ``ValueError``, which the
|
|
registrar logs and ignores) and namespaces the key as ``<plugin>:<key>``, so a plugin can never
|
|
shadow a built-in. :func:`plugin_blueprints` reads the ACTIVE profile's plugin manager at call time;
|
|
plugins load per profile, so nothing here is cached across profiles.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
import string
|
|
from dataclasses import fields
|
|
from typing import Any, Iterable, List, Mapping
|
|
|
|
from cron.blueprint_catalog import AutomationBlueprint, BlueprintSlot, fill_blueprint
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
__all__ = ["build_plugin_blueprint", "plugin_blueprints"]
|
|
|
|
_KEY_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
|
_NAMESPACE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
_SLOT_NAME_RE = re.compile(r"^[A-Za-z_]\w{0,63}$")
|
|
_SLOT_FIELDS = frozenset(f.name for f in fields(BlueprintSlot))
|
|
# Placeholders ``_resolve_schedule`` derives itself: minute/hour from the ``time`` slot, dow from a
|
|
# ``recurrence``/``day`` slot (or ``*`` when neither exists).
|
|
_DERIVED_SCHEDULE_FIELDS = {"minute": "time", "hour": "time", "dow": None}
|
|
|
|
|
|
def _text(name: str, value: Any) -> str:
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise ValueError(f"{name} must be a non-empty string")
|
|
return value.strip()
|
|
|
|
|
|
def _strings(name: str, value: Any) -> tuple:
|
|
if isinstance(value, str) or not isinstance(value, Iterable):
|
|
raise ValueError(f"{name} must be a list of strings")
|
|
items = tuple(value)
|
|
if not all(isinstance(v, str) and v.strip() for v in items):
|
|
raise ValueError(f"{name} must be a list of non-empty strings")
|
|
return items
|
|
|
|
|
|
def _slot(raw: Any) -> BlueprintSlot:
|
|
"""Accept a ``BlueprintSlot`` (in-process) or its field mapping (the plugin-host wire form)."""
|
|
if isinstance(raw, BlueprintSlot):
|
|
slot = raw
|
|
elif isinstance(raw, Mapping):
|
|
unknown = sorted(set(raw) - _SLOT_FIELDS)
|
|
if unknown:
|
|
raise ValueError(f"unknown slot field(s): {', '.join(unknown)}")
|
|
data = dict(raw)
|
|
if "options" in data:
|
|
data["options"] = tuple(data["options"] or ())
|
|
try:
|
|
slot = BlueprintSlot(**data)
|
|
except TypeError as exc: # missing name/type/label
|
|
raise ValueError(f"slot {data.get('name')!r}: {exc}") from exc
|
|
else:
|
|
raise ValueError(f"slot must be a mapping, got {type(raw).__name__}")
|
|
if not isinstance(slot.name, str) or not _SLOT_NAME_RE.match(slot.name):
|
|
raise ValueError(f"invalid slot name {slot.name!r}")
|
|
_text(f"slot {slot.name} label", slot.label)
|
|
return slot
|
|
|
|
|
|
def _placeholders(template: str, what: str) -> list[str]:
|
|
names = []
|
|
for _literal, name, _spec, _conv in string.Formatter().parse(template):
|
|
if name is None:
|
|
continue
|
|
if not _SLOT_NAME_RE.match(name):
|
|
raise ValueError(f"{what} placeholder {{{name}}} must be a plain slot name")
|
|
names.append(name)
|
|
return names
|
|
|
|
|
|
def _validate(blueprint: AutomationBlueprint) -> None:
|
|
by_name = {s.name: s for s in blueprint.slots}
|
|
if len(by_name) != len(blueprint.slots):
|
|
raise ValueError("slot names must be unique")
|
|
for name in _placeholders(blueprint.prompt_template, "prompt_template"):
|
|
slot = by_name.get(name)
|
|
if slot is None:
|
|
raise ValueError(f"prompt_template uses {{{name}}} but there is no slot named {name!r}")
|
|
if slot.optional and slot.default in (None, ""):
|
|
raise ValueError(f"prompt_template uses optional slot {name!r} that has no default")
|
|
for name in _placeholders(blueprint.schedule_template, "schedule_template"):
|
|
if name in _DERIVED_SCHEDULE_FIELDS:
|
|
needed = _DERIVED_SCHEDULE_FIELDS[name]
|
|
if needed and needed not in by_name:
|
|
raise ValueError(f"schedule_template uses {{{name}}}, which needs a slot named {needed!r}")
|
|
elif name not in by_name:
|
|
raise ValueError(f"schedule_template uses {{{name}}} but there is no slot named {name!r}")
|
|
# When every required slot has a default, fill it now so a broken template or schedule fails at
|
|
# registration instead of on the user's first "Schedule it".
|
|
if all(s.optional or s.default not in (None, "") for s in blueprint.slots):
|
|
from cron.jobs import parse_schedule
|
|
|
|
parse_schedule(fill_blueprint(blueprint, {})["schedule"])
|
|
|
|
|
|
def build_plugin_blueprint(
|
|
plugin: str, key: str, *, title: str, description: str, schedule_template: str,
|
|
prompt_template: str, category: str = "general", slots: Iterable[Any] = (),
|
|
deliver_default: str = "origin", skills: Iterable[str] = (), tags: Iterable[str] = (),
|
|
) -> AutomationBlueprint:
|
|
"""Validated blueprint keyed ``<plugin>:<key>``; raises ``ValueError`` on malformed input."""
|
|
if not isinstance(plugin, str) or not _NAMESPACE_RE.match(plugin):
|
|
raise ValueError(f"plugin name {plugin!r} cannot namespace a blueprint key")
|
|
if not isinstance(key, str) or not _KEY_RE.match(key):
|
|
raise ValueError(
|
|
f"invalid key {key!r}: use lowercase letters, digits, '-' or '_' (the "
|
|
f"'{plugin}:' namespace is added automatically)"
|
|
)
|
|
if isinstance(slots, (str, Mapping)) or not isinstance(slots, Iterable):
|
|
raise ValueError("slots must be a list")
|
|
blueprint = AutomationBlueprint(
|
|
key=f"{plugin}:{key}",
|
|
title=_text("title", title),
|
|
description=_text("description", description),
|
|
category=_text("category", category),
|
|
schedule_template=_text("schedule_template", schedule_template),
|
|
prompt_template=_text("prompt_template", prompt_template),
|
|
slots=[_slot(s) for s in slots],
|
|
deliver_default=_text("deliver_default", deliver_default),
|
|
skills=_strings("skills", skills),
|
|
tags=_strings("tags", tags),
|
|
plugin=plugin,
|
|
)
|
|
_validate(blueprint)
|
|
return blueprint
|
|
|
|
|
|
def plugin_blueprints() -> list[AutomationBlueprint]:
|
|
"""Blueprints the active profile's enabled plugins registered (resolved per call)."""
|
|
try:
|
|
from hermes_cli.plugins import discover_plugins, get_plugin_manager
|
|
|
|
discover_plugins() # idempotent; servers that never import model_tools still see plugins
|
|
return get_plugin_manager().list_automation_blueprints()
|
|
except Exception:
|
|
logger.warning("plugin automation blueprints unavailable", exc_info=True)
|
|
return []
|