Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55ef7e972a | ||
|
|
de95477480 | ||
|
|
73eb490537 | ||
|
|
c866d1bdb0 | ||
|
|
512db5f526 |
@@ -1,11 +1,16 @@
|
|||||||
---
|
---
|
||||||
name: Pytest Scaffolding Guidance
|
name: Pytest Scaffolding Guidance
|
||||||
description: Route tests edits to the Personal MCP pytesting resource.
|
description: Use when working under tests/. Route test edits to pytesting guidance and never create or expand tests unless the user explicitly asks.
|
||||||
applyTo: 'tests/**'
|
applyTo: 'tests/**'
|
||||||
---
|
---
|
||||||
|
|
||||||
When editing files under `tests/`, use `skill://pytesting/SKILL.md` as the primary guidance source for test scaffolding and pytest authoring decisions.
|
When editing files under `tests/`, use `skill://pytesting/SKILL.md` as the primary guidance source for test scaffolding and pytest authoring decisions.
|
||||||
|
|
||||||
|
Hard rule:
|
||||||
|
|
||||||
|
- Do not create new test files, test cases, or test scaffolding unless the user explicitly asks for tests in the current request.
|
||||||
|
- If tests could help but were not requested, mention them as an optional next step instead of adding them.
|
||||||
|
|
||||||
Execution pattern:
|
Execution pattern:
|
||||||
|
|
||||||
1. Load `skill://pytesting/SKILL.md` first.
|
1. Load `skill://pytesting/SKILL.md` first.
|
||||||
|
|||||||
Vendored
+18
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Python: personal-mcp entrypoint",
|
||||||
|
"type": "debugpy",
|
||||||
|
"request": "launch",
|
||||||
|
"module": "personal_mcp.__main__",
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"justMyCode": true,
|
||||||
|
"env": {
|
||||||
|
"PYTHONUNBUFFERED": "1"
|
||||||
|
},
|
||||||
|
"args": [ "--port", "8766", "--reload" ]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+1
-7
@@ -51,13 +51,7 @@
|
|||||||
"command": "uv",
|
"command": "uv",
|
||||||
"args": [
|
"args": [
|
||||||
"run",
|
"run",
|
||||||
"uvicorn",
|
"personal-mcp",
|
||||||
"personal_mcp.main:create_app",
|
|
||||||
"--factory",
|
|
||||||
"--host",
|
|
||||||
"127.0.0.1",
|
|
||||||
"--port",
|
|
||||||
"8000",
|
|
||||||
"--reload"
|
"--reload"
|
||||||
],
|
],
|
||||||
"options": {
|
"options": {
|
||||||
|
|||||||
+25
-30
@@ -1,57 +1,52 @@
|
|||||||
FROM python:3.14-slim AS builder
|
FROM python:3.14-slim AS builder
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
UV_SYSTEM_CERTS=1 \
|
||||||
|
UV_PYTHON_DOWNLOADS=0 \
|
||||||
|
UV_NO_MANAGED_PYTHON=1 \
|
||||||
|
UV_SYSTEM_PYTHON=1 \
|
||||||
|
UV_PROJECT_ENVIRONMENT=/usr/local \
|
||||||
UV_COMPILE_BYTECODE=1 \
|
UV_COMPILE_BYTECODE=1 \
|
||||||
UV_LINK_MODE=copy \
|
UV_NO_DEV=1 \
|
||||||
UV_LOCKED=1
|
UV_LOCKED=1 \
|
||||||
|
UV_LINK_MODE=copy
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
--mount=type=bind,source=zensical.toml,target=zensical.toml \
|
--mount=type=bind,source=zensical.toml,target=zensical.toml \
|
||||||
--mount=type=bind,source=docs/,target=docs/ \
|
--mount=type=bind,source=src/personal_mcp/docs/,target=src/personal_mcp/docs/ \
|
||||||
uvx zensical build
|
uvx zensical build --clean --strict
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
uv sync --no-install-project
|
uv sync --no-install-project
|
||||||
|
|
||||||
# COPY --chown=appuser:appuser . /app
|
COPY pyproject.toml uv.lock README.md /app/
|
||||||
|
COPY ./src /app/src
|
||||||
|
|
||||||
# RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
# uv sync --no-editable
|
uv sync
|
||||||
|
|
||||||
FROM python:3.14-slim AS runtime
|
FROM python:3.14-slim AS runtime
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1
|
||||||
PATH="/app/.venv/bin:$PATH" \
|
|
||||||
PERSONAL_MCP_HOST=0.0.0.0 \
|
|
||||||
PERSONAL_MCP_PORT=8765 \
|
|
||||||
PERSONAL_MCP_RELOAD=false \
|
|
||||||
PERSONAL_MCP_SITE_DIR=/app/site
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
EXPOSE 8765
|
|
||||||
|
|
||||||
RUN groupadd --system --gid 1001 appuser && \
|
RUN groupadd --system --gid 1001 appuser && \
|
||||||
useradd --system --uid 1001 --gid appuser appuser
|
useradd --system --uid 1001 --gid appuser appuser
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest --chown=appuser:appuser /uv /uvx /bin/
|
WORKDIR /app
|
||||||
COPY --from=builder --chown=appuser:appuser /app/.venv /app/.venv
|
|
||||||
COPY --from=builder --chown=appuser:appuser /app/site /app/site
|
|
||||||
COPY --chown=appuser:appuser ./docs /app/docs
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN chown appuser:appuser /app
|
||||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
|
||||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
COPY --from=builder --chown=appuser:appuser /usr/local/lib /usr/local/lib
|
||||||
--mount=type=bind,source=src/,target=src/ \
|
COPY --from=builder --chown=appuser:appuser /usr/local/bin /usr/local/bin
|
||||||
uv sync --no-editable --refresh-package prompts
|
COPY --from=builder --chown=appuser:appuser /app /app
|
||||||
|
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
ENTRYPOINT ["python", "-m", "personal_mcp"]
|
ENTRYPOINT ["/usr/local/bin/personal-mcp"]
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# JSL MCP
|
||||||
|
|
||||||
|
```shell
|
||||||
|
uv run mcp-stdio
|
||||||
|
```
|
||||||
|
|
||||||
|
```shell
|
||||||
|
uv run fastmcp list --command "uv run mcp-stdio" --prompts
|
||||||
|
```
|
||||||
|
|
||||||
|
```shell
|
||||||
|
uv run fastmcp call --command "uv run mcp-stdio" \
|
||||||
|
--target authoring --prompt \
|
||||||
|
--input-json '{"artifact_type":"prompt","artifact_id":"release-notes","goal":"Create a reusable release-notes workflow."}' \
|
||||||
|
--json
|
||||||
|
```
|
||||||
+9
-4
@@ -1,8 +1,13 @@
|
|||||||
services:
|
services:
|
||||||
personal-mcp:
|
app:
|
||||||
build:
|
image: personal-mcp:latest
|
||||||
context: .
|
build: .
|
||||||
dockerfile: Dockerfile
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8765:8765"
|
- "8765:8765"
|
||||||
|
environment:
|
||||||
|
PERSONAL_MCP_PORT: 8765
|
||||||
|
PERSONAL_MCP_HOST: 0.0.0.0
|
||||||
|
PERSONAL_MCP_RELOAD: 1
|
||||||
|
volumes:
|
||||||
|
- ./src:/app/src:ro
|
||||||
|
|||||||
+5
-6
@@ -1,5 +1,5 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "prompts"
|
name = "personal_mcp"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -17,13 +17,11 @@ constraint-dependencies = ["fastmcp-slim==4.0.0b4"]
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
personal-mcp = "personal_mcp.__main__:main"
|
personal-mcp = "personal_mcp.__main__:main"
|
||||||
|
mcp-stdio = "personal_mcp.mcp:run_stdio"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["uv_build>=0.12.7,<0.13"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "uv_build"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
|
||||||
packages = ["src/personal_mcp"]
|
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -52,3 +50,4 @@ markers = [
|
|||||||
|
|
||||||
[tool.ty.src]
|
[tool.ty.src]
|
||||||
include = ["src", "tests"]
|
include = ["src", "tests"]
|
||||||
|
exclude = ["src/personal_mcp/docs/skills/*/examples/*.py"]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ def main(cli: bool = True) -> None:
|
|||||||
"""Run the root MCP server."""
|
"""Run the root MCP server."""
|
||||||
settings = get_settings(cli=cli)
|
settings = get_settings(cli=cli)
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"personal_mcp.web.app:create_app",
|
"personal_mcp.app:create_app",
|
||||||
factory=True,
|
factory=True,
|
||||||
host=settings.host,
|
host=settings.host,
|
||||||
port=settings.port,
|
port=settings.port,
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from importlib.resources import as_file
|
||||||
|
from importlib.resources import files
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi import Response
|
||||||
|
from fastapi import status
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from .config import Settings
|
||||||
|
from .config import get_settings
|
||||||
|
from .mcp import create_mcp
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
|
runtime_settings = settings if settings is not None else get_settings()
|
||||||
|
docs_route = runtime_settings.mounts.docs.rstrip("/") or "/docs"
|
||||||
|
mcp_app = create_mcp().http_app(
|
||||||
|
json_response=True,
|
||||||
|
stateless_http=True,
|
||||||
|
transport="http",
|
||||||
|
)
|
||||||
|
app = FastAPI(
|
||||||
|
debug=runtime_settings.debug,
|
||||||
|
docs_url=None,
|
||||||
|
redoc_url=None,
|
||||||
|
openapi_url=None,
|
||||||
|
lifespan=app_lifespan,
|
||||||
|
)
|
||||||
|
app.state.settings = runtime_settings
|
||||||
|
|
||||||
|
async def redirect_root_to_docs() -> RedirectResponse:
|
||||||
|
return RedirectResponse(
|
||||||
|
url=docs_route,
|
||||||
|
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_api_route(
|
||||||
|
"/",
|
||||||
|
redirect_root_to_docs,
|
||||||
|
methods=["GET", "HEAD"],
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.mount(runtime_settings.mounts.mcp, mcp_app, name="mcp")
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def app_lifespan(app: FastAPI):
|
||||||
|
from . import __name__ as package_root_name
|
||||||
|
|
||||||
|
site_resource = files(package_root_name).joinpath("site")
|
||||||
|
with as_file(site_resource) as site_dir:
|
||||||
|
mount_docs(
|
||||||
|
app,
|
||||||
|
docs_route=app.state.settings.mounts.docs,
|
||||||
|
site_dir=site_dir,
|
||||||
|
)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def mount_docs(app: FastAPI, *, docs_route: str, site_dir: Path) -> None:
|
||||||
|
"""Mount the pre-built static docs site, or expose a clear missing-build response."""
|
||||||
|
normalized_route = docs_route.rstrip("/") or "/docs"
|
||||||
|
docs_root = f"{normalized_route}/"
|
||||||
|
|
||||||
|
async def redirect_to_docs_root() -> RedirectResponse:
|
||||||
|
return RedirectResponse(
|
||||||
|
url=docs_root,
|
||||||
|
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_api_route(
|
||||||
|
normalized_route,
|
||||||
|
redirect_to_docs_root,
|
||||||
|
methods=["GET", "HEAD"],
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if site_dir.is_dir():
|
||||||
|
app.mount(
|
||||||
|
normalized_route,
|
||||||
|
StaticFiles(directory=site_dir, html=True),
|
||||||
|
name="docs",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
async def docs_not_built() -> Response:
|
||||||
|
return Response(
|
||||||
|
content=("Static docs have not been built yet. Run `uv run zensical build` before using this route."),
|
||||||
|
media_type="text/plain",
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_api_route(
|
||||||
|
normalized_route,
|
||||||
|
docs_not_built,
|
||||||
|
methods=["GET"],
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
app.add_api_route(
|
||||||
|
f"{normalized_route}/{{path:path}}",
|
||||||
|
docs_not_built,
|
||||||
|
methods=["GET"],
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
@@ -2,7 +2,6 @@ from functools import cache
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pydantic import DirectoryPath
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from pydantic_settings import SettingsConfigDict
|
from pydantic_settings import SettingsConfigDict
|
||||||
@@ -29,7 +28,6 @@ class Settings(BaseSettings):
|
|||||||
debug: bool = False
|
debug: bool = False
|
||||||
log_level: str = "info"
|
log_level: str = "info"
|
||||||
mounts: Mounts = Field(default_factory=Mounts)
|
mounts: Mounts = Field(default_factory=Mounts)
|
||||||
site_dir: DirectoryPath = Field(default=DEFAULT_SITE_DIR)
|
|
||||||
host: str = "localhost"
|
host: str = "localhost"
|
||||||
port: int = 8080
|
port: int = 8080
|
||||||
reload: bool = True
|
reload: bool = True
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Use this skill as a progressive reference for NiceGUI applications built with Fa
|
|||||||
|
|
||||||
- Planning or reviewing NiceGUI application structure and FastAPI composition.
|
- Planning or reviewing NiceGUI application structure and FastAPI composition.
|
||||||
- Building or refactoring pages, components, layouts, and static assets.
|
- Building or refactoring pages, components, layouts, and static assets.
|
||||||
|
- Creating editable tables with Python-authoritative state, validation, and persistence.
|
||||||
- Modeling UI state with bindings or bindable dataclasses.
|
- Modeling UI state with bindings or bindable dataclasses.
|
||||||
- Implementing forms, uploads, refreshes, live updates, or background work.
|
- Implementing forms, uploads, refreshes, live updates, or background work.
|
||||||
- Diagnosing UI state, concurrency, navigation, or asset problems.
|
- Diagnosing UI state, concurrency, navigation, or asset problems.
|
||||||
@@ -59,9 +60,9 @@ Load [styling and customization](./references/styling-and-customization.md) for:
|
|||||||
- cosmetic treatment of controls, surfaces, typography, and visual states
|
- cosmetic treatment of controls, surfaces, typography, and visual states
|
||||||
- visual validation at supported viewport sizes
|
- visual validation at supported viewport sizes
|
||||||
|
|
||||||
### Component Mechanics And Customization
|
### Component Mechanics
|
||||||
|
|
||||||
Load [component mechanics and customization](./references/component-mechanics-and-customization.md) for:
|
Load [component mechanics](./references/component-mechanics.md) for:
|
||||||
|
|
||||||
- the NiceGUI Python wrapper, element bridge, Quasar component, and Vue runtime boundaries
|
- the NiceGUI Python wrapper, element bridge, Quasar component, and Vue runtime boundaries
|
||||||
- deciding between constructors, bindings, Quasar props, events, slots, and frontend methods
|
- deciding between constructors, bindings, Quasar props, events, slots, and frontend methods
|
||||||
@@ -69,7 +70,18 @@ Load [component mechanics and customization](./references/component-mechanics-an
|
|||||||
- detached content and external icon assets
|
- detached content and external icon assets
|
||||||
- source research against the installed NiceGUI and bundled Quasar versions
|
- source research against the installed NiceGUI and bundled Quasar versions
|
||||||
- `ui.select` and `ui.icon` mechanics and caveats
|
- `ui.select` and `ui.icon` mechanics and caveats
|
||||||
- dialog scaling when detached popup geometry must be preserved
|
- scoped component slots and their interaction contracts
|
||||||
|
|
||||||
|
### Editable Tables
|
||||||
|
|
||||||
|
Load [editable tables](./references/tables.md) for:
|
||||||
|
|
||||||
|
- Python-authoritative editable `ui.table` state
|
||||||
|
- rendering dataframe records into row-scoped bindable dataclasses
|
||||||
|
- stable row identity across sorting, filtering, and pagination
|
||||||
|
- NiceGUI editors in Quasar `body-cell-*` scoped slots
|
||||||
|
- validation, persistence, rejection, and canonical row refresh
|
||||||
|
- the full `body` slot required when escalating to `QPopupEdit`
|
||||||
|
|
||||||
### Bindable State
|
### Bindable State
|
||||||
|
|
||||||
@@ -117,8 +129,9 @@ Load [source documentation](./references/source-documentation.md) when:
|
|||||||
|
|
||||||
1. Load [application architecture](./references/architecture.md) for page and component ownership decisions.
|
1. Load [application architecture](./references/architecture.md) for page and component ownership decisions.
|
||||||
2. Load [styling and customization](./references/styling-and-customization.md) for themes, layout, responsive presentation, utility classes, or CSS.
|
2. Load [styling and customization](./references/styling-and-customization.md) for themes, layout, responsive presentation, utility classes, or CSS.
|
||||||
3. Load [component mechanics and customization](./references/component-mechanics-and-customization.md) when behavior must be mapped across NiceGUI, Quasar, and Vue, or when detached content and component-specific behavior are involved.
|
3. Load [component mechanics](./references/component-mechanics.md) when behavior must be mapped across NiceGUI, Quasar, and Vue, or when detached content and component-specific behavior are involved.
|
||||||
4. Add [interaction patterns](./references/interaction-patterns.md) or [bindable dataclasses](./references/binding-dataclasses.md) according to the page behavior.
|
4. Load [editable tables](./references/tables.md) when table cells accept user changes or `QPopupEdit` is being considered.
|
||||||
|
5. Add [interaction patterns](./references/interaction-patterns.md) or [bindable dataclasses](./references/binding-dataclasses.md) according to the page behavior.
|
||||||
|
|
||||||
### Debugging Or Production Review
|
### Debugging Or Production Review
|
||||||
|
|
||||||
@@ -133,6 +146,7 @@ Load [source documentation](./references/source-documentation.md) when:
|
|||||||
- Avoid blocking I/O and CPU-heavy work in the UI event loop.
|
- Avoid blocking I/O and CPU-heavy work in the UI event loop.
|
||||||
- Prefer event-driven updates and explicit refreshes over unrelated polling.
|
- Prefer event-driven updates and explicit refreshes over unrelated polling.
|
||||||
- Discover component capabilities through NiceGUI docs and constructors, then the wrapped Quasar API.
|
- Discover component capabilities through NiceGUI docs and constructors, then the wrapped Quasar API.
|
||||||
|
- Keep editable table records authoritative in Python; send stable row keys with edit proposals and reassert canonical rows after validation.
|
||||||
- Research the current NiceGUI and Quasar source documentation before generating component-specific code or CSS.
|
- Research the current NiceGUI and Quasar source documentation before generating component-specific code or CSS.
|
||||||
- Prefer constructor arguments and native Quasar features through NiceGUI; use Tailwind for structure and scoped static CSS for stable fine tuning.
|
- Prefer constructor arguments and native Quasar features through NiceGUI; use Tailwind for structure and scoped static CSS for stable fine tuning.
|
||||||
- Provide loading, success, and failure states for user-triggered work.
|
- Provide loading, success, and failure states for user-triggered work.
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env -S uv run --script
|
||||||
|
# /// script
|
||||||
|
# dependencies = [
|
||||||
|
# "nicegui==3.16.0",
|
||||||
|
# "pandas",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
from nicegui import binding
|
||||||
|
from nicegui import events
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
STATUS_OPTIONS = ["draft", "active", "archived"]
|
||||||
|
EDITABLE_FIELDS = ("name", "quantity", "status")
|
||||||
|
TableValue = str | int
|
||||||
|
TableRow = dict[str, TableValue]
|
||||||
|
|
||||||
|
|
||||||
|
@binding.bindable_dataclass(bindable_fields=EDITABLE_FIELDS)
|
||||||
|
class EditableRow:
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
quantity: int
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class EditableTableState:
|
||||||
|
rows_by_id: dict[int, EditableRow]
|
||||||
|
table_rows_by_id: dict[int, TableRow]
|
||||||
|
|
||||||
|
def table_rows(self) -> list[TableRow]:
|
||||||
|
return list(self.table_rows_by_id.values())
|
||||||
|
|
||||||
|
|
||||||
|
def dataframe_to_state(dataframe: pd.DataFrame) -> EditableTableState:
|
||||||
|
required_columns = {"id", *EDITABLE_FIELDS}
|
||||||
|
missing_columns = required_columns.difference(dataframe.columns)
|
||||||
|
if missing_columns:
|
||||||
|
raise ValueError(f"Missing columns: {sorted(missing_columns)}")
|
||||||
|
if not dataframe["id"].is_unique:
|
||||||
|
raise ValueError("The id column must contain unique row keys")
|
||||||
|
|
||||||
|
rows_by_id: dict[int, EditableRow] = {}
|
||||||
|
table_rows_by_id: dict[int, TableRow] = {}
|
||||||
|
for record in dataframe.to_dict(orient="records"):
|
||||||
|
row_state = EditableRow(
|
||||||
|
id=int(record["id"]),
|
||||||
|
name=str(record["name"]),
|
||||||
|
quantity=int(record["quantity"]),
|
||||||
|
status=str(record["status"]),
|
||||||
|
)
|
||||||
|
if row_state.status not in STATUS_OPTIONS:
|
||||||
|
raise ValueError(f"Unknown status {row_state.status!r}")
|
||||||
|
if row_state.id in rows_by_id:
|
||||||
|
raise ValueError("Row keys must remain unique after normalization")
|
||||||
|
|
||||||
|
table_row: TableRow = {
|
||||||
|
"id": row_state.id,
|
||||||
|
"name": row_state.name,
|
||||||
|
"quantity": row_state.quantity,
|
||||||
|
"status": row_state.status,
|
||||||
|
}
|
||||||
|
for field_name in EDITABLE_FIELDS:
|
||||||
|
binding.bind_to(
|
||||||
|
row_state,
|
||||||
|
field_name,
|
||||||
|
table_row,
|
||||||
|
field_name,
|
||||||
|
other_strict=True,
|
||||||
|
)
|
||||||
|
rows_by_id[row_state.id] = row_state
|
||||||
|
table_rows_by_id[row_state.id] = table_row
|
||||||
|
|
||||||
|
return EditableTableState(rows_by_id, table_rows_by_id)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_edit(field: str, raw_value: object) -> TableValue:
|
||||||
|
match field:
|
||||||
|
case "name":
|
||||||
|
if not isinstance(raw_value, str) or not (name := raw_value.strip()):
|
||||||
|
raise ValueError("Name is required")
|
||||||
|
return name
|
||||||
|
case "quantity":
|
||||||
|
if isinstance(raw_value, bool) or not isinstance(raw_value, (int, float, str)):
|
||||||
|
raise TypeError("Quantity must be an integer")
|
||||||
|
if isinstance(raw_value, float) and not raw_value.is_integer():
|
||||||
|
raise ValueError("Quantity must be an integer")
|
||||||
|
try:
|
||||||
|
quantity = int(raw_value)
|
||||||
|
except (ValueError, OverflowError) as error:
|
||||||
|
raise ValueError("Quantity must be an integer") from error
|
||||||
|
if not 0 <= quantity <= 1_000:
|
||||||
|
raise ValueError("Quantity must be between 0 and 1000")
|
||||||
|
return quantity
|
||||||
|
case "status":
|
||||||
|
if not isinstance(raw_value, str) or raw_value not in STATUS_OPTIONS:
|
||||||
|
raise ValueError("Unknown status")
|
||||||
|
return raw_value
|
||||||
|
case _:
|
||||||
|
raise ValueError(f"Field {field!r} is not editable")
|
||||||
|
|
||||||
|
|
||||||
|
def save_row(dataframe: pd.DataFrame, row_state: EditableRow) -> None:
|
||||||
|
matching_rows = dataframe["id"].eq(row_state.id)
|
||||||
|
if int(matching_rows.sum()) != 1:
|
||||||
|
raise ValueError("This row no longer exists")
|
||||||
|
dataframe.loc[matching_rows, "name"] = row_state.name
|
||||||
|
dataframe.loc[matching_rows, "quantity"] = row_state.quantity
|
||||||
|
dataframe.loc[matching_rows, "status"] = row_state.status
|
||||||
|
|
||||||
|
|
||||||
|
def render_table(dataframe: pd.DataFrame) -> EditableTableState:
|
||||||
|
state = dataframe_to_state(dataframe)
|
||||||
|
columns = [
|
||||||
|
{"name": "name", "label": "Name", "field": "name", "align": "left"},
|
||||||
|
{"name": "quantity", "label": "Quantity", "field": "quantity", "align": "right"},
|
||||||
|
{"name": "status", "label": "Status", "field": "status", "align": "left"},
|
||||||
|
]
|
||||||
|
table = ui.table(
|
||||||
|
columns=columns,
|
||||||
|
rows=state.table_rows(),
|
||||||
|
row_key="id",
|
||||||
|
selection="multiple",
|
||||||
|
).classes("w-full")
|
||||||
|
|
||||||
|
def apply_edit(event: events.GenericEventArguments) -> None:
|
||||||
|
try:
|
||||||
|
raw_row_id, raw_field, raw_value = event.args
|
||||||
|
row_id = int(raw_row_id)
|
||||||
|
field_name = str(raw_field)
|
||||||
|
row_state = state.rows_by_id.get(row_id)
|
||||||
|
if row_state is None:
|
||||||
|
raise ValueError("This row no longer exists")
|
||||||
|
|
||||||
|
normalized_value = normalize_edit(field_name, raw_value)
|
||||||
|
previous_value = getattr(row_state, field_name)
|
||||||
|
setattr(row_state, field_name, normalized_value)
|
||||||
|
try:
|
||||||
|
save_row(dataframe, row_state)
|
||||||
|
except Exception:
|
||||||
|
setattr(row_state, field_name, previous_value)
|
||||||
|
raise
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
ui.notify(str(error), type="negative")
|
||||||
|
finally:
|
||||||
|
table.update_rows(state.table_rows(), clear_selection=False)
|
||||||
|
|
||||||
|
with table.add_slot("body-cell-name"), table.cell("name"):
|
||||||
|
ui.input().props(':model-value="props.value" dense borderless debounce=400').on(
|
||||||
|
"update:model-value",
|
||||||
|
handler=apply_edit,
|
||||||
|
js_handler="(value) => emit(props.row.id, props.col.name, value)",
|
||||||
|
)
|
||||||
|
|
||||||
|
with table.add_slot("body-cell-quantity"), table.cell("quantity"):
|
||||||
|
ui.number(min=0, max=1_000).props(':model-value="props.value" dense borderless debounce=400').on(
|
||||||
|
"update:model-value",
|
||||||
|
handler=apply_edit,
|
||||||
|
js_handler="(value) => emit(props.row.id, props.col.name, value)",
|
||||||
|
)
|
||||||
|
|
||||||
|
with table.add_slot("body-cell-status"), table.cell("status"):
|
||||||
|
ui.select(STATUS_OPTIONS).props(':model-value="props.value" dense borderless options-dense').on(
|
||||||
|
"update:model-value",
|
||||||
|
handler=apply_edit,
|
||||||
|
js_handler="(value) => emit(props.row.id, props.col.name, value)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ in {"__main__", "__mp_main__"}:
|
||||||
|
items = pd.DataFrame(
|
||||||
|
[
|
||||||
|
{"id": 101, "name": "Desk", "quantity": 4, "status": "active"},
|
||||||
|
{"id": 102, "name": "Lamp", "quantity": 12, "status": "draft"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
table_state = render_table(items)
|
||||||
|
|
||||||
|
ui.run(port=8888, reload=True)
|
||||||
+114
-108
@@ -1,4 +1,4 @@
|
|||||||
# NiceGUI Component Mechanics And Customization
|
# NiceGUI Component Mechanics
|
||||||
|
|
||||||
Use this reference to understand how customization crosses the NiceGUI Python wrapper, Quasar component, Vue runtime, and browser DOM. It owns constructor behavior, prop translation, events, bindings, slots, frontend methods, detached content, and component-specific caveats. For themes, utility classes, CSS properties, responsive page composition, and other cosmetic work, load [visual styling and CSS](./styling-and-customization.md).
|
Use this reference to understand how customization crosses the NiceGUI Python wrapper, Quasar component, Vue runtime, and browser DOM. It owns constructor behavior, prop translation, events, bindings, slots, frontend methods, detached content, and component-specific caveats. For themes, utility classes, CSS properties, responsive page composition, and other cosmetic work, load [visual styling and CSS](./styling-and-customization.md).
|
||||||
|
|
||||||
@@ -48,31 +48,86 @@ Some Quasar components render menus, dialogs, tooltips, and similar content outs
|
|||||||
|
|
||||||
Icons and other externally defined visuals add another boundary: a valid Quasar icon name identifies an asset but does not load its font or stylesheet. Confirm both the naming convention and the application-level asset registration.
|
Icons and other externally defined visuals add another boundary: a valid Quasar icon name identifies an asset but does not load its font or stylesheet. Confirm both the naming convention and the application-level asset registration.
|
||||||
|
|
||||||
## Source Research Gate
|
## Component Customization Workflow
|
||||||
|
|
||||||
Research the target component before generating code or CSS. Do not rely on a remembered NiceGUI or Quasar API.
|
Research the target component before generating code or CSS. Do not rely on a remembered NiceGUI or Quasar API, and do not mix source versions.
|
||||||
|
|
||||||
For each component:
|
### Establish The Version Pair
|
||||||
|
|
||||||
1. Read its current NiceGUI documentation page.
|
1. Read the target project's lockfile or installed package metadata to identify its exact NiceGUI version.
|
||||||
2. Inspect the constructor and implementation in the target project's installed NiceGUI package.
|
2. Open `package.json` at that NiceGUI tag and read the exact `quasar` dependency version.
|
||||||
3. Confirm the wrapped Quasar component in the NiceGUI source.
|
3. Use the NiceGUI tag for both NiceGUI sources and the matching `quasar-v<version>` tag for both Quasar sources.
|
||||||
4. Read the matching Quasar guide and API definition for props, slots, events, and methods.
|
|
||||||
5. Check the target project's pinned NiceGUI version before using current upstream behavior.
|
|
||||||
6. Record which layer owns each proposed customization before writing it.
|
|
||||||
|
|
||||||
Use current upstream source only as a fallback when the target environment is unavailable. If installed and upstream behavior differ, follow the installed version and state the difference.
|
The curated component sections below use NiceGUI `3.16.0` and Quasar `2.18.5`. The pairing comes from [NiceGUI `v3.16.0` frontend dependencies](https://github.com/zauberzeug/nicegui/blob/v3.16.0/package.json). Repeat the version check when the target application uses another NiceGUI release. Never infer compatibility from Quasar's latest release or use NiceGUI `main` with Quasar `dev`.
|
||||||
|
|
||||||
|
### Research Four Sources
|
||||||
|
|
||||||
|
Review these sources in order for the selected version pair:
|
||||||
|
|
||||||
|
1. **NiceGUI documentation:** identify the supported Python API and documented examples for the component.
|
||||||
|
2. **NiceGUI source code:** inspect constructor normalization, validation, props, bindings, events, helpers, and the wrapped frontend component.
|
||||||
|
3. **Quasar documentation:** identify the wrapped component's public props, slots, events, methods, accessibility behavior, and documented warnings.
|
||||||
|
4. **Quasar source code:** verify how those public APIs behave, especially popup mounting, model translation, event flow, rendering, and public methods.
|
||||||
|
|
||||||
|
Use current upstream sources only when the target version is unavailable, and state that fallback explicitly. If the installed package differs from its tag, follow the installed implementation and record the difference.
|
||||||
|
|
||||||
|
### Apply The Findings
|
||||||
|
|
||||||
|
For every component section:
|
||||||
|
|
||||||
|
1. Link the four version-matched sources under **Research Sources**.
|
||||||
|
2. Summarize which layer owns the behavior under **Ownership Result**.
|
||||||
|
3. Order the supported customization surfaces from highest-level NiceGUI API to lower-level Quasar or CSS mechanisms.
|
||||||
|
4. Include an example only after the owning APIs are established.
|
||||||
|
5. Curate a short caveat list from the four sources. Keep only constraints that change implementation, security, accessibility, performance, or testing decisions.
|
||||||
|
|
||||||
If the requirement is purely visual after this ownership check, continue in [visual styling and CSS](./styling-and-customization.md).
|
If the requirement is purely visual after this ownership check, continue in [visual styling and CSS](./styling-and-customization.md).
|
||||||
|
|
||||||
|
## Using Slots In NiceGUI
|
||||||
|
|
||||||
|
A NiceGUI element is the Python-side representation of a browser component. Many elements wrap Quasar Vue components, whose insertion points are exposed as slots. A simple container normally uses one default slot; more complex components expose named slots such as `prepend`, `append`, `option`, `header`, or `body-cell-*`. The available names and their contracts belong to the wrapped component, so verify them in the version-matched Quasar documentation.
|
||||||
|
|
||||||
|
NiceGUI creates a default slot for every element. Entering an element as a context manager enters that default slot, and entering `element.add_slot(name)` selects a named slot. NiceGUI keeps the active slots on a task-local stack; each element constructed inside the `with` block becomes a child of the innermost active slot.
|
||||||
|
|
||||||
|
These mechanics are defined by the tagged [`Element.add_slot()` implementation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/element.py), the [`Slot` context manager](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/slot.py), and NiceGUI's [context-managed scoped-slot examples](https://github.com/zauberzeug/nicegui/blob/v3.16.0/website/documentation/content/table_documentation.py).
|
||||||
|
|
||||||
|
### Prefer Context-Managed NiceGUI Elements
|
||||||
|
|
||||||
|
Build slot content with ordinary NiceGUI elements by default:
|
||||||
|
|
||||||
|
```python
|
||||||
|
name_input = ui.input("Name")
|
||||||
|
|
||||||
|
with name_input.add_slot("prepend"):
|
||||||
|
ui.icon("person")
|
||||||
|
```
|
||||||
|
|
||||||
|
Use nested context managers to express the component hierarchy. This preserves NiceGUI element identity, event registration, updates, deletion, and test visibility. Pass a raw Vue template to `add_slot(name, template)` only when the slot requires client-side structure that ordinary NiceGUI elements cannot express cleanly, such as a `v-for` that creates a variable number of sibling elements.
|
||||||
|
|
||||||
|
### Use Scoped Props On The Client
|
||||||
|
|
||||||
|
A scoped slot receives a `props` object from its owning Vue component. Since NiceGUI `3.5.0`, NiceGUI elements inside a scoped-slot context can reference that object in dynamic `.props()` expressions and JavaScript event handlers:
|
||||||
|
|
||||||
|
- use `.props(":label=props.value")` or another component-supported prop to display a scoped value
|
||||||
|
- use `.props("v-bind=props.itemProps")` when the slot provides a bundle of required attributes and handlers
|
||||||
|
- use `.on(..., js_handler="... emit(...)", handler=...)` to transform and send serializable scoped values to Python
|
||||||
|
|
||||||
|
Scoped props exist only in the browser render context. They are not Python variables and cannot be read by a Python callback until a JavaScript handler emits the required values. Treat `innerHTML`, `v-html`, and raw template interpolation as untrusted HTML unless the source is explicitly sanitized.
|
||||||
|
|
||||||
|
### Preserve The Slot Contract
|
||||||
|
|
||||||
|
Replacing default slot content also replaces the wrapped component's default rendering. Preserve any documented slot-prop bundle that carries behavior. For example, a `QSelect` option slot must bind `props.itemProps` to its root item; otherwise the custom row can lose click selection, disabled state, focus, active state, and keyboard navigation. Keep one root element per virtual-scroll item unless the component documents how to mark additional siblings.
|
||||||
|
|
||||||
## `ui.select`
|
## `ui.select`
|
||||||
|
|
||||||
### Source Map
|
### Research Sources
|
||||||
|
|
||||||
- [NiceGUI `ui.select` documentation](https://nicegui.io/documentation/select)
|
- **NiceGUI documentation:** [`ui.select` documentation source at `v3.16.0`](https://github.com/zauberzeug/nicegui/blob/v3.16.0/website/documentation/content/select_documentation.py)
|
||||||
- [NiceGUI `Select` source](https://github.com/zauberzeug/nicegui/blob/main/nicegui/elements/select.py)
|
- **NiceGUI source code:** [`Select` implementation at `v3.16.0`](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/select.py)
|
||||||
- [Quasar `QSelect` guide](https://quasar.dev/vue-components/select/)
|
- **Quasar documentation:** [`QSelect` documentation source at `2.18.5`](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/docs/src/pages/vue-components/select.md)
|
||||||
- [Quasar `QSelect` API source](https://github.com/quasarframework/quasar/blob/dev/ui/src/components/select/QSelect.json)
|
- **Quasar source code:** [`QSelect` implementation at `2.18.5`](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/select/QSelect.js)
|
||||||
|
|
||||||
|
### Ownership Result
|
||||||
|
|
||||||
NiceGUI's `Select` wraps Quasar `QSelect` but owns important Python-side behavior. Its constructor handles options, labels, values, change callbacks, input filtering, new-value modes, multiple selection, clearing, validation, and key generation. Use those constructor parameters before adding equivalent Quasar props manually.
|
NiceGUI's `Select` wraps Quasar `QSelect` but owns important Python-side behavior. Its constructor handles options, labels, values, change callbacks, input filtering, new-value modes, multiple selection, clearing, validation, and key generation. Use those constructor parameters before adding equivalent Quasar props manually.
|
||||||
|
|
||||||
@@ -82,35 +137,39 @@ NiceGUI's `Select` wraps Quasar `QSelect` but owns important Python-side behavio
|
|||||||
2. Use `.props()` for additional documented `QSelect` behavior such as field design, chips, option density, popup classes, popup positioning, or menu/dialog behavior.
|
2. Use `.props()` for additional documented `QSelect` behavior such as field design, chips, option density, popup classes, popup positioning, or menu/dialog behavior.
|
||||||
3. Use `.classes()` and Tailwind for the field's structural width and placement.
|
3. Use `.classes()` and Tailwind for the field's structural width and placement.
|
||||||
4. Use named slots for prepend, append, loading, no-option, selected, or option content when props are insufficient.
|
4. Use named slots for prepend, append, loading, no-option, selected, or option content when props are insufficient.
|
||||||
5. Use `popup-content-class` to attach an application class to the detached options popup, then fine-tune it in a static stylesheet.
|
5. Preserve the documented scoped-slot props when replacing option content so Quasar retains selection and keyboard behavior.
|
||||||
|
|
||||||
|
### Example: Custom Menu Options With A Scoped Slot
|
||||||
|
|
||||||
|
`QSelect` supplies each option as `props.opt` and its interaction contract as `props.itemProps`. NiceGUI elements can consume both inside the slot context without a raw Vue template:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
item_select = ui.select(
|
item_select = ui.select(
|
||||||
options={"chair": "Chair", "desk": "Desk", "lamp": "Lamp"},
|
options={"chair": "Chair", "desk": "Desk", "lamp": "Lamp"},
|
||||||
label="Items",
|
label="Item",
|
||||||
multiple=True,
|
value="chair",
|
||||||
clearable=True,
|
clearable=True,
|
||||||
with_input=True,
|
with_input=True,
|
||||||
).props(
|
).props("outlined options-dense")
|
||||||
"outlined use-chips options-dense "
|
|
||||||
"popup-content-class=app-item-select-menu"
|
|
||||||
).classes(
|
|
||||||
"w-full md:max-w-md"
|
|
||||||
)
|
|
||||||
|
|
||||||
with item_select.add_slot("prepend"):
|
with item_select.add_slot("prepend"):
|
||||||
ui.icon("inventory_2")
|
ui.icon("search")
|
||||||
|
|
||||||
|
with item_select.add_slot("option"):
|
||||||
|
with ui.item().props("v-bind=props.itemProps"):
|
||||||
|
with ui.item_section().props("avatar"):
|
||||||
|
ui.icon("inventory_2")
|
||||||
|
with ui.item_section():
|
||||||
|
ui.badge().props(":label=props.opt.label outline color=primary")
|
||||||
```
|
```
|
||||||
|
|
||||||
```css
|
The `prepend` slot adds content around the field. The scoped `option` slot replaces every menu row with context-managed NiceGUI elements; the badge reads the browser-side option label through a dynamic Quasar prop. Keep `v-bind=props.itemProps` on the root `ui.item()` so the custom rendering retains the option's interaction and accessibility wiring.
|
||||||
.app-item-select-menu {
|
|
||||||
max-height: min(24rem, 60dvh);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Select-Specific Caveats
|
### Curated Caveats
|
||||||
|
|
||||||
|
These caveats are distilled from the four version-matched sources above:
|
||||||
|
|
||||||
- NiceGUI accepts a list of values or a dictionary mapping values to labels. Do not assume the Python options model is the same as Quasar's JavaScript object-array examples.
|
- NiceGUI accepts a list of values or a dictionary mapping values to labels. Do not assume the Python options model is the same as Quasar's JavaScript object-array examples.
|
||||||
- After mutating `options`, call `update()` or use `set_options()` so the client receives the change.
|
- After mutating `options`, call `update()` or use `set_options()` so the client receives the change.
|
||||||
@@ -118,6 +177,7 @@ with item_select.add_slot("prepend"):
|
|||||||
- A multiple select has a list value. NiceGUI normalizes a non-list initial value, but application state should still use the intended list shape.
|
- A multiple select has a list value. NiceGUI normalizes a non-list initial value, but application state should still use the intended list shape.
|
||||||
- `map-options` has a Quasar performance cost. Do not add it to NiceGUI's mapped options without confirming that the wrapper's value translation requires it.
|
- `map-options` has a Quasar performance cost. Do not add it to NiceGUI's mapped options without confirming that the wrapper's value translation requires it.
|
||||||
- `display-value-html` and `options-html` can create cross-site scripting risk. When using `selected`, `selected-item`, or `option` slots, the application owns sanitization.
|
- `display-value-html` and `options-html` can create cross-site scripting risk. When using `selected`, `selected-item`, or `option` slots, the application owns sanitization.
|
||||||
|
- A custom `option` slot must bind `props.itemProps` to its root `ui.item()` so click, focus, active, disabled, and keyboard behavior remain connected.
|
||||||
- Custom option slots use virtual scrolling. When one option renders multiple sibling elements, Quasar requires `q-virtual-scroll--with-prev` on every additional sibling.
|
- Custom option slots use virtual scrolling. When one option renders multiple sibling elements, Quasar requires `q-virtual-scroll--with-prev` on every additional sibling.
|
||||||
- Buttons placed in `before`, `after`, `prepend`, or `append` field slots do not propagate clicks to the parent. A submit button in one of those slots needs its own submit handler.
|
- Buttons placed in `before`, `after`, `prepend`, or `append` field slots do not propagate clicks to the parent. A submit button in one of those slots needs its own submit handler.
|
||||||
- `QSelect` renders its popup outside the field. Style it through `popup-content-class`; do not assume a descendant selector beneath the field will reach it.
|
- `QSelect` renders its popup outside the field. Style it through `popup-content-class`; do not assume a descendant selector beneath the field will reach it.
|
||||||
@@ -125,76 +185,16 @@ with item_select.add_slot("prepend"):
|
|||||||
|
|
||||||
Use `.on()` or `run_method()` only after confirming the event or method in the installed Quasar API. Prefer NiceGUI's `on_change`, `set_options()`, value bindings, and `is_showing_popup` when they cover the behavior.
|
Use `.on()` or `run_method()` only after confirming the event or method in the installed Quasar API. Prefer NiceGUI's `on_change`, `set_options()`, value bindings, and `is_showing_popup` when they cover the behavior.
|
||||||
|
|
||||||
### Worked Example: Responsive Dialog And Detached Select Popup
|
|
||||||
|
|
||||||
This example is mechanics-sensitive because a `QSelect` popup is detached from the dialog card. Scale the complete card to preserve Quasar's internal field proportions, but style the popup through its own class without changing its coordinate system.
|
|
||||||
|
|
||||||
Use normal field density and attach application classes through supported APIs:
|
|
||||||
|
|
||||||
```python
|
|
||||||
ui.input("Name").props("outlined")
|
|
||||||
ui.number("Quantity").props("outlined")
|
|
||||||
ui.select(...).props("outlined popup-content-class=app-item-detail-menu")
|
|
||||||
ui.textarea("Description").props("outlined autogrow")
|
|
||||||
ui.card().classes("app-detail-card app-item-detail-card")
|
|
||||||
```
|
|
||||||
|
|
||||||
```css
|
|
||||||
:root {
|
|
||||||
--item-dialog-scale: 1;
|
|
||||||
--item-dialog-max-height: calc(100dvh - 3rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-item-detail-card {
|
|
||||||
width: min(50rem, 50vw);
|
|
||||||
max-height: var(--item-dialog-max-height);
|
|
||||||
overflow-y: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
zoom: var(--item-dialog-scale);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Restore Quasar's baseline if a global rule overrides it. */
|
|
||||||
.app-item-detail-card .q-field,
|
|
||||||
.app-item-detail-menu {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 599px) {
|
|
||||||
:root {
|
|
||||||
--item-dialog-scale: 1.2;
|
|
||||||
/* 75dvh becomes 90dvh after 1.2x zoom. */
|
|
||||||
--item-dialog-max-height: 75dvh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-item-detail-card {
|
|
||||||
width: 80vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-item-detail-menu {
|
|
||||||
font-size: 16.8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not apply `zoom` or `transform: scale()` to `.app-item-detail-menu`. Quasar positions the detached menu from the unscaled anchor geometry; scaling the menu afterward separates it from its field. Enlarging its text preserves the positioning coordinate system.
|
|
||||||
|
|
||||||
The card's pre-zoom maximum height must account for the scale:
|
|
||||||
|
|
||||||
\[
|
|
||||||
h_{\mathrm{pre}} = \frac{h_{\mathrm{visible}}}{s}
|
|
||||||
\]
|
|
||||||
|
|
||||||
For a desired visual height of `90dvh` at \(1.2\times\), use `75dvh`. Apply scrolling to the card so the dimmed page remains stationary while the form reaches its final field. The `599px` media query matches the upper edge of Quasar's default extra-small breakpoint; verify it against custom Quasar breakpoint configuration.
|
|
||||||
|
|
||||||
## `ui.icon`
|
## `ui.icon`
|
||||||
|
|
||||||
### Source Map
|
### Research Sources
|
||||||
|
|
||||||
- [NiceGUI `ui.icon` documentation](https://nicegui.io/documentation/icon)
|
- **NiceGUI documentation:** [`ui.icon` documentation source at `v3.16.0`](https://github.com/zauberzeug/nicegui/blob/v3.16.0/website/documentation/content/icon_documentation.py)
|
||||||
- [NiceGUI `Icon` source](https://github.com/zauberzeug/nicegui/blob/main/nicegui/elements/icon.py)
|
- **NiceGUI source code:** [`Icon` implementation at `v3.16.0`](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/icon.py)
|
||||||
- [Quasar `QIcon` guide](https://quasar.dev/vue-components/icon/)
|
- **Quasar documentation:** [`QIcon` documentation source at `2.18.5`](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/docs/src/pages/vue-components/icon.md)
|
||||||
- [Quasar `QIcon` API source](https://github.com/quasarframework/quasar/blob/dev/ui/src/components/icon/QIcon.json)
|
- **Quasar source code:** [`QIcon` implementation at `2.18.5`](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/icon/QIcon.js)
|
||||||
- [Google Material Symbols and Icons](https://fonts.google.com/icons)
|
|
||||||
|
### Ownership Result
|
||||||
|
|
||||||
NiceGUI's `Icon` is a thin `QIcon` wrapper. Its constructor exposes `name`, `size`, and `color`; the source forwards these to a `q-icon` element. Use Quasar's icon naming and asset rules for anything beyond those parameters.
|
NiceGUI's `Icon` is a thin `QIcon` wrapper. Its constructor exposes `name`, `size`, and `color`; the source forwards these to a `q-icon` element. Use Quasar's icon naming and asset rules for anything beyond those parameters.
|
||||||
|
|
||||||
@@ -206,6 +206,8 @@ NiceGUI's `Icon` is a thin `QIcon` wrapper. Its constructor exposes `name`, `siz
|
|||||||
4. Use `.classes()` for structural placement and an application class for stable visual variants.
|
4. Use `.classes()` for structural placement and an application class for stable visual variants.
|
||||||
5. Use a static stylesheet for Material Symbol axes, state variants, custom webfonts, or repeated effects.
|
5. Use a static stylesheet for Material Symbol axes, state variants, custom webfonts, or repeated effects.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
@@ -230,7 +232,9 @@ ui.icon(
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Icon-Specific Caveats
|
### Curated Caveats
|
||||||
|
|
||||||
|
These caveats are distilled from the four version-matched sources above:
|
||||||
|
|
||||||
- Material icon names use snake case. Material variants use prefixes such as `o_`, `r_`, `s_`, `sym_o_`, `sym_r_`, and `sym_s_`.
|
- Material icon names use snake case. Material variants use prefixes such as `o_`, `r_`, `s_`, `sym_o_`, `sym_r_`, and `sym_s_`.
|
||||||
- Other icon families have their own prefixes and require their webfont or stylesheet to be loaded. A valid name does not load the corresponding asset.
|
- Other icon families have their own prefixes and require their webfont or stylesheet to be loaded. A valid name does not load the corresponding asset.
|
||||||
@@ -238,16 +242,18 @@ ui.icon(
|
|||||||
- Icon color inherits text color unless the `color` prop or a CSS color overrides it.
|
- Icon color inherits text color unless the `color` prop or a CSS color overrides it.
|
||||||
- Material Symbol variable axes apply to webfont icons, not static SVG icon exports.
|
- Material Symbol variable axes apply to webfont icons, not static SVG icon exports.
|
||||||
- Quasar also supports SVG path strings, `svguse:` references, and `img:` URLs. Confirm the exact `QIcon` name format and mount path before generating one of these forms.
|
- Quasar also supports SVG path strings, `svguse:` references, and `img:` URLs. Confirm the exact `QIcon` name format and mount path before generating one of these forms.
|
||||||
- For an action, use a semantic control such as `ui.button(icon=..., on_click=...)` and give it an accessible label or tooltip. Do not turn a bare decorative icon into an unlabeled control.
|
- `QIcon` renders with `aria-hidden="true"`. For an action, use a semantic control such as `ui.button(icon=..., on_click=...)` and put the accessible name on that control; a tooltip is supplementary.
|
||||||
- Prefer `ui.icon(...).tooltip(...)` over manually constructing tooltip slot markup when NiceGUI's method covers the requirement.
|
- Prefer `ui.icon(...).tooltip(...)` over manually constructing tooltip slot markup when NiceGUI's method covers the visual hint.
|
||||||
|
|
||||||
## Completion Check
|
## Completion Check
|
||||||
|
|
||||||
Before accepting a special-component customization:
|
Before accepting a special-component customization:
|
||||||
|
|
||||||
1. Cite the NiceGUI component page and implementation that were inspected.
|
1. Record the target NiceGUI version and its declared Quasar version.
|
||||||
2. Cite the matching Quasar guide or API source.
|
2. Link the version-matched NiceGUI documentation and source code.
|
||||||
3. Identify constructor arguments, Quasar props, slots, Tailwind classes, and stylesheet rules separately.
|
3. Link the version-matched Quasar documentation and source code.
|
||||||
4. Confirm detached popup or external asset behavior where applicable.
|
4. Identify constructor arguments, Quasar props, slots, Tailwind classes, and stylesheet rules separately.
|
||||||
5. Test keyboard interaction, focus, labels, and tooltips.
|
5. Confirm detached popup or external asset behavior where applicable.
|
||||||
6. Test the supported mobile, landscape desktop, and portrait desktop viewports.
|
6. Keep the caveat list traceable to the four researched sources.
|
||||||
|
7. Test keyboard interaction, focus, labels, and tooltips.
|
||||||
|
8. Test the supported mobile, landscape desktop, and portrait desktop viewports.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# NiceGUI Visual Styling And CSS
|
# NiceGUI Visual Styling And CSS
|
||||||
|
|
||||||
Use this reference for cosmetic and presentational work: themes, color roles, utility classes, CSS properties, responsive layout, and static assets. For the mechanics of how a NiceGUI Python element maps to a Quasar Vue component, including props, events, slots, methods, teleported content, and wrapper-owned state, load [component mechanics and customization](./component-mechanics-and-customization.md).
|
Use this reference for cosmetic and presentational work: themes, color roles, utility classes, CSS properties, responsive layout, and static assets. For the mechanics of how a NiceGUI Python element maps to a Quasar Vue component, including props, events, slots, methods, teleported content, and wrapper-owned state, load [component mechanics](./component-mechanics.md).
|
||||||
|
|
||||||
For package boundaries, dependency direction, and page or component ownership, load [application architecture](./architecture.md).
|
For package boundaries, dependency direction, and page or component ownership, load [application architecture](./architecture.md).
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ This page owns how an element looks and fits into a page after the correct compo
|
|||||||
- responsive page composition and stable control dimensions
|
- responsive page composition and stable control dimensions
|
||||||
- reusable application classes, CSS custom properties, and static assets
|
- reusable application classes, CSS custom properties, and static assets
|
||||||
|
|
||||||
The companion [component mechanics and customization](./component-mechanics-and-customization.md) reference owns how behavior crosses framework boundaries. Use it when the question is whether a value belongs in a constructor, Quasar prop, Vue event, slot, method, binding, or teleported popup.
|
The companion [component mechanics](./component-mechanics.md) reference owns how behavior crosses framework boundaries. Use it when the question is whether a value belongs in a constructor, Quasar prop, Vue event, slot, method, binding, or teleported popup.
|
||||||
|
|
||||||
## Visual Styling Workflow
|
## Visual Styling Workflow
|
||||||
|
|
||||||
@@ -273,7 +273,7 @@ ui.add_head_html(
|
|||||||
|
|
||||||
## Mechanics-Sensitive Visual Cases
|
## Mechanics-Sensitive Visual Cases
|
||||||
|
|
||||||
Some visual requests depend on framework behavior before CSS can be chosen safely. Use [component mechanics and customization](./component-mechanics-and-customization.md) for detached menus and dialogs, named slots, icon asset families, Quasar internal geometry, frontend methods, and server-client state synchronization. Its responsive dialog example explains why a card can be scaled while a detached `QSelect` popup must remain in its original positioning coordinate system.
|
Some visual requests depend on framework behavior before CSS can be chosen safely. Use [component mechanics](./component-mechanics.md) for detached menus and dialogs, named slots, icon asset families, Quasar internal geometry, frontend methods, and server-client state synchronization. Its select example shows how a NiceGUI scoped slot preserves QSelect's option interaction contract without custom CSS.
|
||||||
|
|
||||||
## Validation Checklist
|
## Validation Checklist
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Editable Tables
|
||||||
|
|
||||||
|
Use this reference when a `ui.table` must accept cell edits while Python remains the authoritative owner of row state. Start with NiceGUI elements in named QTable cell slots. Escalate to raw Quasar row templates only when a requirement, such as `QPopupEdit`, cannot work in a cell slot.
|
||||||
|
|
||||||
|
## Version Baseline
|
||||||
|
|
||||||
|
This reference was verified against the latest released NiceGUI stack at the time of research:
|
||||||
|
|
||||||
|
| Layer | Version | Version evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| NiceGUI | `3.16.0` | [NiceGUI `v3.16.0` release](https://github.com/zauberzeug/nicegui/releases/tag/v3.16.0) |
|
||||||
|
| Quasar | `2.18.5` | [NiceGUI `v3.16.0` frontend dependencies](https://github.com/zauberzeug/nicegui/blob/v3.16.0/package.json) |
|
||||||
|
| Vue | `3.5.22` | [NiceGUI `v3.16.0` frontend dependencies](https://github.com/zauberzeug/nicegui/blob/v3.16.0/package.json) |
|
||||||
|
|
||||||
|
Recheck the dependency manifest and tagged sources when the target application uses another NiceGUI release. Do not infer the Quasar or Vue version from their latest independent releases; use the versions bundled by NiceGUI.
|
||||||
|
|
||||||
|
## Ownership Model
|
||||||
|
|
||||||
|
Treat an edit as a proposal, not a browser-side state mutation:
|
||||||
|
|
||||||
|
1. A render function converts dataframe records into row-scoped [bindable dataclasses](./binding-dataclasses.md).
|
||||||
|
2. Each editable dataclass field is bound to the corresponding serializable QTable row field.
|
||||||
|
3. A NiceGUI editor displays that projection through `props.value` in a QTable scoped slot.
|
||||||
|
4. The editor emits stable row identity, the field name, and the proposed value.
|
||||||
|
5. Python locates the row dataclass, validates and assigns the value, persists the row to the dataframe or repository, and sends the resulting projection back with `table.update_rows(...)`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[Dataframe or repository] -->|render| B[Bindable row dataclasses]
|
||||||
|
B -->|field bindings| C[QTable row payloads]
|
||||||
|
C -->|props.value| D[NiceGUI editor]
|
||||||
|
D -->|row key, field, proposed value| E[Python handler]
|
||||||
|
E --> F{validate}
|
||||||
|
F -->|accept| B
|
||||||
|
B -->|persist| A
|
||||||
|
F -->|reject| G[notify]
|
||||||
|
```
|
||||||
|
|
||||||
|
The bindable dataclasses are the canonical page state in Python. The dataframe is the load and persistence boundary in this example; a production application can replace it with a service or repository. The browser may hold temporary editor state, but it is never the source of truth. Do not mutate `props.row` and mistake Vue reactivity for persistence. Do not use a visual row index as identity: sorting, filtering, and pagination can all change it. Set `row_key` to an immutable, unique field and send that value with every edit proposal.
|
||||||
|
|
||||||
|
## Recommended Cell-Slot Pattern
|
||||||
|
|
||||||
|
[NiceGUI `ui.table`](https://nicegui.io/documentation/table) supports NiceGUI elements in scoped slots since `3.5.0`. The tagged [`Table.cell` implementation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/table.py) creates the corresponding Quasar `QTd`, while the tagged [table client component](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/table.js) forwards QTable's scoped slot props.
|
||||||
|
|
||||||
|
The following example uses a render function to transform a dataframe into an `EditableTableState`. That state owns one `EditableRow` per stable identifier and one serializable QTable payload per row. NiceGUI's `binding.bind_to` links each bindable dataclass field to its corresponding payload field, so assigning `row_state.name`, `row_state.quantity`, or `row_state.status` updates the Python-side table projection immediately.
|
||||||
|
|
||||||
|
A QTable scoped slot is one client-side template reused for every matching cell. It cannot use `bind_value(row_state, "name")` because there is no single Python `row_state` for that template. Instead, the slot reads the bound payload through `props.value` and sends the stable key back to Python, where the handler selects and assigns the corresponding dataclass.
|
||||||
|
|
||||||
|
The complete runnable source is available as [`editable_table.py`](../examples/editable_table.py) and as the supporting resource `skill://nicegui/examples/editable_table.py`.
|
||||||
|
|
||||||
|
```python title="editable_table.py"
|
||||||
|
--8<-- "docs/skills/nicegui/examples/editable_table.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses the same transformed-event path documented by [NiceGUI's table selection example](https://nicegui.io/documentation/table): `.on("update:model-value", ...)` attaches directly to the editor, and `js_handler` emits only the serializable values Python needs. Vue component events [do not bubble](https://vuejs.org/guide/components/events.html), so listening on the table or cell instead of the editor will not capture the editor's model update.
|
||||||
|
|
||||||
|
The `update:model-value` callback receives the emitted model value itself. Forward it with `(value) => emit(..., value)`; do not read `value.value`. For `ui.number`, the underlying Quasar input emits numeric text and NiceGUI normally performs the float conversion in its built-in value handler. Because this custom handler forwards the event, `normalize_edit` accepts numeric strings and performs the authoritative integer conversion in Python.
|
||||||
|
|
||||||
|
The `:model-value="props.value"` prop is deliberately one-way at the client boundary. In Vue, component `v-model` expands to a `modelValue` prop plus an `update:modelValue` listener, as shown in the [Vue component `v-model` guide](https://vuejs.org/guide/components/v-model.html) and its tagged [compiler transform](https://github.com/vuejs/core/blob/v3.5.22/packages/compiler-core/src/transforms/vModel.ts). Here the update listener sends an intent to Python rather than assigning into `props.row`; Python assignment to the selected bindable dataclass then updates the corresponding table-row payload.
|
||||||
|
|
||||||
|
## Commit Policy
|
||||||
|
|
||||||
|
Choose when edits cross the client-server boundary according to the editor:
|
||||||
|
|
||||||
|
- Use `update:model-value` for discrete editors such as `ui.select`, switches, and checkboxes.
|
||||||
|
- For text and numeric inputs, use Quasar's documented `debounce` prop when accepting edits during typing. A trailing delay avoids one server round trip per keystroke.
|
||||||
|
- When the user must explicitly save or cancel a multi-field draft, keep the draft in a dialog or popup and emit one proposal on save. Python must still validate and reassert the canonical row.
|
||||||
|
- For asynchronous persistence, disable or mark the affected editor busy while saving. Add an entity version or other optimistic concurrency check when multiple clients can edit the same record.
|
||||||
|
|
||||||
|
Do not rely on browser validation alone. Quasar editor constraints improve feedback, but the event payload is still untrusted input. The Python handler must enforce the editable-field allowlist, types, ranges, permissions, record existence, and persistence constraints.
|
||||||
|
|
||||||
|
## Persistence And Refresh
|
||||||
|
|
||||||
|
Keep `table.rows` as a projection, not the business model. The row-scoped bindable dataclasses are the page model, and the dataframe or repository is its persistence boundary. On acceptance:
|
||||||
|
|
||||||
|
1. validate and coerce into domain types
|
||||||
|
2. assign the normalized value to the matching bindable dataclass field
|
||||||
|
3. persist that dataclass through the dataframe adapter, service, or repository
|
||||||
|
4. call `table.update_rows(state.table_rows(), clear_selection=False)`
|
||||||
|
|
||||||
|
On validation rejection, leave the dataclass unchanged. On persistence failure, restore its previous value before re-raising or reporting the error. Perform step 4 in either case so the field binding and canonical Python state overwrite any temporary editor display. Preserve selection only when the selected row identities remain valid; otherwise use the default `clear_selection=True`.
|
||||||
|
|
||||||
|
For database-backed applications, make the handler `async`, await the service transaction, and refresh only after it commits. Catch the application's expected validation, conflict, and persistence exceptions separately so the user receives actionable feedback without hiding programming errors.
|
||||||
|
|
||||||
|
## QTable And QPopupEdit Escalation
|
||||||
|
|
||||||
|
The underlying [Quasar QTable guide](https://quasar.dev/vue-components/table) and tagged [`QTable` source](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/QTable.js) define the `body-cell-[name]` props used above, including `row`, `col`, `value`, and the key derived from `row-key`.
|
||||||
|
|
||||||
|
Use [Quasar `QPopupEdit`](https://quasar.dev/vue-components/popup-edit) only when its local draft, validation, save, and cancel interaction is specifically required. Quasar documents that `QPopupEdit` does not work in QTable cell scoped slots; it must be placed under the full `body` slot. Its tagged [source implementation](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/popup-edit/QPopupEdit.js) keeps a cloned draft and emits `save` and `update:modelValue` only after validation.
|
||||||
|
|
||||||
|
That restriction changes the implementation boundary: a full `body` slot must render every `QTr` and `QTd`, preserve QTable's scoped props and row keys, and host the popup. Before taking this path:
|
||||||
|
|
||||||
|
1. confirm an ordinary NiceGUI editor or dialog cannot meet the interaction requirement
|
||||||
|
2. copy the row structure from the matching Quasar `2.18.5` QTable documentation, not another version
|
||||||
|
3. keep popup draft state local rather than assigning into `props.row`
|
||||||
|
4. emit the stable row key, field, and saved proposal to Python
|
||||||
|
5. validate, persist, and replace the table rows from Python exactly as in the cell-slot pattern
|
||||||
|
6. test keyboard focus, save, cancel, validation failure, sorting, filtering, pagination, and selection
|
||||||
|
|
||||||
|
Replacing the full row template has a larger maintenance and accessibility surface. Keep the named cell-slot implementation as the default.
|
||||||
|
|
||||||
|
## Source Map
|
||||||
|
|
||||||
|
### NiceGUI `3.16.0`
|
||||||
|
|
||||||
|
- [Table developer documentation](https://nicegui.io/documentation/table)
|
||||||
|
- [`Table` Python source](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/table.py)
|
||||||
|
- [QTable client wrapper source](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/table.js)
|
||||||
|
- [Pinned frontend dependency manifest](https://github.com/zauberzeug/nicegui/blob/v3.16.0/package.json)
|
||||||
|
|
||||||
|
### Quasar `2.18.5`
|
||||||
|
|
||||||
|
- [QTable developer documentation](https://quasar.dev/vue-components/table)
|
||||||
|
- [`QTable` source](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/QTable.js)
|
||||||
|
- [`QTable` API definition](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/QTable.json)
|
||||||
|
- [QPopupEdit developer documentation](https://quasar.dev/vue-components/popup-edit)
|
||||||
|
- [`QPopupEdit` source](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/popup-edit/QPopupEdit.js)
|
||||||
|
- [`QPopupEdit` API definition](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/popup-edit/QPopupEdit.json)
|
||||||
|
|
||||||
|
### Vue `3.5.22`
|
||||||
|
|
||||||
|
- [Component `v-model`](https://vuejs.org/guide/components/v-model.html)
|
||||||
|
- [Component events](https://vuejs.org/guide/components/events.html)
|
||||||
|
- [Scoped slots](https://vuejs.org/guide/components/slots.html#scoped-slots)
|
||||||
|
- [`v-model` compiler transform](https://github.com/vuejs/core/blob/v3.5.22/packages/compiler-core/src/transforms/vModel.ts)
|
||||||
|
- [Component event runtime](https://github.com/vuejs/core/blob/v3.5.22/packages/runtime-core/src/componentEmits.ts)
|
||||||
|
- [Native `v-model` directives](https://github.com/vuejs/core/blob/v3.5.22/packages/runtime-dom/src/directives/vModel.ts)
|
||||||
|
|
||||||
|
## Completion Check
|
||||||
|
|
||||||
|
Before accepting an editable table:
|
||||||
|
|
||||||
|
1. Pin the NiceGUI release and verify its bundled Quasar and Vue versions.
|
||||||
|
2. Use an immutable, unique `row_key`; never persist by view index.
|
||||||
|
3. Transform dataframe records into row-scoped bindable dataclasses during rendering.
|
||||||
|
4. Bind each editable dataclass field to its corresponding serializable QTable row field.
|
||||||
|
5. Display the projected value from QTable scoped props; do not bind one shared slot template to one Python row object.
|
||||||
|
6. Attach the event listener directly to the editor and emit only row identity, field, and proposed value.
|
||||||
|
7. Validate field access, types, ranges, permissions, and record existence in Python.
|
||||||
|
8. Assign the dataclass field, persist through the owning adapter or service, and roll back that assignment on failure.
|
||||||
|
9. Reassert canonical rows after accepted and rejected proposals.
|
||||||
|
10. Test editing after sort, filter, pagination, and selection changes.
|
||||||
|
11. Test stale rows, invalid input, persistence failure, and concurrent edits.
|
||||||
|
12. Use a full `body` slot for `QPopupEdit`, never a `body-cell-*` slot.
|
||||||
+27
-55
@@ -1,18 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
from mcp_types import CompletionArgument
|
|
||||||
from mcp_types import CompletionContext
|
|
||||||
from mcp_types import Icon
|
from mcp_types import Icon
|
||||||
from mcp_types import PromptReference
|
|
||||||
|
|
||||||
from personal_mcp.prompts import create_prompts_provider
|
from .prompts.provider import prompt_lifespan
|
||||||
from personal_mcp.prompts.models import MarkdownPrompt
|
from .registry.load import get_docs_registry
|
||||||
from personal_mcp.prompts.provider import MarkdownPromptsProvider
|
from .registry.load import read_docs_markdown_path
|
||||||
from personal_mcp.registry.load import get_docs_registry
|
from .skills import skill_lifespan
|
||||||
from personal_mcp.registry.load import read_docs_markdown_path
|
|
||||||
from personal_mcp.registry.models import DocsRegistry
|
|
||||||
from personal_mcp.skills import create_skills_provider
|
|
||||||
|
|
||||||
_SERVER_INSTRUCTIONS = """Personal development guidance exposed as native MCP resources and prompts.
|
_SERVER_INSTRUCTIONS = """Personal development guidance exposed as native MCP resources and prompts.
|
||||||
|
|
||||||
@@ -33,15 +27,22 @@ _SERVER_ICON = Icon(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _ro_annotations() -> dict[str, bool]:
|
def run_stdio() -> None:
|
||||||
return {
|
"""Create the MCP server and expose it over stdio"""
|
||||||
"readOnlyHint": True,
|
create_mcp().run()
|
||||||
"idempotentHint": True,
|
|
||||||
"openWorldHint": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _register_components(mcp: FastMCP, registry: DocsRegistry) -> None:
|
def create_mcp() -> FastMCP:
|
||||||
|
mcp = FastMCP(
|
||||||
|
"personal-mcp",
|
||||||
|
instructions=_SERVER_INSTRUCTIONS,
|
||||||
|
icons=[_SERVER_ICON],
|
||||||
|
on_duplicate="error",
|
||||||
|
lifespan=skill_lifespan | prompt_lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
registry = get_docs_registry()
|
||||||
|
|
||||||
@mcp.resource(
|
@mcp.resource(
|
||||||
"resource://docs/{path*}",
|
"resource://docs/{path*}",
|
||||||
name="docs_markdown",
|
name="docs_markdown",
|
||||||
@@ -49,46 +50,17 @@ def _register_components(mcp: FastMCP, registry: DocsRegistry) -> None:
|
|||||||
description="Read a packaged documentation page by its path relative to the docs root.",
|
description="Read a packaged documentation page by its path relative to the docs root.",
|
||||||
mime_type="text/markdown",
|
mime_type="text/markdown",
|
||||||
tags={"docs"},
|
tags={"docs"},
|
||||||
annotations=_ro_annotations(),
|
annotations={
|
||||||
|
"readOnlyHint": True,
|
||||||
|
"idempotentHint": True,
|
||||||
|
"openWorldHint": False,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
def docs_markdown(path: str) -> dict[str, str]:
|
def docs_markdown(path: str) -> dict[str, str]:
|
||||||
return read_docs_markdown_path(registry, path)
|
return read_docs_markdown_path(registry, path)
|
||||||
|
|
||||||
|
|
||||||
def _register_prompt_completions(mcp: FastMCP, provider: MarkdownPromptsProvider) -> None:
|
|
||||||
@mcp.completion
|
|
||||||
async def complete_prompt_argument(
|
|
||||||
ref: object,
|
|
||||||
argument: CompletionArgument,
|
|
||||||
context: CompletionContext | None,
|
|
||||||
) -> list[str] | None:
|
|
||||||
del context
|
|
||||||
if not isinstance(ref, PromptReference):
|
|
||||||
return None
|
|
||||||
|
|
||||||
prompt = await provider.get_prompt(ref.name)
|
|
||||||
if not isinstance(prompt, MarkdownPrompt):
|
|
||||||
return None
|
|
||||||
|
|
||||||
definition = prompt.definitions.get(argument.name)
|
|
||||||
if definition is None or definition.choices is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
prefix = argument.value.casefold()
|
|
||||||
return [choice for choice in definition.choices if choice.casefold().startswith(prefix)]
|
|
||||||
|
|
||||||
|
|
||||||
def create_mcp() -> FastMCP:
|
|
||||||
registry = get_docs_registry()
|
|
||||||
mcp = FastMCP(
|
|
||||||
"personal-mcp",
|
|
||||||
instructions=_SERVER_INSTRUCTIONS,
|
|
||||||
icons=[_SERVER_ICON],
|
|
||||||
on_duplicate="error",
|
|
||||||
)
|
|
||||||
_register_components(mcp, registry)
|
|
||||||
prompts_provider = create_prompts_provider()
|
|
||||||
mcp.add_provider(prompts_provider)
|
|
||||||
mcp.add_provider(create_skills_provider())
|
|
||||||
_register_prompt_completions(mcp, prompts_provider)
|
|
||||||
return mcp
|
return mcp
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_stdio()
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
from .provider import complete_prompt_argument_choices
|
||||||
from .provider import create_prompts_provider
|
from .provider import create_prompts_provider
|
||||||
|
from .provider import prompt_lifespan
|
||||||
|
|
||||||
__all__ = ["create_prompts_provider"]
|
__all__ = [
|
||||||
|
"complete_prompt_argument_choices",
|
||||||
|
"create_prompts_provider",
|
||||||
|
"prompt_lifespan",
|
||||||
|
]
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ class MarkdownPrompt(Prompt):
|
|||||||
definitions=metadata.arguments,
|
definitions=metadata.arguments,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def complete_argument(self, argument_name: str, argument_value: str) -> list[str] | None:
|
||||||
|
definition = self.definitions.get(argument_name)
|
||||||
|
if definition is None or definition.choices is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
prefix = argument_value.casefold()
|
||||||
|
return [choice for choice in definition.choices if choice.casefold().startswith(prefix)]
|
||||||
|
|
||||||
async def render(self, arguments: dict[str, object] | None = None) -> str:
|
async def render(self, arguments: dict[str, object] | None = None) -> str:
|
||||||
provided = arguments or {}
|
provided = arguments or {}
|
||||||
declared_names = set(self.definitions)
|
declared_names = set(self.definitions)
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ from collections.abc import Sequence
|
|||||||
from importlib.resources import files
|
from importlib.resources import files
|
||||||
from importlib.resources.abc import Traversable
|
from importlib.resources.abc import Traversable
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
from fastmcp.prompts import Prompt
|
from fastmcp.prompts import Prompt
|
||||||
|
from fastmcp.server.lifespan import lifespan
|
||||||
from fastmcp.server.providers import Provider
|
from fastmcp.server.providers import Provider
|
||||||
|
from mcp_types import CompletionArgument
|
||||||
|
from mcp_types import CompletionContext
|
||||||
|
from mcp_types import PromptReference
|
||||||
|
from mcp_types import ResourceTemplateReference
|
||||||
|
|
||||||
from .content import load_prompt_definition
|
from .content import load_prompt_definition
|
||||||
from .models import MarkdownPrompt
|
from .models import MarkdownPrompt
|
||||||
@@ -29,6 +35,41 @@ class MarkdownPromptsProvider(Provider):
|
|||||||
return prompts
|
return prompts
|
||||||
|
|
||||||
|
|
||||||
|
@lifespan
|
||||||
|
async def prompt_lifespan(server: FastMCP):
|
||||||
|
provider = create_prompts_provider()
|
||||||
|
server.add_provider(provider)
|
||||||
|
|
||||||
|
@server.completion
|
||||||
|
async def complete_prompt_argument(
|
||||||
|
ref: PromptReference | ResourceTemplateReference,
|
||||||
|
argument: CompletionArgument,
|
||||||
|
context: CompletionContext | None,
|
||||||
|
) -> list[str] | None:
|
||||||
|
del context
|
||||||
|
return await complete_prompt_argument_choices(provider, ref, argument)
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield {}
|
||||||
|
finally:
|
||||||
|
server.providers.remove(provider)
|
||||||
|
|
||||||
|
|
||||||
def create_prompts_provider(root: Traversable | None = None) -> MarkdownPromptsProvider:
|
def create_prompts_provider(root: Traversable | None = None) -> MarkdownPromptsProvider:
|
||||||
prompts_root = root or files("personal_mcp").joinpath("docs", "prompts")
|
prompts_root = root or files("personal_mcp").joinpath("docs", "prompts")
|
||||||
return MarkdownPromptsProvider(prompts_root)
|
return MarkdownPromptsProvider(prompts_root)
|
||||||
|
|
||||||
|
|
||||||
|
async def complete_prompt_argument_choices(
|
||||||
|
prompts_provider: MarkdownPromptsProvider,
|
||||||
|
ref: object,
|
||||||
|
argument: CompletionArgument,
|
||||||
|
) -> list[str] | None:
|
||||||
|
if not isinstance(ref, PromptReference):
|
||||||
|
return None
|
||||||
|
|
||||||
|
prompt = await prompts_provider.get_prompt(ref.name)
|
||||||
|
if not isinstance(prompt, MarkdownPrompt):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return prompt.complete_argument(argument.name, argument.value)
|
||||||
|
|||||||
@@ -13,10 +13,6 @@ from pydantic import field_validator
|
|||||||
__all__ = ["DocsRegistry"]
|
__all__ = ["DocsRegistry"]
|
||||||
|
|
||||||
|
|
||||||
def frozen_mapping[K, V](value: Mapping[K, V] | None = None) -> Mapping[K, V]:
|
|
||||||
return MappingProxyType(dict(value) if value is not None else {})
|
|
||||||
|
|
||||||
|
|
||||||
def parse_docs_path(value: str | PurePosixPath) -> PurePosixPath:
|
def parse_docs_path(value: str | PurePosixPath) -> PurePosixPath:
|
||||||
raw = value.as_posix() if isinstance(value, PurePosixPath) else value
|
raw = value.as_posix() if isinstance(value, PurePosixPath) else value
|
||||||
if "\\" in raw:
|
if "\\" in raw:
|
||||||
@@ -39,6 +35,10 @@ def _empty_docs_mapping() -> Mapping[DocsPath, str]:
|
|||||||
return frozen_mapping()
|
return frozen_mapping()
|
||||||
|
|
||||||
|
|
||||||
|
def frozen_mapping[K, V](value: Mapping[K, V] | None = None) -> Mapping[K, V]:
|
||||||
|
return MappingProxyType(dict(value) if value is not None else {})
|
||||||
|
|
||||||
|
|
||||||
class DocsRegistry(BaseModel):
|
class DocsRegistry(BaseModel):
|
||||||
"""In-memory index of documentation content."""
|
"""In-memory index of documentation content."""
|
||||||
|
|
||||||
|
|||||||
+17
-26
@@ -1,34 +1,25 @@
|
|||||||
from contextlib import ExitStack
|
from contextlib import contextmanager
|
||||||
from importlib.resources import as_file
|
from importlib.resources import as_file
|
||||||
from importlib.resources import files
|
from importlib.resources import files
|
||||||
from weakref import finalize
|
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
from fastmcp.server.lifespan import lifespan
|
||||||
from fastmcp.server.providers.skills import SkillsDirectoryProvider
|
from fastmcp.server.providers.skills import SkillsDirectoryProvider
|
||||||
|
|
||||||
|
|
||||||
def create_skills_provider() -> SkillsDirectoryProvider:
|
@lifespan
|
||||||
"""Create the provider for skills packaged with personal-mcp."""
|
async def skill_lifespan(server: FastMCP):
|
||||||
skills_resource = files("personal_mcp").joinpath("docs", "skills")
|
with skills_provider() as provider:
|
||||||
if not skills_resource.is_dir():
|
server.add_provider(provider)
|
||||||
raise FileNotFoundError(f"packaged skills directory does not exist: {skills_resource}")
|
try:
|
||||||
|
yield {}
|
||||||
|
finally:
|
||||||
|
server.providers.remove(provider)
|
||||||
|
|
||||||
has_skills = any(
|
|
||||||
skill_dir.is_dir() and skill_dir.joinpath("SKILL.md").is_file() for skill_dir in skills_resource.iterdir()
|
|
||||||
)
|
|
||||||
if not has_skills:
|
|
||||||
raise ValueError(f"packaged skills directory contains no skills: {skills_resource}")
|
|
||||||
|
|
||||||
resources = ExitStack()
|
@contextmanager
|
||||||
try:
|
def skills_provider():
|
||||||
skills_root = resources.enter_context(as_file(skills_resource))
|
with as_file(files(__package__).joinpath("docs", "skills")) as skills_root:
|
||||||
provider = SkillsDirectoryProvider(
|
if not skills_root.is_dir():
|
||||||
roots=skills_root,
|
raise FileNotFoundError(f"packaged skills directory does not exist: {skills_root}")
|
||||||
reload=False,
|
yield SkillsDirectoryProvider(roots=skills_root, reload=True)
|
||||||
supporting_files="template",
|
|
||||||
)
|
|
||||||
except BaseException:
|
|
||||||
resources.close()
|
|
||||||
raise
|
|
||||||
|
|
||||||
finalize(provider, resources.close)
|
|
||||||
return provider
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_FILE_CACHE: dict[Path, tuple[int, str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def read_file(path: Path) -> str:
|
||||||
|
new_ts = path.stat().st_mtime_ns
|
||||||
|
match _FILE_CACHE.get(path):
|
||||||
|
case (int(timestamp), str(content)) if timestamp == new_ts:
|
||||||
|
return content
|
||||||
|
case _:
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
logger.info("Loading file %s", path)
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
_FILE_CACHE[path] = (new_ts, content)
|
||||||
|
return content
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""FastAPI web runtime for personal MCP."""
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
from ..config import Settings
|
|
||||||
from ..config import get_settings
|
|
||||||
from ..mcp import create_mcp
|
|
||||||
from .docs_mount import mount_docs_static
|
|
||||||
from .health import router as health_router
|
|
||||||
|
|
||||||
|
|
||||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
||||||
runtime_settings = settings if settings is not None else get_settings()
|
|
||||||
mcp_app = create_mcp().http_app(
|
|
||||||
path=runtime_settings.mounts.mcp,
|
|
||||||
json_response=True,
|
|
||||||
stateless_http=True,
|
|
||||||
transport="http",
|
|
||||||
)
|
|
||||||
app = FastAPI(
|
|
||||||
debug=runtime_settings.debug,
|
|
||||||
docs_url=None,
|
|
||||||
redoc_url=None,
|
|
||||||
openapi_url=None,
|
|
||||||
lifespan=mcp_app.lifespan,
|
|
||||||
)
|
|
||||||
app.state.settings = runtime_settings
|
|
||||||
|
|
||||||
app.include_router(health_router)
|
|
||||||
mount_docs_static(
|
|
||||||
app,
|
|
||||||
docs_route=runtime_settings.mounts.docs,
|
|
||||||
site_dir=runtime_settings.site_dir,
|
|
||||||
)
|
|
||||||
app.mount("/", mcp_app, name="mcp")
|
|
||||||
return app
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi import Response
|
|
||||||
from fastapi import status
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
|
|
||||||
|
|
||||||
def mount_docs_static(app: FastAPI, *, docs_route: str, site_dir: Path) -> None:
|
|
||||||
"""Mount the pre-built static docs site, or expose a clear missing-build response."""
|
|
||||||
normalized_route = docs_route.rstrip("/") or "/docs"
|
|
||||||
docs_root = f"{normalized_route}/"
|
|
||||||
|
|
||||||
async def redirect_to_docs_root() -> RedirectResponse:
|
|
||||||
return RedirectResponse(
|
|
||||||
url=docs_root, status_code=status.HTTP_307_TEMPORARY_REDIRECT
|
|
||||||
)
|
|
||||||
|
|
||||||
app.add_api_route(
|
|
||||||
normalized_route,
|
|
||||||
redirect_to_docs_root,
|
|
||||||
methods=["GET", "HEAD"],
|
|
||||||
include_in_schema=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
if site_dir.is_dir():
|
|
||||||
app.mount(
|
|
||||||
normalized_route,
|
|
||||||
StaticFiles(directory=site_dir, html=True),
|
|
||||||
name="docs",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
async def docs_not_built() -> Response:
|
|
||||||
return Response(
|
|
||||||
content=(
|
|
||||||
"Static docs have not been built yet. "
|
|
||||||
"Run `uv run zensical build` before using this route."
|
|
||||||
),
|
|
||||||
media_type="text/plain",
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
)
|
|
||||||
|
|
||||||
app.add_api_route(
|
|
||||||
normalized_route,
|
|
||||||
docs_not_built,
|
|
||||||
methods=["GET"],
|
|
||||||
include_in_schema=False,
|
|
||||||
)
|
|
||||||
app.add_api_route(
|
|
||||||
f"{normalized_route}/{{path:path}}",
|
|
||||||
docs_not_built,
|
|
||||||
methods=["GET"],
|
|
||||||
include_in_schema=False,
|
|
||||||
)
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/healthz", include_in_schema=False)
|
|
||||||
def healthz() -> dict[str, str]:
|
|
||||||
return {"status": "ok"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Test package marker for intra-suite imports."""
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
# Global lightweight fixtures can be added here as the suite grows.
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from personal_mcp.prompts.content import load_prompt_definition
|
|
||||||
from personal_mcp.prompts.content import render_prompt
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
|
||||||
|
|
||||||
|
|
||||||
class TestPromptContentRenderer:
|
|
||||||
def test_renders_arguments_without_frontmatter(self) -> None:
|
|
||||||
rendered = render_prompt(
|
|
||||||
"jsfiddle-page-layout",
|
|
||||||
{"domain": "public library", "layout_brief": None},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert not rendered.startswith("---")
|
|
||||||
assert "`domain`: public library" in rendered
|
|
||||||
assert "`layout_brief`: Not provided" in rendered
|
|
||||||
|
|
||||||
def test_rejects_placeholder_drift(self) -> None:
|
|
||||||
with pytest.raises(ValueError, match="placeholders do not match arguments"):
|
|
||||||
render_prompt("jsfiddle-page-layout", {"domain": "public library"})
|
|
||||||
|
|
||||||
def test_rejects_invalid_prompt_id(self) -> None:
|
|
||||||
with pytest.raises(ValueError, match="lowercase kebab-case"):
|
|
||||||
render_prompt("../outside", {})
|
|
||||||
|
|
||||||
def test_rejects_missing_prompt_metadata(self, tmp_path: Path) -> None:
|
|
||||||
document = tmp_path / "PROMPT.md"
|
|
||||||
document.write_text("---\nicon: lucide/messages-square\n---\n\n# Body\n", encoding="utf-8")
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="missing the 'prompt' block"):
|
|
||||||
load_prompt_definition("demo", document)
|
|
||||||
|
|
||||||
def test_rejects_unknown_prompt_metadata(self, tmp_path: Path) -> None:
|
|
||||||
document = tmp_path / "PROMPT.md"
|
|
||||||
document.write_text(
|
|
||||||
"---\nprompt: {version: '1', description: Demo, tags: [demo], arguments: {}, unknown: true}\n"
|
|
||||||
"---\n\n# Body\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Extra inputs are not permitted"):
|
|
||||||
load_prompt_definition("demo", document)
|
|
||||||
|
|
||||||
def test_rejects_declared_placeholder_drift(self, tmp_path: Path) -> None:
|
|
||||||
document = tmp_path / "PROMPT.md"
|
|
||||||
document.write_text(
|
|
||||||
"---\nprompt: {version: '1', description: Demo, tags: [demo], arguments: "
|
|
||||||
"{topic: {description: Topic, required: true}}}\n---\n\n# Body\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="placeholders do not match arguments"):
|
|
||||||
load_prompt_definition("demo", document)
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastmcp import Client
|
|
||||||
from fastmcp import FastMCP
|
|
||||||
from fastmcp.exceptions import PromptError
|
|
||||||
|
|
||||||
from personal_mcp.prompts import create_prompts_provider
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
|
||||||
|
|
||||||
EXPECTED_PROMPTS = {
|
|
||||||
"authoring",
|
|
||||||
"greenfield-architecture",
|
|
||||||
"jsfiddle-page-layout",
|
|
||||||
"mcp-consumer-repo-shim",
|
|
||||||
"nicegui-component-extraction",
|
|
||||||
"pytest-fill-scaffold",
|
|
||||||
"pytest-scaffold",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def write_prompt(document: Path, *, description: str, heading: str = "Demo") -> None:
|
|
||||||
document.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
document.write_text(
|
|
||||||
"---\n"
|
|
||||||
f"prompt: {{version: '1.0.0', description: {description!r}, tags: [demo], arguments: "
|
|
||||||
"{kind: {description: 'Kind to render.', required: true, choices: [first, second]}, "
|
|
||||||
"note: {description: 'Optional note.', required: false}}}\n"
|
|
||||||
"---\n\n"
|
|
||||||
f"# {heading}\n\nKind: {{{{kind}}}}\n\nNote: {{{{note}}}}\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMarkdownPromptsProvider:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_discovers_exact_authored_set(self) -> None:
|
|
||||||
mcp = FastMCP("prompts-test")
|
|
||||||
mcp.add_provider(create_prompts_provider())
|
|
||||||
|
|
||||||
async with Client(mcp) as client:
|
|
||||||
prompts = await client.list_prompts()
|
|
||||||
|
|
||||||
assert {prompt.name for prompt in prompts} == EXPECTED_PROMPTS
|
|
||||||
assert all(prompt.description for prompt in prompts)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_exposes_typed_arguments_and_renders_markdown(self) -> None:
|
|
||||||
mcp = FastMCP("prompts-test")
|
|
||||||
mcp.add_provider(create_prompts_provider())
|
|
||||||
|
|
||||||
async with Client(mcp) as client:
|
|
||||||
prompts = await client.list_prompts()
|
|
||||||
authoring = next(prompt for prompt in prompts if prompt.name == "authoring")
|
|
||||||
result = await client.get_prompt(
|
|
||||||
"authoring",
|
|
||||||
{
|
|
||||||
"artifact_type": "skill",
|
|
||||||
"artifact_id": "demo-skill",
|
|
||||||
"goal": "Demonstrate typed prompts.",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
required = {argument.name for argument in authoring.arguments or [] if argument.required}
|
|
||||||
artifact_type = next(argument for argument in authoring.arguments or [] if argument.name == "artifact_type")
|
|
||||||
assert required == {"artifact_type", "artifact_id", "goal"}
|
|
||||||
assert artifact_type.description == "Artifact type to create.\n\nAccepted values: skill, prompt, shim."
|
|
||||||
assert result.messages
|
|
||||||
assert "`artifact_id`: demo-skill" in result.messages[0].content.text
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_enforces_required_arguments_and_choices(self) -> None:
|
|
||||||
provider = create_prompts_provider()
|
|
||||||
prompt = await provider.get_prompt("authoring")
|
|
||||||
|
|
||||||
assert prompt is not None
|
|
||||||
with pytest.raises(PromptError, match="Missing required arguments"):
|
|
||||||
await prompt.render({"artifact_type": "skill"})
|
|
||||||
with pytest.raises(PromptError, match="must be one of"):
|
|
||||||
await prompt.render(
|
|
||||||
{
|
|
||||||
"artifact_type": "unsupported",
|
|
||||||
"artifact_id": "demo-skill",
|
|
||||||
"goal": "Demonstrate validation.",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_live_loads_edits_without_python_components(self, tmp_path: Path) -> None:
|
|
||||||
prompts_root = tmp_path / "prompts"
|
|
||||||
prompts_root.mkdir()
|
|
||||||
provider = create_prompts_provider(prompts_root)
|
|
||||||
mcp = FastMCP("prompts-test")
|
|
||||||
mcp.add_provider(provider)
|
|
||||||
|
|
||||||
async with Client(mcp) as client:
|
|
||||||
assert await client.list_prompts() == []
|
|
||||||
|
|
||||||
document = prompts_root / "dynamic-demo" / "PROMPT.md"
|
|
||||||
write_prompt(document, description="Initial description")
|
|
||||||
|
|
||||||
prompts = await client.list_prompts()
|
|
||||||
assert [prompt.name for prompt in prompts] == ["dynamic-demo"]
|
|
||||||
assert prompts[0].description == "Initial description"
|
|
||||||
result = await client.get_prompt("dynamic-demo", {"kind": "first"})
|
|
||||||
assert "# Demo" in result.messages[0].content.text
|
|
||||||
assert "Note: Not provided" in result.messages[0].content.text
|
|
||||||
|
|
||||||
write_prompt(document, description="Updated description", heading="Updated")
|
|
||||||
|
|
||||||
prompts = await client.list_prompts()
|
|
||||||
assert prompts[0].description == "Updated description"
|
|
||||||
result = await client.get_prompt("dynamic-demo", {"kind": "second", "note": "ready"})
|
|
||||||
assert "# Updated" in result.messages[0].content.text
|
|
||||||
assert "Note: ready" in result.messages[0].content.text
|
|
||||||
|
|
||||||
document.unlink()
|
|
||||||
document.parent.rmdir()
|
|
||||||
|
|
||||||
assert await client.list_prompts() == []
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from personal_mcp.registry.load import get_docs_registry
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
|
||||||
|
|
||||||
|
|
||||||
class TestCurrentDocsIngestion:
|
|
||||||
"""Covers ingestion of the repository's current docs tree."""
|
|
||||||
|
|
||||||
def test_registry_includes_docs_and_excludes_skills(self) -> None:
|
|
||||||
"""Ensures the docs registry cannot duplicate native skill resources."""
|
|
||||||
registry = get_docs_registry()
|
|
||||||
|
|
||||||
assert PurePosixPath("index.md") in registry.docs_markdown_by_path
|
|
||||||
assert any(path.parts[0] == "prompts" for path in registry.docs_markdown_by_path)
|
|
||||||
assert all(path.parts[0] != "skills" for path in registry.docs_markdown_by_path)
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from personal_mcp.registry.models import parse_docs_path
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
|
||||||
|
|
||||||
|
|
||||||
class TestDocsPathValidation:
|
|
||||||
"""Covers canonical resource-path contracts."""
|
|
||||||
|
|
||||||
def test_parse_docs_path_returns_pure_posix_path(self) -> None:
|
|
||||||
path = parse_docs_path("guides/demo.md")
|
|
||||||
|
|
||||||
assert path == PurePosixPath("guides/demo.md")
|
|
||||||
assert isinstance(path, PurePosixPath)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"value",
|
|
||||||
(
|
|
||||||
"/absolute.md",
|
|
||||||
"../outside.md",
|
|
||||||
"guides\\demo.md",
|
|
||||||
"guides//demo.md",
|
|
||||||
"guides/demo.txt",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
def test_parse_docs_path_rejects_invalid_paths(self, value: str) -> None:
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
parse_docs_path(value)
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from personal_mcp.registry.load import load_markdown
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
|
||||||
|
|
||||||
|
|
||||||
class TestLoadMarkdown:
|
|
||||||
"""Covers recursive Markdown discovery and loading."""
|
|
||||||
|
|
||||||
def test_loads_markdown_in_stable_order(self, tmp_path: Path) -> None:
|
|
||||||
nested = tmp_path / "guides"
|
|
||||||
nested.mkdir()
|
|
||||||
(nested / "b.md").write_text("caf\u00e9\n", encoding="utf-8")
|
|
||||||
(nested / "a.md").write_text("alpha\n", encoding="utf-8")
|
|
||||||
(nested / "ignored.txt").write_text("ignored\n", encoding="utf-8")
|
|
||||||
|
|
||||||
docs = load_markdown(tmp_path)
|
|
||||||
|
|
||||||
assert list(docs) == [
|
|
||||||
PurePosixPath("guides/a.md"),
|
|
||||||
PurePosixPath("guides/b.md"),
|
|
||||||
]
|
|
||||||
assert docs[PurePosixPath("guides/b.md")] == "caf\u00e9\n"
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
from pathlib import PurePosixPath
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from personal_mcp.registry.load import read_docs_markdown_path
|
|
||||||
from personal_mcp.registry.models import DocsRegistry
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
|
||||||
|
|
||||||
|
|
||||||
def _make_registry() -> DocsRegistry:
|
|
||||||
index_path = PurePosixPath("index.md")
|
|
||||||
return DocsRegistry(
|
|
||||||
docs_markdown_by_path={index_path: "# index"},
|
|
||||||
docs_markdown_path_index=(index_path,),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_reads_docs_path_from_string_boundary() -> None:
|
|
||||||
payload = read_docs_markdown_path(_make_registry(), "index.md")
|
|
||||||
|
|
||||||
assert payload == {
|
|
||||||
"uri": "resource://docs/index.md",
|
|
||||||
"format": "markdown",
|
|
||||||
"source_path": "docs/index.md",
|
|
||||||
"content": "# index",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_rejects_skill_docs_path() -> None:
|
|
||||||
with pytest.raises(KeyError, match="unknown docs path"):
|
|
||||||
read_docs_markdown_path(_make_registry(), "skills/demo/SKILL.md")
|
|
||||||
|
|
||||||
|
|
||||||
def test_rejects_non_posix_docs_path() -> None:
|
|
||||||
with pytest.raises(ValueError, match="POSIX separators"):
|
|
||||||
read_docs_markdown_path(_make_registry(), "guides\\demo.md")
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
from importlib.resources import files
|
|
||||||
from importlib.resources.abc import Traversable
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import yaml
|
|
||||||
from fastmcp import Client
|
|
||||||
from fastmcp import FastMCP
|
|
||||||
from fastmcp.utilities.skills import get_skill_manifest
|
|
||||||
from fastmcp.utilities.skills import list_skills
|
|
||||||
|
|
||||||
from personal_mcp.skills import create_skills_provider
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
|
||||||
|
|
||||||
SKILLS_ROOT = files("personal_mcp").joinpath("docs", "skills")
|
|
||||||
|
|
||||||
|
|
||||||
def skill_directories() -> list[Traversable]:
|
|
||||||
return [
|
|
||||||
directory
|
|
||||||
for directory in SKILLS_ROOT.iterdir()
|
|
||||||
if directory.is_dir() and directory.joinpath("SKILL.md").is_file()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class TestSkillsProvider:
|
|
||||||
"""Covers native FastMCP skill discovery and retrieval."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_discovers_authored_skills(self) -> None:
|
|
||||||
"""Ensures each authored skill is exposed with its description."""
|
|
||||||
expected_names = {directory.name for directory in skill_directories()}
|
|
||||||
mcp = FastMCP("skills-test")
|
|
||||||
mcp.add_provider(create_skills_provider())
|
|
||||||
|
|
||||||
async with Client(mcp) as client:
|
|
||||||
skills = await list_skills(client)
|
|
||||||
|
|
||||||
assert {skill.name for skill in skills} == expected_names
|
|
||||||
assert all(skill.description for skill in skills)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_reads_manifest_and_supporting_file(self) -> None:
|
|
||||||
"""Ensures manifests disclose hashed files that remain directly readable."""
|
|
||||||
mcp = FastMCP("skills-test")
|
|
||||||
mcp.add_provider(create_skills_provider())
|
|
||||||
|
|
||||||
async with Client(mcp) as client:
|
|
||||||
manifest = await get_skill_manifest(client, "mcp-details")
|
|
||||||
reference = next(file for file in manifest.files if file.path.startswith("references/"))
|
|
||||||
contents = await client.read_resource(f"skill://mcp-details/{reference.path}")
|
|
||||||
|
|
||||||
assert any(file.path == "SKILL.md" for file in manifest.files)
|
|
||||||
assert all(file.hash.startswith("sha256:") for file in manifest.files)
|
|
||||||
assert reference.size > 0
|
|
||||||
assert contents
|
|
||||||
|
|
||||||
def test_frontmatter_names_match_directories(self) -> None:
|
|
||||||
"""Ensures provider identity and authored skill names remain aligned."""
|
|
||||||
for directory in skill_directories():
|
|
||||||
skill_file = directory.joinpath("SKILL.md")
|
|
||||||
raw = skill_file.read_text(encoding="utf-8")
|
|
||||||
frontmatter = yaml.safe_load(raw.split("---", 2)[1])
|
|
||||||
|
|
||||||
assert set(frontmatter) == {"name", "description"}
|
|
||||||
assert frontmatter["name"] == directory.name
|
|
||||||
assert frontmatter["description"]
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import pytest_asyncio
|
|
||||||
from httpx import ASGITransport
|
|
||||||
from httpx import AsyncClient
|
|
||||||
from httpx2 import ASGITransport as McpASGITransport
|
|
||||||
from httpx2 import AsyncClient as McpAsyncClient
|
|
||||||
from mcp import ClientSession
|
|
||||||
from mcp.client.streamable_http import streamable_http_client
|
|
||||||
|
|
||||||
from personal_mcp.web.app import create_app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def client() -> AsyncGenerator[AsyncClient]:
|
|
||||||
"""Provides an AsyncClient bound to a fresh application instance."""
|
|
||||||
app = create_app()
|
|
||||||
async with AsyncClient(
|
|
||||||
transport=ASGITransport(app=app),
|
|
||||||
base_url="http://testserver",
|
|
||||||
timeout=10.0,
|
|
||||||
) as test_client:
|
|
||||||
yield test_client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mcp_session_factory():
|
|
||||||
"""Provides an in-process context manager factory for MCP SDK sessions."""
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def create_session(*, initialize: bool = True) -> AsyncGenerator[ClientSession]:
|
|
||||||
app = create_app()
|
|
||||||
mcp_url = f"http://testserver{app.state.settings.mounts.mcp}"
|
|
||||||
async with (
|
|
||||||
app.router.lifespan_context(app),
|
|
||||||
McpAsyncClient(
|
|
||||||
transport=McpASGITransport(app=app),
|
|
||||||
base_url="http://testserver",
|
|
||||||
timeout=10.0,
|
|
||||||
) as http_client,
|
|
||||||
streamable_http_client(
|
|
||||||
mcp_url,
|
|
||||||
http_client=http_client,
|
|
||||||
) as (read_stream, write_stream),
|
|
||||||
ClientSession(read_stream, write_stream) as session,
|
|
||||||
):
|
|
||||||
if initialize:
|
|
||||||
await session.initialize()
|
|
||||||
yield session
|
|
||||||
|
|
||||||
return create_session
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from httpx import AsyncClient
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.smoke
|
|
||||||
|
|
||||||
|
|
||||||
class TestMcpHttpEndpoints:
|
|
||||||
"""Covers smoke-level HTTP checks for mounted MCP runtime endpoints."""
|
|
||||||
|
|
||||||
class TestHealthz:
|
|
||||||
"""Covers health endpoint smoke behavior."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_returns_ok_payload(self, client: AsyncClient) -> None:
|
|
||||||
"""Ensures GET /healthz responds with a healthy status payload."""
|
|
||||||
response = await client.get("/healthz")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"status": "ok"}
|
|
||||||
|
|
||||||
class TestDocsRoute:
|
|
||||||
"""Covers static docs route smoke behavior."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_serves_docs_entrypoint(self, client: AsyncClient) -> None:
|
|
||||||
"""Ensures GET /docs returns the docs site entrypoint response."""
|
|
||||||
response = await client.get("/docs", follow_redirects=True)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "text/html" in response.headers["content-type"]
|
|
||||||
|
|
||||||
class TestMcpRoute:
|
|
||||||
"""Covers MCP transport endpoint smoke behavior."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_does_not_publish_legacy_resource_bridge_tools(self, mcp_session_factory) -> None:
|
|
||||||
"""Ensures deprecated compatibility tools are not exposed on the MCP route."""
|
|
||||||
async with mcp_session_factory() as mcp_session:
|
|
||||||
tools_result = await mcp_session.list_tools()
|
|
||||||
|
|
||||||
tool_names = {tool.name for tool in tools_result.tools}
|
|
||||||
assert "search_skills" not in tool_names
|
|
||||||
assert "list_resources" not in tool_names
|
|
||||||
assert "read_resource" not in tool_names
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_accepts_initialize_jsonrpc_request(
|
|
||||||
self,
|
|
||||||
mcp_session_factory,
|
|
||||||
) -> None:
|
|
||||||
"""Ensures POST /mcp accepts an initialize JSON-RPC request."""
|
|
||||||
async with mcp_session_factory(initialize=False) as mcp_session_uninitialized:
|
|
||||||
initialize_result = await mcp_session_uninitialized.initialize()
|
|
||||||
|
|
||||||
assert initialize_result.protocol_version
|
|
||||||
assert initialize_result.server_info.name
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from mcp_types import PromptReference
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.smoke
|
|
||||||
|
|
||||||
EXPECTED_PROMPTS = {
|
|
||||||
"authoring",
|
|
||||||
"greenfield-architecture",
|
|
||||||
"jsfiddle-page-layout",
|
|
||||||
"mcp-consumer-repo-shim",
|
|
||||||
"nicegui-component-extraction",
|
|
||||||
"pytest-fill-scaffold",
|
|
||||||
"pytest-scaffold",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestMcpPromptSurface:
|
|
||||||
"""Covers smoke-level MCP prompt discovery and retrieval paths."""
|
|
||||||
|
|
||||||
class TestServerMetadata:
|
|
||||||
"""Covers client-facing identity and completion capabilities."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_advertises_instructions_and_completions(self, mcp_session_factory) -> None:
|
|
||||||
"""Ensures VS Code receives server guidance and completion support."""
|
|
||||||
async with mcp_session_factory(initialize=False) as mcp_session:
|
|
||||||
result = await mcp_session.initialize()
|
|
||||||
|
|
||||||
assert result.instructions is not None
|
|
||||||
assert "skill://<name>/SKILL.md" in result.instructions
|
|
||||||
assert result.server_info.icons
|
|
||||||
assert result.server_info.icons[0].src.startswith("data:image/svg+xml;base64,")
|
|
||||||
assert result.capabilities.completions is not None
|
|
||||||
|
|
||||||
class TestPromptDiscovery:
|
|
||||||
"""Covers MCP prompts/list behavior using native prompt objects."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_lists_prompt_objects(self, mcp_session_factory) -> None:
|
|
||||||
"""Ensures prompts/list returns native prompt objects with stable names."""
|
|
||||||
async with mcp_session_factory() as mcp_session:
|
|
||||||
result = await mcp_session.list_prompts()
|
|
||||||
|
|
||||||
assert {prompt.name for prompt in result.prompts} == EXPECTED_PROMPTS
|
|
||||||
assert all(prompt.description for prompt in result.prompts)
|
|
||||||
assert all(prompt.title for prompt in result.prompts)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_completes_authored_prompt_choices(self, mcp_session_factory) -> None:
|
|
||||||
"""Ensures finite prompt choices are available as IDE-style suggestions."""
|
|
||||||
async with mcp_session_factory() as mcp_session:
|
|
||||||
result = await mcp_session.complete(
|
|
||||||
PromptReference(type="ref/prompt", name="authoring"),
|
|
||||||
{"name": "artifact_type", "value": "pr"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.completion.values == ["prompt"]
|
|
||||||
|
|
||||||
class TestPromptResolution:
|
|
||||||
"""Covers MCP prompts/get behavior using native request and response objects."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_gets_prompt_as_native_object(self, mcp_session_factory) -> None:
|
|
||||||
"""Ensures prompts/get resolves a listed prompt into structured message objects."""
|
|
||||||
async with mcp_session_factory() as mcp_session:
|
|
||||||
resolved_prompt = await mcp_session.get_prompt(
|
|
||||||
name="authoring",
|
|
||||||
arguments={
|
|
||||||
"artifact_type": "skill",
|
|
||||||
"artifact_id": "demo-skill",
|
|
||||||
"goal": "Demonstrate native prompt rendering.",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert resolved_prompt.messages
|
|
||||||
assert all(message.content for message in resolved_prompt.messages)
|
|
||||||
assert "`artifact_id`: demo-skill" in resolved_prompt.messages[0].content.text
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.smoke
|
|
||||||
|
|
||||||
|
|
||||||
class TestMcpSkillsSurface:
|
|
||||||
"""Covers native skill resources over the HTTP MCP surface."""
|
|
||||||
|
|
||||||
class TestTools:
|
|
||||||
"""Covers absence of deprecated compatibility tools."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_does_not_expose_resource_bridge_tools(self, mcp_session_factory) -> None:
|
|
||||||
"""Ensures native resources are not projected through legacy compatibility tools."""
|
|
||||||
async with mcp_session_factory() as mcp_session:
|
|
||||||
result = await mcp_session.list_tools()
|
|
||||||
|
|
||||||
tool_names = {tool.name for tool in result.tools}
|
|
||||||
assert "search_skills" not in tool_names
|
|
||||||
assert "list_resources" not in tool_names
|
|
||||||
assert "read_resource" not in tool_names
|
|
||||||
|
|
||||||
class TestResources:
|
|
||||||
"""Covers native skill resources, manifests, and file templates."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_lists_main_file_and_manifest(self, mcp_session_factory) -> None:
|
|
||||||
"""Ensures resources/list exposes native skill entry points."""
|
|
||||||
async with mcp_session_factory() as mcp_session:
|
|
||||||
result = await mcp_session.list_resources()
|
|
||||||
resource_uris = {str(resource.uri) for resource in result.resources}
|
|
||||||
|
|
||||||
assert "skill://mcp-details/SKILL.md" in resource_uris
|
|
||||||
assert "skill://mcp-details/_manifest" in resource_uris
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_lists_resource_templates(self, mcp_session_factory) -> None:
|
|
||||||
"""Ensures supporting files use per-skill wildcard templates."""
|
|
||||||
async with mcp_session_factory() as mcp_session:
|
|
||||||
result = await mcp_session.list_resource_templates()
|
|
||||||
template_uris = {template.uri_template for template in result.resource_templates}
|
|
||||||
|
|
||||||
assert "skill://mcp-details/{path*}" in template_uris
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_reads_manifest_and_supporting_file(self, mcp_session_factory) -> None:
|
|
||||||
"""Ensures manifest paths resolve through the supporting-file template."""
|
|
||||||
async with mcp_session_factory() as mcp_session:
|
|
||||||
manifest_result = await mcp_session.read_resource("skill://mcp-details/_manifest")
|
|
||||||
manifest = json.loads(manifest_result.contents[0].text)
|
|
||||||
reference = next(file["path"] for file in manifest["files"] if file["path"].startswith("references/"))
|
|
||||||
reference_result = await mcp_session.read_resource(f"skill://mcp-details/{reference}")
|
|
||||||
|
|
||||||
assert manifest["skill"] == "mcp-details"
|
|
||||||
assert reference_result.contents
|
|
||||||
@@ -1104,6 +1104,61 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983, upload-time = "2026-05-19T18:15:10.728Z" },
|
{ url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983, upload-time = "2026-05-19T18:15:10.728Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "personal-mcp"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "fastapi" },
|
||||||
|
{ name = "fastmcp" },
|
||||||
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "python-json-logger" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
|
{ name = "zensical" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "ipywidgets" },
|
||||||
|
{ name = "pre-commit" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
{ name = "ty" },
|
||||||
|
]
|
||||||
|
test = [
|
||||||
|
{ name = "httpx2" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "fastapi", specifier = ">=0.133.0" },
|
||||||
|
{ name = "fastmcp", specifier = "==4.0.0b4" },
|
||||||
|
{ name = "pydantic-settings", specifier = ">=2" },
|
||||||
|
{ name = "python-json-logger", specifier = ">=4" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0.2" },
|
||||||
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
|
||||||
|
{ name = "zensical", specifier = ">=0.0.45" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "ipywidgets", specifier = ">=8.1.8" },
|
||||||
|
{ name = "pre-commit", specifier = ">=4.6.0" },
|
||||||
|
{ name = "ruff", specifier = ">=0.15.18" },
|
||||||
|
{ name = "ty", specifier = ">=0.0.51" },
|
||||||
|
]
|
||||||
|
test = [
|
||||||
|
{ name = "httpx2", specifier = ">=2.9.1" },
|
||||||
|
{ name = "pytest", specifier = ">=9.1.1" },
|
||||||
|
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
|
||||||
|
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0.2" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pexpect"
|
name = "pexpect"
|
||||||
version = "4.9.0"
|
version = "4.9.0"
|
||||||
@@ -1162,61 +1217,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "prompts"
|
|
||||||
version = "2.0.0"
|
|
||||||
source = { editable = "." }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "fastapi" },
|
|
||||||
{ name = "fastmcp" },
|
|
||||||
{ name = "pydantic-settings" },
|
|
||||||
{ name = "python-json-logger" },
|
|
||||||
{ name = "pyyaml" },
|
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
|
||||||
{ name = "zensical" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dev-dependencies]
|
|
||||||
dev = [
|
|
||||||
{ name = "ipywidgets" },
|
|
||||||
{ name = "pre-commit" },
|
|
||||||
{ name = "ruff" },
|
|
||||||
{ name = "ty" },
|
|
||||||
]
|
|
||||||
test = [
|
|
||||||
{ name = "httpx2" },
|
|
||||||
{ name = "pytest" },
|
|
||||||
{ name = "pytest-asyncio" },
|
|
||||||
{ name = "pytest-cov" },
|
|
||||||
{ name = "pyyaml" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.metadata]
|
|
||||||
requires-dist = [
|
|
||||||
{ name = "fastapi", specifier = ">=0.133.0" },
|
|
||||||
{ name = "fastmcp", specifier = "==4.0.0b4" },
|
|
||||||
{ name = "pydantic-settings", specifier = ">=2" },
|
|
||||||
{ name = "python-json-logger", specifier = ">=4" },
|
|
||||||
{ name = "pyyaml", specifier = ">=6.0.2" },
|
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
|
|
||||||
{ name = "zensical", specifier = ">=0.0.45" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
|
||||||
dev = [
|
|
||||||
{ name = "ipywidgets", specifier = ">=8.1.8" },
|
|
||||||
{ name = "pre-commit", specifier = ">=4.6.0" },
|
|
||||||
{ name = "ruff", specifier = ">=0.15.18" },
|
|
||||||
{ name = "ty", specifier = ">=0.0.51" },
|
|
||||||
]
|
|
||||||
test = [
|
|
||||||
{ name = "httpx2", specifier = ">=2.9.1" },
|
|
||||||
{ name = "pytest", specifier = ">=9.1.1" },
|
|
||||||
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
|
|
||||||
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
|
||||||
{ name = "pyyaml", specifier = ">=6.0.2" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psutil"
|
name = "psutil"
|
||||||
version = "7.2.2"
|
version = "7.2.2"
|
||||||
|
|||||||
+114
-109
@@ -13,6 +13,9 @@
|
|||||||
# Read more: https://zensical.org/docs/setup/basics/#site_name
|
# Read more: https://zensical.org/docs/setup/basics/#site_name
|
||||||
site_name = "Documentation"
|
site_name = "Documentation"
|
||||||
|
|
||||||
|
site_dir = "src/personal_mcp/site"
|
||||||
|
docs_dir = "src/personal_mcp/docs"
|
||||||
|
|
||||||
# The site_description is included in the HTML head and should contain a
|
# The site_description is included in the HTML head and should contain a
|
||||||
# meaningful description of the site content for use by search engines.
|
# meaningful description of the site content for use by search engines.
|
||||||
#
|
#
|
||||||
@@ -27,7 +30,7 @@ site_author = "<your name here>"
|
|||||||
# The site_url is the canonical URL for your site. When building online
|
# The site_url is the canonical URL for your site. When building online
|
||||||
# documentation you should set this.
|
# documentation you should set this.
|
||||||
# Read more: https://zensical.org/docs/setup/basics/#site_url
|
# Read more: https://zensical.org/docs/setup/basics/#site_url
|
||||||
#site_url = "https://www.example.com/"
|
site_url = "https://mcp.john-stream.com/docs"
|
||||||
|
|
||||||
# The copyright notice appears in the page footer and can contain an HTML
|
# The copyright notice appears in the page footer and can contain an HTML
|
||||||
# fragment.
|
# fragment.
|
||||||
@@ -44,114 +47,115 @@ Copyright © 2026 The authors
|
|||||||
# can be defined using TOML syntax.
|
# can be defined using TOML syntax.
|
||||||
#
|
#
|
||||||
# Read more: https://zensical.org/docs/setup/navigation/
|
# Read more: https://zensical.org/docs/setup/navigation/
|
||||||
nav = [
|
# nav = [
|
||||||
{ "Home" = "index.md" },
|
# { "Home" = "index.md" },
|
||||||
{ "Guide" = [
|
# { "Guide" = [
|
||||||
{ "Arch" = "architecture.md" },
|
# { "Arch" = "architecture.md" },
|
||||||
{ "Contracts" = [
|
# { "Contracts" = [
|
||||||
{ "Overview" = "contracts/index.md" },
|
# { "Overview" = "contracts/index.md" },
|
||||||
{ "Prompt" = "contracts/prompt.md" },
|
# { "Prompt" = "contracts/prompt.md" },
|
||||||
{ "Skill" = "contracts/skill_contract.md" },
|
# { "Skill" = "contracts/skill_contract.md" },
|
||||||
{ "Frontmatter" = "contracts/frontmatter.md" },
|
# { "Frontmatter" = "contracts/frontmatter.md" },
|
||||||
{ "URIs" = "contracts/uris.md" },
|
# { "URIs" = "contracts/uris.md" },
|
||||||
] },
|
# ] },
|
||||||
{ "MCP" = "mcp_layout.md" },
|
# { "MCP" = "mcp_layout.md" },
|
||||||
{ "Copilot" = "copilot.md" },
|
# { "Copilot" = "copilot.md" },
|
||||||
{ "Usage" = "usage.md" },
|
# { "Usage" = "usage.md" },
|
||||||
{ "Authoring" = "authoring.md" },
|
# { "Authoring" = "authoring.md" },
|
||||||
{ "Future Work" = "future_work.md" },
|
# { "Future Work" = "future_work.md" },
|
||||||
{ "Testing" = "testing.md" },
|
# { "Testing" = "testing.md" },
|
||||||
{ "Security" = "securing.md" },
|
# { "Security" = "securing.md" },
|
||||||
] },
|
# ] },
|
||||||
{ "Prompts" = [
|
# { "Prompts" = [
|
||||||
{ "Authoring" = "prompts/authoring/PROMPT.md" },
|
# { "Authoring" = "prompts/authoring/PROMPT.md" },
|
||||||
{ "JSFiddle Page Layout" = "prompts/jsfiddle-page-layout/PROMPT.md" },
|
# { "JSFiddle Page Layout" = "prompts/jsfiddle-page-layout/PROMPT.md" },
|
||||||
{ "NiceGUI Component Extraction" = "prompts/nicegui-component-extraction/PROMPT.md" },
|
# { "NiceGUI Component Extraction" = "prompts/nicegui-component-extraction/PROMPT.md" },
|
||||||
{ "Pytest Fill Scaffold" = "prompts/pytest-fill-scaffold/PROMPT.md" },
|
# { "Pytest Fill Scaffold" = "prompts/pytest-fill-scaffold/PROMPT.md" },
|
||||||
{ "Pytest Scaffold" = "prompts/pytest-scaffold/PROMPT.md" },
|
# { "Pytest Scaffold" = "prompts/pytest-scaffold/PROMPT.md" },
|
||||||
{ "Greenfield Architecture" = "prompts/greenfield-architecture/PROMPT.md" },
|
# { "Greenfield Architecture" = "prompts/greenfield-architecture/PROMPT.md" },
|
||||||
{ "MCP Consumer Repo Shim" = "prompts/mcp-consumer-repo-shim/PROMPT.md" },
|
# { "MCP Consumer Repo Shim" = "prompts/mcp-consumer-repo-shim/PROMPT.md" },
|
||||||
] },
|
# ] },
|
||||||
{ "Skills" = [
|
# { "Skills" = [
|
||||||
{ "Copilot" = [
|
# { "Copilot" = [
|
||||||
{ "Overview" = "skills/copilot-customization/SKILL.md" },
|
# { "Overview" = "skills/copilot-customization/SKILL.md" },
|
||||||
{ "VS Code" = "skills/copilot-customization/references/vscode-customization.md" },
|
# { "VS Code" = "skills/copilot-customization/references/vscode-customization.md" },
|
||||||
] },
|
# ] },
|
||||||
{ "VS Code Config" = [
|
# { "VS Code Config" = [
|
||||||
{ "Overview" = "skills/vscode-configuration/SKILL.md" },
|
# { "Overview" = "skills/vscode-configuration/SKILL.md" },
|
||||||
{ "Debug Launch" = "skills/vscode-configuration/references/debug-launch-configurations.md" },
|
# { "Debug Launch" = "skills/vscode-configuration/references/debug-launch-configurations.md" },
|
||||||
{ "FastAPI Debug" = "skills/vscode-configuration/references/fastapi-debugpy-launch.md" },
|
# { "FastAPI Debug" = "skills/vscode-configuration/references/fastapi-debugpy-launch.md" },
|
||||||
{ "Tasks" = "skills/vscode-configuration/references/tasks-json-configuration.md" },
|
# { "Tasks" = "skills/vscode-configuration/references/tasks-json-configuration.md" },
|
||||||
] },
|
# ] },
|
||||||
{ "FastAPI UV" = [
|
# { "FastAPI UV" = [
|
||||||
{ "Overview" = "skills/fastapi-uv-docker/SKILL.md" },
|
# { "Overview" = "skills/fastapi-uv-docker/SKILL.md" },
|
||||||
{ "Best" = "skills/fastapi-uv-docker/references/fastapi-best-practices.md" },
|
# { "Best" = "skills/fastapi-uv-docker/references/fastapi-best-practices.md" },
|
||||||
{ "Layout" = "skills/fastapi-uv-docker/references/uv-project-layout.md" },
|
# { "Layout" = "skills/fastapi-uv-docker/references/uv-project-layout.md" },
|
||||||
{ "Uvicorn" = "skills/fastapi-uv-docker/references/uvicorn-settings.md" },
|
# { "Uvicorn" = "skills/fastapi-uv-docker/references/uvicorn-settings.md" },
|
||||||
{ "Docker" = "skills/fastapi-uv-docker/references/docker-cloud-native.md" },
|
# { "Docker" = "skills/fastapi-uv-docker/references/docker-cloud-native.md" },
|
||||||
] },
|
# ] },
|
||||||
{ "Async SQLA" = [
|
# { "Async SQLA" = [
|
||||||
{ "Overview" = "skills/async-fastapi-sqlmodel/SKILL.md" },
|
# { "Overview" = "skills/async-fastapi-sqlmodel/SKILL.md" },
|
||||||
{ "Engine" = "skills/async-fastapi-sqlmodel/references/engine.md" },
|
# { "Engine" = "skills/async-fastapi-sqlmodel/references/engine.md" },
|
||||||
{ "Session" = "skills/async-fastapi-sqlmodel/references/session.md" },
|
# { "Session" = "skills/async-fastapi-sqlmodel/references/session.md" },
|
||||||
{ "FastAPI" = "skills/async-fastapi-sqlmodel/references/fastapi.md" },
|
# { "FastAPI" = "skills/async-fastapi-sqlmodel/references/fastapi.md" },
|
||||||
{ "Tx" = "skills/async-fastapi-sqlmodel/references/transactions.md" },
|
# { "Tx" = "skills/async-fastapi-sqlmodel/references/transactions.md" },
|
||||||
{ "SQLModel" = "skills/async-fastapi-sqlmodel/references/sqlmodel.md" },
|
# { "SQLModel" = "skills/async-fastapi-sqlmodel/references/sqlmodel.md" },
|
||||||
{ "CRUD" = "skills/async-fastapi-sqlmodel/references/crud.md" },
|
# { "CRUD" = "skills/async-fastapi-sqlmodel/references/crud.md" },
|
||||||
{ "Testing" = "skills/async-fastapi-sqlmodel/references/testing.md" },
|
# { "Testing" = "skills/async-fastapi-sqlmodel/references/testing.md" },
|
||||||
{ "IO" = "skills/async-fastapi-sqlmodel/references/implicit_io.md" },
|
# { "IO" = "skills/async-fastapi-sqlmodel/references/implicit_io.md" },
|
||||||
{ "Obs" = "skills/async-fastapi-sqlmodel/references/observability.md" },
|
# { "Obs" = "skills/async-fastapi-sqlmodel/references/observability.md" },
|
||||||
{ "Template" = "skills/async-fastapi-sqlmodel/references/template.md" },
|
# { "Template" = "skills/async-fastapi-sqlmodel/references/template.md" },
|
||||||
] },
|
# ] },
|
||||||
{ "NiceGUI" = [
|
# { "NiceGUI" = [
|
||||||
{ "Overview" = "skills/nicegui/SKILL.md" },
|
# { "Overview" = "skills/nicegui/SKILL.md" },
|
||||||
{ "App Architecture" = "skills/nicegui/references/architecture.md" },
|
# { "App Architecture" = "skills/nicegui/references/architecture.md" },
|
||||||
{ "Startup" = "skills/nicegui/references/fastapi-uvicorn-startup.md" },
|
# { "Startup" = "skills/nicegui/references/fastapi-uvicorn-startup.md" },
|
||||||
{ "Visual Styling" = "skills/nicegui/references/styling-and-customization.md" },
|
# { "Visual Styling" = "skills/nicegui/references/styling-and-customization.md" },
|
||||||
{ "Component Mechanics" = "skills/nicegui/references/component-mechanics-and-customization.md" },
|
# { "Component Mechanics" = "skills/nicegui/references/component-mechanics.md" },
|
||||||
{ "Binding" = "skills/nicegui/references/binding-dataclasses.md" },
|
# { "Tables" = "skills/nicegui/references/tables.md" },
|
||||||
{ "Flows" = "skills/nicegui/references/interaction-patterns.md" },
|
# { "Binding" = "skills/nicegui/references/binding-dataclasses.md" },
|
||||||
{ "Quality" = "skills/nicegui/references/troubleshooting-and-quality-gates.md" },
|
# { "Flows" = "skills/nicegui/references/interaction-patterns.md" },
|
||||||
{ "Sources" = "skills/nicegui/references/source-documentation.md" },
|
# { "Quality" = "skills/nicegui/references/troubleshooting-and-quality-gates.md" },
|
||||||
] },
|
# { "Sources" = "skills/nicegui/references/source-documentation.md" },
|
||||||
{ "Pytest" = [
|
# ] },
|
||||||
{ "Overview" = "skills/pytesting/SKILL.md" },
|
# { "Pytest" = [
|
||||||
{ "Docs" = "skills/pytesting/references/pytest-docs.md" },
|
# { "Overview" = "skills/pytesting/SKILL.md" },
|
||||||
{ "AsyncIO" = "skills/pytesting/references/asyncio-testing.md" },
|
# { "Docs" = "skills/pytesting/references/pytest-docs.md" },
|
||||||
] },
|
# { "AsyncIO" = "skills/pytesting/references/asyncio-testing.md" },
|
||||||
{ "MCP Details" = [
|
# ] },
|
||||||
{ "Overview" = "skills/mcp-details/SKILL.md" },
|
# { "MCP Details" = [
|
||||||
{ "Protocol" = "skills/mcp-details/references/mcp-protocol-and-spec.md" },
|
# { "Overview" = "skills/mcp-details/SKILL.md" },
|
||||||
{ "SDKs and FastMCP" = "skills/mcp-details/references/sdk-and-fastmcp.md" },
|
# { "Protocol" = "skills/mcp-details/references/mcp-protocol-and-spec.md" },
|
||||||
{ "Ecosystem" = "skills/mcp-details/references/ecosystem-and-tooling.md" },
|
# { "SDKs and FastMCP" = "skills/mcp-details/references/sdk-and-fastmcp.md" },
|
||||||
] },
|
# { "Ecosystem" = "skills/mcp-details/references/ecosystem-and-tooling.md" },
|
||||||
{ "Logging" = [
|
# ] },
|
||||||
{ "Overview" = "skills/python-logging/SKILL.md" },
|
# { "Logging" = [
|
||||||
{ "Docs" = "skills/python-logging/references/python-logging-docs.md" },
|
# { "Overview" = "skills/python-logging/SKILL.md" },
|
||||||
{ "JSON File" = "skills/python-logging/references/json-file-logging.md" },
|
# { "Docs" = "skills/python-logging/references/python-logging-docs.md" },
|
||||||
{ "Network" = "skills/python-logging/references/network-logging-minimal-example.md" },
|
# { "JSON File" = "skills/python-logging/references/json-file-logging.md" },
|
||||||
{ "HTTPX" = "skills/python-logging/references/httpx-logging-handler-example.md" },
|
# { "Network" = "skills/python-logging/references/network-logging-minimal-example.md" },
|
||||||
] },
|
# { "HTTPX" = "skills/python-logging/references/httpx-logging-handler-example.md" },
|
||||||
{ "Pydantic Settings" = [
|
# ] },
|
||||||
{ "Overview" = "skills/pydantic-settings/SKILL.md" },
|
# { "Pydantic Settings" = [
|
||||||
{ "Source Docs" = "skills/pydantic-settings/references/source-documentation.md" },
|
# { "Overview" = "skills/pydantic-settings/SKILL.md" },
|
||||||
{ "Workflow" = "skills/pydantic-settings/references/implementation-workflow.md" },
|
# { "Source Docs" = "skills/pydantic-settings/references/source-documentation.md" },
|
||||||
] },
|
# { "Workflow" = "skills/pydantic-settings/references/implementation-workflow.md" },
|
||||||
{ "Ruff" = [
|
# ] },
|
||||||
{ "Overview" = "skills/ruff-linting-formating/SKILL.md" },
|
# { "Ruff" = [
|
||||||
{ "Docs" = "skills/ruff-linting-formating/references/ruff-docs.md" },
|
# { "Overview" = "skills/ruff-linting-formating/SKILL.md" },
|
||||||
{ "Integrations" = "skills/ruff-linting-formating/references/ruff-integrations.md" },
|
# { "Docs" = "skills/ruff-linting-formating/references/ruff-docs.md" },
|
||||||
] },
|
# { "Integrations" = "skills/ruff-linting-formating/references/ruff-integrations.md" },
|
||||||
{ "Zensical" = [
|
# ] },
|
||||||
{ "Overview" = "skills/zensical-docs/SKILL.md" },
|
# { "Zensical" = [
|
||||||
{ "Features" = "skills/zensical-docs/references/zensical-features.md" },
|
# { "Overview" = "skills/zensical-docs/SKILL.md" },
|
||||||
{ "Theme" = "skills/zensical-docs/references/theme-customization-and-icons.md" },
|
# { "Features" = "skills/zensical-docs/references/zensical-features.md" },
|
||||||
{ "Quality" = "skills/zensical-docs/references/documentation-quality.md" },
|
# { "Theme" = "skills/zensical-docs/references/theme-customization-and-icons.md" },
|
||||||
{ "IA" = "skills/zensical-docs/references/discoverability-and-ia.md" },
|
# { "Quality" = "skills/zensical-docs/references/documentation-quality.md" },
|
||||||
{ "API Docs" = "skills/zensical-docs/references/code-heavy-docs-and-mkdocstrings.md" },
|
# { "IA" = "skills/zensical-docs/references/discoverability-and-ia.md" },
|
||||||
] },
|
# { "API Docs" = "skills/zensical-docs/references/code-heavy-docs-and-mkdocstrings.md" },
|
||||||
] },
|
# ] },
|
||||||
]
|
# ] },
|
||||||
|
# ]
|
||||||
|
|
||||||
# With the "extra_css" option you can add your own CSS styling to customize
|
# With the "extra_css" option you can add your own CSS styling to customize
|
||||||
# your Zensical project according to your needs. You can add any number of
|
# your Zensical project according to your needs. You can add any number of
|
||||||
@@ -441,6 +445,7 @@ anchor_linenums = true
|
|||||||
line_spans = "__span"
|
line_spans = "__span"
|
||||||
pygments_lang_class = true
|
pygments_lang_class = true
|
||||||
[project.markdown_extensions.pymdownx.inlinehilite]
|
[project.markdown_extensions.pymdownx.inlinehilite]
|
||||||
|
[project.markdown_extensions.pymdownx.snippets]
|
||||||
[project.markdown_extensions.pymdownx.keys]
|
[project.markdown_extensions.pymdownx.keys]
|
||||||
[project.markdown_extensions.pymdownx.magiclink]
|
[project.markdown_extensions.pymdownx.magiclink]
|
||||||
[project.markdown_extensions.pymdownx.mark]
|
[project.markdown_extensions.pymdownx.mark]
|
||||||
|
|||||||
Reference in New Issue
Block a user