From 7fec3a4337a16a9129e7334ff94c4fdcc66f5d8d Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:21:06 -0500 Subject: [PATCH] ty checking --- docs/skills/pytest-scaffolding/SKILL.md | 15 + pyproject.toml | 12 + ruff.toml | 1 + src/personal_mcp/catalog/server.py | 35 +- src/personal_mcp/mcp.py | 31 +- src/personal_mcp/registry/ingest/skill.py | 12 +- src/personal_mcp/registry/load.py | 466 ++++++++++++--------- src/personal_mcp/registry/read.py | 1 + src/personal_mcp/skills/document_loader.py | 7 +- tests/registry/ingest/conftest.py | 2 +- tests/registry/ingest/test_skill.py | 2 +- uv.lock | 27 ++ 12 files changed, 372 insertions(+), 239 deletions(-) diff --git a/docs/skills/pytest-scaffolding/SKILL.md b/docs/skills/pytest-scaffolding/SKILL.md index 9a954d9..f4a800f 100644 --- a/docs/skills/pytest-scaffolding/SKILL.md +++ b/docs/skills/pytest-scaffolding/SKILL.md @@ -73,6 +73,19 @@ These are stable defaults regardless of stack: 4. Register markers up front (`unit`, `integration`, `smoke`, `slow`, `external`) and keep strict marker checks enabled. 5. Separate fast feedback (`-m unit`) from broader integration/external lanes. 6. Validate structure early with collection checks before expanding assertions. +7. Prefer behavior-first tests that exercise real code paths and concrete inputs over patching internals. +8. Use monkeypatching, mocks, and fakes extremely sparingly, only when no practical real-input alternative exists, and only after explicit user confirmation. + +## Universal Test Double Policy (Repo-Local Placement) + +Treat this policy as universal guidance for test authoring, while it is documented in this repository-local skill file for now. + +Apply this policy whenever a test change introduces a fake collaborator or patched behavior: + +1. Attempt a real-input, real-object test design first. +2. If that approach is impractical, explain why and request user confirmation before adding monkeypatching, mocks, or fakes. +3. Keep any approved test double narrowly scoped and document the exact boundary it replaces. +4. Revisit approved test doubles when implementation seams improve so they can be removed. ## Stack-Specific Guidance @@ -89,6 +102,7 @@ Primary upstream docs are curated in each reference page. Start with: 3. Pytest markers: [marker examples](https://docs.pytest.org/en/stable/example/markers.html) 4. FastAPI testing: [FastAPI testing tutorial](https://fastapi.tiangolo.com/tutorial/testing/) 5. SQLAlchemy transaction testing: [SQLAlchemy external transaction pattern](https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#joining-a-session-into-an-external-transaction-such-as-for-test-suites) +6. Pytest monkeypatch usage and limits: [monkeypatch how-to](https://docs.pytest.org/en/stable/how-to/monkeypatch.html) ## Quick Validation Commands @@ -108,3 +122,4 @@ When this skill is applied, return: 5. Exact validation commands. 6. Relevant source-doc links for any non-trivial recommendation. 7. Risks, assumptions, or open questions. +8. Explicit confirmation status if monkeypatching, mocks, or fakes were requested or used. diff --git a/pyproject.toml b/pyproject.toml index 1db1ffe..70f8ec5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,9 +25,21 @@ packages = ["src/personal_mcp"] dev = [ "pre-commit>=4.6.0", "ruff>=0.15.18", + "ty>=0.0.51", ] test = [ "pytest>=9.1.1", "pytest-asyncio>=1.4.0", "pytest-cov>=7.1.0", ] + +[tool.pytest.ini_options] +addopts = ["--strict-markers"] +markers = [ + "unit: fast deterministic tests with no external dependencies", + "integration: framework or component integration tests", + "smoke: thin critical-path checks", +] + +[tool.ty.src] +include = ["src", "tests"] diff --git a/ruff.toml b/ruff.toml index 25429e7..4b97024 100644 --- a/ruff.toml +++ b/ruff.toml @@ -49,6 +49,7 @@ ignore = [ "*.ipynb" = [ "F401", # unused imports "F841", # unused local variable + "F821", # undefined name in exploratory notebook cells ] [lint.isort] diff --git a/src/personal_mcp/catalog/server.py b/src/personal_mcp/catalog/server.py index 64f6066..559b28a 100644 --- a/src/personal_mcp/catalog/server.py +++ b/src/personal_mcp/catalog/server.py @@ -2,9 +2,9 @@ from __future__ import annotations from typing import Any -from personal_mcp.skills.document_loader import DocsRegistry -from personal_mcp.skills.document_loader import PromptRecord -from personal_mcp.skills.document_loader import SkillRecord +from personal_mcp.registry.contracts import DocsRegistry +from personal_mcp.registry.models.registry import PromptRecord +from personal_mcp.registry.models.registry import SkillRecord DEFAULT_LIMIT = 20 MAX_LIMIT = 100 @@ -36,8 +36,7 @@ def _summary_payload(skill: SkillRecord) -> dict[str, Any]: "resources": { "document": skill.document_uri, "references": [ - f"resource://skills/{skill.skill_id}/references/{ref_id}" - for ref_id in sorted(skill.references) + f"resource://skills/{skill.skill_id}/references/{ref_id}" for ref_id in sorted(skill.references) ], }, } @@ -124,14 +123,8 @@ def build_skills_index_payload( except ValueError as exc: raise ValueError("cursor must be an integer string") from exc - ordered = [ - registry.skills_by_id[skill_id] for skill_id in registry.skills_in_load_order - ] - matches = [ - skill - for skill in ordered - if _skill_matches(skill, query=query, tag=tag, capability=capability) - ] + ordered = [registry.skills_by_id[skill_id] for skill_id in registry.skills_in_load_order] + matches = [skill for skill in ordered if _skill_matches(skill, query=query, tag=tag, capability=capability)] page = matches[start : start + normalized_limit] next_cursor = start + normalized_limit @@ -187,13 +180,8 @@ def build_prompts_index_payload( except ValueError as exc: raise ValueError("cursor must be an integer string") from exc - ordered = [ - registry.prompts_by_id[prompt_id] - for prompt_id in registry.prompts_in_load_order - ] - matches = [ - prompt for prompt in ordered if _prompt_matches(prompt, query=query, tag=tag) - ] + ordered = [registry.prompts_by_id[prompt_id] for prompt_id in registry.prompts_in_load_order] + matches = [prompt for prompt in ordered if _prompt_matches(prompt, query=query, tag=tag)] page = matches[start : start + normalized_limit] next_cursor = start + normalized_limit @@ -207,9 +195,7 @@ def build_prompts_index_payload( } -def build_prompt_detail_payload( - registry: DocsRegistry, prompt_id: str -) -> dict[str, Any]: +def build_prompt_detail_payload(registry: DocsRegistry, prompt_id: str) -> dict[str, Any]: if prompt_id not in registry.prompts_by_id: raise KeyError(prompt_id) @@ -225,8 +211,7 @@ def build_prompt_detail_payload( "document": prompt.document_uri, }, "arguments": { - arg_name: arg.model_dump(exclude_none=True) - for arg_name, arg in sorted(prompt.arguments.items()) + arg_name: arg.model_dump(exclude_none=True) for arg_name, arg in sorted(prompt.arguments.items()) }, } diff --git a/src/personal_mcp/mcp.py b/src/personal_mcp/mcp.py index db4aa2b..cd10d2c 100644 --- a/src/personal_mcp/mcp.py +++ b/src/personal_mcp/mcp.py @@ -5,6 +5,7 @@ import re from inspect import Parameter from inspect import Signature from typing import Any +from typing import cast from fastmcp import FastMCP from fastmcp.server.transforms import ResourcesAsTools @@ -19,12 +20,12 @@ from personal_mcp.catalog.server import get_pattern_by_id_payload from personal_mcp.catalog.server import get_prompt_by_id_payload from personal_mcp.catalog.server import search_patterns_payload from personal_mcp.catalog.server import search_prompts_payload -from personal_mcp.skills.document_loader import DocsRegistry -from personal_mcp.skills.document_loader import load_docs_registry -from personal_mcp.skills.document_loader import read_docs_markdown_path -from personal_mcp.skills.document_loader import read_prompt_document -from personal_mcp.skills.document_loader import read_skill_document -from personal_mcp.skills.document_loader import read_skill_reference +from personal_mcp.registry.contracts import DocsRegistry +from personal_mcp.registry.load import load_docs_registry +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 DOCS_ROOT = os.getenv("PERSONAL_MCP_DOCS_ROOT", "../../docs") TOOL_SEARCH_MODE = os.getenv("PERSONAL_MCP_TOOL_SEARCH", "none").strip().lower() @@ -70,9 +71,7 @@ def _install_tool_fallback_transforms() -> None: mcp.add_transform(BM25SearchTransform(**kwargs)) return - raise ValueError( - "PERSONAL_MCP_TOOL_SEARCH must be one of: none, regex, bm25" - ) + raise ValueError("PERSONAL_MCP_TOOL_SEARCH must be one of: none, regex, bm25") def _ro_annotations() -> dict[str, bool]: @@ -105,6 +104,13 @@ def _python_type(prompt_arg_type: str) -> type[Any]: return str +def _make_prompt_handler(content: str): + def prompt_handler(**kwargs: Any) -> str: + return _render_prompt_markdown(content, kwargs) + + return prompt_handler + + def _register_prompt_objects() -> None: for prompt_id in REGISTRY.prompts_in_load_order: prompt = REGISTRY.prompts_by_id[prompt_id] @@ -126,15 +132,12 @@ def _register_prompt_objects() -> None: signature = Signature(parameters=params, return_annotation=str) - prompt_content = prompt.document_content - - def prompt_handler(**kwargs: Any) -> str: - return _render_prompt_markdown(prompt_content, kwargs) + prompt_handler = _make_prompt_handler(prompt.document_content) prompt_handler.__name__ = re.sub(r"[^a-zA-Z0-9_]", "_", prompt_id) prompt_handler.__doc__ = prompt.description prompt_handler.__annotations__ = annotations - prompt_handler.__signature__ = signature # type: ignore[attr-defined] + cast(Any, prompt_handler).__signature__ = signature mcp.prompt( prompt_handler, name=prompt_id, diff --git a/src/personal_mcp/registry/ingest/skill.py b/src/personal_mcp/registry/ingest/skill.py index 1f0e142..453f436 100644 --- a/src/personal_mcp/registry/ingest/skill.py +++ b/src/personal_mcp/registry/ingest/skill.py @@ -30,15 +30,9 @@ class SkillFilesBundle: @classmethod def from_paths(cls, slug: str, paths: set[MarkdownDocument]) -> Self: skill = next(iter(p for p in paths if p.relpath.name == "SKILL.md")) - groups = { - k: tuple(v) - for k, v in groupby( - paths, - key=lambda p: fnmatch(p.relpath.as_posix(), f"skills/{slug}/references/*.md"), - ) - } - references = groups.get(True, ()) - other = tuple(p for p in groups.get(False, ()) if p != skill) + sorted_paths = tuple(sorted(paths, key=lambda p: p.relpath.as_posix())) + references = tuple(p for p in sorted_paths if fnmatch(p.relpath.as_posix(), f"skills/{slug}/references/*.md")) + other = tuple(p for p in sorted_paths if p not in references and p != skill) return cls( slug=slug, skill=skill, diff --git a/src/personal_mcp/registry/load.py b/src/personal_mcp/registry/load.py index 8077dac..1a79b77 100644 --- a/src/personal_mcp/registry/load.py +++ b/src/personal_mcp/registry/load.py @@ -1,6 +1,14 @@ from importlib.resources import files from pathlib import PurePosixPath +from pydantic import ValidationError + +from ..skills.document_loader import _discover_top_level_references +from ..skills.document_loader import _parse_frontmatter +from ..skills.document_loader import _reference_id_from_filename +from ..skills.document_loader import _validate_prompt_frontmatter +from ..skills.document_loader import _validate_skill_frontmatter +from ..skills.document_loader import _walk_markdown from .contracts import DocsRegistry from .contracts import PromptRecord from .contracts import PromptSummaryRecord @@ -11,49 +19,47 @@ from .issues import DocsRegistryValidationError from .issues import RegistryIssue -def load_docs_registry( +def _ensure_no_cycles( + skills_by_id: dict[str, SkillRecord], +) -> list[tuple[str, str]]: + state: dict[str, int] = {} + stack: list[str] = [] + cycles: set[tuple[str, str]] = set() + + def visit(skill_id: str) -> None: + state[skill_id] = 1 + stack.append(skill_id) + + for dependency in skills_by_id[skill_id].depends_on: + if dependency not in skills_by_id: + continue + dep_state = state.get(dependency, 0) + if dep_state == 0: + visit(dependency) + continue + if dep_state == 1: + cycle_start = stack[stack.index(dependency)] + cycle_path = stack[stack.index(dependency) :] + [dependency] + cycles.add((cycle_start, " -> ".join(cycle_path))) + + stack.pop() + state[skill_id] = 2 + + for skill_id in sorted(skills_by_id): + if state.get(skill_id, 0) == 0: + visit(skill_id) + + return sorted(cycles) + + +def _load_skills( *, - package_anchor: str, - docs_root: str = "docs", -) -> DocsRegistry: - docs_dir = files(package_anchor).joinpath(docs_root) - issues: list[RegistryIssue] = [] - - if not docs_dir.is_dir(): - raise DocsRegistryValidationError( - [ - RegistryIssue( - code="missing_docs_root", - message="docs root directory does not exist", - skill_id=None, - path=docs_root, - hint="configure docs_root to a valid packaged docs path", - ) - ] - ) - - docs_markdown_by_path: dict[str, str] = {} - for relpath, doc_file in _walk_markdown(docs_dir): - docs_markdown_by_path[relpath] = doc_file.read_text(encoding="utf-8") - - skills_root = docs_dir.joinpath("skills") - if not skills_root.is_dir(): - raise DocsRegistryValidationError( - [ - RegistryIssue( - code="missing_skills_root", - message="skills directory does not exist under docs root", - skill_id=None, - path=f"{docs_root}/skills", - hint="ensure docs/skills is included in packaged docs", - ) - ] - ) - + docs_markdown_by_path: dict[str, str], + skills_root, +) -> tuple[dict[str, SkillRecord], list[SkillSummaryRecord], list[RegistryIssue]]: skills_by_id: dict[str, SkillRecord] = {} summaries: list[SkillSummaryRecord] = [] - prompts_by_id: dict[str, PromptRecord] = {} - prompt_summaries: list[PromptSummaryRecord] = [] + issues: list[RegistryIssue] = [] for skill_dir in sorted(skills_root.iterdir(), key=lambda item: item.name): if not skill_dir.is_dir(): @@ -164,153 +170,173 @@ def load_docs_registry( ) ) - prompts_root = docs_dir.joinpath("prompts") + return skills_by_id, summaries, issues + + +def _load_prompts( + *, + prompts_root, + skills_by_id: dict[str, SkillRecord], +) -> tuple[dict[str, PromptRecord], list[PromptSummaryRecord], list[RegistryIssue]]: + prompts_by_id: dict[str, PromptRecord] = {} + prompt_summaries: list[PromptSummaryRecord] = [] + issues: list[RegistryIssue] = [] discovered_prompt_docs: set[str] = set() - if prompts_root.is_dir(): - for prompt_dir in sorted(prompts_root.iterdir(), key=lambda item: item.name): - if not prompt_dir.is_dir(): - continue - prompt_dir_name = prompt_dir.name - prompt_rel_root = PurePosixPath("prompts").joinpath(prompt_dir_name) - prompt_doc_relpath = prompt_rel_root.joinpath("PROMPT.md").as_posix() - prompt_doc_file = prompt_dir.joinpath("PROMPT.md") + for prompt_dir in sorted(prompts_root.iterdir(), key=lambda item: item.name): + if not prompt_dir.is_dir(): + continue - if not prompt_doc_file.is_file(): - continue + prompt_dir_name = prompt_dir.name + prompt_rel_root = PurePosixPath("prompts").joinpath(prompt_dir_name) + prompt_doc_relpath = prompt_rel_root.joinpath("PROMPT.md").as_posix() + prompt_doc_file = prompt_dir.joinpath("PROMPT.md") - discovered_prompt_docs.add(prompt_doc_relpath) + if not prompt_doc_file.is_file(): + continue - prompt_markdown = prompt_doc_file.read_text(encoding="utf-8") - try: - raw_frontmatter, _ = _parse_frontmatter( - prompt_markdown, + discovered_prompt_docs.add(prompt_doc_relpath) + + prompt_markdown = prompt_doc_file.read_text(encoding="utf-8") + try: + raw_frontmatter, _ = _parse_frontmatter( + prompt_markdown, + path=prompt_doc_relpath, + ) + frontmatter = _validate_prompt_frontmatter( + raw_frontmatter, + prompt_dir_name=prompt_dir_name, + ) + except (ValueError, ValidationError) as exc: + issues.append( + RegistryIssue( + code="invalid_prompt_frontmatter", + message=str(exc), + skill_id=prompt_dir_name, path=prompt_doc_relpath, - ) - frontmatter = _validate_prompt_frontmatter( - raw_frontmatter, - prompt_dir_name=prompt_dir_name, - ) - except (ValueError, ValidationError) as exc: - issues.append( - RegistryIssue( - code="invalid_prompt_frontmatter", - message=str(exc), - skill_id=prompt_dir_name, - path=prompt_doc_relpath, - hint="fix PROMPT.md YAML frontmatter to match the contract", - ) - ) - continue - - prompt_id = frontmatter.name - if prompt_id in prompts_by_id: - issues.append( - RegistryIssue( - code="duplicate_prompt_id", - message="duplicate prompt id discovered", - skill_id=prompt_id, - path=prompt_doc_relpath, - hint="ensure each prompt directory has a unique id", - ) - ) - continue - - if prompt_id in skills_by_id: - issues.append( - RegistryIssue( - code="prompt_skill_id_collision", - message="prompt id collides with an existing skill id", - skill_id=prompt_id, - path=prompt_doc_relpath, - hint="use a unique prompt id that does not match a skill id", - ) - ) - continue - - prompt_record = PromptRecord( - prompt_id=prompt_id, - name=frontmatter.name, - description=frontmatter.description, - version=frontmatter.x_personal_mcp.version, - tags=tuple(frontmatter.x_personal_mcp.tags), - capabilities=tuple(frontmatter.x_personal_mcp.capabilities), - arguments=frontmatter.x_personal_mcp.arguments, - document_uri=f"resource://prompts/{prompt_id}/document", - document_relpath=prompt_doc_relpath, - document_content=prompt_markdown, - ) - prompts_by_id[prompt_id] = prompt_record - prompt_summaries.append( - PromptSummaryRecord( - prompt_id=prompt_record.prompt_id, - name=prompt_record.name, - description=prompt_record.description, - tags=prompt_record.tags, - capabilities=prompt_record.capabilities, - document_uri=prompt_record.document_uri, - version=prompt_record.version, + hint="fix PROMPT.md YAML frontmatter to match the contract", ) ) + continue - for relpath, prompt_file in _walk_markdown( - prompts_root, - prefix=PurePosixPath("prompts"), - ): - if relpath in discovered_prompt_docs or relpath.endswith("/PROMPT.md"): - continue - - prompt_markdown = prompt_file.read_text(encoding="utf-8") - prompt_id = _reference_id_from_filename(PurePosixPath(relpath).name) - if prompt_id is None: - continue - if prompt_id in prompts_by_id: - issues.append( - RegistryIssue( - code="duplicate_prompt_id", - message="duplicate prompt id discovered", - skill_id=prompt_id, - path=relpath, - hint="ensure each prompt id is unique", - ) - ) - continue - if prompt_id in skills_by_id: - issues.append( - RegistryIssue( - code="prompt_skill_id_collision", - message="prompt id collides with an existing skill id", - skill_id=prompt_id, - path=relpath, - hint="use a unique prompt id that does not match a skill id", - ) - ) - continue - - prompt_record = PromptRecord( - prompt_id=prompt_id, - name=prompt_id, - description=f"Legacy prompt loaded from docs/{relpath}", - version="1.0.0", - tags=("prompt", "legacy"), - capabilities=(f"resource://prompts/{prompt_id}/document",), - arguments={}, - document_uri=f"resource://prompts/{prompt_id}/document", - document_relpath=relpath, - document_content=prompt_markdown, - ) - prompts_by_id[prompt_id] = prompt_record - prompt_summaries.append( - PromptSummaryRecord( - prompt_id=prompt_record.prompt_id, - name=prompt_record.name, - description=prompt_record.description, - tags=prompt_record.tags, - capabilities=prompt_record.capabilities, - document_uri=prompt_record.document_uri, - version=prompt_record.version, + prompt_id = frontmatter.name + if prompt_id in prompts_by_id: + issues.append( + RegistryIssue( + code="duplicate_prompt_id", + message="duplicate prompt id discovered", + skill_id=prompt_id, + path=prompt_doc_relpath, + hint="ensure each prompt directory has a unique id", ) ) + continue + + if prompt_id in skills_by_id: + issues.append( + RegistryIssue( + code="prompt_skill_id_collision", + message="prompt id collides with an existing skill id", + skill_id=prompt_id, + path=prompt_doc_relpath, + hint="use a unique prompt id that does not match a skill id", + ) + ) + continue + + prompt_record = PromptRecord( + prompt_id=prompt_id, + name=frontmatter.name, + description=frontmatter.description, + version=frontmatter.x_personal_mcp.version, + tags=tuple(frontmatter.x_personal_mcp.tags), + capabilities=tuple(frontmatter.x_personal_mcp.capabilities), + arguments=frontmatter.x_personal_mcp.arguments, + document_uri=f"resource://prompts/{prompt_id}/document", + document_relpath=prompt_doc_relpath, + document_content=prompt_markdown, + ) + prompts_by_id[prompt_id] = prompt_record + prompt_summaries.append( + PromptSummaryRecord( + prompt_id=prompt_record.prompt_id, + name=prompt_record.name, + description=prompt_record.description, + tags=prompt_record.tags, + capabilities=prompt_record.capabilities, + document_uri=prompt_record.document_uri, + version=prompt_record.version, + ) + ) + + for relpath, prompt_file in _walk_markdown( + prompts_root, + prefix=PurePosixPath("prompts"), + ): + if relpath in discovered_prompt_docs or relpath.endswith("/PROMPT.md"): + continue + + prompt_markdown = prompt_file.read_text(encoding="utf-8") + prompt_id = _reference_id_from_filename(PurePosixPath(relpath).name) + if prompt_id is None: + continue + if prompt_id in prompts_by_id: + issues.append( + RegistryIssue( + code="duplicate_prompt_id", + message="duplicate prompt id discovered", + skill_id=prompt_id, + path=relpath, + hint="ensure each prompt id is unique", + ) + ) + continue + if prompt_id in skills_by_id: + issues.append( + RegistryIssue( + code="prompt_skill_id_collision", + message="prompt id collides with an existing skill id", + skill_id=prompt_id, + path=relpath, + hint="use a unique prompt id that does not match a skill id", + ) + ) + continue + + prompt_record = PromptRecord( + prompt_id=prompt_id, + name=prompt_id, + description=f"Legacy prompt loaded from docs/{relpath}", + version="1.0.0", + tags=("prompt", "legacy"), + capabilities=(f"resource://prompts/{prompt_id}/document",), + arguments={}, + document_uri=f"resource://prompts/{prompt_id}/document", + document_relpath=relpath, + document_content=prompt_markdown, + ) + prompts_by_id[prompt_id] = prompt_record + prompt_summaries.append( + PromptSummaryRecord( + prompt_id=prompt_record.prompt_id, + name=prompt_record.name, + description=prompt_record.description, + tags=prompt_record.tags, + capabilities=prompt_record.capabilities, + document_uri=prompt_record.document_uri, + version=prompt_record.version, + ) + ) + + return prompts_by_id, prompt_summaries, issues + + +def _collect_relationship_and_uri_issues( + *, + skills_by_id: dict[str, SkillRecord], + prompts_by_id: dict[str, PromptRecord], +) -> list[RegistryIssue]: + issues: list[RegistryIssue] = [] for skill_id, record in sorted(skills_by_id.items()): for dependency in record.depends_on: @@ -363,18 +389,84 @@ def load_docs_registry( seen_uris.add(uri) for prompt_id, record in sorted(prompts_by_id.items()): - for uri in [record.document_uri]: - if uri in seen_uris: - issues.append( - RegistryIssue( - code="duplicate_uri", - message=f"duplicate resource URI generated: {uri}", - skill_id=prompt_id, - path=record.document_relpath, - hint="ensure unique prompt ids", - ) + uri = record.document_uri + if uri in seen_uris: + issues.append( + RegistryIssue( + code="duplicate_uri", + message=f"duplicate resource URI generated: {uri}", + skill_id=prompt_id, + path=record.document_relpath, + hint="ensure unique prompt ids", ) - seen_uris.add(uri) + ) + seen_uris.add(uri) + + return issues + + +def load_docs_registry( + *, + package_anchor: str, + docs_root: str = "docs", +) -> DocsRegistry: + docs_dir = files(package_anchor).joinpath(docs_root) + + if not docs_dir.is_dir(): + raise DocsRegistryValidationError( + [ + RegistryIssue( + code="missing_docs_root", + message="docs root directory does not exist", + skill_id=None, + path=docs_root, + hint="configure docs_root to a valid packaged docs path", + ) + ] + ) + + docs_markdown_by_path = { + relpath: doc_file.read_text(encoding="utf-8") for relpath, doc_file in _walk_markdown(docs_dir) + } + + skills_root = docs_dir.joinpath("skills") + if not skills_root.is_dir(): + raise DocsRegistryValidationError( + [ + RegistryIssue( + code="missing_skills_root", + message="skills directory does not exist under docs root", + skill_id=None, + path=f"{docs_root}/skills", + hint="ensure docs/skills is included in packaged docs", + ) + ] + ) + + skills_by_id, summaries, skill_issues = _load_skills( + docs_markdown_by_path=docs_markdown_by_path, + skills_root=skills_root, + ) + + prompts_by_id: dict[str, PromptRecord] = {} + prompt_summaries: list[PromptSummaryRecord] = [] + prompt_issues: list[RegistryIssue] = [] + + prompts_root = docs_dir.joinpath("prompts") + if prompts_root.is_dir(): + prompts_by_id, prompt_summaries, prompt_issues = _load_prompts( + prompts_root=prompts_root, + skills_by_id=skills_by_id, + ) + + issues = [ + *skill_issues, + *prompt_issues, + *_collect_relationship_and_uri_issues( + skills_by_id=skills_by_id, + prompts_by_id=prompts_by_id, + ), + ] if issues: raise DocsRegistryValidationError(issues) diff --git a/src/personal_mcp/registry/read.py b/src/personal_mcp/registry/read.py index 3d91bf0..ffd3fb3 100644 --- a/src/personal_mcp/registry/read.py +++ b/src/personal_mcp/registry/read.py @@ -1,3 +1,4 @@ +from ..skills.document_loader import _normalize_docs_path from .contracts import DocsRegistry diff --git a/src/personal_mcp/skills/document_loader.py b/src/personal_mcp/skills/document_loader.py index 13ca6b2..744dce2 100644 --- a/src/personal_mcp/skills/document_loader.py +++ b/src/personal_mcp/skills/document_loader.py @@ -7,7 +7,9 @@ from typing import Any import yaml +from ..registry.models.common import SKILL_ID_RE from ..registry.models.common import ReferenceEntry +from ..registry.models.prompt import PromptFrontmatter from ..registry.models.skill import SkillFrontmatter @@ -32,15 +34,16 @@ def _parse_frontmatter(markdown: str, *, path: str) -> tuple[dict[str, Any], str body = "\n".join(lines[end_index + 1 :]) parsed = yaml.safe_load(raw_yaml) if not isinstance(parsed, dict): - raise ValueError(f"frontmatter must parse to an object: {path}") + raise TypeError(f"frontmatter must parse to an object: {path}") return parsed, body def _walk_markdown( node: Traversable, *, - prefix: PurePosixPath = PurePosixPath(), + prefix: PurePosixPath | None = None, ) -> list[tuple[str, Traversable]]: + prefix = PurePosixPath() if prefix is None else prefix results: list[tuple[str, Traversable]] = [] for child in sorted(node.iterdir(), key=lambda item: item.name): relpath = prefix.joinpath(child.name) diff --git a/tests/registry/ingest/conftest.py b/tests/registry/ingest/conftest.py index 6214261..f7e453c 100644 --- a/tests/registry/ingest/conftest.py +++ b/tests/registry/ingest/conftest.py @@ -11,7 +11,7 @@ from personal_mcp.registry.ingest.document import MarkdownDocument @pytest.fixture -def make_doc() -> Callable[[str, str], MarkdownDocument]: +def make_doc() -> Callable[[str], MarkdownDocument]: def _make_doc(relpath: str, content: str = "# body\n") -> MarkdownDocument: return MarkdownDocument(relpath=PurePosixPath(relpath), content=content) diff --git a/tests/registry/ingest/test_skill.py b/tests/registry/ingest/test_skill.py index a3ee8df..613b2f0 100644 --- a/tests/registry/ingest/test_skill.py +++ b/tests/registry/ingest/test_skill.py @@ -11,7 +11,7 @@ from personal_mcp.registry.ingest.skill import group_skill_paths pytestmark = pytest.mark.unit -MakeDoc = Callable[[str, str], MarkdownDocument] +MakeDoc = Callable[[str], MarkdownDocument] class TestSkillFilesBundle: diff --git a/uv.lock b/uv.lock index 3cbf249..aed2ba5 100644 --- a/uv.lock +++ b/uv.lock @@ -972,6 +972,7 @@ dependencies = [ dev = [ { name = "pre-commit" }, { name = "ruff" }, + { name = "ty" }, ] test = [ { name = "pytest" }, @@ -993,6 +994,7 @@ requires-dist = [ dev = [ { name = "pre-commit", specifier = ">=4.6.0" }, { name = "ruff", specifier = ">=0.15.18" }, + { name = "ty", specifier = ">=0.0.51" }, ] test = [ { name = "pytest", specifier = ">=9.1.1" }, @@ -1595,6 +1597,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "ty" +version = "0.0.51" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/ce/352fcdba5c72ea20e5d2e46e28809cdb617575b71209d971eff2ace8e6c4/ty-0.0.51.tar.gz", hash = "sha256:b90172d46365bb9d51a7011cbb5c60cc4f514f42c86635df6c092b717f85e1ac", size = 5953151, upload-time = "2026-06-19T01:48:58.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/8f/8fe7cab79a45320b2cdcd602f16d44c8108d2f418ff7ec316c6212f1f0cc/ty-0.0.51-py3-none-linux_armv6l.whl", hash = "sha256:947986bd82d324b3a5c58ce03f1dad160cdf36443d3e8f64b3484b861ba9bc64", size = 11884805, upload-time = "2026-06-19T01:48:20.184Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/56fdc39a3f44c0564fd157e1e59e1f9c3fc5ba57ae4472ded85c67c63d74/ty-0.0.51-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25a5b31e6f23fd5dc63ad29087ded09932409e4154e2fe07bbaed015035990bb", size = 11633593, upload-time = "2026-06-19T01:48:22.998Z" }, + { url = "https://files.pythonhosted.org/packages/33/57/136e83f24fc04f5afdcabff42f40fa27eae5ac3f0e3f12627d072a55f679/ty-0.0.51-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2faed19a8f1505370de071c008df52a994fc03a204f3267c3a33a32ca26f854f", size = 11063076, upload-time = "2026-06-19T01:48:25.223Z" }, + { url = "https://files.pythonhosted.org/packages/32/f8/5d32f0df5692446440ab781b9b119aa3e0c0dbfa78c583fe9be8417d54fa/ty-0.0.51-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08adbe53fb8bc9e7f00e89bf1d3c875a02cda76d83f109d2e6ab1ff35a7bfa8c", size = 11579542, upload-time = "2026-06-19T01:48:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0c/4f54ef338e9623886809ecd508931b0cd5b3aba1e591586a2f6aeaa8bd11/ty-0.0.51-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dc5e93695ab5dcbf1eef663aee60ec23a413547cc9cb06adcb0d842e9166bd0f", size = 11676189, upload-time = "2026-06-19T01:48:29.518Z" }, + { url = "https://files.pythonhosted.org/packages/56/27/31729066f9b9d3596941edaf267894eefc0b30df4518f003dba5f7276258/ty-0.0.51-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd92913bc90d1705ef9391ff8c6822b61e2e827fa295eb30bf0dfabcf815645", size = 12188154, upload-time = "2026-06-19T01:48:31.68Z" }, + { url = "https://files.pythonhosted.org/packages/2f/38/d4301aa12d2283c7130908baf1417a37dfe3e10f5669cb4ce2853c2540b4/ty-0.0.51-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:429a997394dac73870d71b87cc90efc54da3efaf319e72ca18aeef35a78aef90", size = 12780597, upload-time = "2026-06-19T01:48:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/c1/52/4b2e67e53f126d39abe201bd2299e467e27463a284e965ad195cbc217fa0/ty-0.0.51-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62d94f06e8c317e89b6884f2bde443040e596b88c7c79bd944c84c105b06257a", size = 12491115, upload-time = "2026-06-19T01:48:36.169Z" }, + { url = "https://files.pythonhosted.org/packages/74/50/aabfe55c132ebe72b4d639cbf772d931e11b0990d29c1f691922b6ccabc1/ty-0.0.51-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8f52952cff665bc52a36147e610c10f5699d30007d7a14ab7f345cff93476ff", size = 12230135, upload-time = "2026-06-19T01:48:38.445Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1b/9aa428052dbed91c50919cd080426a313cf20ce14c6bfe2b71345e548671/ty-0.0.51-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c1bd1355aee86af01e4e21b0bc16fc460fb05905761f0d8b8d70841de0feade8", size = 12468123, upload-time = "2026-06-19T01:48:40.47Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5a/f6ce69f2575259386c950c40e02578d0902760cb61f95045e9971182c24e/ty-0.0.51-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:79d1877e93460f936bc10ed1a31525702b7ce51075763ccba993be17f0b9e905", size = 11541672, upload-time = "2026-06-19T01:48:42.635Z" }, + { url = "https://files.pythonhosted.org/packages/35/3a/2af48924a683e959e95e5cc4dc88e5a8595206a0812b869032b95196f2b0/ty-0.0.51-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:cc233a6235fb23e2a44b14731a10043e37ba2f30f2c361cf49ad3633c5b9da9c", size = 11694015, upload-time = "2026-06-19T01:48:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/12/899875d8a60b198c8121cb92ce18e18cc072d23ca2130fcdaa176383ef72/ty-0.0.51-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bc7459348a253247bbfb2669a021e614281b86bbea24c36112b8a6e1a2499a16", size = 11832856, upload-time = "2026-06-19T01:48:47.028Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a2/88f681d826d97cc96ef9f6cadd4935f775758944cee07340aa46113bce28/ty-0.0.51-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:49a21237f6fd1de56beaff0a3e85fe022a09a3401e67e3abec41ce838a5d4d2e", size = 12333449, upload-time = "2026-06-19T01:48:49.091Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/535a4163b4452c6978c31fedfd7b5803cf3a2253e9455cde350f86638d6a/ty-0.0.51-py3-none-win32.whl", hash = "sha256:61b4b6a003c3ebe53a63a1125c9b6542aa01bc1b6c9a235d01ee328d000d61a9", size = 11177338, upload-time = "2026-06-19T01:48:51.433Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4d/2334fbb74291a20129fa7aaa8f789619ec9b6883b27f997b8baa27e4674f/ty-0.0.51-py3-none-win_amd64.whl", hash = "sha256:608d417cd1eaf79bcbd713d9830d5e3db9d57ec225c3af3e4ac9a9ff66b45d70", size = 12325675, upload-time = "2026-06-19T01:48:53.774Z" }, + { url = "https://files.pythonhosted.org/packages/50/b5/d49096cd5f3694becb86a5a6ccd0f229ead695fc7430d6bc4dd0a104c6fe/ty-0.0.51-py3-none-win_arm64.whl", hash = "sha256:62ced5e380284f12b2dc4802a3e4ed3dac39913fc6719afde7978814a4c7f169", size = 11657350, upload-time = "2026-06-19T01:48:55.904Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"