mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 14:28:38 +03:00
127 lines
3.9 KiB
Python
127 lines
3.9 KiB
Python
"""Strict schema for native nanobot extension packages."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
from typing import Literal, Self
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
|
|
EXTENSION_API_VERSION = 1
|
|
|
|
_IDENTIFIER = re.compile(r"[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?")
|
|
_PERMISSION = re.compile(r"[a-z][a-z0-9]*(?:[._:-][a-z0-9]+)*")
|
|
|
|
|
|
class DependencyKind(str, Enum):
|
|
"""Kinds of prerequisites resolved before activation."""
|
|
|
|
PYTHON = "python"
|
|
EXECUTABLE = "executable"
|
|
ENVIRONMENT = "environment"
|
|
|
|
|
|
class _ManifestModel(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)
|
|
|
|
|
|
class ExtensionDependency(_ManifestModel):
|
|
"""One activation prerequisite declared by an extension."""
|
|
|
|
kind: DependencyKind
|
|
name: str
|
|
specifier: str = ""
|
|
optional: bool = False
|
|
|
|
@field_validator("name")
|
|
@classmethod
|
|
def validate_name(cls, value: str) -> str:
|
|
return _require_text(value, "extension dependency name")
|
|
|
|
|
|
class ExtensionPermission(_ManifestModel):
|
|
"""A privileged host capability requested by an extension."""
|
|
|
|
name: str
|
|
reason: str = ""
|
|
|
|
@field_validator("name")
|
|
@classmethod
|
|
def validate_name(cls, value: str) -> str:
|
|
if _PERMISSION.fullmatch(value) is None:
|
|
raise ValueError(
|
|
"extension permission must be a lowercase namespaced identifier"
|
|
)
|
|
return value
|
|
|
|
|
|
class ExtensionManifest(_ManifestModel):
|
|
"""Identity, prerequisites, and consent declarations for one extension."""
|
|
|
|
id: str
|
|
name: str
|
|
version: str
|
|
entry: str = "extension:register"
|
|
description: str = ""
|
|
dependencies: tuple[ExtensionDependency, ...] = ()
|
|
permissions: tuple[ExtensionPermission, ...] = ()
|
|
api_version: Literal[EXTENSION_API_VERSION] = Field(
|
|
default=EXTENSION_API_VERSION,
|
|
alias="apiVersion",
|
|
)
|
|
homepage: str = ""
|
|
license: str = ""
|
|
|
|
@field_validator("id")
|
|
@classmethod
|
|
def validate_id(cls, value: str) -> str:
|
|
return _require_identifier(value, "extension id")
|
|
|
|
@field_validator("name", "version")
|
|
@classmethod
|
|
def validate_required_text(cls, value: str, info) -> str:
|
|
return _require_text(value, f"extension {info.field_name}")
|
|
|
|
@field_validator("entry")
|
|
@classmethod
|
|
def validate_entry(cls, value: str) -> str:
|
|
value = _require_text(value, "extension entry")
|
|
module_name = value.partition(":")[0]
|
|
if Path(module_name).is_absolute() or ".." in Path(module_name).parts:
|
|
raise ValueError("extension entry cannot escape the package root")
|
|
return value
|
|
|
|
@model_validator(mode="after")
|
|
def reject_duplicates(self) -> Self:
|
|
dependencies = [(item.kind, item.name) for item in self.dependencies]
|
|
if len(set(dependencies)) != len(dependencies):
|
|
raise ValueError("extension manifest contains duplicate dependencies")
|
|
permissions = [item.name for item in self.permissions]
|
|
if len(set(permissions)) != len(permissions):
|
|
raise ValueError("extension manifest contains duplicate permissions")
|
|
return self
|
|
|
|
|
|
def _require_text(value: str, label: str) -> str:
|
|
if not value.strip():
|
|
raise ValueError(f"{label} must be a non-empty string")
|
|
return value
|
|
|
|
|
|
def _require_identifier(value: str, label: str) -> str:
|
|
value = _require_text(value, label)
|
|
if _IDENTIFIER.fullmatch(value) is None:
|
|
raise ValueError(
|
|
f"{label} must use lowercase letters, digits, dots, underscores, or hyphens"
|
|
)
|
|
return value
|
|
|
|
|
|
def validate_extension_id(value: object) -> str:
|
|
"""Validate and return one portable extension identifier."""
|
|
if not isinstance(value, str):
|
|
raise ValueError("extension id must be a string")
|
|
return _require_identifier(value, "extension id")
|