WIP simplifying load/startup
This commit is contained in:
@@ -8,8 +8,9 @@ import yaml
|
||||
from pydantic import ValidationError
|
||||
|
||||
from personal_mcp.registry.ingest.document import MarkdownDocument
|
||||
from personal_mcp.registry.load import _parse_frontmatter
|
||||
from personal_mcp.registry.models.common import _normalize_docs_path
|
||||
from personal_mcp.registry.models.common import ReferenceEntry
|
||||
from personal_mcp.registry.models.common import parse_docs_path
|
||||
from personal_mcp.registry.models.common import parse_reference_path
|
||||
from personal_mcp.registry.models.registry import DocsRegistry
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
@@ -95,37 +96,43 @@ def assert_model_is_frozen(instance: Any, *, attr: str, value: Any) -> None:
|
||||
setattr(instance, attr, value)
|
||||
|
||||
|
||||
class TestGate1LayoutValidation:
|
||||
"""Gate 1: source layout and parse shape constraints."""
|
||||
|
||||
def test_parse_frontmatter_requires_payload(self) -> None:
|
||||
"""Ensures missing frontmatter fails before metadata validation."""
|
||||
with pytest.raises(ValueError, match="missing YAML frontmatter"):
|
||||
_parse_frontmatter(None, path=PurePosixPath("skills/alpha/SKILL.md"))
|
||||
|
||||
def test_parse_frontmatter_requires_mapping(self) -> None:
|
||||
"""Ensures non-object YAML payloads are rejected at parse time."""
|
||||
with pytest.raises(TypeError, match="frontmatter must parse to an object"):
|
||||
_parse_frontmatter("- one\n- two\n", path=PurePosixPath("skills/alpha/SKILL.md"))
|
||||
|
||||
def test_parse_frontmatter_accepts_mapping(self) -> None:
|
||||
"""Ensures valid mapping payload is returned for downstream validation."""
|
||||
parsed = _parse_frontmatter("name: alpha\n", path=PurePosixPath("skills/alpha/SKILL.md"))
|
||||
|
||||
assert parsed == {"name": "alpha"}
|
||||
|
||||
|
||||
class TestGate5ContractValidation:
|
||||
"""Gate 5: canonical resource-path contract normalization."""
|
||||
"""Gate 5: canonical resource-path contracts."""
|
||||
|
||||
def test_normalize_docs_path_keeps_posix_relative_paths(self) -> None:
|
||||
"""Ensures docs paths remain normalized before registry publication."""
|
||||
assert _normalize_docs_path("skills/demo/SKILL.md") == "skills/demo/SKILL.md"
|
||||
def test_parse_docs_path_returns_pure_posix_path(self) -> None:
|
||||
"""Ensures boundary strings become path objects before publication."""
|
||||
path = parse_docs_path("skills/demo/SKILL.md")
|
||||
|
||||
def test_normalize_docs_path_rejects_parent_traversal(self) -> None:
|
||||
"""Ensures traversal attempts fail contract validation."""
|
||||
with pytest.raises(ValueError, match="normalized docs-relative path"):
|
||||
_normalize_docs_path("../outside.md")
|
||||
assert path == PurePosixPath("skills/demo/SKILL.md")
|
||||
assert isinstance(path, PurePosixPath)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
(
|
||||
"/absolute.md",
|
||||
"../outside.md",
|
||||
"skills\\demo\\SKILL.md",
|
||||
"skills//demo/SKILL.md",
|
||||
"skills/demo/README.txt",
|
||||
),
|
||||
)
|
||||
def test_parse_docs_path_rejects_invalid_paths(self, value: str) -> None:
|
||||
"""Ensures non-canonical docs paths fail contract validation."""
|
||||
with pytest.raises(ValueError):
|
||||
parse_docs_path(value)
|
||||
|
||||
def test_reference_entry_materializes_reference_path(self) -> None:
|
||||
"""Ensures authored reference strings become constrained path objects."""
|
||||
entry = ReferenceEntry.model_validate({"path": "references/guides/setup.md"})
|
||||
|
||||
assert entry.path == PurePosixPath("references/guides/setup.md")
|
||||
assert parse_reference_path(entry.path) == entry.path
|
||||
|
||||
@pytest.mark.parametrize("value", ("guide.md", "other/guide.md", "references.md"))
|
||||
def test_reference_path_stays_under_references(self, value: str) -> None:
|
||||
"""Ensures in-skill references remain below the references directory."""
|
||||
with pytest.raises(ValueError, match="stay under references"):
|
||||
parse_reference_path(value)
|
||||
|
||||
|
||||
class TestGate6FreezeValidation:
|
||||
@@ -133,13 +140,14 @@ class TestGate6FreezeValidation:
|
||||
|
||||
def test_docs_registry_copies_mapping_inputs(self) -> None:
|
||||
"""Ensures registry snapshots are isolated from caller-owned mapping mutations."""
|
||||
source_docs = {"index.md": "# index\n"}
|
||||
index_path = PurePosixPath("index.md")
|
||||
source_docs = {index_path: "# index\n"}
|
||||
registry = DocsRegistry(
|
||||
skills_by_id={},
|
||||
skills_in_load_order=(),
|
||||
skills_summary_in_load_order=(),
|
||||
docs_markdown_by_path=source_docs,
|
||||
docs_markdown_path_index=("index.md",),
|
||||
docs_markdown_path_index=(index_path,),
|
||||
tag_to_skill_ids={},
|
||||
capability_to_skill_ids={},
|
||||
prompts_by_id={},
|
||||
@@ -148,9 +156,10 @@ class TestGate6FreezeValidation:
|
||||
tag_to_prompt_ids={},
|
||||
)
|
||||
|
||||
source_docs["other.md"] = "# other\n"
|
||||
source_docs[PurePosixPath("other.md")] = "# other\n"
|
||||
|
||||
assert "other.md" not in registry.docs_markdown_by_path
|
||||
assert PurePosixPath("other.md") not in registry.docs_markdown_by_path
|
||||
assert registry.docs_markdown_path_index == (index_path,)
|
||||
|
||||
def test_docs_registry_instance_is_frozen(self) -> None:
|
||||
"""Ensures frozen model prevents attribute reassignment."""
|
||||
@@ -168,4 +177,8 @@ class TestGate6FreezeValidation:
|
||||
tag_to_prompt_ids={},
|
||||
)
|
||||
|
||||
assert_model_is_frozen(registry, attr="docs_markdown_path_index", value=("index.md",))
|
||||
assert_model_is_frozen(
|
||||
registry,
|
||||
attr="docs_markdown_path_index",
|
||||
value=(PurePosixPath("index.md"),),
|
||||
)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from personal_mcp.registry.ingest.document import MarkdownDocument
|
||||
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
|
||||
from personal_mcp.registry.load import _build_prompt_record
|
||||
from personal_mcp.registry.load import load_docs_registry
|
||||
from personal_mcp.registry.load import get_docs_registry
|
||||
from personal_mcp.registry.models.registry import PromptSummaryRecord
|
||||
from tests.registry.models.test_document_validation import as_markdown
|
||||
from tests.registry.models.test_document_validation import assert_model_is_frozen
|
||||
from tests.registry.models.test_document_validation import make_markdown_document
|
||||
from tests.registry.models.test_document_validation import make_prompt_frontmatter_payload
|
||||
@@ -91,7 +93,7 @@ class TestPromptValidationGates:
|
||||
record = _build_prompt_record(bundle=bundle)
|
||||
|
||||
assert record.document_uri == "resource://prompts/initial/document"
|
||||
assert record.document_relpath == "prompts/initial/PROMPT.md"
|
||||
assert record.document_relpath == PurePosixPath("prompts/initial/PROMPT.md")
|
||||
|
||||
def test_preserves_argument_schema(self) -> None:
|
||||
"""Ensures argument metadata survives conversion unchanged."""
|
||||
@@ -114,21 +116,31 @@ class TestPromptValidationGates:
|
||||
class TestGate4GraphValidation:
|
||||
"""Gate 4: validate cross-entity identifier coherence."""
|
||||
|
||||
def test_prompt_id_collision_with_skill_id_fails(self, tmp_path) -> None:
|
||||
def test_prompt_id_collision_with_skill_id_fails(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ensures prompt and skill ids cannot collide in published registry."""
|
||||
skill_dir = tmp_path / "skills" / "shared"
|
||||
prompt_dir = tmp_path / "prompts" / "shared"
|
||||
skill_dir.mkdir(parents=True)
|
||||
prompt_dir.mkdir(parents=True)
|
||||
|
||||
skill_frontmatter = make_skill_frontmatter_payload(skill_id="shared")
|
||||
prompt_frontmatter = make_prompt_frontmatter_payload(prompt_id="shared")
|
||||
documents = {
|
||||
PurePosixPath("skills/shared/SKILL.md"): make_markdown_document(
|
||||
"skills/shared/SKILL.md",
|
||||
frontmatter=skill_frontmatter,
|
||||
),
|
||||
PurePosixPath("prompts/shared/PROMPT.md"): make_markdown_document(
|
||||
"prompts/shared/PROMPT.md",
|
||||
frontmatter=prompt_frontmatter,
|
||||
),
|
||||
}
|
||||
|
||||
(skill_dir / "SKILL.md").write_text(as_markdown(skill_frontmatter), encoding="utf-8")
|
||||
(prompt_dir / "PROMPT.md").write_text(as_markdown(prompt_frontmatter), encoding="utf-8")
|
||||
def fake_from_root(_cls, _root):
|
||||
return documents
|
||||
|
||||
with pytest.raises(ValueError, match="prompt_id collides with existing skill_id"):
|
||||
load_docs_registry(package_anchor="personal_mcp", docs_root=str(tmp_path))
|
||||
monkeypatch.setattr(MarkdownDocument, "from_root", classmethod(fake_from_root))
|
||||
get_docs_registry.cache_clear()
|
||||
try:
|
||||
with pytest.raises(ValueError, match="prompt_id collides with existing skill_id"):
|
||||
get_docs_registry()
|
||||
finally:
|
||||
get_docs_registry.cache_clear()
|
||||
|
||||
class TestGate5ContractValidation:
|
||||
"""Gate 5: validate model_dump contract shape for API surfaces."""
|
||||
@@ -187,5 +199,4 @@ class TestPromptValidationGates:
|
||||
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
|
||||
record = _build_prompt_record(bundle=bundle)
|
||||
|
||||
with pytest.raises(ValidationError, match="Instance is frozen"):
|
||||
record.arguments["topic"].required = False
|
||||
assert_model_is_frozen(record.arguments["topic"], attr="required", value=False)
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
import pytest
|
||||
|
||||
from personal_mcp.catalog.server import build_skill_detail_payload
|
||||
from personal_mcp.registry.models.prompt import PromptArgumentEntry
|
||||
from personal_mcp.registry.models.registry import DocsRegistry
|
||||
from personal_mcp.registry.models.registry import PromptRecord
|
||||
from personal_mcp.registry.models.registry import PromptSummaryPayload
|
||||
from personal_mcp.registry.models.registry import ReferenceRecord
|
||||
from personal_mcp.registry.models.registry import SkillPatternPayload
|
||||
from personal_mcp.registry.models.registry import SkillRecord
|
||||
from personal_mcp.registry.models.registry import SkillSummaryPayload
|
||||
from personal_mcp.registry.models.registry import SkillSummaryRecord
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
@@ -22,13 +28,13 @@ def _make_skill_record() -> SkillRecord:
|
||||
tags=("testing", "catalog"),
|
||||
capabilities=("resource://skills/demo-skill/document", "resource://catalog/skills_index"),
|
||||
document_uri="resource://skills/demo-skill/document",
|
||||
document_relpath="skills/demo-skill/SKILL.md",
|
||||
document_relpath=PurePosixPath("skills/demo-skill/SKILL.md"),
|
||||
document_content="# demo",
|
||||
references={
|
||||
"zeta": ReferenceRecord(
|
||||
ref_id="zeta",
|
||||
uri="resource://skills/demo-skill/references/zeta",
|
||||
relpath="skills/demo-skill/references/zeta.md",
|
||||
relpath=PurePosixPath("skills/demo-skill/references/zeta.md"),
|
||||
mime_type="text/markdown",
|
||||
title="Zeta",
|
||||
content="# zeta",
|
||||
@@ -36,7 +42,7 @@ def _make_skill_record() -> SkillRecord:
|
||||
"alpha": ReferenceRecord(
|
||||
ref_id="alpha",
|
||||
uri="resource://skills/demo-skill/references/alpha",
|
||||
relpath="skills/demo-skill/references/alpha.md",
|
||||
relpath=PurePosixPath("skills/demo-skill/references/alpha.md"),
|
||||
mime_type="text/markdown",
|
||||
title="Alpha",
|
||||
content="# alpha",
|
||||
@@ -61,7 +67,7 @@ def _make_prompt_record() -> PromptRecord:
|
||||
)
|
||||
},
|
||||
document_uri="resource://prompts/demo-prompt/document",
|
||||
document_relpath="prompts/demo-prompt/PROMPT.md",
|
||||
document_relpath=PurePosixPath("prompts/demo-prompt/PROMPT.md"),
|
||||
document_content="# demo",
|
||||
)
|
||||
|
||||
@@ -121,3 +127,25 @@ def test_prompt_summary_payload_from_record_shape() -> None:
|
||||
"document_uri": "resource://prompts/demo-prompt/document",
|
||||
"detail_uri": "resource://catalog/prompts/demo-prompt",
|
||||
}
|
||||
|
||||
|
||||
def test_skill_detail_serializes_reference_paths() -> None:
|
||||
record = _make_skill_record()
|
||||
registry = DocsRegistry(
|
||||
skills_by_id={record.skill_id: record},
|
||||
skills_in_load_order=(record.skill_id,),
|
||||
skills_summary_in_load_order=(SkillSummaryRecord.from_record(record),),
|
||||
docs_markdown_by_path={},
|
||||
docs_markdown_path_index=(),
|
||||
tag_to_skill_ids={},
|
||||
capability_to_skill_ids={},
|
||||
prompts_by_id={},
|
||||
prompts_in_load_order=(),
|
||||
prompts_summary_in_load_order=(),
|
||||
tag_to_prompt_ids={},
|
||||
)
|
||||
|
||||
payload = build_skill_detail_payload(registry, record.skill_id)
|
||||
|
||||
assert payload["resources"]["references"]["alpha"]["path"] == ("skills/demo-skill/references/alpha.md")
|
||||
json.dumps(payload)
|
||||
|
||||
@@ -2,14 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from personal_mcp.registry.load import load_docs_registry
|
||||
from personal_mcp.registry.load import get_docs_registry
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
REGISTRY = load_docs_registry(
|
||||
package_anchor="personal_mcp",
|
||||
docs_root="../../docs",
|
||||
)
|
||||
REGISTRY = get_docs_registry()
|
||||
|
||||
# Convention: every skill should include tags for the core libraries/frameworks
|
||||
# it relies on so search_patterns query terms map to discoverable skills.
|
||||
@@ -21,7 +18,7 @@ REQUIRED_LIBRARY_TAGS_BY_SKILL = {
|
||||
"nicegui": {"nicegui", "fastapi"},
|
||||
"nicegui-ui-customization": {"nicegui", "fastapi"},
|
||||
"pytesting": {"pytest", "testing", "fastapi", "asyncio", "anyio"},
|
||||
"python-logging-dictconfig": {"python", "logging"},
|
||||
"python-logging": {"python", "logging"},
|
||||
"python-typing": {"python", "typing"},
|
||||
"ruff-linting-formating": {"ruff", "python"},
|
||||
"vscode-configuration": {"vscode", "debugpy", "fastapi", "python"},
|
||||
@@ -59,6 +56,7 @@ DOMAIN_FACET_TAGS = {
|
||||
"authoring",
|
||||
"bootstrap",
|
||||
"ci",
|
||||
"configuration",
|
||||
"custom-agents",
|
||||
"customization",
|
||||
"deterministic",
|
||||
|
||||
@@ -169,5 +169,4 @@ class TestSkillValidationGates:
|
||||
)
|
||||
record = _build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle))
|
||||
|
||||
with pytest.raises(ValidationError, match="Instance is frozen"):
|
||||
record.references["guide"].title = "Mutated"
|
||||
assert_model_is_frozen(record.references["guide"], attr="title", value="Mutated")
|
||||
|
||||
Reference in New Issue
Block a user