frozen pydantic models

This commit is contained in:
John Lancaster
2026-06-21 12:26:12 -05:00
parent 4320a251f5
commit 9a9432cc55
10 changed files with 309 additions and 119 deletions
+15 -78
View File
@@ -1,80 +1,17 @@
from dataclasses import dataclass
from dataclasses import field
from .models.prompt import PromptArgumentEntry from .models.prompt import PromptArgumentEntry
from .models.registry import DocsRegistry
from .models.registry import PromptRecord
from .models.registry import PromptSummaryRecord
from .models.registry import ReferenceRecord
from .models.registry import SkillRecord
from .models.registry import SkillSummaryRecord
__all__ = [
@dataclass(frozen=True) "DocsRegistry",
class ReferenceRecord: "PromptArgumentEntry",
ref_id: str "PromptRecord",
uri: str "PromptSummaryRecord",
relpath: str "ReferenceRecord",
mime_type: str "SkillRecord",
title: str | None "SkillSummaryRecord",
content: str ]
@dataclass(frozen=True)
class SkillRecord:
skill_id: str
name: str
description: str
version: str
tags: tuple[str, ...]
capabilities: tuple[str, ...]
depends_on: tuple[str, ...]
document_uri: str
document_relpath: str
document_content: str
references: dict[str, ReferenceRecord]
@dataclass(frozen=True)
class SkillSummaryRecord:
skill_id: str
name: str
description: str
tags: tuple[str, ...]
capabilities: tuple[str, ...]
document_uri: str
version: str
@dataclass(frozen=True)
class PromptRecord:
prompt_id: str
name: str
description: str
version: str
tags: tuple[str, ...]
capabilities: tuple[str, ...]
arguments: dict[str, PromptArgumentEntry]
document_uri: str
document_relpath: str
document_content: str
@dataclass(frozen=True)
class PromptSummaryRecord:
prompt_id: str
name: str
description: str
tags: tuple[str, ...]
capabilities: tuple[str, ...]
document_uri: str
version: str
@dataclass(frozen=True)
class DocsRegistry:
skills_by_id: dict[str, SkillRecord]
skills_in_load_order: tuple[str, ...]
skills_summary_in_load_order: tuple[SkillSummaryRecord, ...]
docs_markdown_by_path: dict[str, str]
docs_markdown_path_index: tuple[str, ...]
tag_to_skill_ids: dict[str, tuple[str, ...]]
capability_to_skill_ids: dict[str, tuple[str, ...]]
prompts_by_id: dict[str, PromptRecord] = field(default_factory=dict)
prompts_in_load_order: tuple[str, ...] = ()
prompts_summary_in_load_order: tuple[PromptSummaryRecord, ...] = ()
tag_to_prompt_ids: dict[str, tuple[str, ...]] = field(default_factory=dict)
@@ -0,0 +1 @@
"""Functions to produce immutable dataclasses representing the document registry."""
@@ -6,7 +6,7 @@ from itertools import groupby
from itertools import starmap from itertools import starmap
from typing import Self from typing import Self
from .file import MarkdownDocument from .document import MarkdownDocument
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -0,0 +1,47 @@
from .common import SEMVER_RE
from .common import SKILL_ID_RE
from .common import ReferenceEntry
from .common import StrictFrozenModel
from .document import MarkdownDocumentModel
from .document import SkillFilesBundleModel
from .document import to_markdown_document_model
from .prompt import PromptArgumentEntry
from .prompt import PromptDocumentModel
from .prompt import PromptFrontmatter
from .prompt import PromptMetadata
from .registry import DocsRegistry
from .registry import PromptRecord
from .registry import PromptSummaryRecord
from .registry import ReferenceRecord
from .registry import SkillRecord
from .registry import SkillSummaryRecord
from .skill import PersonalMcpMetadata
from .skill import SkillDocumentModel
from .skill import SkillFrontmatter
from .skill import SkillMetadata
from .skill import SkillReferenceDocumentModel
__all__ = [
"SEMVER_RE",
"SKILL_ID_RE",
"DocsRegistry",
"MarkdownDocumentModel",
"PersonalMcpMetadata",
"PromptArgumentEntry",
"PromptDocumentModel",
"PromptFrontmatter",
"PromptMetadata",
"PromptRecord",
"PromptSummaryRecord",
"ReferenceEntry",
"ReferenceRecord",
"SkillDocumentModel",
"SkillFilesBundleModel",
"SkillFrontmatter",
"SkillMetadata",
"SkillRecord",
"SkillReferenceDocumentModel",
"SkillSummaryRecord",
"StrictFrozenModel",
"to_markdown_document_model",
]
+20 -2
View File
@@ -1,5 +1,8 @@
import re import re
from collections.abc import Mapping
from pathlib import PurePosixPath from pathlib import PurePosixPath
from types import MappingProxyType
from typing import TypeVar
from pydantic import BaseModel from pydantic import BaseModel
from pydantic import ConfigDict from pydantic import ConfigDict
@@ -8,10 +11,25 @@ from pydantic import field_validator
SKILL_ID_RE = re.compile(r"^[a-z][a-z0-9-]*$") SKILL_ID_RE = re.compile(r"^[a-z][a-z0-9-]*$")
SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$") SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$")
K = TypeVar("K")
V = TypeVar("V")
class ReferenceEntry(BaseModel):
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
class StrictFrozenModel(BaseModel):
model_config = ConfigDict(
extra="forbid",
frozen=True,
validate_by_alias=True,
validate_by_name=True,
str_strip_whitespace=True,
)
def frozen_mapping(value: Mapping[K, V] | None = None) -> Mapping[K, V]:
return MappingProxyType(dict(value or {}))
class ReferenceEntry(StrictFrozenModel):
path: str path: str
mime_type: str = "text/markdown" mime_type: str = "text/markdown"
title: str | None = None title: str | None = None
@@ -0,0 +1,51 @@
from pathlib import PurePosixPath
from .common import StrictFrozenModel
class MarkdownDocumentModel(StrictFrozenModel):
relpath: str
content: str
frontmatter: str | None = None
skill_slug: str | None = None
class SkillFilesBundleModel(StrictFrozenModel):
slug: str
skill: MarkdownDocumentModel
references: tuple[MarkdownDocumentModel, ...] = ()
other: tuple[MarkdownDocumentModel, ...] = ()
@classmethod
def from_parts(
cls,
*,
slug: str,
skill: MarkdownDocumentModel,
references: tuple[MarkdownDocumentModel, ...] = (),
other: tuple[MarkdownDocumentModel, ...] = (),
) -> "SkillFilesBundleModel":
# Ensure deterministic ordering even when the source collection is unordered.
references_sorted = tuple(sorted(references, key=lambda doc: doc.relpath))
other_sorted = tuple(sorted(other, key=lambda doc: doc.relpath))
return cls(
slug=slug,
skill=skill,
references=references_sorted,
other=other_sorted,
)
def to_markdown_document_model(
*,
relpath: PurePosixPath,
content: str,
frontmatter: str | None,
skill_slug: str | None,
) -> MarkdownDocumentModel:
return MarkdownDocumentModel(
relpath=relpath.as_posix(),
content=content,
frontmatter=frontmatter,
skill_slug=skill_slug,
)
+25 -18
View File
@@ -1,23 +1,22 @@
import re import re
from collections.abc import Mapping
from typing import Any from typing import Any
from pydantic import BaseModel
from pydantic import ConfigDict
from pydantic import Field from pydantic import Field
from pydantic import field_validator from pydantic import field_validator
from .common import SEMVER_RE from .common import SEMVER_RE
from .common import SKILL_ID_RE from .common import SKILL_ID_RE
from .common import StrictFrozenModel
from .common import frozen_mapping
class PromptArgumentEntry(BaseModel): class PromptArgumentEntry(StrictFrozenModel):
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
type: str = Field(min_length=1) type: str = Field(min_length=1)
description: str | None = None description: str | None = None
required: bool = False required: bool = False
default: Any | None = None default: Any | None = None
enum: list[str] | None = None enum: tuple[str, ...] | None = None
@field_validator("type") @field_validator("type")
@classmethod @classmethod
@@ -36,20 +35,18 @@ class PromptArgumentEntry(BaseModel):
@field_validator("enum") @field_validator("enum")
@classmethod @classmethod
def validate_enum(cls, value: list[str] | None) -> list[str] | None: def validate_enum(cls, value: tuple[str, ...] | None) -> tuple[str, ...] | None:
if value is not None and not value: if value is not None and not value:
raise ValueError("enum must contain at least one value when provided") raise ValueError("enum must contain at least one value when provided")
return value return value
class PromptMetadata(BaseModel): class PromptMetadata(StrictFrozenModel):
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
id: str id: str
version: str version: str
tags: list[str] = Field(default_factory=list) tags: tuple[str, ...] = ()
capabilities: list[str] = Field(min_length=1) capabilities: tuple[str, ...] = Field(min_length=1)
arguments: dict[str, PromptArgumentEntry] = Field(default_factory=dict) arguments: Mapping[str, PromptArgumentEntry] = Field(default_factory=frozen_mapping)
@field_validator("id") @field_validator("id")
@classmethod @classmethod
@@ -67,24 +64,27 @@ class PromptMetadata(BaseModel):
@field_validator("tags") @field_validator("tags")
@classmethod @classmethod
def validate_tags(cls, value: list[str]) -> list[str]: def validate_tags(cls, value: tuple[str, ...]) -> tuple[str, ...]:
for tag in value: for tag in value:
if not SKILL_ID_RE.fullmatch(tag): if not SKILL_ID_RE.fullmatch(tag):
raise ValueError(f"invalid tag: {tag}") raise ValueError(f"invalid tag: {tag}")
return value return value
@field_validator("arguments", mode="before")
@classmethod
def freeze_arguments(cls, value: Mapping[str, PromptArgumentEntry] | None) -> Mapping[str, PromptArgumentEntry]:
return frozen_mapping(value)
@field_validator("arguments") @field_validator("arguments")
@classmethod @classmethod
def validate_argument_names(cls, value: dict[str, PromptArgumentEntry]) -> dict[str, PromptArgumentEntry]: def validate_argument_names(cls, value: Mapping[str, PromptArgumentEntry]) -> Mapping[str, PromptArgumentEntry]:
for name in value: for name in value:
if not re.fullmatch(r"^[A-Za-z_][A-Za-z0-9_]*$", name): if not re.fullmatch(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
raise ValueError(f"invalid prompt argument name: {name}") raise ValueError(f"invalid prompt argument name: {name}")
return value return value
class PromptFrontmatter(BaseModel): class PromptFrontmatter(StrictFrozenModel):
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
name: str = Field(min_length=1, max_length=64) name: str = Field(min_length=1, max_length=64)
description: str = Field(min_length=1, max_length=1024) description: str = Field(min_length=1, max_length=1024)
x_personal_mcp: PromptMetadata = Field(alias="x-personal-mcp") x_personal_mcp: PromptMetadata = Field(alias="x-personal-mcp")
@@ -97,3 +97,10 @@ class PromptFrontmatter(BaseModel):
if "anthropic" in value or "claude" in value: if "anthropic" in value or "claude" in value:
raise ValueError("name must not contain reserved words anthropic or claude") raise ValueError("name must not contain reserved words anthropic or claude")
return value return value
class PromptDocumentModel(StrictFrozenModel):
prompt_id: str
relpath: str
content: str
frontmatter: PromptFrontmatter
@@ -0,0 +1,101 @@
from collections.abc import Mapping
from pydantic import Field
from pydantic import field_validator
from .common import StrictFrozenModel
from .common import frozen_mapping
from .prompt import PromptArgumentEntry
class ReferenceRecord(StrictFrozenModel):
ref_id: str
uri: str
relpath: str
mime_type: str
title: str | None
content: str
class SkillRecord(StrictFrozenModel):
skill_id: str
name: str
description: str
version: str
tags: tuple[str, ...]
capabilities: tuple[str, ...]
depends_on: tuple[str, ...]
document_uri: str
document_relpath: str
document_content: str
references: Mapping[str, ReferenceRecord] = Field(default_factory=frozen_mapping)
@field_validator("references", mode="before")
@classmethod
def freeze_references(cls, value: Mapping[str, ReferenceRecord] | None) -> Mapping[str, ReferenceRecord]:
return frozen_mapping(value)
class SkillSummaryRecord(StrictFrozenModel):
skill_id: str
name: str
description: str
tags: tuple[str, ...]
capabilities: tuple[str, ...]
document_uri: str
version: str
class PromptRecord(StrictFrozenModel):
prompt_id: str
name: str
description: str
version: str
tags: tuple[str, ...]
capabilities: tuple[str, ...]
arguments: Mapping[str, PromptArgumentEntry] = Field(default_factory=frozen_mapping)
document_uri: str
document_relpath: str
document_content: str
@field_validator("arguments", mode="before")
@classmethod
def freeze_arguments(cls, value: Mapping[str, PromptArgumentEntry] | None) -> Mapping[str, PromptArgumentEntry]:
return frozen_mapping(value)
class PromptSummaryRecord(StrictFrozenModel):
prompt_id: str
name: str
description: str
tags: tuple[str, ...]
capabilities: tuple[str, ...]
document_uri: str
version: str
class DocsRegistry(StrictFrozenModel):
skills_by_id: Mapping[str, SkillRecord] = Field(default_factory=frozen_mapping)
skills_in_load_order: tuple[str, ...]
skills_summary_in_load_order: tuple[SkillSummaryRecord, ...]
docs_markdown_by_path: Mapping[str, str] = Field(default_factory=frozen_mapping)
docs_markdown_path_index: tuple[str, ...]
tag_to_skill_ids: Mapping[str, tuple[str, ...]] = Field(default_factory=frozen_mapping)
capability_to_skill_ids: Mapping[str, tuple[str, ...]] = Field(default_factory=frozen_mapping)
prompts_by_id: Mapping[str, PromptRecord] = Field(default_factory=frozen_mapping)
prompts_in_load_order: tuple[str, ...] = ()
prompts_summary_in_load_order: tuple[PromptSummaryRecord, ...] = ()
tag_to_prompt_ids: Mapping[str, tuple[str, ...]] = Field(default_factory=frozen_mapping)
@field_validator(
"skills_by_id",
"docs_markdown_by_path",
"tag_to_skill_ids",
"capability_to_skill_ids",
"prompts_by_id",
"tag_to_prompt_ids",
mode="before",
)
@classmethod
def freeze_mappings(cls, value: Mapping[str, object] | None) -> Mapping[str, object]:
return frozen_mapping(value)
+48 -20
View File
@@ -1,22 +1,22 @@
from pydantic import BaseModel from collections.abc import Mapping
from pydantic import ConfigDict
from pydantic import Field from pydantic import Field
from pydantic import field_validator from pydantic import field_validator
from .common import SEMVER_RE from .common import SEMVER_RE
from .common import SKILL_ID_RE from .common import SKILL_ID_RE
from .common import ReferenceEntry from .common import ReferenceEntry
from .common import StrictFrozenModel
from .common import frozen_mapping
class PersonalMcpMetadata(BaseModel): class SkillMetadata(StrictFrozenModel):
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
id: str id: str
version: str version: str
tags: list[str] = Field(default_factory=list) tags: tuple[str, ...] = ()
capabilities: list[str] = Field(min_length=1) capabilities: tuple[str, ...] = Field(min_length=1)
depends_on: list[str] = Field(default_factory=list) depends_on: tuple[str, ...] = ()
references: dict[str, ReferenceEntry] = Field(default_factory=dict) references: Mapping[str, ReferenceEntry] = Field(default_factory=frozen_mapping)
@field_validator("id") @field_validator("id")
@classmethod @classmethod
@@ -34,7 +34,7 @@ class PersonalMcpMetadata(BaseModel):
@field_validator("tags") @field_validator("tags")
@classmethod @classmethod
def validate_tags(cls, value: list[str]) -> list[str]: def validate_tags(cls, value: tuple[str, ...]) -> tuple[str, ...]:
for tag in value: for tag in value:
if not SKILL_ID_RE.fullmatch(tag): if not SKILL_ID_RE.fullmatch(tag):
raise ValueError(f"invalid tag: {tag}") raise ValueError(f"invalid tag: {tag}")
@@ -42,29 +42,32 @@ class PersonalMcpMetadata(BaseModel):
@field_validator("depends_on") @field_validator("depends_on")
@classmethod @classmethod
def validate_depends_on(cls, value: list[str]) -> list[str]: def validate_depends_on(cls, value: tuple[str, ...]) -> tuple[str, ...]:
for dep in value: for dep in value:
if not SKILL_ID_RE.fullmatch(dep): if not SKILL_ID_RE.fullmatch(dep):
raise ValueError(f"invalid depends_on skill id: {dep}") raise ValueError(f"invalid depends_on skill id: {dep}")
return value return value
@field_validator("references", mode="before")
@classmethod
def freeze_references(cls, value: Mapping[str, ReferenceEntry] | None) -> Mapping[str, ReferenceEntry]:
return frozen_mapping(value)
@field_validator("references") @field_validator("references")
@classmethod @classmethod
def validate_reference_ids(cls, value: dict[str, ReferenceEntry]) -> dict[str, ReferenceEntry]: def validate_reference_ids(cls, value: Mapping[str, ReferenceEntry]) -> Mapping[str, ReferenceEntry]:
for ref_id in value: for ref_id in value:
if not SKILL_ID_RE.fullmatch(ref_id): if not SKILL_ID_RE.fullmatch(ref_id):
raise ValueError(f"invalid reference id: {ref_id}") raise ValueError(f"invalid reference id: {ref_id}")
return value return value
class SkillFrontmatter(BaseModel): class SkillFrontmatter(StrictFrozenModel):
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
name: str = Field(min_length=1, max_length=64) name: str = Field(min_length=1, max_length=64)
description: str = Field(min_length=1, max_length=1024) description: str = Field(min_length=1, max_length=1024)
when_to_use: str | None = None when_to_use: str | None = None
allowed_tools: str | list[str] | None = Field(default=None, alias="allowed-tools") allowed_tools: str | tuple[str, ...] | None = Field(default=None, alias="allowed-tools")
disallowed_tools: str | list[str] | None = Field( disallowed_tools: str | tuple[str, ...] | None = Field(
default=None, default=None,
alias="disallowed-tools", alias="disallowed-tools",
) )
@@ -74,11 +77,18 @@ class SkillFrontmatter(BaseModel):
) )
user_invocable: bool | None = Field(default=None, alias="user-invocable") user_invocable: bool | None = Field(default=None, alias="user-invocable")
argument_hint: str | None = Field(default=None, alias="argument-hint") argument_hint: str | None = Field(default=None, alias="argument-hint")
arguments: str | list[str] | None = None arguments: str | tuple[str, ...] | None = None
license: str | None = None license: str | None = None
compatibility: str | None = None compatibility: str | None = None
metadata: dict[str, str] | None = None metadata: Mapping[str, str] | None = None
x_personal_mcp: PersonalMcpMetadata = Field(alias="x-personal-mcp") x_personal_mcp: SkillMetadata = Field(alias="x-personal-mcp")
@field_validator("metadata", mode="before")
@classmethod
def freeze_metadata(cls, value: Mapping[str, str] | None) -> Mapping[str, str] | None:
if value is None:
return None
return frozen_mapping(value)
@field_validator("name") @field_validator("name")
@classmethod @classmethod
@@ -88,3 +98,21 @@ class SkillFrontmatter(BaseModel):
if "anthropic" in value or "claude" in value: if "anthropic" in value or "claude" in value:
raise ValueError("name must not contain reserved words anthropic or claude") raise ValueError("name must not contain reserved words anthropic or claude")
return value return value
class SkillDocumentModel(StrictFrozenModel):
skill_id: str
relpath: str
content: str
frontmatter: SkillFrontmatter
class SkillReferenceDocumentModel(StrictFrozenModel):
ref_id: str
relpath: str
content: str
entry: ReferenceEntry
# Transitional alias while callers move to neutral naming.
PersonalMcpMetadata = SkillMetadata