Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3abafc4850 | ||
|
|
d4c7952175 | ||
|
|
da58e20b69 | ||
|
|
d79025538b | ||
|
|
7b1e5fcacb | ||
|
|
37461fd880 | ||
|
|
226f19b2c6 | ||
|
|
a18c8456d3 | ||
|
|
34e6d693ab | ||
|
|
efba051cb5 | ||
|
|
c9b6e137f2 | ||
|
|
8f26051a52 | ||
|
|
bc0d6ede49 | ||
|
|
aed2e41ef0 | ||
|
|
d999a04144 | ||
|
|
4818e86a1e | ||
|
|
b6393f1222 | ||
|
|
3897eabfbc | ||
|
|
9e0097708c | ||
|
|
42ea105bee | ||
|
|
5e20f69cfe | ||
|
|
007d823c0a | ||
|
|
27f783fc90 | ||
|
|
7970e76d4f | ||
|
|
70dd0f45d9 | ||
|
|
963805c551 | ||
|
|
a3ca1a65c2 | ||
|
|
94dd47cc19 | ||
|
|
b3d4e55a15 | ||
|
|
7b2b80ecf2 | ||
|
|
d4ca78dbfb | ||
|
|
eeeb6ecdbe | ||
|
|
0177496fab | ||
|
|
00498a2fed | ||
|
|
913ba66d8b | ||
|
|
e2c199c1b7 |
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
name: Authoring Content
|
||||||
|
description: "Use when editing Markdown under docs/. Routes authors to the canonical docs ownership, layout, and symlink guidance."
|
||||||
|
applyTo: 'docs/**/*.md'
|
||||||
|
---
|
||||||
|
|
||||||
|
For edits under `docs/`, use the [Authoring Guide](../../docs/authoring.md) as the entry point for content placement and contracts.
|
||||||
|
|
||||||
|
For source-tree ownership, symlink, packaging, or runtime questions, follow [Source Tree Ownership](../../docs/authoring.md). Treat that section as authoritative instead of restating its guidance here.
|
||||||
|
|
||||||
|
|
||||||
|
Primary references:
|
||||||
|
- [Skill contract](../../docs/contracts/skill_contract.md)
|
||||||
|
- [Prompt contract](../../docs/contracts/prompt.md)
|
||||||
|
- [Frontmatter contract](../../docs/contracts/frontmatter.md)
|
||||||
|
- [URI contract](../../docs/contracts/uris.md)
|
||||||
|
- [Zensical documentation authoring skill](../../docs/skills/zensical-docs/SKILL.md)
|
||||||
@@ -2,3 +2,5 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
.cache*
|
.cache*
|
||||||
site/
|
site/
|
||||||
|
|
||||||
|
*.log*
|
||||||
Vendored
+24
-5
@@ -5,7 +5,12 @@
|
|||||||
"label": "Ruff: Check",
|
"label": "Ruff: Check",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "uv",
|
"command": "uv",
|
||||||
"args": ["run", "ruff", "check", "."],
|
"args": [
|
||||||
|
"run",
|
||||||
|
"ruff",
|
||||||
|
"check",
|
||||||
|
"."
|
||||||
|
],
|
||||||
"options": {
|
"options": {
|
||||||
"cwd": "${workspaceFolder}"
|
"cwd": "${workspaceFolder}"
|
||||||
},
|
},
|
||||||
@@ -15,7 +20,11 @@
|
|||||||
"label": "Ty: Check",
|
"label": "Ty: Check",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "uv",
|
"command": "uv",
|
||||||
"args": ["run", "ty", "check"],
|
"args": [
|
||||||
|
"run",
|
||||||
|
"ty",
|
||||||
|
"check"
|
||||||
|
],
|
||||||
"options": {
|
"options": {
|
||||||
"cwd": "${workspaceFolder}"
|
"cwd": "${workspaceFolder}"
|
||||||
},
|
},
|
||||||
@@ -25,7 +34,11 @@
|
|||||||
"label": "Docs: Build",
|
"label": "Docs: Build",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "uv",
|
"command": "uv",
|
||||||
"args": ["run", "zensical", "build"],
|
"args": [
|
||||||
|
"run",
|
||||||
|
"zensical",
|
||||||
|
"build"
|
||||||
|
],
|
||||||
"options": {
|
"options": {
|
||||||
"cwd": "${workspaceFolder}"
|
"cwd": "${workspaceFolder}"
|
||||||
},
|
},
|
||||||
@@ -39,7 +52,8 @@
|
|||||||
"args": [
|
"args": [
|
||||||
"run",
|
"run",
|
||||||
"uvicorn",
|
"uvicorn",
|
||||||
"personal_mcp.main:app",
|
"personal_mcp.main:create_app",
|
||||||
|
"--factory",
|
||||||
"--host",
|
"--host",
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
"--port",
|
"--port",
|
||||||
@@ -56,7 +70,12 @@
|
|||||||
"label": "Docker: Compose Up (Build)",
|
"label": "Docker: Compose Up (Build)",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "docker",
|
"command": "docker",
|
||||||
"args": ["compose", "up", "--build"],
|
"args": [
|
||||||
|
"compose",
|
||||||
|
"up",
|
||||||
|
"--build",
|
||||||
|
"-d"
|
||||||
|
],
|
||||||
"options": {
|
"options": {
|
||||||
"cwd": "${workspaceFolder}"
|
"cwd": "${workspaceFolder}"
|
||||||
},
|
},
|
||||||
|
|||||||
+32
-21
@@ -1,43 +1,54 @@
|
|||||||
# syntax=docker/dockerfile:1
|
FROM python:3.14-slim AS builder
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||||
FROM python:3.12-slim AS builder
|
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.8.4 /uv /uvx /bin/
|
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
UV_COMPILE_BYTECODE=1 \
|
UV_COMPILE_BYTECODE=1 \
|
||||||
UV_LINK_MODE=copy
|
UV_LINK_MODE=copy \
|
||||||
|
UV_LOCKED=1
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY pyproject.toml uv.lock ./
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
COPY src ./src
|
--mount=type=bind,source=zensical.toml,target=zensical.toml \
|
||||||
|
--mount=type=bind,source=docs/,target=docs/ \
|
||||||
|
uvx zensical build
|
||||||
|
|
||||||
RUN uv sync --frozen --no-dev
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
|
uv sync --no-install-project
|
||||||
|
|
||||||
COPY docs ./docs
|
# COPY --chown=appuser:appuser . /app
|
||||||
COPY zensical.toml ./
|
|
||||||
|
|
||||||
RUN uv run zensical build
|
# RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
# uv sync --no-editable
|
||||||
|
|
||||||
FROM python:3.12-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" \
|
PATH="/app/.venv/bin:$PATH" \
|
||||||
PERSONAL_MCP_HOST=0.0.0.0 \
|
PERSONAL_MCP_SITE_DIR=/app/site
|
||||||
PERSONAL_MCP_PORT=8765
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN groupadd --system --gid 1001 appuser \
|
|
||||||
&& useradd --system --uid 1001 --gid appuser --create-home --home-dir /home/appuser appuser
|
|
||||||
|
|
||||||
COPY --from=builder --chown=appuser:appuser /app /app
|
|
||||||
|
|
||||||
EXPOSE 8765
|
EXPOSE 8765
|
||||||
|
|
||||||
|
RUN groupadd --system --gid 1001 appuser && \
|
||||||
|
useradd --system --uid 1001 --gid appuser appuser
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest --chown=appuser:appuser /uv /uvx /bin/
|
||||||
|
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 \
|
||||||
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
|
--mount=type=bind,source=src/,target=src/ \
|
||||||
|
uv sync --no-editable
|
||||||
|
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
CMD ["uvicorn", "personal_mcp.main:app", "--host", "0.0.0.0", "--port", "8765"]
|
CMD ["uvicorn", "personal_mcp.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8765"]
|
||||||
|
|||||||
+3
-1
@@ -3,6 +3,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8765:8765"
|
- "8765:8765"
|
||||||
restart: unless-stopped
|
volumes:
|
||||||
|
- ./docs:/app/src/personal_mcp/docs
|
||||||
|
|||||||
+11
-4
@@ -85,7 +85,7 @@ Only canonical catalog resources are part of the runtime contract in this phase.
|
|||||||
|
|
||||||
### Registry Loader
|
### Registry Loader
|
||||||
|
|
||||||
The runtime composition includes a startup registry loader that reads packaged docs resources using `importlib.resources.files(...)` and `Traversable` APIs.
|
Importing the package does not read or parse documentation. The MCP server and FastAPI application factories request the registry when constructing a runnable server, using packaged resources through `importlib.resources.files(...)` and `Traversable` APIs.
|
||||||
|
|
||||||
Loader responsibilities:
|
Loader responsibilities:
|
||||||
|
|
||||||
@@ -94,12 +94,16 @@ Loader responsibilities:
|
|||||||
3. Build an in-memory registry keyed by `skill_id`.
|
3. Build an in-memory registry keyed by `skill_id`.
|
||||||
4. Fail fast for duplicate ids, missing markdown files, and broken reference mappings.
|
4. Fail fast for duplicate ids, missing markdown files, and broken reference mappings.
|
||||||
|
|
||||||
Registry load failure is a startup error, not a partial runtime warning.
|
The immutable registry is cached for the process lifetime. Each Uvicorn worker constructs and retains its own registry because worker processes do not share Python objects. Registry load failure is a server-factory startup error, not a package-import error or partial runtime warning.
|
||||||
|
|
||||||
### Content Sources
|
### Content Sources
|
||||||
|
|
||||||
Content is authored in markdown under `docs/` and managed as long-form reference material. Skill documents and companion references now live under `docs/skills/`, while project-authored pages remain alongside them in the docs tree. Resource handlers expose the same authored documents through stable resource URIs.
|
Content is authored in markdown under `docs/` and managed as long-form reference material. Skill documents and companion references now live under `docs/skills/`, while project-authored pages remain alongside them in the docs tree. Resource handlers expose the same authored documents through stable resource URIs.
|
||||||
|
|
||||||
|
The repository root `docs/` directory is the only authored source. The `src/personal_mcp/docs` path is a relative symlink to that directory for source-checkout and editable-install workflows; it is not a second content tree and packaging does not depend on traversing it.
|
||||||
|
|
||||||
|
For wheel builds, [Hatchling forced inclusion](https://hatch.pypa.io/latest/config/build/#forced-inclusion) maps the root `docs/` tree to `personal_mcp/docs/`. The wheel therefore contains regular resource files at that destination rather than a symlink. Runtime registry loading uses [`importlib.resources.files`](https://docs.python.org/3/library/importlib.resources.html#importlib.resources.files) and `Traversable` operations from the `personal_mcp` package anchor, so it does not depend on the repository layout or current working directory.
|
||||||
|
|
||||||
### Static Docs Surface
|
### Static Docs Surface
|
||||||
|
|
||||||
Static docs are built directly from two markdown source streams:
|
Static docs are built directly from two markdown source streams:
|
||||||
@@ -109,6 +113,8 @@ Static docs are built directly from two markdown source streams:
|
|||||||
|
|
||||||
The merged docs tree is built by Zensical into static files and served by the FastAPI app.
|
The merged docs tree is built by Zensical into static files and served by the FastAPI app.
|
||||||
|
|
||||||
|
Generated `site/` files are deployment assets for the human-facing static site. They are separate from the authored Markdown resources packaged under `personal_mcp/docs/`.
|
||||||
|
|
||||||
## Data Flow
|
## Data Flow
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -253,7 +259,8 @@ Allowed exception:
|
|||||||
Existing markdown reference sets are valid examples of authored source material for this architecture:
|
Existing markdown reference sets are valid examples of authored source material for this architecture:
|
||||||
|
|
||||||
1. docs/skills/pytesting/references/pytest-docs.md
|
1. docs/skills/pytesting/references/pytest-docs.md
|
||||||
2. docs/skills/python-logging-dictconfig/references/python-logging-docs.md
|
2. docs/skills/python-logging/references/python-logging-docs.md
|
||||||
3. docs/skills/fastapi-uv-docker/references/fastapi-best-practices.md
|
3. docs/skills/python-logging/references/json-file-logging.md
|
||||||
|
4. docs/skills/fastapi-uv-docker/references/fastapi-best-practices.md
|
||||||
|
|
||||||
These inputs are treated as content sources, while resource URIs and catalog payloads remain the machine-facing contracts.
|
These inputs are treated as content sources, while resource URIs and catalog payloads remain the machine-facing contracts.
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ docs/
|
|||||||
*.md
|
*.md
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Source Tree Ownership
|
||||||
|
|
||||||
|
Edit content only under the repository root `docs/` directory. The `src/personal_mcp/docs` path is a relative symlink provided so package-oriented tooling and editable installs see the same files; do not replace it with copied content or author files through a second tree.
|
||||||
|
|
||||||
|
[Hatchling forced inclusion](https://hatch.pypa.io/latest/config/build/#forced-inclusion) projects root `docs/` into `personal_mcp/docs/` when building the wheel. Installed code reads that destination through [`importlib.resources`](https://docs.python.org/3/library/importlib.resources.html), while Zensical continues to build the human-facing site directly from root `docs/`.
|
||||||
|
|
||||||
|
Package import does not load these resources. A runnable MCP or FastAPI server loads and validates them when its factory runs, then caches the immutable registry for that process. Restart initialized development or worker processes after changing authored Markdown.
|
||||||
|
|
||||||
## Authoring Principles
|
## Authoring Principles
|
||||||
|
|
||||||
1. Keep Markdown as the canonical source and avoid duplicating content into alternate metadata files.
|
1. Keep Markdown as the canonical source and avoid duplicating content into alternate metadata files.
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ Rules:
|
|||||||
|
|
||||||
Valid examples:
|
Valid examples:
|
||||||
|
|
||||||
1. `fill-pytest-scaffold`
|
1. `pytest-fill-scaffold`
|
||||||
2. `review-pr-comments`
|
2. `review-pr-comments`
|
||||||
3. `scaffold-fastapi-service`
|
3. `scaffold-fastapi-service`
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -125,7 +125,7 @@ Use the personal-mcp catalog tools to search for the most relevant skill for Fas
|
|||||||
Example direct-load prompt:
|
Example direct-load prompt:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Call get_skill_document_by_id for fastapi-async-sqlalchemy-modernization and use that document as the main context for this task.
|
Call get_skill_document_by_id for async-fastapi-sqlmodel and use that document as the main context for this task.
|
||||||
```
|
```
|
||||||
|
|
||||||
Example bounded-selection prompt:
|
Example bounded-selection prompt:
|
||||||
|
|||||||
+2
-2
@@ -22,10 +22,10 @@ Install dependencies first:
|
|||||||
uv sync
|
uv sync
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the app locally with the static docs rebuilt first:
|
Run the app locally with the static docs rebuilt first, using [Uvicorn factory mode](https://www.uvicorn.org/settings/#application):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run zensical build && uv run uvicorn personal_mcp.main:app --host 127.0.0.1 --port 8765
|
uv run zensical build && uv run uvicorn personal_mcp.main:create_app --factory --host 127.0.0.1 --port 8765
|
||||||
```
|
```
|
||||||
|
|
||||||
Build and run the Docker image with the same exposed port:
|
Build and run the Docker image with the same exposed port:
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
window.MathJax = {
|
||||||
|
tex: {
|
||||||
|
inlineMath: [['\\(', '\\)']],
|
||||||
|
displayMath: [['\\[', '\\]']],
|
||||||
|
processEscapes: true,
|
||||||
|
processEnvironments: true
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
ignoreHtmlClass: '.*|',
|
||||||
|
processHtmlClass: 'arithmatex'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document$.subscribe(() => {
|
||||||
|
MathJax.startup.output.clearCache();
|
||||||
|
MathJax.typesetClear();
|
||||||
|
MathJax.texReset();
|
||||||
|
MathJax.typesetPromise();
|
||||||
|
});
|
||||||
|
|
||||||
|
component$.subscribe(({ ref }) => {
|
||||||
|
if (ref.classList.contains('md-annotation')) {
|
||||||
|
MathJax.typesetPromise([ref]);
|
||||||
|
}
|
||||||
|
});
|
||||||
+3
-2
@@ -194,7 +194,8 @@ This keeps docs publication explicit and predictable.
|
|||||||
Existing reference docs remain valid content inputs in this pattern:
|
Existing reference docs remain valid content inputs in this pattern:
|
||||||
|
|
||||||
1. docs/skills/pytesting/references/pytest-docs.md
|
1. docs/skills/pytesting/references/pytest-docs.md
|
||||||
2. docs/skills/python-logging-dictconfig/references/python-logging-docs.md
|
2. docs/skills/python-logging/references/python-logging-docs.md
|
||||||
3. docs/skills/fastapi-uv-docker/references/fastapi-best-practices.md
|
3. docs/skills/python-logging/references/json-file-logging.md
|
||||||
|
4. docs/skills/fastapi-uv-docker/references/fastapi-best-practices.md
|
||||||
|
|
||||||
These are source documents, not deployment artifacts.
|
These are source documents, not deployment artifacts.
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
name: fill-pytest-scaffold
|
name: pytest-fill-scaffold
|
||||||
description: Fill scaffolded pytest test methods with assertions, fixtures, and minimal test data while preserving concise test names and one-line intent docstrings.
|
description: Fill scaffolded pytest test methods with assertions, fixtures, and minimal test data while preserving concise test names and one-line intent docstrings.
|
||||||
x-personal-mcp:
|
x-personal-mcp:
|
||||||
id: fill-pytest-scaffold
|
id: pytest-fill-scaffold
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
tags:
|
tags:
|
||||||
- pytest
|
- pytest
|
||||||
@@ -10,7 +10,7 @@ x-personal-mcp:
|
|||||||
- scaffolding
|
- scaffolding
|
||||||
- prompts
|
- prompts
|
||||||
capabilities:
|
capabilities:
|
||||||
- resource://prompts/fill-pytest-scaffold/document
|
- resource://prompts/pytest-fill-scaffold/document
|
||||||
arguments:
|
arguments:
|
||||||
target_files:
|
target_files:
|
||||||
description: Target test file paths under tests/.
|
description: Target test file paths under tests/.
|
||||||
@@ -26,7 +26,7 @@ x-personal-mcp:
|
|||||||
required: false
|
required: false
|
||||||
---
|
---
|
||||||
|
|
||||||
# Fill Pytest Scaffold
|
# Pytest Fill Scaffold
|
||||||
|
|
||||||
Use this prompt after test scaffolding exists and method names/docstrings are already in place.
|
Use this prompt after test scaffolding exists and method names/docstrings are already in place.
|
||||||
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
---
|
||||||
|
name: pytest-scaffold
|
||||||
|
description: Plan and optionally scaffold pytest file and class structure for selected Python modules while preserving concise behavior-focused test names and one-line intent docstrings.
|
||||||
|
x-personal-mcp:
|
||||||
|
id: pytest-scaffold
|
||||||
|
version: 1.0.0
|
||||||
|
tags:
|
||||||
|
- pytest
|
||||||
|
- testing
|
||||||
|
- scaffolding
|
||||||
|
- prompts
|
||||||
|
capabilities:
|
||||||
|
- resource://prompts/pytest-scaffold/document
|
||||||
|
arguments:
|
||||||
|
target_modules:
|
||||||
|
description: Target module path(s) under src/.
|
||||||
|
required: true
|
||||||
|
mode:
|
||||||
|
description: Execution mode, either plan-only or scaffold.
|
||||||
|
required: true
|
||||||
|
path_strategy:
|
||||||
|
description: Optional mapping preference for src to tests paths.
|
||||||
|
required: false
|
||||||
|
naming_style:
|
||||||
|
description: Optional preference for concise method naming style.
|
||||||
|
required: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pytest Scaffold
|
||||||
|
|
||||||
|
Use this prompt to consistently plan and scaffold pytest test modules for selected Python source modules.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- Required:
|
||||||
|
- target_modules: one or more module paths under src/
|
||||||
|
- mode: one of plan-only or scaffold
|
||||||
|
- Optional:
|
||||||
|
- path_strategy: preference for how source paths map into tests/
|
||||||
|
- naming_style: preference for concise method naming style
|
||||||
|
|
||||||
|
## Required References
|
||||||
|
|
||||||
|
Load these in order and apply only the relevant sections:
|
||||||
|
|
||||||
|
1. Primary conventions: [Pytesting Skill](../../skills/pytesting/SKILL.md)
|
||||||
|
2. Hierarchy and naming: [Naming and Organization](../../skills/pytesting/references/naming-and-organization.md)
|
||||||
|
3. Marker and fixture defaults: [Pytest Docs Notes](../../skills/pytesting/references/pytest-docs.md)
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Inspect the current tests/ layout and infer existing naming and grouping conventions.
|
||||||
|
2. Propose a concise hierarchy plan first:
|
||||||
|
- test file paths
|
||||||
|
- class hierarchy
|
||||||
|
- method naming pattern
|
||||||
|
- fixture placement choices (tests/conftest.py or subtree conftest.py)
|
||||||
|
3. If mode is scaffold, implement only the scaffold structure:
|
||||||
|
- create missing test modules
|
||||||
|
- create class hierarchy
|
||||||
|
- add one-line docstrings to each class and test method
|
||||||
|
- keep test method names short and behavior-focused
|
||||||
|
4. Treat docstring-only scaffolds as an intentionally stable baseline for later fill-in work.
|
||||||
|
5. Validate collection with:
|
||||||
|
- uv run pytest --collect-only -q
|
||||||
|
6. Report outcomes:
|
||||||
|
- files created or updated
|
||||||
|
- collection result
|
||||||
|
- ambiguities and follow-up choices
|
||||||
|
|
||||||
|
## Naming Defaults
|
||||||
|
|
||||||
|
- Class naming:
|
||||||
|
- Test<PrimarySubject> as a top-level subject class
|
||||||
|
- nested Test<MethodOrArea> classes where extra context improves readability
|
||||||
|
- Test<FunctionName> top-level classes for standalone module functions
|
||||||
|
- Method naming:
|
||||||
|
- test_<short_outcome>
|
||||||
|
- one behavior target per method
|
||||||
|
- one-line docstring for full intent
|
||||||
|
|
||||||
|
## Authoring Rules
|
||||||
|
|
||||||
|
1. Keep scope focused on structure and naming in this prompt.
|
||||||
|
2. Do not fill test implementation details unless explicitly requested.
|
||||||
|
3. Preserve established repository conventions when they are already present.
|
||||||
|
4. If input constraints conflict, ask one concise clarifying question before editing.
|
||||||
|
|
||||||
|
## Output Contract
|
||||||
|
|
||||||
|
Return:
|
||||||
|
|
||||||
|
1. Discovery summary and references used.
|
||||||
|
2. Proposed or applied test tree.
|
||||||
|
3. Class and method naming map.
|
||||||
|
4. Validation command result.
|
||||||
|
5. Open questions only when they block completion.
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
---
|
||||||
|
name: async-fastapi-sqlmodel
|
||||||
|
description: 'Explain and apply async database principles for FastAPI, SQLAlchemy 2.x, and SQLModel. Use when: learning or reviewing AsyncEngine and AsyncSession lifecycles, FastAPI lifespan and yield dependencies, transaction boundaries, concurrency safety, implicit ORM I/O, AsyncExitStack, pooling, testing, or SQLModel integration.'
|
||||||
|
x-personal-mcp:
|
||||||
|
id: async-fastapi-sqlmodel
|
||||||
|
version: 1.1.0
|
||||||
|
tags:
|
||||||
|
- fastapi
|
||||||
|
- sqlalchemy
|
||||||
|
- sqlmodel
|
||||||
|
- async
|
||||||
|
- asyncio
|
||||||
|
- database
|
||||||
|
- transactions
|
||||||
|
- resource-lifecycle
|
||||||
|
- architecture
|
||||||
|
capabilities:
|
||||||
|
- resource://skills/async-fastapi-sqlmodel/document
|
||||||
|
---
|
||||||
|
|
||||||
|
# Async FastAPI, SQLAlchemy, and SQLModel
|
||||||
|
|
||||||
|
Use this skill to explain how an async database layer works, why the recommended patterns exist, and how to evaluate code against them. Teach the runtime model before suggesting implementation changes.
|
||||||
|
|
||||||
|
Primary targets: PostgreSQL with asyncpg and SQLite with aiosqlite.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- Explain an async engine, session factory, session, connection, or transaction.
|
||||||
|
- Review FastAPI lifespan or dependency-based database management.
|
||||||
|
- Diagnose shared-session concurrency, implicit I/O, cleanup, or transaction problems.
|
||||||
|
- Compare SQLModel's model conveniences with SQLAlchemy's async runtime APIs.
|
||||||
|
- Decide whether a context manager, `AsyncExitStack`, eager loading, pooling option, or explicit transaction is appropriate.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Produce a focused technical explanation that:
|
||||||
|
|
||||||
|
- Defines the objects involved and identifies who owns each one.
|
||||||
|
- Traces acquisition, use, transaction behavior, and cleanup.
|
||||||
|
- Separates required invariants from defaults and situational choices.
|
||||||
|
- Explains failure modes and concurrency consequences.
|
||||||
|
- Uses a minimal canonical pattern when code clarifies the mechanics.
|
||||||
|
- Links claims to the relevant reference and upstream documentation.
|
||||||
|
|
||||||
|
Do not default to producing a project plan. Give sequencing advice only when the user explicitly asks for implementation steps.
|
||||||
|
|
||||||
|
## Mental Model
|
||||||
|
|
||||||
|
Keep three ownership scopes distinct:
|
||||||
|
|
||||||
|
| Scope | Object | Purpose | Typical owner |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Application process | `AsyncEngine` and `async_sessionmaker` | Dialect, connection pool, and repeatable session configuration | FastAPI lifespan |
|
||||||
|
| Request or concurrent task | `AsyncSession` | Mutable ORM identity map and transactional state | A `yield` dependency or explicit unit of work |
|
||||||
|
| Atomic operation | `SessionTransaction` | Commit all changes together or roll them back together | Service or use-case boundary |
|
||||||
|
|
||||||
|
The engine is a long-lived factory and pool, not a single database connection. The session is a mutable unit-of-work object, not a concurrency-safe global. A transaction is a consistency boundary, not merely a call to `commit()`.
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
### Match lifetime to ownership
|
||||||
|
|
||||||
|
- Create one `AsyncEngine` per process and database configuration in the normal case.
|
||||||
|
- Dispose it explicitly in an awaitable shutdown path; garbage collection cannot reliably await async driver cleanup.
|
||||||
|
- Configure `async_sessionmaker` once and call it to create short-lived sessions.
|
||||||
|
- Close each session deterministically with `async with` or a FastAPI dependency that yields once.
|
||||||
|
|
||||||
|
See [engine lifecycle](references/engine.md) and [session management](references/session.md).
|
||||||
|
|
||||||
|
### Isolate mutable session state
|
||||||
|
|
||||||
|
An `AsyncSession` represents one stateful transaction in progress. Never use one session in multiple concurrent tasks, including branches of `asyncio.gather()`. Give each task its own session and pass sessions explicitly rather than relying on mutable scoped globals.
|
||||||
|
|
||||||
|
See [session management](references/session.md).
|
||||||
|
|
||||||
|
### Make I/O visible
|
||||||
|
|
||||||
|
Async ORM code must not unexpectedly issue SQL during ordinary attribute access. Load relationships and deferred columns explicitly with eager loader options such as `selectinload()`, use `awaitable_attrs` or `refresh()` for deliberate fallback loading, and consider `lazy="raise"` where accidental access should fail fast. `expire_on_commit=False` is a common async configuration because post-commit expiration can otherwise turn attribute reads into implicit I/O.
|
||||||
|
|
||||||
|
See [implicit ORM I/O](references/implicit_io.md).
|
||||||
|
|
||||||
|
### Put transactions around business invariants
|
||||||
|
|
||||||
|
Use `async with session.begin():` when several operations must commit or roll back as one unit. A successful exit flushes and commits; an exception rolls back. Reads still participate in SQLAlchemy's autobegin behavior unless the connection uses true DBAPI autocommit, so describe a path as read-only because of application intent and permissions, not because a session silently has no transaction.
|
||||||
|
|
||||||
|
Use `begin_nested()` only for a real SAVEPOINT requirement and account for backend-specific behavior. In SQLAlchemy 2.x, calling `session.commit()` commits the outermost transaction, not the current savepoint.
|
||||||
|
|
||||||
|
See [transaction boundaries](references/transactions.md).
|
||||||
|
|
||||||
|
### Keep framework boundaries explicit
|
||||||
|
|
||||||
|
FastAPI lifespan owns resources shared by many requests. A dependency with one `yield` owns request-scoped resources and runs cleanup after use. These are related context-manager mechanisms but solve different lifetime problems.
|
||||||
|
|
||||||
|
Use `AsyncExitStack` when lifespan acquires a variable, conditional, or mixed collection of context-managed resources. It records cleanup as resources are acquired and unwinds callbacks in reverse order. A single engine with one cleanup callback can use a plain `try/finally`; `AsyncExitStack` is a composition tool, not a requirement.
|
||||||
|
|
||||||
|
See [engine lifecycle](references/engine.md).
|
||||||
|
|
||||||
|
### Use SQLModel as the primary modeling layer
|
||||||
|
|
||||||
|
Default to SQLModel for table models and API data models in FastAPI applications. A SQLModel table model is also a SQLAlchemy model, and every SQLModel model is also a Pydantic model, so shared base models can reduce schema duplication while preserving access to SQLAlchemy's full ORM.
|
||||||
|
|
||||||
|
SQLModel does not replace SQLAlchemy's async engine, session, transaction, or loader mechanics. Its main tutorial currently demonstrates synchronous sessions and its advanced guide still lists comprehensive async documentation as future work. For async applications, combine SQLModel models and statements with SQLAlchemy's `AsyncSession` APIs. Use SQLAlchemy declarative models only when a concrete unsupported mapping or library constraint justifies the exception.
|
||||||
|
|
||||||
|
See [SQLModel integration](references/sqlmodel.md).
|
||||||
|
|
||||||
|
### Configure from evidence
|
||||||
|
|
||||||
|
Pool sizing, overflow, recycle, pre-ping, isolation, statement timeouts, and health checks depend on the driver, database, deployment concurrency, and failure model. Explain defaults and tradeoffs before recommending values. Avoid treating pool checkout as proof that a useful query can succeed.
|
||||||
|
|
||||||
|
See [observability and resilience](references/observability.md).
|
||||||
|
|
||||||
|
## Reference Map
|
||||||
|
|
||||||
|
| Concept | Reference |
|
||||||
|
|---|---|
|
||||||
|
| Engine lifecycle and ownership | [Engine lifecycle reference](references/engine.md) |
|
||||||
|
| Session factory and scope | [Session management reference](references/session.md) |
|
||||||
|
| Transaction boundaries | [Transaction boundaries reference](references/transactions.md) |
|
||||||
|
| Lifespan composition | [Engine lifecycle reference](references/engine.md) |
|
||||||
|
| Dependency injection | [Session management reference](references/session.md) |
|
||||||
|
| Implicit I/O control in ORM | [Implicit I/O reference](references/implicit_io.md) |
|
||||||
|
| Observability and resilience | [Observability reference](references/observability.md) |
|
||||||
|
| SQLModel-first modeling | [SQLModel integration reference](references/sqlmodel.md) |
|
||||||
|
| CRUD repository and standalone functions | [Basic CRUD reference](references/crud.md) |
|
||||||
|
|
||||||
|
## Canonical Composition Pattern
|
||||||
|
|
||||||
|
This example shows the ownership boundaries. Adapt state storage and dependency wiring to the application's conventions.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
async with AsyncExitStack() as stack:
|
||||||
|
engine = create_async_engine(settings.database_url)
|
||||||
|
stack.push_async_callback(engine.dispose)
|
||||||
|
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
app.state.session_factory = session_factory
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||||
|
async with app.state.session_factory() as session:
|
||||||
|
yield session
|
||||||
|
```
|
||||||
|
|
||||||
|
For direct construction without `AsyncExitStack`, put `await engine.dispose()` in a `finally` block. For background work that outlives a request, create a new session inside that task instead of retaining the request's session.
|
||||||
|
|
||||||
|
## Explanation Procedure
|
||||||
|
|
||||||
|
1. Identify the exact concept or observed behavior in question.
|
||||||
|
2. Name the owning scope: application, request/task, or transaction.
|
||||||
|
3. Trace what state the object holds and where actual database I/O can occur.
|
||||||
|
4. Explain normal entry, successful exit, exceptional exit, and concurrent use.
|
||||||
|
5. Distinguish an invariant from a recommended default or backend-specific choice.
|
||||||
|
6. Load only the matching reference documents and cite upstream sources.
|
||||||
|
7. Show the smallest useful code pattern or contrast when prose is insufficient.
|
||||||
|
8. End with concrete checks the reader can use to inspect their own code.
|
||||||
|
|
||||||
|
When reviewing code, verify:
|
||||||
|
|
||||||
|
- The URL uses an asyncio-compatible dialect.
|
||||||
|
- Engine creation and disposal have one clear owner.
|
||||||
|
- Every session has a bounded lifetime and is not shared across tasks.
|
||||||
|
- Transaction boundaries match business invariants and exception behavior.
|
||||||
|
- Relationship and deferred-column access cannot surprise the event loop with implicit I/O.
|
||||||
|
- Pool and timeout settings are justified by deployment behavior.
|
||||||
|
- Tests exercise rollback, cleanup, concurrency, and lifespan behavior where relevant.
|
||||||
|
|
||||||
|
## Anti-Patterns to Flag
|
||||||
|
|
||||||
|
- Creating engines inside request handlers.
|
||||||
|
- Sharing one AsyncSession across concurrent tasks.
|
||||||
|
- Implicit commit/rollback behavior with unclear ownership.
|
||||||
|
- Global mutable session state.
|
||||||
|
- Lifespan cleanup that depends on implicit garbage collection.
|
||||||
|
- Treating `AsyncExitStack` as mandatory for a fixed single resource.
|
||||||
|
- Treating SQLModel's synchronous tutorial examples as the async runtime pattern.
|
||||||
|
- Allowing lazy relationship access to hide database I/O.
|
||||||
|
- Copying pool settings without relating them to worker count and database capacity.
|
||||||
|
|
||||||
|
## Output Contract
|
||||||
|
|
||||||
|
Answer in the shape best suited to the question, usually:
|
||||||
|
|
||||||
|
1. Direct explanation.
|
||||||
|
2. Underlying lifecycle or transaction mechanics.
|
||||||
|
3. Required invariants and situational tradeoffs.
|
||||||
|
4. Minimal example or code-review findings when useful.
|
||||||
|
5. Verification questions and source links.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
!!! info "Primary sources"
|
||||||
|
- [SQLAlchemy asyncio extension](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html)
|
||||||
|
- [SQLAlchemy transaction management](https://docs.sqlalchemy.org/en/21/orm/session_transaction.html)
|
||||||
|
- [FastAPI lifespan events](https://fastapi.tiangolo.com/advanced/events/)
|
||||||
|
- [FastAPI dependencies with `yield`](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield/)
|
||||||
|
- [Python `AsyncExitStack`](https://docs.python.org/3/library/contextlib.html#contextlib.AsyncExitStack)
|
||||||
|
- [SQLModel session dependency pattern](https://sqlmodel.tiangolo.com/tutorial/fastapi/session-with-dependency/)
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
# Basic CRUD Repository and Functions
|
||||||
|
|
||||||
|
!!! info "Primary sources"
|
||||||
|
- [SQLModel create-data tutorial](https://sqlmodel.tiangolo.com/tutorial/fastapi/multiple-models/)
|
||||||
|
- [SQLModel update-data tutorial](https://sqlmodel.tiangolo.com/tutorial/fastapi/update-extra-data/)
|
||||||
|
- [SQLModel select tutorial](https://sqlmodel.tiangolo.com/tutorial/select/)
|
||||||
|
- [SQLAlchemy `AsyncSession` API](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.AsyncSession)
|
||||||
|
|
||||||
|
??? abstract "Decision metadata"
|
||||||
|
- Status: adopted
|
||||||
|
- Decision level: advisory
|
||||||
|
- Applies to: api-runtime, workers, tests
|
||||||
|
- Last reviewed: 2026-07-26
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Show a small SQLModel CRUD layer in two forms:
|
||||||
|
|
||||||
|
- independent functions for convenient standalone or composed operations;
|
||||||
|
- a repository object that groups those functions behind one domain-oriented interface.
|
||||||
|
|
||||||
|
Every public operation accepts an optional `AsyncSession`. When omitted, reads resolve the cached session factory and own a short-lived session, while writes resolve the same factory and own a complete session-and-transaction scope. When supplied, reads borrow the session and writes borrow its already-active caller-owned transaction. The repository stores configuration and delegates to the same functions without changing those semantics.
|
||||||
|
|
||||||
|
Use the same vocabulary at every layer:
|
||||||
|
|
||||||
|
| Operation | Function | Repository method | Scope when session is omitted | Missing-row result |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Create | `create_widget()` | `create()` | Owned transaction | Not applicable |
|
||||||
|
| Read one | `get_widget()` | `get()` | Owned session | `None` |
|
||||||
|
| Read many | `list_widgets()` | `list()` | Owned session | Empty list |
|
||||||
|
| Update | `update_widget()` | `update()` | Owned transaction | `None` |
|
||||||
|
| Delete | `delete_widget()` | `delete()` | Owned transaction | `None` |
|
||||||
|
|
||||||
|
Functions and repository methods both put domain arguments first. Database configuration and sessions are keyword-only infrastructure arguments. This keeps call sites analogous and makes ownership choices visible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
Start with one table model when the application does not need distinct persistence and API schemas.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlmodel import Field
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class Widget(SQLModel, table=True):
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
name: str = Field(index=True)
|
||||||
|
description: str | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
This reference uses direct field arguments and full-update semantics to keep the CRUD mechanics visible. Introduce separate create, update, or public schemas only when an API boundary needs different validation, field visibility, or partial-update behavior. See [SQLModel integration](sqlmodel.md) for that larger modeling pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Independent CRUD Functions
|
||||||
|
|
||||||
|
Functions are the simplest default when grouping state or behavior in an object adds no value. Each function is a complete operation boundary: it can run standalone by resolving the cached factory from `database_url`, or compose into a caller-owned scope through `session`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from .session import session_scope
|
||||||
|
from .session import transaction_scope
|
||||||
|
|
||||||
|
|
||||||
|
async def create_widget(
|
||||||
|
name: str,
|
||||||
|
description: str | None = None,
|
||||||
|
*,
|
||||||
|
database_url: str,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Widget:
|
||||||
|
async with transaction_scope(
|
||||||
|
database_url=database_url,
|
||||||
|
session=session,
|
||||||
|
) as active_session:
|
||||||
|
widget = Widget(name=name, description=description)
|
||||||
|
active_session.add(widget)
|
||||||
|
await active_session.flush()
|
||||||
|
return widget
|
||||||
|
|
||||||
|
|
||||||
|
async def get_widget(
|
||||||
|
widget_id: int,
|
||||||
|
*,
|
||||||
|
database_url: str,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Widget | None:
|
||||||
|
async with session_scope(
|
||||||
|
database_url=database_url,
|
||||||
|
session=session,
|
||||||
|
) as active_session:
|
||||||
|
return await active_session.get(Widget, widget_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_widgets(
|
||||||
|
*,
|
||||||
|
database_url: str,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> list[Widget]:
|
||||||
|
if offset < 0:
|
||||||
|
raise ValueError("offset must be non-negative")
|
||||||
|
if not 1 <= limit <= 100:
|
||||||
|
raise ValueError("limit must be between 1 and 100")
|
||||||
|
|
||||||
|
async with session_scope(
|
||||||
|
database_url=database_url,
|
||||||
|
session=session,
|
||||||
|
) as active_session:
|
||||||
|
statement = select(Widget).order_by(Widget.id).offset(offset).limit(limit)
|
||||||
|
return list(await active_session.scalars(statement))
|
||||||
|
|
||||||
|
|
||||||
|
async def update_widget(
|
||||||
|
widget_id: int,
|
||||||
|
name: str,
|
||||||
|
description: str | None,
|
||||||
|
*,
|
||||||
|
database_url: str,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Widget | None:
|
||||||
|
async with transaction_scope(
|
||||||
|
database_url=database_url,
|
||||||
|
session=session,
|
||||||
|
) as active_session:
|
||||||
|
widget = await active_session.get(Widget, widget_id)
|
||||||
|
if widget is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
widget.name = name
|
||||||
|
widget.description = description
|
||||||
|
await active_session.flush()
|
||||||
|
return widget
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_widget(
|
||||||
|
widget_id: int,
|
||||||
|
*,
|
||||||
|
database_url: str,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Widget | None:
|
||||||
|
async with transaction_scope(
|
||||||
|
database_url=database_url,
|
||||||
|
session=session,
|
||||||
|
) as active_session:
|
||||||
|
widget = await active_session.get(Widget, widget_id)
|
||||||
|
if widget is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
await active_session.delete(widget)
|
||||||
|
await active_session.flush()
|
||||||
|
return widget
|
||||||
|
```
|
||||||
|
|
||||||
|
Update and delete load the row through the same session that mutates it. This avoids accepting detached instances from an earlier standalone read and gives both operations an explicit `None` result that the application layer can map to a domain or HTTP error. Delete returns the loaded object for callers that need its values, but that object represents a row scheduled for deletion and must not be reused as persistent state. List operations validate their bounds and order by the primary key so pagination is deterministic. Add a unique tiebreaker whenever ordering by a non-unique field.
|
||||||
|
|
||||||
|
`flush()` sends pending writes and populates ordinary generated primary keys. It does not itself commit. For a standalone write, the surrounding owned `transaction_scope()` commits after the function body succeeds. For a supplied session, the caller's outer transaction retains commit and rollback ownership. Use `await active_session.refresh(widget)` only when the operation deliberately needs database-generated state that was not returned during the flush; an unconditional refresh adds another query.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Object
|
||||||
|
|
||||||
|
A repository can provide a stable domain-facing interface when several callers need the same grouped operations. It stores repeatable database configuration, never a mutable session. Every method delegates to the analogous function and exposes the same optional-session contract.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
class WidgetRepository:
|
||||||
|
def __init__(self, database_url: str) -> None:
|
||||||
|
self.database_url = database_url
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
description: str | None = None,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Widget:
|
||||||
|
return await create_widget(
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
database_url=self.database_url,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get(
|
||||||
|
self,
|
||||||
|
widget_id: int,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Widget | None:
|
||||||
|
return await get_widget(
|
||||||
|
widget_id,
|
||||||
|
database_url=self.database_url,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> list[Widget]:
|
||||||
|
return await list_widgets(
|
||||||
|
database_url=self.database_url,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self,
|
||||||
|
widget_id: int,
|
||||||
|
name: str,
|
||||||
|
description: str | None,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Widget | None:
|
||||||
|
return await update_widget(
|
||||||
|
widget_id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
database_url=self.database_url,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete(
|
||||||
|
self,
|
||||||
|
widget_id: int,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Widget | None:
|
||||||
|
return await delete_widget(
|
||||||
|
widget_id,
|
||||||
|
database_url=self.database_url,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The object is intentionally thin. Tests can construct it with a test database URL or pass a transaction-scoped test session to individual methods. A caller-provided session always wins and remains open after the method returns. A standalone operation closes its owned session before returning, so returned objects are detached; load every required scalar, deferred column, and relationship explicitly before the scope exits, and do not mutate those objects expecting persistence.
|
||||||
|
|
||||||
|
If a read participates in a later write, pass the same session and place both operations inside the explicit transaction. This avoids splitting one use case across sessions and keeps SQLAlchemy's autobegin behavior from obscuring transaction ownership. Add a repository only when its naming, shared query policy, dependency substitution, or domain boundary improves the application. Independent functions remain a valid and often clearer design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transaction Ownership
|
||||||
|
|
||||||
|
Compose multiple calls under one use-case transaction. At this boundary, a supplied session joins its already-active caller-owned transaction, while omitting the session creates a standalone session and transaction. Each nested CRUD write receives `active_session`, detects that transaction, and borrows it instead of committing independently.
|
||||||
|
|
||||||
|
The scope names describe exactly what they own: `session_scope()` manages session lifetime but never commits, while `transaction_scope()` manages a complete transaction only when it also creates the session. Both yield the name `active_session` because downstream CRUD code does not need to know whether the session was borrowed or owned.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .session import transaction_scope
|
||||||
|
|
||||||
|
|
||||||
|
async def replace_widget(
|
||||||
|
repository: WidgetRepository,
|
||||||
|
widget_id: int,
|
||||||
|
replacement_name: str,
|
||||||
|
replacement_description: str | None = None,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Widget | None:
|
||||||
|
async with transaction_scope(
|
||||||
|
database_url=repository.database_url,
|
||||||
|
session=session,
|
||||||
|
) as active_session:
|
||||||
|
deleted_widget = await repository.delete(
|
||||||
|
widget_id,
|
||||||
|
session=active_session,
|
||||||
|
)
|
||||||
|
if deleted_widget is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return await repository.create(
|
||||||
|
replacement_name,
|
||||||
|
replacement_description,
|
||||||
|
session=active_session,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
If creation fails, deletion rolls back with it. For a caller-owned transaction, wrap the call in `async with session.begin():` and pass that session. For a standalone use case, omit the session; the outer `transaction_scope()` commits on successful exit, rolls back on exception, and closes its owned session. Do not add direct `commit()` calls to CRUD functions or repository methods because that prevents callers from composing several operations atomically. See [transaction boundaries](transactions.md) and [session management](session.md) for ownership details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
- Storing one mutable `AsyncSession` on a long-lived repository object.
|
||||||
|
- Constructing ad hoc factories or sessions instead of resolving the cached factory through the scope helpers.
|
||||||
|
- Using `session_scope()` for an optional write, which would close an owned session without committing.
|
||||||
|
- Accepting a supplied session for a write without requiring an active caller-owned transaction.
|
||||||
|
- Calling `commit()` or `rollback()` directly instead of expressing ownership through `transaction_scope()`.
|
||||||
|
- Accepting unbounded list queries.
|
||||||
|
- Accepting detached ORM instances for update or delete when an identifier can be resolved in the active session.
|
||||||
|
- Accessing unloaded attributes after a standalone repository read has closed its owned session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operational Checks
|
||||||
|
|
||||||
|
- Every CRUD call receives a task-local `AsyncSession`.
|
||||||
|
- Standalone reads resolve the cached factory by database URL and close their owned session.
|
||||||
|
- Standalone writes resolve the cached factory and own commit, rollback, and session cleanup through `transaction_scope()`.
|
||||||
|
- Supplied write sessions already have an active caller-owned transaction.
|
||||||
|
- Each complete operation, service, or use-case boundary borrows an active transaction or owns a complete session-and-transaction scope.
|
||||||
|
- List operations have pagination and deterministic ordering where required.
|
||||||
|
- Update requires values for both mutable fields; passing `None` explicitly clears the nullable description.
|
||||||
|
- Get, update, and delete use the same identifier and missing-row semantics.
|
||||||
|
- Functions and repository methods use domain arguments first and keyword-only infrastructure arguments consistently.
|
||||||
|
- Standalone reads load all state needed after their owned session closes.
|
||||||
|
- Repository objects hold configuration or policy, never request-scoped session state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checks
|
||||||
|
|
||||||
|
- Create tests verify generated identifiers and persisted field values after commit.
|
||||||
|
- Get and list tests cover found, missing, pagination, and ordering behavior.
|
||||||
|
- List tests reject negative offsets and limits outside the supported range.
|
||||||
|
- Update tests cover replacement of both mutable fields, including clearing the nullable description.
|
||||||
|
- Update and delete tests cover missing identifiers without mutating the database.
|
||||||
|
- Delete tests verify the returned row and its absence after commit.
|
||||||
|
- Failure tests verify that a surrounding transaction rolls back all composed CRUD calls.
|
||||||
|
- Optional-session read tests verify borrowed sessions remain open and owned sessions close without committing.
|
||||||
|
- Optional-session write tests verify supplied transactions remain caller-owned and standalone transactions commit or roll back before closing.
|
||||||
|
- Composition tests pass one active session through several CRUD calls and verify one atomic commit or rollback.
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# Async SQLAlchemy Engine
|
||||||
|
|
||||||
|
!!! info "Primary sources"
|
||||||
|
- [Python `functools.cache`](https://docs.python.org/3/library/functools.html#functools.cache)
|
||||||
|
- [SQLAlchemy connections](https://docs.sqlalchemy.org/en/21/core/connections.html)
|
||||||
|
- [SQLAlchemy asyncio extension](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html)
|
||||||
|
- [SQLAlchemy pooling and multiprocessing](https://docs.sqlalchemy.org/en/21/core/pooling.html#pooling-multiprocessing)
|
||||||
|
- [FastAPI lifespan events](https://fastapi.tiangolo.com/advanced/events/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Engine Ownership Model
|
||||||
|
|
||||||
|
Create one async engine per process per database URL and keep engine construction independent from FastAPI.
|
||||||
|
|
||||||
|
- SQLAlchemy guidance: the engine is intended as a long-lived, concurrent registry over pooled DB connections, not a per-request object.
|
||||||
|
- A cached function provides stable process-local engine identity without making framework state the only way to obtain it.
|
||||||
|
- FastAPI lifespan starts and stops that independently defined resource; it does not contain the construction policy.
|
||||||
|
|
||||||
|
!!! tip "Practical rule"
|
||||||
|
- Exactly one `create_async_engine(...)` call in the cached engine factory.
|
||||||
|
- Zero `create_async_engine(...)` calls in request handlers.
|
||||||
|
- Zero calls to the cached factory from repository code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cached Engine Factory
|
||||||
|
|
||||||
|
Use [`functools.cache`](https://docs.python.org/3/library/functools.html#functools.cache) on a synchronous factory. Creating an `AsyncEngine` configures the dialect and pool; it does not need to await a database connection.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from functools import cache
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def get_engine(database_url: str) -> AsyncEngine:
|
||||||
|
return create_async_engine(
|
||||||
|
database_url,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def dispose_engine(database_url: str) -> None:
|
||||||
|
engine = get_engine(database_url)
|
||||||
|
try:
|
||||||
|
await engine.dispose()
|
||||||
|
finally:
|
||||||
|
get_engine.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_engine(database_url: str) -> AsyncEngine:
|
||||||
|
await dispose_engine(database_url)
|
||||||
|
return get_engine(database_url)
|
||||||
|
```
|
||||||
|
|
||||||
|
The database URL is an explicit, hashable cache key. Calls with the same URL return the same engine; a different URL receives a different engine. If engine options vary at runtime, make them explicit hashable arguments too.
|
||||||
|
|
||||||
|
Resolve settings at the composition boundary and call `get_engine(settings.database_url)`. Do not hide settings lookup or engine creation inside feature code.
|
||||||
|
|
||||||
|
## Thin FastAPI Lifespan Wrapper
|
||||||
|
|
||||||
|
The lifespan context manager only connects the cached resource to FastAPI ownership:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
database_url = app.state.settings.database_url
|
||||||
|
engine = get_engine(database_url)
|
||||||
|
app.state.engine = engine
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
await dispose_engine(database_url)
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
```
|
||||||
|
|
||||||
|
`dispose()` closes checked-in connections and replaces the pool, but it does not remove the Python object from `functools.cache`. `dispose_engine()` clears the cache even if driver cleanup raises, preventing a later lifespan run or test from retrieving that engine instance.
|
||||||
|
|
||||||
|
This simple cleanup assumes one configured database URL per process. If a process intentionally owns several cached engines, use a small registry with per-key removal instead of clearing the whole cache. For a fixed engine, `try/finally` is sufficient; use `AsyncExitStack` when lifespan composes multiple conditional or dynamically acquired resources.
|
||||||
|
|
||||||
|
When directly testing engine construction or lifespan behavior:
|
||||||
|
|
||||||
|
- Call `get_engine.cache_clear()` before the test to remove process-local state.
|
||||||
|
- Dispose any engine the test creates.
|
||||||
|
- Clear the cache again during teardown, even when the test fails.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Driver URLs (Project Requirement: asyncpg + aiosqlite)
|
||||||
|
|
||||||
|
Use SQLAlchemy async driver URLs:
|
||||||
|
|
||||||
|
- PostgreSQL: `postgresql+asyncpg://user:pass@host:5432/dbname`
|
||||||
|
- SQLite: `sqlite+aiosqlite:///./app.db`
|
||||||
|
|
||||||
|
!!! warning "Driver compatibility"
|
||||||
|
- Do not mix sync drivers, for example `psycopg2`, with `create_async_engine()`.
|
||||||
|
- Keep URL construction centralized in settings/config, not in feature modules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pooling Defaults and Tuning
|
||||||
|
|
||||||
|
Default behavior is usually correct first:
|
||||||
|
|
||||||
|
- Async engines use async-compatible pooling (`AsyncAdaptedQueuePool`) by default.
|
||||||
|
- Start with defaults, then tune from observed load (`pool_size`, `max_overflow`, `pool_timeout`, `pool_recycle`).
|
||||||
|
- Enable `pool_pre_ping=True` for safer stale-connection handling in long-running services.
|
||||||
|
|
||||||
|
When to switch pool strategy:
|
||||||
|
|
||||||
|
- `NullPool` if you explicitly need no pooling (special environments, some tests, or strict cross-loop constraints).
|
||||||
|
- Keep in mind this increases connect/disconnect churn.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Disposal Semantics
|
||||||
|
|
||||||
|
`engine.dispose()` replaces/disposes the pool, but only checked-in connections are immediately closed.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Dispose when the app is shutting down.
|
||||||
|
- Dispose before reusing an engine across event loops.
|
||||||
|
- In forked child-process initialization, use `engine.dispose(close=False)` (sync API guidance) so child processes do not touch parent-held connections.
|
||||||
|
|
||||||
|
Avoid relying on garbage collection for engine cleanup in async code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event Loop and Process Boundaries
|
||||||
|
|
||||||
|
Do not share pooled connections across boundaries:
|
||||||
|
|
||||||
|
- Multiple event loops: do not reuse the same pooled async engine across loops unless you intentionally disable pooling (`NullPool`) or dispose before handoff.
|
||||||
|
- Multiprocessing/fork: pooled connections must not be inherited for active use across process boundaries.
|
||||||
|
|
||||||
|
This prevents broken socket state and cross-process connection corruption.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Not to Do
|
||||||
|
|
||||||
|
- Create an engine inside every request dependency.
|
||||||
|
- Create/dispose engines inside repository methods.
|
||||||
|
- Call `get_engine()` from repositories instead of injecting their engine or session dependency.
|
||||||
|
- Keep engine creation as a hidden side effect of import-time module globals.
|
||||||
|
- Dispose a cached engine without clearing the cache during final teardown.
|
||||||
|
- Use deprecated FastAPI startup/shutdown events together with lifespan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Engine Design Checklist
|
||||||
|
|
||||||
|
- One engine per process per DB URL.
|
||||||
|
- Engine created by one cached, framework-independent factory.
|
||||||
|
- Lifespan only retrieves, exposes, disposes, and uncaches the engine.
|
||||||
|
- Async driver URL matches backend (`asyncpg` or `aiosqlite`).
|
||||||
|
- Pooling strategy is explicit for non-default needs.
|
||||||
|
- No request-path engine creation.
|
||||||
|
- Tests dispose engines and clear cached state deterministically.
|
||||||
+5
-11
@@ -66,13 +66,13 @@ roles = await user.awaitable_attrs.roles
|
|||||||
|
|
||||||
## Practical Enforcement Model
|
## Practical Enforcement Model
|
||||||
|
|
||||||
Use phased enforcement:
|
Require explicit I/O behavior on every async ORM path:
|
||||||
|
|
||||||
1. High-traffic and latency-sensitive routes: enforce explicit eager loading.
|
1. Define loader options for relationships and deferred columns needed by the operation.
|
||||||
2. Background tasks and less critical paths: track and progressively tighten.
|
2. Use `refresh()` or awaitable attributes only when the additional query is deliberate and visible.
|
||||||
3. Add review checks to prevent newly introduced implicit-load hotspots.
|
3. Add review checks that reject unplanned lazy-load paths.
|
||||||
|
|
||||||
This keeps modernization pragmatic while reducing hidden I/O over time.
|
This keeps event-loop behavior predictable and makes query boundaries reviewable from the code.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -99,9 +99,3 @@ This keeps modernization pragmatic while reducing hidden I/O over time.
|
|||||||
- Tests verify expected data is present without hidden secondary query surprises.
|
- Tests verify expected data is present without hidden secondary query surprises.
|
||||||
- Regression tests exist for routes previously affected by implicit-load failures.
|
- Regression tests exist for routes previously affected by implicit-load failures.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
- Start advisory: target high-risk paths first.
|
|
||||||
- As coverage improves, elevate selected rules to mandatory in code review policy.
|
|
||||||
+5
-3
@@ -1,6 +1,6 @@
|
|||||||
# FastAPI Async SQLAlchemy References Index
|
# FastAPI Async SQLAlchemy References Index
|
||||||
|
|
||||||
Purpose: concept registry for modernization guidance used by this skill.
|
Purpose: concept registry for the principles, mechanics, and implementation guidance used by this skill.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -13,12 +13,14 @@ Purpose: concept registry for modernization guidance used by this skill.
|
|||||||
| Transaction boundaries | [transactions.md](transactions.md) | adopted | mandatory | platform/backend | 2026-06-17 |
|
| Transaction boundaries | [transactions.md](transactions.md) | adopted | mandatory | platform/backend | 2026-06-17 |
|
||||||
| Implicit ORM I/O under asyncio | [implicit_io.md](implicit_io.md) | adopted | advisory | platform/backend | 2026-06-17 |
|
| Implicit ORM I/O under asyncio | [implicit_io.md](implicit_io.md) | adopted | advisory | platform/backend | 2026-06-17 |
|
||||||
| Observability and resilience | [observability.md](observability.md) | adopted | mandatory | platform/backend | 2026-06-17 |
|
| Observability and resilience | [observability.md](observability.md) | adopted | mandatory | platform/backend | 2026-06-17 |
|
||||||
|
| SQLModel modeling and async boundaries | [sqlmodel.md](sqlmodel.md) | adopted | mandatory | platform/backend | 2026-07-26 |
|
||||||
|
| Basic CRUD repository and functions | [crud.md](crud.md) | adopted | advisory | platform/backend | 2026-07-26 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How to Use This Folder
|
## How to Use This Folder
|
||||||
|
|
||||||
- `SKILL.md` defines the planning workflow and migration procedure.
|
- `SKILL.md` defines the explanatory workflow and shared mental model.
|
||||||
- Each concept doc defines policy-level guidance for one concern.
|
- Each concept doc defines policy-level guidance for one concern.
|
||||||
- Use the template in [template.md](template.md) for new concept docs.
|
- Use the template in [template.md](template.md) for new concept docs.
|
||||||
- Keep references source-linked and implementation snippets minimal.
|
- Keep references source-linked and implementation snippets minimal.
|
||||||
@@ -29,4 +31,4 @@ Purpose: concept registry for modernization guidance used by this skill.
|
|||||||
|
|
||||||
- If a PR changes database lifecycle/session/ORM loading behavior, update the relevant concept file.
|
- If a PR changes database lifecycle/session/ORM loading behavior, update the relevant concept file.
|
||||||
- Keep `Status`, `Decision Level`, and `Last Reviewed` current.
|
- Keep `Status`, `Decision Level`, and `Last Reviewed` current.
|
||||||
- Use `advisory` only when incremental rollout is intended; use `mandatory` for required runtime policy.
|
- Use `advisory` for recommendations that depend on application context; use `mandatory` for required runtime policy.
|
||||||
-7
@@ -105,10 +105,3 @@ Readiness checks should be lightweight and bounded (timeouts), not heavy diagnos
|
|||||||
- Readiness endpoint test covers healthy and unhealthy DB states.
|
- Readiness endpoint test covers healthy and unhealthy DB states.
|
||||||
- Integration test simulates disconnect/reconnect behavior.
|
- Integration test simulates disconnect/reconnect behavior.
|
||||||
- Load/concurrency tests validate pool behavior under stress.
|
- Load/concurrency tests validate pool behavior under stress.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
- Start with resilient defaults (`pool_pre_ping`) and simple health policy.
|
|
||||||
- Add deeper metrics/event hooks incrementally once baseline reliability is in place.
|
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
# Async SQLAlchemy Session Management
|
||||||
|
|
||||||
|
!!! info "Primary sources"
|
||||||
|
- [Python `functools.cache`](https://docs.python.org/3/library/functools.html#functools.cache)
|
||||||
|
- [Python `asynccontextmanager`](https://docs.python.org/3/library/contextlib.html#contextlib.asynccontextmanager)
|
||||||
|
- [SQLAlchemy asyncio extension](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html)
|
||||||
|
- [SQLAlchemy session basics](https://docs.sqlalchemy.org/en/21/orm/session_basics.html)
|
||||||
|
- [FastAPI dependencies with yield](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Define one canonical session model for FastAPI + SQLAlchemy asyncio:
|
||||||
|
|
||||||
|
- configure one shared session factory,
|
||||||
|
- create one AsyncSession per request or per unit-of-work,
|
||||||
|
- never share one AsyncSession across concurrent tasks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope and Non-Goals
|
||||||
|
|
||||||
|
- In scope: session factory creation, FastAPI dependency wiring, request/task scoping, transaction demarcation.
|
||||||
|
- Out of scope: ORM model design, query optimization strategy, schema migration tooling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Create one cached `async_sessionmaker` per app-owned AsyncEngine.
|
||||||
|
- Let repositories resolve the cached maker by database URL.
|
||||||
|
- Use a fresh AsyncSession for each request or explicit unit-of-work.
|
||||||
|
- Pass an `AsyncSession` directly to data-access functions.
|
||||||
|
- Borrow a caller-provided session without closing or committing it.
|
||||||
|
- Do not share AsyncSession across `asyncio.gather()` or parallel tasks.
|
||||||
|
- Prefer direct dependency injection over global scoped-session patterns in new code.
|
||||||
|
- Use explicit transaction boundaries (`async with session.begin():`) for writes.
|
||||||
|
- When a use case accepts an optional session, borrow only an active caller-owned transaction or own the complete session-and-transaction scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sessions and Transactions
|
||||||
|
|
||||||
|
A session and a transaction solve related but different problems:
|
||||||
|
|
||||||
|
| Concept | Responsibility | Typical lifetime |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `AsyncSession` | Provides the ORM workspace: executes queries, tracks loaded and changed objects in its identity map, and flushes pending changes. It also coordinates access to a database connection. | One request, task, or explicit unit of work. |
|
||||||
|
| Transaction | Defines the atomic database boundary: all work inside it commits together on success or rolls back together on failure. | One complete operation that must have a single outcome. |
|
||||||
|
|
||||||
|
A transaction belongs to a session; it is not an alternative to one. The session is the interface used by application and data-access code, while the transaction determines when that work becomes permanent. A session may coordinate sequential transactions during its lifetime, although short-lived application scopes commonly use one session for one transaction.
|
||||||
|
|
||||||
|
Use a session without a helper-owned commit boundary for independent reads or lower-level functions that must participate in whatever transaction their caller controls:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with session_factory() as session:
|
||||||
|
item = await find_item(session, item_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use an explicit transaction for writes, read-modify-write operations, or several statements that must succeed or fail as one unit:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with session_factory.begin() as session:
|
||||||
|
order = await create_order(session, order_data)
|
||||||
|
await reserve_inventory(session, order)
|
||||||
|
```
|
||||||
|
|
||||||
|
SQLAlchemy sessions use [autobegin](https://docs.sqlalchemy.org/en/21/orm/session_basics.html#auto-begin), so the first database operation normally starts a transaction even for a read. Therefore, “session-only” means that the surrounding helper owns only session lifetime and does not promise to commit; it does not mean that no database transaction exists. Closing such a session releases its resources and rolls back any unfinished transaction. An explicit `begin()` is valuable when application code must make the atomic boundary and commit ownership visible.
|
||||||
|
|
||||||
|
For most read-only operations, a session context is sufficient. Use an explicit transaction for reads when they need a defined consistency boundary, participate in a larger atomic operation, or use locking such as `SELECT ... FOR UPDATE`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Factory Mechanics
|
||||||
|
|
||||||
|
An `async_sessionmaker[AsyncSession]` is a reusable configuration object and callable session producer. It stores how sessions should be created, including the engine binding and options such as `expire_on_commit=False`. It is not itself a session, connection, or transaction, and calling it does not make a shared global `AsyncSession`.
|
||||||
|
|
||||||
|
Cache it by the application-owned engine so repeated composition calls return the same maker:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from functools import cache
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from .engine import dispose_engine
|
||||||
|
from .engine import get_engine
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def get_session_factory(database_url: str) -> async_sessionmaker[AsyncSession]:
|
||||||
|
return async_sessionmaker(
|
||||||
|
bind=get_engine(database_url),
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def dispose_session_factory(database_url: str) -> None:
|
||||||
|
get_session_factory.cache_clear()
|
||||||
|
await dispose_engine(database_url)
|
||||||
|
```
|
||||||
|
|
||||||
|
`functools.cache` caches by argument equality and requires hashable arguments. The database URL is an explicit string key shared with the cached engine factory. The cache retains the returned maker until `get_session_factory.cache_clear()` runs. Cache the synchronous maker function, never an async function and never a produced `AsyncSession`.
|
||||||
|
|
||||||
|
Each call to `session_factory()` creates a distinct `AsyncSession`. The caller that invokes the factory owns that session lifetime and must close it, normally with `async with`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with session_factory() as session:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
The factory can be shared across requests and tasks. Sessions produced by it cannot be shared across concurrent tasks.
|
||||||
|
|
||||||
|
An `async_sessionmaker` has no connection pool or async `dispose()` method of its own. `dispose_session_factory()` means "invalidate the cached maker, then dispose its engine." Clearing the maker first ensures no subsequent composition call can retrieve a maker bound to the engine being shut down.
|
||||||
|
|
||||||
|
Use the helper when shutting down or replacing the database resources:
|
||||||
|
|
||||||
|
```python
|
||||||
|
await dispose_session_factory(database_url)
|
||||||
|
```
|
||||||
|
|
||||||
|
Otherwise, a later call can return a maker that still references the old engine object. This matters in lifespan tests, application restarts within one process, and test suites that replace engines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optional Session Ownership
|
||||||
|
|
||||||
|
A small [`asynccontextmanager`](https://docs.python.org/3/library/contextlib.html#contextlib.asynccontextmanager) can make repository methods composable. It borrows an existing session when supplied; otherwise it creates and closes one from a supplied factory:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def session_scope(
|
||||||
|
*,
|
||||||
|
database_url: str,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> AsyncIterator[AsyncSession]:
|
||||||
|
if session is not None:
|
||||||
|
yield session
|
||||||
|
return
|
||||||
|
|
||||||
|
async with get_session_factory(database_url)() as owned_session:
|
||||||
|
yield owned_session
|
||||||
|
```
|
||||||
|
|
||||||
|
The branch is intentionally explicit. Python's [`nullcontext`](https://docs.python.org/3/library/contextlib.html#contextlib.nullcontext) can express the same borrow-or-own idea, but the branch keeps ownership and typing obvious.
|
||||||
|
|
||||||
|
This helper manages session lifetime only:
|
||||||
|
|
||||||
|
- It does not close, commit, or roll back a supplied session; the caller owns it.
|
||||||
|
- It closes a session that it creates. Closing releases resources and rolls back an unfinished transaction; it does not commit.
|
||||||
|
- It does not start a transaction. Put `session.begin()` at the use-case boundary.
|
||||||
|
- A supplied session wins; the cached factory is not resolved.
|
||||||
|
- Otherwise, `database_url` selects the cached factory returned by `get_session_factory()`.
|
||||||
|
|
||||||
|
Do not turn this into an implicit unit-of-work helper that sometimes commits. Whether work joins an existing transaction or creates a new one must remain visible to the caller.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optional Transaction Ownership
|
||||||
|
|
||||||
|
Use a separate context manager when a service or use-case function must support both a caller-owned transaction and a standalone transaction. A supplied session must already be inside a transaction; otherwise the helper creates a session and transaction together with `async_sessionmaker.begin()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@asynccontextmanager
|
||||||
|
async def transaction_scope(
|
||||||
|
*,
|
||||||
|
database_url: str,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> AsyncIterator[AsyncSession]:
|
||||||
|
if session is not None:
|
||||||
|
if not session.in_transaction():
|
||||||
|
raise RuntimeError("A supplied session must have an active transaction")
|
||||||
|
yield session
|
||||||
|
return
|
||||||
|
|
||||||
|
session_factory = get_session_factory(database_url)
|
||||||
|
async with session_factory.begin() as owned_session:
|
||||||
|
yield owned_session
|
||||||
|
```
|
||||||
|
|
||||||
|
Here, `begin()` is intentionally called on the [`async_sessionmaker`](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.async_sessionmaker.begin), not on an existing `AsyncSession`. The related APIs have different ownership semantics:
|
||||||
|
|
||||||
|
- `session_factory()` creates a session whose lifetime the surrounding code must manage; it does not commit automatically.
|
||||||
|
- `session_factory.begin()` creates a new session and transaction together, commits on successful exit or rolls back on exceptional exit, and then closes the session.
|
||||||
|
- `session.begin()` manages a transaction on an existing session but does not own or close that session.
|
||||||
|
|
||||||
|
The factory form is equivalent in ownership terms to creating a session and then entering that session's transaction:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with session_factory() as owned_session:
|
||||||
|
async with owned_session.begin():
|
||||||
|
yield owned_session
|
||||||
|
```
|
||||||
|
|
||||||
|
This helper makes transaction ownership follow the same explicit borrow-or-own mechanics as session ownership:
|
||||||
|
|
||||||
|
- A supplied session and its active transaction remain caller-owned. The helper does not commit, roll back, or close them.
|
||||||
|
- Without a supplied session, the helper owns the session and transaction. Successful exit commits; exceptional exit rolls back; either path closes the session.
|
||||||
|
- Use this helper only at a complete operation, service, or use-case boundary. A public CRUD function or repository method may be such a boundary when its optional-session contract explicitly states that omitting the session owns and commits one transaction. Never use it inside a lower-level session-required helper.
|
||||||
|
- Do not silently begin a transaction on a supplied session. That would make commit ownership depend on hidden helper behavior.
|
||||||
|
|
||||||
|
Callers that supply a session make their ownership visible with an outer transaction:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with session_factory() as session:
|
||||||
|
async with session.begin():
|
||||||
|
await run_use_case(..., session=session)
|
||||||
|
```
|
||||||
|
|
||||||
|
Standalone callers omit the session and let the use case own the complete unit of work:
|
||||||
|
|
||||||
|
```python
|
||||||
|
await run_use_case(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository and Function Boundaries
|
||||||
|
|
||||||
|
Pass the database URL to repository constructors. The repository stores repeatable database configuration, not mutable session state, and `session_scope()` resolves the cached factory when a standalone operation needs a session:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
async def find_item(session: AsyncSession, item_id: int) -> Item | None:
|
||||||
|
statement = select(Item).where(Item.id == item_id)
|
||||||
|
return await session.scalar(statement)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemRepository:
|
||||||
|
def __init__(self, database_url: str) -> None:
|
||||||
|
self.database_url = database_url
|
||||||
|
|
||||||
|
async def find(
|
||||||
|
self,
|
||||||
|
item_id: int,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Item | None:
|
||||||
|
async with session_scope(
|
||||||
|
database_url=self.database_url,
|
||||||
|
session=session,
|
||||||
|
) as active_session:
|
||||||
|
return await find_item(active_session, item_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
This split gives each layer one job:
|
||||||
|
|
||||||
|
- The repository object identifies its database configuration and creates a session only for a standalone call.
|
||||||
|
- Standalone calls reuse the cached factory selected by database URL.
|
||||||
|
- A caller can pass a session to join an existing unit of work; the repository borrows it.
|
||||||
|
- The access function owns only the query and requires an existing `AsyncSession`.
|
||||||
|
- Application wiring supplies the production factory.
|
||||||
|
- Tests can use a test database URL or call `find_item()` with a transaction-scoped test session.
|
||||||
|
|
||||||
|
When several repository operations must share one transaction, pass the same session through each call. Put the transaction at the use-case boundary:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with session_factory() as session:
|
||||||
|
async with session.begin():
|
||||||
|
item = await repository.find(item_id, session=session)
|
||||||
|
await update_item(session, item, changes)
|
||||||
|
```
|
||||||
|
|
||||||
|
This preserves atomicity without making repository objects hold mutable `AsyncSession` instances across calls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Canonical FastAPI Dependency Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi import Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
|
|
||||||
|
|
||||||
|
type SessionFactory = async_sessionmaker[AsyncSession]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_session_factory(request: Request) -> SessionFactory:
|
||||||
|
return get_session_factory(request.app.state.settings.database_url)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db_session(
|
||||||
|
session_factory: SessionFactory = Depends(resolve_session_factory),
|
||||||
|
) -> AsyncIterator[AsyncSession]:
|
||||||
|
async with session_factory() as session:
|
||||||
|
yield session
|
||||||
|
```
|
||||||
|
|
||||||
|
Route usage:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from .session import get_db_session
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items")
|
||||||
|
async def create_item(session: AsyncSession = Depends(get_db_session)) -> dict:
|
||||||
|
async with session.begin():
|
||||||
|
# write operations here
|
||||||
|
...
|
||||||
|
return {"status": "ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Guidance
|
||||||
|
|
||||||
|
- `expire_on_commit=False` is commonly preferred in asyncio applications to reduce accidental post-commit reload behavior.
|
||||||
|
- `AsyncSession.refresh()` is preferred over broad expiration patterns when state refresh is needed.
|
||||||
|
- [`async_sessionmaker.begin()`](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.async_sessionmaker.begin) is a concise option when one scope must create a session, begin a transaction, commit on success, roll back on failure, and close. Do not use it when borrowing a caller's session.
|
||||||
|
|
||||||
|
## SQLModel Alignment
|
||||||
|
|
||||||
|
- Use SQLModel as the default model and statement layer while keeping the same session ownership model: one `async_sessionmaker`, one `AsyncSession` per request/unit-of-work.
|
||||||
|
- SQLModel does not replace SQLAlchemy async lifecycle primitives; it provides model declaration, validation, and typing ergonomics on top of them.
|
||||||
|
- Do not mix ad hoc session construction with the canonical async dependency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Concurrency Rules
|
||||||
|
|
||||||
|
- One session per concurrent task.
|
||||||
|
- If work fans out into parallel tasks, each task receives its own AsyncSession.
|
||||||
|
- Pass sessions explicitly to service functions; avoid mutable global session state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
- A singleton/global AsyncSession reused across requests.
|
||||||
|
- Sharing one AsyncSession across parallel tasks.
|
||||||
|
- Passing an application-global AsyncSession to a repository constructor.
|
||||||
|
- Caching an `AsyncSession` instead of caching `async_sessionmaker`.
|
||||||
|
- Leaving a cached maker pointing at a disposed or replaced engine.
|
||||||
|
- Calling the session factory inside low-level access functions such as `find_item()`.
|
||||||
|
- Hidden session creation in lower access functions with no caller control.
|
||||||
|
- Closing or committing a session supplied by the caller.
|
||||||
|
- Starting a new transaction inside a helper that may receive a session already in a transaction.
|
||||||
|
- Silently starting or committing a transaction on a supplied session.
|
||||||
|
- Mixing commit/rollback ownership across layers without a declared boundary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operational Checks
|
||||||
|
|
||||||
|
- Exactly one cached `async_sessionmaker` exists per application engine.
|
||||||
|
- Session factory caches are cleared before their engines are disposed or replaced.
|
||||||
|
- Request handlers receive sessions from one canonical dependency.
|
||||||
|
- No code path creates AsyncSession in module import side effects.
|
||||||
|
- Background jobs and API handlers each create task-local sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checks
|
||||||
|
|
||||||
|
- Repository constructors accept a test database URL without FastAPI startup.
|
||||||
|
- Session-taking access functions accept a transaction-scoped test session directly.
|
||||||
|
- Optional-session tests verify that borrowed sessions remain open and created sessions close.
|
||||||
|
- Optional-session tests verify that neither path commits implicitly.
|
||||||
|
- Optional-transaction tests verify supplied sessions require an active transaction and remain caller-owned.
|
||||||
|
- Optional-transaction tests verify owned transactions commit on success, roll back on failure, and close their sessions.
|
||||||
|
- Cache tests clear `get_session_factory` before and after replacing engines.
|
||||||
|
- Dependency override exists for the FastAPI session factory.
|
||||||
|
- Rollback behavior is verified for failed write units.
|
||||||
|
- Parallel-task tests verify no shared AsyncSession instances.
|
||||||
|
- Lifespan tests confirm session factory is initialized and teardown-safe.
|
||||||
|
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# SQLModel-First Modeling and Async Boundaries
|
||||||
|
|
||||||
|
!!! info "Primary sources"
|
||||||
|
- [SQLModel documentation](https://sqlmodel.tiangolo.com/)
|
||||||
|
- [SQLModel features](https://sqlmodel.tiangolo.com/features/)
|
||||||
|
- [SQLModel advanced guide](https://sqlmodel.tiangolo.com/advanced/)
|
||||||
|
- [SQLModel FastAPI session dependency tutorial](https://sqlmodel.tiangolo.com/tutorial/fastapi/session-with-dependency/)
|
||||||
|
- [SQLModel release notes](https://sqlmodel.tiangolo.com/release-notes/)
|
||||||
|
- [SQLAlchemy asyncio extension](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html)
|
||||||
|
|
||||||
|
??? abstract "Decision metadata"
|
||||||
|
- Status: adopted
|
||||||
|
- Decision level: mandatory
|
||||||
|
- Applies to: api-runtime, workers, tests
|
||||||
|
- Last reviewed: 2026-07-26
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Define SQLModel as the primary model layer for async FastAPI applications and explain how it composes with SQLAlchemy's async runtime.
|
||||||
|
|
||||||
|
SQLModel is designed for FastAPI, built on Pydantic and SQLAlchemy, and intended to minimize duplication while preserving the capabilities of both. Async engine, session, transaction, and loading behavior still follow SQLAlchemy's asyncio contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope and Non-Goals
|
||||||
|
|
||||||
|
- In scope: table models, API data models, SQLAlchemy interoperability, async session usage, and exception criteria.
|
||||||
|
- Out of scope: replacing SQLAlchemy's async runtime primitives or claiming that synchronous tutorial examples are async patterns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Default to SQLModel for new table models and API data models.
|
||||||
|
- Keep SQLAlchemy async primitives as the runtime base: `create_async_engine`, `async_sessionmaker`, and `AsyncSession`.
|
||||||
|
- Keep transaction and session ownership policies identical whether models are SQLAlchemy Declarative or SQLModel.
|
||||||
|
- Use SQLModel inheritance to share validated fields while keeping table, create, update, and public contracts distinct where their semantics differ.
|
||||||
|
- Use SQLAlchemy declarative models only for a concrete unsupported mapping or third-party constraint; document the reason.
|
||||||
|
- Use SQLAlchemy relationship loading options explicitly on async paths.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Patterns
|
||||||
|
|
||||||
|
### Pattern A: Data model split for API boundaries
|
||||||
|
|
||||||
|
Use distinct models for persistence and external contracts.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class UserBase(SQLModel):
|
||||||
|
email: str
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class User(UserBase, table=True):
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UserRead(UserBase):
|
||||||
|
id: int
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern B: Keep SQLModel models with the async runtime
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
|
||||||
|
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
users = (await session.scalars(select(User))).all()
|
||||||
|
```
|
||||||
|
|
||||||
|
`sqlmodel.select()` keeps SQLModel's typing-oriented statement construction, while `AsyncSession.scalars()` and the surrounding lifecycle come from SQLAlchemy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Interoperability Notes
|
||||||
|
|
||||||
|
- A SQLModel table model is a SQLAlchemy model and can participate in SQLAlchemy relationships, statements, loader options, and sessions.
|
||||||
|
- A SQLModel model is also a Pydantic model; non-table models are useful for request and response contracts.
|
||||||
|
- SQLModel's official FastAPI dependency tutorial currently uses synchronous `Session`; translate the ownership pattern, not the concrete session type, for async applications.
|
||||||
|
- SQLModel's advanced guide still lists dedicated async documentation as future work, so use SQLAlchemy's asyncio documentation as the authority for runtime mechanics.
|
||||||
|
- Prefer one query style per module to reduce cognitive overhead.
|
||||||
|
- Keep loader strategies explicit in async paths to avoid implicit I/O surprises.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
- Treating SQLModel as an alternative to SQLAlchemy rather than a layer built on it.
|
||||||
|
- Copying a synchronous `Session` example into an async request path.
|
||||||
|
- Constructing sessions in handlers instead of using the application session factory.
|
||||||
|
- Mixing multiple query/session idioms within the same module without clear conventions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operational Checks
|
||||||
|
|
||||||
|
- New model modules are SQLModel-first; exceptions state the unsupported need or constraint.
|
||||||
|
- Session/transaction ownership remains consistent across both model styles.
|
||||||
|
- Table, create, update, and public models share fields intentionally without exposing persistence-only data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checks
|
||||||
|
|
||||||
|
- Module-level tests verify CRUD semantics for SQLModel models through `AsyncSession`.
|
||||||
|
- API tests verify response/request model behavior for SQLModel-based endpoints.
|
||||||
|
- Relationship tests verify async loader strategies do not depend on implicit I/O.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version Checks
|
||||||
|
|
||||||
|
- Verify installed SQLModel, SQLAlchemy, and Pydantic versions together when using newly added typing or ORM features.
|
||||||
-5
@@ -57,8 +57,3 @@ Describe what this concept governs and why it exists.
|
|||||||
- Test 1
|
- Test 1
|
||||||
- Test 2
|
- Test 2
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
- Staged rollout notes and compatibility caveats.
|
|
||||||
+5
-11
@@ -30,7 +30,8 @@ Define consistent transaction demarcation for async SQLAlchemy so write behavior
|
|||||||
|
|
||||||
- Every mutating use case must run inside an explicit transaction boundary.
|
- Every mutating use case must run inside an explicit transaction boundary.
|
||||||
- Prefer `async with session.begin():` for write units.
|
- Prefer `async with session.begin():` for write units.
|
||||||
- Keep transaction ownership at service/use-case boundary, not deep in helper internals.
|
- Keep transaction ownership at a service, use-case, or explicitly documented complete-operation boundary, not deep in helper internals.
|
||||||
|
- An optional-session write may own one transaction when omitting the session clearly means standalone execution; a supplied session must remain caller-owned.
|
||||||
- Read paths should not auto-upgrade into hidden write behavior.
|
- Read paths should not auto-upgrade into hidden write behavior.
|
||||||
- On exception in a transaction block, rely on rollback semantics and propagate or map exceptions intentionally.
|
- On exception in a transaction block, rely on rollback semantics and propagate or map exceptions intentionally.
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ Use nested transactions only when partial failure semantics are explicitly requi
|
|||||||
## Anti-Patterns
|
## Anti-Patterns
|
||||||
|
|
||||||
- Multiple commits scattered across one logical use case.
|
- Multiple commits scattered across one logical use case.
|
||||||
- Helper functions that commit/rollback without caller awareness.
|
- Helper functions that commit or roll back without an explicit ownership contract.
|
||||||
- Mixing implicit and explicit transaction styles in confusing ways.
|
- Mixing implicit and explicit transaction styles in confusing ways.
|
||||||
- Using savepoints as a default pattern rather than a targeted tool.
|
- Using savepoints as a default pattern rather than a targeted tool.
|
||||||
|
|
||||||
@@ -90,8 +91,8 @@ Use nested transactions only when partial failure semantics are explicitly requi
|
|||||||
|
|
||||||
## Operational Checks
|
## Operational Checks
|
||||||
|
|
||||||
- All mutating service functions declare one clear transaction boundary.
|
- All mutating services and complete operations declare one clear transaction boundary.
|
||||||
- No repository/helper performs hidden commit calls.
|
- No repository or helper performs hidden direct commit calls; standalone ownership is expressed through a documented transaction scope.
|
||||||
- Transaction style is consistent across handlers and workers.
|
- Transaction style is consistent across handlers and workers.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -102,10 +103,3 @@ Use nested transactions only when partial failure semantics are explicitly requi
|
|||||||
- Failure path test verifies rollback behavior.
|
- Failure path test verifies rollback behavior.
|
||||||
- Tests cover concurrency-sensitive write flows.
|
- Tests cover concurrency-sensitive write flows.
|
||||||
- Savepoint usage (if present) has dedicated behavior tests.
|
- Savepoint usage (if present) has dedicated behavior tests.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
- First stabilize session scope, then normalize transaction ownership.
|
|
||||||
- Replace ad hoc commit patterns incrementally with bounded write units.
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
---
|
|
||||||
name: fastapi-async-sqlalchemy-modernization
|
|
||||||
description: 'Create a step-by-step modernization plan for an existing FastAPI app using SQLAlchemy async patterns, context managers, and AsyncExitStack. Use when: planning migration from legacy DB setup, standardizing async engine/session lifecycles, defining transaction boundaries, and aligning with SQLAlchemy 2.x best practices.'
|
|
||||||
x-personal-mcp:
|
|
||||||
id: fastapi-async-sqlalchemy-modernization
|
|
||||||
version: 1.0.0
|
|
||||||
tags:
|
|
||||||
- fastapi
|
|
||||||
- sqlalchemy
|
|
||||||
- async
|
|
||||||
- asyncio
|
|
||||||
- modernization
|
|
||||||
capabilities:
|
|
||||||
- resource://skills/fastapi-async-sqlalchemy-modernization/document
|
|
||||||
---
|
|
||||||
|
|
||||||
# FastAPI Async SQLAlchemy Modernization Plan
|
|
||||||
|
|
||||||
Create an implementation-ready plan that brings an existing FastAPI application in line with modern async SQLAlchemy practices, with explicit resource lifecycles and deterministic cleanup using async context managers and AsyncExitStack.
|
|
||||||
|
|
||||||
Primary targets: PostgreSQL with asyncpg and SQLite with aiosqlite.
|
|
||||||
|
|
||||||
## When to Use
|
|
||||||
|
|
||||||
- Existing FastAPI app has ad hoc database setup or mixed sync/async access.
|
|
||||||
- Session management is inconsistent across routes/services.
|
|
||||||
- Lifespan startup and shutdown work is spread across globals and side effects.
|
|
||||||
- Team needs a migration plan first, not immediate large-scale rewrites.
|
|
||||||
|
|
||||||
## Outcome
|
|
||||||
|
|
||||||
Produce a practical modernization plan with:
|
|
||||||
|
|
||||||
- Current-state gap assessment.
|
|
||||||
- Target architecture for engine/session/transaction lifecycle.
|
|
||||||
- Branch-based migration path (low-risk staged rollout).
|
|
||||||
- Quality gates and completion checks.
|
|
||||||
- Risks, rollback strategy, and test plan.
|
|
||||||
|
|
||||||
## Top-Level Concepts
|
|
||||||
|
|
||||||
Use these concepts as the planning backbone:
|
|
||||||
|
|
||||||
1. Engine lifecycle and ownership:
|
|
||||||
One AsyncEngine per process for each DB URL, created once and disposed explicitly when the app lifecycle ends.
|
|
||||||
See the [engine lifecycle reference](references/engine.md).
|
|
||||||
2. Session factory and scope:
|
|
||||||
Use async_sessionmaker for configuration; create one AsyncSession per request or unit-of-work, never shared across concurrent tasks.
|
|
||||||
See the [session management reference](references/session.md).
|
|
||||||
3. Transaction boundaries:
|
|
||||||
Prefer context-managed begin blocks for write units and explicit read-only sessions for queries.
|
|
||||||
See the [transaction boundaries reference](references/transactions.md).
|
|
||||||
4. Lifespan composition:
|
|
||||||
Compose startup/shutdown resources with AsyncExitStack so cleanup is deterministic and ordered.
|
|
||||||
See the [engine lifecycle reference](references/engine.md).
|
|
||||||
5. Dependency injection:
|
|
||||||
Provide sessions via FastAPI dependencies with async generators/context managers, not globals.
|
|
||||||
See the [session management reference](references/session.md).
|
|
||||||
6. Implicit I/O control in ORM:
|
|
||||||
Avoid accidental lazy loads; use explicit eager-loading/refresh strategies for asyncio safety.
|
|
||||||
See the [implicit I/O reference](references/implicit_io.md).
|
|
||||||
7. Observability and resilience:
|
|
||||||
Add pool/connection settings, logging, timeout, and health checks as first-class plan items.
|
|
||||||
See the [observability reference](references/observability.md).
|
|
||||||
|
|
||||||
### Concept Reference Map
|
|
||||||
|
|
||||||
| Concept | Reference |
|
|
||||||
|---|---|
|
|
||||||
| Engine lifecycle and ownership | [Engine lifecycle reference](references/engine.md) |
|
|
||||||
| Session factory and scope | [Session management reference](references/session.md) |
|
|
||||||
| Transaction boundaries | [Transaction boundaries reference](references/transactions.md) |
|
|
||||||
| Lifespan composition | [Engine lifecycle reference](references/engine.md) |
|
|
||||||
| Dependency injection | [Session management reference](references/session.md) |
|
|
||||||
| Implicit I/O control in ORM | [Implicit I/O reference](references/implicit_io.md) |
|
|
||||||
| Observability and resilience | [Observability reference](references/observability.md) |
|
|
||||||
|
|
||||||
## Decision Points
|
|
||||||
|
|
||||||
Use these branching decisions before proposing migration steps.
|
|
||||||
|
|
||||||
| Decision | Branch A | Branch B |
|
|
||||||
|---|---|---|
|
|
||||||
| DB driver | Already async driver (e.g. asyncpg, aiosqlite): modernize in place | Sync driver: plan driver migration first |
|
|
||||||
| ORM usage | Already ORM 2.x style (`select`, `session.execute`) | Legacy Query API: add compatibility stage and refactor incrementally |
|
|
||||||
| Session scope | Request-scoped already | Global/shared sessions found: prioritize session-scope fix first |
|
|
||||||
| Lifespan | Existing FastAPI lifespan hook | No lifespan hook: introduce lifespan before broader DB changes |
|
|
||||||
| Concurrency | Background jobs/tasks use DB | No background DB use |
|
|
||||||
| Transaction style | Explicit context-managed transactions | Implicit/autobegin side effects |
|
|
||||||
|
|
||||||
## Procedure
|
|
||||||
|
|
||||||
### Step 0: Audit Current State
|
|
||||||
|
|
||||||
Inventory the app and write a concise gap list.
|
|
||||||
|
|
||||||
- Engine creation location(s) and count.
|
|
||||||
- Driver URL(s) and async compatibility.
|
|
||||||
- Session creation patterns in routes/services/background tasks.
|
|
||||||
- Transaction handling style (explicit begin/commit/rollback vs implicit).
|
|
||||||
- Lifespan startup/shutdown and cleanup behavior.
|
|
||||||
- ORM loading patterns that may trigger implicit I/O.
|
|
||||||
|
|
||||||
Completion check: every DB touchpoint is mapped to its engine, session, and transaction source.
|
|
||||||
|
|
||||||
### Step 1: Define the Target Runtime Model
|
|
||||||
|
|
||||||
Define one canonical model to migrate toward.
|
|
||||||
|
|
||||||
- Create AsyncEngine once per process.
|
|
||||||
- Configure async_sessionmaker once.
|
|
||||||
- Use per-request AsyncSession dependency.
|
|
||||||
- Keep one AsyncSession per concurrent task.
|
|
||||||
- Use context-managed transactions for writes.
|
|
||||||
|
|
||||||
Completion check: architecture diagram can explain where engine/session are created, used, and closed.
|
|
||||||
|
|
||||||
### Step 2: Plan Engine Modernization
|
|
||||||
|
|
||||||
Plan engine creation and pool behavior.
|
|
||||||
|
|
||||||
- Use `create_async_engine()` with async dialect URL.
|
|
||||||
- Standardize pool settings and pre-ping strategy where relevant.
|
|
||||||
- Decide isolation level strategy at engine level (avoid ad hoc per-operation switching unless justified).
|
|
||||||
- Define explicit disposal policy for short-lived scopes and tests.
|
|
||||||
|
|
||||||
Completion check: engine configuration is centralized and no per-request engine creation remains.
|
|
||||||
|
|
||||||
### Step 3: Plan Session Lifecycle Modernization
|
|
||||||
|
|
||||||
Define session factory and request dependency pattern.
|
|
||||||
|
|
||||||
- Build `async_sessionmaker(engine, expire_on_commit=False)` unless a strict reason says otherwise.
|
|
||||||
- Provide session via dependency that yields exactly one AsyncSession.
|
|
||||||
- Explicitly prohibit sharing a single AsyncSession across concurrent tasks.
|
|
||||||
- Prefer direct dependency passing over async_scoped_session for new designs.
|
|
||||||
|
|
||||||
Completion check: all route/service entry points receive a session from one canonical dependency.
|
|
||||||
|
|
||||||
### Step 4: Plan Transaction Demarcation
|
|
||||||
|
|
||||||
Establish consistent write and read behavior.
|
|
||||||
|
|
||||||
- Writes: `async with session.begin(): ...` for atomic units.
|
|
||||||
- Reads: execute in managed session context with explicit loader options.
|
|
||||||
- Nested/SAVEPOINT use only where required; call out backend caveats.
|
|
||||||
- Define rollback behavior for service-layer exceptions.
|
|
||||||
|
|
||||||
Completion check: every mutating use case has a declared transaction boundary.
|
|
||||||
|
|
||||||
### Step 5: Compose Lifespan with AsyncExitStack
|
|
||||||
|
|
||||||
Use async context composition as the preferred orchestration pattern.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from contextlib import AsyncExitStack, asynccontextmanager
|
|
||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
async with AsyncExitStack() as stack:
|
|
||||||
# Compose resources in acquisition order; cleanup is automatic in reverse order.
|
|
||||||
engine = create_async_engine(settings.database_url)
|
|
||||||
stack.push_async_callback(engine.dispose)
|
|
||||||
|
|
||||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
||||||
app.state.session_factory = session_factory
|
|
||||||
|
|
||||||
# Add other async resources with stack.enter_async_context(...) as needed.
|
|
||||||
yield
|
|
||||||
```
|
|
||||||
|
|
||||||
Planning rules:
|
|
||||||
|
|
||||||
- Register every acquired resource with AsyncExitStack at acquisition time.
|
|
||||||
- Prefer `enter_async_context()` for resources that already expose async context managers.
|
|
||||||
- Prefer `push_async_callback()` for async cleanup callables.
|
|
||||||
- Keep resource ownership in lifespan, not in route handlers.
|
|
||||||
|
|
||||||
Completion check: startup/shutdown ordering is explicit and deterministic.
|
|
||||||
|
|
||||||
### Step 6: Prevent Implicit ORM I/O Under Asyncio (Advisory Mode)
|
|
||||||
|
|
||||||
Plan for explicit loading behavior, but treat this as progressive guidance rather than a hard gate.
|
|
||||||
|
|
||||||
- Recommend eager-loading strategies (for example selectin-style loading) where relationship access is required.
|
|
||||||
- For lazy/deferred attributes, define explicit awaitable or refresh paths on high-risk and high-traffic paths first.
|
|
||||||
- Document model-level defaults and known exceptions so teams can migrate incrementally.
|
|
||||||
|
|
||||||
Completion check: critical request paths have explicit loading plans; non-critical paths have tracked follow-up items.
|
|
||||||
|
|
||||||
### Step 7: Testing and Verification Plan
|
|
||||||
|
|
||||||
Create modernization quality gates.
|
|
||||||
|
|
||||||
- Unit tests for session dependency and transaction behavior.
|
|
||||||
- Integration tests for commit/rollback semantics.
|
|
||||||
- Concurrency tests confirming one-session-per-task behavior.
|
|
||||||
- Lifespan tests verifying cleanup calls and ordering.
|
|
||||||
- Health/readiness tests including DB connectivity checks.
|
|
||||||
|
|
||||||
Completion check: all quality gates pass under the target async configuration.
|
|
||||||
|
|
||||||
### Step 8: Rollout Strategy
|
|
||||||
|
|
||||||
Plan low-risk migration phases.
|
|
||||||
|
|
||||||
1. Introduce centralized engine/session factory and lifespan orchestration.
|
|
||||||
2. Migrate read paths to new session dependency.
|
|
||||||
3. Migrate write paths to explicit transaction blocks.
|
|
||||||
4. Remove legacy globals/helpers and dead code.
|
|
||||||
5. Enable stricter linting/review checks for forbidden patterns.
|
|
||||||
|
|
||||||
Completion check: no legacy session/engine creation path remains in production code.
|
|
||||||
|
|
||||||
## Quality Criteria
|
|
||||||
|
|
||||||
A plan is complete only when it includes:
|
|
||||||
|
|
||||||
- Clear current vs target architecture.
|
|
||||||
- Branch decisions with rationale.
|
|
||||||
- Explicit context-manager patterns for resource ownership.
|
|
||||||
- AsyncExitStack composition strategy.
|
|
||||||
- Transaction policy and exception behavior.
|
|
||||||
- Concrete tests and rollout checkpoints.
|
|
||||||
- A documented advisory backlog for non-critical implicit I/O improvements.
|
|
||||||
|
|
||||||
## Anti-Patterns to Flag
|
|
||||||
|
|
||||||
- Creating engines inside request handlers.
|
|
||||||
- Sharing one AsyncSession across concurrent tasks.
|
|
||||||
- Implicit commit/rollback behavior with unclear ownership.
|
|
||||||
- Global mutable session state.
|
|
||||||
- Lifespan cleanup that depends on implicit garbage collection.
|
|
||||||
|
|
||||||
## Output Contract
|
|
||||||
|
|
||||||
Return the plan as:
|
|
||||||
|
|
||||||
1. Current-state gap summary.
|
|
||||||
2. Target architecture summary.
|
|
||||||
3. Phased migration checklist with branch notes.
|
|
||||||
4. Risk register and rollback approach.
|
|
||||||
5. Verification matrix (tests + operational checks).
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
!!! info "Primary sources"
|
|
||||||
- [SQLAlchemy engine and connections](https://docs.sqlalchemy.org/en/21/core/connections.html)
|
|
||||||
- [SQLAlchemy asyncio extension](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html)
|
|
||||||
- [Python async context managers and AsyncExitStack](https://docs.python.org/3/library/contextlib.html)
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
# Async SQLAlchemy Engine
|
|
||||||
|
|
||||||
!!! info "Primary sources"
|
|
||||||
- [SQLAlchemy connections](https://docs.sqlalchemy.org/en/21/core/connections.html)
|
|
||||||
- [SQLAlchemy asyncio extension](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html)
|
|
||||||
- [SQLAlchemy pooling and multiprocessing](https://docs.sqlalchemy.org/en/21/core/pooling.html#pooling-multiprocessing)
|
|
||||||
- [FastAPI lifespan events](https://fastapi.tiangolo.com/advanced/events/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Engine Ownership Model
|
|
||||||
|
|
||||||
Create one async engine per process per database URL and keep it for the app lifetime.
|
|
||||||
|
|
||||||
- SQLAlchemy guidance: the engine is intended as a long-lived, concurrent registry over pooled DB connections, not a per-request object.
|
|
||||||
- In FastAPI, app startup and shutdown ownership belongs in lifespan.
|
|
||||||
- Use `FastAPI(lifespan=...)` (not startup/shutdown events) for modern lifecycle wiring.
|
|
||||||
|
|
||||||
!!! tip "Practical rule"
|
|
||||||
- Exactly one `create_async_engine(...)` call in app bootstrap code.
|
|
||||||
- Zero `create_async_engine(...)` calls in request handlers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Canonical Lifespan Pattern (AsyncExitStack)
|
|
||||||
|
|
||||||
Use `@asynccontextmanager` + `AsyncExitStack` to make teardown deterministic and composable.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from contextlib import AsyncExitStack, asynccontextmanager
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
async with AsyncExitStack() as stack:
|
|
||||||
engine: AsyncEngine = create_async_engine(
|
|
||||||
app.state.settings.database_url,
|
|
||||||
pool_pre_ping=True,
|
|
||||||
# Optional examples:
|
|
||||||
# echo=app.state.settings.sql_echo,
|
|
||||||
# pool_size=10,
|
|
||||||
# max_overflow=20,
|
|
||||||
)
|
|
||||||
app.state.engine = engine
|
|
||||||
|
|
||||||
# Ensure engine disposal always runs at shutdown.
|
|
||||||
stack.push_async_callback(engine.dispose)
|
|
||||||
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
|
||||||
```
|
|
||||||
|
|
||||||
Why this pattern:
|
|
||||||
- FastAPI executes code before `yield` at startup and after `yield` at shutdown.
|
|
||||||
- `AsyncExitStack` lets you register multiple async cleanups in one place while preserving order.
|
|
||||||
- Explicit disposal (directly awaited or via `AsyncExitStack` callback) avoids event-loop-closed warnings when objects fall out of scope.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Driver URLs (Project Requirement: asyncpg + aiosqlite)
|
|
||||||
|
|
||||||
Use SQLAlchemy async driver URLs:
|
|
||||||
|
|
||||||
- PostgreSQL: `postgresql+asyncpg://user:pass@host:5432/dbname`
|
|
||||||
- SQLite: `sqlite+aiosqlite:///./app.db`
|
|
||||||
|
|
||||||
!!! warning "Driver compatibility"
|
|
||||||
- Do not mix sync drivers, for example `psycopg2`, with `create_async_engine()`.
|
|
||||||
- Keep URL construction centralized in settings/config, not in feature modules.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pooling Defaults and Tuning
|
|
||||||
|
|
||||||
Default behavior is usually correct first:
|
|
||||||
|
|
||||||
- Async engines use async-compatible pooling (`AsyncAdaptedQueuePool`) by default.
|
|
||||||
- Start with defaults, then tune from observed load (`pool_size`, `max_overflow`, `pool_timeout`, `pool_recycle`).
|
|
||||||
- Enable `pool_pre_ping=True` for safer stale-connection handling in long-running services.
|
|
||||||
|
|
||||||
When to switch pool strategy:
|
|
||||||
|
|
||||||
- `NullPool` if you explicitly need no pooling (special environments, some tests, or strict cross-loop constraints).
|
|
||||||
- Keep in mind this increases connect/disconnect churn.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Disposal Semantics
|
|
||||||
|
|
||||||
`engine.dispose()` replaces/disposes the pool, but only checked-in connections are immediately closed.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Dispose when the app is shutting down.
|
|
||||||
- Dispose before reusing an engine across event loops.
|
|
||||||
- In forked child-process initialization, use `engine.dispose(close=False)` (sync API guidance) so child processes do not touch parent-held connections.
|
|
||||||
|
|
||||||
Avoid relying on garbage collection for engine cleanup in async code.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Event Loop and Process Boundaries
|
|
||||||
|
|
||||||
Do not share pooled connections across boundaries:
|
|
||||||
|
|
||||||
- Multiple event loops: do not reuse the same pooled async engine across loops unless you intentionally disable pooling (`NullPool`) or dispose before handoff.
|
|
||||||
- Multiprocessing/fork: pooled connections must not be inherited for active use across process boundaries.
|
|
||||||
|
|
||||||
This prevents broken socket state and cross-process connection corruption.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What Not to Do
|
|
||||||
|
|
||||||
- Create an engine inside every request dependency.
|
|
||||||
- Create/dispose engines inside repository methods.
|
|
||||||
- Keep engine creation as a hidden side effect of import-time module globals.
|
|
||||||
- Use deprecated FastAPI startup/shutdown events together with lifespan.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Engine Design Checklist
|
|
||||||
|
|
||||||
- One engine per process per DB URL.
|
|
||||||
- Engine created in lifespan startup.
|
|
||||||
- Engine disposed in lifespan shutdown.
|
|
||||||
- Async driver URL matches backend (`asyncpg` or `aiosqlite`).
|
|
||||||
- Pooling strategy is explicit for non-default needs.
|
|
||||||
- No request-path engine creation.
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
# Async SQLAlchemy Session Management
|
|
||||||
|
|
||||||
!!! info "Primary sources"
|
|
||||||
- [SQLAlchemy asyncio extension](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html)
|
|
||||||
- [SQLAlchemy session basics](https://docs.sqlalchemy.org/en/21/orm/session_basics.html)
|
|
||||||
- [FastAPI dependencies with yield](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield/)
|
|
||||||
|
|
||||||
??? abstract "Decision metadata"
|
|
||||||
- Status: adopted
|
|
||||||
- Decision level: mandatory
|
|
||||||
- Applies to: api-runtime, workers, tests
|
|
||||||
- Last reviewed: 2026-06-17
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Define one canonical session model for FastAPI + SQLAlchemy asyncio:
|
|
||||||
|
|
||||||
- configure one shared session factory,
|
|
||||||
- create one AsyncSession per request or per unit-of-work,
|
|
||||||
- never share one AsyncSession across concurrent tasks.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope and Non-Goals
|
|
||||||
|
|
||||||
- In scope: session factory creation, FastAPI dependency wiring, request/task scoping, transaction demarcation.
|
|
||||||
- Out of scope: ORM model design, query optimization strategy, schema migration tooling.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Create `async_sessionmaker` once from app-owned AsyncEngine.
|
|
||||||
- Use a fresh AsyncSession for each request or explicit unit-of-work.
|
|
||||||
- Do not share AsyncSession across `asyncio.gather()` or parallel tasks.
|
|
||||||
- Prefer direct dependency injection over global scoped-session patterns in new code.
|
|
||||||
- Use explicit transaction boundaries (`async with session.begin():`) for writes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Canonical FastAPI Dependency Pattern
|
|
||||||
|
|
||||||
```python
|
|
||||||
from collections.abc import AsyncIterator
|
|
||||||
|
|
||||||
from fastapi import Depends, Request
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
||||||
|
|
||||||
|
|
||||||
def get_session_factory(request: Request) -> async_sessionmaker[AsyncSession]:
|
|
||||||
return request.app.state.session_factory
|
|
||||||
|
|
||||||
|
|
||||||
async def get_db_session(
|
|
||||||
session_factory: async_sessionmaker[AsyncSession] = Depends(get_session_factory),
|
|
||||||
) -> AsyncIterator[AsyncSession]:
|
|
||||||
async with session_factory() as session:
|
|
||||||
yield session
|
|
||||||
```
|
|
||||||
|
|
||||||
Route usage:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/items")
|
|
||||||
async def create_item(session: AsyncSession = Depends(get_db_session)) -> dict:
|
|
||||||
async with session.begin():
|
|
||||||
# write operations here
|
|
||||||
...
|
|
||||||
return {"status": "ok"}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration Guidance
|
|
||||||
|
|
||||||
Typical session factory setup:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
||||||
|
|
||||||
session_factory = async_sessionmaker(
|
|
||||||
engine,
|
|
||||||
class_=AsyncSession,
|
|
||||||
expire_on_commit=False,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- `expire_on_commit=False` is commonly preferred in asyncio applications to reduce accidental post-commit reload behavior.
|
|
||||||
- `AsyncSession.refresh()` is preferred over broad expiration patterns when state refresh is needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Concurrency Rules
|
|
||||||
|
|
||||||
- One session per concurrent task.
|
|
||||||
- If work fans out into parallel tasks, each task receives its own AsyncSession.
|
|
||||||
- Pass sessions explicitly to service functions; avoid mutable global session state.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Anti-Patterns
|
|
||||||
|
|
||||||
- A singleton/global AsyncSession reused across requests.
|
|
||||||
- Sharing one AsyncSession across parallel tasks.
|
|
||||||
- Hidden session creation in lower repository helpers with no caller control.
|
|
||||||
- Mixing commit/rollback ownership across layers without a declared boundary.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Operational Checks
|
|
||||||
|
|
||||||
- Exactly one `async_sessionmaker` is registered in app lifecycle.
|
|
||||||
- Request handlers receive sessions from one canonical dependency.
|
|
||||||
- No code path creates AsyncSession in module import side effects.
|
|
||||||
- Background jobs and API handlers each create task-local sessions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checks
|
|
||||||
|
|
||||||
- Dependency override exists for test session factory.
|
|
||||||
- Rollback behavior is verified for failed write units.
|
|
||||||
- Parallel-task tests verify no shared AsyncSession instances.
|
|
||||||
- Lifespan tests confirm session factory is initialized and teardown-safe.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
- If current code uses global/shared sessions, fix scope first before refactoring query style.
|
|
||||||
- If legacy sync patterns are present, keep session boundary rules stable while migrating incrementally.
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
---
|
|
||||||
name: nicegui-ui-customization
|
|
||||||
description: 'Design and implement production NiceGUI UIs with reusable components, Tailwind-first styling, event-driven interactions, and troubleshooting for uploads, state, and static assets. Use when building or refactoring NiceGUI pages and interaction flows.'
|
|
||||||
x-personal-mcp:
|
|
||||||
id: nicegui-ui-customization
|
|
||||||
version: 1.0.0
|
|
||||||
tags:
|
|
||||||
- nicegui
|
|
||||||
- fastapi
|
|
||||||
- ui
|
|
||||||
- customization
|
|
||||||
- frontend
|
|
||||||
capabilities:
|
|
||||||
- resource://skills/nicegui-ui-customization/document
|
|
||||||
---
|
|
||||||
|
|
||||||
# NiceGUI UI Customization Workflow
|
|
||||||
|
|
||||||
Create, style, and ship production NiceGUI UI flows with a repeatable process. The workflow keeps structure in Python, favors Tailwind and Quasar APIs for styling, and uses event-driven interaction patterns over ad-hoc polling.
|
|
||||||
|
|
||||||
## When To Use
|
|
||||||
|
|
||||||
- Building a new NiceGUI page or dashboard
|
|
||||||
- Refactoring a page into reusable components
|
|
||||||
- Adding file upload, form submission, live status, or background-job UX
|
|
||||||
- Troubleshooting race conditions, stale assets, or inconsistent state updates
|
|
||||||
|
|
||||||
## Target Outcome
|
|
||||||
|
|
||||||
Deliver a responsive, accessible UI flow that:
|
|
||||||
|
|
||||||
- keeps clear boundaries between page adapters, reusable components, and services
|
|
||||||
- uses Tailwind-first styling with minimal custom CSS
|
|
||||||
- updates UI through events and bindings
|
|
||||||
- has validation, user feedback, and failure handling
|
|
||||||
- passes a production-readiness check at the end
|
|
||||||
|
|
||||||
## Progressive Loading References
|
|
||||||
|
|
||||||
Load these references only when needed:
|
|
||||||
|
|
||||||
- Architecture and styling rules: [architecture and styling](./references/architecture-and-styling.md)
|
|
||||||
- Event and state interaction patterns: [interaction patterns](./references/interaction-patterns.md)
|
|
||||||
- Troubleshooting and release gates: [troubleshooting and quality gates](./references/troubleshooting-and-quality-gates.md)
|
|
||||||
|
|
||||||
## Procedure
|
|
||||||
|
|
||||||
### 1. Define the UI Slice
|
|
||||||
|
|
||||||
- Capture the user-visible outcome for this task in one sentence.
|
|
||||||
- Identify route-level page modules to touch.
|
|
||||||
- Identify service operations needed by the UI.
|
|
||||||
|
|
||||||
Completion check:
|
|
||||||
|
|
||||||
- You can name the target page, component candidates, and service calls before coding.
|
|
||||||
|
|
||||||
### 2. Choose Component Extraction Strategy
|
|
||||||
|
|
||||||
Decision point:
|
|
||||||
|
|
||||||
- If a layout pattern appears in 2 or more pages, extract it to `ui/components/`.
|
|
||||||
- If a pattern is page-specific, keep it in the page module.
|
|
||||||
|
|
||||||
Completion check:
|
|
||||||
|
|
||||||
- Reused UI patterns are encapsulated as callable components.
|
|
||||||
|
|
||||||
### 3. Build Responsive Layout First
|
|
||||||
|
|
||||||
- Use Tailwind utility classes for structure and spacing.
|
|
||||||
- Use responsive breakpoints (`sm:`, `md:`, `lg:`).
|
|
||||||
- Reserve `.style()` for dynamic values that cannot be expressed with classes.
|
|
||||||
|
|
||||||
Completion check:
|
|
||||||
|
|
||||||
- Layout works at mobile and desktop widths without custom CSS overrides.
|
|
||||||
|
|
||||||
### 4. Add Reactive State And Events
|
|
||||||
|
|
||||||
- Use bindable dataclasses for local page state.
|
|
||||||
- Prefer event handlers (`on_click`, `on_upload`, etc.) over periodic polling.
|
|
||||||
- Trigger explicit refreshes with `@ui.refreshable` where needed.
|
|
||||||
|
|
||||||
Decision point by interaction type:
|
|
||||||
|
|
||||||
- File upload: validate size/type, delegate storage to a service, notify success/failure.
|
|
||||||
- Form submit: bind inputs to dataclass fields, validate in service layer, clear state on success.
|
|
||||||
- Real-time status: use SSE or WebSocket for push updates.
|
|
||||||
- Long jobs: run in background task, update status endpoint or stream.
|
|
||||||
|
|
||||||
Completion check:
|
|
||||||
|
|
||||||
- Every user action has explicit positive and negative feedback via `ui.notify()`.
|
|
||||||
|
|
||||||
### 5. Apply Styling Strategy
|
|
||||||
|
|
||||||
Preferred order:
|
|
||||||
|
|
||||||
1. Tailwind utility classes
|
|
||||||
2. Quasar props
|
|
||||||
3. Reusable styled component functions
|
|
||||||
|
|
||||||
Only if absolutely necessary:
|
|
||||||
|
|
||||||
- Load minimal custom CSS once at startup in `bootstrap.py`.
|
|
||||||
- Keep custom CSS tokenized (variables) and documented.
|
|
||||||
|
|
||||||
Completion check:
|
|
||||||
|
|
||||||
- Styling is mostly class/props-driven and not dependent on scattered ad-hoc CSS.
|
|
||||||
|
|
||||||
### 6. Harden Against Common Failures
|
|
||||||
|
|
||||||
- Prevent duplicate submissions by disabling controls during in-flight operations.
|
|
||||||
- Avoid overlapping timers for the same state target.
|
|
||||||
- Serialize dependent updates (`await` service call before mutation/render).
|
|
||||||
- Verify static mount paths and cache behavior for changed assets.
|
|
||||||
|
|
||||||
Completion check:
|
|
||||||
|
|
||||||
- Race conditions and stale asset symptoms are addressed with explicit safeguards.
|
|
||||||
|
|
||||||
### 7. Final Production Readiness Review
|
|
||||||
|
|
||||||
Pass all checks:
|
|
||||||
|
|
||||||
- Structure: pages, components, services follow one-way dependency flow.
|
|
||||||
- Responsiveness: tested at small and large viewport widths.
|
|
||||||
- Accessibility: labels, button text, and action visibility are clear.
|
|
||||||
- Reliability: validation and exception paths produce user-facing notifications.
|
|
||||||
- Maintainability: repeated UI patterns are extracted; business logic stays in services.
|
|
||||||
|
|
||||||
If any check fails, return to the relevant step and iterate.
|
|
||||||
|
|
||||||
## Completion Contract
|
|
||||||
|
|
||||||
This workflow is complete when:
|
|
||||||
|
|
||||||
- the page flow meets the target outcome
|
|
||||||
- architecture boundaries are preserved
|
|
||||||
- chosen interaction pattern is implemented with explicit success and failure feedback
|
|
||||||
- troubleshooting checks pass
|
|
||||||
- production-readiness gate passes
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# Architecture and Styling Reference
|
|
||||||
|
|
||||||
## Project Boundaries
|
|
||||||
|
|
||||||
Use this dependency direction:
|
|
||||||
|
|
||||||
- pages import components and services
|
|
||||||
- components contain presentation logic only
|
|
||||||
- services contain business logic and do not import UI
|
|
||||||
- static assets are mounted and loaded once at bootstrap
|
|
||||||
|
|
||||||
Suggested module split:
|
|
||||||
|
|
||||||
```text
|
|
||||||
src/app/
|
|
||||||
ui/pages/
|
|
||||||
ui/components/
|
|
||||||
ui/static/
|
|
||||||
services/
|
|
||||||
api/
|
|
||||||
bootstrap.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Component Extraction Rules
|
|
||||||
|
|
||||||
Extract to ui/components when a pattern appears in two or more pages.
|
|
||||||
|
|
||||||
Keep in-page if the layout is specific to a single route.
|
|
||||||
|
|
||||||
```python
|
|
||||||
def card_section(title: str, content: str) -> ui.card:
|
|
||||||
with ui.card().classes("w-full max-w-md") as card:
|
|
||||||
ui.label(title).classes("text-lg font-bold")
|
|
||||||
ui.label(content).classes("text-gray-600")
|
|
||||||
return card
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tailwind-First Layout Pattern
|
|
||||||
|
|
||||||
Use Tailwind utility classes for structure and spacing.
|
|
||||||
Use breakpoint classes for responsive behavior.
|
|
||||||
Use .style() only for values that must be computed dynamically.
|
|
||||||
|
|
||||||
```python
|
|
||||||
with ui.column().classes("w-full"):
|
|
||||||
with ui.row().classes("w-full gap-4 flex-wrap sm:flex-nowrap"):
|
|
||||||
ui.card().classes("flex-1 min-w-64")
|
|
||||||
ui.card().classes("flex-1 min-w-64")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Styling Decision Order
|
|
||||||
|
|
||||||
1. Tailwind utility classes
|
|
||||||
2. Quasar props
|
|
||||||
3. Reusable styled component functions
|
|
||||||
4. Minimal custom CSS loaded once at bootstrap (only when needed)
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory="src/app/static"), name="static")
|
|
||||||
ui.add_css(open("src/app/static/css/base.css").read())
|
|
||||||
```
|
|
||||||
|
|
||||||
## Static Asset Rules
|
|
||||||
|
|
||||||
- Keep custom CSS small and tokenized with variables.
|
|
||||||
- Avoid per-page CSS injection.
|
|
||||||
- Verify static mount paths and reverse proxy rewrites.
|
|
||||||
|
|
||||||
## Links
|
|
||||||
|
|
||||||
!!! info "Primary sources"
|
|
||||||
- [NiceGUI elements](https://nicegui.io/documentation/element)
|
|
||||||
- [NiceGUI binding properties](https://nicegui.io/documentation/section_binding_properties)
|
|
||||||
- [Tailwind utility-first styling](https://tailwindcss.com/docs/utility-first)
|
|
||||||
- [Quasar components](https://quasar.dev/vue-components)
|
|
||||||
+104
-153
@@ -1,206 +1,157 @@
|
|||||||
---
|
---
|
||||||
name: nicegui
|
name: nicegui
|
||||||
description: 'Design and scaffold a production-ready NiceGUI + FastAPI application architecture. Use for multi-page app planning, package boundaries, optional DB/LangGraph/docs integration, and implementation checklists.'
|
description: 'Reference hub for NiceGUI and FastAPI application structure, typed configuration, ASGI and Uvicorn startup, UI composition, styling, bindable state, interactions, troubleshooting, testing, and source documentation. Use when planning, implementing, reviewing, deploying, or debugging NiceGUI applications; load only the references relevant to the task.'
|
||||||
x-personal-mcp:
|
x-personal-mcp:
|
||||||
id: nicegui
|
id: nicegui
|
||||||
version: 1.0.0
|
version: 2.5.0
|
||||||
tags:
|
tags:
|
||||||
- nicegui
|
- nicegui
|
||||||
- fastapi
|
- fastapi
|
||||||
|
- asgi
|
||||||
|
- uvicorn
|
||||||
|
- pydantic-settings
|
||||||
|
- configuration
|
||||||
|
- deployment
|
||||||
- ui
|
- ui
|
||||||
- architecture
|
- architecture
|
||||||
|
- scaffolding
|
||||||
|
- customization
|
||||||
|
- frontend
|
||||||
|
- testing
|
||||||
|
- source-docs
|
||||||
capabilities:
|
capabilities:
|
||||||
- resource://skills/nicegui/document
|
- resource://skills/nicegui/document
|
||||||
---
|
---
|
||||||
|
|
||||||
# NiceGUI
|
# NiceGUI Reference
|
||||||
|
|
||||||
Design a production-minded NiceGUI + FastAPI architecture with clear boundaries, optional extensions, and a concrete implementation checklist.
|
Use this skill as a progressive reference for NiceGUI applications built with FastAPI. Start with the routing map, load only the material needed for the current question, and reconcile it with the target project's NiceGUI version and established conventions.
|
||||||
|
|
||||||
## When to Use
|
## When to Use
|
||||||
|
|
||||||
- You need a reusable architecture plan before implementing a NiceGUI app.
|
- Planning or reviewing NiceGUI application structure and FastAPI composition.
|
||||||
- You want FastAPI app-factory structure and lifespan wiring.
|
- Building or refactoring pages, components, layouts, and static assets.
|
||||||
- You need optional guidance for database, LangGraph workflows, or mounted static docs.
|
- Modeling UI state with bindings or bindable dataclasses.
|
||||||
- You want output that is concise, structured, and implementation-ready.
|
- Implementing forms, uploads, refreshes, live updates, or background work.
|
||||||
|
- Diagnosing UI state, concurrency, navigation, or asset problems.
|
||||||
|
- Verifying framework behavior against primary documentation.
|
||||||
|
|
||||||
## Inputs to Collect
|
## How to Use This Skill
|
||||||
|
|
||||||
Collect these inputs up front. If not provided, make safe defaults and state assumptions.
|
1. Classify the request using the discovery map below.
|
||||||
|
2. Load the smallest relevant reference, or at most two references for a mixed concern.
|
||||||
|
3. Inspect the target repository before applying guidance; preserve its sound local patterns.
|
||||||
|
4. Check the pinned NiceGUI and integration versions before relying on version-specific APIs.
|
||||||
|
5. Validate the changed behavior with focused tests and, for UI work, relevant viewport checks.
|
||||||
|
|
||||||
- Product scope and primary user journeys.
|
## Progressive Discovery Map
|
||||||
- Required pages and route map.
|
|
||||||
- Whether persistent data is required.
|
|
||||||
- Whether AI orchestration (multi-step, streaming, approvals) is required.
|
|
||||||
- Whether generated docs should be mounted in-app.
|
|
||||||
- Runtime/deployment constraints (single service vs split services, environment requirements).
|
|
||||||
|
|
||||||
## Outcome
|
### Application Architecture
|
||||||
|
|
||||||
Produce:
|
Load [application architecture](./references/architecture.md) for:
|
||||||
|
|
||||||
- A concise architecture explanation.
|
- FastAPI app factories and lifespan ownership
|
||||||
- How core services, UI pages, and UI components fit together.
|
- package boundaries and dependency direction
|
||||||
- Explicit decision on DB ownership or involvement.
|
- page registration and health routes
|
||||||
- Explicit decision on AI workflow (or no AI).
|
- optional persistence, LangGraph, or mounted documentation
|
||||||
- A checklist implementation plan organized by package and domain.
|
- async responsiveness and baseline tests
|
||||||
|
|
||||||
## Procedure
|
### FastAPI And Uvicorn Startup
|
||||||
|
|
||||||
1. Frame the baseline architecture.
|
Load [FastAPI and Uvicorn startup](./references/fastapi-uvicorn-startup.md) for:
|
||||||
2. Choose optional extensions (DB, AI, docs) using decision points below.
|
|
||||||
3. Map modules, dependencies, and key boundaries.
|
|
||||||
4. Define async behavior and UI responsiveness expectations.
|
|
||||||
5. Define key functions/classes and configuration surfaces.
|
|
||||||
6. Produce phased checklist with rollout or migration notes when relevant.
|
|
||||||
7. Run completion checks before returning.
|
|
||||||
|
|
||||||
### 1) Baseline architecture
|
- choosing between `ui.run()` and `ui.run_with()`
|
||||||
|
- understanding the parent FastAPI app and NiceGUI's internal app
|
||||||
|
- composing ASGI lifespan and mounted routes
|
||||||
|
- loading one typed settings snapshot for server and application configuration
|
||||||
|
- serving an app instance or factory with Uvicorn
|
||||||
|
- exposing programmatic startup through `[project.scripts]`
|
||||||
|
- reload, worker, and process-local state constraints
|
||||||
|
|
||||||
Use a src-layout with FastAPI as the ASGI app and NiceGUI registered via composition.
|
### Components And Styling
|
||||||
|
|
||||||
- App factory pattern: `create_app()`.
|
Load [architecture and styling](./references/architecture-and-styling.md) for:
|
||||||
- Lifespan for startup and shutdown resource management.
|
|
||||||
- `api/` for HTTP handlers, `services/` for business logic.
|
|
||||||
- `ui/pages/` for page modules, `ui/components/` for shared UI.
|
|
||||||
- Health endpoint on FastAPI side: `/healthz`.
|
|
||||||
|
|
||||||
Recommended base shape:
|
- page, component, and service boundaries
|
||||||
|
- component extraction decisions
|
||||||
|
- Quasar props, Tailwind utilities, and custom CSS boundaries
|
||||||
|
- responsive layout and static asset conventions
|
||||||
|
- Tailwind and Quasar breakpoint scales, container queries, and responsive testing
|
||||||
|
- uniformly scaling dialogs on mobile
|
||||||
|
- preserving Quasar field proportions
|
||||||
|
- keeping detached `QSelect` menus anchored
|
||||||
|
- sizing scrollable dialog cards under CSS `zoom`
|
||||||
|
- validating zoomed controls with Playwright or a browser
|
||||||
|
|
||||||
```text
|
### Bindable State
|
||||||
.
|
|
||||||
├─ pyproject.toml
|
|
||||||
├─ .env.example
|
|
||||||
├─ README.md
|
|
||||||
├─ src/
|
|
||||||
│ └─ app/
|
|
||||||
│ ├─ __init__.py
|
|
||||||
│ ├─ main.py
|
|
||||||
│ ├─ bootstrap.py
|
|
||||||
│ ├─ config.py
|
|
||||||
│ ├─ logging.py
|
|
||||||
│ ├─ api/
|
|
||||||
│ │ ├─ __init__.py
|
|
||||||
│ │ └─ health.py
|
|
||||||
│ ├─ services/
|
|
||||||
│ │ ├─ __init__.py
|
|
||||||
│ │ └─ example_service.py
|
|
||||||
│ └─ ui/
|
|
||||||
│ ├─ __init__.py
|
|
||||||
│ ├─ components/
|
|
||||||
│ │ ├─ __init__.py
|
|
||||||
│ │ └─ nav.py
|
|
||||||
│ └─ pages/
|
|
||||||
│ ├─ __init__.py
|
|
||||||
│ ├─ home.py
|
|
||||||
│ ├─ dashboard.py
|
|
||||||
│ └─ about.py
|
|
||||||
└─ tests/
|
|
||||||
├─ test_health.py
|
|
||||||
└─ test_pages_registration.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2) Decision points
|
Load [bindable dataclasses](./references/binding-dataclasses.md) for:
|
||||||
|
|
||||||
#### Database needed?
|
- typed local UI state
|
||||||
|
- propagation and refresh behavior
|
||||||
|
- nested structures and strict bindings
|
||||||
|
- mutable defaults, performance, and version notes
|
||||||
|
|
||||||
- If no: keep `services/` pure and skip persistence layers.
|
### Interaction Patterns
|
||||||
- If yes: add `db/` package with engine/session/model/repository layering.
|
|
||||||
- Prefer one process-level engine and request-scoped sessions via `yield`.
|
|
||||||
- Prefer Alembic migrations for schema changes.
|
|
||||||
|
|
||||||
#### AI workflow needed?
|
Load [interaction patterns](./references/interaction-patterns.md) for:
|
||||||
|
|
||||||
- If no: keep `services/` focused on app logic only.
|
- uploads and form submission
|
||||||
- If yes: add `ai/` package (state, nodes, graph, runtime, contracts).
|
- explicit refreshes
|
||||||
- Keep graph internals out of `ui/pages/` and API handlers.
|
- server-sent events and WebSockets
|
||||||
- Use stable thread/session IDs for resumable flows.
|
- background work and duplicate-submission guards
|
||||||
|
|
||||||
#### Mounted docs needed?
|
### Troubleshooting And Quality
|
||||||
|
|
||||||
- If no: skip docs mounting.
|
Load [troubleshooting and quality gates](./references/troubleshooting-and-quality-gates.md) for:
|
||||||
- If yes: mount generated static site under configurable route (default `/docs`).
|
|
||||||
- Keep docs mounting in composition layer, not page modules.
|
|
||||||
|
|
||||||
### 3) Page and component registration
|
- upload failures and UI race conditions
|
||||||
|
- stale assets and navigation drift
|
||||||
|
- responsiveness, accessibility, reliability, and maintainability checks
|
||||||
|
|
||||||
- Require at minimum page modules for `/`, `/dashboard`, `/about`.
|
### Primary Sources
|
||||||
- Prefer explicit registration pattern:
|
|
||||||
- `ui/pages/__init__.py` exports `register_pages()`.
|
|
||||||
- Each page module exports `register_page()`.
|
|
||||||
- Shared shell components (header/nav/drawer) live in `ui/components/`.
|
|
||||||
|
|
||||||
### 4) Dependency direction rules
|
Load [source documentation](./references/source-documentation.md) when:
|
||||||
|
|
||||||
Prefer:
|
- behavior is version-sensitive or uncertain
|
||||||
|
- an integration recommendation needs verification
|
||||||
|
- upstream NiceGUI, FastAPI, Tailwind, Quasar, SQLAlchemy, Pydantic, or LangGraph documentation is required
|
||||||
|
|
||||||
- `main/bootstrap` -> `config/logging` + `api` + `ui/pages` + `services`
|
## Common Discovery Paths
|
||||||
- `api` -> `services`
|
|
||||||
- `ui/pages` -> `ui/components` + `services`
|
|
||||||
- `services` -> helpers/clients (and `db/` when enabled)
|
|
||||||
|
|
||||||
Avoid reverse imports from services into API or UI modules.
|
### New Application Or Architecture Review
|
||||||
|
|
||||||
### 5) Async and UI responsiveness rules
|
1. Load [application architecture](./references/architecture.md).
|
||||||
|
2. Add [FastAPI and Uvicorn startup](./references/fastapi-uvicorn-startup.md) when FastAPI owns the application or startup must be exposed as a project command.
|
||||||
|
3. Add [architecture and styling](./references/architecture-and-styling.md) only when page and component design is in scope.
|
||||||
|
|
||||||
- Prefer `async def` for page handlers, service methods, and integrations when the call path includes I/O.
|
### Page Or Component Work
|
||||||
- Use non-blocking clients/libraries where possible so long-running I/O does not freeze UI updates.
|
|
||||||
- Do not run blocking calls (`time.sleep`, blocking HTTP/database clients) in UI event handlers.
|
|
||||||
- For heavy CPU work, offload to worker/background execution and keep the UI loop free.
|
|
||||||
- Show progress states for long actions (disable action button, show spinner/progress text, re-enable on completion).
|
|
||||||
- Stream or chunk incremental results to the UI when workflows are multi-step or long-running.
|
|
||||||
- Keep cancellation and timeout behavior explicit for user-triggered long tasks.
|
|
||||||
- Ensure exceptions from async tasks are surfaced with user-friendly feedback and logged for diagnostics.
|
|
||||||
|
|
||||||
### 6) Testing minimums
|
1. Load [architecture and styling](./references/architecture-and-styling.md).
|
||||||
|
2. Add [interaction patterns](./references/interaction-patterns.md) or [bindable dataclasses](./references/binding-dataclasses.md) according to the page behavior.
|
||||||
|
|
||||||
- Test FastAPI health route behavior.
|
### Debugging Or Production Review
|
||||||
- Test page registration wiring.
|
|
||||||
- If DB enabled: session lifecycle and rollback behavior tests.
|
|
||||||
- If AI enabled: graph happy path and interrupt/resume coverage.
|
|
||||||
- If docs enabled: mounted docs route returns index page.
|
|
||||||
- For async flows: test long-running actions preserve UI responsiveness (loading state, completion state, and error state).
|
|
||||||
|
|
||||||
### 7) Styling architecture
|
1. Start with [troubleshooting and quality gates](./references/troubleshooting-and-quality-gates.md).
|
||||||
|
2. Follow the symptom to one detailed reference.
|
||||||
|
3. Confirm uncertain behavior in [source documentation](./references/source-documentation.md).
|
||||||
|
|
||||||
- Keep structure and layout in Python modules using NiceGUI class composition.
|
## General Defaults
|
||||||
- Keep visual polish in shared CSS files, loaded once at startup.
|
|
||||||
- Prefer semantic reusable classes over ad hoc per-page styling.
|
|
||||||
|
|
||||||
## Completion Checks
|
- Keep composition, transport, services, pages, and components directionally separated.
|
||||||
|
- Keep business logic out of UI components and event handlers.
|
||||||
|
- Avoid blocking I/O and CPU-heavy work in the UI event loop.
|
||||||
|
- Prefer event-driven updates and explicit refreshes over unrelated polling.
|
||||||
|
- Prefer Tailwind utilities, then Quasar props, then reusable component helpers; use minimal shared CSS when those are insufficient.
|
||||||
|
- Provide loading, success, and failure states for user-triggered work.
|
||||||
|
- Treat version-specific guidance as a prompt to verify the project's dependency version.
|
||||||
|
|
||||||
- Uses app factory and FastAPI lifespan.
|
## Reference Use Contract
|
||||||
- Pages are modularized (not single-file UI).
|
|
||||||
- Health endpoint exists on FastAPI side.
|
|
||||||
- Dependency direction is clean and one-way.
|
|
||||||
- Async-first guidance is applied where I/O exists, with explicit non-blocking UX states.
|
|
||||||
- Optional DB/AI/docs decisions are explicit and reflected in structure.
|
|
||||||
- Output includes architecture summary and package-organized checklist.
|
|
||||||
|
|
||||||
## Output Contract
|
When applying this skill:
|
||||||
|
|
||||||
Return:
|
- return only guidance relevant to the current task
|
||||||
|
- distinguish repository facts from reference recommendations
|
||||||
- Concise high-level architecture.
|
- cite the appropriate source reference for framework-level claims
|
||||||
- How core services, pages, and shared components fit.
|
- state assumptions when application requirements are missing
|
||||||
- DB involvement and ownership stance.
|
- report the focused checks used to validate implementation changes
|
||||||
- AI workflow stance and runtime flow.
|
|
||||||
- Checklist plan by package and domain:
|
|
||||||
- key functions/classes
|
|
||||||
- settings/config surfaces
|
|
||||||
- rollout/migration notes (when relevant)
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- Do not collapse all pages into one file.
|
|
||||||
- Do not use globals or implicit global side effects.
|
|
||||||
- Do not block UI event handlers with synchronous I/O or long CPU tasks.
|
|
||||||
- Always define loading/progress/error states for long user-triggered actions.
|
|
||||||
- Keep code minimal but production-minded.
|
|
||||||
- Prefer clarity and maintainability over clever abstractions.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- Architecture and integration details: [NiceGUI architecture reference](./references/architecture.md)
|
|
||||||
- Dataclass binding deep dive: [Bindable dataclasses in NiceGUI](./references/binding-dataclasses.md)
|
|
||||||
- Source documentation links: [NiceGUI source documentation](./references/source-documentation.md)
|
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
# NiceGUI Page Layout And Styling
|
||||||
|
|
||||||
|
Use this reference to structure NiceGUI pages, choose component boundaries, apply responsive layout, and introduce custom CSS without fighting Quasar's internal geometry.
|
||||||
|
|
||||||
|
## Ownership And Dependency Boundaries
|
||||||
|
|
||||||
|
Keep dependencies flowing in one direction:
|
||||||
|
|
||||||
|
- pages import components and services
|
||||||
|
- components contain presentation logic only
|
||||||
|
- services contain business logic and do not import UI
|
||||||
|
- bootstrap code mounts static assets and loads shared CSS once
|
||||||
|
|
||||||
|
Suggested module split:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/my_app/
|
||||||
|
ui/
|
||||||
|
pages/
|
||||||
|
components/
|
||||||
|
static/
|
||||||
|
services/
|
||||||
|
api/
|
||||||
|
```
|
||||||
|
|
||||||
|
Page modules should compose a route from reusable presentation and service calls. They should not own domain rules, persistence, or long-running synchronous work.
|
||||||
|
|
||||||
|
## Page Composition
|
||||||
|
|
||||||
|
Build the outer layout before styling individual controls:
|
||||||
|
|
||||||
|
1. Define the page shell and width constraints.
|
||||||
|
2. Establish responsive rows, columns, gaps, and wrapping.
|
||||||
|
3. Add semantic sections and repeated components.
|
||||||
|
4. Configure Quasar component appearance with props.
|
||||||
|
5. Add custom CSS only for behavior that props and utilities cannot express safely.
|
||||||
|
|
||||||
|
```python
|
||||||
|
with ui.column().classes("w-full max-w-6xl mx-auto gap-6 px-4"):
|
||||||
|
page_header(title="Inventory")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full gap-4 flex-wrap lg:flex-nowrap items-start"):
|
||||||
|
filters_panel().classes("w-full lg:w-72 shrink-0")
|
||||||
|
item_grid().classes("w-full flex-1 min-w-0")
|
||||||
|
```
|
||||||
|
|
||||||
|
Use stable width, minimum-width, and flex constraints so labels, icons, validation messages, and loaded content do not shift the surrounding layout.
|
||||||
|
|
||||||
|
## Component Extraction
|
||||||
|
|
||||||
|
Extract a presentation pattern to `ui/components/` when it appears on two or more pages or when it owns a meaningful interaction boundary. Keep one-off route layout in the page module.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def card_section(title: str, content: str) -> ui.card:
|
||||||
|
with ui.card().classes("w-full max-w-md") as card:
|
||||||
|
ui.label(title).classes("text-lg font-bold")
|
||||||
|
ui.label(content).classes("text-gray-600")
|
||||||
|
return card
|
||||||
|
```
|
||||||
|
|
||||||
|
Reusable components should accept data and event callbacks rather than import page state or business services implicitly.
|
||||||
|
|
||||||
|
## Styling Decision Order
|
||||||
|
|
||||||
|
NiceGUI wraps Quasar components. Choose the styling mechanism according to what it owns:
|
||||||
|
|
||||||
|
1. Use Quasar props for component appearance, density, labels, and popup behavior.
|
||||||
|
2. Use NiceGUI `.classes()` and Tailwind utilities for width, spacing, alignment, and responsive layout.
|
||||||
|
3. Use reusable component functions for repeated visual patterns.
|
||||||
|
4. Use `.style()` for genuinely dynamic inline values.
|
||||||
|
5. Use minimal shared CSS only when props and utilities are insufficient.
|
||||||
|
|
||||||
|
Common Quasar props include:
|
||||||
|
|
||||||
|
- `outlined`
|
||||||
|
- `dense`
|
||||||
|
- `stack-label`
|
||||||
|
- `popup-content-class`
|
||||||
|
- `input-class`
|
||||||
|
- `input-style`
|
||||||
|
|
||||||
|
Avoid overriding internal selectors such as:
|
||||||
|
|
||||||
|
- `.q-field__label`
|
||||||
|
- `.q-field__native`
|
||||||
|
- `.q-field__control`
|
||||||
|
- `.q-field__input`
|
||||||
|
|
||||||
|
Quasar coordinates field height, padding, labels, values, icons, and floating-label transforms. Changing only one internal part tends to cause clipping or overlap.
|
||||||
|
|
||||||
|
## Responsive Layout
|
||||||
|
|
||||||
|
Support these layouts only:
|
||||||
|
|
||||||
|
- mobile: a single-column layout with wrapping toolbars and full-width controls
|
||||||
|
- landscape desktop: $1920 \times 1080$ with side-by-side panels where they improve scanning
|
||||||
|
- portrait desktop: $1080 \times 1920 with stacked panels or a narrow fixed sidebar
|
||||||
|
|
||||||
|
Build the mobile layout first, then add one desktop breakpoint when a row or grid needs more space. Prefer flex wrapping and fluid grids before adding another breakpoint. Use Tailwind classes for page layout and Quasar props for component behavior.
|
||||||
|
|
||||||
|
```python
|
||||||
|
with ui.row().classes('w-full flex-wrap gap-4 lg:flex-nowrap items-start'):
|
||||||
|
filters_panel().classes('w-full lg:w-72 shrink-0')
|
||||||
|
item_grid().classes('w-full flex-1 min-w-0')
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `min-w-0` for flexible children, `flex-wrap` for toolbars, and `max-w-* mx-auto` to keep portrait layouts readable. Do not add device-specific component trees, container queries, or custom breakpoints unless a supported layout demonstrates a concrete failure.
|
||||||
|
|
||||||
|
## Static Assets And Shared CSS
|
||||||
|
|
||||||
|
- Mount static assets from the composition layer.
|
||||||
|
- Load shared CSS once rather than injecting it from individual pages.
|
||||||
|
- Keep custom CSS tokenized with variables and scoped to application classes.
|
||||||
|
- Avoid broad rules against Quasar internals.
|
||||||
|
- Verify mount paths, reverse-proxy rewrites, and cache behavior.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
STATIC_DIR = Path(__file__).parent / "ui" / "static"
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||||
|
ui.add_css((STATIC_DIR / "css" / "base.css").read_text(encoding="utf-8"))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Responsive Dialog Pattern
|
||||||
|
|
||||||
|
Use whole-card scaling when a form dialog must become uniformly larger on mobile while preserving Quasar's internal proportions. Keep detached select menus unscaled and make the card itself scrollable.
|
||||||
|
|
||||||
|
### Use Normal Field Density
|
||||||
|
|
||||||
|
Normal Quasar fields are approximately `56px` high, while dense fields are approximately `40px` high. Remove `dense` when larger controls are needed.
|
||||||
|
|
||||||
|
```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")
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a scoped class to the dialog card:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ui.card().classes("app-detail-card app-item-detail-card")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scale The Complete 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The main mobile tuning knob is:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--item-dialog-scale: 1.2;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keep Detached Popups Unscaled
|
||||||
|
|
||||||
|
Do not apply `zoom` or `transform: scale()` to a `QSelect` popup menu. Quasar renders menus outside the dialog and positions them from the unscaled anchor geometry. Scaling the menu container afterward separates it from its field.
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.app-item-detail-card,
|
||||||
|
.app-item-detail-menu {
|
||||||
|
zoom: 1.2;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.app-item-detail-card {
|
||||||
|
zoom: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-item-detail-menu {
|
||||||
|
font-size: 16.8px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `popup-content-class=app-item-detail-menu` to target the detached menu and enlarge its text without changing its coordinate system.
|
||||||
|
|
||||||
|
### Account For Zoom When Scrolling
|
||||||
|
|
||||||
|
The card's pre-zoom maximum height must account for the scale:
|
||||||
|
|
||||||
|
\[
|
||||||
|
\begin{aligned}
|
||||||
|
h_{\mathrm{pre}} &= \frac{h_{\mathrm{visible}}}{s} \\
|
||||||
|
\text{where } s &= \text{the zoom scale}
|
||||||
|
\end{aligned}
|
||||||
|
\]
|
||||||
|
|
||||||
|
For a desired visual height of `90dvh` at \(1.2\times\):
|
||||||
|
|
||||||
|
\[
|
||||||
|
\frac{90\,\mathrm{dvh}}{1.2} = 75\,\mathrm{dvh}
|
||||||
|
\]
|
||||||
|
|
||||||
|
Therefore:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--item-dialog-max-height: 75dvh;
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply scrolling to the card itself:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.app-item-detail-card {
|
||||||
|
max-height: var(--item-dialog-max-height);
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps the dimmed page stationary while the form scrolls.
|
||||||
|
|
||||||
|
### Match The Quasar Breakpoint
|
||||||
|
|
||||||
|
Quasar's extra-small breakpoint ends at `599.98px`. A mobile-only rule can use:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
/* Mobile rules. */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm custom breakpoint values against the target application's Quasar configuration.
|
||||||
|
|
||||||
|
## Validation Checklist
|
||||||
|
|
||||||
|
Check each completed page at these three viewports:
|
||||||
|
|
||||||
|
1. A representative mobile viewport, such as $390 \times 844$.
|
||||||
|
2. Landscape desktop at $1920 \times 1080$.
|
||||||
|
3. Portrait desktop at $1080 \times 1920$.
|
||||||
|
|
||||||
|
Confirm that page sections do not overlap, toolbars wrap on mobile, desktop panels use the available space without becoming excessively wide, and dialogs remain visible and scroll to their final field.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
!!! info "Primary sources"
|
||||||
|
- [NiceGUI element styling and props](https://nicegui.io/documentation/element)
|
||||||
|
- [NiceGUI binding properties](https://nicegui.io/documentation/section_binding_properties)
|
||||||
|
- [Quasar components](https://quasar.dev/vue-components)
|
||||||
|
- [Quasar field](https://quasar.dev/vue-components/field/)
|
||||||
|
- [Quasar select](https://quasar.dev/vue-components/select/)
|
||||||
|
- [Tailwind responsive design](https://tailwindcss.com/docs/responsive-design)
|
||||||
|
- [MDN `zoom`](https://developer.mozilla.org/en-US/docs/Web/CSS/zoom)
|
||||||
@@ -1,32 +1,77 @@
|
|||||||
# NiceGUI Architecture Reference
|
# NiceGUI Application Architecture
|
||||||
|
|
||||||
This reference expands the workflow in the main skill file and is loaded only when needed.
|
Load this reference for application composition, package boundaries, and optional subsystem decisions.
|
||||||
|
|
||||||
## Baseline package boundaries
|
## Baseline Package Boundaries
|
||||||
|
|
||||||
- `main.py`: process entrypoint only.
|
- `main.py`: process entry point and app factory exposure.
|
||||||
- `bootstrap.py`: app composition, router wiring, page registration, lifespan orchestration.
|
- `bootstrap.py`: app composition, router wiring, page registration, and lifespan orchestration.
|
||||||
- `config.py`: typed settings and env parsing.
|
- `config.py`: typed settings and environment parsing.
|
||||||
- `logging.py`: centralized logging setup.
|
- `logging.py`: centralized logging setup.
|
||||||
- `api/`: HTTP transport layer; delegates to services.
|
- `api/`: HTTP transport that delegates to services.
|
||||||
- `services/`: business/use-case logic.
|
- `services/`: business and use-case logic.
|
||||||
- `ui/pages/`: route-level NiceGUI pages.
|
- `ui/pages/`: route-level NiceGUI pages.
|
||||||
- `ui/components/`: shared UI building blocks.
|
- `ui/components/`: shared presentation building blocks.
|
||||||
|
|
||||||
## Required baseline behavior
|
Recommended base shape:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├─ pyproject.toml
|
||||||
|
├─ .env.example
|
||||||
|
├─ src/
|
||||||
|
│ └─ app/
|
||||||
|
│ ├─ __init__.py
|
||||||
|
│ ├─ main.py
|
||||||
|
│ ├─ bootstrap.py
|
||||||
|
│ ├─ config.py
|
||||||
|
│ ├─ logging.py
|
||||||
|
│ ├─ api/
|
||||||
|
│ │ ├─ __init__.py
|
||||||
|
│ │ └─ health.py
|
||||||
|
│ ├─ services/
|
||||||
|
│ │ ├─ __init__.py
|
||||||
|
│ │ └─ example_service.py
|
||||||
|
│ └─ ui/
|
||||||
|
│ ├─ __init__.py
|
||||||
|
│ ├─ components/
|
||||||
|
│ │ ├─ __init__.py
|
||||||
|
│ │ └─ nav.py
|
||||||
|
│ └─ pages/
|
||||||
|
│ ├─ __init__.py
|
||||||
|
│ ├─ home.py
|
||||||
|
│ ├─ dashboard.py
|
||||||
|
│ └─ about.py
|
||||||
|
└─ tests/
|
||||||
|
├─ test_health.py
|
||||||
|
└─ test_pages_registration.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Required Baseline Behavior
|
||||||
|
|
||||||
- FastAPI is the base ASGI app.
|
- FastAPI is the base ASGI app.
|
||||||
- NiceGUI pages are modular and registered from page modules.
|
- `create_app()` composes routes, resources, and NiceGUI.
|
||||||
- Minimum pages: `/`, `/dashboard`, `/about`.
|
- Lifespan owns startup and shutdown resources.
|
||||||
- FastAPI health route: `/healthz`.
|
- NiceGUI pages are modular and explicitly registered.
|
||||||
- Lifespan handles startup/shutdown resources.
|
- FastAPI exposes a health route such as `/healthz`.
|
||||||
- No global side effects at import time.
|
- Imports do not trigger runtime global side effects.
|
||||||
|
|
||||||
## Optional extension: Database
|
For the ownership relationship between a caller-created FastAPI app, `nicegui.app`, `ui.run_with()`, Uvicorn, and a packaged startup command, load [FastAPI and Uvicorn startup](./fastapi-uvicorn-startup.md).
|
||||||
|
|
||||||
Use only if persistence is required.
|
## Dependency Direction
|
||||||
|
|
||||||
Suggested additions:
|
Prefer:
|
||||||
|
|
||||||
|
- `main/bootstrap` -> `config/logging` + `api` + `ui/pages` + `services`
|
||||||
|
- `api` -> `services`
|
||||||
|
- `ui/pages` -> `ui/components` + `services`
|
||||||
|
- `services` -> helpers, clients, and `db/` when enabled
|
||||||
|
|
||||||
|
Avoid imports from services back into API or UI modules.
|
||||||
|
|
||||||
|
## Optional Persistence
|
||||||
|
|
||||||
|
Use only when the product requires durable data.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/app/db/
|
src/app/db/
|
||||||
@@ -37,19 +82,15 @@ src/app/db/
|
|||||||
└─ repositories/
|
└─ repositories/
|
||||||
```
|
```
|
||||||
|
|
||||||
Guidelines:
|
- Create one engine and sessionmaker per process.
|
||||||
|
- Provide request- or operation-scoped sessions with `yield`.
|
||||||
|
- Keep transaction boundaries explicit in service or repository flows.
|
||||||
|
- Never share sessions across concurrent tasks.
|
||||||
|
- Use Alembic as the schema migration source of truth.
|
||||||
|
|
||||||
- One engine and one sessionmaker per process.
|
## Optional LangGraph AI
|
||||||
- Request-scoped session dependency using `yield`.
|
|
||||||
- Explicit transaction boundaries in service/repository flows.
|
|
||||||
- Avoid shared sessions across concurrent tasks.
|
|
||||||
- Use Alembic as schema source of truth.
|
|
||||||
|
|
||||||
## Optional extension: LangGraph AI
|
Use only for multi-step orchestration, resumable work, streaming, or human approval.
|
||||||
|
|
||||||
Use only for multi-step AI orchestration or human-in-the-loop workflows.
|
|
||||||
|
|
||||||
Suggested additions:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/app/ai/
|
src/app/ai/
|
||||||
@@ -60,33 +101,37 @@ src/app/ai/
|
|||||||
└─ contracts.py
|
└─ contracts.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Guidelines:
|
- Keep graph internals outside API and UI modules.
|
||||||
|
- Invoke graphs through a service such as `services/ai_service.py`.
|
||||||
- Keep graph internals outside API/UI modules.
|
- Use stable thread or session IDs for resumable flows.
|
||||||
- Invoke graph through `services/ai_service.py`.
|
|
||||||
- Use stable thread/session IDs for resumable sessions.
|
|
||||||
- Keep interrupt payloads JSON-serializable.
|
- Keep interrupt payloads JSON-serializable.
|
||||||
|
|
||||||
## Optional extension: Mounted static docs
|
## Optional Mounted Docs
|
||||||
|
|
||||||
Use only when generated docs should be served in-app.
|
Use only when generated docs must be served by the application.
|
||||||
|
|
||||||
Suggested settings:
|
Suggested settings:
|
||||||
|
|
||||||
- `docs_enabled`
|
- `docs_enabled`
|
||||||
- `docs_mount_path`
|
- `docs_mount_path`
|
||||||
- `docs_site_dir`
|
- `docs_site_dir`
|
||||||
- `docs_require_build` (optional)
|
- `docs_require_build`
|
||||||
|
|
||||||
Guidelines:
|
Mount docs in the composition layer, normalize the mount path, avoid route conflicts, and define behavior for missing build artifacts.
|
||||||
|
|
||||||
- Mount docs in composition layer (`bootstrap.py`).
|
## Async And Responsiveness
|
||||||
- Normalize mount path and avoid route conflicts.
|
|
||||||
- Warn on missing build artifacts unless strict mode is enabled.
|
|
||||||
|
|
||||||
## Suggested output quality criteria
|
- Use `async def` where a handler or service path performs I/O.
|
||||||
|
- Prefer non-blocking clients and libraries.
|
||||||
|
- Offload CPU-heavy work to worker or background execution.
|
||||||
|
- Define progress, cancellation, timeout, completion, and error states for long actions.
|
||||||
|
- Stream or chunk results when workflows are long-running or multi-step.
|
||||||
|
|
||||||
- Clear architecture summary with assumptions.
|
## Testing Minimums
|
||||||
- Explicit decisions for DB, AI, and docs.
|
|
||||||
- Package-scoped implementation checklist.
|
- Test the FastAPI health route.
|
||||||
- Minimal test plan aligned to enabled features.
|
- Test page registration wiring.
|
||||||
|
- If persistence is enabled, test session lifecycle and rollback behavior.
|
||||||
|
- If AI is enabled, test happy paths and interrupt/resume behavior.
|
||||||
|
- If docs are enabled, test the mounted index route.
|
||||||
|
- For long actions, test loading, completion, and error states.
|
||||||
@@ -1,119 +1,100 @@
|
|||||||
# Binding Dataclasses Deep Dive
|
# Binding Dataclasses Deep Dive
|
||||||
|
|
||||||
This reference explains how to model state with NiceGUI bindable dataclasses and how to avoid common update and performance pitfalls.
|
Use this reference to model NiceGUI state with bindable dataclasses and avoid common propagation and performance pitfalls.
|
||||||
|
|
||||||
## Primary Sources
|
## Primary Sources
|
||||||
|
|
||||||
- NiceGUI binding docs: [Binding properties](https://www.nicegui.io/documentation/section_binding_properties)
|
- NiceGUI binding docs: [binding properties](https://www.nicegui.io/documentation/section_binding_properties)
|
||||||
- Python dataclass docs: [dataclasses module](https://docs.python.org/3/library/dataclasses.html)
|
- Python dataclass docs: [dataclasses module](https://docs.python.org/3/library/dataclasses.html)
|
||||||
- Data class design rationale: [PEP 557](https://peps.python.org/pep-0557/)
|
- Data class design rationale: [PEP 557](https://peps.python.org/pep-0557/)
|
||||||
|
|
||||||
## What bindable_dataclass changes
|
## Bindable Dataclass Behavior
|
||||||
|
|
||||||
`@binding.bindable_dataclass` extends standard dataclasses by turning fields into bindable properties so UI bindings can propagate immediately when a field is assigned.
|
`@binding.bindable_dataclass` extends standard dataclasses by turning fields into bindable properties, allowing UI bindings to propagate when a field is assigned.
|
||||||
|
|
||||||
Baseline pattern:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from nicegui import binding, ui
|
from nicegui import binding, ui
|
||||||
|
|
||||||
|
|
||||||
@binding.bindable_dataclass
|
@binding.bindable_dataclass
|
||||||
class Profile:
|
class Profile:
|
||||||
name: str = 'Ada'
|
name: str = "Ada"
|
||||||
age: int = 37
|
age: int = 37
|
||||||
|
|
||||||
|
|
||||||
profile = Profile()
|
profile = Profile()
|
||||||
|
|
||||||
ui.input('Name').bind_value(profile, 'name')
|
ui.input("Name").bind_value(profile, "name")
|
||||||
ui.number('Age', min=0).bind_value(profile, 'age')
|
ui.number("Age", min=0).bind_value(profile, "age")
|
||||||
ui.label().bind_text_from(profile, 'name', backward=lambda n: f'User: {n}')
|
ui.label().bind_text_from(profile, "name", backward=lambda name: f"User: {name}")
|
||||||
```
|
```
|
||||||
|
|
||||||
## Propagation model and performance
|
## Propagation And Performance
|
||||||
|
|
||||||
NiceGUI distinguishes between two link types:
|
NiceGUI distinguishes between two link types:
|
||||||
|
|
||||||
- Bindable properties: efficient, event-like propagation on assignment.
|
- Bindable properties propagate efficiently when values are assigned.
|
||||||
- Active links: polled in a refresh loop (default every 0.1s).
|
- Active links are checked in a refresh loop.
|
||||||
|
|
||||||
Practical implications:
|
Prefer bindable dataclasses for frequently updated form state. Keep binding transforms pure and inexpensive. If an application has many active links, tune `binding_refresh_interval` in `ui.run(...)` only after measuring the impact.
|
||||||
|
|
||||||
- Prefer bindable dataclasses for frequently updated form state.
|
## Dataclass Modeling Rules
|
||||||
- Keep transform functions pure and side-effect free.
|
|
||||||
- If many active links exist, tune `binding_refresh_interval` in `ui.run(...)` carefully.
|
|
||||||
|
|
||||||
## Dataclass modeling rules that matter for binding
|
|
||||||
|
|
||||||
- Use `field(default_factory=...)` for mutable defaults.
|
- Use `field(default_factory=...)` for mutable defaults.
|
||||||
- Avoid `frozen=True` for models that should be edited from UI controls.
|
- Avoid `frozen=True` for models edited by UI controls.
|
||||||
- Use `slots=True` only when you have confirmed compatibility with your inheritance and extension needs.
|
- Use `slots=True` only after confirming compatibility with inheritance and extension needs.
|
||||||
- Keep UI-editable fields explicit and typed.
|
- Keep UI-editable fields explicit and typed.
|
||||||
|
|
||||||
Example with safe mutable defaults:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from dataclasses import field
|
from dataclasses import field
|
||||||
|
|
||||||
from nicegui import binding
|
from nicegui import binding
|
||||||
|
|
||||||
|
|
||||||
@binding.bindable_dataclass
|
@binding.bindable_dataclass
|
||||||
class Filters:
|
class Filters:
|
||||||
query: str = ''
|
query: str = ""
|
||||||
tags: list[str] = field(default_factory=list)
|
tags: list[str] = field(default_factory=list)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Nested structures and binding paths
|
## Nested Structures
|
||||||
|
|
||||||
NiceGUI supports nested key paths via tuples for nested data (for example dictionaries and nested structures).
|
NiceGUI supports tuple paths for nested data structures.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
data = {'user': {'name': 'Ada'}}
|
data = {"user": {"name": "Ada"}}
|
||||||
|
|
||||||
ui.input('Name').bind_value(data, ('user', 'name'))
|
ui.input("Name").bind_value(data, ("user", "name"))
|
||||||
ui.label().bind_text_from(data, ('user', 'name'))
|
ui.label().bind_text_from(data, ("user", "name"))
|
||||||
```
|
```
|
||||||
|
|
||||||
When using nested dataclasses, keep updates explicit and predictable at the field level.
|
Keep nested dataclass updates explicit and predictable at the field level.
|
||||||
|
|
||||||
## Strictness and refactor safety
|
## Strictness And Refactor Safety
|
||||||
|
|
||||||
Binding can warn when attributes do not exist.
|
|
||||||
|
|
||||||
- Object attributes are checked by default.
|
- Object attributes are checked by default.
|
||||||
- Dictionary keys are not checked by default.
|
- Dictionary keys are not checked by default.
|
||||||
- Use `strict=True` when you want missing-key warnings for dict-backed state.
|
- Use `strict=True` when missing dictionary keys should produce warnings.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from nicegui import app, ui
|
from nicegui import app, ui
|
||||||
|
|
||||||
ui.input().bind_value(app.storage.user, 'display_name', strict=True)
|
ui.input().bind_value(app.storage.user, "display_name", strict=True)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Common pitfalls and safer alternatives
|
## Common Pitfalls
|
||||||
|
|
||||||
- Pitfall: mutating nested mutable values in place and expecting immediate UI sync.
|
- In-place mutation may not produce immediate UI synchronization. Assign the updated value back to the bound field.
|
||||||
- Safer alternative: assign back to the bound field after updates so change propagation is explicit.
|
- Heavy binding transforms can degrade refresh performance. Move expensive work to event handlers or services.
|
||||||
|
- State shared across unrelated pages or users can leak data. Scope models to the appropriate page, client, or user context.
|
||||||
|
|
||||||
- Pitfall: heavy transform functions in bindings.
|
## Version Checks
|
||||||
- Safer alternative: keep transformations cheap and deterministic; move heavy work to event handlers.
|
|
||||||
|
|
||||||
- Pitfall: one model shared across unrelated pages or users.
|
- `bindable_dataclass` was added in NiceGUI 2.11.0.
|
||||||
- Safer alternative: scope model instances to page/client/user context as needed.
|
- Depth-first binding propagation was documented in NiceGUI 2.16.0.
|
||||||
|
- Binding `strict` behavior was documented in NiceGUI 3.0.0.
|
||||||
|
- Tuple paths for nested properties were documented in NiceGUI 3.10.0.
|
||||||
|
|
||||||
## Version notes to remember
|
Verify these behaviors against the NiceGUI version pinned by the target project.
|
||||||
|
|
||||||
- `bindable_dataclass` added in NiceGUI 2.11.0.
|
|
||||||
- Binding `strict` behavior documented as added in NiceGUI 3.0.0.
|
|
||||||
- Tuple paths for nested properties documented as added in NiceGUI 3.10.0.
|
|
||||||
- Depth-first binding propagation update documented in NiceGUI 2.16.0.
|
|
||||||
|
|
||||||
Verify behavior against the NiceGUI version pinned in your project before relying on version-specific semantics.
|
|
||||||
|
|
||||||
## Quick checklist
|
|
||||||
|
|
||||||
- Choose bindable dataclasses for interactive form-like state.
|
|
||||||
- Use `default_factory` for mutable fields.
|
|
||||||
- Keep transform functions pure.
|
|
||||||
- Use strict mode intentionally.
|
|
||||||
- Re-check version notes before migration work.
|
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
# FastAPI And Uvicorn Startup
|
||||||
|
|
||||||
|
Use this reference when FastAPI owns the application and NiceGUI is one part of it. The central distinction is between **composing an ASGI application** and **starting an ASGI server**:
|
||||||
|
|
||||||
|
- [`ui.run_with()`](https://github.com/zauberzeug/nicegui/blob/main/nicegui/ui_run_with.py) composes NiceGUI with a caller-owned FastAPI application. It does not start Uvicorn.
|
||||||
|
- [`uvicorn.run()`](https://www.uvicorn.org/#running-programmatically) starts the server and tells it which ASGI application to serve.
|
||||||
|
|
||||||
|
## Ownership Model
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
E["Project script: my-app"] --> M["main()"]
|
||||||
|
M --> S["get_settings()"]
|
||||||
|
M --> U["uvicorn.run()"]
|
||||||
|
U --> F["create_app()"]
|
||||||
|
F --> S
|
||||||
|
F --> P["Parent FastAPI app"]
|
||||||
|
P --> A["API routes and middleware"]
|
||||||
|
P -->|"mount_path=/gui"| N["NiceGUI App"]
|
||||||
|
U -->|"ASGI requests and lifespan"| P
|
||||||
|
```
|
||||||
|
|
||||||
|
The objects have separate responsibilities:
|
||||||
|
|
||||||
|
| Object | Owner | Responsibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Parent `FastAPI` instance | Application code | Root ASGI app, API routes, middleware, lifespan, and mounted applications |
|
||||||
|
| `Settings` instance | Application code | Immutable, process-local configuration snapshot shared by startup and composition |
|
||||||
|
| `nicegui.app` | NiceGUI | A process-local [`App`](https://github.com/zauberzeug/nicegui/blob/main/nicegui/app/app.py) instance that subclasses `FastAPI` |
|
||||||
|
| `ui.run_with(parent_app)` | NiceGUI integration | Configures NiceGUI, mounts `nicegui.app` into `parent_app`, and integrates lifecycle handling |
|
||||||
|
| Uvicorn | Server process | Imports or receives the root ASGI app, opens sockets, drives lifespan, and serves requests |
|
||||||
|
|
||||||
|
Uvicorn must serve the **parent FastAPI app** when using `ui.run_with()`. Passing `nicegui.app` to `ui.run_with()` is rejected because it would mount NiceGUI into itself and recurse on unmatched routes.
|
||||||
|
|
||||||
|
## Choose One Startup Mode
|
||||||
|
|
||||||
|
### Let NiceGUI Own Startup
|
||||||
|
|
||||||
|
Use `ui.run()` when NiceGUI is the main application. Add ordinary FastAPI routes to the exported `nicegui.app` object:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from nicegui import app, ui
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/healthz')
|
||||||
|
def health() -> dict[str, str]:
|
||||||
|
return {'status': 'ok'}
|
||||||
|
|
||||||
|
|
||||||
|
@ui.page('/')
|
||||||
|
def home() -> None:
|
||||||
|
ui.label('Home')
|
||||||
|
|
||||||
|
|
||||||
|
ui.run()
|
||||||
|
```
|
||||||
|
|
||||||
|
In this mode, NiceGUI configures and starts its own [Uvicorn-derived server](https://github.com/zauberzeug/nicegui/blob/main/nicegui/server.py). Do not also call `uvicorn.run()`.
|
||||||
|
|
||||||
|
### Let FastAPI Own The Application
|
||||||
|
|
||||||
|
Use `ui.run_with()` when an existing FastAPI application owns middleware, API routers, OpenAPI configuration, lifespan resources, or deployment startup. The [official NiceGUI FastAPI example](https://github.com/zauberzeug/nicegui/blob/main/examples/fastapi/main.py) follows this model.
|
||||||
|
|
||||||
|
`mount_path` controls where the NiceGUI application appears externally. A NiceGUI page declared as `/` is reachable at `/gui/` when mounted at `/gui`, while parent routes such as `/healthz` remain at the root. A dedicated UI prefix usually makes ownership and route conflicts clearer than mounting both applications at `/`.
|
||||||
|
|
||||||
|
## Canonical Factory Layout
|
||||||
|
|
||||||
|
Keep application composition importable and server startup explicit:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├─ pyproject.toml
|
||||||
|
└─ src/
|
||||||
|
└─ my_app/
|
||||||
|
├─ __init__.py
|
||||||
|
├─ config.py
|
||||||
|
└─ main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
```python title="src/my_app/config.py"
|
||||||
|
from functools import cache
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class ServerSettings(BaseModel):
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
|
host: str = '0.0.0.0'
|
||||||
|
port: int = 8000
|
||||||
|
log_level: Literal['critical', 'error', 'warning', 'info', 'debug', 'trace'] = (
|
||||||
|
'info'
|
||||||
|
)
|
||||||
|
reload: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class GuiSettings(BaseModel):
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
|
mount_path: str = '/gui'
|
||||||
|
storage_secret: SecretStr | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix='MY_APP_',
|
||||||
|
env_nested_delimiter='__',
|
||||||
|
env_file='.env',
|
||||||
|
env_file_encoding='utf-8',
|
||||||
|
frozen=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
server: ServerSettings = Field(default_factory=ServerSettings)
|
||||||
|
gui: GuiSettings = Field(default_factory=GuiSettings)
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
```
|
||||||
|
|
||||||
|
`ServerSettings` and `GuiSettings` inherit from `BaseModel` because they share one application owner, source policy, and process lifecycle. The root `BaseSettings` reads the sources once and validates one atomic snapshot. Environment variables use names such as `MY_APP_SERVER__PORT`, `MY_APP_SERVER__RELOAD`, `MY_APP_GUI__MOUNT_PATH`, and `MY_APP_GUI__STORAGE_SECRET`.
|
||||||
|
|
||||||
|
The argument-free [`functools.cache`](https://docs.python.org/3/library/functools.html#functools.cache) provider is appropriate here because both the project entry point and Uvicorn's zero-argument factory need process-lifetime access. Each reload or worker process gets its own settings instance. Do not add override arguments to `get_settings()`; inject a `Settings` instance directly into `create_app()` in tests or alternate composition roots. See the [Pydantic settings implementation guide](../../pydantic-settings/SKILL.md) for source precedence, independent settings boundaries, cache clearing, and runtime reload guidance.
|
||||||
|
|
||||||
|
```python title="src/my_app/main.py"
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from my_app.config import Settings, get_settings
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
app.state.ready = True
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
app.state.ready = False
|
||||||
|
|
||||||
|
|
||||||
|
def register_pages() -> None:
|
||||||
|
@ui.page('/')
|
||||||
|
def dashboard() -> None:
|
||||||
|
ui.label('Dashboard')
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
|
settings = settings or get_settings()
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
app.state.settings = settings
|
||||||
|
|
||||||
|
@app.get('/healthz')
|
||||||
|
def health() -> dict[str, str]:
|
||||||
|
return {'status': 'ok'}
|
||||||
|
|
||||||
|
register_pages()
|
||||||
|
ui.run_with(
|
||||||
|
app,
|
||||||
|
mount_path=settings.gui.mount_path,
|
||||||
|
storage_secret=(
|
||||||
|
settings.gui.storage_secret.get_secret_value()
|
||||||
|
if settings.gui.storage_secret is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
uvicorn.run(
|
||||||
|
'my_app.main:create_app',
|
||||||
|
factory=True,
|
||||||
|
host=settings.server.host,
|
||||||
|
port=settings.server.port,
|
||||||
|
log_level=settings.server.log_level,
|
||||||
|
reload=settings.server.reload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
The `storage_secret` is optional unless the application uses `ui.storage.user` or `ui.storage.browser`. `SecretStr` prevents accidental plaintext display in logs and model representations, while `get_secret_value()` unwraps it only at the NiceGUI integration boundary. Supply production secrets through environment variables or a supported settings secret source rather than committing them.
|
||||||
|
|
||||||
|
The example passes an [import string and `factory=True`](https://www.uvicorn.org/settings/#application) to Uvicorn. Uvicorn imports `my_app.main`, calls the zero-argument `create_app` factory, and serves the returned parent FastAPI app. Import strings are also required when Uvicorn creates reload or worker subprocesses; passing `create_app()` directly only supports the simple single-process case.
|
||||||
|
|
||||||
|
NiceGUI keeps framework state in its process-local app singleton. Treat `create_app()` as a once-per-worker factory. Calling it repeatedly in one interpreter can register the same pages and lifecycle handlers more than once; tests that create multiple apps must isolate or reset NiceGUI state.
|
||||||
|
|
||||||
|
## Lifespan Ordering
|
||||||
|
|
||||||
|
The [ASGI lifespan protocol](https://asgi.readthedocs.io/en/latest/specs/lifespan.html) is driven by the server. Uvicorn sends startup before accepting requests and sends shutdown while terminating the process. Lifespan runs once per event loop, including once in each worker process.
|
||||||
|
|
||||||
|
Current NiceGUI source integrates with the parent application by:
|
||||||
|
|
||||||
|
1. Capturing the parent FastAPI lifespan context.
|
||||||
|
2. Mounting NiceGUI's internal app on the parent.
|
||||||
|
3. Replacing the parent lifespan with a wrapper.
|
||||||
|
4. Starting NiceGUI before entering the original parent lifespan.
|
||||||
|
5. Exiting the original parent lifespan before shutting down NiceGUI.
|
||||||
|
|
||||||
|
This exact ordering comes from the current [`ui.run_with` implementation](https://github.com/zauberzeug/nicegui/blob/main/nicegui/ui_run_with.py) and is version-sensitive. Check the pinned NiceGUI version before making one startup handler depend on another framework's internal ordering.
|
||||||
|
|
||||||
|
Create database pools, HTTP clients, and similar resources in the parent [FastAPI lifespan](https://fastapi.tiangolo.com/advanced/events/), then close them after `yield`. Do not create event-loop-bound resources at import time or assume that globals are shared between workers.
|
||||||
|
|
||||||
|
## Expose The Server As A Project Script
|
||||||
|
|
||||||
|
Map a command name to the no-argument startup function:
|
||||||
|
|
||||||
|
```toml title="pyproject.toml"
|
||||||
|
[project]
|
||||||
|
name = "my-app"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi",
|
||||||
|
"nicegui",
|
||||||
|
"pydantic-settings",
|
||||||
|
"uvicorn[standard]",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
my-app = "my_app.main:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/my_app"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the installed command through uv:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
The uv [project entry-point documentation](https://docs.astral.sh/uv/concepts/projects/config/#entry-points) requires a build system so uv installs the project and generates its command. The `[project.scripts]` target follows the [PyPA entry-point specification](https://packaging.python.org/en/latest/specifications/entry-points/#use-for-scripts): its generated wrapper imports `main`, calls it without arguments, and uses the return value as the process exit status. Returning `None` means successful completion.
|
||||||
|
|
||||||
|
The settings model now owns host, port, logging, reload, mount path, and storage-secret configuration. Add an explicit CLI settings source or another CLI parser only when the project command needs user-supplied arguments; the entry-point callable itself still receives no arguments.
|
||||||
|
|
||||||
|
## Development Reload
|
||||||
|
|
||||||
|
Because `main()` supplies an import string, it can enable Uvicorn reload for local development:
|
||||||
|
|
||||||
|
```dotenv title=".env"
|
||||||
|
MY_APP_SERVER__HOST=127.0.0.1
|
||||||
|
MY_APP_SERVER__RELOAD=true
|
||||||
|
```
|
||||||
|
|
||||||
|
The cached settings object is a process-start snapshot. Changing an environment variable or dotenv file does not mutate a running instance; restart the process, or let the development reloader create a new worker when a watched file changes. Keep reload disabled in production. Uvicorn documents [`reload` and `workers` as mutually exclusive](https://www.uvicorn.org/settings/#production), and each worker would have independent settings, NiceGUI state, lifespan resources, and WebSocket connections. Use one worker by default unless the application has explicitly validated session affinity and externalized every stateful dependency needed across processes.
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
| Anti-pattern | Why it fails | Preferred approach |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ui.run_with(nicegui.app)` | Mounts NiceGUI into itself | Pass a separately created `FastAPI()` instance |
|
||||||
|
| Calling both `ui.run()` and `ui.run_with()` | Gives two paths responsibility for startup | Choose one ownership model |
|
||||||
|
| `uvicorn.run(create_app(), reload=True)` | Reload subprocesses cannot import the app object | Use an import string with `factory=True` |
|
||||||
|
| Calling `uvicorn.run()` at module import time | Importing the module starts a blocking server and breaks subprocess startup | Call it from `main()` |
|
||||||
|
| Top-level `ui.label(...)` with `ui.run_with()` | Script-mode elements are discarded by this integration | Register UI in `@ui.page` functions or a root callable |
|
||||||
|
| Multiple workers by default | Process-local UI state and WebSockets are not automatically shared | Start with one worker and validate a distributed design explicitly |
|
||||||
|
| Reconstructing `Settings()` throughout the app | Re-reads sources and obscures the active configuration lifecycle | Inject the startup snapshot or use the argument-free provider at framework boundaries |
|
||||||
|
| Adding kwargs to cached `get_settings()` | Retains one hidden process-lifetime instance per argument combination | Construct explicit `Settings(...)` overrides and inject them |
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Use `TestClient` as a context manager so the parent ASGI lifespan runs:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from my_app.config import GuiSettings, Settings
|
||||||
|
from my_app.main import create_app
|
||||||
|
|
||||||
|
|
||||||
|
def test_application_routes() -> None:
|
||||||
|
settings = Settings(
|
||||||
|
gui=GuiSettings(storage_secret='test-storage-secret'),
|
||||||
|
)
|
||||||
|
|
||||||
|
with TestClient(create_app(settings)) as client:
|
||||||
|
assert client.get('/healthz').json() == {'status': 'ok'}
|
||||||
|
assert client.get('/gui/').status_code == 200
|
||||||
|
```
|
||||||
|
|
||||||
|
Also verify:
|
||||||
|
|
||||||
|
- startup resources exist while the client context is active and are released afterward
|
||||||
|
- the mounted UI returns HTML and parent API failures retain FastAPI's JSON responses
|
||||||
|
- `uv run my-app` starts the server and responds on both the API and UI paths
|
||||||
|
- shutdown signals complete without orphaned background tasks
|
||||||
|
|
||||||
|
## Primary Sources
|
||||||
|
|
||||||
|
- [NiceGUI pages, routing, and FastAPI integration](https://www.nicegui.io/documentation/section_pages_routing)
|
||||||
|
- [NiceGUI `ui.run_with` implementation](https://github.com/zauberzeug/nicegui/blob/main/nicegui/ui_run_with.py)
|
||||||
|
- [NiceGUI FastAPI example](https://github.com/zauberzeug/nicegui/blob/main/examples/fastapi/main.py)
|
||||||
|
- [FastAPI lifespan events](https://fastapi.tiangolo.com/advanced/events/)
|
||||||
|
- [ASGI lifespan protocol](https://asgi.readthedocs.io/en/latest/specs/lifespan.html)
|
||||||
|
- [Uvicorn settings](https://www.uvicorn.org/settings/)
|
||||||
|
- [Uvicorn programmatic startup](https://www.uvicorn.org/#running-programmatically)
|
||||||
|
- [Pydantic settings management](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/)
|
||||||
|
- [`functools.cache`](https://docs.python.org/3/library/functools.html#functools.cache)
|
||||||
|
- [uv project entry points](https://docs.astral.sh/uv/concepts/projects/config/#entry-points)
|
||||||
|
- [PyPA entry points specification](https://packaging.python.org/en/latest/specifications/entry-points/)
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
# Source Documentation
|
# Source Documentation
|
||||||
|
|
||||||
Use these links for framework-specific details.
|
Use these links to verify framework-specific behavior before relying on version-sensitive or integration-specific guidance.
|
||||||
|
|
||||||
|
## NiceGUI
|
||||||
|
|
||||||
|
!!! info "NiceGUI sources"
|
||||||
|
- [Pages, routing, and FastAPI integration](https://www.nicegui.io/documentation/section_pages_routing)
|
||||||
|
- [`ui.run_with` implementation](https://github.com/zauberzeug/nicegui/blob/main/nicegui/ui_run_with.py)
|
||||||
|
- [FastAPI integration example](https://github.com/zauberzeug/nicegui/blob/main/examples/fastapi/main.py)
|
||||||
|
- [Binding properties and bindable dataclasses](https://www.nicegui.io/documentation/section_binding_properties)
|
||||||
|
- [Action events](https://www.nicegui.io/documentation/section_action_events)
|
||||||
|
- [Security best practices](https://www.nicegui.io/documentation/section_security)
|
||||||
|
|
||||||
## FastAPI
|
## FastAPI
|
||||||
|
|
||||||
@@ -8,40 +18,53 @@ Use these links for framework-specific details.
|
|||||||
- [Lifespan events](https://fastapi.tiangolo.com/advanced/events/)
|
- [Lifespan events](https://fastapi.tiangolo.com/advanced/events/)
|
||||||
- [Settings and environment variables](https://fastapi.tiangolo.com/advanced/settings/)
|
- [Settings and environment variables](https://fastapi.tiangolo.com/advanced/settings/)
|
||||||
- [Dependencies with yield](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield/)
|
- [Dependencies with yield](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield/)
|
||||||
- [SQL databases tutorial](https://fastapi.tiangolo.com/tutorial/sql-databases/)
|
- [Server-sent events](https://fastapi.tiangolo.com/advanced/server-sent-events/)
|
||||||
|
- [WebSockets](https://fastapi.tiangolo.com/advanced/websockets/)
|
||||||
|
|
||||||
## SQLAlchemy and Alembic
|
## ASGI And Uvicorn
|
||||||
|
|
||||||
|
!!! info "Server and lifespan sources"
|
||||||
|
- [ASGI lifespan protocol](https://asgi.readthedocs.io/en/latest/specs/lifespan.html)
|
||||||
|
- [Uvicorn settings](https://www.uvicorn.org/settings/)
|
||||||
|
- [Uvicorn programmatic startup](https://www.uvicorn.org/#running-programmatically)
|
||||||
|
- [Uvicorn deployment](https://www.uvicorn.org/deployment/)
|
||||||
|
|
||||||
|
## uv And Project Scripts
|
||||||
|
|
||||||
|
!!! info "Packaging and command sources"
|
||||||
|
- [uv project entry points](https://docs.astral.sh/uv/concepts/projects/config/#entry-points)
|
||||||
|
- [uv project packaging](https://docs.astral.sh/uv/concepts/projects/config/#project-packaging)
|
||||||
|
- [PyPA entry points specification](https://packaging.python.org/en/latest/specifications/entry-points/)
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
!!! info "Styling sources"
|
||||||
|
- [Tailwind utility-first styling](https://tailwindcss.com/docs/utility-first)
|
||||||
|
- [Tailwind responsive design and container queries](https://tailwindcss.com/docs/responsive-design)
|
||||||
|
- [Quasar components](https://quasar.dev/vue-components)
|
||||||
|
- [Quasar Screen plugin documentation source](https://github.com/quasarframework/quasar/blob/dev/docs/src/pages/options/screen-plugin.md)
|
||||||
|
- [CSS media queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries)
|
||||||
|
- [CSS container queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment/Container_queries)
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
!!! info "Persistence sources"
|
!!! info "Persistence sources"
|
||||||
- [SQLAlchemy engine configuration and pooling](https://docs.sqlalchemy.org/en/20/core/engines.html)
|
- [SQLAlchemy engine configuration and pooling](https://docs.sqlalchemy.org/en/20/core/engines.html)
|
||||||
- [SQLAlchemy session lifecycle basics](https://docs.sqlalchemy.org/en/20/orm/session_basics.html)
|
- [SQLAlchemy session lifecycle](https://docs.sqlalchemy.org/en/20/orm/session_basics.html)
|
||||||
- [Alembic tutorial](https://alembic.sqlalchemy.org/en/latest/tutorial.html)
|
- [Alembic tutorial](https://alembic.sqlalchemy.org/en/latest/tutorial.html)
|
||||||
|
|
||||||
## Pydantic
|
## Configuration And Dataclasses
|
||||||
|
|
||||||
!!! info "Pydantic source"
|
!!! info "Python and Pydantic sources"
|
||||||
- [Pydantic settings management](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/)
|
- [Pydantic settings management](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)
|
||||||
|
- [Python dataclasses](https://docs.python.org/3/library/dataclasses.html)
|
||||||
## NiceGUI
|
|
||||||
|
|
||||||
!!! info "NiceGUI sources"
|
|
||||||
- [Pages, routing, and FastAPI integration](https://www.nicegui.io/documentation/section_pages_routing)
|
|
||||||
- [Binding properties and bindable dataclass](https://www.nicegui.io/documentation/section_binding_properties)
|
|
||||||
- [Security best practices](https://www.nicegui.io/documentation/section_security)
|
|
||||||
|
|
||||||
## Python Dataclasses
|
|
||||||
|
|
||||||
!!! info "Python sources"
|
|
||||||
- [dataclasses module reference](https://docs.python.org/3/library/dataclasses.html)
|
|
||||||
- [PEP 557: Data Classes](https://peps.python.org/pep-0557/)
|
- [PEP 557: Data Classes](https://peps.python.org/pep-0557/)
|
||||||
|
|
||||||
## LangGraph
|
## LangGraph
|
||||||
|
|
||||||
!!! info "LangGraph sources"
|
!!! info "LangGraph sources"
|
||||||
- [Overview](https://docs.langchain.com/oss/python/langgraph/overview)
|
- [Overview](https://docs.langchain.com/oss/python/langgraph/overview)
|
||||||
- [Quickstart](https://docs.langchain.com/oss/python/langgraph/quickstart)
|
|
||||||
- [Workflows and agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents)
|
- [Workflows and agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents)
|
||||||
- [Persistence](https://docs.langchain.com/oss/python/langgraph/persistence)
|
- [Persistence](https://docs.langchain.com/oss/python/langgraph/persistence)
|
||||||
- [Memory concepts](https://docs.langchain.com/oss/python/concepts/memory)
|
|
||||||
- [Streaming](https://docs.langchain.com/oss/python/langgraph/streaming)
|
- [Streaming](https://docs.langchain.com/oss/python/langgraph/streaming)
|
||||||
- [Interrupts and human-in-the-loop](https://docs.langchain.com/oss/python/langgraph/interrupts)
|
- [Interrupts and human-in-the-loop](https://docs.langchain.com/oss/python/langgraph/interrupts)
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
---
|
||||||
|
name: pydantic-settings
|
||||||
|
description: "Practical guide for implementing typed application configuration with pydantic-settings. Use when designing BaseSettings models, choosing nested or independent settings boundaries, managing settings lifecycles, configuring dotenv or secrets, and customizing source priority safely."
|
||||||
|
x-personal-mcp:
|
||||||
|
id: pydantic-settings
|
||||||
|
version: 1.1.0
|
||||||
|
tags:
|
||||||
|
- python
|
||||||
|
- pydantic
|
||||||
|
- pydantic-settings
|
||||||
|
- configuration
|
||||||
|
- env-vars
|
||||||
|
- secrets
|
||||||
|
- dotenv
|
||||||
|
- source-priority
|
||||||
|
- caching
|
||||||
|
- lifecycle
|
||||||
|
capabilities:
|
||||||
|
- resource://skills/pydantic-settings/document
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pydantic Settings Implementation Guide
|
||||||
|
|
||||||
|
Use this skill to implement robust, typed application configuration with `pydantic-settings` in production Python services.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- You need a single typed configuration model for app settings.
|
||||||
|
- You are migrating from ad-hoc `os.getenv(...)` calls.
|
||||||
|
- You need predictable precedence across init args, env vars, dotenv files, and secrets.
|
||||||
|
- You need nested settings models and reliable parsing behavior.
|
||||||
|
- You need to choose between one nested application settings object and independently owned settings objects.
|
||||||
|
- You need a deliberate construction, caching, or reload lifecycle.
|
||||||
|
- You need to customize settings sources or source order safely.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
### 1. Baseline Model
|
||||||
|
|
||||||
|
Create a single settings model for the service boundary:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseSettings(BaseModel):
|
||||||
|
host: str = "localhost"
|
||||||
|
port: int = 5432
|
||||||
|
user: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="APP_",
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
frozen=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
debug: bool = False
|
||||||
|
log_level: str = "info"
|
||||||
|
database: DatabaseSettings
|
||||||
|
api_key: str = Field(validation_alias="MY_API_KEY")
|
||||||
|
```
|
||||||
|
|
||||||
|
Quality gate:
|
||||||
|
|
||||||
|
1. Required fields fail fast when missing.
|
||||||
|
2. Defaults are intentional and safe.
|
||||||
|
|
||||||
|
### 2. Pick Env Naming Rules
|
||||||
|
|
||||||
|
1. Choose one prefix and apply it consistently.
|
||||||
|
2. Use aliases only for compatibility or external contracts.
|
||||||
|
3. Document whether env names are case-sensitive.
|
||||||
|
|
||||||
|
Quality gate:
|
||||||
|
|
||||||
|
1. Team can derive env variable names without guessing.
|
||||||
|
2. Legacy names are supported only where needed.
|
||||||
|
|
||||||
|
### 3. Decide Nested Parsing
|
||||||
|
|
||||||
|
For nested models via env vars, configure delimiters intentionally:
|
||||||
|
|
||||||
|
```python
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="APP_",
|
||||||
|
env_nested_delimiter="__",
|
||||||
|
env_nested_max_split=1,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Typical vars:
|
||||||
|
|
||||||
|
1. `APP_DATABASE={"host": "db", "port": 5432, "user": "svc", "password": "pw"}`
|
||||||
|
2. `APP_DATABASE__HOST=db.internal`
|
||||||
|
|
||||||
|
Quality gate:
|
||||||
|
|
||||||
|
1. Nested overrides behave as expected.
|
||||||
|
2. Delimiter choice does not collide with field names.
|
||||||
|
|
||||||
|
### 4. Confirm Source Priority
|
||||||
|
|
||||||
|
Default priority (higher first):
|
||||||
|
|
||||||
|
1. CLI args (if enabled)
|
||||||
|
2. init kwargs
|
||||||
|
3. env vars
|
||||||
|
4. dotenv
|
||||||
|
5. secrets dir
|
||||||
|
6. defaults
|
||||||
|
|
||||||
|
Only customize when required:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic_settings import PydanticBaseSettingsSource
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def settings_customise_sources(
|
||||||
|
cls,
|
||||||
|
settings_cls: type[BaseSettings],
|
||||||
|
init_settings: PydanticBaseSettingsSource,
|
||||||
|
env_settings: PydanticBaseSettingsSource,
|
||||||
|
dotenv_settings: PydanticBaseSettingsSource,
|
||||||
|
file_secret_settings: PydanticBaseSettingsSource,
|
||||||
|
) -> tuple[PydanticBaseSettingsSource, ...]:
|
||||||
|
return (init_settings, env_settings, dotenv_settings, file_secret_settings)
|
||||||
|
```
|
||||||
|
|
||||||
|
Quality gate:
|
||||||
|
|
||||||
|
1. Priority order is explicit in code.
|
||||||
|
2. Tests verify conflict resolution.
|
||||||
|
|
||||||
|
### 5. Add Secrets Strategy
|
||||||
|
|
||||||
|
1. In local development, dotenv is acceptable for non-production values.
|
||||||
|
2. In deployed environments, prefer env vars or secret managers.
|
||||||
|
3. For file-mounted secrets, use `secrets_dir`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="APP_",
|
||||||
|
env_file=".env",
|
||||||
|
secrets_dir="/run/secrets",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Quality gate:
|
||||||
|
|
||||||
|
1. No secret literals in repository code.
|
||||||
|
2. Missing secrets behavior is understood per environment.
|
||||||
|
|
||||||
|
### 6. Choose Nested Or Independent Settings Boundaries
|
||||||
|
|
||||||
|
Prefer one root `BaseSettings` object with nested `BaseModel` sections when the configuration belongs to one application lifecycle:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseSettings(BaseModel):
|
||||||
|
host: str = "localhost"
|
||||||
|
port: int = 5432
|
||||||
|
|
||||||
|
|
||||||
|
class ObservabilitySettings(BaseModel):
|
||||||
|
log_level: str = "INFO"
|
||||||
|
json_logs: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="APP_",
|
||||||
|
env_nested_delimiter="__",
|
||||||
|
frozen=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
database: DatabaseSettings = Field(default_factory=DatabaseSettings)
|
||||||
|
observability: ObservabilitySettings = Field(
|
||||||
|
default_factory=ObservabilitySettings
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
This produces names such as `APP_DATABASE__HOST` and gives the application one validated, atomic configuration snapshot. Nested sections should normally inherit from `BaseModel`, not `BaseSettings`; otherwise each nested settings model can collect sources independently and produce surprising results.
|
||||||
|
|
||||||
|
Use independent `BaseSettings` classes when the objects have genuinely independent ownership:
|
||||||
|
|
||||||
|
1. Different packages or deployable components own the schemas.
|
||||||
|
2. Each object needs its own env prefix or source policy.
|
||||||
|
3. A component is optional or loaded lazily.
|
||||||
|
4. Components need different reload lifecycles.
|
||||||
|
5. The same component must run outside the application.
|
||||||
|
|
||||||
|
Construct independent objects explicitly at the composition root and inject each dependency. Do not nest one `BaseSettings` class inside another merely to reuse its fields. Extract a shared `BaseModel` schema when models need common structure.
|
||||||
|
|
||||||
|
Quality gate:
|
||||||
|
|
||||||
|
1. Nested sections share one source policy and lifecycle.
|
||||||
|
2. Independent settings have distinct owners, prefixes, or lifecycles.
|
||||||
|
3. The application does not repeatedly scan the same sources through accidental nested `BaseSettings` construction.
|
||||||
|
|
||||||
|
### 7. Own The Settings Lifecycle
|
||||||
|
|
||||||
|
For most applications, construct settings once at the composition root and pass the validated object to services:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def main() -> None:
|
||||||
|
settings = Settings()
|
||||||
|
application = Application(settings=settings)
|
||||||
|
application.run()
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes ownership, startup failure, and test overrides explicit. Treat the object as a snapshot: environment variables and files changing later do not update an existing instance. Prefer `frozen=True` for shared settings so consumers cannot silently mutate process-wide configuration.
|
||||||
|
|
||||||
|
Use [`functools.cache`](https://docs.python.org/3/library/functools.html#functools.cache) only when process-lifetime singleton access is intentional and explicit injection is awkward, such as a framework dependency provider:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from functools import cache
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the cached factory argument-free. Passing override kwargs creates one cached instance per argument combination, retains those values for the process lifetime, and obscures which configuration is active. In tests, instantiate `Settings(...)` directly or override the dependency; when a test must exercise the cached getter, isolate environment changes with `get_settings.cache_clear()` before and after the assertion.
|
||||||
|
|
||||||
|
`cache` is process-local. Every worker process gets its own instance, and concurrent first calls can construct more than one instance before the cache is populated. Settings construction must therefore be side-effect free; create engines, clients, and sessions in their own lifecycle-managed providers.
|
||||||
|
|
||||||
|
Quality gate:
|
||||||
|
|
||||||
|
1. Settings are created once per intended application or worker lifecycle.
|
||||||
|
2. Cached factories are argument-free and side-effect free.
|
||||||
|
3. Tests do not leak cached settings or environment changes.
|
||||||
|
4. Resource construction is separate from configuration parsing.
|
||||||
|
|
||||||
|
### 8. Reload Deliberately
|
||||||
|
|
||||||
|
Static service configuration should normally require a process restart. If runtime reload is a real requirement, construct a fresh settings instance and atomically replace the owned reference. Do not call `__init__()` on a shared instance: readers can observe mutation in progress, and resources derived from old values may remain alive.
|
||||||
|
|
||||||
|
Settings sources are synchronous. In an async application, construction or reload that reads dotenv, secrets, JSON, TOML, or YAML files should run in a worker thread:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
|
async def load_settings() -> Settings:
|
||||||
|
return await asyncio.to_thread(Settings)
|
||||||
|
```
|
||||||
|
|
||||||
|
Clearing `get_settings` is sufficient for controlled tests or single-threaded administration, but it is not an atomic live-reload protocol. Concurrent applications should own the current reference behind an application-specific lock or lifecycle manager, swap in a fully validated replacement, and then rebuild dependent resources.
|
||||||
|
|
||||||
|
Quality gate:
|
||||||
|
|
||||||
|
1. Reload creates and validates a replacement before publication.
|
||||||
|
2. Readers cannot observe a partially mutated object.
|
||||||
|
3. Dependent resources are recreated after the settings reference changes.
|
||||||
|
4. File-backed source reads do not block an async event loop.
|
||||||
|
|
||||||
|
### 9. Add Focused Lifecycle Tests
|
||||||
|
|
||||||
|
Do not add tests that re-validate baseline `pydantic-settings` functionality unless custom behavior is layered on top. Test the application-owned behavior instead:
|
||||||
|
|
||||||
|
1. Repeated cached getter calls return the same instance.
|
||||||
|
2. Cache clearing after an environment change returns a newly validated instance.
|
||||||
|
3. Explicitly injected settings bypass global cached state.
|
||||||
|
4. Reload swaps the settings snapshot and rebuilds dependent resources, when reload is supported.
|
||||||
|
|
||||||
|
Suggested invocation:
|
||||||
|
|
||||||
|
1. `uv run pytest -q`
|
||||||
|
|
||||||
|
## Completion Checks
|
||||||
|
|
||||||
|
1. Settings ownership matches the application or component lifecycle.
|
||||||
|
2. Source precedence is documented and tested.
|
||||||
|
3. Env naming conventions and aliases are explicit and stable.
|
||||||
|
4. Nested parsing behavior is tested when custom parsing behavior is added.
|
||||||
|
5. Secrets and dotenv usage are environment-appropriate and do not leak sensitive defaults.
|
||||||
|
6. Validation errors are actionable and fail fast for required values.
|
||||||
|
7. Cached factories are argument-free, process-local, and cleared deliberately in tests.
|
||||||
|
8. Nested models share one source policy; independent settings have an explicit ownership reason.
|
||||||
|
9. Runtime reload, if supported, replaces a validated snapshot and rebuilds dependent resources.
|
||||||
|
|
||||||
|
## Output Contract
|
||||||
|
|
||||||
|
When this skill is applied, return:
|
||||||
|
|
||||||
|
1. Which references were consulted.
|
||||||
|
2. The chosen source-precedence model and why.
|
||||||
|
3. The exact parsing and alias decisions made.
|
||||||
|
4. Any deferred choices and their risk.
|
||||||
|
5. The validation commands or tests run to confirm behavior.
|
||||||
|
|
||||||
|
Use these upstream docs when implementing or reviewing `pydantic-settings` behavior.
|
||||||
|
|
||||||
|
## Source Docs
|
||||||
|
|
||||||
|
### Primary
|
||||||
|
|
||||||
|
- [Settings Management](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/)
|
||||||
|
- [pydantic-settings package repository](https://github.com/pydantic/pydantic-settings)
|
||||||
|
|
||||||
|
### Core Concepts
|
||||||
|
|
||||||
|
- [Field aliases](https://pydantic.dev/docs/validation/latest/concepts/fields/#field-aliases)
|
||||||
|
- [Alias choices](https://pydantic.dev/docs/validation/latest/concepts/alias#aliaspath-and-aliaschoices)
|
||||||
|
- [Validation default behavior](https://pydantic.dev/docs/validation/latest/concepts/fields#validate-default-values)
|
||||||
|
- [ImportString type](https://pydantic.dev/docs/validation/latest/api/pydantic/types/#pydantic.types.ImportString)
|
||||||
|
|
||||||
|
### Priority And Sources
|
||||||
|
|
||||||
|
- [Field value priority](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#field-value-priority)
|
||||||
|
- [Customise settings sources](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#customise-settings-sources)
|
||||||
|
- [Other settings source types](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#other-settings-source)
|
||||||
|
|
||||||
|
### Environment And Parsing
|
||||||
|
|
||||||
|
- [Environment variable names and prefix behavior](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#environment-variable-names)
|
||||||
|
- [Case sensitivity behavior](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#case-sensitivity)
|
||||||
|
- [Parsing environment variable values](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#parsing-environment-variable-values)
|
||||||
|
- [Nested model default partial updates](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#nested-model-default-partial-updates)
|
||||||
|
|
||||||
|
### Lifecycle And Reloading
|
||||||
|
|
||||||
|
- [In-place reloading](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#in-place-reloading)
|
||||||
|
- [Async environments](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#async-environments)
|
||||||
|
- [`functools.cache`](https://docs.python.org/3/library/functools.html#functools.cache)
|
||||||
|
|
||||||
|
### Dotenv And Secrets
|
||||||
|
|
||||||
|
- [Dotenv support](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#dotenv-env-support)
|
||||||
|
- [Secrets](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#secrets)
|
||||||
|
- [Nested secrets](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#nested-secrets)
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
---
|
|
||||||
name: python-logging-dictconfig
|
|
||||||
description: 'Set up idiomatic Python logging with logging.config.dictConfig. Use when creating or refactoring logging setup, standardizing handlers/formatters, and enforcing centralized config.'
|
|
||||||
x-personal-mcp:
|
|
||||||
id: python-logging-dictconfig
|
|
||||||
version: 1.0.0
|
|
||||||
tags:
|
|
||||||
- logging
|
|
||||||
- python
|
|
||||||
- observability
|
|
||||||
capabilities:
|
|
||||||
- resource://skills/python-logging-dictconfig/document
|
|
||||||
---
|
|
||||||
|
|
||||||
# Idiomatic Python Logging with dictConfig
|
|
||||||
|
|
||||||
Use this skill to produce a minimal, centralized logging setup using `logging.config.dictConfig`.
|
|
||||||
|
|
||||||
Load references only when needed:
|
|
||||||
- Python logging overview and hierarchy: [Python logging references](./references/python-logging-docs.md)
|
|
||||||
|
|
||||||
## When to Use
|
|
||||||
|
|
||||||
- A project configures logging ad hoc with `basicConfig` across multiple modules.
|
|
||||||
- You need one canonical logging configuration for app startup.
|
|
||||||
- You need consistent formatting and levels across console/file handlers.
|
|
||||||
- You want library modules to use named loggers without configuring logging themselves.
|
|
||||||
|
|
||||||
## Inputs To Collect
|
|
||||||
|
|
||||||
1. Runtime type: script, library, web app, worker, CLI.
|
|
||||||
2. Destinations: stdout only, file only, or both.
|
|
||||||
3. Desired default level: `INFO`, `DEBUG`, etc.
|
|
||||||
4. Whether third-party loggers should be tuned (for example `uvicorn`, `sqlalchemy`).
|
|
||||||
|
|
||||||
If missing, assume:
|
|
||||||
- stdout handler
|
|
||||||
- human-readable formatter
|
|
||||||
- root level `INFO`
|
|
||||||
- `disable_existing_loggers: False`
|
|
||||||
|
|
||||||
## Procedure
|
|
||||||
|
|
||||||
1. Define a single `LOGGING` dictionary in one startup-oriented module (for example `logging_config.py`).
|
|
||||||
2. Include `version: 1` and set `disable_existing_loggers: False` unless there is a specific reason to silence existing loggers.
|
|
||||||
3. Define formatters first, then handlers, then logger routing (`root` and optional named `loggers`).
|
|
||||||
4. Use `logging.config.dictConfig(LOGGING)` exactly once during application startup.
|
|
||||||
5. In all modules, get loggers via `logger = logging.getLogger(__name__)` and never call `basicConfig`.
|
|
||||||
6. Keep libraries configuration-free: libraries should emit logs, applications decide routing.
|
|
||||||
7. Verify behavior with a quick smoke check at multiple levels (`DEBUG`, `INFO`, `WARNING`, `ERROR`).
|
|
||||||
|
|
||||||
## Minimal Baseline Templates
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
!!! warning "Don't use the name `logging.py` because it will conflict
|
|
||||||
|
|
||||||
```python title="logging_config.py"
|
|
||||||
import logging.config
|
|
||||||
|
|
||||||
LOGGING = {
|
|
||||||
"version": 1,
|
|
||||||
"disable_existing_loggers": False,
|
|
||||||
"formatters": {
|
|
||||||
"basic": {
|
|
||||||
"format": "%(asctime)s.%(msecs)03d [%(levelname)s] %(message)s",
|
|
||||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"handlers": {
|
|
||||||
"console": {
|
|
||||||
"class": "logging.StreamHandler",
|
|
||||||
"formatter": "basic",
|
|
||||||
"stream": "ext://sys.stdout",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"level": "INFO",
|
|
||||||
"handlers": ["console"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def configure_logging() -> None:
|
|
||||||
logging.config.dictConfig(LOGGING)
|
|
||||||
```
|
|
||||||
|
|
||||||
```python title="app.py"
|
|
||||||
# app startup
|
|
||||||
from .logging_config import configure_logging
|
|
||||||
|
|
||||||
configure_logging()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
The preferred way of instantiating loggers is at the top of modules like this:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Completion Checks
|
|
||||||
1. `dictConfig` is called once at startup, not per module.
|
|
||||||
2. No `basicConfig` calls remain.
|
|
||||||
3. Modules use `getLogger(__name__)`.
|
|
||||||
4. Logs appear at expected level and destination.
|
|
||||||
5. Third-party logger noise is intentionally configured or left at defaults.
|
|
||||||
6. No module named `logging.py` in the project.
|
|
||||||
|
|
||||||
## Branching Guidance
|
|
||||||
- If structured logs are required: switch formatter output to JSON while keeping `dictConfig` topology unchanged.
|
|
||||||
- If both console and file output are needed: add a file handler and attach it to `root`.
|
|
||||||
- If a specific framework logger is too noisy: add a named logger override under `loggers`.
|
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
---
|
||||||
|
name: python-logging
|
||||||
|
description: 'Design, review, or refactor Python logging. Use when choosing logger names, levels, handlers, library/application boundaries, basicConfig, dictConfig, structured logs, or operational logging defaults.'
|
||||||
|
x-personal-mcp:
|
||||||
|
id: python-logging
|
||||||
|
version: 1.0.0
|
||||||
|
tags:
|
||||||
|
- logging
|
||||||
|
- python
|
||||||
|
- observability
|
||||||
|
capabilities:
|
||||||
|
- resource://skills/python-logging/document
|
||||||
|
---
|
||||||
|
|
||||||
|
# Python Logging
|
||||||
|
|
||||||
|
Use this skill to produce idiomatic Python logging guidance or a small logging setup for an application, library, CLI, worker, or web service.
|
||||||
|
|
||||||
|
Load references only when needed:
|
||||||
|
|
||||||
|
[Python logging references](./references/python-logging-docs.md)
|
||||||
|
: Python logging overview, library guidance, handlers, and dictConfig schema
|
||||||
|
|
||||||
|
[JSON file logging pattern](./references/json-file-logging.md)
|
||||||
|
: Queue-backed rotating JSON file pattern for local machine-readable logs
|
||||||
|
|
||||||
|
[Network logging minimal example](./references/network-logging-minimal-example.md)
|
||||||
|
: Minimal network logging example with a receiver and queue-backed client
|
||||||
|
|
||||||
|
[HTTPX logging handler example](./references/httpx-logging-handler-example.md)
|
||||||
|
: HTTP JSON logging example with `httpx` and a queue-backed client
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- A project mixes `print`, root logger calls, scattered `basicConfig`, or ad hoc handlers.
|
||||||
|
- You need to choose logging levels, destinations, formatter fields, or logger names.
|
||||||
|
- You need a clear boundary between library logging and application logging configuration.
|
||||||
|
- You need a centralized logging setup, including a `logging.config.dictConfig` section.
|
||||||
|
- You are tuning framework or third-party loggers such as `uvicorn`, `sqlalchemy`, or HTTP clients.
|
||||||
|
|
||||||
|
## Inputs To Collect
|
||||||
|
|
||||||
|
1. Runtime type: script, library, CLI, web app, worker, service, or notebook.
|
||||||
|
2. Audience: humans in a terminal, operators in files, machines in JSON, or test assertions.
|
||||||
|
3. Destinations: stdout/stderr, file, rotating file, queue, syslog, external collector, or none for libraries.
|
||||||
|
4. Default level and verbosity controls: `INFO`, `DEBUG`, CLI flag, environment variable, or config file.
|
||||||
|
5. Operational constraints: async event loop, multiprocessing, container logs, sensitive data, or high-volume paths.
|
||||||
|
|
||||||
|
If missing, assume:
|
||||||
|
- application code, not a reusable library
|
||||||
|
- stdout console logging
|
||||||
|
- human-readable formatter
|
||||||
|
- root level `INFO`
|
||||||
|
- no file logging unless requested
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
1. Classify the project boundary first: application code configures logging; library code emits logs and avoids configuring handlers.
|
||||||
|
2. In modules, create loggers with `logger = logging.getLogger(__name__)` so logger names follow the package hierarchy.
|
||||||
|
3. Use level semantics consistently: `DEBUG` for diagnosis, `INFO` for normal milestones, `WARNING` for notable recoverable conditions, `ERROR` for failed operations, and `CRITICAL` for process-threatening failures.
|
||||||
|
4. Prefer parameterized logging calls such as `logger.info("Processed %s items", count)` so message formatting is deferred until the record is emitted.
|
||||||
|
5. Configure handlers and formatters once during application startup.
|
||||||
|
6. Keep third-party logger overrides explicit and narrow. Tune noisy loggers by name instead of muting broad logger hierarchies.
|
||||||
|
7. Smoke-check output at expected levels and destinations, including one suppressed `DEBUG` message and one exception path if errors are logged.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Do not name a module `logging.py`; it shadows the standard library package.
|
||||||
|
- Do not call `basicConfig` or attach handlers in every module.
|
||||||
|
- Do not log to the root logger from libraries. Use named loggers and, only if needed, attach `logging.NullHandler()` to the library's top-level logger.
|
||||||
|
- Do not create loggers per request, user, file, or connection. Use contextual fields, adapters, or filters instead.
|
||||||
|
- Use `logger.exception(...)` only inside an exception handler when the traceback is useful.
|
||||||
|
- For async or high-throughput code, avoid slow network or file handlers on the hot path; consider `QueueHandler` and a listener.
|
||||||
|
- Avoid custom levels unless there is a strong interoperability reason.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### `logging.basicConfig`
|
||||||
|
|
||||||
|
```python title="Bare minimum"
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
|
|
||||||
|
logging.info("Hello, world!")
|
||||||
|
```
|
||||||
|
|
||||||
|
```python title="With a little formatting"
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format="%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
logging.info("Hello, world!")
|
||||||
|
```
|
||||||
|
|
||||||
|
### `logging.config.dictConfig`
|
||||||
|
|
||||||
|
```python title="Minimal dictConfig example"
|
||||||
|
import logging
|
||||||
|
import logging.config
|
||||||
|
|
||||||
|
logging.config.dictConfig(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"console": {
|
||||||
|
"format": "%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s",
|
||||||
|
"datefmt": "%Y-%m-%dT%H:%M:%S",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "console",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {"level": "INFO", "handlers": ["console"]},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.info("Hello world")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Config Composition
|
||||||
|
|
||||||
|
Example function suitable for merging dicts for `dictConfig`
|
||||||
|
|
||||||
|
```python title="composed config"
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from copy import copy
|
||||||
|
from functools import reduce
|
||||||
|
|
||||||
|
|
||||||
|
BASE = ...
|
||||||
|
CONSOLE = ...
|
||||||
|
JSON_FILE = ...
|
||||||
|
RICH = ...
|
||||||
|
|
||||||
|
|
||||||
|
def merge(a: Mapping, b: Mapping) -> Mapping:
|
||||||
|
"""Recursively merge config dicts"""
|
||||||
|
a = dict(a)
|
||||||
|
for k, v in b.items():
|
||||||
|
match a.get(k), v:
|
||||||
|
case Mapping() as inner, Mapping():
|
||||||
|
a[k] = merge(inner, v)
|
||||||
|
case Sequence() as inner, Iterable():
|
||||||
|
new = list(copy(inner))
|
||||||
|
a[k] = new + [sub_v for sub_v in v if sub_v not in new]
|
||||||
|
case _:
|
||||||
|
a[k] = v
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(
|
||||||
|
base_config: dict | None = None,
|
||||||
|
*,
|
||||||
|
enable_console: bool = True,
|
||||||
|
enable_file: bool = True,
|
||||||
|
enable_rich: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Configure logging using the merged configuration."""
|
||||||
|
configs = [base_config or BASE]
|
||||||
|
if enable_console:
|
||||||
|
configs.append(CONSOLE)
|
||||||
|
if enable_file:
|
||||||
|
configs.append(JSON_FILE)
|
||||||
|
if enable_rich:
|
||||||
|
configs.append(RICH)
|
||||||
|
final_config = dict(reduce(merge, configs))
|
||||||
|
logging.config.dictConfig(final_config)
|
||||||
|
return final_config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using dictConfig
|
||||||
|
|
||||||
|
Use `logging.config.dictConfig` when configuration should be centralized, data-driven, or richer than `basicConfig`.
|
||||||
|
|
||||||
|
1. Define one `LOGGING` dictionary in a startup-oriented module such as `logging_config.py`.
|
||||||
|
2. Include `version: 1` and usually set `disable_existing_loggers: False` so existing named loggers are not silently disabled.
|
||||||
|
3. Define formatters, then handlers, then logger routing with `root` and optional named `loggers`.
|
||||||
|
4. Call `logging.config.dictConfig(LOGGING)` once during application startup.
|
||||||
|
5. Keep application logging calls unchanged when adding new destinations or formats.
|
||||||
|
|
||||||
|
## Application Usage
|
||||||
|
|
||||||
|
Concrete examples of how logging should be configured and used.
|
||||||
|
|
||||||
|
!!! warning "It's important to avoid the obvious name of `logging.py` to avoid weird clashes with IDEs and python internals."
|
||||||
|
|
||||||
|
=== "dictConfig"
|
||||||
|
|
||||||
|
```python title="logging_config.py"
|
||||||
|
import logging.config
|
||||||
|
|
||||||
|
LOGGING = ...
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
logging.config.dictConfig(LOGGING)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "basicConfig"
|
||||||
|
|
||||||
|
```python title="logging_config.py"
|
||||||
|
import logging.config
|
||||||
|
|
||||||
|
LOGGING = ...
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
logging.basicConfig(**LOGGING)
|
||||||
|
```
|
||||||
|
|
||||||
|
```python title="app.py"
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def run(count: int) -> None:
|
||||||
|
logger.info("Processing %s items", count)
|
||||||
|
```
|
||||||
|
|
||||||
|
```python title="main.py"
|
||||||
|
from app import run
|
||||||
|
from logging_config import configure_logging
|
||||||
|
|
||||||
|
configure_logging()
|
||||||
|
run(5)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Branching Guidance
|
||||||
|
|
||||||
|
- If the code is a tiny script: use `basicConfig` once near the entry point and module loggers elsewhere.
|
||||||
|
- If the code is a library: remove handlers and configuration calls; document logger names and optionally add `NullHandler` at the package root.
|
||||||
|
- If structured logs are required: keep the same logger and handler topology, but switch formatter output to JSON or a structured formatter.
|
||||||
|
- If console and file output are needed: add one file or rotating-file handler and attach it centrally. For a queue-backed JSON file setup, use the [JSON file logging pattern](./references/json-file-logging.md).
|
||||||
|
- If multiple processes write to one file: use a queue/listener or process-safe collection path rather than opening the same file independently in each process.
|
||||||
|
- If logs must cross a network: send records to a receiver or collector from a queue-backed handler, keep the receiver responsible for final destinations, and avoid exposing unauthenticated logging ports.
|
||||||
|
- If a framework logger is noisy: add a named logger override with a level and leave unrelated logger propagation alone.
|
||||||
|
|
||||||
|
## Completion Checks
|
||||||
|
|
||||||
|
1. Modules use `logging.getLogger(__name__)`.
|
||||||
|
2. Application startup configures logging once.
|
||||||
|
3. Libraries do not configure application handlers.
|
||||||
|
4. Levels match the severity semantics in this skill.
|
||||||
|
5. Logs include enough context to identify source, severity, and event without leaking secrets.
|
||||||
|
6. Expected destinations receive messages and suppressed levels stay quiet.
|
||||||
|
7. No source file or package is named `logging.py`.
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# HTTPX Logging Handler Example
|
||||||
|
|
||||||
|
Use this reference when an application should emit JSON logs to an HTTP collector while keeping startup logging configuration declarative.
|
||||||
|
|
||||||
|
This page follows the top-level skill pattern:
|
||||||
|
|
||||||
|
- define one `LOGGING` dictionary
|
||||||
|
- apply it once with `logging.config.dictConfig(LOGGING)`
|
||||||
|
- keep modules focused on logger calls
|
||||||
|
|
||||||
|
Source docs to keep nearby:
|
||||||
|
|
||||||
|
- [HTTPX clients](https://www.python-httpx.org/advanced/clients/)
|
||||||
|
- [HTTPX timeouts](https://www.python-httpx.org/advanced/timeouts/)
|
||||||
|
- [`logging.config.dictConfig`](https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig)
|
||||||
|
|
||||||
|
## Minimal Topology
|
||||||
|
|
||||||
|
```text
|
||||||
|
application code -> named logger -> HttpxJsonLogHandler -> HTTP collector
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reusable Handler Type
|
||||||
|
|
||||||
|
Keep transport behavior in one handler class and wire it declaratively through `dictConfig`.
|
||||||
|
|
||||||
|
```python title="httpx_json_handler.py"
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class HttpxJsonLogHandler(logging.Handler):
|
||||||
|
def __init__(self, collector_url: str, timeout_seconds: float = 2.0, token: str | None = None) -> None:
|
||||||
|
super().__init__()
|
||||||
|
headers = {"content-type": "application/json"}
|
||||||
|
if token is not None:
|
||||||
|
headers["authorization"] = f"Bearer {token}"
|
||||||
|
timeout = httpx.Timeout(timeout_seconds)
|
||||||
|
self.client = httpx.Client(base_url=collector_url, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
payload = {
|
||||||
|
"name": record.name,
|
||||||
|
"levelname": record.levelname,
|
||||||
|
"levelno": record.levelno,
|
||||||
|
"pathname": record.pathname,
|
||||||
|
"lineno": record.lineno,
|
||||||
|
"funcName": record.funcName,
|
||||||
|
"created": record.created,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = self.client.post("/logs", json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPError:
|
||||||
|
self.handleError(record)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.client.close()
|
||||||
|
super().close()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Application Logging Configuration (Declarative)
|
||||||
|
|
||||||
|
```python title="logging_config.py"
|
||||||
|
import logging.config
|
||||||
|
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"handlers": {
|
||||||
|
"httpx": {
|
||||||
|
"class": "httpx_json_handler.HttpxJsonLogHandler",
|
||||||
|
"collector_url": "http://127.0.0.1:9021",
|
||||||
|
"timeout_seconds": 2.0,
|
||||||
|
"token": None,
|
||||||
|
},
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"level": "INFO",
|
||||||
|
"stream": "ext://sys.stdout",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"level": "INFO",
|
||||||
|
"handlers": ["httpx", "console"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
logging.config.dictConfig(LOGGING)
|
||||||
|
```
|
||||||
|
|
||||||
|
```python title="feature.py"
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_customer(customer_id: str) -> None:
|
||||||
|
logger.info("Syncing customer %s", customer_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
```python title="main.py"
|
||||||
|
from feature import sync_customer
|
||||||
|
from logging_config import configure_logging
|
||||||
|
|
||||||
|
configure_logging()
|
||||||
|
sync_customer("C-101")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Collector-Side Configuration (Declarative)
|
||||||
|
|
||||||
|
Whether you use an internal HTTP endpoint or a managed collector, keep receiver-side formatting and routing declared on the receiver side, not in application modules.
|
||||||
|
|
||||||
|
## Why This Pattern
|
||||||
|
|
||||||
|
- Logging wiring is declared once and applied once.
|
||||||
|
- Runtime behavior changes by editing config fields, not scattered root mutations.
|
||||||
|
- Feature modules stay independent from transport details.
|
||||||
|
- HTTP connection details remain encapsulated in one handler type.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
1. Is there one `LOGGING` dict for the application process?
|
||||||
|
2. Is `dictConfig` called once at startup?
|
||||||
|
3. Are module loggers created via `logging.getLogger(__name__)`?
|
||||||
|
4. Are HTTP endpoint, timeout, and auth token inputs declared in handler config?
|
||||||
|
5. Are final routing/retention decisions handled by the collector side?
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# JSON File Logging Pattern (Queue + Rotation)
|
||||||
|
|
||||||
|
Use this reference when you need machine-readable JSON logs written to rotating files without blocking caller threads.
|
||||||
|
|
||||||
|
This page captures the pattern used in the logging notebook example: configure a queue-backed root logger, route queued records to a rotating JSON file handler, and explicitly start and stop the `QueueListener` around workload execution.
|
||||||
|
|
||||||
|
|
||||||
|
## Pattern Overview
|
||||||
|
|
||||||
|
Use this topology:
|
||||||
|
|
||||||
|
```text
|
||||||
|
application code -> named logger/root logger -> QueueHandler -> QueueListener -> RotatingFileHandler(JSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
Why this shape:
|
||||||
|
|
||||||
|
- `QueueHandler` keeps file I/O off the main execution path. See [Dealing with handlers that block](https://docs.python.org/3/howto/logging-cookbook.html#dealing-with-handlers-that-block).
|
||||||
|
- `RotatingFileHandler` bounds disk usage and preserves recent history in backups. See [RotatingFileHandler](https://docs.python.org/3/library/logging.handlers.html#rotatingfilehandler).
|
||||||
|
- A JSON formatter makes logs easy to parse for automation and analytics. See [python-json-logger](https://nhairs.github.io/python-json-logger/latest/).
|
||||||
|
|
||||||
|
## Configuration Example
|
||||||
|
|
||||||
|
```python title="logging_config.py"
|
||||||
|
import logging
|
||||||
|
import logging.config
|
||||||
|
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"console": {
|
||||||
|
"format": "%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s",
|
||||||
|
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
"()": "pythonjsonlogger.json.JsonFormatter",
|
||||||
|
"format": "pathname,lineno,taskName,created,name,levelname,message,args",
|
||||||
|
"style": ",",
|
||||||
|
"rename_fields": {"levelname": "level"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "console",
|
||||||
|
"level": "INFO",
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"class": "logging.handlers.QueueHandler",
|
||||||
|
"handlers": ["file"],
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"class": "logging.handlers.RotatingFileHandler",
|
||||||
|
"filename": "app.log",
|
||||||
|
"maxBytes": 1024**2 * 5,
|
||||||
|
"backupCount": 5,
|
||||||
|
"formatter": "json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"root": {"level": "DEBUG", "handlers": ["queue", "console"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.config.dictConfig(LOGGING)
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Queue/listener configuration through `dictConfig` is documented in [Configuring QueueHandler and QueueListener](https://docs.python.org/3/library/logging.config.html#configuring-queuehandler-and-queuelistener).
|
||||||
|
- `disable_existing_loggers: False` is usually safer unless you intentionally want to disable existing non-root loggers.
|
||||||
|
|
||||||
|
## Listener Lifecycle Pattern
|
||||||
|
|
||||||
|
When using queue-backed logging, treat listener startup and shutdown as explicit lifecycle responsibilities.
|
||||||
|
|
||||||
|
```python title="listener_lifecycle.py"
|
||||||
|
import logging
|
||||||
|
import warnings
|
||||||
|
from collections.abc import Callable
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from functools import cache
|
||||||
|
from logging.handlers import QueueHandler, QueueListener
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def get_listener(queue_handler_name: str) -> QueueListener | None:
|
||||||
|
match logging.getHandlerByName(queue_handler_name):
|
||||||
|
case QueueHandler(listener=QueueListener() as listener):
|
||||||
|
return listener
|
||||||
|
|
||||||
|
|
||||||
|
def _listener_action(queue_handler_name: str, action: Callable[[QueueListener], None]):
|
||||||
|
match get_listener(queue_handler_name):
|
||||||
|
case QueueListener() as listener:
|
||||||
|
action(listener)
|
||||||
|
return listener
|
||||||
|
|
||||||
|
|
||||||
|
def start_listener(queue_handler_name: str) -> None:
|
||||||
|
listener = _listener_action(queue_handler_name, lambda listener: listener.start())
|
||||||
|
if listener is None:
|
||||||
|
warnings.warn(f"{queue_handler_name} is not set up correctly", stacklevel=2)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def stop_listener(queue_handler_name: str) -> None:
|
||||||
|
_listener_action(queue_handler_name, lambda listener: listener.stop())
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def listener_lifespan(queue_handler_name: str):
|
||||||
|
start_listener(queue_handler_name)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
stop_listener(queue_handler_name)
|
||||||
|
|
||||||
|
|
||||||
|
with listener_lifespan("queue"):
|
||||||
|
logging.info("Started")
|
||||||
|
for _ in range(10**6):
|
||||||
|
logging.debug("Hello world")
|
||||||
|
logging.info("Done")
|
||||||
|
logging.info("Console only")
|
||||||
|
```
|
||||||
|
|
||||||
|
This demonstrates deterministic listener startup/shutdown around the active workload
|
||||||
|
|
||||||
|
Docs for APIs used above:
|
||||||
|
|
||||||
|
- [`logging.getHandlerByName`](https://docs.python.org/3/library/logging.html#logging.getHandlerByName)
|
||||||
|
- [`QueueHandler`](https://docs.python.org/3/library/logging.handlers.html#queuehandler)
|
||||||
|
- [`QueueListener`](https://docs.python.org/3/library/logging.handlers.html#queuelistener)
|
||||||
|
- [`contextlib.contextmanager`](https://docs.python.org/3/library/contextlib.html#contextlib.contextmanager)
|
||||||
|
- [`functools.cache`](https://docs.python.org/3/library/functools.html#functools.cache)
|
||||||
|
|
||||||
|
|
||||||
|
## Reading JSON Logs Back
|
||||||
|
|
||||||
|
For quick validation, read recent lines and deserialize JSON:
|
||||||
|
|
||||||
|
```python title="inspect_logs.py"
|
||||||
|
import json
|
||||||
|
from collections import deque
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def read_last_n_lines(file: str | Path, *, n: int):
|
||||||
|
with Path(file).open("r") as f:
|
||||||
|
return deque(f, maxlen=n)
|
||||||
|
|
||||||
|
|
||||||
|
lines = read_last_n_lines("app.log", n=5)
|
||||||
|
records = list(map(json.loads, lines))
|
||||||
|
```
|
||||||
|
|
||||||
|
For rotated logs, enumerate files by basename and sort by modification time before reading.
|
||||||
|
|
||||||
|
## Practical Checks
|
||||||
|
|
||||||
|
Before calling this done:
|
||||||
|
|
||||||
|
1. Confirm listener startup and shutdown run for the workload lifecycle.
|
||||||
|
2. Confirm `app.log` receives JSON lines, not plain text.
|
||||||
|
3. Confirm rotation occurs at the expected size and backup count.
|
||||||
|
4. Confirm console output still appears at the desired level.
|
||||||
|
5. Confirm exceptions and key context fields are preserved in JSON output.
|
||||||
|
|
||||||
|
## Source Links
|
||||||
|
|
||||||
|
- [Logging Cookbook](https://docs.python.org/3/howto/logging-cookbook.html)
|
||||||
|
- [logging.config reference](https://docs.python.org/3/library/logging.config.html)
|
||||||
|
- [Configuring QueueHandler and QueueListener](https://docs.python.org/3/library/logging.config.html#configuring-queuehandler-and-queuelistener)
|
||||||
|
- [logging handlers reference](https://docs.python.org/3/library/logging.handlers.html)
|
||||||
|
- [LogRecord attributes](https://docs.python.org/3/library/logging.html#logrecord-attributes)
|
||||||
|
- [python-json-logger docs](https://nhairs.github.io/python-json-logger/latest/)
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# Network Logging Minimal Example
|
||||||
|
|
||||||
|
Use this reference when an application should send logs over TCP to a local receiver and you want a complete, working baseline.
|
||||||
|
|
||||||
|
This page shows how the pieces fit together end to end:
|
||||||
|
|
||||||
|
- application code logs with named loggers
|
||||||
|
- startup applies one declarative `LOGGING` config
|
||||||
|
- `SocketHandler` sends records to a receiver
|
||||||
|
- receiver uses `socketserver` and local logging config for final routing
|
||||||
|
|
||||||
|
Source docs to keep nearby:
|
||||||
|
|
||||||
|
- [Sending and receiving logging events across a network](https://docs.python.org/3/howto/logging-cookbook.html#sending-and-receiving-logging-events-across-a-network)
|
||||||
|
- [`SocketHandler`](https://docs.python.org/3/library/logging.handlers.html#sockethandler)
|
||||||
|
- [`socketserver`](https://docs.python.org/3/library/socketserver.html)
|
||||||
|
- [`logging.makeLogRecord`](https://docs.python.org/3/library/logging.html#logging.makeLogRecord)
|
||||||
|
|
||||||
|
## Minimal Topology
|
||||||
|
|
||||||
|
```text
|
||||||
|
app module -> logger -> SocketHandler -> TCP receiver -> local handlers
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1) Client Logging Config (Declarative)
|
||||||
|
|
||||||
|
```python title="logging_config.py"
|
||||||
|
import logging.config
|
||||||
|
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"console": {
|
||||||
|
"format": "%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||||
|
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"network": {
|
||||||
|
"class": "logging.handlers.SocketHandler",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 9020,
|
||||||
|
},
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "console",
|
||||||
|
"level": "INFO",
|
||||||
|
"stream": "ext://sys.stdout",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"level": "INFO",
|
||||||
|
"handlers": ["network", "console"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
logging.config.dictConfig(LOGGING)
|
||||||
|
```
|
||||||
|
|
||||||
|
```python title="feature.py"
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def process_order(order_id: str) -> None:
|
||||||
|
logger.info("Processing order %s", order_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
```python title="main.py"
|
||||||
|
from feature import process_order
|
||||||
|
from logging_config import configure_logging
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
configure_logging()
|
||||||
|
process_order("A-42")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2) Receiver Logging Config (Declarative)
|
||||||
|
|
||||||
|
```python title="receiver_logging_config.py"
|
||||||
|
import logging.config
|
||||||
|
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"console": {
|
||||||
|
"format": "%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||||
|
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "console",
|
||||||
|
"stream": "ext://sys.stdout",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {"level": "INFO", "handlers": ["console"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def configure_receiver_logging() -> None:
|
||||||
|
logging.config.dictConfig(LOGGING)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3) Cookbook Receiver (`socketserver`) Implementation
|
||||||
|
|
||||||
|
This receiver follows the same structure as the Python logging cookbook example.
|
||||||
|
|
||||||
|
`SocketHandler` sends:
|
||||||
|
|
||||||
|
- a 4-byte big-endian length prefix
|
||||||
|
- a pickle payload containing a `LogRecord` dictionary
|
||||||
|
|
||||||
|
```python title="log_receiver.py"
|
||||||
|
import logging
|
||||||
|
import pickle
|
||||||
|
import socketserver
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from receiver_logging_config import configure_receiver_logging
|
||||||
|
|
||||||
|
|
||||||
|
class LogRecordStreamHandler(socketserver.StreamRequestHandler):
|
||||||
|
def handle(self) -> None:
|
||||||
|
while True:
|
||||||
|
chunk = self.connection.recv(4)
|
||||||
|
if len(chunk) < 4:
|
||||||
|
break
|
||||||
|
|
||||||
|
payload_len = struct.unpack(">L", chunk)[0]
|
||||||
|
payload = self.connection.recv(payload_len)
|
||||||
|
while len(payload) < payload_len:
|
||||||
|
payload = payload + self.connection.recv(payload_len - len(payload))
|
||||||
|
|
||||||
|
try:
|
||||||
|
record_dict = pickle.loads(payload)
|
||||||
|
record = logging.makeLogRecord(record_dict)
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).exception("Dropped malformed log record")
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.handle_log_record(record)
|
||||||
|
|
||||||
|
def handle_log_record(self, record: logging.LogRecord) -> None:
|
||||||
|
logger = logging.getLogger(record.name)
|
||||||
|
if logger.isEnabledFor(record.levelno):
|
||||||
|
logger.handle(record)
|
||||||
|
|
||||||
|
|
||||||
|
class LogRecordSocketReceiver(socketserver.ThreadingTCPServer):
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
configure_receiver_logging()
|
||||||
|
with LogRecordSocketReceiver(("127.0.0.1", 9020), LogRecordStreamHandler) as server:
|
||||||
|
logging.getLogger(__name__).info("Receiver listening on 127.0.0.1:9020")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4) How It Fits Together In Practice
|
||||||
|
|
||||||
|
1. Start `log_receiver.py`.
|
||||||
|
2. Start `main.py` from the client app.
|
||||||
|
3. Client logs go to console and TCP.
|
||||||
|
4. Receiver reconstructs records and emits them through its own handlers.
|
||||||
|
|
||||||
|
This split keeps app emission and receiver routing independent while still being fully runnable.
|
||||||
|
|
||||||
|
## Important Security Note
|
||||||
|
|
||||||
|
`SocketHandler` uses pickle serialization. Treat this as trusted-network-only transport.
|
||||||
|
|
||||||
|
- Bind receiver to localhost or a trusted private network.
|
||||||
|
- Do not expose this receiver to untrusted clients.
|
||||||
|
- For hostile boundaries, use JSON/TLS with authenticated ingestion instead of raw pickle.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
1. Is there one `LOGGING` dict per process role (client and receiver)?
|
||||||
|
2. Is `dictConfig` called once at each process startup?
|
||||||
|
3. Does the receiver decode length-prefixed payloads correctly?
|
||||||
|
4. Do modules only use `logging.getLogger(__name__)`?
|
||||||
|
5. Is the receiver endpoint protected by trust boundaries?
|
||||||
+8
-6
@@ -1,6 +1,6 @@
|
|||||||
# Python Logging References
|
# Python Logging Source References
|
||||||
|
|
||||||
Use these official Python docs when applying this skill.
|
Use these official Python docs when applying the Python logging skill.
|
||||||
|
|
||||||
## Core Documentation
|
## Core Documentation
|
||||||
|
|
||||||
@@ -10,13 +10,15 @@ Use these official Python docs when applying this skill.
|
|||||||
- [logging API reference](https://docs.python.org/3/library/logging.html)
|
- [logging API reference](https://docs.python.org/3/library/logging.html)
|
||||||
- [logging.config reference](https://docs.python.org/3/library/logging.config.html)
|
- [logging.config reference](https://docs.python.org/3/library/logging.config.html)
|
||||||
|
|
||||||
## dictConfig-Specific
|
## Configuration And dictConfig
|
||||||
|
|
||||||
!!! info "dictConfig references"
|
!!! info "dictConfig references"
|
||||||
- [Dictionary schema details](https://docs.python.org/3/library/logging.config.html#logging-config-dictschema) for `version`, formatters, handlers, loggers, and root.
|
- [Dictionary schema details](https://docs.python.org/3/library/logging.config.html#logging-config-dictschema) for `version`, formatters, handlers, loggers, and root.
|
||||||
- [`logging.config.dictConfig`](https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig) function reference.
|
- [`logging.config.dictConfig`](https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig) function reference.
|
||||||
|
|
||||||
## Practical Notes
|
## Practical Notes
|
||||||
- Prefer app-level centralized config with one startup call to `dictConfig`.
|
- Prefer module loggers created with `logging.getLogger(__name__)`.
|
||||||
- In modules, use `logging.getLogger(__name__)`.
|
- Let applications configure handlers and formatters; libraries should emit logs without taking over routing.
|
||||||
- Avoid calling `basicConfig` in libraries or scattered modules.
|
- Use `basicConfig` for simple scripts and `dictConfig` for centralized application configuration.
|
||||||
|
- Explicitly set `disable_existing_loggers: False` in `dictConfig` unless disabling existing non-root loggers is intentional.
|
||||||
|
- Use queue-based handlers when slow handlers would block async, threaded, or high-volume code paths.
|
||||||
@@ -32,7 +32,7 @@ Use [`.vscode/tasks.json`](https://code.visualstudio.com/docs/editor/tasks) to d
|
|||||||
"label": "App: Run",
|
"label": "App: Run",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "uv",
|
"command": "uv",
|
||||||
"args": ["run", "uvicorn", "personal_mcp.main:app", "--host", "127.0.0.1", "--port", "8000", "--reload"],
|
"args": ["run", "uvicorn", "personal_mcp.main:create_app", "--factory", "--host", "127.0.0.1", "--port", "8000", "--reload"],
|
||||||
"options": {
|
"options": {
|
||||||
"cwd": "${workspaceFolder}"
|
"cwd": "${workspaceFolder}"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -90,6 +90,94 @@ For prompt content, there is a third option when the client supports MCP prompt
|
|||||||
|
|
||||||
Instruction quality and metadata quality still matter, because they influence whether Copilot recognizes that the MCP server is relevant and chooses the tool path well.
|
Instruction quality and metadata quality still matter, because they influence whether Copilot recognizes that the MCP server is relevant and chooses the tool path well.
|
||||||
|
|
||||||
|
## Invocation Mechanics Deep Dive
|
||||||
|
|
||||||
|
This section expands on how invocation works at runtime across chat entry points.
|
||||||
|
|
||||||
|
### Invocation Surfaces
|
||||||
|
|
||||||
|
A user request can arrive through one of these surfaces:
|
||||||
|
|
||||||
|
1. plain chat request in Ask/Edit/Agent mode
|
||||||
|
2. slash command invocation of a prompt or skill
|
||||||
|
3. chat request with manually attached MCP resources
|
||||||
|
|
||||||
|
Each surface changes how much discovery Copilot must do before applying guidance.
|
||||||
|
|
||||||
|
### Resolution Order
|
||||||
|
|
||||||
|
When multiple retrieval paths are possible, use this priority order:
|
||||||
|
|
||||||
|
1. attached MCP resources already in context
|
||||||
|
2. explicit slash-command workflow steps
|
||||||
|
3. catalog-first discovery via MCP resources
|
||||||
|
4. tool fallback (`list_resources` then `read_resource`, then thin catalog parity tools)
|
||||||
|
|
||||||
|
This ordering keeps behavior predictable while minimizing unnecessary context expansion.
|
||||||
|
|
||||||
|
### Prompt Invocation Pipeline
|
||||||
|
|
||||||
|
For prompt-oriented flows, treat invocation as this sequence:
|
||||||
|
|
||||||
|
1. parse prompt frontmatter and argument hints
|
||||||
|
2. validate required inputs and ask one clarifying question if blocked
|
||||||
|
3. run bounded discovery against prompt or skill catalogs
|
||||||
|
4. fetch only selected document resources
|
||||||
|
5. apply instructions to produce edits, recommendations, or commands
|
||||||
|
6. report what was loaded and why
|
||||||
|
|
||||||
|
Prompt objects and prompt document resources are additive mechanisms. The authored Markdown prompt document remains the canonical contract.
|
||||||
|
|
||||||
|
### Argument Syntax Nuance
|
||||||
|
|
||||||
|
Invocation strings such as target_modules=src/personal_mcp/registry/ingest/skill.py, mode=plan-only are a structured authoring convention, not a guaranteed client-level grammar.
|
||||||
|
|
||||||
|
In practice:
|
||||||
|
|
||||||
|
1. Prompt metadata defines expected argument names and intent.
|
||||||
|
2. Prompt body instructions define how those inputs should be interpreted.
|
||||||
|
3. Copilot may receive equivalent intent in freeform phrasing and still resolve it correctly.
|
||||||
|
|
||||||
|
Implication for authors:
|
||||||
|
|
||||||
|
1. Treat key=value examples as clarity aids for users.
|
||||||
|
2. Do not assume strict parser enforcement unless your prompt explicitly validates and rejects malformed input.
|
||||||
|
3. Include accepted invocation examples and one fallback freeform example so behavior is predictable for both humans and the model.
|
||||||
|
|
||||||
|
This distinction is important because argument hints improve discoverability, while robust prompt instructions determine actual runtime reliability.
|
||||||
|
|
||||||
|
### Skill Invocation Pipeline
|
||||||
|
|
||||||
|
For guided skill loading, use this sequence:
|
||||||
|
|
||||||
|
1. start from `resource://catalog/skills_index` or scoped index query
|
||||||
|
2. inspect one or two top candidates for intent and capability fit
|
||||||
|
3. fetch `resource://skills/<skill-id>/document`
|
||||||
|
4. load references only when the task needs deeper detail
|
||||||
|
5. apply only relevant sections and keep context bounded
|
||||||
|
|
||||||
|
This avoids the common failure mode where many skill documents are loaded up front.
|
||||||
|
|
||||||
|
### Determinism vs Flexibility
|
||||||
|
|
||||||
|
Use this decision rule:
|
||||||
|
|
||||||
|
1. choose slash-command invocation when repeatability and step order are critical
|
||||||
|
2. choose guided loading when requests vary and speed matters more than strict orchestration
|
||||||
|
3. escalate from guided loading to slash-command flow when confidence is low or conflicting skills appear
|
||||||
|
|
||||||
|
### Invocation Trace (What to Log in Results)
|
||||||
|
|
||||||
|
For transparent operation, include a concise invocation trace in task outputs:
|
||||||
|
|
||||||
|
1. entry surface used (plain chat, slash command, or attached resource)
|
||||||
|
2. discovery source used (catalog resource or tool path)
|
||||||
|
3. resources fetched (ids only)
|
||||||
|
4. clarifying questions asked (if any)
|
||||||
|
5. reason for fallback or escalation (if used)
|
||||||
|
|
||||||
|
This makes behavior auditable and easier to tune over time.
|
||||||
|
|
||||||
## Operating Pattern
|
## Operating Pattern
|
||||||
|
|
||||||
Use both modes intentionally in Copilot Chat.
|
Use both modes intentionally in Copilot Chat.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ dependencies = [
|
|||||||
"fastapi>=0.115.0",
|
"fastapi>=0.115.0",
|
||||||
"fastmcp>=2.10.0",
|
"fastmcp>=2.10.0",
|
||||||
"pydantic-settings>=2.0.0",
|
"pydantic-settings>=2.0.0",
|
||||||
|
"python-json-logger>=4.1.0",
|
||||||
"pyyaml>=6.0.2",
|
"pyyaml>=6.0.2",
|
||||||
"uvicorn[standard]>=0.34.0",
|
"uvicorn[standard]>=0.34.0",
|
||||||
"zensical>=0.0.45",
|
"zensical>=0.0.45",
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ ignore = [
|
|||||||
"*.ipynb" = [
|
"*.ipynb" = [
|
||||||
"F401", # unused imports
|
"F401", # unused imports
|
||||||
"F841", # unused local variable
|
"F841", # unused local variable
|
||||||
"F821", # undefined name in exploratory notebook cells
|
"F821", # undefined name in exploratory notebook cells,
|
||||||
|
"LOG015", # root logger calls
|
||||||
]
|
]
|
||||||
|
|
||||||
[lint.isort]
|
[lint.isort]
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ def build_skill_detail_payload(registry: DocsRegistry, skill_id: str) -> dict[st
|
|||||||
"uri": ref.uri,
|
"uri": ref.uri,
|
||||||
"mime_type": ref.mime_type,
|
"mime_type": ref.mime_type,
|
||||||
"title": ref.title,
|
"title": ref.title,
|
||||||
"path": ref.relpath,
|
"path": ref.relpath.as_posix(),
|
||||||
}
|
}
|
||||||
for ref_id, ref in sorted(skill.references.items())
|
for ref_id, ref in sorted(skill.references.items())
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from functools import cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from pydantic import DirectoryPath
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from pydantic_settings import SettingsConfigDict
|
||||||
|
|
||||||
|
DEFAULT_ENV_FILE = Path(".env").resolve()
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
class Mounts(BaseModel):
|
||||||
|
docs: str = "/docs"
|
||||||
|
mcp: str = "/mcp"
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Runtime settings for the HTTP MCP and docs server."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=DEFAULT_ENV_FILE,
|
||||||
|
env_prefix="PERSONAL_MCP_",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
debug: bool = False
|
||||||
|
log_level: str = "info"
|
||||||
|
mounts: Mounts = Field(default_factory=Mounts)
|
||||||
|
mcp_transport: Literal["http", "sse"] = "http"
|
||||||
|
site_dir: DirectoryPath = Field(default=_REPO_ROOT / "site")
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def get_settings(**overrides) -> Settings:
|
||||||
|
return Settings(**overrides)
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_settings(**overrides):
|
||||||
|
get_settings.cache_clear()
|
||||||
|
return get_settings(**overrides)
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../docs
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
from personal_mcp.mcp import mcp
|
from fastapi import FastAPI
|
||||||
from personal_mcp.web.app import app
|
|
||||||
|
|
||||||
__all__ = ["app", "main", "mcp"]
|
from personal_mcp.mcp import create_mcp
|
||||||
|
from personal_mcp.web.app import create_app as create_fastapi
|
||||||
|
|
||||||
|
__all__ = ["create_app", "main"]
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
"""Create the HTTP application for ASGI servers using factory mode."""
|
||||||
|
return create_fastapi()
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Run the root MCP server."""
|
"""Run the root MCP server."""
|
||||||
mcp.run()
|
create_mcp().run()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+29
-44
@@ -20,22 +20,15 @@ from personal_mcp.catalog.server import get_pattern_by_id_payload
|
|||||||
from personal_mcp.catalog.server import get_prompt_by_id_payload
|
from personal_mcp.catalog.server import get_prompt_by_id_payload
|
||||||
from personal_mcp.catalog.server import search_patterns_payload
|
from personal_mcp.catalog.server import search_patterns_payload
|
||||||
from personal_mcp.catalog.server import search_prompts_payload
|
from personal_mcp.catalog.server import search_prompts_payload
|
||||||
from personal_mcp.registry.load import load_docs_registry
|
from personal_mcp.registry.load import get_docs_registry
|
||||||
from personal_mcp.registry.models.registry import DocsRegistry
|
from personal_mcp.registry.models.registry import DocsRegistry
|
||||||
from personal_mcp.registry.read import read_docs_markdown_path
|
from personal_mcp.registry.read import read_docs_markdown_path
|
||||||
from personal_mcp.registry.read import read_prompt_document
|
from personal_mcp.registry.read import read_prompt_document
|
||||||
from personal_mcp.registry.read import read_skill_document
|
from personal_mcp.registry.read import read_skill_document
|
||||||
from personal_mcp.registry.read import read_skill_reference
|
from personal_mcp.registry.read import read_skill_reference
|
||||||
|
|
||||||
DOCS_ROOT = os.getenv("PERSONAL_MCP_DOCS_ROOT", "../../docs")
|
|
||||||
TOOL_SEARCH_MODE = os.getenv("PERSONAL_MCP_TOOL_SEARCH", "none").strip().lower()
|
TOOL_SEARCH_MODE = os.getenv("PERSONAL_MCP_TOOL_SEARCH", "none").strip().lower()
|
||||||
TOOL_SEARCH_MAX_RESULTS = os.getenv("PERSONAL_MCP_TOOL_SEARCH_MAX_RESULTS", "5")
|
TOOL_SEARCH_MAX_RESULTS = os.getenv("PERSONAL_MCP_TOOL_SEARCH_MAX_RESULTS", "5")
|
||||||
REGISTRY: DocsRegistry = load_docs_registry(
|
|
||||||
package_anchor="personal_mcp",
|
|
||||||
docs_root=DOCS_ROOT,
|
|
||||||
)
|
|
||||||
|
|
||||||
mcp = FastMCP("personal-mcp", on_duplicate="error")
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_positive_int(value: str, *, env_name: str) -> int:
|
def _parse_positive_int(value: str, *, env_name: str) -> int:
|
||||||
@@ -48,7 +41,7 @@ def _parse_positive_int(value: str, *, env_name: str) -> int:
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def _install_tool_fallback_transforms() -> None:
|
def _install_tool_fallback_transforms(mcp: FastMCP) -> None:
|
||||||
# Expose list_resources/read_resource for tool-only clients.
|
# Expose list_resources/read_resource for tool-only clients.
|
||||||
mcp.add_transform(ResourcesAsTools(mcp))
|
mcp.add_transform(ResourcesAsTools(mcp))
|
||||||
|
|
||||||
@@ -95,9 +88,9 @@ def _make_prompt_handler(content: str):
|
|||||||
return prompt_handler
|
return prompt_handler
|
||||||
|
|
||||||
|
|
||||||
def _register_prompt_objects() -> None:
|
def _register_prompt_objects(mcp: FastMCP, registry: DocsRegistry) -> None:
|
||||||
for prompt_id in REGISTRY.prompts_in_load_order:
|
for prompt_id in registry.prompts_in_load_order:
|
||||||
prompt = REGISTRY.prompts_by_id[prompt_id]
|
prompt = registry.prompts_by_id[prompt_id]
|
||||||
annotations: dict[str, Any] = {}
|
annotations: dict[str, Any] = {}
|
||||||
params: list[Parameter] = []
|
params: list[Parameter] = []
|
||||||
|
|
||||||
@@ -129,6 +122,7 @@ def _register_prompt_objects() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _register_components(mcp: FastMCP, registry: DocsRegistry) -> None:
|
||||||
@mcp.resource(
|
@mcp.resource(
|
||||||
"resource://catalog/skills_index",
|
"resource://catalog/skills_index",
|
||||||
mime_type="application/json",
|
mime_type="application/json",
|
||||||
@@ -136,8 +130,7 @@ def _register_prompt_objects() -> None:
|
|||||||
annotations=_ro_annotations(),
|
annotations=_ro_annotations(),
|
||||||
)
|
)
|
||||||
def skills_index() -> dict[str, Any]:
|
def skills_index() -> dict[str, Any]:
|
||||||
return build_skills_index_payload(REGISTRY)
|
return build_skills_index_payload(registry)
|
||||||
|
|
||||||
|
|
||||||
@mcp.resource(
|
@mcp.resource(
|
||||||
"resource://catalog/skills_index{?q,tag,capability,cursor,limit}",
|
"resource://catalog/skills_index{?q,tag,capability,cursor,limit}",
|
||||||
@@ -153,7 +146,7 @@ def skills_index_query(
|
|||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return build_skills_index_payload(
|
return build_skills_index_payload(
|
||||||
REGISTRY,
|
registry,
|
||||||
query=q,
|
query=q,
|
||||||
tag=tag,
|
tag=tag,
|
||||||
capability=capability,
|
capability=capability,
|
||||||
@@ -161,7 +154,6 @@ def skills_index_query(
|
|||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@mcp.resource(
|
@mcp.resource(
|
||||||
"resource://catalog/skills/{skill_id}",
|
"resource://catalog/skills/{skill_id}",
|
||||||
mime_type="application/json",
|
mime_type="application/json",
|
||||||
@@ -169,8 +161,7 @@ def skills_index_query(
|
|||||||
annotations=_ro_annotations(),
|
annotations=_ro_annotations(),
|
||||||
)
|
)
|
||||||
def skill_detail(skill_id: str) -> dict[str, Any]:
|
def skill_detail(skill_id: str) -> dict[str, Any]:
|
||||||
return build_skill_detail_payload(REGISTRY, skill_id)
|
return build_skill_detail_payload(registry, skill_id)
|
||||||
|
|
||||||
|
|
||||||
@mcp.resource(
|
@mcp.resource(
|
||||||
"resource://skills/{skill_id}/document",
|
"resource://skills/{skill_id}/document",
|
||||||
@@ -179,8 +170,7 @@ def skill_detail(skill_id: str) -> dict[str, Any]:
|
|||||||
annotations=_ro_annotations(),
|
annotations=_ro_annotations(),
|
||||||
)
|
)
|
||||||
def skill_document(skill_id: str) -> dict[str, str]:
|
def skill_document(skill_id: str) -> dict[str, str]:
|
||||||
return read_skill_document(REGISTRY, skill_id)
|
return read_skill_document(registry, skill_id)
|
||||||
|
|
||||||
|
|
||||||
@mcp.resource(
|
@mcp.resource(
|
||||||
"resource://skills/{skill_id}/references/{ref_id}",
|
"resource://skills/{skill_id}/references/{ref_id}",
|
||||||
@@ -189,8 +179,7 @@ def skill_document(skill_id: str) -> dict[str, str]:
|
|||||||
annotations=_ro_annotations(),
|
annotations=_ro_annotations(),
|
||||||
)
|
)
|
||||||
def skill_reference(skill_id: str, ref_id: str) -> dict[str, str]:
|
def skill_reference(skill_id: str, ref_id: str) -> dict[str, str]:
|
||||||
return read_skill_reference(REGISTRY, skill_id=skill_id, ref_id=ref_id)
|
return read_skill_reference(registry, skill_id=skill_id, ref_id=ref_id)
|
||||||
|
|
||||||
|
|
||||||
@mcp.resource(
|
@mcp.resource(
|
||||||
"resource://docs/{path*}",
|
"resource://docs/{path*}",
|
||||||
@@ -199,8 +188,7 @@ def skill_reference(skill_id: str, ref_id: str) -> dict[str, str]:
|
|||||||
annotations=_ro_annotations(),
|
annotations=_ro_annotations(),
|
||||||
)
|
)
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
@mcp.resource(
|
@mcp.resource(
|
||||||
"resource://catalog/prompts_index",
|
"resource://catalog/prompts_index",
|
||||||
@@ -209,8 +197,7 @@ def docs_markdown(path: str) -> dict[str, str]:
|
|||||||
annotations=_ro_annotations(),
|
annotations=_ro_annotations(),
|
||||||
)
|
)
|
||||||
def prompts_index() -> dict[str, Any]:
|
def prompts_index() -> dict[str, Any]:
|
||||||
return build_prompts_index_payload(REGISTRY)
|
return build_prompts_index_payload(registry)
|
||||||
|
|
||||||
|
|
||||||
@mcp.resource(
|
@mcp.resource(
|
||||||
"resource://catalog/prompts_index{?q,tag,cursor,limit}",
|
"resource://catalog/prompts_index{?q,tag,cursor,limit}",
|
||||||
@@ -225,14 +212,13 @@ def prompts_index_query(
|
|||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return build_prompts_index_payload(
|
return build_prompts_index_payload(
|
||||||
REGISTRY,
|
registry,
|
||||||
query=q,
|
query=q,
|
||||||
tag=tag,
|
tag=tag,
|
||||||
cursor=cursor,
|
cursor=cursor,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@mcp.resource(
|
@mcp.resource(
|
||||||
"resource://catalog/prompts/{prompt_id}",
|
"resource://catalog/prompts/{prompt_id}",
|
||||||
mime_type="application/json",
|
mime_type="application/json",
|
||||||
@@ -240,8 +226,7 @@ def prompts_index_query(
|
|||||||
annotations=_ro_annotations(),
|
annotations=_ro_annotations(),
|
||||||
)
|
)
|
||||||
def prompt_detail(prompt_id: str) -> dict[str, Any]:
|
def prompt_detail(prompt_id: str) -> dict[str, Any]:
|
||||||
return build_prompt_detail_payload(REGISTRY, prompt_id)
|
return build_prompt_detail_payload(registry, prompt_id)
|
||||||
|
|
||||||
|
|
||||||
@mcp.resource(
|
@mcp.resource(
|
||||||
"resource://prompts/{prompt_id}/document",
|
"resource://prompts/{prompt_id}/document",
|
||||||
@@ -250,8 +235,7 @@ def prompt_detail(prompt_id: str) -> dict[str, Any]:
|
|||||||
annotations=_ro_annotations(),
|
annotations=_ro_annotations(),
|
||||||
)
|
)
|
||||||
def prompt_document(prompt_id: str) -> dict[str, str]:
|
def prompt_document(prompt_id: str) -> dict[str, str]:
|
||||||
return read_prompt_document(REGISTRY, prompt_id)
|
return read_prompt_document(registry, prompt_id)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
@mcp.tool
|
||||||
def search_patterns(
|
def search_patterns(
|
||||||
@@ -262,32 +246,29 @@ def search_patterns(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Search normalized pattern metadata with optional tags and pagination."""
|
"""Search normalized pattern metadata with optional tags and pagination."""
|
||||||
return search_patterns_payload(
|
return search_patterns_payload(
|
||||||
REGISTRY,
|
registry,
|
||||||
query=query,
|
query=query,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
skip=skip,
|
skip=skip,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
@mcp.tool
|
||||||
def get_pattern_by_id(id: str) -> dict[str, Any]:
|
def get_pattern_by_id(id: str) -> dict[str, Any]:
|
||||||
"""Return one normalized pattern by stable id."""
|
"""Return one normalized pattern by stable id."""
|
||||||
return get_pattern_by_id_payload(REGISTRY, id)
|
return get_pattern_by_id_payload(registry, id)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
@mcp.tool
|
||||||
def get_skill_document_by_id(skill_id: str) -> dict[str, Any]:
|
def get_skill_document_by_id(skill_id: str) -> dict[str, Any]:
|
||||||
"""Return the canonical skill document payload for a stable skill id."""
|
"""Return the canonical skill document payload for a stable skill id."""
|
||||||
if skill_id not in REGISTRY.skills_by_id:
|
if skill_id not in registry.skills_by_id:
|
||||||
return {"found": False, "id": skill_id}
|
return {"found": False, "id": skill_id}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"found": True,
|
"found": True,
|
||||||
"document": read_skill_document(REGISTRY, skill_id),
|
"document": read_skill_document(registry, skill_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
@mcp.tool
|
||||||
def search_prompts(
|
def search_prompts(
|
||||||
query: str = "",
|
query: str = "",
|
||||||
@@ -297,19 +278,23 @@ def search_prompts(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Search prompt metadata with optional tags and pagination."""
|
"""Search prompt metadata with optional tags and pagination."""
|
||||||
return search_prompts_payload(
|
return search_prompts_payload(
|
||||||
REGISTRY,
|
registry,
|
||||||
query=query,
|
query=query,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
skip=skip,
|
skip=skip,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
@mcp.tool
|
||||||
def get_prompt_by_id(prompt_id: str) -> dict[str, Any]:
|
def get_prompt_by_id(prompt_id: str) -> dict[str, Any]:
|
||||||
"""Return one prompt by stable id."""
|
"""Return one prompt by stable id."""
|
||||||
return get_prompt_by_id_payload(REGISTRY, prompt_id)
|
return get_prompt_by_id_payload(registry, prompt_id)
|
||||||
|
|
||||||
|
|
||||||
_install_tool_fallback_transforms()
|
def create_mcp() -> FastMCP:
|
||||||
_register_prompt_objects()
|
registry = get_docs_registry()
|
||||||
|
mcp = FastMCP("personal-mcp", on_duplicate="error")
|
||||||
|
_register_components(mcp, registry)
|
||||||
|
_register_prompt_objects(mcp, registry)
|
||||||
|
_install_tool_fallback_transforms(mcp)
|
||||||
|
return mcp
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
from collections.abc import Iterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from dataclasses import field
|
from dataclasses import field
|
||||||
from importlib.resources.abc import Traversable
|
from importlib.resources.abc import Traversable
|
||||||
@@ -6,26 +7,32 @@ from itertools import starmap
|
|||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from typing import Self
|
from typing import Self
|
||||||
|
|
||||||
|
from personal_mcp.registry.models.common import DocsPath
|
||||||
|
from personal_mcp.registry.models.common import parse_docs_path
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class MarkdownDocument:
|
class MarkdownDocument:
|
||||||
"""Represents a loaded markdown document with its content and frontmatter."""
|
"""Represents a loaded markdown document with its content and frontmatter."""
|
||||||
|
|
||||||
relpath: PurePosixPath
|
relpath: DocsPath
|
||||||
"""The relative path of the document within the package resources."""
|
"""The relative path of the document within the package resources."""
|
||||||
content: str = field(repr=False)
|
content: str = field(repr=False)
|
||||||
"""The raw markdown content of the document."""
|
"""The raw markdown content of the document."""
|
||||||
frontmatter: str | None = field(repr=False, default=None)
|
frontmatter: str | None = field(repr=False, default=None)
|
||||||
"""The raw YAML frontmatter of the document, if present."""
|
"""The raw YAML frontmatter of the document, if present."""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "relpath", parse_docs_path(self.relpath))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_root(cls, root: Traversable):
|
def from_root(cls, root: Traversable) -> dict[DocsPath, Self]:
|
||||||
"""Recursively load all markdown documents from the root resource."""
|
"""Recursively load all markdown documents from the root resource."""
|
||||||
mapped = starmap(cls.from_resource, walk_resources(root))
|
mapped = starmap(cls.from_resource, walk_resources(root))
|
||||||
return {d.relpath: d for d in mapped}
|
return {d.relpath: d for d in mapped}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_resource(cls, relpath: PurePosixPath, resource: Traversable) -> Self:
|
def from_resource(cls, relpath: DocsPath, resource: Traversable) -> Self:
|
||||||
"""Load a markdown document from a package resource."""
|
"""Load a markdown document from a package resource."""
|
||||||
raw = resource.read_text(encoding="utf-8")
|
raw = resource.read_text(encoding="utf-8")
|
||||||
frontmatter = get_raw_frontmatter(raw)
|
frontmatter = get_raw_frontmatter(raw)
|
||||||
@@ -49,16 +56,14 @@ def walk_resources(
|
|||||||
*,
|
*,
|
||||||
suffix: str = ".md",
|
suffix: str = ".md",
|
||||||
prefix: PurePosixPath | None = None,
|
prefix: PurePosixPath | None = None,
|
||||||
) -> Generator[tuple[PurePosixPath, Traversable]]:
|
) -> Iterator[tuple[PurePosixPath, Traversable]]:
|
||||||
"""Recursively yield all resources in node, with their full path."""
|
"""Recursively yield all resources in node, with their full path."""
|
||||||
prefix = prefix if prefix is not None else PurePosixPath()
|
prefix = prefix or PurePosixPath()
|
||||||
for child in sorted(node.iterdir(), key=lambda item: item.name):
|
for child in sorted(node.iterdir(), key=lambda item: item.name):
|
||||||
relpath = prefix.joinpath(child.name)
|
relpath = prefix / child.name
|
||||||
if child.is_dir():
|
if child.is_dir():
|
||||||
yield from walk_resources(child, suffix=suffix, prefix=relpath)
|
yield from walk_resources(child, suffix=suffix, prefix=relpath)
|
||||||
continue
|
elif child.is_file() and child.name.lower().endswith(suffix):
|
||||||
if not child.is_file() or not child.name.lower().endswith(suffix):
|
|
||||||
continue
|
|
||||||
yield relpath, child
|
yield relpath, child
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class PromptFilesBundle:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_paths(cls, slug: str, paths: set[MarkdownDocument]) -> Self:
|
def from_paths(cls, slug: str, paths: set[MarkdownDocument]) -> Self:
|
||||||
prompt = next(iter(p for p in paths if p.relpath.name == "PROMPT.md"))
|
prompt = next(iter(p for p in paths if p.relpath.name == "PROMPT.md"))
|
||||||
sorted_paths = tuple(sorted(paths, key=lambda p: p.relpath.as_posix()))
|
sorted_paths = tuple(sorted(paths, key=lambda p: p.relpath))
|
||||||
other = tuple(p for p in sorted_paths if p != prompt)
|
other = tuple(p for p in sorted_paths if p != prompt)
|
||||||
return cls(
|
return cls(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
@@ -41,7 +41,7 @@ def group_prompt_paths(docs: Iterable[MarkdownDocument]) -> dict[str, set[Markdo
|
|||||||
grouped: dict[str, set[MarkdownDocument]] = {}
|
grouped: dict[str, set[MarkdownDocument]] = {}
|
||||||
for doc in sorted(
|
for doc in sorted(
|
||||||
filter(lambda d: d.prompt_slug is not None, docs),
|
filter(lambda d: d.prompt_slug is not None, docs),
|
||||||
key=lambda d: d.relpath.as_posix(),
|
key=lambda d: d.relpath,
|
||||||
):
|
):
|
||||||
if doc.prompt_slug:
|
if doc.prompt_slug:
|
||||||
grouped.setdefault(doc.prompt_slug, set()).add(doc)
|
grouped.setdefault(doc.prompt_slug, set()).add(doc)
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
|
import re
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from fnmatch import fnmatch
|
|
||||||
from importlib.resources.abc import Traversable
|
from importlib.resources.abc import Traversable
|
||||||
from itertools import groupby
|
from itertools import groupby
|
||||||
from itertools import starmap
|
from itertools import starmap
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from typing import Self
|
from typing import Self
|
||||||
|
|
||||||
|
from personal_mcp.registry.models.common import SKILL_ID_RE
|
||||||
|
from personal_mcp.registry.models.common import DocsPath
|
||||||
from personal_mcp.registry.models.common import ReferenceEntry
|
from personal_mcp.registry.models.common import ReferenceEntry
|
||||||
from personal_mcp.registry.models.skill import SkillFrontmatter
|
from personal_mcp.registry.models.skill import SkillFrontmatter
|
||||||
from personal_mcp.registry.models.skill import StoredSkill
|
from personal_mcp.registry.models.skill import StoredSkill
|
||||||
from personal_mcp.registry.models.skill import StoredSkillReference
|
from personal_mcp.registry.models.skill import StoredSkillReference
|
||||||
from personal_mcp.skills.document_loader import _reference_id_from_filename
|
|
||||||
from personal_mcp.skills.document_loader import _title_from_reference_filename
|
|
||||||
|
|
||||||
from .document import MarkdownDocument
|
from .document import MarkdownDocument
|
||||||
|
|
||||||
@@ -39,8 +39,9 @@ class SkillFilesBundle:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_paths(cls, slug: str, paths: set[MarkdownDocument]) -> Self:
|
def from_paths(cls, slug: str, paths: set[MarkdownDocument]) -> Self:
|
||||||
skill = next(iter(p for p in paths if p.relpath.name == "SKILL.md"))
|
skill = next(iter(p for p in paths if p.relpath.name == "SKILL.md"))
|
||||||
sorted_paths = tuple(sorted(paths, key=lambda p: p.relpath.as_posix()))
|
sorted_paths = tuple(sorted(paths, key=lambda p: p.relpath))
|
||||||
references = tuple(p for p in sorted_paths if fnmatch(p.relpath.as_posix(), f"skills/{slug}/references/*.md"))
|
references_dir = PurePosixPath("skills", slug, "references")
|
||||||
|
references = tuple(p for p in sorted_paths if p.relpath.parent == references_dir)
|
||||||
other = tuple(p for p in sorted_paths if p not in references and p != skill)
|
other = tuple(p for p in sorted_paths if p not in references and p != skill)
|
||||||
return cls(
|
return cls(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
@@ -60,6 +61,23 @@ def group_skill_paths(docs: Iterable[MarkdownDocument]) -> dict[str, set[Markdow
|
|||||||
return {k: set(g) for k, g in grouped if k}
|
return {k: set(g) for k, g in grouped if k}
|
||||||
|
|
||||||
|
|
||||||
|
def _title_from_reference_filename(filename: str) -> str:
|
||||||
|
stem = PurePosixPath(filename).stem
|
||||||
|
normalized = stem.replace("-", " ").replace("_", " ").split()
|
||||||
|
if not normalized:
|
||||||
|
return stem
|
||||||
|
return " ".join(token.capitalize() for token in normalized)
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_id_from_filename(filename: str) -> str | None:
|
||||||
|
stem = PurePosixPath(filename).stem.strip().lower().replace("_", "-")
|
||||||
|
normalized = re.sub(r"[^a-z0-9-]+", "-", stem)
|
||||||
|
normalized = re.sub(r"-+", "-", normalized).strip("-")
|
||||||
|
if not normalized or not SKILL_ID_RE.fullmatch(normalized):
|
||||||
|
return None
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def _discover_reference_entries(bundle: SkillFilesBundle) -> dict[str, ReferenceEntry]:
|
def _discover_reference_entries(bundle: SkillFilesBundle) -> dict[str, ReferenceEntry]:
|
||||||
discovered: dict[str, ReferenceEntry] = {}
|
discovered: dict[str, ReferenceEntry] = {}
|
||||||
for reference_doc in bundle.references:
|
for reference_doc in bundle.references:
|
||||||
@@ -67,7 +85,7 @@ def _discover_reference_entries(bundle: SkillFilesBundle) -> dict[str, Reference
|
|||||||
if ref_id is None:
|
if ref_id is None:
|
||||||
continue
|
continue
|
||||||
discovered[ref_id] = ReferenceEntry(
|
discovered[ref_id] = ReferenceEntry(
|
||||||
path=PurePosixPath("references").joinpath(reference_doc.relpath.name).as_posix(),
|
path=PurePosixPath("references", reference_doc.relpath.name),
|
||||||
title=_title_from_reference_filename(reference_doc.relpath.name),
|
title=_title_from_reference_filename(reference_doc.relpath.name),
|
||||||
)
|
)
|
||||||
return discovered
|
return discovered
|
||||||
@@ -76,7 +94,7 @@ def _discover_reference_entries(bundle: SkillFilesBundle) -> dict[str, Reference
|
|||||||
def build_stored_skill(
|
def build_stored_skill(
|
||||||
*,
|
*,
|
||||||
bundle: SkillFilesBundle,
|
bundle: SkillFilesBundle,
|
||||||
docs_by_relpath: Mapping[PurePosixPath, MarkdownDocument],
|
docs_by_relpath: Mapping[DocsPath, MarkdownDocument],
|
||||||
) -> StoredSkill:
|
) -> StoredSkill:
|
||||||
frontmatter = SkillFrontmatter.from_raw_yaml(bundle.skill.frontmatter)
|
frontmatter = SkillFrontmatter.from_raw_yaml(bundle.skill.frontmatter)
|
||||||
metadata = frontmatter.x_personal_mcp
|
metadata = frontmatter.x_personal_mcp
|
||||||
@@ -85,7 +103,7 @@ def build_stored_skill(
|
|||||||
|
|
||||||
references: dict[str, StoredSkillReference] = {}
|
references: dict[str, StoredSkillReference] = {}
|
||||||
for ref_id, entry in sorted(merged_entries.items()):
|
for ref_id, entry in sorted(merged_entries.items()):
|
||||||
ref_relpath = PurePosixPath("skills").joinpath(bundle.slug).joinpath(entry.path)
|
ref_relpath = PurePosixPath("skills", bundle.slug, entry.path)
|
||||||
if ref_relpath not in docs_by_relpath:
|
if ref_relpath not in docs_by_relpath:
|
||||||
raise KeyError(f"reference document not found for '{metadata.id}:{ref_id}' at {ref_relpath.as_posix()}")
|
raise KeyError(f"reference document not found for '{metadata.id}:{ref_id}' at {ref_relpath.as_posix()}")
|
||||||
ref_doc = docs_by_relpath[ref_relpath]
|
ref_doc = docs_by_relpath[ref_relpath]
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from functools import cache
|
||||||
from pathlib import PurePosixPath
|
from importlib.resources import files
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from personal_mcp.registry.ingest.document import MarkdownDocument
|
from personal_mcp.registry.ingest.document import MarkdownDocument
|
||||||
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
|
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
|
||||||
from personal_mcp.registry.ingest.skill import SkillFilesBundle
|
from personal_mcp.registry.ingest.skill import SkillFilesBundle
|
||||||
from personal_mcp.registry.ingest.skill import build_stored_skill
|
from personal_mcp.registry.ingest.skill import build_stored_skill
|
||||||
from personal_mcp.registry.models.common import _normalize_docs_path
|
from personal_mcp.registry.models.common import DocsPath
|
||||||
from personal_mcp.registry.models.prompt import StoredPrompt
|
from personal_mcp.registry.models.prompt import StoredPrompt
|
||||||
from personal_mcp.registry.models.registry import DocsRegistry
|
from personal_mcp.registry.models.registry import DocsRegistry
|
||||||
from personal_mcp.registry.models.registry import PromptRecord
|
from personal_mcp.registry.models.registry import PromptRecord
|
||||||
@@ -21,25 +18,7 @@ from personal_mcp.registry.models.registry import SkillRecord
|
|||||||
from personal_mcp.registry.models.registry import SkillSummaryRecord
|
from personal_mcp.registry.models.registry import SkillSummaryRecord
|
||||||
|
|
||||||
|
|
||||||
def _parse_frontmatter(raw_frontmatter: str | None, *, path: PurePosixPath) -> dict[str, object]:
|
def _build_skill_record(*, bundle: SkillFilesBundle, docs_by_relpath: dict[DocsPath, MarkdownDocument]) -> SkillRecord:
|
||||||
"""Parse frontmatter YAML into a mapping for downstream validation.
|
|
||||||
|
|
||||||
This helper is retained for compatibility with model-validation tests that
|
|
||||||
exercise gate behavior directly at parse boundaries.
|
|
||||||
"""
|
|
||||||
if raw_frontmatter is None:
|
|
||||||
raise ValueError(f"missing YAML frontmatter: {path.as_posix()}")
|
|
||||||
|
|
||||||
parsed = yaml.safe_load(raw_frontmatter)
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
raise TypeError(f"frontmatter must parse to an object: {path.as_posix()}")
|
|
||||||
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def _build_skill_record(
|
|
||||||
*, bundle: SkillFilesBundle, docs_by_relpath: dict[PurePosixPath, MarkdownDocument]
|
|
||||||
) -> SkillRecord:
|
|
||||||
stored = build_stored_skill(bundle=bundle, docs_by_relpath=docs_by_relpath)
|
stored = build_stored_skill(bundle=bundle, docs_by_relpath=docs_by_relpath)
|
||||||
metadata = stored.frontmatter.x_personal_mcp
|
metadata = stored.frontmatter.x_personal_mcp
|
||||||
references: dict[str, ReferenceRecord] = {}
|
references: dict[str, ReferenceRecord] = {}
|
||||||
@@ -47,7 +26,7 @@ def _build_skill_record(
|
|||||||
references[ref_id] = ReferenceRecord(
|
references[ref_id] = ReferenceRecord(
|
||||||
ref_id=ref_id,
|
ref_id=ref_id,
|
||||||
uri=f"resource://skills/{metadata.id}/references/{ref_id}",
|
uri=f"resource://skills/{metadata.id}/references/{ref_id}",
|
||||||
relpath=ref.relpath.as_posix(),
|
relpath=ref.relpath,
|
||||||
mime_type=ref.entry.mime_type,
|
mime_type=ref.entry.mime_type,
|
||||||
title=ref.entry.title,
|
title=ref.entry.title,
|
||||||
content=ref.content,
|
content=ref.content,
|
||||||
@@ -61,7 +40,7 @@ def _build_skill_record(
|
|||||||
tags=tuple(metadata.tags),
|
tags=tuple(metadata.tags),
|
||||||
capabilities=tuple(metadata.capabilities),
|
capabilities=tuple(metadata.capabilities),
|
||||||
document_uri=f"resource://skills/{metadata.id}/document",
|
document_uri=f"resource://skills/{metadata.id}/document",
|
||||||
document_relpath=stored.relpath.as_posix(),
|
document_relpath=stored.relpath,
|
||||||
document_content=stored.content,
|
document_content=stored.content,
|
||||||
references=references,
|
references=references,
|
||||||
)
|
)
|
||||||
@@ -80,7 +59,7 @@ def _build_prompt_record(*, bundle: PromptFilesBundle) -> PromptRecord:
|
|||||||
capabilities=tuple(metadata.capabilities),
|
capabilities=tuple(metadata.capabilities),
|
||||||
arguments=dict(metadata.arguments),
|
arguments=dict(metadata.arguments),
|
||||||
document_uri=f"resource://prompts/{metadata.id}/document",
|
document_uri=f"resource://prompts/{metadata.id}/document",
|
||||||
document_relpath=stored.relpath.as_posix(),
|
document_relpath=stored.relpath,
|
||||||
document_content=stored.content,
|
document_content=stored.content,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -116,23 +95,14 @@ def _build_tag_index_prompts(
|
|||||||
return {tag: tuple(ids) for tag, ids in sorted(tag_index.items())}
|
return {tag: tuple(ids) for tag, ids in sorted(tag_index.items())}
|
||||||
|
|
||||||
|
|
||||||
def _resolve_docs_root(*, package_anchor: str, docs_root: str) -> Path:
|
@cache
|
||||||
package = importlib.import_module(package_anchor)
|
def get_docs_registry() -> DocsRegistry:
|
||||||
package_file = getattr(package, "__file__", None)
|
root = files("personal_mcp").joinpath("docs")
|
||||||
if package_file is None:
|
if not root.is_dir():
|
||||||
raise ValueError(f"package anchor '{package_anchor}' has no file location")
|
raise FileNotFoundError(f"docs root does not exist or is not a directory: {root}")
|
||||||
|
docs = MarkdownDocument.from_root(root)
|
||||||
|
|
||||||
resolved = Path(package_file).resolve().parent.joinpath(docs_root).resolve()
|
docs_markdown_by_path = {relpath: doc.content for relpath, doc in docs.items()}
|
||||||
if not resolved.exists() or not resolved.is_dir():
|
|
||||||
raise FileNotFoundError(f"docs root does not exist or is not a directory: {resolved}")
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
|
|
||||||
def load_docs_registry(*, package_anchor: str, docs_root: str = "docs") -> DocsRegistry:
|
|
||||||
docs_path = _resolve_docs_root(package_anchor=package_anchor, docs_root=docs_root)
|
|
||||||
docs = MarkdownDocument.from_root(docs_path)
|
|
||||||
|
|
||||||
docs_markdown_by_path = {_normalize_docs_path(relpath.as_posix()): doc.content for relpath, doc in docs.items()}
|
|
||||||
|
|
||||||
skill_bundles = SkillFilesBundle.from_docs(docs.values())
|
skill_bundles = SkillFilesBundle.from_docs(docs.values())
|
||||||
prompt_bundles = PromptFilesBundle.from_docs(docs.values())
|
prompt_bundles = PromptFilesBundle.from_docs(docs.values())
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import re
|
|||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
|
from typing import Annotated
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from pydantic import BeforeValidator
|
||||||
from pydantic import ConfigDict
|
from pydantic import ConfigDict
|
||||||
from pydantic import field_validator
|
|
||||||
|
|
||||||
SKILL_ID_RE: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9-]*$")
|
SKILL_ID_RE: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9-]*$")
|
||||||
SEMVER_RE: Final[re.Pattern[str]] = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$")
|
SEMVER_RE: Final[re.Pattern[str]] = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$")
|
||||||
@@ -29,30 +30,35 @@ def frozen_mapping[K, V](value: Mapping[K, V] | None = None) -> Mapping[K, V]:
|
|||||||
return MappingProxyType(dict(value) if value is not None else {})
|
return MappingProxyType(dict(value) if value is not None else {})
|
||||||
|
|
||||||
|
|
||||||
|
def parse_docs_path(value: str | PurePosixPath) -> PurePosixPath:
|
||||||
|
raw = value.as_posix() if isinstance(value, PurePosixPath) else value
|
||||||
|
if "\\" in raw:
|
||||||
|
raise ValueError("docs path must use POSIX separators")
|
||||||
|
|
||||||
|
path = PurePosixPath(raw)
|
||||||
|
if path.is_absolute() or ".." in path.parts:
|
||||||
|
raise ValueError("path must be a docs-relative path")
|
||||||
|
if path.as_posix() != raw:
|
||||||
|
raise ValueError("path must be normalized")
|
||||||
|
if path.suffix.lower() != ".md":
|
||||||
|
raise ValueError("path must point to a markdown file")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def parse_reference_path(value: str | PurePosixPath) -> PurePosixPath:
|
||||||
|
path = parse_docs_path(value)
|
||||||
|
if len(path.parts) < 2 or path.parts[0] != "references":
|
||||||
|
raise ValueError("reference path must stay under references/")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
type DocsPath = Annotated[PurePosixPath, BeforeValidator(parse_docs_path)]
|
||||||
|
type ReferencePath = Annotated[PurePosixPath, BeforeValidator(parse_reference_path)]
|
||||||
|
|
||||||
|
|
||||||
class ReferenceEntry(StrictFrozenModel):
|
class ReferenceEntry(StrictFrozenModel):
|
||||||
"""Reference metadata for a markdown file within a skill."""
|
"""Reference metadata for a markdown file within a skill."""
|
||||||
|
|
||||||
path: str
|
path: ReferencePath
|
||||||
mime_type: str = "text/markdown"
|
mime_type: str = "text/markdown"
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
|
|
||||||
@field_validator("path")
|
|
||||||
@classmethod
|
|
||||||
def validate_reference_path(cls, value: str) -> str:
|
|
||||||
path = PurePosixPath(value)
|
|
||||||
if path.is_absolute() or ".." in path.parts:
|
|
||||||
raise ValueError("reference path must be a relative in-skill path")
|
|
||||||
if not str(path).startswith("references/"):
|
|
||||||
raise ValueError("reference path must stay under references/")
|
|
||||||
if path.suffix.lower() != ".md":
|
|
||||||
raise ValueError("reference path must target a markdown file")
|
|
||||||
return path.as_posix()
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_docs_path(path: str) -> str:
|
|
||||||
normalized = PurePosixPath(path)
|
|
||||||
if normalized.is_absolute() or ".." in normalized.parts:
|
|
||||||
raise ValueError("path must be a normalized docs-relative path")
|
|
||||||
if normalized.suffix.lower() != ".md":
|
|
||||||
raise ValueError("path must point to a markdown file")
|
|
||||||
return normalized.as_posix()
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import re
|
import re
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from pathlib import PurePosixPath
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
@@ -10,6 +9,7 @@ from pydantic import model_validator
|
|||||||
|
|
||||||
from .common import SEMVER_RE
|
from .common import SEMVER_RE
|
||||||
from .common import SKILL_ID_RE
|
from .common import SKILL_ID_RE
|
||||||
|
from .common import DocsPath
|
||||||
from .common import StrictFrozenModel
|
from .common import StrictFrozenModel
|
||||||
from .common import frozen_mapping
|
from .common import frozen_mapping
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ class StoredPrompt(StrictFrozenModel):
|
|||||||
"""Normalized prompt document content with path and frontmatter for storage in the registry."""
|
"""Normalized prompt document content with path and frontmatter for storage in the registry."""
|
||||||
|
|
||||||
prompt_id: str
|
prompt_id: str
|
||||||
relpath: PurePosixPath
|
relpath: DocsPath
|
||||||
content: str
|
content: str
|
||||||
frontmatter: PromptFrontmatter
|
frontmatter: PromptFrontmatter
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,22 @@ from collections.abc import Mapping
|
|||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic import field_validator
|
from pydantic import field_validator
|
||||||
|
|
||||||
|
from .common import DocsPath
|
||||||
from .common import StrictFrozenModel
|
from .common import StrictFrozenModel
|
||||||
from .common import frozen_mapping
|
from .common import frozen_mapping
|
||||||
from .prompt import PromptArgumentEntry
|
from .prompt import PromptArgumentEntry
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_docs_mapping() -> Mapping[DocsPath, str]:
|
||||||
|
return frozen_mapping()
|
||||||
|
|
||||||
|
|
||||||
class ReferenceRecord(StrictFrozenModel):
|
class ReferenceRecord(StrictFrozenModel):
|
||||||
"""Registry record for a resolved skill reference document."""
|
"""Registry record for a resolved skill reference document."""
|
||||||
|
|
||||||
ref_id: str
|
ref_id: str
|
||||||
uri: str
|
uri: str
|
||||||
relpath: str
|
relpath: DocsPath
|
||||||
mime_type: str
|
mime_type: str
|
||||||
title: str | None
|
title: str | None
|
||||||
content: str
|
content: str
|
||||||
@@ -29,7 +34,7 @@ class SkillRecord(StrictFrozenModel):
|
|||||||
tags: tuple[str, ...]
|
tags: tuple[str, ...]
|
||||||
capabilities: tuple[str, ...]
|
capabilities: tuple[str, ...]
|
||||||
document_uri: str
|
document_uri: str
|
||||||
document_relpath: str
|
document_relpath: DocsPath
|
||||||
document_content: str
|
document_content: str
|
||||||
references: Mapping[str, ReferenceRecord] = Field(default_factory=frozen_mapping)
|
references: Mapping[str, ReferenceRecord] = Field(default_factory=frozen_mapping)
|
||||||
|
|
||||||
@@ -74,7 +79,7 @@ class PromptRecord(StrictFrozenModel):
|
|||||||
capabilities: tuple[str, ...]
|
capabilities: tuple[str, ...]
|
||||||
arguments: Mapping[str, PromptArgumentEntry] = Field(default_factory=frozen_mapping)
|
arguments: Mapping[str, PromptArgumentEntry] = Field(default_factory=frozen_mapping)
|
||||||
document_uri: str
|
document_uri: str
|
||||||
document_relpath: str
|
document_relpath: DocsPath
|
||||||
document_content: str
|
document_content: str
|
||||||
|
|
||||||
@field_validator("arguments", mode="before")
|
@field_validator("arguments", mode="before")
|
||||||
@@ -194,16 +199,27 @@ class DocsRegistry(StrictFrozenModel):
|
|||||||
"""In-memory index of loaded skills, prompts, and docs content."""
|
"""In-memory index of loaded skills, prompts, and docs content."""
|
||||||
|
|
||||||
skills_by_id: Mapping[str, SkillRecord] = Field(default_factory=frozen_mapping)
|
skills_by_id: Mapping[str, SkillRecord] = Field(default_factory=frozen_mapping)
|
||||||
|
"""Maps each skill identifier to its fully resolved registry record."""
|
||||||
skills_in_load_order: tuple[str, ...]
|
skills_in_load_order: tuple[str, ...]
|
||||||
|
"""Preserves skill identifiers in deterministic source loading order."""
|
||||||
skills_summary_in_load_order: tuple[SkillSummaryRecord, ...]
|
skills_summary_in_load_order: tuple[SkillSummaryRecord, ...]
|
||||||
docs_markdown_by_path: Mapping[str, str] = Field(default_factory=frozen_mapping)
|
"""Stores compact skill summaries in the same deterministic loading order."""
|
||||||
docs_markdown_path_index: tuple[str, ...]
|
docs_markdown_by_path: Mapping[DocsPath, str] = Field(default_factory=_empty_docs_mapping)
|
||||||
|
"""Maps each documentation path to its loaded Markdown content."""
|
||||||
|
docs_markdown_path_index: tuple[DocsPath, ...]
|
||||||
|
"""Lists documentation paths in deterministic index order."""
|
||||||
tag_to_skill_ids: Mapping[str, tuple[str, ...]] = Field(default_factory=frozen_mapping)
|
tag_to_skill_ids: Mapping[str, tuple[str, ...]] = Field(default_factory=frozen_mapping)
|
||||||
|
"""Indexes skill identifiers by tag for catalog filtering and search."""
|
||||||
capability_to_skill_ids: Mapping[str, tuple[str, ...]] = Field(default_factory=frozen_mapping)
|
capability_to_skill_ids: Mapping[str, tuple[str, ...]] = Field(default_factory=frozen_mapping)
|
||||||
|
"""Indexes skill identifiers by the capabilities they provide."""
|
||||||
prompts_by_id: Mapping[str, PromptRecord] = Field(default_factory=frozen_mapping)
|
prompts_by_id: Mapping[str, PromptRecord] = Field(default_factory=frozen_mapping)
|
||||||
|
"""Maps each prompt identifier to its fully resolved registry record."""
|
||||||
prompts_in_load_order: tuple[str, ...] = ()
|
prompts_in_load_order: tuple[str, ...] = ()
|
||||||
|
"""Preserves prompt identifiers in deterministic source loading order."""
|
||||||
prompts_summary_in_load_order: tuple[PromptSummaryRecord, ...] = ()
|
prompts_summary_in_load_order: tuple[PromptSummaryRecord, ...] = ()
|
||||||
|
"""Stores compact prompt summaries in the same deterministic loading order."""
|
||||||
tag_to_prompt_ids: Mapping[str, tuple[str, ...]] = Field(default_factory=frozen_mapping)
|
tag_to_prompt_ids: Mapping[str, tuple[str, ...]] = Field(default_factory=frozen_mapping)
|
||||||
|
"""Indexes prompt identifiers by tag for catalog filtering and search."""
|
||||||
|
|
||||||
@field_validator(
|
@field_validator(
|
||||||
"skills_by_id",
|
"skills_by_id",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from pathlib import PurePosixPath
|
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
@@ -8,6 +7,7 @@ from pydantic import model_validator
|
|||||||
|
|
||||||
from .common import SEMVER_RE
|
from .common import SEMVER_RE
|
||||||
from .common import SKILL_ID_RE
|
from .common import SKILL_ID_RE
|
||||||
|
from .common import DocsPath
|
||||||
from .common import ReferenceEntry
|
from .common import ReferenceEntry
|
||||||
from .common import StrictFrozenModel
|
from .common import StrictFrozenModel
|
||||||
from .common import frozen_mapping
|
from .common import frozen_mapping
|
||||||
@@ -91,7 +91,7 @@ class StoredSkillReference(StrictFrozenModel):
|
|||||||
"""Structured representation of a skill reference markdown document."""
|
"""Structured representation of a skill reference markdown document."""
|
||||||
|
|
||||||
ref_id: str
|
ref_id: str
|
||||||
relpath: PurePosixPath
|
relpath: DocsPath
|
||||||
content: str
|
content: str
|
||||||
entry: ReferenceEntry
|
entry: ReferenceEntry
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ class StoredSkill(StrictFrozenModel):
|
|||||||
"""Structured representation of a skill markdown document."""
|
"""Structured representation of a skill markdown document."""
|
||||||
|
|
||||||
skill_id: str
|
skill_id: str
|
||||||
relpath: PurePosixPath
|
relpath: DocsPath
|
||||||
content: str
|
content: str
|
||||||
frontmatter: SkillFrontmatter
|
frontmatter: SkillFrontmatter
|
||||||
references: Mapping[str, StoredSkillReference] = Field(default_factory=frozen_mapping)
|
references: Mapping[str, StoredSkillReference] = Field(default_factory=frozen_mapping)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from .models.common import _normalize_docs_path
|
from .models.common import parse_docs_path
|
||||||
from .models.registry import DocsRegistry
|
from .models.registry import DocsRegistry
|
||||||
|
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ def read_skill_document(registry: DocsRegistry, skill_id: str) -> dict[str, str]
|
|||||||
"id": skill.skill_id,
|
"id": skill.skill_id,
|
||||||
"uri": skill.document_uri,
|
"uri": skill.document_uri,
|
||||||
"format": "markdown",
|
"format": "markdown",
|
||||||
"source_path": f"docs/{skill.document_relpath}",
|
"source_path": f"docs/{skill.document_relpath.as_posix()}",
|
||||||
"content": skill.document_content,
|
"content": skill.document_content,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,20 +32,20 @@ def read_skill_reference(
|
|||||||
"skill_id": skill_id,
|
"skill_id": skill_id,
|
||||||
"uri": reference.uri,
|
"uri": reference.uri,
|
||||||
"format": "markdown",
|
"format": "markdown",
|
||||||
"source_path": f"docs/{reference.relpath}",
|
"source_path": f"docs/{reference.relpath.as_posix()}",
|
||||||
"content": reference.content,
|
"content": reference.content,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def read_docs_markdown_path(registry: DocsRegistry, path: str) -> dict[str, str]:
|
def read_docs_markdown_path(registry: DocsRegistry, path: str) -> dict[str, str]:
|
||||||
normalized_path = _normalize_docs_path(path)
|
docs_path = parse_docs_path(path)
|
||||||
if normalized_path not in registry.docs_markdown_by_path:
|
if docs_path not in registry.docs_markdown_by_path:
|
||||||
raise KeyError(f"unknown docs path: {normalized_path}")
|
raise KeyError(f"unknown docs path: {docs_path.as_posix()}")
|
||||||
return {
|
return {
|
||||||
"uri": f"resource://docs/{normalized_path}",
|
"uri": f"resource://docs/{docs_path.as_posix()}",
|
||||||
"format": "markdown",
|
"format": "markdown",
|
||||||
"source_path": f"docs/{normalized_path}",
|
"source_path": f"docs/{docs_path.as_posix()}",
|
||||||
"content": registry.docs_markdown_by_path[normalized_path],
|
"content": registry.docs_markdown_by_path[docs_path],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -57,6 +57,6 @@ def read_prompt_document(registry: DocsRegistry, prompt_id: str) -> dict[str, st
|
|||||||
"id": prompt.prompt_id,
|
"id": prompt.prompt_id,
|
||||||
"uri": prompt.document_uri,
|
"uri": prompt.document_uri,
|
||||||
"format": "markdown",
|
"format": "markdown",
|
||||||
"source_path": f"docs/{prompt.document_relpath}",
|
"source_path": f"docs/{prompt.document_relpath.as_posix()}",
|
||||||
"content": prompt.document_content,
|
"content": prompt.document_content,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from importlib.resources.abc import Traversable
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from ..registry.models.common import SKILL_ID_RE
|
|
||||||
from ..registry.models.common import ReferenceEntry
|
|
||||||
from ..registry.models.prompt import PromptFrontmatter
|
|
||||||
from ..registry.models.skill import SkillFrontmatter
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_frontmatter(markdown: str, *, path: str) -> tuple[dict[str, Any], str]:
|
|
||||||
if not markdown.startswith("---"):
|
|
||||||
raise ValueError(f"missing YAML frontmatter: {path}")
|
|
||||||
|
|
||||||
lines = markdown.splitlines()
|
|
||||||
if len(lines) < 3 or lines[0].strip() != "---":
|
|
||||||
raise ValueError(f"invalid YAML frontmatter start: {path}")
|
|
||||||
|
|
||||||
end_index: int | None = None
|
|
||||||
for i in range(1, len(lines)):
|
|
||||||
if lines[i].strip() == "---":
|
|
||||||
end_index = i
|
|
||||||
break
|
|
||||||
|
|
||||||
if end_index is None:
|
|
||||||
raise ValueError(f"missing YAML frontmatter terminator: {path}")
|
|
||||||
|
|
||||||
raw_yaml = "\n".join(lines[1:end_index])
|
|
||||||
body = "\n".join(lines[end_index + 1 :])
|
|
||||||
parsed = yaml.safe_load(raw_yaml)
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
raise TypeError(f"frontmatter must parse to an object: {path}")
|
|
||||||
return parsed, body
|
|
||||||
|
|
||||||
|
|
||||||
def _walk_markdown(
|
|
||||||
node: Traversable,
|
|
||||||
*,
|
|
||||||
prefix: PurePosixPath | None = None,
|
|
||||||
) -> list[tuple[str, Traversable]]:
|
|
||||||
prefix = PurePosixPath() if prefix is None else prefix
|
|
||||||
results: list[tuple[str, Traversable]] = []
|
|
||||||
for child in sorted(node.iterdir(), key=lambda item: item.name):
|
|
||||||
relpath = prefix.joinpath(child.name)
|
|
||||||
if child.is_dir():
|
|
||||||
results.extend(_walk_markdown(child, prefix=relpath))
|
|
||||||
continue
|
|
||||||
if not child.is_file() or not child.name.lower().endswith(".md"):
|
|
||||||
continue
|
|
||||||
results.append((relpath.as_posix(), child))
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_skill_frontmatter(raw: dict[str, Any], *, skill_dir_name: str) -> SkillFrontmatter:
|
|
||||||
model = SkillFrontmatter.model_validate(raw)
|
|
||||||
if model.name != skill_dir_name:
|
|
||||||
raise ValueError("frontmatter name must exactly match skill directory name")
|
|
||||||
if model.x_personal_mcp.id != model.name:
|
|
||||||
raise ValueError("x-personal-mcp.id must exactly match name")
|
|
||||||
expected_capability = f"resource://skills/{model.name}/document"
|
|
||||||
if expected_capability not in model.x_personal_mcp.capabilities:
|
|
||||||
raise ValueError(f"capabilities must include {expected_capability}")
|
|
||||||
return model
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_prompt_frontmatter(raw: dict[str, Any], *, prompt_dir_name: str) -> PromptFrontmatter:
|
|
||||||
model = PromptFrontmatter.model_validate(raw)
|
|
||||||
if model.name != prompt_dir_name:
|
|
||||||
raise ValueError("frontmatter name must exactly match prompt directory name")
|
|
||||||
if model.x_personal_mcp.id != model.name:
|
|
||||||
raise ValueError("x-personal-mcp.id must exactly match name")
|
|
||||||
expected_capability = f"resource://prompts/{model.name}/document"
|
|
||||||
if expected_capability not in model.x_personal_mcp.capabilities:
|
|
||||||
raise ValueError(f"capabilities must include {expected_capability}")
|
|
||||||
return model
|
|
||||||
|
|
||||||
|
|
||||||
def _title_from_reference_filename(filename: str) -> str:
|
|
||||||
stem = PurePosixPath(filename).stem
|
|
||||||
normalized = stem.replace("-", " ").replace("_", " ").split()
|
|
||||||
if not normalized:
|
|
||||||
return stem
|
|
||||||
return " ".join(token.capitalize() for token in normalized)
|
|
||||||
|
|
||||||
|
|
||||||
def _reference_id_from_filename(filename: str) -> str | None:
|
|
||||||
stem = PurePosixPath(filename).stem.strip().lower().replace("_", "-")
|
|
||||||
normalized = re.sub(r"[^a-z0-9-]+", "-", stem)
|
|
||||||
normalized = re.sub(r"-+", "-", normalized).strip("-")
|
|
||||||
if not normalized:
|
|
||||||
return None
|
|
||||||
if not SKILL_ID_RE.fullmatch(normalized):
|
|
||||||
return None
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _discover_top_level_references(
|
|
||||||
*,
|
|
||||||
skill_dir: Traversable,
|
|
||||||
) -> dict[str, ReferenceEntry]:
|
|
||||||
references_dir = skill_dir.joinpath("references")
|
|
||||||
if not references_dir.is_dir():
|
|
||||||
return {}
|
|
||||||
|
|
||||||
discovered: dict[str, ReferenceEntry] = {}
|
|
||||||
for child in sorted(references_dir.iterdir(), key=lambda item: item.name):
|
|
||||||
if child.is_dir() or not child.is_file():
|
|
||||||
continue
|
|
||||||
if not child.name.lower().endswith(".md"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
ref_id = _reference_id_from_filename(child.name)
|
|
||||||
if ref_id is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
discovered[ref_id] = ReferenceEntry(
|
|
||||||
path=PurePosixPath("references").joinpath(child.name).as_posix(),
|
|
||||||
title=_title_from_reference_filename(child.name),
|
|
||||||
)
|
|
||||||
return discovered
|
|
||||||
+10
-13
@@ -1,19 +1,19 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from personal_mcp.mcp import mcp
|
from ..config import Settings
|
||||||
from personal_mcp.web.config import Settings
|
from ..config import get_settings
|
||||||
from personal_mcp.web.config import get_settings
|
from ..mcp import create_mcp
|
||||||
from personal_mcp.web.docs_mount import mount_docs_static
|
from .docs_mount import mount_docs_static
|
||||||
from personal_mcp.web.health import router as health_router
|
from .health import router as health_router
|
||||||
|
|
||||||
|
|
||||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
runtime_settings = settings or get_settings()
|
runtime_settings = settings if settings is not None else get_settings()
|
||||||
mcp_app = mcp.http_app(
|
mcp_app = create_mcp().http_app(
|
||||||
path=runtime_settings.mcp_route,
|
path=runtime_settings.mounts.mcp,
|
||||||
json_response=True,
|
json_response=True,
|
||||||
stateless_http=True,
|
stateless_http=True,
|
||||||
transport="http",
|
transport=runtime_settings.mcp_transport,
|
||||||
)
|
)
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
debug=runtime_settings.debug,
|
debug=runtime_settings.debug,
|
||||||
@@ -27,11 +27,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
mount_docs_static(
|
mount_docs_static(
|
||||||
app,
|
app,
|
||||||
docs_route=runtime_settings.docs_route,
|
docs_route=runtime_settings.mounts.docs,
|
||||||
site_dir=runtime_settings.site_dir,
|
site_dir=runtime_settings.site_dir,
|
||||||
)
|
)
|
||||||
app.mount("/", mcp_app, name="mcp")
|
app.mount("/", mcp_app, name="mcp")
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
app = create_app()
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from pydantic import Field
|
|
||||||
from pydantic_settings import BaseSettings
|
|
||||||
from pydantic_settings import SettingsConfigDict
|
|
||||||
|
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
"""Runtime settings for the HTTP MCP and docs server."""
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
|
||||||
env_file=".env",
|
|
||||||
env_prefix="PERSONAL_MCP_",
|
|
||||||
extra="ignore",
|
|
||||||
)
|
|
||||||
|
|
||||||
host: str = "127.0.0.1"
|
|
||||||
port: int = 8000
|
|
||||||
debug: bool = False
|
|
||||||
log_level: str = "info"
|
|
||||||
docs_route: str = "/docs"
|
|
||||||
mcp_route: str = "/mcp"
|
|
||||||
site_dir: Path = Field(default=_REPO_ROOT / "site")
|
|
||||||
|
|
||||||
|
|
||||||
def get_settings() -> Settings:
|
|
||||||
return Settings()
|
|
||||||
@@ -98,7 +98,7 @@ class TestMarkdownDocument:
|
|||||||
|
|
||||||
def test_none_for_incomplete_skill(self) -> None:
|
def test_none_for_incomplete_skill(self) -> None:
|
||||||
"""Ensures skill_slug is None for incomplete skills paths."""
|
"""Ensures skill_slug is None for incomplete skills paths."""
|
||||||
doc = MarkdownDocument(relpath=PurePosixPath("skills/demo"), content="#")
|
doc = MarkdownDocument(relpath=PurePosixPath("skills/demo.md"), content="#")
|
||||||
|
|
||||||
assert doc.skill_slug is None
|
assert doc.skill_slug is None
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ class TestMarkdownDocument:
|
|||||||
|
|
||||||
def test_none_for_incomplete_prompt(self) -> None:
|
def test_none_for_incomplete_prompt(self) -> None:
|
||||||
"""Ensures prompt_slug is None for incomplete prompt paths."""
|
"""Ensures prompt_slug is None for incomplete prompt paths."""
|
||||||
doc = MarkdownDocument(relpath=PurePosixPath("prompts/demo"), content="#")
|
doc = MarkdownDocument(relpath=PurePosixPath("prompts/demo.md"), content="#")
|
||||||
|
|
||||||
assert doc.prompt_slug is None
|
assert doc.prompt_slug is None
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import yaml
|
|||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from personal_mcp.registry.ingest.document import MarkdownDocument
|
from personal_mcp.registry.ingest.document import MarkdownDocument
|
||||||
from personal_mcp.registry.load import _parse_frontmatter
|
from personal_mcp.registry.models.common import ReferenceEntry
|
||||||
from personal_mcp.registry.models.common import _normalize_docs_path
|
from personal_mcp.registry.models.common import parse_docs_path
|
||||||
|
from personal_mcp.registry.models.common import parse_reference_path
|
||||||
from personal_mcp.registry.models.registry import DocsRegistry
|
from personal_mcp.registry.models.registry import DocsRegistry
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
pytestmark = pytest.mark.unit
|
||||||
@@ -95,37 +96,43 @@ def assert_model_is_frozen(instance: Any, *, attr: str, value: Any) -> None:
|
|||||||
setattr(instance, attr, value)
|
setattr(instance, attr, value)
|
||||||
|
|
||||||
|
|
||||||
class TestGate1LayoutValidation:
|
|
||||||
"""Gate 1: source layout and parse shape constraints."""
|
|
||||||
|
|
||||||
def test_parse_frontmatter_requires_payload(self) -> None:
|
|
||||||
"""Ensures missing frontmatter fails before metadata validation."""
|
|
||||||
with pytest.raises(ValueError, match="missing YAML frontmatter"):
|
|
||||||
_parse_frontmatter(None, path=PurePosixPath("skills/alpha/SKILL.md"))
|
|
||||||
|
|
||||||
def test_parse_frontmatter_requires_mapping(self) -> None:
|
|
||||||
"""Ensures non-object YAML payloads are rejected at parse time."""
|
|
||||||
with pytest.raises(TypeError, match="frontmatter must parse to an object"):
|
|
||||||
_parse_frontmatter("- one\n- two\n", path=PurePosixPath("skills/alpha/SKILL.md"))
|
|
||||||
|
|
||||||
def test_parse_frontmatter_accepts_mapping(self) -> None:
|
|
||||||
"""Ensures valid mapping payload is returned for downstream validation."""
|
|
||||||
parsed = _parse_frontmatter("name: alpha\n", path=PurePosixPath("skills/alpha/SKILL.md"))
|
|
||||||
|
|
||||||
assert parsed == {"name": "alpha"}
|
|
||||||
|
|
||||||
|
|
||||||
class TestGate5ContractValidation:
|
class TestGate5ContractValidation:
|
||||||
"""Gate 5: canonical resource-path contract normalization."""
|
"""Gate 5: canonical resource-path contracts."""
|
||||||
|
|
||||||
def test_normalize_docs_path_keeps_posix_relative_paths(self) -> None:
|
def test_parse_docs_path_returns_pure_posix_path(self) -> None:
|
||||||
"""Ensures docs paths remain normalized before registry publication."""
|
"""Ensures boundary strings become path objects before publication."""
|
||||||
assert _normalize_docs_path("skills/demo/SKILL.md") == "skills/demo/SKILL.md"
|
path = parse_docs_path("skills/demo/SKILL.md")
|
||||||
|
|
||||||
def test_normalize_docs_path_rejects_parent_traversal(self) -> None:
|
assert path == PurePosixPath("skills/demo/SKILL.md")
|
||||||
"""Ensures traversal attempts fail contract validation."""
|
assert isinstance(path, PurePosixPath)
|
||||||
with pytest.raises(ValueError, match="normalized docs-relative path"):
|
|
||||||
_normalize_docs_path("../outside.md")
|
@pytest.mark.parametrize(
|
||||||
|
"value",
|
||||||
|
(
|
||||||
|
"/absolute.md",
|
||||||
|
"../outside.md",
|
||||||
|
"skills\\demo\\SKILL.md",
|
||||||
|
"skills//demo/SKILL.md",
|
||||||
|
"skills/demo/README.txt",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_parse_docs_path_rejects_invalid_paths(self, value: str) -> None:
|
||||||
|
"""Ensures non-canonical docs paths fail contract validation."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
parse_docs_path(value)
|
||||||
|
|
||||||
|
def test_reference_entry_materializes_reference_path(self) -> None:
|
||||||
|
"""Ensures authored reference strings become constrained path objects."""
|
||||||
|
entry = ReferenceEntry.model_validate({"path": "references/guides/setup.md"})
|
||||||
|
|
||||||
|
assert entry.path == PurePosixPath("references/guides/setup.md")
|
||||||
|
assert parse_reference_path(entry.path) == entry.path
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ("guide.md", "other/guide.md", "references.md"))
|
||||||
|
def test_reference_path_stays_under_references(self, value: str) -> None:
|
||||||
|
"""Ensures in-skill references remain below the references directory."""
|
||||||
|
with pytest.raises(ValueError, match="stay under references"):
|
||||||
|
parse_reference_path(value)
|
||||||
|
|
||||||
|
|
||||||
class TestGate6FreezeValidation:
|
class TestGate6FreezeValidation:
|
||||||
@@ -133,13 +140,14 @@ class TestGate6FreezeValidation:
|
|||||||
|
|
||||||
def test_docs_registry_copies_mapping_inputs(self) -> None:
|
def test_docs_registry_copies_mapping_inputs(self) -> None:
|
||||||
"""Ensures registry snapshots are isolated from caller-owned mapping mutations."""
|
"""Ensures registry snapshots are isolated from caller-owned mapping mutations."""
|
||||||
source_docs = {"index.md": "# index\n"}
|
index_path = PurePosixPath("index.md")
|
||||||
|
source_docs = {index_path: "# index\n"}
|
||||||
registry = DocsRegistry(
|
registry = DocsRegistry(
|
||||||
skills_by_id={},
|
skills_by_id={},
|
||||||
skills_in_load_order=(),
|
skills_in_load_order=(),
|
||||||
skills_summary_in_load_order=(),
|
skills_summary_in_load_order=(),
|
||||||
docs_markdown_by_path=source_docs,
|
docs_markdown_by_path=source_docs,
|
||||||
docs_markdown_path_index=("index.md",),
|
docs_markdown_path_index=(index_path,),
|
||||||
tag_to_skill_ids={},
|
tag_to_skill_ids={},
|
||||||
capability_to_skill_ids={},
|
capability_to_skill_ids={},
|
||||||
prompts_by_id={},
|
prompts_by_id={},
|
||||||
@@ -148,9 +156,10 @@ class TestGate6FreezeValidation:
|
|||||||
tag_to_prompt_ids={},
|
tag_to_prompt_ids={},
|
||||||
)
|
)
|
||||||
|
|
||||||
source_docs["other.md"] = "# other\n"
|
source_docs[PurePosixPath("other.md")] = "# other\n"
|
||||||
|
|
||||||
assert "other.md" not in registry.docs_markdown_by_path
|
assert PurePosixPath("other.md") not in registry.docs_markdown_by_path
|
||||||
|
assert registry.docs_markdown_path_index == (index_path,)
|
||||||
|
|
||||||
def test_docs_registry_instance_is_frozen(self) -> None:
|
def test_docs_registry_instance_is_frozen(self) -> None:
|
||||||
"""Ensures frozen model prevents attribute reassignment."""
|
"""Ensures frozen model prevents attribute reassignment."""
|
||||||
@@ -168,4 +177,8 @@ class TestGate6FreezeValidation:
|
|||||||
tag_to_prompt_ids={},
|
tag_to_prompt_ids={},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert_model_is_frozen(registry, attr="docs_markdown_path_index", value=("index.md",))
|
assert_model_is_frozen(
|
||||||
|
registry,
|
||||||
|
attr="docs_markdown_path_index",
|
||||||
|
value=(PurePosixPath("index.md"),),
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from personal_mcp.registry.ingest.document import MarkdownDocument
|
||||||
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
|
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
|
||||||
from personal_mcp.registry.load import _build_prompt_record
|
from personal_mcp.registry.load import _build_prompt_record
|
||||||
from personal_mcp.registry.load import load_docs_registry
|
from personal_mcp.registry.load import get_docs_registry
|
||||||
from personal_mcp.registry.models.registry import PromptSummaryRecord
|
from personal_mcp.registry.models.registry import PromptSummaryRecord
|
||||||
from tests.registry.models.test_document_validation import as_markdown
|
|
||||||
from tests.registry.models.test_document_validation import assert_model_is_frozen
|
from tests.registry.models.test_document_validation import assert_model_is_frozen
|
||||||
from tests.registry.models.test_document_validation import make_markdown_document
|
from tests.registry.models.test_document_validation import make_markdown_document
|
||||||
from tests.registry.models.test_document_validation import make_prompt_frontmatter_payload
|
from tests.registry.models.test_document_validation import make_prompt_frontmatter_payload
|
||||||
@@ -91,7 +93,7 @@ class TestPromptValidationGates:
|
|||||||
record = _build_prompt_record(bundle=bundle)
|
record = _build_prompt_record(bundle=bundle)
|
||||||
|
|
||||||
assert record.document_uri == "resource://prompts/initial/document"
|
assert record.document_uri == "resource://prompts/initial/document"
|
||||||
assert record.document_relpath == "prompts/initial/PROMPT.md"
|
assert record.document_relpath == PurePosixPath("prompts/initial/PROMPT.md")
|
||||||
|
|
||||||
def test_preserves_argument_schema(self) -> None:
|
def test_preserves_argument_schema(self) -> None:
|
||||||
"""Ensures argument metadata survives conversion unchanged."""
|
"""Ensures argument metadata survives conversion unchanged."""
|
||||||
@@ -114,21 +116,31 @@ class TestPromptValidationGates:
|
|||||||
class TestGate4GraphValidation:
|
class TestGate4GraphValidation:
|
||||||
"""Gate 4: validate cross-entity identifier coherence."""
|
"""Gate 4: validate cross-entity identifier coherence."""
|
||||||
|
|
||||||
def test_prompt_id_collision_with_skill_id_fails(self, tmp_path) -> None:
|
def test_prompt_id_collision_with_skill_id_fails(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Ensures prompt and skill ids cannot collide in published registry."""
|
"""Ensures prompt and skill ids cannot collide in published registry."""
|
||||||
skill_dir = tmp_path / "skills" / "shared"
|
|
||||||
prompt_dir = tmp_path / "prompts" / "shared"
|
|
||||||
skill_dir.mkdir(parents=True)
|
|
||||||
prompt_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
skill_frontmatter = make_skill_frontmatter_payload(skill_id="shared")
|
skill_frontmatter = make_skill_frontmatter_payload(skill_id="shared")
|
||||||
prompt_frontmatter = make_prompt_frontmatter_payload(prompt_id="shared")
|
prompt_frontmatter = make_prompt_frontmatter_payload(prompt_id="shared")
|
||||||
|
documents = {
|
||||||
|
PurePosixPath("skills/shared/SKILL.md"): make_markdown_document(
|
||||||
|
"skills/shared/SKILL.md",
|
||||||
|
frontmatter=skill_frontmatter,
|
||||||
|
),
|
||||||
|
PurePosixPath("prompts/shared/PROMPT.md"): make_markdown_document(
|
||||||
|
"prompts/shared/PROMPT.md",
|
||||||
|
frontmatter=prompt_frontmatter,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
(skill_dir / "SKILL.md").write_text(as_markdown(skill_frontmatter), encoding="utf-8")
|
def fake_from_root(_cls, _root):
|
||||||
(prompt_dir / "PROMPT.md").write_text(as_markdown(prompt_frontmatter), encoding="utf-8")
|
return documents
|
||||||
|
|
||||||
|
monkeypatch.setattr(MarkdownDocument, "from_root", classmethod(fake_from_root))
|
||||||
|
get_docs_registry.cache_clear()
|
||||||
|
try:
|
||||||
with pytest.raises(ValueError, match="prompt_id collides with existing skill_id"):
|
with pytest.raises(ValueError, match="prompt_id collides with existing skill_id"):
|
||||||
load_docs_registry(package_anchor="personal_mcp", docs_root=str(tmp_path))
|
get_docs_registry()
|
||||||
|
finally:
|
||||||
|
get_docs_registry.cache_clear()
|
||||||
|
|
||||||
class TestGate5ContractValidation:
|
class TestGate5ContractValidation:
|
||||||
"""Gate 5: validate model_dump contract shape for API surfaces."""
|
"""Gate 5: validate model_dump contract shape for API surfaces."""
|
||||||
@@ -187,5 +199,4 @@ class TestPromptValidationGates:
|
|||||||
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
|
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
|
||||||
record = _build_prompt_record(bundle=bundle)
|
record = _build_prompt_record(bundle=bundle)
|
||||||
|
|
||||||
with pytest.raises(ValidationError, match="Instance is frozen"):
|
assert_model_is_frozen(record.arguments["topic"], attr="required", value=False)
|
||||||
record.arguments["topic"].required = False
|
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from personal_mcp.catalog.server import build_skill_detail_payload
|
||||||
from personal_mcp.registry.models.prompt import PromptArgumentEntry
|
from personal_mcp.registry.models.prompt import PromptArgumentEntry
|
||||||
|
from personal_mcp.registry.models.registry import DocsRegistry
|
||||||
from personal_mcp.registry.models.registry import PromptRecord
|
from personal_mcp.registry.models.registry import PromptRecord
|
||||||
from personal_mcp.registry.models.registry import PromptSummaryPayload
|
from personal_mcp.registry.models.registry import PromptSummaryPayload
|
||||||
from personal_mcp.registry.models.registry import ReferenceRecord
|
from personal_mcp.registry.models.registry import ReferenceRecord
|
||||||
from personal_mcp.registry.models.registry import SkillPatternPayload
|
from personal_mcp.registry.models.registry import SkillPatternPayload
|
||||||
from personal_mcp.registry.models.registry import SkillRecord
|
from personal_mcp.registry.models.registry import SkillRecord
|
||||||
from personal_mcp.registry.models.registry import SkillSummaryPayload
|
from personal_mcp.registry.models.registry import SkillSummaryPayload
|
||||||
|
from personal_mcp.registry.models.registry import SkillSummaryRecord
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
@@ -22,13 +28,13 @@ def _make_skill_record() -> SkillRecord:
|
|||||||
tags=("testing", "catalog"),
|
tags=("testing", "catalog"),
|
||||||
capabilities=("resource://skills/demo-skill/document", "resource://catalog/skills_index"),
|
capabilities=("resource://skills/demo-skill/document", "resource://catalog/skills_index"),
|
||||||
document_uri="resource://skills/demo-skill/document",
|
document_uri="resource://skills/demo-skill/document",
|
||||||
document_relpath="skills/demo-skill/SKILL.md",
|
document_relpath=PurePosixPath("skills/demo-skill/SKILL.md"),
|
||||||
document_content="# demo",
|
document_content="# demo",
|
||||||
references={
|
references={
|
||||||
"zeta": ReferenceRecord(
|
"zeta": ReferenceRecord(
|
||||||
ref_id="zeta",
|
ref_id="zeta",
|
||||||
uri="resource://skills/demo-skill/references/zeta",
|
uri="resource://skills/demo-skill/references/zeta",
|
||||||
relpath="skills/demo-skill/references/zeta.md",
|
relpath=PurePosixPath("skills/demo-skill/references/zeta.md"),
|
||||||
mime_type="text/markdown",
|
mime_type="text/markdown",
|
||||||
title="Zeta",
|
title="Zeta",
|
||||||
content="# zeta",
|
content="# zeta",
|
||||||
@@ -36,7 +42,7 @@ def _make_skill_record() -> SkillRecord:
|
|||||||
"alpha": ReferenceRecord(
|
"alpha": ReferenceRecord(
|
||||||
ref_id="alpha",
|
ref_id="alpha",
|
||||||
uri="resource://skills/demo-skill/references/alpha",
|
uri="resource://skills/demo-skill/references/alpha",
|
||||||
relpath="skills/demo-skill/references/alpha.md",
|
relpath=PurePosixPath("skills/demo-skill/references/alpha.md"),
|
||||||
mime_type="text/markdown",
|
mime_type="text/markdown",
|
||||||
title="Alpha",
|
title="Alpha",
|
||||||
content="# alpha",
|
content="# alpha",
|
||||||
@@ -61,7 +67,7 @@ def _make_prompt_record() -> PromptRecord:
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
document_uri="resource://prompts/demo-prompt/document",
|
document_uri="resource://prompts/demo-prompt/document",
|
||||||
document_relpath="prompts/demo-prompt/PROMPT.md",
|
document_relpath=PurePosixPath("prompts/demo-prompt/PROMPT.md"),
|
||||||
document_content="# demo",
|
document_content="# demo",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,3 +127,25 @@ def test_prompt_summary_payload_from_record_shape() -> None:
|
|||||||
"document_uri": "resource://prompts/demo-prompt/document",
|
"document_uri": "resource://prompts/demo-prompt/document",
|
||||||
"detail_uri": "resource://catalog/prompts/demo-prompt",
|
"detail_uri": "resource://catalog/prompts/demo-prompt",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_detail_serializes_reference_paths() -> None:
|
||||||
|
record = _make_skill_record()
|
||||||
|
registry = DocsRegistry(
|
||||||
|
skills_by_id={record.skill_id: record},
|
||||||
|
skills_in_load_order=(record.skill_id,),
|
||||||
|
skills_summary_in_load_order=(SkillSummaryRecord.from_record(record),),
|
||||||
|
docs_markdown_by_path={},
|
||||||
|
docs_markdown_path_index=(),
|
||||||
|
tag_to_skill_ids={},
|
||||||
|
capability_to_skill_ids={},
|
||||||
|
prompts_by_id={},
|
||||||
|
prompts_in_load_order=(),
|
||||||
|
prompts_summary_in_load_order=(),
|
||||||
|
tag_to_prompt_ids={},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = build_skill_detail_payload(registry, record.skill_id)
|
||||||
|
|
||||||
|
assert payload["resources"]["references"]["alpha"]["path"] == ("skills/demo-skill/references/alpha.md")
|
||||||
|
json.dumps(payload)
|
||||||
|
|||||||
@@ -2,26 +2,22 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from personal_mcp.registry.load import load_docs_registry
|
from personal_mcp.registry.load import get_docs_registry
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
REGISTRY = load_docs_registry(
|
REGISTRY = get_docs_registry()
|
||||||
package_anchor="personal_mcp",
|
|
||||||
docs_root="../../docs",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Convention: every skill should include tags for the core libraries/frameworks
|
# Convention: every skill should include tags for the core libraries/frameworks
|
||||||
# it relies on so search_patterns query terms map to discoverable skills.
|
# it relies on so search_patterns query terms map to discoverable skills.
|
||||||
REQUIRED_LIBRARY_TAGS_BY_SKILL = {
|
REQUIRED_LIBRARY_TAGS_BY_SKILL = {
|
||||||
"copilot-customization": {"copilot", "vscode", "mcp"},
|
"copilot-customization": {"copilot", "vscode", "mcp"},
|
||||||
"fastapi-async-sqlalchemy-modernization": {"fastapi", "sqlalchemy", "asyncio"},
|
"async-fastapi-sqlmodel": {"fastapi", "sqlalchemy", "asyncio"},
|
||||||
"fastapi-uv-docker": {"fastapi", "uv", "uvicorn", "docker"},
|
"fastapi-uv-docker": {"fastapi", "uv", "uvicorn", "docker"},
|
||||||
"mcp-details": {"mcp", "fastmcp"},
|
"mcp-details": {"mcp", "fastmcp"},
|
||||||
"nicegui": {"nicegui", "fastapi"},
|
"nicegui": {"nicegui", "fastapi"},
|
||||||
"nicegui-ui-customization": {"nicegui", "fastapi"},
|
|
||||||
"pytesting": {"pytest", "testing", "fastapi", "asyncio", "anyio"},
|
"pytesting": {"pytest", "testing", "fastapi", "asyncio", "anyio"},
|
||||||
"python-logging-dictconfig": {"python", "logging"},
|
"python-logging": {"python", "logging"},
|
||||||
"python-typing": {"python", "typing"},
|
"python-typing": {"python", "typing"},
|
||||||
"ruff-linting-formating": {"ruff", "python"},
|
"ruff-linting-formating": {"ruff", "python"},
|
||||||
"vscode-configuration": {"vscode", "debugpy", "fastapi", "python"},
|
"vscode-configuration": {"vscode", "debugpy", "fastapi", "python"},
|
||||||
@@ -59,6 +55,7 @@ DOMAIN_FACET_TAGS = {
|
|||||||
"authoring",
|
"authoring",
|
||||||
"bootstrap",
|
"bootstrap",
|
||||||
"ci",
|
"ci",
|
||||||
|
"configuration",
|
||||||
"custom-agents",
|
"custom-agents",
|
||||||
"customization",
|
"customization",
|
||||||
"deterministic",
|
"deterministic",
|
||||||
|
|||||||
@@ -169,5 +169,4 @@ class TestSkillValidationGates:
|
|||||||
)
|
)
|
||||||
record = _build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle))
|
record = _build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle))
|
||||||
|
|
||||||
with pytest.raises(ValidationError, match="Instance is frozen"):
|
assert_model_is_frozen(record.references["guide"], attr="title", value="Mutated")
|
||||||
record.references["guide"].title = "Mutated"
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from personal_mcp.registry.models.registry import DocsRegistry
|
||||||
|
from personal_mcp.registry.models.registry import PromptRecord
|
||||||
|
from personal_mcp.registry.models.registry import PromptSummaryRecord
|
||||||
|
from personal_mcp.registry.models.registry import ReferenceRecord
|
||||||
|
from personal_mcp.registry.models.registry import SkillRecord
|
||||||
|
from personal_mcp.registry.models.registry import SkillSummaryRecord
|
||||||
|
from personal_mcp.registry.read import read_docs_markdown_path
|
||||||
|
from personal_mcp.registry.read import read_prompt_document
|
||||||
|
from personal_mcp.registry.read import read_skill_document
|
||||||
|
from personal_mcp.registry.read import read_skill_reference
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
def _make_registry() -> DocsRegistry:
|
||||||
|
skill_path = PurePosixPath("skills/demo/SKILL.md")
|
||||||
|
reference_path = PurePosixPath("skills/demo/references/guide.md")
|
||||||
|
prompt_path = PurePosixPath("prompts/demo-prompt/PROMPT.md")
|
||||||
|
index_path = PurePosixPath("index.md")
|
||||||
|
reference = ReferenceRecord(
|
||||||
|
ref_id="guide",
|
||||||
|
uri="resource://skills/demo/references/guide",
|
||||||
|
relpath=reference_path,
|
||||||
|
mime_type="text/markdown",
|
||||||
|
title="Guide",
|
||||||
|
content="# guide",
|
||||||
|
)
|
||||||
|
skill = SkillRecord(
|
||||||
|
skill_id="demo",
|
||||||
|
name="demo",
|
||||||
|
description="demo skill",
|
||||||
|
version="1.0.0",
|
||||||
|
tags=("testing",),
|
||||||
|
capabilities=("resource://skills/demo/document",),
|
||||||
|
document_uri="resource://skills/demo/document",
|
||||||
|
document_relpath=skill_path,
|
||||||
|
document_content="# demo",
|
||||||
|
references={"guide": reference},
|
||||||
|
)
|
||||||
|
prompt = PromptRecord(
|
||||||
|
prompt_id="demo-prompt",
|
||||||
|
name="demo-prompt",
|
||||||
|
description="demo prompt",
|
||||||
|
version="1.0.0",
|
||||||
|
tags=("testing",),
|
||||||
|
capabilities=("resource://prompts/demo-prompt/document",),
|
||||||
|
arguments={},
|
||||||
|
document_uri="resource://prompts/demo-prompt/document",
|
||||||
|
document_relpath=prompt_path,
|
||||||
|
document_content="# prompt",
|
||||||
|
)
|
||||||
|
return DocsRegistry(
|
||||||
|
skills_by_id={skill.skill_id: skill},
|
||||||
|
skills_in_load_order=(skill.skill_id,),
|
||||||
|
skills_summary_in_load_order=(SkillSummaryRecord.from_record(skill),),
|
||||||
|
docs_markdown_by_path={index_path: "# index"},
|
||||||
|
docs_markdown_path_index=(index_path,),
|
||||||
|
tag_to_skill_ids={"testing": (skill.skill_id,)},
|
||||||
|
capability_to_skill_ids={},
|
||||||
|
prompts_by_id={prompt.prompt_id: prompt},
|
||||||
|
prompts_in_load_order=(prompt.prompt_id,),
|
||||||
|
prompts_summary_in_load_order=(PromptSummaryRecord.from_record(prompt),),
|
||||||
|
tag_to_prompt_ids={"testing": (prompt.prompt_id,)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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_non_posix_docs_path() -> None:
|
||||||
|
with pytest.raises(ValueError, match="POSIX separators"):
|
||||||
|
read_docs_markdown_path(_make_registry(), "skills\\demo\\SKILL.md")
|
||||||
|
|
||||||
|
|
||||||
|
def test_serializes_record_paths_in_document_payloads() -> None:
|
||||||
|
registry = _make_registry()
|
||||||
|
|
||||||
|
assert read_skill_document(registry, "demo")["source_path"] == "docs/skills/demo/SKILL.md"
|
||||||
|
assert read_skill_reference(registry, skill_id="demo", ref_id="guide")["source_path"] == (
|
||||||
|
"docs/skills/demo/references/guide.md"
|
||||||
|
)
|
||||||
|
assert read_prompt_document(registry, "demo-prompt")["source_path"] == ("docs/prompts/demo-prompt/PROMPT.md")
|
||||||
@@ -32,7 +32,7 @@ def mcp_session_factory():
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def create_session(*, initialize: bool = True) -> AsyncIterator[ClientSession]:
|
async def create_session(*, initialize: bool = True) -> AsyncIterator[ClientSession]:
|
||||||
app = create_app()
|
app = create_app()
|
||||||
mcp_url = f"http://testserver{app.state.settings.mcp_route}"
|
mcp_url = f"http://testserver{app.state.settings.mounts.mcp}"
|
||||||
async with (
|
async with (
|
||||||
app.router.lifespan_context(app),
|
app.router.lifespan_context(app),
|
||||||
AsyncClient(
|
AsyncClient(
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ SEARCH_QUERY_PARAMETERS = (
|
|||||||
),
|
),
|
||||||
pytest.param(
|
pytest.param(
|
||||||
"asyncio",
|
"asyncio",
|
||||||
{"pytesting", "fastapi-async-sqlalchemy-modernization"},
|
{"pytesting", "async-fastapi-sqlmodel"},
|
||||||
id="query-asyncio",
|
id="query-asyncio",
|
||||||
),
|
),
|
||||||
pytest.param(
|
pytest.param(
|
||||||
|
|||||||
+32
-20
@@ -65,7 +65,8 @@ nav = [
|
|||||||
] },
|
] },
|
||||||
{ "Prompts" = [
|
{ "Prompts" = [
|
||||||
{ "Authoring" = "prompts/authoring/PROMPT.md" },
|
{ "Authoring" = "prompts/authoring/PROMPT.md" },
|
||||||
{ "Fill Pytest Scaffold" = "prompts/fill-pytest-scaffold/PROMPT.md" },
|
{ "Pytest Fill Scaffold" = "prompts/pytest-fill-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" },
|
||||||
] },
|
] },
|
||||||
@@ -88,26 +89,26 @@ nav = [
|
|||||||
{ "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/fastapi-async-sqlalchemy-modernization/SKILL.md" },
|
{ "Overview" = "skills/async-fastapi-sqlmodel/SKILL.md" },
|
||||||
{ "Index" = "skills/fastapi-async-sqlalchemy-modernization/references/index.md" },
|
{ "Engine" = "skills/async-fastapi-sqlmodel/references/engine.md" },
|
||||||
{ "Engine" = "skills/fastapi-async-sqlalchemy-modernization/references/engine.md" },
|
{ "Session" = "skills/async-fastapi-sqlmodel/references/session.md" },
|
||||||
{ "Session" = "skills/fastapi-async-sqlalchemy-modernization/references/session.md" },
|
{ "Tx" = "skills/async-fastapi-sqlmodel/references/transactions.md" },
|
||||||
{ "Tx" = "skills/fastapi-async-sqlalchemy-modernization/references/transactions.md" },
|
{ "SQLModel" = "skills/async-fastapi-sqlmodel/references/sqlmodel.md" },
|
||||||
{ "IO" = "skills/fastapi-async-sqlalchemy-modernization/references/implicit_io.md" },
|
{ "CRUD" = "skills/async-fastapi-sqlmodel/references/crud.md" },
|
||||||
{ "Obs" = "skills/fastapi-async-sqlalchemy-modernization/references/observability.md" },
|
{ "IO" = "skills/async-fastapi-sqlmodel/references/implicit_io.md" },
|
||||||
{ "Template" = "skills/fastapi-async-sqlalchemy-modernization/references/template.md" },
|
{ "Obs" = "skills/async-fastapi-sqlmodel/references/observability.md" },
|
||||||
|
{ "Template" = "skills/async-fastapi-sqlmodel/references/template.md" },
|
||||||
] },
|
] },
|
||||||
{ "NiceGUI" = [
|
{ "NiceGUI" = [
|
||||||
{ "Overview" = "skills/nicegui/SKILL.md" },
|
{ "Overview" = "skills/nicegui/SKILL.md" },
|
||||||
{ "Arch" = "skills/nicegui/references/architecture.md" },
|
{ "App Architecture" = "skills/nicegui/references/architecture.md" },
|
||||||
|
{ "Startup" = "skills/nicegui/references/fastapi-uvicorn-startup.md" },
|
||||||
|
{ "Layout and Style" = "skills/nicegui/references/architecture-and-styling.md" },
|
||||||
|
{ "Binding" = "skills/nicegui/references/binding-dataclasses.md" },
|
||||||
|
{ "Flows" = "skills/nicegui/references/interaction-patterns.md" },
|
||||||
|
{ "Quality" = "skills/nicegui/references/troubleshooting-and-quality-gates.md" },
|
||||||
{ "Sources" = "skills/nicegui/references/source-documentation.md" },
|
{ "Sources" = "skills/nicegui/references/source-documentation.md" },
|
||||||
] },
|
] },
|
||||||
{ "NiceGUI Fine-Tuning" = [
|
|
||||||
{ "Overview" = "skills/nicegui-ui-customization/SKILL.md" },
|
|
||||||
{ "Style" = "skills/nicegui-ui-customization/references/architecture-and-styling.md" },
|
|
||||||
{ "Flows" = "skills/nicegui-ui-customization/references/interaction-patterns.md" },
|
|
||||||
{ "Quality" = "skills/nicegui-ui-customization/references/troubleshooting-and-quality-gates.md" },
|
|
||||||
] },
|
|
||||||
{ "Pytest" = [
|
{ "Pytest" = [
|
||||||
{ "Overview" = "skills/pytesting/SKILL.md" },
|
{ "Overview" = "skills/pytesting/SKILL.md" },
|
||||||
{ "Docs" = "skills/pytesting/references/pytest-docs.md" },
|
{ "Docs" = "skills/pytesting/references/pytest-docs.md" },
|
||||||
@@ -120,8 +121,16 @@ nav = [
|
|||||||
{ "Ecosystem" = "skills/mcp-details/references/ecosystem-and-tooling.md" },
|
{ "Ecosystem" = "skills/mcp-details/references/ecosystem-and-tooling.md" },
|
||||||
] },
|
] },
|
||||||
{ "Logging" = [
|
{ "Logging" = [
|
||||||
{ "Overview" = "skills/python-logging-dictconfig/SKILL.md" },
|
{ "Overview" = "skills/python-logging/SKILL.md" },
|
||||||
{ "Docs" = "skills/python-logging-dictconfig/references/python-logging-docs.md" },
|
{ "Docs" = "skills/python-logging/references/python-logging-docs.md" },
|
||||||
|
{ "JSON File" = "skills/python-logging/references/json-file-logging.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" },
|
||||||
|
{ "Source Docs" = "skills/pydantic-settings/references/source-documentation.md" },
|
||||||
|
{ "Workflow" = "skills/pydantic-settings/references/implementation-workflow.md" },
|
||||||
] },
|
] },
|
||||||
{ "Ruff" = [
|
{ "Ruff" = [
|
||||||
{ "Overview" = "skills/ruff-linting-formating/SKILL.md" },
|
{ "Overview" = "skills/ruff-linting-formating/SKILL.md" },
|
||||||
@@ -130,7 +139,6 @@ nav = [
|
|||||||
] },
|
] },
|
||||||
{ "Zensical" = [
|
{ "Zensical" = [
|
||||||
{ "Overview" = "skills/zensical-docs/SKILL.md" },
|
{ "Overview" = "skills/zensical-docs/SKILL.md" },
|
||||||
{ "Map" = "skills/zensical-docs/references/index.md" },
|
|
||||||
{ "Features" = "skills/zensical-docs/references/zensical-features.md" },
|
{ "Features" = "skills/zensical-docs/references/zensical-features.md" },
|
||||||
{ "Theme" = "skills/zensical-docs/references/theme-customization-and-icons.md" },
|
{ "Theme" = "skills/zensical-docs/references/theme-customization-and-icons.md" },
|
||||||
{ "Quality" = "skills/zensical-docs/references/documentation-quality.md" },
|
{ "Quality" = "skills/zensical-docs/references/documentation-quality.md" },
|
||||||
@@ -156,7 +164,11 @@ extra_css = ["stylesheets/mermaid-override.css"]
|
|||||||
# The path provided should be relative to the "docs_dir".
|
# The path provided should be relative to the "docs_dir".
|
||||||
#
|
#
|
||||||
# Read more: https://zensical.org/docs/customization/#additional-javascript
|
# Read more: https://zensical.org/docs/customization/#additional-javascript
|
||||||
extra_javascript = ["javascripts/mermaid-override.js"]
|
extra_javascript = [
|
||||||
|
"javascripts/mermaid-override.js",
|
||||||
|
"javascripts/mathjax.js",
|
||||||
|
"https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js",
|
||||||
|
]
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# Section for configuring theme options
|
# Section for configuring theme options
|
||||||
|
|||||||
Reference in New Issue
Block a user