ty checking
This commit is contained in:
@@ -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.
|
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.
|
5. Separate fast feedback (`-m unit`) from broader integration/external lanes.
|
||||||
6. Validate structure early with collection checks before expanding assertions.
|
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
|
## 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)
|
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/)
|
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)
|
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
|
## Quick Validation Commands
|
||||||
|
|
||||||
@@ -108,3 +122,4 @@ When this skill is applied, return:
|
|||||||
5. Exact validation commands.
|
5. Exact validation commands.
|
||||||
6. Relevant source-doc links for any non-trivial recommendation.
|
6. Relevant source-doc links for any non-trivial recommendation.
|
||||||
7. Risks, assumptions, or open questions.
|
7. Risks, assumptions, or open questions.
|
||||||
|
8. Explicit confirmation status if monkeypatching, mocks, or fakes were requested or used.
|
||||||
|
|||||||
@@ -25,9 +25,21 @@ packages = ["src/personal_mcp"]
|
|||||||
dev = [
|
dev = [
|
||||||
"pre-commit>=4.6.0",
|
"pre-commit>=4.6.0",
|
||||||
"ruff>=0.15.18",
|
"ruff>=0.15.18",
|
||||||
|
"ty>=0.0.51",
|
||||||
]
|
]
|
||||||
test = [
|
test = [
|
||||||
"pytest>=9.1.1",
|
"pytest>=9.1.1",
|
||||||
"pytest-asyncio>=1.4.0",
|
"pytest-asyncio>=1.4.0",
|
||||||
"pytest-cov>=7.1.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"]
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ ignore = [
|
|||||||
"*.ipynb" = [
|
"*.ipynb" = [
|
||||||
"F401", # unused imports
|
"F401", # unused imports
|
||||||
"F841", # unused local variable
|
"F841", # unused local variable
|
||||||
|
"F821", # undefined name in exploratory notebook cells
|
||||||
]
|
]
|
||||||
|
|
||||||
[lint.isort]
|
[lint.isort]
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from personal_mcp.skills.document_loader import DocsRegistry
|
from personal_mcp.registry.contracts import DocsRegistry
|
||||||
from personal_mcp.skills.document_loader import PromptRecord
|
from personal_mcp.registry.models.registry import PromptRecord
|
||||||
from personal_mcp.skills.document_loader import SkillRecord
|
from personal_mcp.registry.models.registry import SkillRecord
|
||||||
|
|
||||||
DEFAULT_LIMIT = 20
|
DEFAULT_LIMIT = 20
|
||||||
MAX_LIMIT = 100
|
MAX_LIMIT = 100
|
||||||
@@ -36,8 +36,7 @@ def _summary_payload(skill: SkillRecord) -> dict[str, Any]:
|
|||||||
"resources": {
|
"resources": {
|
||||||
"document": skill.document_uri,
|
"document": skill.document_uri,
|
||||||
"references": [
|
"references": [
|
||||||
f"resource://skills/{skill.skill_id}/references/{ref_id}"
|
f"resource://skills/{skill.skill_id}/references/{ref_id}" for ref_id in sorted(skill.references)
|
||||||
for ref_id in sorted(skill.references)
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -124,14 +123,8 @@ def build_skills_index_payload(
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError("cursor must be an integer string") from exc
|
raise ValueError("cursor must be an integer string") from exc
|
||||||
|
|
||||||
ordered = [
|
ordered = [registry.skills_by_id[skill_id] for skill_id in registry.skills_in_load_order]
|
||||||
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)]
|
||||||
]
|
|
||||||
matches = [
|
|
||||||
skill
|
|
||||||
for skill in ordered
|
|
||||||
if _skill_matches(skill, query=query, tag=tag, capability=capability)
|
|
||||||
]
|
|
||||||
|
|
||||||
page = matches[start : start + normalized_limit]
|
page = matches[start : start + normalized_limit]
|
||||||
next_cursor = start + normalized_limit
|
next_cursor = start + normalized_limit
|
||||||
@@ -187,13 +180,8 @@ def build_prompts_index_payload(
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError("cursor must be an integer string") from exc
|
raise ValueError("cursor must be an integer string") from exc
|
||||||
|
|
||||||
ordered = [
|
ordered = [registry.prompts_by_id[prompt_id] for prompt_id in registry.prompts_in_load_order]
|
||||||
registry.prompts_by_id[prompt_id]
|
matches = [prompt for prompt in ordered if _prompt_matches(prompt, query=query, tag=tag)]
|
||||||
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]
|
page = matches[start : start + normalized_limit]
|
||||||
next_cursor = start + normalized_limit
|
next_cursor = start + normalized_limit
|
||||||
@@ -207,9 +195,7 @@ def build_prompts_index_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_prompt_detail_payload(
|
def build_prompt_detail_payload(registry: DocsRegistry, prompt_id: str) -> dict[str, Any]:
|
||||||
registry: DocsRegistry, prompt_id: str
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if prompt_id not in registry.prompts_by_id:
|
if prompt_id not in registry.prompts_by_id:
|
||||||
raise KeyError(prompt_id)
|
raise KeyError(prompt_id)
|
||||||
|
|
||||||
@@ -225,8 +211,7 @@ def build_prompt_detail_payload(
|
|||||||
"document": prompt.document_uri,
|
"document": prompt.document_uri,
|
||||||
},
|
},
|
||||||
"arguments": {
|
"arguments": {
|
||||||
arg_name: arg.model_dump(exclude_none=True)
|
arg_name: arg.model_dump(exclude_none=True) for arg_name, arg in sorted(prompt.arguments.items())
|
||||||
for arg_name, arg in sorted(prompt.arguments.items())
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-14
@@ -5,6 +5,7 @@ import re
|
|||||||
from inspect import Parameter
|
from inspect import Parameter
|
||||||
from inspect import Signature
|
from inspect import Signature
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
from fastmcp.server.transforms import ResourcesAsTools
|
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 get_prompt_by_id_payload
|
||||||
from personal_mcp.catalog.server import search_patterns_payload
|
from personal_mcp.catalog.server import search_patterns_payload
|
||||||
from personal_mcp.catalog.server import search_prompts_payload
|
from personal_mcp.catalog.server import search_prompts_payload
|
||||||
from personal_mcp.skills.document_loader import DocsRegistry
|
from personal_mcp.registry.contracts import DocsRegistry
|
||||||
from personal_mcp.skills.document_loader import load_docs_registry
|
from personal_mcp.registry.load import load_docs_registry
|
||||||
from personal_mcp.skills.document_loader import read_docs_markdown_path
|
from personal_mcp.registry.read import read_docs_markdown_path
|
||||||
from personal_mcp.skills.document_loader import read_prompt_document
|
from personal_mcp.registry.read import read_prompt_document
|
||||||
from personal_mcp.skills.document_loader import read_skill_document
|
from personal_mcp.registry.read import read_skill_document
|
||||||
from personal_mcp.skills.document_loader import read_skill_reference
|
from personal_mcp.registry.read import read_skill_reference
|
||||||
|
|
||||||
DOCS_ROOT = os.getenv("PERSONAL_MCP_DOCS_ROOT", "../../docs")
|
DOCS_ROOT = os.getenv("PERSONAL_MCP_DOCS_ROOT", "../../docs")
|
||||||
TOOL_SEARCH_MODE = os.getenv("PERSONAL_MCP_TOOL_SEARCH", "none").strip().lower()
|
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))
|
mcp.add_transform(BM25SearchTransform(**kwargs))
|
||||||
return
|
return
|
||||||
|
|
||||||
raise ValueError(
|
raise ValueError("PERSONAL_MCP_TOOL_SEARCH must be one of: none, regex, bm25")
|
||||||
"PERSONAL_MCP_TOOL_SEARCH must be one of: none, regex, bm25"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _ro_annotations() -> dict[str, bool]:
|
def _ro_annotations() -> dict[str, bool]:
|
||||||
@@ -105,6 +104,13 @@ def _python_type(prompt_arg_type: str) -> type[Any]:
|
|||||||
return str
|
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:
|
def _register_prompt_objects() -> None:
|
||||||
for prompt_id in REGISTRY.prompts_in_load_order:
|
for prompt_id in REGISTRY.prompts_in_load_order:
|
||||||
prompt = REGISTRY.prompts_by_id[prompt_id]
|
prompt = REGISTRY.prompts_by_id[prompt_id]
|
||||||
@@ -126,15 +132,12 @@ def _register_prompt_objects() -> None:
|
|||||||
|
|
||||||
signature = Signature(parameters=params, return_annotation=str)
|
signature = Signature(parameters=params, return_annotation=str)
|
||||||
|
|
||||||
prompt_content = prompt.document_content
|
prompt_handler = _make_prompt_handler(prompt.document_content)
|
||||||
|
|
||||||
def prompt_handler(**kwargs: Any) -> str:
|
|
||||||
return _render_prompt_markdown(prompt_content, kwargs)
|
|
||||||
|
|
||||||
prompt_handler.__name__ = re.sub(r"[^a-zA-Z0-9_]", "_", prompt_id)
|
prompt_handler.__name__ = re.sub(r"[^a-zA-Z0-9_]", "_", prompt_id)
|
||||||
prompt_handler.__doc__ = prompt.description
|
prompt_handler.__doc__ = prompt.description
|
||||||
prompt_handler.__annotations__ = annotations
|
prompt_handler.__annotations__ = annotations
|
||||||
prompt_handler.__signature__ = signature # type: ignore[attr-defined]
|
cast(Any, prompt_handler).__signature__ = signature
|
||||||
mcp.prompt(
|
mcp.prompt(
|
||||||
prompt_handler,
|
prompt_handler,
|
||||||
name=prompt_id,
|
name=prompt_id,
|
||||||
|
|||||||
@@ -30,15 +30,9 @@ class SkillFilesBundle:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_paths(cls, slug: str, paths: set[MarkdownDocument]) -> Self:
|
def from_paths(cls, slug: str, paths: set[MarkdownDocument]) -> Self:
|
||||||
skill = next(iter(p for p in paths if p.relpath.name == "SKILL.md"))
|
skill = next(iter(p for p in paths if p.relpath.name == "SKILL.md"))
|
||||||
groups = {
|
sorted_paths = tuple(sorted(paths, key=lambda p: p.relpath.as_posix()))
|
||||||
k: tuple(v)
|
references = tuple(p for p in sorted_paths if fnmatch(p.relpath.as_posix(), f"skills/{slug}/references/*.md"))
|
||||||
for k, v in groupby(
|
other = tuple(p for p in sorted_paths if p not in references and p != skill)
|
||||||
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)
|
|
||||||
return cls(
|
return cls(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
skill=skill,
|
skill=skill,
|
||||||
|
|||||||
+279
-187
@@ -1,6 +1,14 @@
|
|||||||
from importlib.resources import files
|
from importlib.resources import files
|
||||||
from pathlib import PurePosixPath
|
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 DocsRegistry
|
||||||
from .contracts import PromptRecord
|
from .contracts import PromptRecord
|
||||||
from .contracts import PromptSummaryRecord
|
from .contracts import PromptSummaryRecord
|
||||||
@@ -11,49 +19,47 @@ from .issues import DocsRegistryValidationError
|
|||||||
from .issues import RegistryIssue
|
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_markdown_by_path: dict[str, str],
|
||||||
docs_root: str = "docs",
|
skills_root,
|
||||||
) -> DocsRegistry:
|
) -> tuple[dict[str, SkillRecord], list[SkillSummaryRecord], list[RegistryIssue]]:
|
||||||
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",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
skills_by_id: dict[str, SkillRecord] = {}
|
skills_by_id: dict[str, SkillRecord] = {}
|
||||||
summaries: list[SkillSummaryRecord] = []
|
summaries: list[SkillSummaryRecord] = []
|
||||||
prompts_by_id: dict[str, PromptRecord] = {}
|
issues: list[RegistryIssue] = []
|
||||||
prompt_summaries: list[PromptSummaryRecord] = []
|
|
||||||
|
|
||||||
for skill_dir in sorted(skills_root.iterdir(), key=lambda item: item.name):
|
for skill_dir in sorted(skills_root.iterdir(), key=lambda item: item.name):
|
||||||
if not skill_dir.is_dir():
|
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()
|
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
|
for prompt_dir in sorted(prompts_root.iterdir(), key=lambda item: item.name):
|
||||||
prompt_rel_root = PurePosixPath("prompts").joinpath(prompt_dir_name)
|
if not prompt_dir.is_dir():
|
||||||
prompt_doc_relpath = prompt_rel_root.joinpath("PROMPT.md").as_posix()
|
continue
|
||||||
prompt_doc_file = prompt_dir.joinpath("PROMPT.md")
|
|
||||||
|
|
||||||
if not prompt_doc_file.is_file():
|
prompt_dir_name = prompt_dir.name
|
||||||
continue
|
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")
|
discovered_prompt_docs.add(prompt_doc_relpath)
|
||||||
try:
|
|
||||||
raw_frontmatter, _ = _parse_frontmatter(
|
prompt_markdown = prompt_doc_file.read_text(encoding="utf-8")
|
||||||
prompt_markdown,
|
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,
|
path=prompt_doc_relpath,
|
||||||
)
|
hint="fix PROMPT.md YAML frontmatter to match the contract",
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
for relpath, prompt_file in _walk_markdown(
|
prompt_id = frontmatter.name
|
||||||
prompts_root,
|
if prompt_id in prompts_by_id:
|
||||||
prefix=PurePosixPath("prompts"),
|
issues.append(
|
||||||
):
|
RegistryIssue(
|
||||||
if relpath in discovered_prompt_docs or relpath.endswith("/PROMPT.md"):
|
code="duplicate_prompt_id",
|
||||||
continue
|
message="duplicate prompt id discovered",
|
||||||
|
skill_id=prompt_id,
|
||||||
prompt_markdown = prompt_file.read_text(encoding="utf-8")
|
path=prompt_doc_relpath,
|
||||||
prompt_id = _reference_id_from_filename(PurePosixPath(relpath).name)
|
hint="ensure each prompt directory has a unique id",
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
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 skill_id, record in sorted(skills_by_id.items()):
|
||||||
for dependency in record.depends_on:
|
for dependency in record.depends_on:
|
||||||
@@ -363,18 +389,84 @@ def load_docs_registry(
|
|||||||
seen_uris.add(uri)
|
seen_uris.add(uri)
|
||||||
|
|
||||||
for prompt_id, record in sorted(prompts_by_id.items()):
|
for prompt_id, record in sorted(prompts_by_id.items()):
|
||||||
for uri in [record.document_uri]:
|
uri = record.document_uri
|
||||||
if uri in seen_uris:
|
if uri in seen_uris:
|
||||||
issues.append(
|
issues.append(
|
||||||
RegistryIssue(
|
RegistryIssue(
|
||||||
code="duplicate_uri",
|
code="duplicate_uri",
|
||||||
message=f"duplicate resource URI generated: {uri}",
|
message=f"duplicate resource URI generated: {uri}",
|
||||||
skill_id=prompt_id,
|
skill_id=prompt_id,
|
||||||
path=record.document_relpath,
|
path=record.document_relpath,
|
||||||
hint="ensure unique prompt ids",
|
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:
|
if issues:
|
||||||
raise DocsRegistryValidationError(issues)
|
raise DocsRegistryValidationError(issues)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from ..skills.document_loader import _normalize_docs_path
|
||||||
from .contracts import DocsRegistry
|
from .contracts import DocsRegistry
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ from typing import Any
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
from ..registry.models.common import SKILL_ID_RE
|
||||||
from ..registry.models.common import ReferenceEntry
|
from ..registry.models.common import ReferenceEntry
|
||||||
|
from ..registry.models.prompt import PromptFrontmatter
|
||||||
from ..registry.models.skill import SkillFrontmatter
|
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 :])
|
body = "\n".join(lines[end_index + 1 :])
|
||||||
parsed = yaml.safe_load(raw_yaml)
|
parsed = yaml.safe_load(raw_yaml)
|
||||||
if not isinstance(parsed, dict):
|
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
|
return parsed, body
|
||||||
|
|
||||||
|
|
||||||
def _walk_markdown(
|
def _walk_markdown(
|
||||||
node: Traversable,
|
node: Traversable,
|
||||||
*,
|
*,
|
||||||
prefix: PurePosixPath = PurePosixPath(),
|
prefix: PurePosixPath | None = None,
|
||||||
) -> list[tuple[str, Traversable]]:
|
) -> list[tuple[str, Traversable]]:
|
||||||
|
prefix = PurePosixPath() if prefix is None else prefix
|
||||||
results: list[tuple[str, Traversable]] = []
|
results: list[tuple[str, Traversable]] = []
|
||||||
for child in sorted(node.iterdir(), key=lambda item: item.name):
|
for child in sorted(node.iterdir(), key=lambda item: item.name):
|
||||||
relpath = prefix.joinpath(child.name)
|
relpath = prefix.joinpath(child.name)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from personal_mcp.registry.ingest.document import MarkdownDocument
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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:
|
def _make_doc(relpath: str, content: str = "# body\n") -> MarkdownDocument:
|
||||||
return MarkdownDocument(relpath=PurePosixPath(relpath), content=content)
|
return MarkdownDocument(relpath=PurePosixPath(relpath), content=content)
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from personal_mcp.registry.ingest.skill import group_skill_paths
|
|||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
MakeDoc = Callable[[str, str], MarkdownDocument]
|
MakeDoc = Callable[[str], MarkdownDocument]
|
||||||
|
|
||||||
|
|
||||||
class TestSkillFilesBundle:
|
class TestSkillFilesBundle:
|
||||||
|
|||||||
@@ -972,6 +972,7 @@ dependencies = [
|
|||||||
dev = [
|
dev = [
|
||||||
{ name = "pre-commit" },
|
{ name = "pre-commit" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
|
{ name = "ty" },
|
||||||
]
|
]
|
||||||
test = [
|
test = [
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
@@ -993,6 +994,7 @@ requires-dist = [
|
|||||||
dev = [
|
dev = [
|
||||||
{ name = "pre-commit", specifier = ">=4.6.0" },
|
{ name = "pre-commit", specifier = ">=4.6.0" },
|
||||||
{ name = "ruff", specifier = ">=0.15.18" },
|
{ name = "ruff", specifier = ">=0.15.18" },
|
||||||
|
{ name = "ty", specifier = ">=0.0.51" },
|
||||||
]
|
]
|
||||||
test = [
|
test = [
|
||||||
{ name = "pytest", specifier = ">=9.1.1" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user