reorg
This commit is contained in:
@@ -4,8 +4,8 @@ from fastmcp import FastMCP
|
|||||||
|
|
||||||
from personal_mcp.prompts import create_prompts_provider
|
from personal_mcp.prompts import create_prompts_provider
|
||||||
from personal_mcp.registry.load import get_docs_registry
|
from personal_mcp.registry.load import get_docs_registry
|
||||||
from personal_mcp.registry.models.registry import DocsRegistry
|
from personal_mcp.registry.load import read_docs_markdown_path
|
||||||
from personal_mcp.registry.read import read_docs_markdown_path
|
from personal_mcp.registry.models import DocsRegistry
|
||||||
from personal_mcp.skills import create_skills_provider
|
from personal_mcp.skills import create_skills_provider
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ from typing import Any
|
|||||||
import yaml
|
import yaml
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from personal_mcp.prompts.models import PromptDefinition
|
from .models import PromptDefinition
|
||||||
from personal_mcp.prompts.models import PromptMetadata
|
from .models import PromptMetadata
|
||||||
|
|
||||||
_PROMPT_ID_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
_PROMPT_ID_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
||||||
_FRONTMATTER_RE = re.compile(r"\A---\r?\n(?P<yaml>.*?)\r?\n---(?:\r?\n|$)", re.DOTALL)
|
_FRONTMATTER_RE = re.compile(r"\A---\r?\n(?P<yaml>.*?)\r?\n---(?:\r?\n|$)", re.DOTALL)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ from importlib.resources.abc import Traversable
|
|||||||
from fastmcp.prompts import Prompt
|
from fastmcp.prompts import Prompt
|
||||||
from fastmcp.server.providers import Provider
|
from fastmcp.server.providers import Provider
|
||||||
|
|
||||||
from personal_mcp.prompts.content import load_prompt_definition
|
from .content import load_prompt_definition
|
||||||
from personal_mcp.prompts.models import MarkdownPrompt
|
from .models import MarkdownPrompt
|
||||||
|
|
||||||
|
|
||||||
class MarkdownPromptsProvider(Provider):
|
class MarkdownPromptsProvider(Provider):
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from . import load as load
|
||||||
|
from . import models as models
|
||||||
|
from .load import get_docs_registry
|
||||||
|
from .models import DocsPath
|
||||||
|
from .models import DocsRegistry
|
||||||
|
|
||||||
|
__all__ = ["DocsPath", "DocsRegistry", "get_docs_registry", "load", "models"]
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
from .models.registry import DocsRegistry
|
|
||||||
|
|
||||||
__all__ = ["DocsRegistry"]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Functions to produce immutable dataclasses representing the document registry."""
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
from collections.abc import Iterator
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from importlib.resources.abc import Traversable
|
|
||||||
from itertools import starmap
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
from typing import Self
|
|
||||||
|
|
||||||
from personal_mcp.registry.models.common import DocsPath
|
|
||||||
from personal_mcp.registry.models.common import parse_docs_path
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class MarkdownDocument:
|
|
||||||
"""Represents a loaded markdown document with its content and frontmatter."""
|
|
||||||
|
|
||||||
relpath: DocsPath
|
|
||||||
"""The relative path of the document within the package resources."""
|
|
||||||
content: str
|
|
||||||
"""The raw markdown content of the document."""
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
object.__setattr__(self, "relpath", parse_docs_path(self.relpath))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_root(cls, root: Traversable) -> dict[DocsPath, Self]:
|
|
||||||
"""Recursively load all markdown documents from the root resource."""
|
|
||||||
mapped = starmap(cls.from_resource, walk_resources(root))
|
|
||||||
return {d.relpath: d for d in mapped}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_resource(cls, relpath: DocsPath, resource: Traversable) -> Self:
|
|
||||||
"""Load a markdown document from a package resource."""
|
|
||||||
return cls(relpath=relpath, content=resource.read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
def walk_resources(
|
|
||||||
node: Traversable,
|
|
||||||
*,
|
|
||||||
suffix: str = ".md",
|
|
||||||
prefix: PurePosixPath | None = None,
|
|
||||||
) -> Iterator[tuple[PurePosixPath, Traversable]]:
|
|
||||||
"""Recursively yield all resources in node, with their full path."""
|
|
||||||
prefix = prefix or PurePosixPath()
|
|
||||||
for child in sorted(node.iterdir(), key=lambda item: item.name):
|
|
||||||
relpath = prefix / child.name
|
|
||||||
if child.is_dir():
|
|
||||||
yield from walk_resources(child, suffix=suffix, prefix=relpath)
|
|
||||||
elif child.is_file() and child.name.lower().endswith(suffix):
|
|
||||||
yield relpath, child
|
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
from functools import cache
|
from functools import cache
|
||||||
from importlib.resources import files
|
from importlib.resources import files
|
||||||
|
from importlib.resources.abc import Traversable
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
from personal_mcp.registry.ingest.document import MarkdownDocument
|
from .models import DocsPath
|
||||||
from personal_mcp.registry.models.registry import DocsRegistry
|
from .models import DocsRegistry
|
||||||
|
from .models import parse_docs_path
|
||||||
|
|
||||||
|
|
||||||
@cache
|
@cache
|
||||||
@@ -12,11 +16,49 @@ def get_docs_registry() -> DocsRegistry:
|
|||||||
root = files("personal_mcp").joinpath("docs")
|
root = files("personal_mcp").joinpath("docs")
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
raise FileNotFoundError(f"docs root does not exist or is not a directory: {root}")
|
raise FileNotFoundError(f"docs root does not exist or is not a directory: {root}")
|
||||||
docs = MarkdownDocument.from_root(root)
|
docs = load_markdown(root)
|
||||||
|
|
||||||
docs_markdown_by_path = {relpath: doc.content for relpath, doc in docs.items() if relpath.parts[0] != "skills"}
|
docs_markdown_by_path = {relpath: content for relpath, content in docs.items() if relpath.parts[0] != "skills"}
|
||||||
|
|
||||||
return DocsRegistry(
|
return DocsRegistry(
|
||||||
docs_markdown_by_path=docs_markdown_by_path,
|
docs_markdown_by_path=docs_markdown_by_path,
|
||||||
docs_markdown_path_index=tuple(sorted(docs_markdown_by_path)),
|
docs_markdown_path_index=tuple(sorted(docs_markdown_by_path)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_markdown(root: Traversable) -> dict[DocsPath, str]:
|
||||||
|
"""Recursively load Markdown content keyed by package-relative path."""
|
||||||
|
return {
|
||||||
|
parse_docs_path(relpath): resource.read_text(encoding="utf-8")
|
||||||
|
for relpath, resource in walk_resources(root)
|
||||||
|
} # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def walk_resources(
|
||||||
|
node: Traversable,
|
||||||
|
*,
|
||||||
|
suffix: str = ".md",
|
||||||
|
prefix: PurePosixPath | None = None,
|
||||||
|
) -> Iterator[tuple[PurePosixPath, Traversable]]:
|
||||||
|
"""Recursively yield all resources in node, with their full path."""
|
||||||
|
prefix = prefix or PurePosixPath()
|
||||||
|
for child in sorted(node.iterdir(), key=lambda item: item.name):
|
||||||
|
relpath = prefix / child.name
|
||||||
|
if child.is_dir():
|
||||||
|
yield from walk_resources(child, suffix=suffix, prefix=relpath)
|
||||||
|
elif child.is_file() and child.name.lower().endswith(suffix):
|
||||||
|
yield relpath, child
|
||||||
|
|
||||||
|
|
||||||
|
def read_docs_markdown_path(registry: DocsRegistry, path: str) -> dict[str, str]:
|
||||||
|
docs_path = parse_docs_path(path)
|
||||||
|
if docs_path.parts[0] == "skills":
|
||||||
|
raise KeyError(f"unknown docs path: {docs_path.as_posix()}")
|
||||||
|
if docs_path not in registry.docs_markdown_by_path:
|
||||||
|
raise KeyError(f"unknown docs path: {docs_path.as_posix()}")
|
||||||
|
return {
|
||||||
|
"uri": f"resource://docs/{docs_path.as_posix()}",
|
||||||
|
"format": "markdown",
|
||||||
|
"source_path": f"docs/{docs_path.as_posix()}",
|
||||||
|
"content": registry.docs_markdown_by_path[docs_path],
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,16 +7,10 @@ from typing import ClassVar
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pydantic import BeforeValidator
|
from pydantic import BeforeValidator
|
||||||
from pydantic import ConfigDict
|
from pydantic import ConfigDict
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic import field_validator
|
||||||
|
|
||||||
|
__all__ = ["DocsRegistry"]
|
||||||
class StrictFrozenModel(BaseModel):
|
|
||||||
"""Immutable base model with strict field validation rules."""
|
|
||||||
|
|
||||||
model_config: ClassVar[ConfigDict] = ConfigDict(
|
|
||||||
extra="forbid",
|
|
||||||
frozen=True,
|
|
||||||
str_strip_whitespace=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def frozen_mapping[K, V](value: Mapping[K, V] | None = None) -> Mapping[K, V]:
|
def frozen_mapping[K, V](value: Mapping[K, V] | None = None) -> Mapping[K, V]:
|
||||||
@@ -39,3 +33,27 @@ def parse_docs_path(value: str | PurePosixPath) -> PurePosixPath:
|
|||||||
|
|
||||||
|
|
||||||
type DocsPath = Annotated[PurePosixPath, BeforeValidator(parse_docs_path)]
|
type DocsPath = Annotated[PurePosixPath, BeforeValidator(parse_docs_path)]
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_docs_mapping() -> Mapping[DocsPath, str]:
|
||||||
|
return frozen_mapping()
|
||||||
|
|
||||||
|
|
||||||
|
class DocsRegistry(BaseModel):
|
||||||
|
"""In-memory index of documentation content."""
|
||||||
|
|
||||||
|
docs_markdown_by_path: Mapping[DocsPath, str] = Field(default_factory=_empty_docs_mapping)
|
||||||
|
"""Maps each documentation path to its loaded Markdown content."""
|
||||||
|
docs_markdown_path_index: tuple[DocsPath, ...]
|
||||||
|
"""Lists documentation paths in deterministic index order."""
|
||||||
|
|
||||||
|
@field_validator("docs_markdown_by_path", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def freeze_mappings(cls, value: Mapping[str, object] | None) -> Mapping[str, object]:
|
||||||
|
return frozen_mapping(value)
|
||||||
|
|
||||||
|
model_config: ClassVar[ConfigDict] = ConfigDict(
|
||||||
|
extra="forbid",
|
||||||
|
frozen=True,
|
||||||
|
str_strip_whitespace=True,
|
||||||
|
)
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Pydantic models for the document registry."""
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
from collections.abc import Mapping
|
|
||||||
|
|
||||||
from pydantic import Field
|
|
||||||
from pydantic import field_validator
|
|
||||||
|
|
||||||
from .common import DocsPath
|
|
||||||
from .common import StrictFrozenModel
|
|
||||||
from .common import frozen_mapping
|
|
||||||
|
|
||||||
|
|
||||||
def _empty_docs_mapping() -> Mapping[DocsPath, str]:
|
|
||||||
return frozen_mapping()
|
|
||||||
|
|
||||||
|
|
||||||
class DocsRegistry(StrictFrozenModel):
|
|
||||||
"""In-memory index of documentation content."""
|
|
||||||
|
|
||||||
docs_markdown_by_path: Mapping[DocsPath, str] = Field(default_factory=_empty_docs_mapping)
|
|
||||||
"""Maps each documentation path to its loaded Markdown content."""
|
|
||||||
docs_markdown_path_index: tuple[DocsPath, ...]
|
|
||||||
"""Lists documentation paths in deterministic index order."""
|
|
||||||
|
|
||||||
@field_validator("docs_markdown_by_path", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def freeze_mappings(cls, value: Mapping[str, object] | None) -> Mapping[str, object]:
|
|
||||||
return frozen_mapping(value)
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
from .models.common import parse_docs_path
|
|
||||||
from .models.registry import DocsRegistry
|
|
||||||
|
|
||||||
|
|
||||||
def read_docs_markdown_path(registry: DocsRegistry, path: str) -> dict[str, str]:
|
|
||||||
docs_path = parse_docs_path(path)
|
|
||||||
if docs_path.parts[0] == "skills":
|
|
||||||
raise KeyError(f"unknown docs path: {docs_path.as_posix()}")
|
|
||||||
if docs_path not in registry.docs_markdown_by_path:
|
|
||||||
raise KeyError(f"unknown docs path: {docs_path.as_posix()}")
|
|
||||||
return {
|
|
||||||
"uri": f"resource://docs/{docs_path.as_posix()}",
|
|
||||||
"format": "markdown",
|
|
||||||
"source_path": f"docs/{docs_path.as_posix()}",
|
|
||||||
"content": registry.docs_markdown_by_path[docs_path],
|
|
||||||
}
|
|
||||||
@@ -3,12 +3,12 @@ from pathlib import PurePosixPath
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from personal_mcp.registry.ingest.document import MarkdownDocument
|
from personal_mcp.registry.load import load_markdown
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
class TestMarkdownDocument:
|
class TestLoadMarkdown:
|
||||||
"""Covers recursive Markdown discovery and loading."""
|
"""Covers recursive Markdown discovery and loading."""
|
||||||
|
|
||||||
def test_loads_markdown_in_stable_order(self, tmp_path: Path) -> None:
|
def test_loads_markdown_in_stable_order(self, tmp_path: Path) -> None:
|
||||||
@@ -18,10 +18,10 @@ class TestMarkdownDocument:
|
|||||||
(nested / "a.md").write_text("alpha\n", encoding="utf-8")
|
(nested / "a.md").write_text("alpha\n", encoding="utf-8")
|
||||||
(nested / "ignored.txt").write_text("ignored\n", encoding="utf-8")
|
(nested / "ignored.txt").write_text("ignored\n", encoding="utf-8")
|
||||||
|
|
||||||
docs = MarkdownDocument.from_root(tmp_path)
|
docs = load_markdown(tmp_path)
|
||||||
|
|
||||||
assert list(docs) == [
|
assert list(docs) == [
|
||||||
PurePosixPath("guides/a.md"),
|
PurePosixPath("guides/a.md"),
|
||||||
PurePosixPath("guides/b.md"),
|
PurePosixPath("guides/b.md"),
|
||||||
]
|
]
|
||||||
assert docs[PurePosixPath("guides/b.md")].content == "caf\u00e9\n"
|
assert docs[PurePosixPath("guides/b.md")] == "caf\u00e9\n"
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from personal_mcp.registry.models.registry import DocsRegistry
|
from personal_mcp.registry.models.registry import DocsRegistry
|
||||||
from personal_mcp.registry.read import read_docs_markdown_path
|
|
||||||
|
from personal_mcp.registry.load import read_docs_markdown_path
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user