Compare commits

..
5 Commits
Author SHA1 Message Date
John Lancaster 55ef7e972a debug launch config 2026-08-29 20:45:15 -05:00
John Lancaster de95477480 big rework 2026-08-29 20:10:30 -05:00
John Lancaster 73eb490537 utils.read_file 2026-08-29 19:13:20 -05:00
John Lancaster c866d1bdb0 component mechanics 2026-08-29 17:01:09 -05:00
John Lancaster 512db5f526 started tables reference page 2026-08-27 21:54:36 -05:00
42 changed files with 948 additions and 1135 deletions
@@ -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.
+18
View File
@@ -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" ]
}
]
}
+1 -7
View File
@@ -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
View File
@@ -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"]
+16
View File
@@ -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
View File
@@ -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
-1
View File
@@ -1 +0,0 @@
src/personal_mcp/docs
+5 -6
View File
@@ -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"]
+1 -1
View File
@@ -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,
+109
View File
@@ -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
View File
@@ -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
+19 -5
View File
@@ -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)
@@ -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
View File
@@ -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()
+7 -1
View File
@@ -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",
]
+8
View File
@@ -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)
+41
View File
@@ -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)
+4 -4
View File
@@ -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
View File
@@ -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
+20
View File
@@ -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
View File
@@ -1 +0,0 @@
"""FastAPI web runtime for personal MCP."""
-34
View File
@@ -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
-56
View File
@@ -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,
)
-8
View File
@@ -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
View File
@@ -1 +0,0 @@
"""Test package marker for intra-suite imports."""
-3
View File
@@ -1,3 +0,0 @@
from __future__ import annotations
# Global lightweight fixtures can be added here as the suite grows.
-57
View File
@@ -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)
-121
View File
@@ -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)
-27
View File
@@ -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"
-37
View File
@@ -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")
-67
View File
@@ -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"]
-55
View File
@@ -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
-58
View File
@@ -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
-79
View File
@@ -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
-59
View File
@@ -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
Generated
+55 -55
View File
@@ -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
View File
@@ -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 &copy; 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]