WIP simplifying load/startup
This commit is contained in:
@@ -98,7 +98,7 @@ class TestMarkdownDocument:
|
||||
|
||||
def test_none_for_incomplete_skill(self) -> None:
|
||||
"""Ensures skill_slug is None for incomplete skills paths."""
|
||||
doc = MarkdownDocument(relpath=PurePosixPath("skills/demo"), content="#")
|
||||
doc = MarkdownDocument(relpath=PurePosixPath("skills/demo.md"), content="#")
|
||||
|
||||
assert doc.skill_slug is None
|
||||
|
||||
@@ -119,7 +119,7 @@ class TestMarkdownDocument:
|
||||
|
||||
def test_none_for_incomplete_prompt(self) -> None:
|
||||
"""Ensures prompt_slug is None for incomplete prompt paths."""
|
||||
doc = MarkdownDocument(relpath=PurePosixPath("prompts/demo"), content="#")
|
||||
doc = MarkdownDocument(relpath=PurePosixPath("prompts/demo.md"), content="#")
|
||||
|
||||
assert doc.prompt_slug is None
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
import pytest
|
||||
|
||||
from personal_mcp.registry.models.registry import DocsRegistry
|
||||
from personal_mcp.registry.models.registry import PromptRecord
|
||||
from personal_mcp.registry.models.registry import PromptSummaryRecord
|
||||
from personal_mcp.registry.models.registry import ReferenceRecord
|
||||
from personal_mcp.registry.models.registry import SkillRecord
|
||||
from personal_mcp.registry.models.registry import SkillSummaryRecord
|
||||
from personal_mcp.registry.read import read_docs_markdown_path
|
||||
from personal_mcp.registry.read import read_prompt_document
|
||||
from personal_mcp.registry.read import read_skill_document
|
||||
from personal_mcp.registry.read import read_skill_reference
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _make_registry() -> DocsRegistry:
|
||||
skill_path = PurePosixPath("skills/demo/SKILL.md")
|
||||
reference_path = PurePosixPath("skills/demo/references/guide.md")
|
||||
prompt_path = PurePosixPath("prompts/demo-prompt/PROMPT.md")
|
||||
index_path = PurePosixPath("index.md")
|
||||
reference = ReferenceRecord(
|
||||
ref_id="guide",
|
||||
uri="resource://skills/demo/references/guide",
|
||||
relpath=reference_path,
|
||||
mime_type="text/markdown",
|
||||
title="Guide",
|
||||
content="# guide",
|
||||
)
|
||||
skill = SkillRecord(
|
||||
skill_id="demo",
|
||||
name="demo",
|
||||
description="demo skill",
|
||||
version="1.0.0",
|
||||
tags=("testing",),
|
||||
capabilities=("resource://skills/demo/document",),
|
||||
document_uri="resource://skills/demo/document",
|
||||
document_relpath=skill_path,
|
||||
document_content="# demo",
|
||||
references={"guide": reference},
|
||||
)
|
||||
prompt = PromptRecord(
|
||||
prompt_id="demo-prompt",
|
||||
name="demo-prompt",
|
||||
description="demo prompt",
|
||||
version="1.0.0",
|
||||
tags=("testing",),
|
||||
capabilities=("resource://prompts/demo-prompt/document",),
|
||||
arguments={},
|
||||
document_uri="resource://prompts/demo-prompt/document",
|
||||
document_relpath=prompt_path,
|
||||
document_content="# prompt",
|
||||
)
|
||||
return DocsRegistry(
|
||||
skills_by_id={skill.skill_id: skill},
|
||||
skills_in_load_order=(skill.skill_id,),
|
||||
skills_summary_in_load_order=(SkillSummaryRecord.from_record(skill),),
|
||||
docs_markdown_by_path={index_path: "# index"},
|
||||
docs_markdown_path_index=(index_path,),
|
||||
tag_to_skill_ids={"testing": (skill.skill_id,)},
|
||||
capability_to_skill_ids={},
|
||||
prompts_by_id={prompt.prompt_id: prompt},
|
||||
prompts_in_load_order=(prompt.prompt_id,),
|
||||
prompts_summary_in_load_order=(PromptSummaryRecord.from_record(prompt),),
|
||||
tag_to_prompt_ids={"testing": (prompt.prompt_id,)},
|
||||
)
|
||||
|
||||
|
||||
def test_reads_docs_path_from_string_boundary() -> None:
|
||||
payload = read_docs_markdown_path(_make_registry(), "index.md")
|
||||
|
||||
assert payload == {
|
||||
"uri": "resource://docs/index.md",
|
||||
"format": "markdown",
|
||||
"source_path": "docs/index.md",
|
||||
"content": "# index",
|
||||
}
|
||||
|
||||
|
||||
def test_rejects_non_posix_docs_path() -> None:
|
||||
with pytest.raises(ValueError, match="POSIX separators"):
|
||||
read_docs_markdown_path(_make_registry(), "skills\\demo\\SKILL.md")
|
||||
|
||||
|
||||
def test_serializes_record_paths_in_document_payloads() -> None:
|
||||
registry = _make_registry()
|
||||
|
||||
assert read_skill_document(registry, "demo")["source_path"] == "docs/skills/demo/SKILL.md"
|
||||
assert read_skill_reference(registry, skill_id="demo", ref_id="guide")["source_path"] == (
|
||||
"docs/skills/demo/references/guide.md"
|
||||
)
|
||||
assert read_prompt_document(registry, "demo-prompt")["source_path"] == ("docs/prompts/demo-prompt/PROMPT.md")
|
||||
@@ -32,7 +32,7 @@ def mcp_session_factory():
|
||||
@asynccontextmanager
|
||||
async def create_session(*, initialize: bool = True) -> AsyncIterator[ClientSession]:
|
||||
app = create_app()
|
||||
mcp_url = f"http://testserver{app.state.settings.mcp_route}"
|
||||
mcp_url = f"http://testserver{app.state.settings.mounts.mcp}"
|
||||
async with (
|
||||
app.router.lifespan_context(app),
|
||||
AsyncClient(
|
||||
|
||||
Reference in New Issue
Block a user