Compare commits
17
Commits
65669a2100
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d13ecd6718 | ||
|
|
5cefca852d | ||
|
|
78a489c690 | ||
|
|
9312784c2f | ||
|
|
09d2a4bcaf | ||
|
|
86c7d54244 | ||
|
|
f75b24705e | ||
|
|
b87b1df642 | ||
|
|
bf11b7865d | ||
|
|
be579c347e | ||
|
|
9eb4ccbc6e | ||
|
|
bbaa84720c | ||
|
|
b2ac4102f7 | ||
|
|
fd5ce6f63b | ||
|
|
783ecf421e | ||
|
|
f6752313be | ||
|
|
12f916455b |
@@ -18,8 +18,9 @@ from .mcp import create_mcp
|
|||||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
runtime_settings = settings if settings is not None else get_settings()
|
runtime_settings = settings if settings is not None else get_settings()
|
||||||
docs_route = runtime_settings.mounts.docs.rstrip("/") or "/docs"
|
docs_route = runtime_settings.mounts.docs.rstrip("/") or "/docs"
|
||||||
|
mcp_route = runtime_settings.mounts.mcp.rstrip("/") or "/mcp"
|
||||||
mcp_app = create_mcp().http_app(
|
mcp_app = create_mcp().http_app(
|
||||||
path="/",
|
path=mcp_route,
|
||||||
json_response=True,
|
json_response=True,
|
||||||
stateless_http=True,
|
stateless_http=True,
|
||||||
transport="http",
|
transport="http",
|
||||||
@@ -46,7 +47,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.mount(runtime_settings.mounts.mcp, mcp_app, name="mcp")
|
app.router.routes.extend(mcp_app.routes)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,102 +4,40 @@ icon: lucide/library
|
|||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
## Overview
|
Personal MCP is a small publishing service. Markdown is written once and made available in two ways:
|
||||||
|
|
||||||
The application combines a FastMCP server with a pre-built Zensical documentation site. Markdown under `src/personal_mcp/docs/` is the single authored content tree, while native FastMCP providers own skill and prompt discovery.
|
1. as an MCP server for AI clients
|
||||||
|
2. as a documentation website for people
|
||||||
|
|
||||||
The runtime has four content paths:
|
## How It Fits Together
|
||||||
|
|
||||||
1. `SkillsDirectoryProvider` publishes native `skill://` resources from packaged skill directories.
|
|
||||||
2. A custom prompt provider loads declarative prompt definitions from packaged Markdown.
|
|
||||||
3. The general docs registry publishes non-skill Markdown through `resource://docs/{path*}`.
|
|
||||||
4. FastAPI serves the pre-built `site/` directory.
|
|
||||||
|
|
||||||
There is no custom skill catalog, prompt catalog, or per-prompt Python module.
|
|
||||||
|
|
||||||
## Source Ownership
|
|
||||||
|
|
||||||
### Skills
|
|
||||||
|
|
||||||
Each skill owns one directory:
|
|
||||||
|
|
||||||
1. `src/personal_mcp/docs/skills/<skill-id>/SKILL.md`
|
|
||||||
2. `src/personal_mcp/docs/skills/<skill-id>/<supporting-path>`
|
|
||||||
|
|
||||||
`SkillsDirectoryProvider` publishes:
|
|
||||||
|
|
||||||
1. `skill://<name>/SKILL.md`
|
|
||||||
2. `skill://<name>/_manifest`
|
|
||||||
3. `skill://<name>/{path*}`
|
|
||||||
|
|
||||||
The provider parses standard skill frontmatter and generates the manifest. The general docs registry excludes `skills/**`, so only the native provider owns this namespace.
|
|
||||||
|
|
||||||
### Prompts
|
|
||||||
|
|
||||||
Each prompt has one source: `src/personal_mcp/docs/prompts/<prompt-id>/PROMPT.md`. Its nested `prompt` frontmatter owns runtime metadata and argument declarations, while its body owns canonical prose.
|
|
||||||
|
|
||||||
The custom provider reads packaged Markdown with `importlib.resources`, validates metadata and exact placeholder-to-argument equality, and creates native FastMCP prompt objects. It rescans on each list and get request, so an editable deployment observes file additions, edits, and deletions without a restart.
|
|
||||||
|
|
||||||
FastMCP exposes prompts through native `prompts/list` and `prompts/get` operations.
|
|
||||||
|
|
||||||
### General Docs
|
|
||||||
|
|
||||||
The docs registry indexes packaged Markdown for `resource://docs/{path*}`. It rejects `skills/**` because skills are provider-owned. Prompt Markdown can remain visible as general documentation, but prompt invocation is owned by the native prompt provider.
|
|
||||||
|
|
||||||
## Runtime Composition
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart LR
|
||||||
A[Packaged Skill Directories] --> B[SkillsDirectoryProvider]
|
A[Markdown in src/personal_mcp/docs] --> B[MCP resources and prompts]
|
||||||
C[Packaged Prompt Markdown] --> D[Markdown Prompt Provider]
|
A --> C[Documentation website]
|
||||||
F[General Markdown] --> G[Docs Registry]
|
B --> D[AI clients]
|
||||||
B --> H[FastMCP Server]
|
C --> E[Human readers]
|
||||||
D --> H
|
|
||||||
G --> H
|
|
||||||
H --> K[MCP Transport]
|
|
||||||
L[Zensical Site Output] --> M[FastAPI Static Mount]
|
|
||||||
K --> M
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Server construction is lazy with respect to package import. Each application process creates its providers and docs snapshot when the server factory runs. Skills use startup discovery, while prompts are reloaded when a client lists or gets prompts.
|
The running application has two routes:
|
||||||
|
|
||||||
## Packaging
|
- `/mcp` is the MCP endpoint.
|
||||||
|
- `/docs/` is the pre-built documentation site.
|
||||||
|
|
||||||
The regular directory `src/personal_mcp/docs/` is the only authored Markdown source. The `uv_build` backend includes it as package data beneath `personal_mcp/docs/` in built distributions.
|
The root URL redirects to the website.
|
||||||
|
|
||||||
Runtime reads are package-relative:
|
## Content Types
|
||||||
|
|
||||||
1. Prompt content and general docs use `importlib.resources` and `Traversable` APIs.
|
The server publishes three kinds of Markdown content:
|
||||||
2. `SkillsDirectoryProvider` receives the packaged `personal_mcp/docs/skills` filesystem path.
|
|
||||||
3. No runtime content lookup depends on the current working directory.
|
|
||||||
|
|
||||||
## Public Contracts
|
- **Skills** are reusable guidance that clients read as `skill://` resources.
|
||||||
|
- **Prompts** are parameterized workflows that clients invoke as MCP prompts.
|
||||||
|
- **Documentation** is available to clients through `resource://docs/...` and to people on the website.
|
||||||
|
|
||||||
The machine-facing surfaces are:
|
FastMCP provides the MCP behavior. FastAPI hosts that server beside the static site, and Zensical builds the site from the same Markdown files.
|
||||||
|
|
||||||
1. Native skill resources under `skill://<name>/...`.
|
## Source Of Truth
|
||||||
2. Native MCP prompt list and get operations.
|
|
||||||
3. `resource://docs/{path*}` for general Markdown.
|
|
||||||
|
|
||||||
Canonical contracts are documented in:
|
All authored content lives under `src/personal_mcp/docs/`. The generated `src/personal_mcp/site/` directory is build output and should not be edited by hand.
|
||||||
|
|
||||||
1. [Prompt Contract](./contracts/prompt.md)
|
For exact file formats and URI rules, see the [content contracts](./contracts/index.md). For everyday changes, start with the [Authoring Guide](./authoring.md).
|
||||||
2. [Skill Contract](./contracts/skill_contract.md)
|
|
||||||
3. [Frontmatter Contract](./contracts/frontmatter.md)
|
|
||||||
4. [URI Contract](./contracts/uris.md)
|
|
||||||
|
|
||||||
Only these canonical provider and protocol surfaces are registered.
|
|
||||||
|
|
||||||
## Static Documentation
|
|
||||||
|
|
||||||
Zensical builds `src/personal_mcp/docs/` into `src/personal_mcp/site/` before deployment. FastAPI mounts that immutable output in the same process that hosts FastMCP. Generated site files are deployment assets and are never an authored source.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
Changes are accepted only after:
|
|
||||||
|
|
||||||
1. focused provider and protocol tests
|
|
||||||
2. Ruff and ty checks
|
|
||||||
3. a Zensical build
|
|
||||||
4. the full pytest suite
|
|
||||||
5. an installed-wheel smoke test when packaging or provider paths change
|
|
||||||
|
|||||||
@@ -4,112 +4,60 @@ icon: lucide/pencil
|
|||||||
|
|
||||||
# Authoring Guide
|
# Authoring Guide
|
||||||
|
|
||||||
This page defines the practical workflow for maintaining skills, prompts, and project documentation in the package-native `src/personal_mcp/docs` source tree.
|
All authored content lives under `src/personal_mcp/docs/`. The same files feed the MCP server and the documentation website.
|
||||||
|
|
||||||
Primary references:
|
|
||||||
|
|
||||||
1. [Skill Contract](./contracts/skill_contract.md)
|
|
||||||
2. [Prompt Contract](./contracts/prompt.md)
|
|
||||||
3. [Frontmatter Contract](./contracts/frontmatter.md)
|
|
||||||
4. [URI Contract](./contracts/uris.md)
|
|
||||||
5. [Zensical documentation skill](./skills/zensical-docs/SKILL.md)
|
|
||||||
|
|
||||||
## Source Tree Ownership
|
## Source Tree Ownership
|
||||||
|
|
||||||
Edit content only under `src/personal_mcp/docs`. This directory is the canonical authored source for both MCP content and the documentation site.
|
|
||||||
|
|
||||||
The `uv_build` backend packages this tree under `personal_mcp/docs/`. The installed package therefore gives runtime providers package-relative content, while Zensical builds the human site directly from `src/personal_mcp/docs` as configured by `docs_dir` in the repository's `zensical.toml`.
|
|
||||||
|
|
||||||
Generated `src/personal_mcp/site/` content is a build artifact and must not be edited by hand.
|
|
||||||
|
|
||||||
## Content Layout
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/personal_mcp/docs/
|
src/personal_mcp/docs/
|
||||||
*.md
|
*.md # General documentation
|
||||||
contracts/
|
|
||||||
prompts/<prompt-id>/
|
prompts/<prompt-id>/
|
||||||
PROMPT.md
|
PROMPT.md # One MCP prompt
|
||||||
references/
|
|
||||||
skills/<skill-name>/
|
skills/<skill-name>/
|
||||||
SKILL.md
|
SKILL.md # Main skill guidance
|
||||||
references/
|
references/ # Optional supporting material
|
||||||
```
|
```
|
||||||
|
|
||||||
Keep skill and prompt files inside their owning directories. Relative links may cross sections, but content ownership should remain clear.
|
Do not edit `src/personal_mcp/site/` by hand. It is generated by Zensical.
|
||||||
|
|
||||||
|
## Documentation Pages
|
||||||
|
|
||||||
|
Add general documentation as Markdown under `src/personal_mcp/docs/`. Use relative links between pages. Top-level pages need an `icon` in their frontmatter, and navigation changes belong in `zensical.toml`.
|
||||||
|
|
||||||
## Skill Authoring
|
## Skill Authoring
|
||||||
|
|
||||||
A skill is discovered when a direct child of `src/personal_mcp/docs/skills/` contains `SKILL.md`.
|
Skills follow the [Agent Skills specification](https://agentskills.io/specification). Each skill is a directory containing a required `SKILL.md` file with YAML frontmatter and Markdown instructions:
|
||||||
|
|
||||||
Required frontmatter:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
name: <skill-name>
|
name: <skill-name>
|
||||||
description: <what the skill does and when to use it>
|
description: <what this skill covers and when to use it>
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
The specification requires:
|
||||||
|
|
||||||
1. Use lowercase kebab-case for the directory and `name`.
|
1. `name` must match the directory name, contain 1-64 lowercase letters, numbers, or hyphens, and have no leading, trailing, or consecutive hyphens.
|
||||||
2. Keep `name` exactly equal to the directory name.
|
2. `description` must contain 1-1024 characters and explain both what the skill does and when an agent should use it.
|
||||||
3. Write a specific description because clients use it for discovery.
|
3. The body of `SKILL.md` must contain the instructions an agent needs after selecting the skill.
|
||||||
4. Do not add `x-personal-mcp`, versions, tags, capabilities, or reference mappings.
|
|
||||||
5. Put supporting material anywhere beneath the skill directory, normally under `references/`.
|
|
||||||
6. Link supporting files from `SKILL.md` so humans and agents understand when to load them.
|
|
||||||
|
|
||||||
FastMCP recursively scans every skill file and generates `skill://<name>/_manifest`. Supporting-resource identity is the real relative path, not a synthetic reference id.
|
This repository adds two narrower conventions: names start with a letter, and frontmatter contains only the required `name` and `description`. The specification also defines optional `license`, `compatibility`, `metadata`, and experimental `allowed-tools` fields, but they are not part of this repository's current [Skill Contract](./contracts/skill_contract.md).
|
||||||
|
|
||||||
Recommended sequence:
|
Write skills for progressive disclosure. Keep discovery information in the frontmatter, the main workflow in `SKILL.md`, and detailed material in focused files under `references/`. Link to supporting files with paths relative to the skill root, and avoid chains of references that require an agent to open several files before finding the useful content.
|
||||||
|
|
||||||
1. Draft or revise `SKILL.md` routing guidance.
|
The Agent Skills specification also permits `scripts/`, `assets/`, and other supporting directories. This project is primarily a guidance library, so prefer `references/` unless the skill genuinely needs executable or static resources.
|
||||||
2. Add focused supporting files.
|
|
||||||
3. Verify relative links.
|
[FastMCP's Skills Provider](https://gofastmcp.com/servers/providers/skills) publishes each compliant directory as MCP resources, including the main file, a generated manifest, and any supporting files. The specification's [`skills-ref` validator](https://github.com/agentskills/agentskills/tree/main/skills-ref) can validate an individual skill before the repository-wide checks run.
|
||||||
4. Run the provider tests and docs build.
|
|
||||||
5. Restart running servers because production uses `reload=False`.
|
|
||||||
|
|
||||||
## Prompt Authoring
|
## Prompt Authoring
|
||||||
|
|
||||||
A prompt is one self-describing `src/personal_mcp/docs/prompts/<prompt-id>/PROMPT.md` file:
|
A prompt is one `PROMPT.md` file under `src/personal_mcp/docs/prompts/<prompt-id>/`. Its `prompt` frontmatter describes the workflow and arguments; its Markdown body contains the instructions.
|
||||||
|
|
||||||
1. Create a lowercase kebab-case directory beneath `src/personal_mcp/docs/prompts/`.
|
Use each declared argument as a `{{placeholder}}` in the body. The server validates prompt metadata and placeholders when the prompt is discovered.
|
||||||
2. Add a nested `prompt` frontmatter mapping with version, description, tags, and ordered arguments.
|
|
||||||
3. Give every argument a description and explicit required flag.
|
|
||||||
4. Add `choices` only when a string argument accepts a fixed set of values.
|
|
||||||
5. Use each argument exactly once or more as a `{{argument_name}}` placeholder in the body.
|
|
||||||
6. Do not add a Python component, name field, metadata sidecar, or central catalog entry.
|
|
||||||
|
|
||||||
The custom provider rescans prompt documents during every native list and get request. Changes in an editable checkout are therefore visible on the next request without a process restart. Invalid metadata or placeholder drift fails that request with a configuration error.
|
The [Prompt Contract](./contracts/prompt.md) and [Frontmatter Contract](./contracts/frontmatter.md) contain the exact schema.
|
||||||
|
|
||||||
## Frontmatter Safety
|
## Validate Changes
|
||||||
|
|
||||||
1. Quote scalar values containing `:`.
|
|
||||||
2. Quote values with reserved YAML characters such as `#`, `{}`, `[]`, or leading `*`.
|
|
||||||
3. Use block scalars for punctuation-heavy multiline text.
|
|
||||||
4. Keep fields within the applicable skill or documentation contract.
|
|
||||||
|
|
||||||
## Writing Quality
|
|
||||||
|
|
||||||
1. Prefer focused sections and descriptive headings.
|
|
||||||
2. Link feature-level claims to authoritative sources.
|
|
||||||
3. Use relative links for internal pages.
|
|
||||||
4. Keep code examples minimal and actionable.
|
|
||||||
5. Avoid bare URLs in prose.
|
|
||||||
6. Load only supporting material relevant to the immediate task.
|
|
||||||
|
|
||||||
## Copilot Routing
|
|
||||||
|
|
||||||
Active instructions should point directly to native main resources:
|
|
||||||
|
|
||||||
1. `skill://zensical-docs/SKILL.md`
|
|
||||||
2. `skill://pytesting/SKILL.md`
|
|
||||||
3. `skill://vscode-configuration/SKILL.md`
|
|
||||||
|
|
||||||
When deeper guidance is needed, read the selected skill's `_manifest` and fetch supporting files by their listed path.
|
|
||||||
|
|
||||||
## Validation Checklist
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run zensical build
|
uv run zensical build
|
||||||
@@ -118,13 +66,6 @@ uv run ty check
|
|||||||
uv run pytest
|
uv run pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
For packaging changes, also build and inspect an installed wheel so provider path resolution is verified outside the editable checkout.
|
Restart a running server after changing content so every surface reads the latest package data.
|
||||||
|
|
||||||
## Navigation
|
For detailed rules, see the [content contracts](./contracts/index.md). For writing and site features, use the [Zensical documentation skill](./skills/zensical-docs/SKILL.md).
|
||||||
|
|
||||||
When adding or moving pages:
|
|
||||||
|
|
||||||
1. update `zensical.toml`
|
|
||||||
2. keep top-level page icons in frontmatter
|
|
||||||
3. rebuild the site
|
|
||||||
4. verify internal links and navigation labels
|
|
||||||
|
|||||||
@@ -2,129 +2,56 @@
|
|||||||
icon: lucide/bot
|
icon: lucide/bot
|
||||||
---
|
---
|
||||||
|
|
||||||
# Copilot MCP Mechanics
|
# Using With GitHub Copilot
|
||||||
|
|
||||||
## Purpose
|
Once Personal MCP is configured as a VS Code MCP server, Copilot can use its resources, prompts, and read-only tools.
|
||||||
|
|
||||||
This page explains how GitHub Copilot in VS Code consumes native skill resources and prompts from `personal-mcp`.
|
For general connection details, see [Using Personal MCP](./usage.md). For VS Code setup options, see [Add and manage MCP servers](https://code.visualstudio.com/docs/agent-customization/mcp-servers).
|
||||||
|
|
||||||
## Capability Lanes
|
## Skills And Documentation
|
||||||
|
|
||||||
Copilot interacts with MCP servers through independently exposed lanes:
|
Use **MCP: Browse Resources** to inspect the server's resources. Skills appear as `skill://<name>/SKILL.md`; general pages appear under `resource://docs/...`.
|
||||||
|
|
||||||
1. tools invoked during execution
|
For a task that needs guidance:
|
||||||
2. resources attached as read-only context
|
|
||||||
3. server-provided prompts
|
|
||||||
|
|
||||||
This server publishes skills as native `skill://` resources, general docs as `resource://docs/{path*}` resources, and workflows as native MCP prompt objects. It intentionally publishes no compatibility tools that mirror resources or prompts.
|
1. choose the skill whose description best matches the task
|
||||||
|
2. read its main `SKILL.md`
|
||||||
|
3. read `_manifest` only when supporting material is needed
|
||||||
|
4. attach or read only the relevant supporting files
|
||||||
|
|
||||||
## VS Code Feature Coverage
|
When the current chat surface supports MCP resource attachments, the same resources are available from **Add Context**.
|
||||||
|
|
||||||
The server uses every FastMCP feature that applies to its read-only guidance workload:
|
## Prompts
|
||||||
|
|
||||||
| Feature | Usage |
|
Personal MCP prompts appear as `/<server>.<prompt>` chat commands. Select a command and fill in its arguments to start the workflow. Arguments with a fixed list of choices offer completion as you type.
|
||||||
| --- | --- |
|
|
||||||
| Server identity | The initialize response includes a stable name, usage instructions, and a self-contained icon for VS Code's MCP server UI. |
|
|
||||||
| Tools | No tools are published for this documentation-only server surface. Resource and prompt operations stay on native MCP capabilities. |
|
|
||||||
| Resources | Documentation and skills use native resources and wildcard resource templates with explicit Markdown MIME types. |
|
|
||||||
| Prompts | Declarative workflows use native prompt objects with descriptions, display titles, typed arguments, and slash-command access. |
|
|
||||||
| Argument completion | Prompt arguments with authored `choices` are returned through `completion/complete` as the user types. |
|
|
||||||
|
|
||||||
[FastMCP server identity](https://gofastmcp.com/servers/server), [component icons](https://gofastmcp.com/servers/icons), [tool metadata](https://gofastmcp.com/servers/tools), and [argument completion](https://gofastmcp.com/servers/completions) define the implementation details. [VS Code's MCP documentation](https://code.visualstudio.com/docs/agent-customization/mcp-servers) describes how tools, resources, prompts, and MCP Apps appear in the client.
|
## Automatic Use
|
||||||
|
|
||||||
The following capabilities are conditional rather than useful by default:
|
Copilot can use four fallback tools when the chat surface does not expose resources or prompts directly:
|
||||||
|
|
||||||
1. MCP Apps require an interactive tool result such as a form or visualization; this server returns guidance and structured resource data only.
|
- `list_resources` and `read_resource`
|
||||||
2. Sampling is appropriate only when server-side work must ask VS Code to run an LLM. The current server retrieves authored content and does not generate it.
|
- `list_prompts` and `get_prompt`
|
||||||
3. Elicitation is appropriate only when a running operation needs additional user input. Prompt arguments already collect all required input before execution.
|
|
||||||
4. Progress, client logging, and background tasks require long-running operations. Current reads and prompt rendering are bounded local operations.
|
|
||||||
5. Client roots matter only when server behavior depends on client filesystem roots. This server reads packaged content and never traverses a client workspace.
|
|
||||||
6. `website_url` requires a canonical public deployment URL. None is configured, so the server does not advertise a guessed address.
|
|
||||||
|
|
||||||
Add one of these capabilities when a concrete workflow needs it, then cover its negotiated capability and protocol response in the HTTP MCP smoke tests. See the [FastMCP Apps overview](https://gofastmcp.com/apps/overview), [sampling](https://gofastmcp.com/servers/sampling), [elicitation](https://gofastmcp.com/servers/elicitation), [progress reporting](https://gofastmcp.com/servers/progress), and [MCP context](https://gofastmcp.com/servers/context) for the activation criteria.
|
These tools access the same content as the native features. A repository instruction can guide Copilot toward the intended order:
|
||||||
|
|
||||||
## Native Skill Resources
|
|
||||||
|
|
||||||
For every skill, Copilot can discover:
|
|
||||||
|
|
||||||
1. `skill://<name>/SKILL.md`
|
|
||||||
2. `skill://<name>/_manifest`
|
|
||||||
3. `skill://<name>/{path*}` supporting-file template
|
|
||||||
|
|
||||||
The main resource description comes from `SKILL.md`. The manifest discloses supporting paths, sizes, and SHA256 hashes. Native resources remain the only skill content and discovery contract; the tools search or delegate to that same resource surface rather than maintaining a parallel catalog.
|
|
||||||
|
|
||||||
## Resource Picker Availability
|
|
||||||
|
|
||||||
`MCP Resources...` in Add Context requires both:
|
|
||||||
|
|
||||||
1. a connected server advertising resource capability
|
|
||||||
2. a chat surface that exposes MCP resource attachment
|
|
||||||
|
|
||||||
A successful `resources/list` response does not guarantee the picker appears in every session type. Use `MCP: Browse Resources` to distinguish server availability from chat UI availability.
|
|
||||||
|
|
||||||
## Recommended Workflow
|
|
||||||
|
|
||||||
For autonomous agents:
|
|
||||||
|
|
||||||
1. browse native resources and compare skill descriptions
|
|
||||||
2. read one relevant `skill://<name>/SKILL.md`
|
|
||||||
3. read `_manifest` only if supporting detail may be needed
|
|
||||||
4. read only selected supporting files
|
|
||||||
|
|
||||||
For manual context attachment, browse the server's resources and attach the same bounded set of files.
|
|
||||||
|
|
||||||
## Prompt Examples
|
|
||||||
|
|
||||||
Resource attachment:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Use the attached personal-mcp skill as guidance, then reconcile it with the repository before proposing changes.
|
|
||||||
```
|
|
||||||
|
|
||||||
Direct loading:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Read skill://async-fastapi-sqlmodel/SKILL.md and apply only the sections relevant to this repository.
|
|
||||||
```
|
|
||||||
|
|
||||||
Supporting material:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Read skill://pytesting/_manifest, select the one reference relevant to async test lifecycle, and use that file with the main skill instructions.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Repository Instruction Pattern
|
|
||||||
|
|
||||||
A repo-level instruction should name the native retrieval order and context budget:
|
|
||||||
|
|
||||||
```md
|
|
||||||
When a task matches a personal-mcp skill:
|
When a task matches a personal-mcp skill:
|
||||||
|
1. Prefer an attached skill resource, or browse resources and choose one by description.
|
||||||
1. Prefer an already attached native skill resource.
|
2. Read its main file and load supporting material only when needed.
|
||||||
2. Otherwise browse MCP resources and select one `skill://<name>/SKILL.md` by description.
|
3. Reconcile the guidance with the current repository before editing.
|
||||||
3. Read the selected skill and read `_manifest` only when supporting material is needed.
|
|
||||||
4. Load at most two candidate main files and only the relevant supporting paths.
|
|
||||||
5. Reconcile guidance with the current repository before editing.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Instructions steer behavior but do not force VS Code to attach resources automatically.
|
Instructions guide resource use but do not force VS Code to attach resources automatically.
|
||||||
|
|
||||||
## Prompt Objects
|
|
||||||
|
|
||||||
Prompts remain separate from skills. When the client supports MCP prompt APIs, use prompt listing and `get_prompt` for parameterized workflows. Each authored `PROMPT.md` is the complete source of truth for its metadata, arguments, and prose; changes are loaded on the next prompt request.
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
1. Use `MCP: List Servers` to confirm the server is enabled.
|
1. Use `MCP: List Servers` to confirm the server is enabled.
|
||||||
2. Use `MCP: Browse Resources` to confirm native skill resources exist.
|
2. Use `MCP: Browse Resources` to confirm resources are available.
|
||||||
3. Confirm `Add Context > MCP Resources` lists server resources in the active chat surface.
|
3. Restart the MCP server after changing its content.
|
||||||
4. Restart the MCP server after changing skill files because production uses `reload=False`.
|
4. Reload the VS Code window if the server is healthy but the resource or tool list remains stale.
|
||||||
5. Reload the VS Code window if the server is healthy but the resource or tool picker remains stale.
|
|
||||||
|
|
||||||
## Further Reading
|
## Further Reading
|
||||||
|
|
||||||
1. [FastMCP Skills Provider](https://gofastmcp.com/servers/providers/skills)
|
1. [VS Code MCP configuration](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
|
||||||
2. [Add and manage MCP servers](https://code.visualstudio.com/docs/agent-customization/mcp-servers)
|
2. [Managing context in VS Code](https://code.visualstudio.com/docs/chat/copilot-chat-context)
|
||||||
3. [MCP configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
|
3. [Using Personal MCP](./usage.md)
|
||||||
4. [Manage context for AI](https://code.visualstudio.com/docs/chat/copilot-chat-context)
|
|
||||||
5. [Skill Usage Mechanics](./usage.md)
|
|
||||||
|
|||||||
@@ -4,45 +4,46 @@ icon: lucide/rocket
|
|||||||
|
|
||||||
# Personal MCP
|
# Personal MCP
|
||||||
|
|
||||||
This project is a document library of software patterns, best practices, and structured references to external documentation. The same markdown files are published through two equivalent surfaces, so human-readable docs and MCP resources stay aligned.
|
Personal MCP is a library of software development guidance for people and AI assistants. Content is written once in Markdown and published as both a documentation website and an MCP server.
|
||||||
|
|
||||||
## MCP Server
|
## What It Provides
|
||||||
|
|
||||||
An [MCP server](https://modelcontextprotocol.io/docs/getting-started/intro) at `/mcp` provides context for AI systems. The markdown files are exposed as [resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources) and are structured to be easily consumed by [MCP clients](https://modelcontextprotocol.io/docs/learn/client-concepts), such as VS Code.
|
- **Skills** provide focused guidance for development tasks.
|
||||||
|
- **Prompts** provide reusable workflows with named inputs.
|
||||||
|
- **Documentation** makes the same material easy to browse and maintain.
|
||||||
|
|
||||||
## Docs
|
The HTTP service exposes the [MCP](https://modelcontextprotocol.io/docs/getting-started/intro) endpoint at `/mcp` and the website at `/docs/`.
|
||||||
|
|
||||||
A website at `/docs` for humans to read and review.
|
## Quick Start
|
||||||
|
|
||||||
## Quick start
|
Install dependencies, build the website, and start the server:
|
||||||
|
|
||||||
Install dependencies first:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync
|
uv sync
|
||||||
|
uv run zensical build
|
||||||
|
uv run personal-mcp --host 127.0.0.1 --port 8765
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the app locally with the static docs rebuilt first, using [Uvicorn factory mode](https://www.uvicorn.org/settings/#application):
|
Then open `http://127.0.0.1:8765/docs/` or connect an MCP client to `http://127.0.0.1:8765/mcp`.
|
||||||
|
|
||||||
|
The MCP server can also run over standard input and output:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run zensical build && uv run uvicorn personal_mcp.web.app:create_app --factory --host 127.0.0.1 --port 8765
|
uv run mcp-stdio
|
||||||
```
|
```
|
||||||
|
|
||||||
Build and run the Docker image with the same exposed port:
|
For Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t personal-mcp . && docker run --rm -p 8765:8765 personal-mcp
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
When the server is running, the health check is available at `/healthz` and the generated docs are available at `/docs/`.
|
## Read Next
|
||||||
|
|
||||||
## Architecture
|
- [Using Personal MCP](./usage.md)
|
||||||
|
- [Authoring Guide](./authoring.md)
|
||||||
- [Resource-First Pattern Module Architecture](./architecture.md)
|
- [Architecture](./architecture.md)
|
||||||
- [Contracts](./contracts/index.md)
|
- [Running the Server](./mcp_layout.md)
|
||||||
- [Content Contract](./contracts/index.md#content-contract)
|
- [Testing](./testing.md)
|
||||||
- [Frontmatter Contract](./contracts/frontmatter.md)
|
- [Security](./securing.md)
|
||||||
- [URI Contract](./contracts/uris.md)
|
- [Content Contracts](./contracts/index.md)
|
||||||
- [Static Docs Hosting Pattern](./mcp_layout.md)
|
|
||||||
- [Skill Usage Mechanics](./usage.md)
|
|
||||||
- [Copilot MCP Mechanics](./copilot.md)
|
|
||||||
|
|||||||
@@ -2,92 +2,43 @@
|
|||||||
icon: lucide/server
|
icon: lucide/server
|
||||||
---
|
---
|
||||||
|
|
||||||
# Runtime And Static Docs Layout
|
# Running The Server
|
||||||
|
|
||||||
## Purpose
|
Personal MCP can run as an HTTP service or as a local stdio process.
|
||||||
|
|
||||||
The project serves native MCP content and a pre-built documentation site from one FastAPI process. Markdown is authored once under `src/personal_mcp/docs/`; runtime providers and Zensical consume that same package-owned tree for different purposes.
|
## Local HTTP Server
|
||||||
|
|
||||||
## Repository Layout
|
Build the website before starting the application:
|
||||||
|
|
||||||
```mermaid
|
```bash
|
||||||
---
|
uv sync
|
||||||
config:
|
uv run zensical build
|
||||||
treeView:
|
uv run personal-mcp --host 127.0.0.1 --port 8765
|
||||||
rowIndent: 32
|
|
||||||
lineThickness: 2
|
|
||||||
---
|
|
||||||
treeView-beta
|
|
||||||
"project-root"
|
|
||||||
"src/personal_mcp"
|
|
||||||
"docs"
|
|
||||||
"prompts/<prompt-id>/PROMPT.md"
|
|
||||||
"skills/<skill-id>/SKILL.md"
|
|
||||||
"skills/<skill-id>/<supporting-files>"
|
|
||||||
"<general-pages>.md"
|
|
||||||
"site"
|
|
||||||
"static build output"
|
|
||||||
"app.py"
|
|
||||||
"mcp.py"
|
|
||||||
"skills.py"
|
|
||||||
"prompts/"
|
|
||||||
"registry/"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Ownership rules:
|
The server then provides:
|
||||||
|
|
||||||
1. `src/personal_mcp/docs/skills/` is owned exclusively by `SkillsDirectoryProvider` at runtime.
|
- `http://127.0.0.1:8765/docs/` for the website
|
||||||
2. Each file under `src/personal_mcp/docs/prompts/` owns its prompt metadata, argument schema, and prose.
|
- `http://127.0.0.1:8765/mcp` for MCP clients
|
||||||
3. The docs registry owns only general Markdown resources and explicitly excludes skills.
|
|
||||||
4. `src/personal_mcp/site/` is generated output.
|
|
||||||
5. The deleted custom `catalog/` package is not part of the runtime.
|
|
||||||
|
|
||||||
## Runtime Composition
|
The host, port, log level, debug mode, and reload behavior can be set with command-line options or `PERSONAL_MCP_` environment variables.
|
||||||
|
|
||||||
```mermaid
|
## Local Stdio Server
|
||||||
flowchart TD
|
|
||||||
A[Packaged Skills] --> B[SkillsDirectoryProvider]
|
For clients that manage the server process themselves:
|
||||||
C[Packaged Prompt Markdown] --> D[Markdown Prompt Provider]
|
|
||||||
E[Packaged Markdown] --> F[Docs Registry]
|
```bash
|
||||||
B --> G[FastMCP]
|
uv run mcp-stdio
|
||||||
D --> G
|
|
||||||
F --> G
|
|
||||||
G --> H[MCP Transport]
|
|
||||||
H --> K[FastAPI Application]
|
|
||||||
L[Pre-built site] --> M[Static /docs Mount]
|
|
||||||
K --> M
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Runtime guarantees:
|
This mode provides MCP only; it does not host the website.
|
||||||
|
|
||||||
1. Providers are installed before serving requests.
|
## Docker
|
||||||
2. Prompt discovery rescans authored files on each list and get request.
|
|
||||||
3. Duplicate components fail according to FastMCP's configured duplicate policy.
|
|
||||||
4. Skills and prompts use native FastMCP component surfaces.
|
|
||||||
5. General docs path parsing rejects traversal, backslashes, non-Markdown paths, and the skill namespace.
|
|
||||||
|
|
||||||
## Build And Publish Flow
|
The included Compose configuration builds the website into the image and publishes the service on port `8765`:
|
||||||
|
|
||||||
1. Author prompt definitions and prose under `src/personal_mcp/docs/prompts/`.
|
```bash
|
||||||
2. Run `uv run zensical build` to produce `src/personal_mcp/site/`.
|
docker compose up --build
|
||||||
3. Build the wheel, which packages the authored docs under `personal_mcp/docs/`.
|
```
|
||||||
4. Start the app and serve MCP plus the static site.
|
|
||||||
|
|
||||||
No runtime Markdown-to-HTML conversion occurs.
|
For a remote deployment, place the service behind a reverse proxy and review the [security guidance](./securing.md).
|
||||||
|
|
||||||
## Machine-Facing Mapping
|
|
||||||
|
|
||||||
1. `src/personal_mcp/docs/skills/<skill-id>/SKILL.md` maps to `skill://<skill-id>/SKILL.md`.
|
|
||||||
2. Skill supporting files map to `skill://<skill-id>/<path>`.
|
|
||||||
3. Declarative prompt documents map to native MCP prompt names.
|
|
||||||
4. General `src/personal_mcp/docs/<path>.md` maps to `resource://docs/{path*}`.
|
|
||||||
|
|
||||||
The server publishes no tool projections of resources or prompts.
|
|
||||||
|
|
||||||
## Public Surface Policy
|
|
||||||
|
|
||||||
Canonical provider and protocol surfaces are the only public interfaces.
|
|
||||||
|
|
||||||
## Static Mount Expectations
|
|
||||||
|
|
||||||
The FastAPI app mounts the Zensical output, serves index and asset files, and returns a clear unavailable response when the static output is absent. The site directory is immutable for a given build and remains separate from packaged authored Markdown.
|
|
||||||
|
|||||||
@@ -2,138 +2,41 @@
|
|||||||
icon: lucide/shield-check
|
icon: lucide/shield-check
|
||||||
---
|
---
|
||||||
|
|
||||||
# Securing Remote Access
|
# Security
|
||||||
|
|
||||||
## Context
|
## Public By Design
|
||||||
|
|
||||||
This project exposes two related surfaces from the same runtime:
|
The application does not implement authentication. Its current purpose is to publish read-only guidance, so everything exposed through the server must be safe to make public.
|
||||||
|
|
||||||
1. a static documentation site under `/docs`
|
This includes:
|
||||||
2. a Streamable HTTP MCP endpoint under `/mcp`
|
|
||||||
|
|
||||||
The same Markdown content backs both surfaces. For the current project shape, the MCP server is resource-first and primarily exposes public skill and documentation text. It is not intended to expose secrets, private data, shell access, filesystem access, or tools with side effects.
|
- the website under `/docs/`
|
||||||
|
- resources and prompts under `/mcp`
|
||||||
|
- the four read-only fallback tools
|
||||||
|
|
||||||
The expected deployment path is:
|
Do not add secrets, private notes, credentials, or sensitive environment details to the authored content.
|
||||||
|
|
||||||
|
## Remote Deployment
|
||||||
|
|
||||||
|
Place the service behind a reverse proxy or tunnel rather than exposing the container directly. The edge can provide TLS, rate limiting, access logs, and optional authentication without adding those concerns to this small application.
|
||||||
|
|
||||||
|
A simple deployment is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Public internet
|
Internet -> reverse proxy or tunnel -> personal-mcp
|
||||||
-> Cloudflare Tunnel
|
|
||||||
-> Caddy
|
|
||||||
-> personal-mcp container
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Decision
|
The website and MCP endpoint can remain public while they contain only public, read-only content. Use edge authentication if access should be limited.
|
||||||
|
|
||||||
For the current use case, heavy application-level authentication is not required.
|
## When Authentication Becomes Required
|
||||||
|
|
||||||
The recommended posture is:
|
Protect `/mcp` before adding any capability that can:
|
||||||
|
|
||||||
1. Keep the service behind Cloudflare Tunnel and Caddy.
|
|
||||||
2. Do not expose the container port directly to the public internet.
|
|
||||||
3. Treat everything exposed through MCP as publishable public documentation.
|
|
||||||
4. Add stronger authentication only if the MCP surface later includes sensitive content or tools with meaningful side effects.
|
|
||||||
|
|
||||||
This keeps the deployment simple while preserving a clear upgrade path.
|
|
||||||
|
|
||||||
## Tradeoffs
|
|
||||||
|
|
||||||
### Leaving `/mcp` Public
|
|
||||||
|
|
||||||
This is acceptable if `/mcp` exposes only the same public Markdown already available through `/docs`.
|
|
||||||
|
|
||||||
Benefits:
|
|
||||||
|
|
||||||
1. lowest operational friction
|
|
||||||
2. fewer compatibility issues with MCP clients
|
|
||||||
3. no need to implement OAuth, mTLS, JWT validation, or custom auth middleware
|
|
||||||
4. consistent with the project assumption that documentation content is public
|
|
||||||
|
|
||||||
Risks:
|
|
||||||
|
|
||||||
1. random scraping, probing, or fuzzing of a machine endpoint
|
|
||||||
2. possible bandwidth or CPU nuisance traffic
|
|
||||||
3. accidental future exposure if new tools or private resources are added
|
|
||||||
4. less control over who can use the MCP endpoint
|
|
||||||
|
|
||||||
### Protecting `/mcp` With Cloudflare Access
|
|
||||||
|
|
||||||
Cloudflare Access can add a lightweight gate using GitHub, Google, one-time PIN, or service tokens.
|
|
||||||
|
|
||||||
Benefits:
|
|
||||||
|
|
||||||
1. reduces random internet traffic
|
|
||||||
2. requires little app code
|
|
||||||
3. works well for a small trusted team
|
|
||||||
4. provides logs and centralized access control
|
|
||||||
|
|
||||||
Costs:
|
|
||||||
|
|
||||||
1. browser-based login may not work with all MCP clients
|
|
||||||
2. non-browser MCP clients may need Cloudflare Access service tokens
|
|
||||||
3. adds operational configuration for a low-sensitivity endpoint
|
|
||||||
|
|
||||||
### Using mTLS
|
|
||||||
|
|
||||||
mTLS is useful when both client and server environments are tightly controlled.
|
|
||||||
|
|
||||||
Benefits:
|
|
||||||
|
|
||||||
1. strong client identity
|
|
||||||
2. good fit for service-to-service or private infrastructure
|
|
||||||
3. can be used between Cloudflare, Caddy, and the backend if desired
|
|
||||||
|
|
||||||
Costs:
|
|
||||||
|
|
||||||
1. harder certificate provisioning and rotation
|
|
||||||
2. weaker compatibility with normal MCP clients
|
|
||||||
3. unnecessary for public documentation-only content
|
|
||||||
|
|
||||||
For this project, mTLS is not the primary recommendation.
|
|
||||||
|
|
||||||
## Practical Recommendation
|
|
||||||
|
|
||||||
Use a simple public-docs posture unless the endpoint changes.
|
|
||||||
|
|
||||||
Recommended current setup:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/docs public
|
|
||||||
/mcp public or lightly protected
|
|
||||||
```
|
|
||||||
|
|
||||||
If `/mcp` remains public, add only basic operational safeguards:
|
|
||||||
|
|
||||||
1. keep Cloudflare Tunnel and Caddy in front
|
|
||||||
2. avoid publishing `8765` directly
|
|
||||||
3. enable Cloudflare or Caddy rate limiting if traffic becomes noisy
|
|
||||||
4. monitor logs for unusual request volume
|
|
||||||
5. document that MCP resources must remain safe to publish
|
|
||||||
|
|
||||||
A slightly stricter setup is also reasonable:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/docs public
|
|
||||||
/mcp Cloudflare Access or service token
|
|
||||||
```
|
|
||||||
|
|
||||||
This is the best option if the team wants to reduce drive-by MCP traffic without adding auth code to the application.
|
|
||||||
|
|
||||||
## Upgrade Trigger
|
|
||||||
|
|
||||||
Add real authentication before introducing any MCP capability that can:
|
|
||||||
|
|
||||||
1. read non-public files
|
1. read non-public files
|
||||||
2. access private notes or credentials
|
2. access private data or credentials
|
||||||
3. call upstream APIs
|
3. call authenticated services
|
||||||
4. mutate data
|
4. mutate data
|
||||||
5. run commands
|
5. run commands
|
||||||
6. expose environment details
|
6. perform expensive work
|
||||||
7. perform expensive computation
|
|
||||||
|
|
||||||
At that point, prefer edge-level authentication first, such as Cloudflare Access, and consider proper OAuth 2.1 resource-server behavior only if broad public MCP client interoperability becomes a goal.
|
At that point, choose authentication based on the clients that need to connect. Edge authentication is the simplest option for a small trusted audience; standards-based MCP authorization is more appropriate when broad client interoperability is required.
|
||||||
|
|
||||||
## Security Invariant
|
|
||||||
|
|
||||||
Everything exposed by the MCP server must be safe to publish publicly.
|
|
||||||
|
|
||||||
If that invariant stops being true, `/mcp` should be protected before the new capability is deployed.
|
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
---
|
||||||
|
name: cli-client
|
||||||
|
description: 'Design, implement, review, or refine Python command-line clients for remote APIs. Use for command structure, automation-friendly input and output, configuration, authentication, HTTP transport, pagination, retries, errors and exit codes, state-changing operations, packaging, or CLI testing.'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Python API CLI Clients
|
||||||
|
|
||||||
|
Use this skill as a conceptual reference for command-line applications that operate remote services. Preserve established project conventions, but make the command surface predictable for people, scripts, CI jobs, and shell composition.
|
||||||
|
|
||||||
|
## Design Priorities
|
||||||
|
|
||||||
|
A good API CLI should be:
|
||||||
|
|
||||||
|
- **Task-oriented:** commands reflect user goals rather than HTTP endpoints or internal service classes.
|
||||||
|
- **Predictable:** names, flags, defaults, output, errors, and exit statuses behave consistently.
|
||||||
|
- **Composable:** successful data goes to stdout, diagnostics go to stderr, and machine-readable output is stable.
|
||||||
|
- **Safe:** destructive operations are explicit, retries respect operation semantics, and secrets never enter output or logs.
|
||||||
|
- **Layered:** command parsing, application behavior, API resources, transport, authentication, and persistence have distinct owners.
|
||||||
|
- **Inspectable:** users can discover commands and effective configuration without reading source code.
|
||||||
|
|
||||||
|
Use the [Command Line Interface Guidelines](https://clig.dev/) as the general human-interface baseline. Prefer the target project's established command framework and HTTP library over introducing replacements without a concrete need.
|
||||||
|
|
||||||
|
## Command Model
|
||||||
|
|
||||||
|
Design the command tree around a small, consistent grammar:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mycli <resource> <action> [arguments] [options]
|
||||||
|
mycli projects list --owner alice
|
||||||
|
mycli projects get PROJECT_ID
|
||||||
|
mycli projects create --name NAME
|
||||||
|
mycli projects delete PROJECT_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
- Use nouns for resource groups and familiar verbs for actions.
|
||||||
|
- Keep equivalent operations parallel across resources: `list`, `get`, `create`, `update`, and `delete` should not change meaning by command group.
|
||||||
|
- Prefer explicit positional arguments for primary identities and named options for modifiers.
|
||||||
|
- Reserve global options for behavior that applies consistently across commands, such as profile, endpoint, output format, verbosity, and non-interactive mode.
|
||||||
|
- Give every command useful `--help` output with a one-line purpose, argument meaning, defaults, and behavior-changing caveats.
|
||||||
|
- Avoid mirroring every server endpoint. Combine low-level calls when one user task requires them, and omit endpoints that do not form a coherent CLI operation.
|
||||||
|
|
||||||
|
Do not make users memorize hidden context. When a command depends on an active account, project, region, or profile, make that context discoverable and overridable.
|
||||||
|
|
||||||
|
## Responsibility Boundaries
|
||||||
|
|
||||||
|
Keep the command layer thin and dependencies directional:
|
||||||
|
|
||||||
|
```text
|
||||||
|
entry point and bootstrap
|
||||||
|
└── command groups
|
||||||
|
└── application services
|
||||||
|
└── API client and resources
|
||||||
|
└── authenticated HTTP transport
|
||||||
|
└── HTTP library
|
||||||
|
|
||||||
|
configuration ───────────────┘
|
||||||
|
credentials ──> authentication
|
||||||
|
renderers <──── command results
|
||||||
|
```
|
||||||
|
|
||||||
|
| Layer | Owns | Avoid |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Entry point | Dependency construction, top-level exception mapping, process exit | Business logic and API calls |
|
||||||
|
| Command | Parsing, prompts, presentation, command-specific orchestration | Raw HTTP details and credential refresh |
|
||||||
|
| Application service | Multi-request use cases and domain decisions | Terminal formatting |
|
||||||
|
| API resource | Endpoint paths, request parameters, and response models | CLI prompts and global process state |
|
||||||
|
| Transport | Base URL, headers, serialization, timeouts, retries, and response decoding | Resource-specific business rules |
|
||||||
|
| Authentication | Credential acquisition, storage, and renewal | API resource behavior |
|
||||||
|
| Renderer | Human and machine-readable output | Network calls and state mutation |
|
||||||
|
|
||||||
|
Keep framework objects at the command boundary. Core operations should accept ordinary typed values and return structured results so they can be tested without invoking a subprocess.
|
||||||
|
|
||||||
|
## Input And Interaction
|
||||||
|
|
||||||
|
- Accept flags for every value that automation may need to provide. Prompts are an interactive convenience, not the only input path.
|
||||||
|
- Prompt only when stdin and stderr are attached to a terminal and the user has not selected non-interactive mode.
|
||||||
|
- In non-interactive mode, fail quickly with a specific missing-input error instead of waiting for input.
|
||||||
|
- Read large request bodies from a file or stdin; avoid forcing structured documents into shell-escaped arguments.
|
||||||
|
- Distinguish an omitted option from an explicit empty value when the API supports partial updates.
|
||||||
|
- Validate syntax locally, but let the service remain authoritative for remote identities, permissions, and business rules.
|
||||||
|
- Support `--` before pass-through values or positional arguments that can begin with a hyphen.
|
||||||
|
|
||||||
|
For destructive or difficult-to-reverse operations, state the target precisely and require confirmation in interactive sessions. Provide an explicit option such as `--yes` for automation; never silently infer consent merely because input is non-interactive.
|
||||||
|
|
||||||
|
## Output Contract
|
||||||
|
|
||||||
|
Treat output as a public interface.
|
||||||
|
|
||||||
|
- Write requested results to stdout and diagnostics, progress, warnings, and errors to stderr.
|
||||||
|
- Make the default human output concise and scannable. Do not print the same result as both prose and a table.
|
||||||
|
- Provide one stable machine-readable format, usually `--output json` or `--json`, for commands whose results are useful in automation.
|
||||||
|
- Serialize machine output from typed result models rather than scraping human-formatted strings.
|
||||||
|
- Keep machine-readable stdout clean: no progress bars, update notices, color codes, or explanatory prefixes.
|
||||||
|
- Disable color and animated progress when the output stream is not a terminal or when the user requests it.
|
||||||
|
- Use a pager only for interactive human output, and provide a consistent way to disable it.
|
||||||
|
- Document whether list commands emit one aggregate value or a stream of records; do not switch shapes based on result count.
|
||||||
|
|
||||||
|
When adding fields, preserve existing machine-readable fields where practical. Treat renaming, removing, or changing the type of a field as a compatibility decision.
|
||||||
|
|
||||||
|
## Configuration Model
|
||||||
|
|
||||||
|
Use one documented precedence order:
|
||||||
|
|
||||||
|
```text
|
||||||
|
command-line option > environment variable > selected profile/config file > built-in default
|
||||||
|
```
|
||||||
|
|
||||||
|
- Resolve configuration once near startup and pass a validated settings object inward.
|
||||||
|
- Keep endpoint, profile, timeout, output mode, and similar behavior visible through a config or diagnostics command.
|
||||||
|
- Show provenance when troubleshooting precedence, but redact secret values.
|
||||||
|
- Store configuration in platform-appropriate user directories rather than the current working directory unless project-local configuration is intentional.
|
||||||
|
- Keep credentials behind a separate storage abstraction. A convenient config file is not automatically an acceptable secret store.
|
||||||
|
- Validate incompatible options together and report the conflict in the user's vocabulary.
|
||||||
|
|
||||||
|
## HTTP And API Behavior
|
||||||
|
|
||||||
|
Centralize remote-call behavior in the transport or API client:
|
||||||
|
|
||||||
|
- Set explicit connect, read, write, and pool timeouts appropriate to the service.
|
||||||
|
- Send a useful user agent containing the CLI name and version.
|
||||||
|
- Map service errors into a small application error taxonomy before they reach commands.
|
||||||
|
- Retry only transient failures, honor `Retry-After`, cap attempts and elapsed time, and add jitter where concurrent clients may synchronize.
|
||||||
|
- Automatically retry state-changing requests only when they are demonstrably replay-safe, such as through an idempotency key accepted by the service.
|
||||||
|
- Preserve server request or correlation IDs in verbose diagnostics without exposing sensitive response data.
|
||||||
|
- Keep pagination in the API layer. Let commands choose whether to fetch one page, stream pages, or collect all results based on output and memory requirements.
|
||||||
|
- Make cancellation responsive between requests and during long-running operations.
|
||||||
|
|
||||||
|
Do not leak raw HTTP-library exceptions as the normal user interface. Preserve the original exception as the cause for debugging while presenting a stable CLI-level error.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Choose authentication from the service contract and execution context. Keep credential acquisition and renewal out of command handlers and API resources.
|
||||||
|
|
||||||
|
For OAuth-protected APIs, load [OAuth 2.0 for installed CLI clients](./references/oauth.md). It covers public clients, Authorization Code with PKCE, loopback callbacks, device authorization, Authlib and HTTPX2 boundaries, protected token storage, synchronized refresh, scopes, discovery, and bounded authentication retries.
|
||||||
|
|
||||||
|
For API keys or static tokens:
|
||||||
|
|
||||||
|
- Accept them through an explicit credential provider such as an OS credential store, environment variable, or CI secret integration.
|
||||||
|
- Define precedence when more than one provider is configured.
|
||||||
|
- Never place credentials in command arguments by default because process listings and shell history may expose them.
|
||||||
|
- Redact credentials and credential-like headers from errors, debug logs, traces, and support bundles.
|
||||||
|
|
||||||
|
For unattended workloads, use a service identity and grant intended for machines. Do not reuse a person's interactive credentials as automation identity.
|
||||||
|
|
||||||
|
## Errors And Exit Status
|
||||||
|
|
||||||
|
Keep a small documented taxonomy and map it once at the entry point:
|
||||||
|
|
||||||
|
| Category | User-facing behavior | Exit-status requirement |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Usage or validation | Explain the invalid input and show the nearest help hint | Stable nonzero status distinct from remote failure |
|
||||||
|
| Authentication | Explain whether login or credential repair is required | Stable nonzero status |
|
||||||
|
| Authorization | Identify the denied operation without claiming credentials are expired | Stable nonzero status |
|
||||||
|
| Not found or conflict | Name the target and preserve actionable server context | Stable nonzero status if scripts branch on it |
|
||||||
|
| Rate limit or transient service failure | Explain retryability and any known retry time | Stable nonzero status |
|
||||||
|
| Unexpected failure | Concise message plus opt-in diagnostic detail | Generic nonzero status |
|
||||||
|
|
||||||
|
- Return zero only when the requested operation completed according to its contract.
|
||||||
|
- Do not require scripts to parse prose to distinguish common failure categories.
|
||||||
|
- Keep normal errors concise. Put tracebacks, request details, and internal context behind an explicit debug or verbose mode.
|
||||||
|
- Handle interruption without a traceback by default and use the platform's conventional interrupted-process status.
|
||||||
|
- Preserve partial-success information for batch operations and define whether partial success is a failing exit status.
|
||||||
|
|
||||||
|
## State-Changing Operations
|
||||||
|
|
||||||
|
- Display or return the identity of the affected resource.
|
||||||
|
- Support a dry-run or plan mode when the service can accurately predict a consequential change.
|
||||||
|
- Use idempotency keys for retried creates or actions when the API supports them.
|
||||||
|
- Do not claim rollback if the remote API cannot provide it.
|
||||||
|
- For batch changes, define ordering, concurrency limits, stop/continue behavior, and partial-failure reporting.
|
||||||
|
- Keep local caches disposable unless their contents are explicitly part of the user contract.
|
||||||
|
|
||||||
|
## Concurrency And Async Boundaries
|
||||||
|
|
||||||
|
Use concurrency only where it improves a measured workflow such as independent page or resource retrieval. Bound concurrent requests to respect service and local limits.
|
||||||
|
|
||||||
|
Choose one owner for the event loop. Command handlers may call an async application boundary, but lower layers should not invoke nested event-loop runners. Keep synchronous and asynchronous APIs separate or adapt them in one explicit place.
|
||||||
|
|
||||||
|
## Suggested Package Shape
|
||||||
|
|
||||||
|
Adapt this shape to the project's size and existing conventions:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/mycli/
|
||||||
|
├── __main__.py
|
||||||
|
├── cli.py
|
||||||
|
├── config.py
|
||||||
|
├── errors.py
|
||||||
|
├── output.py
|
||||||
|
├── api/
|
||||||
|
│ ├── client.py
|
||||||
|
│ ├── transport.py
|
||||||
|
│ └── resources/
|
||||||
|
└── auth/
|
||||||
|
```
|
||||||
|
|
||||||
|
Small clients can combine modules while preserving the conceptual boundaries. Split code when a boundary has distinct dependencies, state, tests, or change cadence, not merely to reproduce the example tree.
|
||||||
|
|
||||||
|
## Design Sequence
|
||||||
|
|
||||||
|
1. Inventory the user tasks, execution environments, API capabilities, and existing project conventions.
|
||||||
|
2. Define the command grammar, required inputs, destructive-operation policy, output modes, and exit-status contract before wiring endpoints.
|
||||||
|
3. Define typed configuration and its precedence, including credential providers and active context.
|
||||||
|
4. Establish API resource, transport, authentication, and error boundaries.
|
||||||
|
5. Implement one vertical command path through parsing, service behavior, transport, rendering, and error mapping.
|
||||||
|
6. Verify the path both in-process and as an installed subprocess before repeating the pattern.
|
||||||
|
7. Add concurrency, retries, caching, rich presentation, and convenience prompts only where requirements justify them.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- Unit-test command-independent services, API resources, renderers, configuration resolution, and error mapping directly.
|
||||||
|
- Test command invocation with isolated environment variables, config directories, stdin, stdout, and stderr.
|
||||||
|
- Assert exit status, stdout, and stderr independently.
|
||||||
|
- Cover human and machine-readable output, including empty and multi-page results.
|
||||||
|
- Use a mock transport for timeouts, malformed responses, pagination, rate limits, transient retries, and permanent failures.
|
||||||
|
- Verify interactive confirmation and non-interactive refusal for destructive operations.
|
||||||
|
- Test redaction with realistic secret shapes in headers, URLs, response bodies, and nested exceptions.
|
||||||
|
- Add live-service tests only for behavior a local fake cannot represent, using isolated accounts and CI-managed credentials.
|
||||||
|
- Build and install the distribution in a clean environment to verify the console entry point and runtime dependencies.
|
||||||
|
|
||||||
|
## Reference Map
|
||||||
|
|
||||||
|
| Topic | Load when | Reference |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Python library selection | Choosing or comparing a parser framework, terminal output, TUI, configuration, HTTP, authentication, testing, or packaging stack | [Python CLI library selection](./references/library-selection.md) |
|
||||||
|
| OAuth for installed applications | The API uses OAuth, OIDC discovery, refresh tokens, loopback callbacks, or device authorization | [OAuth 2.0 for installed CLI clients](./references/oauth.md) |
|
||||||
|
|
||||||
|
## Completion Checks
|
||||||
|
|
||||||
|
1. Commands model recognizable user tasks with consistent names and options.
|
||||||
|
2. Interactive conveniences have explicit non-interactive equivalents.
|
||||||
|
3. Stdout, stderr, machine output, and exit statuses form a stable automation contract.
|
||||||
|
4. Configuration has one visible precedence order and credentials use an appropriate protected source.
|
||||||
|
5. Commands do not own raw HTTP, authentication lifecycle, or terminal-independent business logic.
|
||||||
|
6. Timeouts, retries, pagination, cancellation, and state-changing request safety are explicit.
|
||||||
|
7. Errors are actionable, categorized, redacted, and mapped at one process boundary.
|
||||||
|
8. Destructive and batch operations define confirmation, idempotency, and partial-failure behavior.
|
||||||
|
9. Tests exercise installed command behavior as well as isolated application and transport logic.
|
||||||
|
10. Help text and diagnostics let users discover the command surface and effective non-secret configuration.
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Python CLI Library Selection
|
||||||
|
|
||||||
|
Use this reference when choosing libraries for a new Python CLI or deciding whether an existing stack still fits. Choose each layer independently: a command parser, terminal renderer, terminal UI, settings model, HTTP client, authentication implementation, test runner, and package manager solve different problems.
|
||||||
|
|
||||||
|
## Default Stack
|
||||||
|
|
||||||
|
For a new typed application CLI, start with:
|
||||||
|
|
||||||
|
| Concern | Default | Why |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Command parsing | [Cyclopts](https://cyclopts.readthedocs.io/en/latest/) | Type-driven commands, rich type support, docstring-derived help, validation, command groups, configuration sources, and testing helpers |
|
||||||
|
| Human terminal output | [Rich](https://rich.readthedocs.io/en/stable/) | Tables, progress, status, syntax, terminal detection, and separate output consoles |
|
||||||
|
| Settings and data validation | [Pydantic](https://docs.pydantic.dev/latest/) and [Pydantic Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) | Typed models and explicit environment, dotenv, secret, and custom settings sources |
|
||||||
|
| HTTP | [HTTPX2](https://pydantic.dev/docs/httpx2/) | Actively maintained synchronous and asynchronous APIs, explicit clients, timeouts, streaming, and testable transports |
|
||||||
|
| OAuth | [Authlib 1.8+](https://pypi.org/project/Authlib/) | OAuth implementation integrated with HTTPX2; use the [OAuth reference](./oauth.md) for architecture and security requirements |
|
||||||
|
| Tests | [pytest](https://docs.pytest.org/en/stable/) | Fixtures, parametrization, output capture, monkeypatching, and a broad plugin ecosystem |
|
||||||
|
| Project and environment | [uv](https://docs.astral.sh/uv/) | Project dependencies, lockfiles, environments, scripts, builds, and tool execution |
|
||||||
|
|
||||||
|
Add [Textual](https://textual.textualize.io/) only when the product needs a persistent, event-driven terminal interface. It complements a command parser; it does not replace the scriptable command surface.
|
||||||
|
|
||||||
|
This is a starting point, not a mandate. Preserve a sound existing stack unless a requirement exposes a concrete limitation.
|
||||||
|
|
||||||
|
## Choose The Command Framework
|
||||||
|
|
||||||
|
### Use Cyclopts by default for a new typed application CLI
|
||||||
|
|
||||||
|
[Cyclopts](https://cyclopts.readthedocs.io/en/latest/) derives commands and parameters from Python function signatures and supports built-in and user-defined types, including unions, literals, dataclasses, Pydantic models, and attrs classes. It can derive help from docstrings and provides converters, validators, nested commands, lazy loading, configuration sources, documentation integration, and testing guidance.
|
||||||
|
|
||||||
|
Choose Cyclopts when:
|
||||||
|
|
||||||
|
- Modern type annotations should be the primary command schema.
|
||||||
|
- Commands accept structured dataclasses or validation models.
|
||||||
|
- Rich unions, literals, nested structures, or reusable parameter groups matter.
|
||||||
|
- The project values concise declarations and generated documentation.
|
||||||
|
- A newer and smaller ecosystem is an acceptable tradeoff.
|
||||||
|
|
||||||
|
Before committing, prototype the hardest command signature, help page, validation error, completion behavior, and test invocation. Do not evaluate a framework only on a one-command example.
|
||||||
|
|
||||||
|
### Use Typer for the mainstream type-driven choice
|
||||||
|
|
||||||
|
[Typer](https://typer.tiangolo.com/) also derives CLI arguments and options from Python type hints and provides automatic help, shell completion, nested command groups, Rich-formatted output, packaging guidance, and test helpers. It is a strong choice when contributor familiarity, established examples, and ecosystem recognition matter more than Cyclopts' broader type model.
|
||||||
|
|
||||||
|
Since Typer 0.26.0, [Typer vendors Click](https://typer.tiangolo.com/#click-code) rather than depending on the external Click package. Do not assume an arbitrary Click extension or subclass will integrate with modern Typer; verify that requirement against the installed Typer release.
|
||||||
|
|
||||||
|
Choose Typer when:
|
||||||
|
|
||||||
|
- The team already knows Typer or follows the FastAPI ecosystem.
|
||||||
|
- The command types fit Typer's supported parameter model.
|
||||||
|
- A familiar, established type-driven framework lowers contributor cost.
|
||||||
|
- Existing Typer conventions or integrations outweigh framework-switching benefits.
|
||||||
|
|
||||||
|
### Use Click when explicit control is the requirement
|
||||||
|
|
||||||
|
[Click](https://click.palletsprojects.com/en/stable/) models commands, groups, contexts, parameters, types, and invocation explicitly. It supports arbitrary command nesting, lazy subcommand loading, custom parameter types, extension APIs, testing utilities, and a mature plugin ecosystem.
|
||||||
|
|
||||||
|
Choose Click when:
|
||||||
|
|
||||||
|
- The CLI is itself a framework or plugin host.
|
||||||
|
- Commands must be discovered or loaded lazily.
|
||||||
|
- Parsing, context propagation, invocation, or help behavior needs unusual customization.
|
||||||
|
- Existing Click extensions are a hard dependency.
|
||||||
|
- Explicit declarations are preferable to inference from application types.
|
||||||
|
|
||||||
|
Do not choose Click merely because it is mature. For ordinary application commands, the extra parser-level detail may duplicate function types, defaults, validation, and documentation.
|
||||||
|
|
||||||
|
### Use argparse when dependency constraints dominate
|
||||||
|
|
||||||
|
[`argparse`](https://docs.python.org/3/library/argparse.html) is the standard-library parser and supports subcommands, generated help, custom actions and types, argument files, and parser-level error handling. Python 3.14 added colored help and `suggest_on_error`, making its default experience more capable than older comparisons imply.
|
||||||
|
|
||||||
|
Choose argparse when:
|
||||||
|
|
||||||
|
- The tool must remain standard-library-only.
|
||||||
|
- It is a small utility with a stable and modest command surface.
|
||||||
|
- Conservative deployment environments value availability over declaration ergonomics.
|
||||||
|
- Adding a runtime dependency has a real operational cost.
|
||||||
|
|
||||||
|
For a substantial typed application, account for the duplication between parser declarations and the application's function signatures, models, defaults, validation, and help text.
|
||||||
|
|
||||||
|
### Use Fire for exposure, not deliberate public design
|
||||||
|
|
||||||
|
[Python Fire](https://github.com/google/python-fire) generates a CLI from functions, classes, modules, mappings, and other Python objects. This is useful for developer tools, debugging, exploration, and rapidly exposing an internal Python API.
|
||||||
|
|
||||||
|
Avoid Fire as the default for a stable public CLI. Exposing the Python object model couples command names, arguments, and behavior to implementation details instead of treating the CLI as a deliberately designed compatibility surface.
|
||||||
|
|
||||||
|
## Framework Decision Table
|
||||||
|
|
||||||
|
| Primary requirement | Choose | Main tradeoff |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| New, typed application with rich parameter models | Cyclopts | Smaller ecosystem and less accumulated operational history |
|
||||||
|
| Type-driven CLI with maximum contributor familiarity | Typer | Verify complex typing and Click-extension assumptions |
|
||||||
|
| Plugin framework or unusual parser behavior | Click | More explicit declarations and parser-specific code |
|
||||||
|
| Standard-library-only or tiny utility | argparse | Imperative setup and duplicated schema information |
|
||||||
|
| Rapid internal exposure of Python objects | Fire | Python implementation becomes the CLI contract |
|
||||||
|
|
||||||
|
When Cyclopts and Typer both fit, build the same representative vertical slice in each. Include the most complex parameter model, nested command registration, configuration injection, help output, validation failure, shell completion, and command test. Select from that evidence rather than syntax preference.
|
||||||
|
|
||||||
|
## Keep Complementary Libraries In Their Layer
|
||||||
|
|
||||||
|
### Rich is presentation, not parsing
|
||||||
|
|
||||||
|
Use [Rich](https://rich.readthedocs.io/en/stable/) behind a renderer abstraction for human-readable tables, progress, status displays, syntax, and styled errors. Keep structured output on a separate serialization path so `--output json` never contains decoration, progress, or terminal control codes.
|
||||||
|
|
||||||
|
Typer includes Rich as a dependency and uses it for formatted errors. Cyclopts can also produce Rich-oriented help and errors. This does not remove the need for an application-owned output boundary.
|
||||||
|
|
||||||
|
### Textual is an optional interactive mode
|
||||||
|
|
||||||
|
Use [Textual](https://textual.textualize.io/) when users need a persistent screen, navigation, reactive widgets, keyboard actions, or live dashboards. Keep ordinary parser commands for automation and direct operations:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mycli projects list -> scriptable command
|
||||||
|
mycli interactive -> Textual application
|
||||||
|
both -> application services -> API client
|
||||||
|
```
|
||||||
|
|
||||||
|
The command and TUI adapters should call the same application services. Do not embed API and domain behavior separately in Textual event handlers.
|
||||||
|
|
||||||
|
## Supporting Stack Decisions
|
||||||
|
|
||||||
|
### Configuration and models
|
||||||
|
|
||||||
|
Use dataclasses when configuration is small, already parsed, and needs no source orchestration. Use [Pydantic](https://docs.pydantic.dev/latest/) for structured request, response, configuration, or command models that benefit from validation and serialization. Use [Pydantic Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) when environment variables, dotenv files, secret files, or custom settings sources participate in explicit precedence.
|
||||||
|
|
||||||
|
Do not pass framework parameter objects into application services. Convert parser output into ordinary typed values or application models at the command boundary.
|
||||||
|
|
||||||
|
### HTTP
|
||||||
|
|
||||||
|
Use [HTTPX2](https://pydantic.dev/docs/httpx2/) for new API clients. It is the actively developed continuation of HTTPX and supports synchronous and asynchronous clients, explicit timeouts, streaming, custom authentication, and mock transports. [Authlib 1.8+](https://pypi.org/project/Authlib/) integrates with HTTPX2 directly. Reuse a client with explicit timeouts rather than calling top-level request functions throughout resource methods.
|
||||||
|
|
||||||
|
Preserve [HTTPX](https://www.python-httpx.org/) in a sound existing client until its dependencies, type checks, and transport tests are ready to migrate. Do not use HTTPX2's process-wide import alias from reusable library code, and do not keep HTTPX and HTTPX2 as permanent parallel transports without a concrete compatibility requirement.
|
||||||
|
|
||||||
|
Use [aiohttp](https://docs.aiohttp.org/en/stable/) when the project already standardizes on its async client, depends on its streaming or WebSocket behavior, or has measured requirements that justify a different transport. Do not introduce both HTTPX2 and aiohttp without a clear ownership boundary.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
Use [Authlib 1.8+](https://pypi.org/project/Authlib/) for OAuth protocol behavior rather than implementing grants, PKCE, token parsing, and refresh directly. Keep it behind an authentication abstraction. For OAuth-enabled installed applications, follow [OAuth 2.0 for installed CLI clients](./oauth.md).
|
||||||
|
|
||||||
|
Use [keyring](https://keyring.readthedocs.io/en/latest/) to access operating-system credential stores when the deployment environment provides one. Treat headless secret storage as an explicit deployment decision rather than silently falling back to plaintext configuration.
|
||||||
|
|
||||||
|
### Testing and packaging
|
||||||
|
|
||||||
|
Use [pytest](https://docs.pytest.org/en/stable/) for application and command tests. Combine framework-level invocation helpers with subprocess tests of the installed console entry point; a runner helper alone does not verify packaging or startup behavior.
|
||||||
|
|
||||||
|
Use [uv](https://docs.astral.sh/uv/) for dependency management, lockfiles, isolated tool execution, and project commands when the repository adopts uv. Declare the CLI through a `[project.scripts]` entry point in [`pyproject.toml`](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#creating-and-packaging-command-line-tools) so installation, tests, and users invoke the same bootstrap path.
|
||||||
|
|
||||||
|
## Architecture Rule
|
||||||
|
|
||||||
|
Do not couple application behavior to the chosen CLI framework:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cyclopts / Typer / Click / argparse
|
||||||
|
|
|
||||||
|
v
|
||||||
|
thin command adapters
|
||||||
|
|
|
||||||
|
v
|
||||||
|
application services
|
||||||
|
|
|
||||||
|
v
|
||||||
|
API client
|
||||||
|
```
|
||||||
|
|
||||||
|
Command functions should parse or receive values, call an application service, and hand the result to a renderer. Keep API calls, authentication, retries, and domain decisions outside parser decorators and callbacks. This makes framework-specific tests small and keeps a future parser migration bounded.
|
||||||
|
|
||||||
|
## Selection Checklist
|
||||||
|
|
||||||
|
1. Identify the minimum supported Python version and dependency constraints.
|
||||||
|
2. Model the hardest real command, not the smallest demonstration command.
|
||||||
|
3. Decide whether type annotations or explicit parser objects should own the command schema.
|
||||||
|
4. Check complex types, nested commands, lazy loading, plugins, completion, help, validation, and test support against actual requirements.
|
||||||
|
5. Separate parsing from Rich presentation and optional Textual interaction.
|
||||||
|
6. Select configuration, HTTP, authentication, testing, and packaging libraries independently.
|
||||||
|
7. Prototype ambiguous framework choices with the same vertical slice.
|
||||||
|
8. Pin compatible versions and verify behavior against installed-library documentation before implementation.
|
||||||
|
9. Keep application services free of CLI-framework types.
|
||||||
|
10. Preserve stable stdout, stderr, and exit-status contracts regardless of library defaults.
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
# OAuth 2.0 For Installed CLI Clients
|
||||||
|
|
||||||
|
Use this reference when a Python CLI calls an OAuth-protected API. Keep authentication, token lifecycle, HTTP transport, and API resources as separate ownership boundaries.
|
||||||
|
|
||||||
|
## Recommended Default
|
||||||
|
|
||||||
|
For an interactive installed CLI, use:
|
||||||
|
|
||||||
|
> Public client + Authorization Code + PKCE using `S256` + loopback callback + Authlib 1.8+ + HTTPX2 + protected credential store + centralized token refresh + minimum scopes.
|
||||||
|
|
||||||
|
Follow the current [OAuth 2.0 Security Best Current Practice](https://www.rfc-editor.org/rfc/rfc9700) and the [OAuth 2.0 guidance for native applications](https://www.rfc-editor.org/rfc/rfc8252). Do not use the implicit grant, Resource Owner Password Credentials grant, or an embedded client secret.
|
||||||
|
|
||||||
|
## Responsibility Boundaries
|
||||||
|
|
||||||
|
Keep dependencies pointed inward toward authentication and transport primitives:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CLI commands
|
||||||
|
├── login/logout/status -> OAuthManager -> authorization server
|
||||||
|
└── API commands -> ApiClient/resources -> OAuthTransport
|
||||||
|
└── TokenManager
|
||||||
|
├── TokenStore
|
||||||
|
└── OAuth client / HTTP transport
|
||||||
|
```
|
||||||
|
|
||||||
|
| Component | Owns | Must not own |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `OAuthManager` | Interactive login, callback validation, token exchange, logout | API resource methods |
|
||||||
|
| `TokenStore` | Loading, atomically saving, and deleting sensitive token state | Refresh policy or HTTP requests |
|
||||||
|
| `TokenManager` | Expiry checks, refresh synchronization, and valid access-token retrieval | CLI presentation or resource URLs |
|
||||||
|
| `OAuthTransport` | Bearer-token injection and one bounded authentication retry | Interactive login UX |
|
||||||
|
| `ApiClient` and resources | API operations, request models, and response models | OAuth grants, refresh tokens, or credential storage |
|
||||||
|
|
||||||
|
Keep [Authlib's HTTPX2 OAuth client](https://github.com/authlib/authlib/blob/v1.8.0/authlib/integrations/httpx_client/oauth2_client.py) behind the authentication boundary. API resources should request an authenticated transport or call `TokenManager.get_valid_access_token()`; they should not depend directly on Authlib.
|
||||||
|
|
||||||
|
## Library Selection
|
||||||
|
|
||||||
|
Use one library per responsibility and keep each one behind an application-owned interface:
|
||||||
|
|
||||||
|
| Concern | Default for new code | Use something else when |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| OAuth protocol | [Authlib 1.8+](https://pypi.org/project/Authlib/) | A provider supplies an official, maintained SDK that correctly implements its non-standard behavior |
|
||||||
|
| API and OAuth HTTP | [HTTPX2 2.x](https://pydantic.dev/docs/httpx2/) | Preserve HTTPX in an existing stable client until its dependencies and test doubles are ready to migrate |
|
||||||
|
| Desktop credential storage | [keyring](https://keyring.readthedocs.io/en/latest/) | The target platforms are explicitly supported by a reviewed native alternative, or the deployment already owns a managed vault |
|
||||||
|
| Async coordination | [`asyncio`](https://docs.python.org/3/library/asyncio-sync.html) for an asyncio-only CLI | The application already standardizes on [AnyIO](https://anyio.readthedocs.io/en/stable/) or supports multiple async backends |
|
||||||
|
| Tests | [pytest](https://docs.pytest.org/en/stable/) and [`httpx2.MockTransport`](https://pydantic.dev/docs/httpx2/advanced/transports/#mock-transports) | The repository has an established equivalent |
|
||||||
|
|
||||||
|
[HTTPX2](https://pypi.org/project/httpx2/) is the actively developed continuation of HTTPX under Pydantic stewardship. It keeps the familiar client, request, response, authentication, and mock-transport APIs while using the `httpx2` import. Authlib 1.8 moved its HTTP client integration to HTTPX2, so new OAuth clients can use one HTTP implementation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add "Authlib>=1.8" "httpx2>=2" keyring
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not call [`httpx2.alias_httpx()`](https://pydantic.dev/docs/httpx2/api/api/#httpx2.alias_httpx) from reusable library code. It changes imports process-wide and exists as a temporary application-level migration aid. If an existing dependency still requires `httpx`, keep both clients behind local abstractions and remove HTTPX only after dependency and transport tests pass.
|
||||||
|
|
||||||
|
## Configuration And Secret State
|
||||||
|
|
||||||
|
Separate public OAuth configuration from sensitive OAuth state.
|
||||||
|
|
||||||
|
Public configuration may contain:
|
||||||
|
|
||||||
|
- Client ID for the registered public client.
|
||||||
|
- Issuer, authorization endpoint, token endpoint, and optional revocation or device-authorization endpoint.
|
||||||
|
- Exact registered redirect URI rules.
|
||||||
|
- Required scopes and, when supported, resource or audience indicators.
|
||||||
|
|
||||||
|
Sensitive state includes:
|
||||||
|
|
||||||
|
- Access tokens.
|
||||||
|
- Refresh tokens.
|
||||||
|
- Token expiry and related token response fields when they reveal account or authorization state.
|
||||||
|
|
||||||
|
Represent the complete token response and store it through a narrow abstraction. A `TypedDict` documents the common fields without discarding provider-specific fields from the runtime dictionary:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Protocol, Required, TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthToken(TypedDict, total=False):
|
||||||
|
access_token: Required[str]
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str
|
||||||
|
expires_at: float
|
||||||
|
expires_in: int
|
||||||
|
scope: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenStore(Protocol):
|
||||||
|
async def load(self) -> OAuthToken | None: ...
|
||||||
|
async def save(self, token: OAuthToken) -> None: ...
|
||||||
|
async def delete(self) -> None: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
For a desktop CLI, [keyring](https://keyring.readthedocs.io/en/latest/) remains the conservative default because it selects macOS Keychain, Windows Credential Locker, Secret Service, or KWallet as available. Its API is synchronous, so move calls off the event-loop thread. Store one JSON document per account so access-token and refresh-token rotation is replaced as one logical update:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
import keyring
|
||||||
|
from keyring.backend import KeyringBackend
|
||||||
|
from keyring.errors import KeyringError
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialStoreError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class KeyringTokenStore:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
service: str,
|
||||||
|
account: str,
|
||||||
|
backend: KeyringBackend | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._service = service
|
||||||
|
self._account = account
|
||||||
|
self._backend = backend or keyring.get_keyring()
|
||||||
|
if self._backend.priority <= 0:
|
||||||
|
raise CredentialStoreError("No protected credential store is available")
|
||||||
|
|
||||||
|
async def load(self) -> OAuthToken | None:
|
||||||
|
try:
|
||||||
|
raw = await anyio.to_thread.run_sync(
|
||||||
|
self._backend.get_password, self._service, self._account
|
||||||
|
)
|
||||||
|
except KeyringError as error:
|
||||||
|
raise CredentialStoreError("Could not read OAuth credentials") from error
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
raise CredentialStoreError("Stored OAuth credentials are invalid") from error
|
||||||
|
if not isinstance(value, dict) or not isinstance(value.get("access_token"), str):
|
||||||
|
raise CredentialStoreError("Stored OAuth credentials are invalid")
|
||||||
|
return cast("OAuthToken", value)
|
||||||
|
|
||||||
|
async def save(self, token: OAuthToken) -> None:
|
||||||
|
encoded = json.dumps(token, separators=(",", ":"))
|
||||||
|
try:
|
||||||
|
await anyio.to_thread.run_sync(
|
||||||
|
self._backend.set_password,
|
||||||
|
self._service,
|
||||||
|
self._account,
|
||||||
|
encoded,
|
||||||
|
)
|
||||||
|
except KeyringError as error:
|
||||||
|
raise CredentialStoreError("Could not save OAuth credentials") from error
|
||||||
|
|
||||||
|
async def delete(self) -> None:
|
||||||
|
if await self.load() is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await anyio.to_thread.run_sync(
|
||||||
|
self._backend.delete_password, self._service, self._account
|
||||||
|
)
|
||||||
|
except KeyringError as error:
|
||||||
|
raise CredentialStoreError("Could not delete OAuth credentials") from error
|
||||||
|
```
|
||||||
|
|
||||||
|
Fail closed when no recommended backend is available. Do not install [`keyrings.alt`](https://pypi.org/project/keyrings.alt/) as an automatic fallback; it intentionally includes possibly insecure backends.
|
||||||
|
|
||||||
|
### Storage Alternatives
|
||||||
|
|
||||||
|
- Consider [`rust-native-keyring`](https://pypi.org/project/rust-native-keyring/) when native compiled wheels, the Rust [keyring ecosystem](https://github.com/open-source-cooperative/keyring-rs), and its richer credential-store selection fit the supported platforms. Its Python package is still `0.x`, so pin it, verify wheel availability, and test lock/unlock and deletion behavior on every target OS before preferring it over `keyring`.
|
||||||
|
- Consider the official [1Password Python SDK](https://www.1password.dev/sdks/) when users already rely on 1Password and desktop authorization prompts, auditing, or shared vault policy are product requirements. It is asynchronous and supports desktop-app authorization, but its SDK is also still version `0`; it is not a transparent local-keyring replacement.
|
||||||
|
- Use a managed service such as [AWS Secrets Manager](https://docs.aws.amazon.com/secretsmanager/latest/userguide/retrieving-secrets-python-sdk.html), [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/secrets/quick-create-python), [Google Secret Manager](https://docs.cloud.google.com/secret-manager/docs/reference/libraries), or [HashiCorp Vault](https://developer.hashicorp.com/vault/docs/get-started/developer-qs) for unattended or centrally governed deployments. Authenticate with workload identity or another deployment-owned mechanism; do not solve storage by introducing a second long-lived bootstrap secret.
|
||||||
|
|
||||||
|
For a headless environment without a credential service, require an explicit storage backend appropriate to its threat model; do not silently fall back to plaintext config. Never emit tokens through logs, telemetry, exceptions, shell output, or status commands.
|
||||||
|
|
||||||
|
## Interactive Authorization
|
||||||
|
|
||||||
|
Use Authorization Code with PKCE for browser-based login:
|
||||||
|
|
||||||
|
1. Generate a cryptographically random `state` and a fresh PKCE `code_verifier` for every attempt.
|
||||||
|
2. Derive the `S256` code challenge and construct the authorization URL with the exact requested redirect URI and minimum scopes.
|
||||||
|
3. Bind a temporary listener to `127.0.0.1` on an ephemeral port. Do not expose it on all interfaces.
|
||||||
|
4. Open the system browser and wait for one callback with a short timeout and cancellation path.
|
||||||
|
5. Reject OAuth errors, a missing code, or any callback whose `state` does not exactly match.
|
||||||
|
6. Exchange the code using the original verifier and redirect URI.
|
||||||
|
7. Persist the complete returned token state atomically, then stop the listener immediately.
|
||||||
|
8. Return a minimal success page and CLI message without displaying credentials.
|
||||||
|
|
||||||
|
Treat the CLI as a public client. A secret distributed inside source, a package, a binary, or an environment-independent configuration file cannot authenticate installed copies of the CLI.
|
||||||
|
|
||||||
|
The following manager shows the Authlib-owned part of a loopback flow. A separate callback receiver should bind `127.0.0.1`, accept one request, enforce a timeout and maximum request size, then pass the complete callback URL to `finish()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import secrets
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
from authlib.integrations.httpx_client import AsyncOAuth2Client
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OAuthConfig:
|
||||||
|
client_id: str
|
||||||
|
authorization_endpoint: str
|
||||||
|
token_endpoint: str
|
||||||
|
redirect_uri: str
|
||||||
|
scopes: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PendingAuthorization:
|
||||||
|
url: str
|
||||||
|
state: str
|
||||||
|
code_verifier: str
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthManager:
|
||||||
|
def __init__(self, config: OAuthConfig, store: TokenStore) -> None:
|
||||||
|
self._config = config
|
||||||
|
self._store = store
|
||||||
|
self._client = AsyncOAuth2Client(
|
||||||
|
client_id=config.client_id,
|
||||||
|
redirect_uri=config.redirect_uri,
|
||||||
|
scope=" ".join(config.scopes),
|
||||||
|
code_challenge_method="S256",
|
||||||
|
token_endpoint_auth_method="none",
|
||||||
|
)
|
||||||
|
|
||||||
|
def begin(self) -> PendingAuthorization:
|
||||||
|
verifier = secrets.token_urlsafe(64)
|
||||||
|
url, state = self._client.create_authorization_url(
|
||||||
|
self._config.authorization_endpoint,
|
||||||
|
code_verifier=verifier,
|
||||||
|
)
|
||||||
|
return PendingAuthorization(url=url, state=state, code_verifier=verifier)
|
||||||
|
|
||||||
|
async def finish(
|
||||||
|
self, callback_url: str, pending: PendingAuthorization
|
||||||
|
) -> OAuthToken:
|
||||||
|
callback = urlsplit(callback_url)
|
||||||
|
expected = urlsplit(self._config.redirect_uri)
|
||||||
|
callback_target = (callback.scheme, callback.hostname, callback.port, callback.path)
|
||||||
|
expected_target = (expected.scheme, expected.hostname, expected.port, expected.path)
|
||||||
|
if callback_target != expected_target:
|
||||||
|
raise AuthenticationError("OAuth callback used an unexpected redirect URI")
|
||||||
|
|
||||||
|
query = parse_qs(callback.query)
|
||||||
|
if query.get("state") != [pending.state]:
|
||||||
|
raise AuthenticationError("OAuth callback state did not match")
|
||||||
|
if "error" in query:
|
||||||
|
raise AuthenticationError("Authorization server rejected login")
|
||||||
|
code = query.get("code", [None])[0]
|
||||||
|
if code is None:
|
||||||
|
raise AuthenticationError("OAuth callback did not contain a code")
|
||||||
|
|
||||||
|
result = await self._client.fetch_token(
|
||||||
|
self._config.token_endpoint,
|
||||||
|
code=code,
|
||||||
|
code_verifier=pending.code_verifier,
|
||||||
|
)
|
||||||
|
token = cast("OAuthToken", dict(result))
|
||||||
|
await self._store.save(token)
|
||||||
|
return token
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
await self._client.aclose()
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not persist `PendingAuthorization`: `state` and `code_verifier` are short-lived, single-attempt values. Define `AuthenticationError` in the application's stable error taxonomy and keep callback query values out of its message.
|
||||||
|
|
||||||
|
If a browser or loopback listener is impractical and the provider exposes it, use the standardized [Device Authorization Grant](https://www.rfc-editor.org/rfc/rfc8628). Respect the server-provided polling interval, `slow_down`, expiration, and cancellation behavior. Do not invent a device flow against a provider that does not advertise or document one.
|
||||||
|
|
||||||
|
## Authorization Server Metadata
|
||||||
|
|
||||||
|
Prefer [OAuth 2.0 Authorization Server Metadata](https://www.rfc-editor.org/rfc/rfc8414) or OpenID Connect discovery when the provider supports it. Validate that discovered metadata belongs to the configured issuer and require HTTPS for non-loopback endpoints.
|
||||||
|
|
||||||
|
Use explicit endpoints when discovery is unavailable or when a controlled deployment intentionally pins them. Do not mix endpoints discovered from one issuer with configuration from another.
|
||||||
|
|
||||||
|
## Token Lifecycle
|
||||||
|
|
||||||
|
Centralize expiry and refresh decisions in `TokenManager`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
load token
|
||||||
|
├── missing -> authentication required
|
||||||
|
├── valid beyond refresh leeway -> return access token
|
||||||
|
└── expired or near expiry
|
||||||
|
└── acquire refresh lock
|
||||||
|
├── reload token
|
||||||
|
├── return it if another task refreshed it
|
||||||
|
└── refresh, atomically persist the full response, and return it
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a small expiry leeway, commonly 60 seconds, to avoid starting a request with a token that expires in transit. For a single-process async CLI, an `asyncio.Lock` is sufficient for in-process refresh coordination. If multiple processes can share one token store, add storage-level coordination or optimistic versioning; an in-process lock cannot prevent cross-process races.
|
||||||
|
|
||||||
|
Preserve a refresh token when the server omits it from a refresh response, but replace it whenever rotation returns a new one. Save the newly returned token as one atomic state update so an older writer cannot restore a superseded refresh token.
|
||||||
|
|
||||||
|
Classify missing, expired-without-refresh, rejected-refresh, and revoked credentials as authentication failures with a clear path to log in again. Do not turn refresh failures into anonymous API calls.
|
||||||
|
|
||||||
|
A small refresher adapter and token manager keep Authlib details out of storage and API resources:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AuthlibTokenRefresher:
|
||||||
|
def __init__(self, config: OAuthConfig) -> None:
|
||||||
|
self._config = config
|
||||||
|
|
||||||
|
async def refresh(self, token: OAuthToken) -> OAuthToken:
|
||||||
|
refresh_token = token.get("refresh_token")
|
||||||
|
if refresh_token is None:
|
||||||
|
raise AuthenticationError("Login is required")
|
||||||
|
async with AsyncOAuth2Client(
|
||||||
|
client_id=self._config.client_id,
|
||||||
|
token=dict(token),
|
||||||
|
token_endpoint_auth_method="none",
|
||||||
|
) as client:
|
||||||
|
result = await client.refresh_token(
|
||||||
|
self._config.token_endpoint,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
)
|
||||||
|
refreshed = cast("OAuthToken", dict(result))
|
||||||
|
refreshed.setdefault("refresh_token", refresh_token)
|
||||||
|
return refreshed
|
||||||
|
|
||||||
|
|
||||||
|
class TokenManager:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: TokenStore,
|
||||||
|
refresher: AuthlibTokenRefresher,
|
||||||
|
*,
|
||||||
|
refresh_leeway: float = 60.0,
|
||||||
|
clock: Callable[[], float] = time.time,
|
||||||
|
) -> None:
|
||||||
|
self._store = store
|
||||||
|
self._refresher = refresher
|
||||||
|
self._refresh_leeway = refresh_leeway
|
||||||
|
self._clock = clock
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
def _is_usable(self, token: OAuthToken) -> bool:
|
||||||
|
expires_at = token.get("expires_at")
|
||||||
|
return expires_at is None or expires_at > self._clock() + self._refresh_leeway
|
||||||
|
|
||||||
|
async def get_valid_access_token(self, *, force_refresh: bool = False) -> str:
|
||||||
|
token = await self._store.load()
|
||||||
|
if token is None:
|
||||||
|
raise AuthenticationError("Login is required")
|
||||||
|
if not force_refresh and self._is_usable(token):
|
||||||
|
return token["access_token"]
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
token = await self._store.load()
|
||||||
|
if token is None:
|
||||||
|
raise AuthenticationError("Login is required")
|
||||||
|
if not force_refresh and self._is_usable(token):
|
||||||
|
return token["access_token"]
|
||||||
|
refreshed = await self._refresher.refresh(token)
|
||||||
|
await self._store.save(refreshed)
|
||||||
|
return refreshed["access_token"]
|
||||||
|
```
|
||||||
|
|
||||||
|
This lock covers one process. Replace or augment it with storage-level compare-and-swap or an inter-process lock when several processes share the same credential entry.
|
||||||
|
|
||||||
|
## Authenticated HTTP Transport
|
||||||
|
|
||||||
|
The transport should:
|
||||||
|
|
||||||
|
1. Obtain a valid access token before sending a protected request.
|
||||||
|
2. Inject the authorization header without exposing the token to API resource code.
|
||||||
|
3. Apply the project's normal timeout, TLS, proxy, retry, and error-mapping policy.
|
||||||
|
4. Optionally react to one `401` by forcing one synchronized refresh and replaying the request once.
|
||||||
|
5. Raise an authentication error if the replay is still unauthorized.
|
||||||
|
|
||||||
|
Do not refresh blindly on every `401`; unauthorized responses can indicate revocation, malformed credentials, the wrong audience, or another authentication failure. Never create an unbounded refresh or request loop. Replay only requests whose body can be safely regenerated, and do not treat `403` as an expiry signal.
|
||||||
|
|
||||||
|
[HTTPX2 custom authentication](https://pydantic.dev/docs/httpx2/advanced/authentication/#custom-authentication-schemes) is a compact way to apply the token manager to every API request. This example retries one bodyless, read-only request after a synchronized refresh and leaves all other `401` responses untouched:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx2
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthAuth(httpx2.Auth):
|
||||||
|
requires_response_body = True
|
||||||
|
|
||||||
|
def __init__(self, tokens: TokenManager) -> None:
|
||||||
|
self._tokens = tokens
|
||||||
|
|
||||||
|
def sync_auth_flow(self, request: httpx2.Request):
|
||||||
|
raise RuntimeError("OAuthAuth requires httpx2.AsyncClient")
|
||||||
|
yield request
|
||||||
|
|
||||||
|
async def async_auth_flow(self, request: httpx2.Request):
|
||||||
|
access_token = await self._tokens.get_valid_access_token()
|
||||||
|
request.headers["Authorization"] = f"Bearer {access_token}"
|
||||||
|
response = yield request
|
||||||
|
|
||||||
|
if response.status_code != 401 or request.method not in {"GET", "HEAD", "OPTIONS"}:
|
||||||
|
return
|
||||||
|
access_token = await self._tokens.get_valid_access_token(force_refresh=True)
|
||||||
|
request.headers["Authorization"] = f"Bearer {access_token}"
|
||||||
|
yield request
|
||||||
|
|
||||||
|
|
||||||
|
class ApiClient:
|
||||||
|
def __init__(self, base_url: str, tokens: TokenManager) -> None:
|
||||||
|
self._http = httpx2.AsyncClient(
|
||||||
|
base_url=base_url,
|
||||||
|
auth=OAuthAuth(tokens),
|
||||||
|
timeout=httpx2.Timeout(20.0, connect=5.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_project(self, project_id: str) -> dict[str, object]:
|
||||||
|
response = await self._http.get(f"/projects/{project_id}")
|
||||||
|
response.raise_for_status()
|
||||||
|
value = response.json()
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("Expected an object response")
|
||||||
|
return value
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
await self._http.aclose()
|
||||||
|
```
|
||||||
|
|
||||||
|
For streaming requests, uploads, or state-changing methods, omit automatic replay unless the API supplies an idempotency mechanism and the request body can be rebuilt. Map the final HTTPX2 response or exception into application errors before it reaches a CLI command.
|
||||||
|
|
||||||
|
## Scopes And Token Restrictions
|
||||||
|
|
||||||
|
- Request only scopes needed by the CLI's supported operations.
|
||||||
|
- Keep scopes explicit in configuration and stable in tests.
|
||||||
|
- Request offline access or equivalent provider-specific scope only when refresh tokens are needed.
|
||||||
|
- Use audience or resource restrictions when supported.
|
||||||
|
- Detect when stored authorization lacks scopes required by a command and direct the user through deliberate reauthorization rather than quietly broadening every login.
|
||||||
|
|
||||||
|
## CLI Surface
|
||||||
|
|
||||||
|
Expose a small authentication surface consistent with the existing command framework:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mycli login
|
||||||
|
mycli logout
|
||||||
|
mycli auth status
|
||||||
|
```
|
||||||
|
|
||||||
|
`login` starts interactive authorization and reports only progress and outcome. `logout` deletes local token state and uses the provider's revocation endpoint when supported; explain if remote revocation fails after local deletion. `auth status` may show account identity, granted scopes, issuer, and expiry, but never a token or authorization code.
|
||||||
|
|
||||||
|
A manual `auth refresh` command is optional and primarily diagnostic. Normal API commands should not require users to manage refresh timing.
|
||||||
|
|
||||||
|
## Suggested Package Shape
|
||||||
|
|
||||||
|
Adapt names to the existing project rather than forcing this exact tree:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/mycli/
|
||||||
|
├── cli.py
|
||||||
|
├── errors.py
|
||||||
|
├── api/
|
||||||
|
│ ├── client.py
|
||||||
|
│ ├── transport.py
|
||||||
|
│ └── resources/
|
||||||
|
└── auth/
|
||||||
|
├── config.py
|
||||||
|
├── manager.py
|
||||||
|
├── token.py
|
||||||
|
└── store.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep token models independent from provider client objects so storage, tests, and API code do not inherit Authlib's internal representation as a public application contract.
|
||||||
|
|
||||||
|
## Branching Guidance
|
||||||
|
|
||||||
|
- If the provider supports loopback redirects: use Authorization Code with PKCE and an ephemeral `127.0.0.1` listener.
|
||||||
|
- If the execution environment cannot open a browser or accept a loopback callback: use Device Authorization Grant only when the provider supports it.
|
||||||
|
- If the API is called by unattended automation rather than a person: use the provider's machine-to-machine grant and credential mechanism; do not reuse an interactive user's refresh token as service identity.
|
||||||
|
- If the provider supports discovery: derive endpoints from validated issuer metadata; otherwise pin explicit HTTPS endpoints.
|
||||||
|
- If requests are concurrent in one process: serialize refresh with a lock and re-read state after acquisition.
|
||||||
|
- If token state is shared across processes: use inter-process coordination and atomic persistence.
|
||||||
|
- If an existing CLI already has HTTP and configuration abstractions: integrate at those boundaries instead of replacing the command framework or resource layer.
|
||||||
|
|
||||||
|
## Tests And Verification
|
||||||
|
|
||||||
|
Test protocol behavior without depending on a live identity provider:
|
||||||
|
|
||||||
|
- Login creates fresh state and verifier values and uses `S256`.
|
||||||
|
- Callback handling accepts the expected state and rejects missing, mismatched, duplicate, timed-out, and OAuth-error callbacks.
|
||||||
|
- The listener binds only to loopback and always shuts down.
|
||||||
|
- Token exchange uses the same redirect URI and verifier as authorization.
|
||||||
|
- Valid tokens bypass refresh; near-expiry tokens refresh once.
|
||||||
|
- Concurrent requests cause one refresh and all callers observe the persisted replacement token.
|
||||||
|
- Refresh-token rotation cannot be overwritten by stale state.
|
||||||
|
- A `401` causes at most one eligible replay; a second `401` fails.
|
||||||
|
- Status and error output remain useful without revealing token values.
|
||||||
|
- Logout clears local state and handles optional remote revocation explicitly.
|
||||||
|
|
||||||
|
Use deterministic clocks, fake token stores, and [`httpx2.MockTransport`](https://pydantic.dev/docs/httpx2/advanced/transports/#mock-transports) for unit tests. Add a provider integration test only when the project has suitable isolated credentials and CI secret handling.
|
||||||
|
|
||||||
|
## Completion Checks
|
||||||
|
|
||||||
|
1. The CLI is registered and implemented as a public client without an embedded secret.
|
||||||
|
2. Interactive login uses Authorization Code with PKCE `S256`, fresh state, exact redirect validation, and a bounded loopback listener.
|
||||||
|
3. Device authorization is conditional on provider support and follows server polling instructions.
|
||||||
|
4. Token storage is replaceable, protected, atomic, and absent from logs and normal output.
|
||||||
|
5. One component owns expiry, synchronized refresh, and refresh-token rotation.
|
||||||
|
6. API resources remain independent of OAuth protocol and storage details.
|
||||||
|
7. Scopes and audience are minimal and explicit.
|
||||||
|
8. Authentication retries are bounded and replay only eligible requests.
|
||||||
|
9. Login, logout, status, refresh, concurrency, callback rejection, and redaction paths have focused verification.
|
||||||
|
10. Provider-specific behavior and installed dependency versions are checked against current primary documentation.
|
||||||
@@ -1,164 +1,73 @@
|
|||||||
---
|
---
|
||||||
name: nicegui
|
name: nicegui
|
||||||
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.'
|
description: 'Build, review, debug, configure, deploy, and package NiceGUI applications. Use for FastAPI or Uvicorn integration, ui.run settings, native mode, Docker or executable deployment, app factories and lifespan, thin pages and reusable component factories, bindable dataclass handles, ui.refreshable methods, ui.* components, Quasar props/events/slots, Tailwind layout, colors, bindings, editable ui.table cells, uploads/forms/live updates, or version-specific source research.'
|
||||||
---
|
---
|
||||||
|
|
||||||
# NiceGUI Reference
|
# NiceGUI Application Guide
|
||||||
|
|
||||||
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.
|
Use this skill to choose the smallest supporting reference for a NiceGUI task. The pages cover different ownership boundaries; do not load the whole reference set.
|
||||||
|
|
||||||
## When to Use
|
## Workflow
|
||||||
|
|
||||||
- Planning or reviewing NiceGUI application structure and FastAPI composition.
|
1. Inspect the target project's pinned NiceGUI version, entry point, and existing page/component patterns.
|
||||||
- Building or refactoring pages, components, layouts, and static assets.
|
2. Match the request to one row in the routing table and load that primary reference.
|
||||||
- Creating editable tables with Python-authoritative state, validation, and persistence.
|
3. Load the optional companion only when the task crosses the boundary named in the last column.
|
||||||
- Modeling UI state with bindings or bindable dataclasses.
|
4. Prefer NiceGUI's typed constructor, binding, or helper API; descend to Quasar props, events, slots, or methods only when the wrapper does not expose the required behavior.
|
||||||
- Implementing forms, uploads, refreshes, live updates, or background work.
|
5. Validate the changed behavior with a focused test. For visual work, also check the supported mobile, landscape desktop, and portrait desktop viewports.
|
||||||
- Diagnosing UI state, concurrency, navigation, or asset problems.
|
|
||||||
- Verifying framework behavior against primary documentation.
|
|
||||||
|
|
||||||
## How to Use This Skill
|
## Task Routing
|
||||||
|
|
||||||
1. Classify the request using the discovery map below.
|
| Task or symptom | Load first | Add only when |
|
||||||
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.
|
| Choose package boundaries, dependency direction, thin page composition, reusable component factories, returned dataclass component handles, page registration, health routes, or optional subsystem placement | [application architecture](./references/architecture.md) | Add [binding dataclasses](./references/binding-dataclasses.md) for the component handle's binding graph or [FastAPI and Uvicorn startup](./references/fastapi-uvicorn-startup.md) for concrete ASGI ownership. |
|
||||||
4. Check the pinned NiceGUI and integration versions before relying on version-specific APIs.
|
| Decide between `ui.run()` and `ui.run_with()`, compose a parent FastAPI app, define lifespan ordering, build an app factory, configure typed settings, expose a project script, or handle reload/workers | [FastAPI and Uvicorn startup](./references/fastapi-uvicorn-startup.md) | Add [configuration and deployment](./references/configuration-and-deployment.md) for concrete `ui.run` options, hosting, native mode, or packaging. |
|
||||||
5. Validate the changed behavior with focused tests and, for UI work, relevant viewport checks.
|
| Configure `ui.run`, consume `app.urls`, select NiceGUI environment variables, run behind Docker or a reverse proxy, enable HTTPS, build a native app, package with PyInstaller or Nuitka, or evaluate NiceGUI On Air | [configuration and deployment](./references/configuration-and-deployment.md) | Add [FastAPI and Uvicorn startup](./references/fastapi-uvicorn-startup.md) when a parent ASGI app, app factory, lifespan, reload, or workers own part of startup. |
|
||||||
|
| Choose a `ui.*` constructor, binding, Quasar prop, event, slot, or frontend method; diagnose model events, event payloads, scoped-slot props, detached popups, `ui.select`, or `ui.icon` | [component mechanics](./references/component-mechanics.md) | Add [source documentation](./references/source-documentation.md) when the installed wrapper or bundled Quasar version must be verified. |
|
||||||
|
| Build page shells, rows, columns, grids, widths, overflow, responsive reflow, typography, font loading, static assets, or deliberate scaling | [page structure, typography, and scaling](./references/styling-and-customization.md) | Add [component mechanics](./references/component-mechanics.md) when layout depends on a Quasar prop, slot, popup, or generated component structure. |
|
||||||
|
| Configure `app.colors()`, `ui.colors()`, semantic or fixed Quasar colors, custom color names, component color values, CSS color variables, or `ui.dark_mode()` | [NiceGUI and Quasar color theming](./references/colors-and-quasar-theming.md) | Add [page structure, typography, and scaling](./references/styling-and-customization.md) only when the task also changes physical layout or CSS loading. |
|
||||||
|
| Model typed page or component state with `binding.bindable_dataclass`, return a bound component handle, understand propagation and transform direction, bind nested values, avoid active-link polling, or design projection/persistence rollback | [binding dataclasses](./references/binding-dataclasses.md) | Add [application architecture](./references/architecture.md) for the render-factory and thin-page boundary or [component mechanics](./references/component-mechanics.md) for browser-originated proposals. |
|
||||||
|
| Customize `ui.table` or QTable columns, formatting, classes, props, responsive density, toolbar controls, visible columns, empty states, named slots, or frontend methods | [table customization](./references/table-customization.md) | Add [editable tables](./references/tables.md) only when cells also accept server-authoritative edits. |
|
||||||
|
| Make `ui.table` cells editable with stable row keys, dataframe projections, row-scoped dataclasses, validation, touched rows, selection-preserving refresh, or `QPopupEdit` | [editable tables](./references/tables.md) | Follow its links to binding or component mechanics only when changing the underlying projection or event bridge. |
|
||||||
|
| Implement uploads, form submission, SSE versus WebSockets, background jobs, duplicate-submit guards, or `@ui.refreshable` and `@ui.refreshable_method` component regions | [interaction patterns](./references/interaction-patterns.md) | Add [application architecture](./references/architecture.md) for the reusable component contract or [binding dataclasses](./references/binding-dataclasses.md) when state propagation itself is the problem. |
|
||||||
|
| Build or explain URL-backed tabs, persistent tab panels, `ui.sub_pages` route adapters, browser-history synchronization, or parameterized routes that share one tab | [URL-backed tabs with sub pages](./references/tabbed-subpages.md) | Add [binding dataclasses](./references/binding-dataclasses.md) only when the route-backed state grows beyond the single field shown in the example. |
|
||||||
|
| Investigate upload errors, async UI races, stale assets, navigation/state drift, or perform a compact production-readiness review | [troubleshooting and quality gates](./references/troubleshooting-and-quality-gates.md) | Follow the symptom to one detailed reference above. |
|
||||||
|
| Verify a framework claim against primary NiceGUI, FastAPI, Uvicorn, Tailwind, Quasar, SQLAlchemy, Pydantic, or LangGraph documentation | [source documentation](./references/source-documentation.md) | Use a task page first when implementation guidance, not source lookup, is needed. |
|
||||||
|
|
||||||
## Progressive Discovery Map
|
## Boundary Rules
|
||||||
|
|
||||||
### Application Architecture
|
- Use [application architecture](./references/architecture.md) for module ownership, not for page geometry or low-level component behavior.
|
||||||
|
- Use [FastAPI and Uvicorn startup](./references/fastapi-uvicorn-startup.md) to decide which process owns startup; use [configuration and deployment](./references/configuration-and-deployment.md) after that decision for runtime, native, hosting, and packaging settings.
|
||||||
|
- Keep page functions thin: compose page shells and returned component handles there; keep each component's element tree, bindings, callbacks, and bounded refreshes in its render factory or component object.
|
||||||
|
- Use [page structure, typography, and scaling](./references/styling-and-customization.md) for physical layout. Use [component mechanics](./references/component-mechanics.md) for the behavior crossing NiceGUI, Quasar, Vue, and browser boundaries.
|
||||||
|
- Start read-only table presentation and QTable control work in [table customization](./references/table-customization.md); keep editable state and validation in [editable tables](./references/tables.md).
|
||||||
|
- Use [binding dataclasses](./references/binding-dataclasses.md) for the binding graph and Python model projections. Use [interaction patterns](./references/interaction-patterns.md) for user workflows such as upload, submit, refresh, streaming, and background work.
|
||||||
|
- Start editable-table work in [editable tables](./references/tables.md). It already identifies the exact binding and event sections needed by that pattern.
|
||||||
|
- Treat [source documentation](./references/source-documentation.md) as a source index, not as an implementation workflow.
|
||||||
|
|
||||||
Load [application architecture](./references/architecture.md) for:
|
## Runnable Examples
|
||||||
|
|
||||||
- FastAPI app factories and lifespan ownership
|
Load an example only when its exact mechanic matches the task:
|
||||||
- package boundaries and dependency direction
|
|
||||||
- page registration and health routes
|
|
||||||
- optional persistence, LangGraph, or mounted documentation
|
|
||||||
- async responsiveness and baseline tests
|
|
||||||
|
|
||||||
### FastAPI And Uvicorn Startup
|
- [binding transforms](./examples/data_binding.py): `bindable_dataclass`, `ui.date`, and typed `forward`/`backward` conversion.
|
||||||
|
- [select events](./examples/select_events.py): `on_change`, generic Quasar events, `update:model-value`, browser-to-Python payload forwarding, and programmatic value changes.
|
||||||
|
- [table customization](./examples/table_customization.py): raw-value sorting with cosmetic prefix, suffix, and datetime formatting; dynamic classes; QTable props; toolbar and cell slots; filtering; visible columns; and empty states.
|
||||||
|
- [editable table](./examples/editable_table.py): dataframe-to-row state, named QTable cell slots, controlled editors, dialog-based whole-row save/cancel edits, Python validation, touched rows, and canonical row refresh.
|
||||||
|
- [tabbed sub-pages](./examples/tab_spa.py): a persistent shell with URL-backed tabs, tab panels, browser-history navigation, and retained state for a parameterized report route. See the [reference explanation](./references/tabbed-subpages.md) for the ownership model and behavioral boundaries.
|
||||||
|
|
||||||
Load [FastAPI and Uvicorn startup](./references/fastapi-uvicorn-startup.md) for:
|
## Defaults That Span References
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
### Styling And Customization
|
|
||||||
|
|
||||||
Load [styling and customization](./references/styling-and-customization.md) for:
|
|
||||||
|
|
||||||
- app-wide and page-level color themes, dark mode, and semantic CSS tokens
|
|
||||||
- Tailwind and Quasar utility classes
|
|
||||||
- scoped CSS properties and stable application classes
|
|
||||||
- responsive page composition and static asset loading
|
|
||||||
- cosmetic treatment of controls, surfaces, typography, and visual states
|
|
||||||
- visual validation at supported viewport sizes
|
|
||||||
|
|
||||||
### Component Mechanics
|
|
||||||
|
|
||||||
Load [component mechanics](./references/component-mechanics.md) for:
|
|
||||||
|
|
||||||
- the NiceGUI Python wrapper, element bridge, Quasar component, and Vue runtime boundaries
|
|
||||||
- deciding between constructors, bindings, Quasar props, events, slots, and frontend methods
|
|
||||||
- controlled values, model events, transformed payloads, and server-authoritative edit proposals
|
|
||||||
- server-client state and event flow, validation timing, and commit policy
|
|
||||||
- detached content and external icon assets
|
|
||||||
- source research against the installed NiceGUI and bundled Quasar versions
|
|
||||||
- `ui.select` and `ui.icon` mechanics and caveats
|
|
||||||
- scoped component slots and their interaction contracts
|
|
||||||
|
|
||||||
### Editable Tables
|
|
||||||
|
|
||||||
Load [editable tables](./references/tables.md) for:
|
|
||||||
|
|
||||||
- Python-authoritative editable `ui.table` state
|
|
||||||
- rendering dataframe records into row-scoped bindable dataclasses
|
|
||||||
- stable row identity across sorting, filtering, and pagination
|
|
||||||
- NiceGUI editors in Quasar `body-cell-*` scoped slots
|
|
||||||
- QTable row refresh and selection preservation after edits
|
|
||||||
- the full `body` slot required when escalating to `QPopupEdit`
|
|
||||||
|
|
||||||
### Bindable State
|
|
||||||
|
|
||||||
Load [bindable dataclasses](./references/binding-dataclasses.md) for:
|
|
||||||
|
|
||||||
- typed local UI state
|
|
||||||
- propagation, serializable projections, persistence, and rollback behavior
|
|
||||||
- nested structures and strict bindings
|
|
||||||
- mutable defaults, performance, and version notes
|
|
||||||
|
|
||||||
### Interaction Patterns
|
|
||||||
|
|
||||||
Load [interaction patterns](./references/interaction-patterns.md) for:
|
|
||||||
|
|
||||||
- uploads and form submission
|
|
||||||
- explicit refreshes
|
|
||||||
- server-sent events and WebSockets
|
|
||||||
- background work and duplicate-submission guards
|
|
||||||
|
|
||||||
### Troubleshooting And Quality
|
|
||||||
|
|
||||||
Load [troubleshooting and quality gates](./references/troubleshooting-and-quality-gates.md) for:
|
|
||||||
|
|
||||||
- upload failures and UI race conditions
|
|
||||||
- stale assets and navigation drift
|
|
||||||
- responsiveness, accessibility, reliability, and maintainability checks
|
|
||||||
|
|
||||||
### Primary Sources
|
|
||||||
|
|
||||||
Load [source documentation](./references/source-documentation.md) when:
|
|
||||||
|
|
||||||
- behavior is version-sensitive or uncertain
|
|
||||||
- an integration recommendation needs verification
|
|
||||||
- upstream NiceGUI, FastAPI, Tailwind, Quasar, SQLAlchemy, Pydantic, or LangGraph documentation is required
|
|
||||||
|
|
||||||
## Common Discovery Paths
|
|
||||||
|
|
||||||
### New Application Or Architecture Review
|
|
||||||
|
|
||||||
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 [styling and customization](./references/styling-and-customization.md) only when page layout or visual customization is in scope.
|
|
||||||
|
|
||||||
### Page Or Component Work
|
|
||||||
|
|
||||||
1. Load [application architecture](./references/architecture.md) for page and component ownership decisions.
|
|
||||||
2. Load [styling and customization](./references/styling-and-customization.md) for themes, layout, responsive presentation, utility classes, or CSS.
|
|
||||||
3. Load [component mechanics](./references/component-mechanics.md) when behavior must be mapped across NiceGUI, Quasar, and Vue, or when detached content and component-specific behavior are involved.
|
|
||||||
4. Load [editable tables](./references/tables.md) when table cells accept user changes or `QPopupEdit` is being considered.
|
|
||||||
5. Add [interaction patterns](./references/interaction-patterns.md) or [bindable dataclasses](./references/binding-dataclasses.md) according to the page behavior.
|
|
||||||
|
|
||||||
### Debugging Or Production Review
|
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
## General Defaults
|
|
||||||
|
|
||||||
- Keep composition, transport, services, pages, and components directionally separated.
|
- Keep composition, transport, services, pages, and components directionally separated.
|
||||||
|
- Prefer reusable render functions that return typed component handles; use bindable dataclass fields for the state intentionally exposed to composition code.
|
||||||
- Keep business logic out of UI components and event handlers.
|
- Keep business logic out of UI components and event handlers.
|
||||||
- Avoid blocking I/O and CPU-heavy work in the UI event loop.
|
- Avoid blocking I/O and CPU-heavy work in the UI event loop.
|
||||||
- Prefer event-driven updates and explicit refreshes over unrelated polling.
|
- Prefer event-driven updates and explicit refreshes to unrelated polling.
|
||||||
- Discover component capabilities through NiceGUI docs and constructors, then the wrapped Quasar API.
|
- Keep browser-originated values as proposals; validate and normalize them in Python before mutating authoritative state.
|
||||||
- Keep editable table records authoritative in Python; send stable row keys with edit proposals and reassert canonical rows after validation.
|
- Prefer NiceGUI context managers and `ui.*` elements over raw Vue templates. Keep application logic and authoritative state in Python; use minimal browser expressions only for scoped-slot values or client-only behavior, following [Python-owned slot composition](./references/component-mechanics.md#prefer-python-owned-composition).
|
||||||
- Research the current NiceGUI and Quasar source documentation before generating component-specific code or CSS.
|
- For each presentation requirement, check the component's typed Python arguments and helpers before using `.props(...)` or `.classes(...)`. Create an application class and add CSS only when no Python API, documented component prop or slot, or existing utility class can express the requirement.
|
||||||
- Prefer constructor arguments and native Quasar features through NiceGUI; use Tailwind for structure and scoped static CSS for stable fine tuning.
|
- Use NiceGUI context managers for element structure and Tailwind for generic layout, spacing, responsive behavior, and typography. Keep Quasar classes for semantic palette roles or component-specific geometry, and use Quasar props for component behavior and density; see [combining Tailwind with Quasar utilities](./references/styling-and-customization.md#combine-tailwind-with-quasar-utilities-deliberately).
|
||||||
- Provide loading, success, and failure states for user-triggered work.
|
- Provide loading, success, and failure states for user-triggered work.
|
||||||
- Treat version-specific guidance as a prompt to verify the project's dependency version.
|
- Verify component-specific behavior against the installed NiceGUI version and its bundled Quasar version.
|
||||||
|
|
||||||
## Reference Use Contract
|
## Completion Check
|
||||||
|
|
||||||
When applying this skill:
|
Before finishing, distinguish target-repository facts from reference recommendations, cite the supporting page used for framework-specific claims, state unresolved assumptions, and report the focused behavior and viewport checks performed.
|
||||||
|
|
||||||
- return only guidance relevant to the current task
|
|
||||||
- distinguish repository facts from reference recommendations
|
|
||||||
- cite the appropriate source reference for framework-level claims
|
|
||||||
- state assumptions when application requirements are missing
|
|
||||||
- report the focused checks used to validate implementation changes
|
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
# dependencies = [
|
# dependencies = [
|
||||||
# "nicegui==3.16.0",
|
# "nicegui==3.16.0",
|
||||||
# "pandas",
|
# "pandas",
|
||||||
|
# "pydantic>=2",
|
||||||
# ]
|
# ]
|
||||||
# ///
|
# ///
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from dataclasses import field
|
from dataclasses import field
|
||||||
|
|
||||||
@@ -13,6 +15,9 @@ import pandas as pd
|
|||||||
from nicegui import binding
|
from nicegui import binding
|
||||||
from nicegui import events
|
from nicegui import events
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from pydantic import field_validator
|
||||||
|
|
||||||
STATUS_OPTIONS = ["draft", "active", "archived"]
|
STATUS_OPTIONS = ["draft", "active", "archived"]
|
||||||
EDITABLE_FIELDS = ("name", "quantity", "status")
|
EDITABLE_FIELDS = ("name", "quantity", "status")
|
||||||
@@ -21,6 +26,52 @@ type TableValue = str | int
|
|||||||
type TableRow = dict[str, TableValue]
|
type TableRow = dict[str, TableValue]
|
||||||
|
|
||||||
|
|
||||||
|
class RowEditDraft(BaseModel):
|
||||||
|
name: str
|
||||||
|
quantity: int
|
||||||
|
status: str
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def validate_name(cls, value: str) -> str:
|
||||||
|
if not (name := value.strip()):
|
||||||
|
raise ValueError("Name is required")
|
||||||
|
return name
|
||||||
|
|
||||||
|
@field_validator("quantity", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def validate_quantity(cls, value: object) -> int:
|
||||||
|
if isinstance(value, bool) or value is None:
|
||||||
|
raise TypeError("Quantity must be an integer")
|
||||||
|
if not isinstance(value, (int, float, str)):
|
||||||
|
raise TypeError("Quantity must be an integer")
|
||||||
|
if isinstance(value, float) and not value.is_integer():
|
||||||
|
raise ValueError("Quantity must be an integer")
|
||||||
|
try:
|
||||||
|
quantity = int(value)
|
||||||
|
except (TypeError, ValueError, OverflowError) as error:
|
||||||
|
raise ValueError("Quantity must be an integer") from error
|
||||||
|
if not 0 <= quantity <= 1_000:
|
||||||
|
raise ValueError("Quantity must be between 0 and 1000")
|
||||||
|
return quantity
|
||||||
|
|
||||||
|
@field_validator("status")
|
||||||
|
@classmethod
|
||||||
|
def validate_status(cls, value: str) -> str:
|
||||||
|
if value not in STATUS_OPTIONS:
|
||||||
|
raise ValueError("Unknown status")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RowEditorDialog:
|
||||||
|
open_for_row_id: Callable[[int], None]
|
||||||
|
|
||||||
|
|
||||||
|
def _validation_message(error: ValidationError) -> str:
|
||||||
|
return str(error.errors()[0]["msg"])
|
||||||
|
|
||||||
|
|
||||||
@binding.bindable_dataclass
|
@binding.bindable_dataclass
|
||||||
class EditableRow:
|
class EditableRow:
|
||||||
id: int
|
id: int
|
||||||
@@ -46,6 +97,18 @@ class EditableRow:
|
|||||||
other_strict=True,
|
other_strict=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def to_draft(self) -> RowEditDraft:
|
||||||
|
return RowEditDraft(name=self.name, quantity=self.quantity, status=self.status)
|
||||||
|
|
||||||
|
def validate_update(self, updates: dict[str, object]) -> RowEditDraft:
|
||||||
|
base_values = self.to_draft().model_dump()
|
||||||
|
return RowEditDraft.model_validate({**base_values, **updates})
|
||||||
|
|
||||||
|
def apply_draft(self, draft: RowEditDraft) -> None:
|
||||||
|
self.name = draft.name
|
||||||
|
self.quantity = draft.quantity
|
||||||
|
self.status = draft.status
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class EditableTableState:
|
class EditableTableState:
|
||||||
@@ -86,30 +149,65 @@ def dataframe_to_state(dataframe: pd.DataFrame) -> EditableTableState:
|
|||||||
return EditableTableState(rows_by_id)
|
return EditableTableState(rows_by_id)
|
||||||
|
|
||||||
|
|
||||||
def normalize_edit(field: str, raw_value: object) -> TableValue:
|
def render_row_editor_dialog(
|
||||||
match field:
|
state: EditableTableState,
|
||||||
case "name":
|
refresh_table: Callable[[], None],
|
||||||
if not isinstance(raw_value, str) or not (name := raw_value.strip()):
|
) -> RowEditorDialog:
|
||||||
raise ValueError("Name is required")
|
selected_row_id: int | None = None
|
||||||
return name
|
|
||||||
case "quantity":
|
with ui.dialog() as edit_dialog, ui.card().classes("w-96"):
|
||||||
if isinstance(raw_value, bool) or not isinstance(raw_value, (int, float, str)):
|
dialog_heading = ui.label("Edit row")
|
||||||
raise TypeError("Quantity must be an integer")
|
draft_name = ui.input("Name")
|
||||||
if isinstance(raw_value, float) and not raw_value.is_integer():
|
draft_quantity = ui.number("Quantity", min=0, max=1_000, precision=0)
|
||||||
raise ValueError("Quantity must be an integer")
|
draft_status = ui.select(STATUS_OPTIONS, label="Status")
|
||||||
try:
|
with ui.row().classes("w-full justify-end"):
|
||||||
quantity = int(raw_value)
|
ui.button("Cancel", on_click=edit_dialog.close).props("flat")
|
||||||
except (ValueError, OverflowError) as error:
|
|
||||||
raise ValueError("Quantity must be an integer") from error
|
def save_dialog_edit() -> None:
|
||||||
if not 0 <= quantity <= 1_000:
|
nonlocal selected_row_id
|
||||||
raise ValueError("Quantity must be between 0 and 1000")
|
try:
|
||||||
return quantity
|
if selected_row_id is None:
|
||||||
case "status":
|
raise ValueError("Select a row before saving")
|
||||||
if not isinstance(raw_value, str) or raw_value not in STATUS_OPTIONS:
|
|
||||||
raise ValueError("Unknown status")
|
row_state = state.row(selected_row_id)
|
||||||
return raw_value
|
if row_state is None:
|
||||||
case _:
|
raise ValueError("This row no longer exists")
|
||||||
raise ValueError(f"Field {field!r} is not editable")
|
|
||||||
|
draft = row_state.validate_update(
|
||||||
|
{
|
||||||
|
"name": draft_name.value,
|
||||||
|
"quantity": draft_quantity.value,
|
||||||
|
"status": draft_status.value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
row_state.apply_draft(draft)
|
||||||
|
row_state.touched = True
|
||||||
|
edit_dialog.close()
|
||||||
|
except ValidationError as error:
|
||||||
|
ui.notify(_validation_message(error), type="negative")
|
||||||
|
except ValueError as error:
|
||||||
|
ui.notify(str(error), type="negative")
|
||||||
|
finally:
|
||||||
|
refresh_table()
|
||||||
|
|
||||||
|
ui.button("Save", icon="save", on_click=save_dialog_edit)
|
||||||
|
|
||||||
|
def open_for_row_id(row_id: int) -> None:
|
||||||
|
nonlocal selected_row_id
|
||||||
|
row_state = state.row(row_id)
|
||||||
|
if row_state is None:
|
||||||
|
ui.notify("This row no longer exists", type="negative")
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_row_id = row_id
|
||||||
|
draft = row_state.to_draft()
|
||||||
|
dialog_heading.set_text(f"Edit row {row_id}")
|
||||||
|
draft_name.set_value(draft.name)
|
||||||
|
draft_quantity.set_value(draft.quantity)
|
||||||
|
draft_status.set_value(draft.status)
|
||||||
|
edit_dialog.open()
|
||||||
|
|
||||||
|
return RowEditorDialog(open_for_row_id=open_for_row_id)
|
||||||
|
|
||||||
|
|
||||||
def render_table(dataframe: pd.DataFrame) -> EditableTableState:
|
def render_table(dataframe: pd.DataFrame) -> EditableTableState:
|
||||||
@@ -118,6 +216,7 @@ def render_table(dataframe: pd.DataFrame) -> EditableTableState:
|
|||||||
{"name": "name", "label": "Name", "field": "name", "align": "left"},
|
{"name": "name", "label": "Name", "field": "name", "align": "left"},
|
||||||
{"name": "quantity", "label": "Quantity", "field": "quantity", "align": "right"},
|
{"name": "quantity", "label": "Quantity", "field": "quantity", "align": "right"},
|
||||||
{"name": "status", "label": "Status", "field": "status", "align": "left"},
|
{"name": "status", "label": "Status", "field": "status", "align": "left"},
|
||||||
|
{"name": "actions", "label": "Actions", "field": "id", "align": "center"},
|
||||||
]
|
]
|
||||||
table = ui.table(
|
table = ui.table(
|
||||||
columns=columns,
|
columns=columns,
|
||||||
@@ -127,59 +226,96 @@ def render_table(dataframe: pd.DataFrame) -> EditableTableState:
|
|||||||
pagination=10,
|
pagination=10,
|
||||||
).classes("w-120")
|
).classes("w-120")
|
||||||
|
|
||||||
def apply_edit(event: events.GenericEventArguments) -> None:
|
def refresh_table() -> None:
|
||||||
|
table.update_rows(state.table_rows(), clear_selection=False)
|
||||||
|
|
||||||
|
def apply_inline_edit(event: events.GenericEventArguments) -> None:
|
||||||
try:
|
try:
|
||||||
raw_row_id, raw_field, raw_value = event.args
|
raw_row_id, raw_field, raw_value = event.args
|
||||||
row_id = int(raw_row_id)
|
row_id = int(raw_row_id)
|
||||||
field_name = str(raw_field)
|
field_name = str(raw_field)
|
||||||
|
if field_name not in EDITABLE_FIELDS:
|
||||||
|
raise ValueError(f"Field {field_name!r} is not editable")
|
||||||
|
|
||||||
row_state = state.row(row_id)
|
row_state = state.row(row_id)
|
||||||
if row_state is None:
|
if row_state is None:
|
||||||
raise ValueError("This row no longer exists")
|
raise ValueError("This row no longer exists")
|
||||||
|
|
||||||
normalized_value = normalize_edit(field_name, raw_value)
|
draft = row_state.validate_update({field_name: raw_value})
|
||||||
setattr(row_state, field_name, normalized_value)
|
row_state.apply_draft(draft)
|
||||||
row_state.touched = True
|
row_state.touched = True
|
||||||
|
except ValidationError as error:
|
||||||
|
ui.notify(_validation_message(error), type="negative")
|
||||||
except (TypeError, ValueError) as error:
|
except (TypeError, ValueError) as error:
|
||||||
ui.notify(str(error), type="negative")
|
ui.notify(str(error), type="negative")
|
||||||
finally:
|
finally:
|
||||||
table.update_rows(state.table_rows(), clear_selection=False)
|
refresh_table()
|
||||||
|
|
||||||
def show_changes() -> None:
|
def show_changes() -> None:
|
||||||
changed_rows = state.touched_rows()
|
changed_rows = state.touched_rows()
|
||||||
if not changed_rows:
|
if not changed_rows:
|
||||||
ui.notify("No rows changed")
|
ui.notify("No rows changed")
|
||||||
return
|
return
|
||||||
summary = "; ".join(
|
for row in changed_rows:
|
||||||
f"{row.id}: {row.name}, quantity {row.quantity}, status {row.status}" for row in changed_rows
|
ui.notify(f"Changed row: {row.id}: {row.name}, quantity {row.quantity}, status {row.status}")
|
||||||
)
|
|
||||||
ui.notify(f"Changed rows: {summary}")
|
|
||||||
|
|
||||||
|
row_editor = render_row_editor_dialog(state, refresh_table)
|
||||||
|
|
||||||
|
_add_slots(
|
||||||
|
table,
|
||||||
|
apply_inline_edit,
|
||||||
|
row_editor.open_for_row_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
with ui.row().classes("w-120 justify-end"):
|
||||||
|
ui.button("Show changes", icon="edit_note", on_click=show_changes)
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def open_dialog_for_row(open_editor: Callable[[int], None], event: events.GenericEventArguments) -> None:
|
||||||
|
try:
|
||||||
|
row_id = int(event.args)
|
||||||
|
open_editor(row_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
ui.notify("Invalid row key", type="negative")
|
||||||
|
|
||||||
|
|
||||||
|
def _add_slots(
|
||||||
|
table: ui.table,
|
||||||
|
apply_inline_edit: Callable[[events.GenericEventArguments], None],
|
||||||
|
open_editor: Callable[[int], None],
|
||||||
|
):
|
||||||
with table.add_slot("body-cell-name"), table.cell("name"):
|
with table.add_slot("body-cell-name"), table.cell("name"):
|
||||||
name_input = ui.input().props(remove="value")
|
name_input = ui.input().props(remove="value")
|
||||||
name_input.props(':value="props.value" dense borderless debounce=400').on(
|
name_input.props(':value="props.value" dense borderless debounce=400').on(
|
||||||
"update:value",
|
"update:value",
|
||||||
handler=apply_edit,
|
handler=apply_inline_edit,
|
||||||
js_handler="(value) => emit(props.row.id, props.col.name, value)",
|
js_handler="(value) => emit(props.row.id, props.col.name, value)",
|
||||||
)
|
)
|
||||||
|
|
||||||
with table.add_slot("body-cell-quantity"), table.cell("quantity"):
|
with table.add_slot("body-cell-quantity"), table.cell("quantity"):
|
||||||
ui.number(min=0, max=1_000).props(':model-value="props.value" dense borderless debounce=400').on(
|
ui.number(min=0, max=1_000).props(':model-value="props.value" dense borderless debounce=400').on(
|
||||||
"update:model-value",
|
"update:model-value",
|
||||||
handler=apply_edit,
|
handler=apply_inline_edit,
|
||||||
js_handler="(value) => emit(props.row.id, props.col.name, value)",
|
js_handler="(value) => emit(props.row.id, props.col.name, value)",
|
||||||
)
|
)
|
||||||
|
|
||||||
with table.add_slot("body-cell-status"), table.cell("status"):
|
with table.add_slot("body-cell-status"), table.cell("status"):
|
||||||
ui.select(STATUS_OPTIONS).props(':model-value="props.value" dense borderless options-dense').on(
|
ui.select(STATUS_OPTIONS).props(':model-value="props.value" dense borderless options-dense').on(
|
||||||
"update:model-value",
|
"update:model-value",
|
||||||
handler=apply_edit,
|
handler=apply_inline_edit,
|
||||||
js_handler="(option) => emit(props.row.id, props.col.name, option.label)",
|
js_handler="(option) => emit(props.row.id, props.col.name, option.label)",
|
||||||
)
|
)
|
||||||
|
|
||||||
with ui.row().classes("w-120 justify-end"):
|
with table.add_slot("body-cell-actions"), table.cell("actions"):
|
||||||
ui.button("Show changes", icon="edit_note", on_click=show_changes)
|
edit_button = ui.button(icon="edit")
|
||||||
|
edit_button.props('flat round dense color=primary aria-label="Edit row"')
|
||||||
return state
|
edit_button.tooltip("Edit this row").on(
|
||||||
|
"click",
|
||||||
|
handler=lambda event: open_dialog_for_row(open_editor, event),
|
||||||
|
js_handler="() => emit(props.row.id)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ in {"__main__", "__mp_main__"}:
|
if __name__ in {"__main__", "__mp_main__"}:
|
||||||
|
|||||||
+210
@@ -0,0 +1,210 @@
|
|||||||
|
#!/usr/bin/env -S uv run --script
|
||||||
|
# /// script
|
||||||
|
# dependencies = [
|
||||||
|
# "nicegui==3.16.0",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
|
||||||
|
"""Demonstrate URL-backed tabs with persistent parameterized-route state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from nicegui import binding
|
||||||
|
from nicegui import events
|
||||||
|
from nicegui import ui
|
||||||
|
from nicegui.elements.tabs import Tab
|
||||||
|
from nicegui.elements.tabs import TabPanel
|
||||||
|
|
||||||
|
type PageBuilder = Callable[..., None]
|
||||||
|
|
||||||
|
DEFAULT_REPORT_PATH = "/reports/a"
|
||||||
|
REPORTS_TAB = "reports"
|
||||||
|
TAB_ROUTES = frozenset({"/", "/projects", "/settings"})
|
||||||
|
|
||||||
|
|
||||||
|
def page_heading(title: str, description: str) -> None:
|
||||||
|
"""Render a shared heading for sub-page content."""
|
||||||
|
with ui.column().classes("w-full gap-1"):
|
||||||
|
ui.label(title).classes("text-3xl font-semibold text-stone-900")
|
||||||
|
ui.label(description).classes("text-base text-stone-600")
|
||||||
|
|
||||||
|
|
||||||
|
def overview_page() -> None:
|
||||||
|
"""Render the overview sub-page."""
|
||||||
|
with ui.column().classes("w-full max-w-6xl mx-auto gap-6 px-4 py-8"):
|
||||||
|
page_heading("Overview", "A quick read on the workspace today.")
|
||||||
|
|
||||||
|
metrics = (
|
||||||
|
("Active projects", "8", "folder_open", "primary"),
|
||||||
|
("Tasks completed", "24", "task_alt", "positive"),
|
||||||
|
("Needs attention", "3", "error_outline", "warning"),
|
||||||
|
)
|
||||||
|
with ui.grid().classes("w-full grid-cols-1 gap-4 md:grid-cols-3"):
|
||||||
|
for label, value, icon, color in metrics:
|
||||||
|
with ui.card().classes("w-full p-5 gap-3"):
|
||||||
|
with ui.row().classes("w-full items-center justify-between"):
|
||||||
|
ui.label(label).classes("text-sm font-medium text-stone-600")
|
||||||
|
ui.icon(icon, color=color).classes("text-2xl")
|
||||||
|
ui.label(value).classes("text-3xl font-semibold text-stone-900")
|
||||||
|
|
||||||
|
|
||||||
|
def projects_page() -> None:
|
||||||
|
"""Render the projects sub-page."""
|
||||||
|
with ui.column().classes("w-full max-w-6xl mx-auto gap-6 px-4 py-8"):
|
||||||
|
page_heading("Projects", "Each route builds its own content inside the shared shell.")
|
||||||
|
|
||||||
|
with ui.list().props("bordered separator").classes("w-full bg-white rounded"):
|
||||||
|
for name, status, color in (
|
||||||
|
("Client portal", "On track", "positive"),
|
||||||
|
("Mobile refresh", "In review", "primary"),
|
||||||
|
("Data migration", "Blocked", "negative"),
|
||||||
|
):
|
||||||
|
with ui.item():
|
||||||
|
with ui.item_section().props("avatar"):
|
||||||
|
ui.icon("folder", color=color)
|
||||||
|
with ui.item_section():
|
||||||
|
ui.item_label(name)
|
||||||
|
ui.item_label(status).props("caption")
|
||||||
|
with ui.item_section().props("side"):
|
||||||
|
ui.badge(status, color=color)
|
||||||
|
|
||||||
|
|
||||||
|
def report_page(state: NavigationState) -> None:
|
||||||
|
"""Render report content bound to the active route parameter."""
|
||||||
|
with ui.column().classes("w-full max-w-6xl mx-auto gap-6 px-4 py-8"):
|
||||||
|
with ui.column().classes("w-full gap-1"):
|
||||||
|
ui.label().bind_text_from(
|
||||||
|
state,
|
||||||
|
"active_report_path",
|
||||||
|
backward=lambda path: f"Report {report_id_from_path(path).upper()}",
|
||||||
|
).classes("text-3xl font-semibold text-stone-900")
|
||||||
|
ui.label("The report ID is injected from the URL path.").classes("text-base text-stone-600")
|
||||||
|
|
||||||
|
with ui.card().classes("w-full max-w-2xl p-5 gap-4"):
|
||||||
|
ui.label("Parameterized route").classes("text-xl font-semibold text-stone-900")
|
||||||
|
ui.label().bind_text_from(
|
||||||
|
state,
|
||||||
|
"active_report_path",
|
||||||
|
backward=lambda path: f"Loaded {path}",
|
||||||
|
).classes("text-stone-600")
|
||||||
|
with ui.row().classes("gap-2"):
|
||||||
|
ui.button("Report A", on_click=lambda: ui.navigate.to("/reports/a")).props("outline")
|
||||||
|
ui.button("Report B", on_click=lambda: ui.navigate.to("/reports/b")).props("outline")
|
||||||
|
|
||||||
|
|
||||||
|
def settings_page() -> None:
|
||||||
|
"""Render the settings sub-page."""
|
||||||
|
with ui.column().classes("w-full max-w-6xl mx-auto gap-6 px-4 py-8"):
|
||||||
|
page_heading("Settings", "Controls here are recreated when this sub-page is opened.")
|
||||||
|
|
||||||
|
with ui.card().classes("w-full max-w-2xl p-5 gap-4"):
|
||||||
|
ui.label("Notifications").classes("text-xl font-semibold text-stone-900")
|
||||||
|
ui.switch("Weekly summary", value=True)
|
||||||
|
ui.switch("Project status changes", value=True)
|
||||||
|
ui.switch("Product announcements", value=False)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_route(path: str) -> str:
|
||||||
|
"""Extract and normalize the path portion of a route."""
|
||||||
|
return urlsplit(path).path.rstrip("/") or "/"
|
||||||
|
|
||||||
|
|
||||||
|
def tab_name_for_route(route: str) -> str:
|
||||||
|
"""Return the tab name associated with a concrete route."""
|
||||||
|
if route.startswith("/reports/"):
|
||||||
|
return REPORTS_TAB
|
||||||
|
return route if route in TAB_ROUTES else "/"
|
||||||
|
|
||||||
|
|
||||||
|
@binding.bindable_dataclass
|
||||||
|
class NavigationState:
|
||||||
|
"""Store client-local navigation state for parameterized tabs."""
|
||||||
|
|
||||||
|
active_report_path: str = DEFAULT_REPORT_PATH
|
||||||
|
|
||||||
|
|
||||||
|
type TabHandler = events.ValueChangeEventArguments[str | Tab | TabPanel | None]
|
||||||
|
|
||||||
|
|
||||||
|
def report_id_from_path(path: str) -> str:
|
||||||
|
"""Extract the report ID from a normalized report route."""
|
||||||
|
return path.rsplit("/", maxsplit=1)[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def create_tabs(state: NavigationState, initial_route: str) -> ui.tabs:
|
||||||
|
"""Create route-aware tabs and retain the last selected report."""
|
||||||
|
|
||||||
|
def navigate(event: TabHandler) -> None:
|
||||||
|
"""Navigate to the route represented by the selected tab."""
|
||||||
|
match event.value:
|
||||||
|
case str(tabname):
|
||||||
|
destination = state.active_report_path if tabname == REPORTS_TAB else tabname
|
||||||
|
ui.navigate.to(destination)
|
||||||
|
case _:
|
||||||
|
return
|
||||||
|
|
||||||
|
with ui.column().classes("mx-auto"), ui.tabs() as tabs:
|
||||||
|
ui.tab("/", label="Overview", icon="space_dashboard")
|
||||||
|
ui.tab("/projects", label="Projects", icon="folder_open")
|
||||||
|
ui.tab(REPORTS_TAB, label="Reports", icon="summarize")
|
||||||
|
ui.tab("/settings", label="Settings", icon="settings")
|
||||||
|
|
||||||
|
tabs.set_value(tab_name_for_route(initial_route))
|
||||||
|
tabs.on_value_change(navigate)
|
||||||
|
|
||||||
|
return tabs
|
||||||
|
|
||||||
|
|
||||||
|
def render_tab_panels(tabs: ui.tabs, state: NavigationState, active_tab: str) -> None:
|
||||||
|
"""Render all tabbed page content inside a tab panels container."""
|
||||||
|
with ui.tab_panels(tabs, value=active_tab, animated=True).classes("w-full"):
|
||||||
|
with ui.tab_panel("/"):
|
||||||
|
overview_page()
|
||||||
|
with ui.tab_panel("/projects"):
|
||||||
|
projects_page()
|
||||||
|
with ui.tab_panel(REPORTS_TAB):
|
||||||
|
report_page(state)
|
||||||
|
with ui.tab_panel("/settings"):
|
||||||
|
settings_page()
|
||||||
|
|
||||||
|
|
||||||
|
def root() -> None:
|
||||||
|
"""Build the persistent application shell and sub-page container."""
|
||||||
|
initial_route = normalize_route(ui.context.client.sub_pages_router.current_path)
|
||||||
|
state = NavigationState()
|
||||||
|
if tab_name_for_route(initial_route) == REPORTS_TAB:
|
||||||
|
state.active_report_path = initial_route
|
||||||
|
|
||||||
|
with ui.header(elevated=True).classes("py-0 items-center"):
|
||||||
|
tabs = create_tabs(state, initial_route)
|
||||||
|
ui.button(icon="settings").classes("text-white").props("round flat").tooltip("Settings")
|
||||||
|
|
||||||
|
render_tab_panels(tabs, state, tab_name_for_route(initial_route))
|
||||||
|
|
||||||
|
def route_overview() -> None:
|
||||||
|
tabs.set_value("/")
|
||||||
|
|
||||||
|
def route_projects() -> None:
|
||||||
|
tabs.set_value("/projects")
|
||||||
|
|
||||||
|
def route_reports(report_id: str) -> None:
|
||||||
|
state.active_report_path = f"/reports/{report_id}"
|
||||||
|
tabs.set_value(REPORTS_TAB)
|
||||||
|
|
||||||
|
def route_settings() -> None:
|
||||||
|
tabs.set_value("/settings")
|
||||||
|
|
||||||
|
routes: dict[str, PageBuilder] = {
|
||||||
|
"/": route_overview,
|
||||||
|
"/projects": route_projects,
|
||||||
|
"/reports/{report_id}": route_reports,
|
||||||
|
"/settings": route_settings,
|
||||||
|
}
|
||||||
|
ui.sub_pages(routes).classes("hidden")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ in {"__main__", "__mp_main__"}:
|
||||||
|
ui.run(root, title="Northstar", port=8888, reload=True)
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
#!/usr/bin/env -S uv run --script
|
||||||
|
# /// script
|
||||||
|
# dependencies = [
|
||||||
|
# "nicegui==3.16.0",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
|
||||||
|
from nicegui import events
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
type TableValue = str | int | float
|
||||||
|
type TableRow = dict[str, TableValue]
|
||||||
|
|
||||||
|
STATUS_COLORS = {
|
||||||
|
"Ready": "positive",
|
||||||
|
"Low": "warning",
|
||||||
|
"Backorder": "negative",
|
||||||
|
}
|
||||||
|
|
||||||
|
COLUMNS = [
|
||||||
|
{
|
||||||
|
"name": "actions",
|
||||||
|
"label": "Actions",
|
||||||
|
"required": True,
|
||||||
|
"align": "center",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"label": "Product",
|
||||||
|
"field": "name",
|
||||||
|
"required": True,
|
||||||
|
"sortable": True,
|
||||||
|
"align": "left",
|
||||||
|
"headerClasses": "bg-grey-2 text-grey-9 font-bold",
|
||||||
|
"classes": "font-medium",
|
||||||
|
"headerStyle": "width: 40%; min-width: 12rem",
|
||||||
|
"style": "width: 40%; min-width: 12rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "category",
|
||||||
|
"label": "Category",
|
||||||
|
"field": "category",
|
||||||
|
"sortable": True,
|
||||||
|
"align": "left",
|
||||||
|
"headerStyle": "width: 9rem",
|
||||||
|
"style": "width: 9rem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stock",
|
||||||
|
"label": "In stock",
|
||||||
|
"field": "stock",
|
||||||
|
"sortable": True,
|
||||||
|
"align": "right",
|
||||||
|
"headerStyle": "width: 7rem",
|
||||||
|
"style": "width: 7rem",
|
||||||
|
":classes": "row => row.stock < 10 ? 'text-negative font-bold' : ''",
|
||||||
|
":format": "value => value == null ? '' : `${value} units`",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "price",
|
||||||
|
"label": "Unit price",
|
||||||
|
"field": "price",
|
||||||
|
"sortable": True,
|
||||||
|
"align": "right",
|
||||||
|
"headerStyle": "width: 8rem",
|
||||||
|
"style": "width: 8rem",
|
||||||
|
":format": "value => value == null ? '' : `$${value.toFixed(2)}`",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"label": "Status",
|
||||||
|
"field": "status",
|
||||||
|
"sortable": True,
|
||||||
|
"align": "center",
|
||||||
|
"headerStyle": "width: 8rem",
|
||||||
|
"style": "width: 8rem",
|
||||||
|
"colorByValue": STATUS_COLORS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"label": "Updated",
|
||||||
|
"field": "updated_at",
|
||||||
|
"sortable": True,
|
||||||
|
"align": "left",
|
||||||
|
"headerStyle": "width: 13rem",
|
||||||
|
"style": "width: 13rem",
|
||||||
|
":sort": "(left, right) => Date.parse(left) - Date.parse(right)",
|
||||||
|
":format": """(() => {
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
});
|
||||||
|
return value => {
|
||||||
|
if (!value) return '';
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
return Number.isNaN(timestamp) ? 'Invalid date' : formatter.format(timestamp);
|
||||||
|
};
|
||||||
|
})()""",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
ROWS: list[TableRow] = [
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"name": "Desk lamp",
|
||||||
|
"category": "Lighting",
|
||||||
|
"stock": 7,
|
||||||
|
"price": 42.5,
|
||||||
|
"status": "Low",
|
||||||
|
"updated_at": "2026-08-31T16:20:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 102,
|
||||||
|
"name": "Task chair",
|
||||||
|
"category": "Seating",
|
||||||
|
"stock": 18,
|
||||||
|
"price": 289.0,
|
||||||
|
"status": "Ready",
|
||||||
|
"updated_at": "2026-09-01T08:45:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 103,
|
||||||
|
"name": "Monitor arm",
|
||||||
|
"category": "Hardware",
|
||||||
|
"stock": 0,
|
||||||
|
"price": 119.95,
|
||||||
|
"status": "Backorder",
|
||||||
|
"updated_at": "2026-08-29T11:05:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 104,
|
||||||
|
"name": "Cable tray",
|
||||||
|
"category": "Hardware",
|
||||||
|
"stock": 34,
|
||||||
|
"price": 31.25,
|
||||||
|
"status": "Ready",
|
||||||
|
"updated_at": "2026-09-01T14:30:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 105,
|
||||||
|
"name": "Side table",
|
||||||
|
"category": "Furniture",
|
||||||
|
"stock": 9,
|
||||||
|
"price": 164.5,
|
||||||
|
"status": "Low",
|
||||||
|
"updated_at": "2026-08-30T19:15:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 106,
|
||||||
|
"name": "Floor light",
|
||||||
|
"category": "Lighting",
|
||||||
|
"stock": 15,
|
||||||
|
"price": 98.0,
|
||||||
|
"status": "Ready",
|
||||||
|
"updated_at": "2026-09-01T10:10:00Z",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def render_table() -> ui.table:
|
||||||
|
table = ui.table(
|
||||||
|
columns=COLUMNS,
|
||||||
|
column_defaults={"headerClasses": "text-grey-8"},
|
||||||
|
rows=ROWS,
|
||||||
|
row_key="id",
|
||||||
|
pagination={
|
||||||
|
"sortBy": "stock",
|
||||||
|
"descending": True,
|
||||||
|
"rowsPerPage": 5,
|
||||||
|
},
|
||||||
|
).classes("w-full max-w-5xl")
|
||||||
|
|
||||||
|
(
|
||||||
|
table.props(
|
||||||
|
# Surface and cell layout.
|
||||||
|
"flat bordered separator=horizontal wrap-cells"
|
||||||
|
)
|
||||||
|
.props(
|
||||||
|
# Local sorting and page-size choices; zero means "all rows".
|
||||||
|
'binary-state-sort :rows-per-page-options="[5, 10, 0]"'
|
||||||
|
)
|
||||||
|
.props(
|
||||||
|
# Compact cells only below Quasar's medium breakpoint.
|
||||||
|
':dense="Quasar.Screen.lt.md"'
|
||||||
|
)
|
||||||
|
.props(
|
||||||
|
# Distinguish an empty dataset from a filter with no matches.
|
||||||
|
'no-data-label="No inventory items" no-results-label="No matching inventory items"'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with table.add_slot("top-left"), ui.row(align_items="center").classes("gap-2"):
|
||||||
|
ui.icon("inventory_2", size="sm", color="primary")
|
||||||
|
ui.label("Inventory").classes("text-xl font-medium")
|
||||||
|
ui.badge(str(len(ROWS)), color="grey-3", text_color="grey-9")
|
||||||
|
|
||||||
|
with table.add_slot("top-right"):
|
||||||
|
ui.input(placeholder="Search inventory").props("dense outlined clearable debounce=250").bind_value_to(
|
||||||
|
table,
|
||||||
|
"filter",
|
||||||
|
)
|
||||||
|
|
||||||
|
with table.add_slot("body-cell-status"), table.cell("status"):
|
||||||
|
ui.badge(outline=True).props(':label="props.value" :color="props.col.colorByValue[props.value] ?? \'grey\'"')
|
||||||
|
|
||||||
|
def open_product(event: events.GenericEventArguments) -> None:
|
||||||
|
product = next((row for row in table.rows if row[table.row_key] == event.args), None)
|
||||||
|
if product is None:
|
||||||
|
ui.notify("Product no longer exists", type="negative")
|
||||||
|
return
|
||||||
|
ui.notify(f"Opening {product['name']}")
|
||||||
|
|
||||||
|
with (
|
||||||
|
table.add_slot("body-cell-actions"),
|
||||||
|
table.cell("actions"),
|
||||||
|
ui.button(icon="open_in_new")
|
||||||
|
.props("round flat size='sm'")
|
||||||
|
.on(
|
||||||
|
"click.stop",
|
||||||
|
handler=open_product,
|
||||||
|
js_handler="() => emit(props.key)",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
ui.tooltip("Open product")
|
||||||
|
|
||||||
|
with table.add_slot("no-data"), ui.row(align_items="center").classes("w-full justify-center gap-2 p-6 text-grey-7"):
|
||||||
|
ui.icon("inventory_2", size="2em").props(":name=\"props.filter ? 'filter_alt_off' : 'inventory_2'\"")
|
||||||
|
ui.element("span").props(':textContent="props.message"')
|
||||||
|
|
||||||
|
optional_columns = [column for column in table.columns if not column.get("required")]
|
||||||
|
|
||||||
|
def set_visible_columns(names: list[str]) -> None:
|
||||||
|
table.props["visible-columns"] = names
|
||||||
|
table.update()
|
||||||
|
|
||||||
|
ui.select(
|
||||||
|
{column["name"]: column["label"] for column in optional_columns},
|
||||||
|
value=[column["name"] for column in optional_columns],
|
||||||
|
label="Visible columns",
|
||||||
|
multiple=True,
|
||||||
|
clearable=True,
|
||||||
|
on_change=lambda event: set_visible_columns(event.value),
|
||||||
|
).props("outlined dense options-dense").classes("w-64")
|
||||||
|
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ in {"__main__", "__mp_main__"}:
|
||||||
|
with ui.column(align_items="center").classes("w-full gap-4 p-4"):
|
||||||
|
render_table()
|
||||||
|
|
||||||
|
ui.run(port=8888, reload=True)
|
||||||
@@ -70,12 +70,104 @@ Avoid imports from services back into API or UI modules.
|
|||||||
|
|
||||||
## Page And Component Ownership
|
## Page And Component Ownership
|
||||||
|
|
||||||
Page modules compose routes from presentation components and service calls. They should not own domain rules, persistence, or long-running synchronous work.
|
Page modules should be a thin route-level composition layer. A page resolves route inputs and page-scoped dependencies, establishes the page shell, composes reusable components, and wires only the interactions that cross component boundaries. It should not contain a component's internal element tree, field bindings, refresh logic, domain rules, persistence, or long-running synchronous work.
|
||||||
|
|
||||||
Extract a presentation pattern to `ui/components/` when it appears on two or more pages or owns a meaningful interaction boundary. Keep one-off route composition in the page module. Reusable components should accept data and event callbacks instead of importing page state or business services implicitly.
|
Extract a presentation pattern to `ui/components/` when it appears on two or more pages or owns a meaningful state or interaction boundary. Reusable components should accept initial data, use-case functions, and event callbacks explicitly instead of importing page state or business services implicitly.
|
||||||
|
|
||||||
For page composition, responsive layout, Quasar props, and CSS customization, load [styling and customization](./styling-and-customization.md).
|
For page composition, responsive layout, Quasar props, and CSS customization, load [styling and customization](./styling-and-customization.md).
|
||||||
|
|
||||||
|
## Reusable Component Contract
|
||||||
|
|
||||||
|
In this architecture, a "component" is an application-level composition pattern, not necessarily a custom Vue component or a subclass of NiceGUI `Element`. Its usual shape is:
|
||||||
|
|
||||||
|
1. A typed dataclass represents the component's public handle and local UI state.
|
||||||
|
2. A render or factory function creates one component instance, builds its element subtree, and binds elements to that instance.
|
||||||
|
3. The function returns the instance so its caller can read or change intentional state, invoke public actions, or coordinate it with another component.
|
||||||
|
4. Internal elements, event handlers, validation feedback, and refreshable regions remain private to the component unless an imperative element handle is intentionally part of its API.
|
||||||
|
|
||||||
|
Use [`binding.bindable_dataclass`](./binding-dataclasses.md) for fields that drive or receive element properties. Plain `@dataclass` is sufficient when the returned object only groups element handles or callbacks and does not need immediate field propagation. Use `bindable_fields` when the dataclass also stores injected dependencies or other fields that should not participate in NiceGUI's binding graph.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import field
|
||||||
|
|
||||||
|
from nicegui import binding, ui
|
||||||
|
|
||||||
|
|
||||||
|
Search = Callable[[str], Awaitable[list[str]]]
|
||||||
|
|
||||||
|
|
||||||
|
@binding.bindable_dataclass(bindable_fields={"query", "busy", "items"})
|
||||||
|
class SearchPanel:
|
||||||
|
search: Search = field(repr=False)
|
||||||
|
query: str = ""
|
||||||
|
busy: bool = False
|
||||||
|
items: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@ui.refreshable_method
|
||||||
|
def render_results(self) -> None:
|
||||||
|
if not self.items:
|
||||||
|
ui.label("No results")
|
||||||
|
for item in self.items:
|
||||||
|
ui.label(item)
|
||||||
|
|
||||||
|
async def submit(self) -> None:
|
||||||
|
if self.busy:
|
||||||
|
return
|
||||||
|
self.busy = True
|
||||||
|
try:
|
||||||
|
self.items = await self.search(self.query)
|
||||||
|
await self.render_results.refresh()
|
||||||
|
finally:
|
||||||
|
self.busy = False
|
||||||
|
|
||||||
|
|
||||||
|
def render_search_panel(search: Search) -> SearchPanel:
|
||||||
|
panel = SearchPanel(search=search)
|
||||||
|
with ui.column().classes("w-full gap-3"):
|
||||||
|
ui.input("Search").bind_value(panel, "query")
|
||||||
|
ui.button("Search", on_click=panel.submit).bind_enabled_from(
|
||||||
|
panel,
|
||||||
|
"busy",
|
||||||
|
backward=lambda busy: not busy,
|
||||||
|
)
|
||||||
|
ui.label().bind_text_from(
|
||||||
|
panel,
|
||||||
|
"items",
|
||||||
|
backward=lambda items: f"{len(items)} results",
|
||||||
|
)
|
||||||
|
panel.render_results()
|
||||||
|
return panel
|
||||||
|
```
|
||||||
|
|
||||||
|
The returned dataclass is the component API. Its bindable fields synchronize stable element properties, while `render_results()` owns a bounded region whose child structure changes with `items`. The injected `search` callable preserves dependency direction: the component can invoke a use case without locating a service globally.
|
||||||
|
|
||||||
|
[`@ui.refreshable_method`](https://nicegui.io/documentation/refreshable) is the instance-oriented refresh surface for this pattern. NiceGUI records refresh targets by method instance, allowing each page-created component object to refresh independently. Detailed target, argument, async, and lifecycle behavior is documented under [refreshable component regions](./interaction-patterns.md#refreshable-component-regions).
|
||||||
|
|
||||||
|
### Thin Page Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from app.services.catalog import search_catalog
|
||||||
|
from app.ui.components.search_panel import render_search_panel
|
||||||
|
|
||||||
|
|
||||||
|
@ui.page("/catalog")
|
||||||
|
def catalog_page() -> None:
|
||||||
|
with ui.column().classes("mx-auto w-full max-w-5xl gap-6"):
|
||||||
|
ui.label("Catalog").classes("text-2xl font-semibold")
|
||||||
|
render_search_panel(search_catalog)
|
||||||
|
```
|
||||||
|
|
||||||
|
The page owns the route and composition. The component owns its controls, binding graph, feedback state, and structural refresh. The service owns search rules and data access. If two component handles must coordinate, keep the page wiring declarative, such as subscribing one component's public event to another component's public refresh action; move orchestration with domain meaning into a service.
|
||||||
|
|
||||||
|
### Component Lifetime
|
||||||
|
|
||||||
|
Create component state during each page build unless sharing is deliberate. A module-global component dataclass can leak UI state across clients, and a module-global `@ui.refreshable` function can refresh every recorded target. Do not retain returned handles beyond their owning client without an explicit cleanup and stale-client policy.
|
||||||
|
|
||||||
|
Bindings to elements are removed with NiceGUI's element lifecycle. Refreshing a region deletes and recreates the elements inside that region, so external code should retain the component handle rather than private child element references. Component-owned subscriptions, timers, and background tasks must follow the client deletion rules in [interaction mechanics](./interaction-patterns.md#page-and-client-lifetime).
|
||||||
|
|
||||||
## Optional Persistence
|
## Optional Persistence
|
||||||
|
|
||||||
Use only when the product requires durable data.
|
Use only when the product requires durable data.
|
||||||
|
|||||||
@@ -51,6 +51,24 @@ class SearchState:
|
|||||||
|
|
||||||
A bound field omitted from `bindable_fields` still works, but NiceGUI must treat it as an active link and poll it for changes.
|
A bound field omitted from `bindable_fields` still works, but NiceGUI must treat it as an active link and poll it for changes.
|
||||||
|
|
||||||
|
## Bindable Dataclasses As Component Handles
|
||||||
|
|
||||||
|
A reusable NiceGUI component can expose a bindable dataclass as its typed public handle. The component's render function creates the dataclass instance, builds the element subtree, establishes bindings against that instance, and returns it to the page. This keeps the page at the composition level while the component owns its field wiring and internal elements. See the complete [reusable component contract](./architecture.md#reusable-component-contract).
|
||||||
|
|
||||||
|
Choose binding direction according to what the public field represents:
|
||||||
|
|
||||||
|
| Component field | Typical element relationship | Binding surface |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| editable value such as `query` | control and component share the value | `element.bind_value(handle, "query")` |
|
||||||
|
| rendered status such as `busy` or `count` | component state drives text, visibility, or enabled state | `bind_*_from` with a pure transform when needed |
|
||||||
|
| browser proposal requiring validation | event handler validates before assignment | explicit callback, then assign the accepted bindable field |
|
||||||
|
| collection controlling child count or layout | component state is read while rebuilding a bounded subtree | `@ui.refreshable_method`, not a binding to the child list itself |
|
||||||
|
| injected service or callback | component implementation dependency | ordinary dataclass field omitted from `bindable_fields` |
|
||||||
|
|
||||||
|
The returned handle should expose intentional component state and actions, not every child element. Bindable fields are effective for stable element properties because assignments propagate immediately. They do not create or delete elements when collection structure changes; a component-owned refreshable method should rebuild that region after the authoritative field is replaced. The target and instance behavior is defined under [refreshable component regions](./interaction-patterns.md#refreshable-component-regions).
|
||||||
|
|
||||||
|
Create one handle during each page build unless shared state is deliberate. A module-global bindable component model propagates across clients that bind to it, just as a module-level refreshable function can own targets from multiple clients.
|
||||||
|
|
||||||
## Binding Graph And Propagation
|
## Binding Graph And Propagation
|
||||||
|
|
||||||
NiceGUI stores bindings as directed edges from one object attribute to another. A two-way binding is two one-way edges with transforms in opposite directions.
|
NiceGUI stores bindings as directed edges from one object attribute to another. A two-way binding is two one-way edges with transforms in opposite directions.
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# NiceGUI And Quasar Color Theming
|
||||||
|
|
||||||
|
This reference describes how NiceGUI's Python color APIs map onto Quasar's browser-side color system. It distinguishes theme configuration from individual element colors, fixed palette colors from runtime brand roles, and palette values from dark-mode state.
|
||||||
|
|
||||||
|
The primary public references are [NiceGUI styling and appearance](https://nicegui.io/documentation/section_styling_appearance), [NiceGUI color theming](https://nicegui.io/documentation/colors), and the [Quasar color palette](https://quasar.dev/style/color-palette).
|
||||||
|
|
||||||
|
## Boundary At A Glance
|
||||||
|
|
||||||
|
NiceGUI does not define an independent component theme engine. It configures and consumes the Quasar color system while adding Python-facing scope, value classification, and CSS cascade behavior.
|
||||||
|
|
||||||
|
| Surface | NiceGUI owns | Quasar or the browser owns |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `app.colors(...)` | application-wide Python configuration and custom-name registration | initial Quasar brand configuration and the resulting `--q-*` values on each page |
|
||||||
|
| `ui.colors(...)` | a page-level element and precedence over `app.colors()` | runtime `--q-*` properties on `document.body` plus custom `text-*` and `bg-*` classes |
|
||||||
|
| component `color=` and `text_color=` arguments | classification of supported values as Quasar, Tailwind, or CSS colors on color-aware wrappers | rendering through a Quasar prop, a utility class, or an inline CSS declaration |
|
||||||
|
| `.props("color=...")` | transport of the prop to the frontend component | interpretation of the value by that Quasar component |
|
||||||
|
| `.classes("text-primary bg-positive")` | attachment of class names and NiceGUI's CSS layer arrangement | Quasar's semantic utility classes and their `--q-*` variable references |
|
||||||
|
| `ui.dark_mode(...)` | Python control and binding with `True`, `False`, or automatic `None` state | Quasar dark-mode state, `body--light` or `body--dark`, and dark-aware components |
|
||||||
|
|
||||||
|
The central handoff is a CSS custom property. NiceGUI supplies a value such as `#176b5b`; Quasar components and helpers consume `var(--q-primary)`.
|
||||||
|
|
||||||
|
## Quasar Color Namespaces
|
||||||
|
|
||||||
|
Quasar exposes two materially different kinds of color name. Only one kind is changed by NiceGUI's theme APIs.
|
||||||
|
|
||||||
|
### Runtime Brand Roles
|
||||||
|
|
||||||
|
Quasar's semantic brand roles are backed by root or body-level CSS custom properties. Components and semantic utility classes follow these values at runtime. NiceGUI exposes the eight Quasar brand roles and the separate dark-page surface through `app.colors()` and `ui.colors()`.
|
||||||
|
|
||||||
|
| NiceGUI argument | CSS custom property | NiceGUI default | Intended meaning |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `primary` | `--q-primary` | `#5898d4` | main action and brand emphasis |
|
||||||
|
| `secondary` | `--q-secondary` | `#26a69a` | secondary brand emphasis |
|
||||||
|
| `accent` | `--q-accent` | `#9c27b0` | accent emphasis |
|
||||||
|
| `dark` | `--q-dark` | `#1d1d1d` | dark component surface |
|
||||||
|
| `dark_page` | `--q-dark-page` | `#121212` | dark page background |
|
||||||
|
| `positive` | `--q-positive` | `#21ba45` | success state |
|
||||||
|
| `negative` | `--q-negative` | `#c10015` | error or destructive state |
|
||||||
|
| `info` | `--q-info` | `#31ccec` | informational state |
|
||||||
|
| `warning` | `--q-warning` | `#f2c037` | warning state |
|
||||||
|
|
||||||
|
For example, `color="primary"`, `.props("color=primary")`, `text-primary`, and `bg-primary` all reach Quasar's semantic primary role. Changing that role changes every consumer of `--q-primary`; it does not rewrite fixed palette colors.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from nicegui import app, ui
|
||||||
|
|
||||||
|
app.colors(
|
||||||
|
primary="#176b5b",
|
||||||
|
secondary="#52645f",
|
||||||
|
accent="#c05a32",
|
||||||
|
positive="#2e7d32",
|
||||||
|
negative="#b3261e",
|
||||||
|
info="#276b8e",
|
||||||
|
warning="#a86600",
|
||||||
|
dark="#202523",
|
||||||
|
dark_page="#151917",
|
||||||
|
)
|
||||||
|
|
||||||
|
ui.button("Save")
|
||||||
|
ui.label("Saved").classes("text-positive")
|
||||||
|
```
|
||||||
|
|
||||||
|
The current NiceGUI client implementation writes page-level values to `document.body` in [`colors.js`](https://github.com/zauberzeug/nicegui/blob/main/nicegui/elements/colors.js). Quasar's semantic helpers reference those properties, as described under [dynamic brand colors](https://quasar.dev/style/color-palette#dynamic-change-of-brand-colors-dynamic-theme-colors).
|
||||||
|
|
||||||
|
### Fixed Palette Colors
|
||||||
|
|
||||||
|
Names such as `red-5`, `teal-10`, and `blue-grey-2` belong to Quasar's compiled [color list](https://quasar.dev/style/color-palette#color-list). Their `text-*` and `bg-*` classes contain fixed color values rather than references to the semantic brand variables.
|
||||||
|
|
||||||
|
Consequently:
|
||||||
|
|
||||||
|
- `ui.colors(primary="#0057b8")` changes `primary`, `text-primary`, and `bg-primary` consumers.
|
||||||
|
- It does not change `blue`, `blue-6`, `text-blue-6`, or `bg-blue-6`.
|
||||||
|
- A fixed palette color can be assigned to a component, for example `ui.button("Open", color="teal-7")`, without adding it to the application theme.
|
||||||
|
|
||||||
|
The fixed palette is a Quasar facility bundled into NiceGUI. It is not generated by `app.colors()` or `ui.colors()`.
|
||||||
|
|
||||||
|
### Custom Semantic Names
|
||||||
|
|
||||||
|
Extra keyword arguments create application-specific names:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from nicegui import app, ui
|
||||||
|
|
||||||
|
app.colors(brand="#176b5b", review_required="#a86600")
|
||||||
|
|
||||||
|
ui.button("Continue", color="brand")
|
||||||
|
ui.label("Review required").classes("text-review-required")
|
||||||
|
```
|
||||||
|
|
||||||
|
NiceGUI normalizes underscores in Python keyword names to hyphens in browser color names. For each custom name, the client-side [`applyColors`](https://github.com/zauberzeug/nicegui/blob/main/nicegui/static/nicegui.js) helper creates:
|
||||||
|
|
||||||
|
- a `--q-<name>` property on `document.body`
|
||||||
|
- a `.text-<name>` class that reads that property
|
||||||
|
- a `.bg-<name>` class that reads that property
|
||||||
|
|
||||||
|
This automates the custom-class pattern shown in Quasar's [adding your own colors](https://quasar.dev/style/color-palette#adding-your-own-colors) reference. NiceGUI also registers the name in its Python-side Quasar color set so color-aware wrappers pass the value as a Quasar color prop. The name must therefore be declared with `app.colors()` or `ui.colors()` before a NiceGUI component first uses it; this ordering requirement is part of the [NiceGUI custom colors contract](https://nicegui.io/documentation/colors#custom_colors).
|
||||||
|
|
||||||
|
## Scope And Precedence
|
||||||
|
|
||||||
|
The effective palette has three levels:
|
||||||
|
|
||||||
|
| Level | Scope | Effect |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| bundled Quasar values | every page | fallback values supplied by Quasar's CSS |
|
||||||
|
| `app.colors(...)` | all NiceGUI pages | populates NiceGUI's Quasar brand configuration before each client app starts |
|
||||||
|
| `ui.colors(...)` | current page | writes the core and custom properties on that page's `document.body` and takes precedence over app-wide values |
|
||||||
|
|
||||||
|
`app.colors()` is configuration, not a rendered UI element. NiceGUI stores its values in the application's Quasar configuration; see the current [`App.colors` implementation](https://github.com/zauberzeug/nicegui/blob/main/nicegui/app/app.py).
|
||||||
|
|
||||||
|
`ui.colors()` is rendered into a specific page. Its DOM placement in a row, card, or other container does not scope the palette to that subtree because its client component writes to `document.body`. A page with two calls therefore has one effective page palette, with the last mounted call determining the core values. Subtree-specific theming requires application CSS variables or directly scoped `--q-*` overrides, not nested `ui.colors()` elements.
|
||||||
|
|
||||||
|
The `ui.colors()` initializer supplies all nine core values. A call such as `ui.colors(primary="#555")` is therefore a complete core-palette assignment: unspecified roles resolve to NiceGUI's defaults rather than acting as a one-property patch over `app.colors()`. Pages that must retain customized app-wide secondary, status, or dark values should pass those values explicitly in the page override.
|
||||||
|
|
||||||
|
`app.colors()` was added in NiceGUI 3.6.0, while custom colors were added to `ui.colors()` in 2.2.0. Applications pinned to earlier NiceGUI releases need version-matched behavior from the [NiceGUI colors reference](https://nicegui.io/documentation/colors).
|
||||||
|
|
||||||
|
## Element Color Values
|
||||||
|
|
||||||
|
On elements implemented with NiceGUI's color mixins, a `color`, `text_color`, or corresponding setter value is classified in this order by [`color_elements.py`](https://github.com/zauberzeug/nicegui/blob/main/nicegui/elements/mixins/color_elements.py):
|
||||||
|
|
||||||
|
| Input kind | Example | NiceGUI output | Theme response |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Quasar semantic, fixed, or registered custom name | `primary`, `red-5`, `brand` | Quasar component color prop | semantic and custom names follow `--q-*`; fixed names do not |
|
||||||
|
| recognized Tailwind color | `red-500` | `bg-red-500` or `text-red-500` class | independent of the Quasar palette |
|
||||||
|
| other CSS color value | `#ff0000`, `rgb(255 0 0)`, `rebeccapurple` | inline `background-color` or `color` | independent of the Quasar palette |
|
||||||
|
| `None` | `None` | removes the managed color | falls back to component and cascade defaults |
|
||||||
|
|
||||||
|
This classification is a NiceGUI convenience, not a general Quasar rule. Passing `.props("color=#ff0000")` bypasses NiceGUI's color mixin and asks the Quasar component to interpret `#ff0000` as its `color` prop. Likewise, components that expose a raw Quasar color prop without using the mixin may accept only the values documented by that component. The specific NiceGUI constructor documentation remains authoritative for each element.
|
||||||
|
|
||||||
|
Quasar and Tailwind color classes share the same HTML class list but not the same namespace conventions. `text-red-5` is a Quasar fixed-palette helper; `text-red-500` is a Tailwind-compatible utility. Semantic names such as `text-primary` are Quasar helpers.
|
||||||
|
|
||||||
|
## Palette Values And Dark Mode Are Separate
|
||||||
|
|
||||||
|
The `dark` and `dark_page` arguments define colors; they do not enable dark mode. Mode state is controlled by [`ui.dark_mode()`](https://nicegui.io/documentation/dark_mode), the `dark` argument of `ui.run()`, or a page decorator. `ui.dark_mode()` takes precedence for its page and maps `None` to Quasar's automatic system-preference mode.
|
||||||
|
|
||||||
|
When dark mode is active, Quasar:
|
||||||
|
|
||||||
|
- applies `body--dark` instead of `body--light`
|
||||||
|
- uses the dark page background and dark-aware component behavior
|
||||||
|
- automatically enables the dark state of Quasar components that support a `dark` prop
|
||||||
|
|
||||||
|
These behaviors are defined by [Quasar dark mode](https://quasar.dev/style/dark-mode). Application-owned surfaces can key off the same body class and reuse Quasar variables:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--app-surface: #ffffff;
|
||||||
|
--app-text: #202623;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body--dark {
|
||||||
|
--app-surface: var(--q-dark);
|
||||||
|
--app-text: #eef3f0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Changing `--q-dark` while the page remains in light mode changes consumers of the `dark` role but does not add `body--dark`. Enabling dark mode without designing application-specific text, border, and surface tokens does not automatically recolor arbitrary custom CSS.
|
||||||
|
|
||||||
|
## CSS Classes And Cascade
|
||||||
|
|
||||||
|
NiceGUI ships Quasar's color helpers, so `.classes("text-primary")` and `.classes("bg-warning")` can be attached directly to NiceGUI elements. Quasar defines these helpers with `!important`.
|
||||||
|
|
||||||
|
NiceGUI changes the cascade arrangement around the bundled Quasar CSS. Its [CSS layer reference](https://nicegui.io/documentation/section_styling_appearance#css_layers) explains how Quasar rules are split into layers so important Tailwind utilities or application rules in suitable layers can override them. This is a NiceGUI integration detail; the class names and color semantics still come from Quasar.
|
||||||
|
|
||||||
|
Direct CSS can consume the same semantic properties without a Quasar class:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.app-focus-ring {
|
||||||
|
outline: 2px solid var(--q-primary);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Such CSS follows runtime palette changes because it reads the same property. A literal declaration such as `outline-color: #176b5b` does not.
|
||||||
|
|
||||||
|
## Source Index
|
||||||
|
|
||||||
|
!!! info "Primary sources"
|
||||||
|
- [NiceGUI styling and appearance](https://nicegui.io/documentation/section_styling_appearance)
|
||||||
|
- [NiceGUI color theming](https://nicegui.io/documentation/colors)
|
||||||
|
- [NiceGUI dark mode](https://nicegui.io/documentation/dark_mode)
|
||||||
|
- [Quasar color palette](https://quasar.dev/style/color-palette)
|
||||||
|
- [Quasar dark mode](https://quasar.dev/style/dark-mode)
|
||||||
|
- [Quasar theme builder](https://quasar.dev/style/theme-builder)
|
||||||
|
|
||||||
|
!!! info "Implementation references"
|
||||||
|
- [NiceGUI app-wide color configuration](https://github.com/zauberzeug/nicegui/blob/main/nicegui/app/app.py)
|
||||||
|
- [NiceGUI page color element](https://github.com/zauberzeug/nicegui/blob/main/nicegui/elements/colors.py)
|
||||||
|
- [NiceGUI page color client component](https://github.com/zauberzeug/nicegui/blob/main/nicegui/elements/colors.js)
|
||||||
|
- [NiceGUI custom color CSS generation](https://github.com/zauberzeug/nicegui/blob/main/nicegui/static/nicegui.js)
|
||||||
|
- [NiceGUI element color classification](https://github.com/zauberzeug/nicegui/blob/main/nicegui/elements/mixins/color_elements.py)
|
||||||
|
- [NiceGUI color behavior tests](https://github.com/zauberzeug/nicegui/blob/main/tests/test_colors.py)
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
NiceGUI components are Python objects that describe browser UI elements. A component constructor creates an element, constructor arguments configure its common behavior, and methods on the returned object expose styling, events, bindings, slots, and client-side capabilities.
|
NiceGUI components are Python objects that describe browser UI elements. A component constructor creates an element, constructor arguments configure its common behavior, and methods on the returned object expose styling, events, bindings, slots, and client-side capabilities.
|
||||||
|
|
||||||
This reference begins with those everyday component APIs, then describes the NiceGUI, Quasar, Vue, and browser layers beneath them. Themes, responsive composition, and broader visual design are covered separately in [visual styling and CSS](./styling-and-customization.md).
|
This reference begins with those everyday component APIs, then describes the NiceGUI, Quasar, Vue, and browser layers beneath them. Page structure, typography, responsive composition, and scaling are covered separately in [styling and customization](./styling-and-customization.md).
|
||||||
|
|
||||||
## Basic Components
|
## Basic Components
|
||||||
|
|
||||||
@@ -235,6 +235,14 @@ viewport.on("scroll.passive", handle_scroll, throttle=0.1)
|
|||||||
|
|
||||||
NiceGUI separates listener options such as `capture`, `once`, and `passive`, event modifiers such as `stop`, `prevent`, and `self`, and key filters such as `enter`. The tagged [`EventListener.to_dict()` implementation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/event_listener.py) performs that classification before the frontend applies Vue's `withModifiers()` and `withKeys()` helpers. `throttle`, `leading_events`, and `trailing_events` regulate messages sent to Python; they do not throttle a client-only `js_handler` that never calls `emit`.
|
NiceGUI separates listener options such as `capture`, `once`, and `passive`, event modifiers such as `stop`, `prevent`, and `self`, and key filters such as `enter`. The tagged [`EventListener.to_dict()` implementation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/event_listener.py) performs that classification before the frontend applies Vue's `withModifiers()` and `withKeys()` helpers. `throttle`, `leading_events`, and `trailing_events` regulate messages sent to Python; they do not throttle a client-only `js_handler` that never calls `emit`.
|
||||||
|
|
||||||
|
### Custom Vue Components
|
||||||
|
|
||||||
|
When NiceGUI's wrappers and the documented Quasar extension points cannot express a component, subclass `ui.element` and pair it with a Vue component. Start from NiceGUI's [custom Vue component example](https://github.com/zauberzeug/nicegui/tree/main/examples/custom_vue_component), keeping Python responsible for the server-facing state and event contract.
|
||||||
|
|
||||||
|
For a component with npm dependencies, bundle the frontend module and pass its ESM module name and bundled file path through the `esm` parameter on the Python element subclass. NiceGUI adds that module to the page import map. The [signature pad example](https://github.com/zauberzeug/nicegui/tree/main/examples/signature_pad) and [node module integration example](https://github.com/zauberzeug/nicegui/tree/main/examples/node_module_integration) demonstrate the package and bundling boundary.
|
||||||
|
|
||||||
|
Treat the generated JavaScript and CSS as package data in executable builds. PyInstaller or Nuitka configuration must include those assets, and the packaged artifact must be checked for successful module loading rather than only for process startup. Do not introduce a custom Vue component merely to avoid a supported NiceGUI constructor, Quasar prop, event, slot, or public method.
|
||||||
|
|
||||||
## Framework Boundary Model
|
## Framework Boundary Model
|
||||||
|
|
||||||
A NiceGUI component is not a Python-rendered HTML fragment. Customization passes through several owners:
|
A NiceGUI component is not a Python-rendered HTML fragment. Customization passes through several owners:
|
||||||
@@ -302,6 +310,14 @@ NiceGUI creates a default slot for every element. Entering an element as a conte
|
|||||||
|
|
||||||
These mechanics are defined by the tagged [`Element.add_slot()` implementation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/element.py), the [`Slot` context manager](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/slot.py), and NiceGUI's [context-managed scoped-slot examples](https://github.com/zauberzeug/nicegui/blob/v3.16.0/website/documentation/content/table_documentation.py).
|
These mechanics are defined by the tagged [`Element.add_slot()` implementation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/element.py), the [`Slot` context manager](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/slot.py), and NiceGUI's [context-managed scoped-slot examples](https://github.com/zauberzeug/nicegui/blob/v3.16.0/website/documentation/content/table_documentation.py).
|
||||||
|
|
||||||
|
### Prefer Python-Owned Composition
|
||||||
|
|
||||||
|
Use NiceGUI context managers and `ui.*` elements for slot structure whenever they can represent the required element tree. Keep values, mappings, validation, permissions, event handling, and authoritative state transitions in Python. This preserves element identity, typed wrapper APIs, lifecycle cleanup, test visibility, and the normal NiceGUI update path.
|
||||||
|
|
||||||
|
Use the narrowest browser-side expression for state that exists only while Quasar renders a scoped slot. A dynamic prop such as `:label="props.value"` may project that value into a NiceGUI element without moving the surrounding structure or business rules into JavaScript. When Python needs a browser-owned value, emit the smallest serializable proposal to a Python handler and validate it there.
|
||||||
|
|
||||||
|
Escalate to `add_slot(name, template)` only when the slot contract requires client-side structure that context-managed NiceGUI elements cannot preserve, such as a browser-side `v-for`, a variable number of sibling roots, or Vue's object form of `v-bind` for a Quasar interaction bundle. Keep raw templates small, use documented scoped props, and do not duplicate authoritative application logic in JavaScript.
|
||||||
|
|
||||||
### Context-Managed NiceGUI Elements
|
### Context-Managed NiceGUI Elements
|
||||||
|
|
||||||
Ordinary NiceGUI elements can populate slot content:
|
Ordinary NiceGUI elements can populate slot content:
|
||||||
@@ -313,7 +329,7 @@ with name_input.add_slot("prepend"):
|
|||||||
ui.icon("person")
|
ui.icon("person")
|
||||||
```
|
```
|
||||||
|
|
||||||
Nested context managers express the component hierarchy while preserving NiceGUI element identity, event registration, updates, deletion, and test visibility. The `add_slot(name, template)` form accepts a raw Vue template for client-side structures such as a `v-for` that creates a variable number of sibling elements.
|
Nested context managers express the component hierarchy while preserving NiceGUI element identity, event registration, updates, deletion, and test visibility.
|
||||||
|
|
||||||
### Scoped Props On The Client
|
### Scoped Props On The Client
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# NiceGUI Configuration And Deployment
|
||||||
|
|
||||||
|
Use this reference when a NiceGUI task concerns `ui.run(...)` settings, runtime URLs, native windows, environment variables, server hosting, executable packaging, or NiceGUI On Air. For startup ownership, app factories, `ui.run_with(...)`, lifespan, reload, and workers, load [FastAPI and Uvicorn startup](./fastapi-uvicorn-startup.md).
|
||||||
|
|
||||||
|
The public surfaces below follow NiceGUI's current [configuration and deployment documentation](https://nicegui.io/documentation/section_configuration_deployment). Inspect the target project's pinned NiceGUI version before relying on a recently added option or native-mode behavior.
|
||||||
|
|
||||||
|
## Configure The Owning Runtime
|
||||||
|
|
||||||
|
Choose the process owner before setting runtime options:
|
||||||
|
|
||||||
|
| Deployment shape | Owning surface | Where configuration belongs |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| NiceGUI is the application and starts its server | `ui.run(...)` | NiceGUI arguments plus additional Uvicorn keyword arguments |
|
||||||
|
| A parent FastAPI app owns startup | `ui.run_with(parent_app, ...)` and the external ASGI server | NiceGUI composition options in `ui.run_with`; socket, TLS, reload, and worker options in Uvicorn or the process manager |
|
||||||
|
| Desktop application | `ui.run(native=True, ...)` | NiceGUI runtime options and `app.native` configuration |
|
||||||
|
| Packaged browser or desktop executable | `ui.run(reload=False, ...)` | import-safe page registration, packaging flags, and multiprocessing setup |
|
||||||
|
|
||||||
|
Do not split ownership by calling `ui.run()` and a separate server launcher for the same app. The exact composition patterns and worker constraints are in [FastAPI and Uvicorn startup](./fastapi-uvicorn-startup.md).
|
||||||
|
|
||||||
|
## Select `ui.run` Options Deliberately
|
||||||
|
|
||||||
|
[`ui.run(...)`](https://nicegui.io/documentation/run) accepts several groups of settings:
|
||||||
|
|
||||||
|
| Concern | Representative options | Decision rule |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| route and metadata | `root`, `title`, `viewport`, `favicon`, `language`, `dark`, `markdown` | Use a root callable or decorated pages; override metadata per page when it is route-specific. |
|
||||||
|
| network and launch | `host`, `port`, `show`, `on_air` | Bind and expose only the interfaces required by the deployment; treat On Air as a separate remote-access choice. |
|
||||||
|
| client recovery | `reconnect_timeout`, `message_history_length` | Tune together from observed disconnect duration and replay volume; replay is not durable job delivery. |
|
||||||
|
| binding work | `binding_refresh_interval` | Reduce active links before lowering the interval; use `None` only when no polling-based links need updates. |
|
||||||
|
| static delivery | `cache_control_directives`, `gzip_middleware_factory` | Preserve deliberate cache lifetimes and compression behavior; disabling gzip or changing immutable caching is an operational decision. |
|
||||||
|
| development | `reload`, `uvicorn_reload_dirs`, `uvicorn_reload_includes`, `uvicorn_reload_excludes`, `uvicorn_logging_level` | Keep reload local to development and restart fully when changing options that the reloader process owns. |
|
||||||
|
| frontend runtime | `tailwind`, `unocss`, `prod_js` | Verify class compatibility before switching CSS engines; use production Vue and Quasar assets in deployed apps. |
|
||||||
|
| API visibility | `fastapi_docs`, `endpoint_documentation` | Expose only the OpenAPI surfaces the application intends to publish. |
|
||||||
|
| browser storage | `storage_secret`, `session_middleware_kwargs` | A secret is required for `ui.storage.user` and `ui.storage.browser`; load it from a secret source and configure cookie policy for the deployment. |
|
||||||
|
| native window | `native`, `window_size`, `fullscreen`, `frameless` | Use only for a desktop app with a supported browser engine. |
|
||||||
|
|
||||||
|
Additional keyword arguments are forwarded to `uvicorn.run`. Most `ui.run` option changes require stopping and fully restarting the process; do not assume development auto-reload applies them.
|
||||||
|
|
||||||
|
## Read Runtime URLs After Binding
|
||||||
|
|
||||||
|
[`app.urls`](https://nicegui.io/documentation/section_configuration_deployment#urls) contains the URLs on which the running app is available. The server has not bound its sockets during `app.on_startup`, so the collection is not available there. Read it in a page function or subscribe to `app.urls.on_change` when another application component needs the final addresses.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from nicegui import app, ui
|
||||||
|
|
||||||
|
|
||||||
|
@ui.page('/')
|
||||||
|
def home() -> None:
|
||||||
|
for url in app.urls:
|
||||||
|
ui.link(url, target=url)
|
||||||
|
|
||||||
|
|
||||||
|
ui.run()
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not derive a public URL solely from the listening host and port when a reverse proxy, container port mapping, or tunnel owns the external address.
|
||||||
|
|
||||||
|
## Configure Environment-Controlled Facilities
|
||||||
|
|
||||||
|
NiceGUI recognizes these framework environment variables:
|
||||||
|
|
||||||
|
| Variable | Default | Effect |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `MATPLOTLIB` | enabled | Set to `false` to skip the potentially costly Matplotlib import; `ui.pyplot` and `ui.line_plot` then remain unavailable. |
|
||||||
|
| `NICEGUI_STORAGE_PATH` | `.nicegui` in the working directory | Changes the local storage-file directory. |
|
||||||
|
| `NICEGUI_REDIS_URL` | no Redis backend | Selects Redis for shared persistent storage. |
|
||||||
|
| `NICEGUI_REDIS_KEY_PREFIX` | `nicegui:` | Namespaces NiceGUI keys in Redis. |
|
||||||
|
| `MARKDOWN_CONTENT_CACHE_SIZE` | `1000` | Bounds cached Markdown snippets. |
|
||||||
|
| `RST_CONTENT_CACHE_SIZE` | `1000` | Bounds cached reStructuredText snippets. |
|
||||||
|
|
||||||
|
Treat these as process-start configuration. For application-owned host, port, credentials, feature flags, and service settings, use one validated settings model rather than scattering direct environment reads. When multiple processes or executables share local storage, do not let them independently rewrite the same files; give each instance a distinct `NICEGUI_STORAGE_PATH` or configure Redis where state must be shared.
|
||||||
|
|
||||||
|
## Deploy A Browser-Hosted App
|
||||||
|
|
||||||
|
Run the production entry point under a service manager or container restart policy. NiceGUI's [multi-architecture Docker image](https://hub.docker.com/r/zauberzeug/nicegui) runs an application mounted at `/app`; its default internal port is `8080`, so publish that port explicitly. The image supports non-root execution through `PUID` and `PGID` and passes process signals through to the app.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --detach --restart always \
|
||||||
|
--publish 80:8080 \
|
||||||
|
--env PUID="$(id -u)" \
|
||||||
|
--env PGID="$(id -g)" \
|
||||||
|
--volume "$PWD:/app" \
|
||||||
|
zauberzeug/nicegui:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
For HTTPS, either pass Uvicorn's `ssl_certfile` and `ssl_keyfile` options to `ui.run(...)` or terminate TLS at a reverse proxy such as NGINX or Traefik. A reverse-proxy deployment must preserve NiceGUI's HTTP and Socket.IO traffic, forwarding scheme and host information, route prefixes, timeouts, and upload limits consistently. Verify the rendered page, static assets, websocket connection, reconnect behavior, and upload path through the public URL rather than only against the container port.
|
||||||
|
|
||||||
|
Use one worker by default. NiceGUI clients, element trees, tasks, and ordinary Python state are process-local; a multi-worker deployment needs compatible session affinity and externalized shared state. See [FastAPI and Uvicorn startup](./fastapi-uvicorn-startup.md#development-reload) before adding workers or combining them with reload.
|
||||||
|
|
||||||
|
## Build A Native Desktop App
|
||||||
|
|
||||||
|
[`ui.run(native=True)`](https://nicegui.io/documentation/section_configuration_deployment#native-mode) launches a pywebview window. `window_size`, `fullscreen`, and `frameless` cover common presentation settings. Configure lower-level pywebview behavior before startup through:
|
||||||
|
|
||||||
|
- `app.native.window_args` for `webview.create_window` arguments
|
||||||
|
- `app.native.start_args` for `webview.start` arguments
|
||||||
|
- `app.native.settings` for pywebview settings
|
||||||
|
- `app.native.main_window` for asynchronous access to the running window
|
||||||
|
|
||||||
|
Values in `window_args` and `start_args` take precedence over overlapping `ui.run` arguments. The browser engine must support ES modules and import maps; use Chrome 89 or newer, a current WebKitGTK or Qt backend on Linux, and the EdgeChromium prerequisites used by pywebview on Windows. A local Windows `favicon` used as the native icon must be an `.ico` file.
|
||||||
|
|
||||||
|
Native mode chooses an available port automatically when `port` is omitted. Browser mode defaults to `8080`; use `native.find_open_port()` explicitly when multiple browser-mode executable instances must coexist.
|
||||||
|
|
||||||
|
### Native Events And Process Placement
|
||||||
|
|
||||||
|
Register sync or async handlers with `app.native.on(...)`. Supported lifecycle and window events are `shown`, `loaded`, `minimized`, `maximized`, `restored`, `resized`, `moved`, `closed`, and `drop`. Resized and moved events expose dimensions or coordinates in `event.args`; drop events expose filesystem paths under `event.args['files']`.
|
||||||
|
|
||||||
|
The native UI runs in a separate process. Define `app.native.window_args`, `start_args`, `settings`, and event registrations outside the `if __name__ == '__main__':` guard so the child process sees them.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from nicegui import app, ui
|
||||||
|
|
||||||
|
app.native.window_args['resizable'] = False
|
||||||
|
app.native.on('drop', lambda event: print(event.args['files']))
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
ui.run(native=True, reload=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
Native storage follows the same scopes as browser mode. Multiple executable instances started from one working directory can collide on the default `.nicegui` files; isolate `NICEGUI_STORAGE_PATH` per instance or use Redis for intentionally shared state.
|
||||||
|
|
||||||
|
## Package An Executable
|
||||||
|
|
||||||
|
Both `nicegui-pack`/PyInstaller and Nuitka require an import-safe application:
|
||||||
|
|
||||||
|
1. Disable auto-reload with `ui.run(reload=False, ...)`.
|
||||||
|
2. Supply a `root` page callable to `ui.run` or register at least one `@ui.page`.
|
||||||
|
3. Decide whether the executable opens a browser or uses `native=True`.
|
||||||
|
4. Use an available port when simultaneous instances are valid.
|
||||||
|
5. Exercise the built artifact on every target operating system; a successful build on the development host does not establish runtime compatibility.
|
||||||
|
|
||||||
|
With [`nicegui-pack`](https://nicegui.io/documentation/section_configuration_deployment#package-for-installation), `--onefile` is convenient but starts more slowly because PyInstaller extracts it on each run. A directory build starts faster and can be archived for distribution. Use `--windowed` only with `native=True`; a browser-mode application without a console has no normal Ctrl-C exit surface.
|
||||||
|
|
||||||
|
Nuitka must include both NiceGUI modules and package data because NiceGUI uses lazy imports and ships frontend assets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m nuitka \
|
||||||
|
--onefile \
|
||||||
|
--include-package=nicegui \
|
||||||
|
--include-package-data=nicegui \
|
||||||
|
main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Add equivalent package and package-data flags for optional libraries that ship templates or frontend assets. Prefer `--standalone` when startup speed matters more than producing one file.
|
||||||
|
|
||||||
|
### Multiprocessing In Packaged Native Apps
|
||||||
|
|
||||||
|
Packaged native apps must call [`multiprocessing.freeze_support()`](https://docs.python.org/3/library/multiprocessing.html#multiprocessing.freeze_support) as the first statement inside the main guard to prevent recursive process creation. Keep native settings outside the guard so the spawned native process applies them.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from multiprocessing import freeze_support
|
||||||
|
|
||||||
|
from nicegui import app, ui
|
||||||
|
|
||||||
|
app.native.window_args['transparent'] = True
|
||||||
|
|
||||||
|
|
||||||
|
def root() -> None:
|
||||||
|
ui.label('Packaged app')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
freeze_support()
|
||||||
|
ui.run(root, native=True, reload=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use On Air Only For Deliberate Remote Access
|
||||||
|
|
||||||
|
[`ui.run(on_air=True)`](https://nicegui.io/documentation/section_configuration_deployment#nicegui-on-air) creates a temporary public URL, currently valid for one hour. A private device token can select a stable organization/device URL. Treat that token as a secret, and do not log or commit it.
|
||||||
|
|
||||||
|
NiceGUI On Air is a tech preview, not a substitute for selecting an authentication, authorization, availability, and data-governance model. Before exposing an application, review what data and actions become reachable, add application authentication where needed, and verify the service's current operational and privacy terms. Use ordinary hosted deployment when the application requires controlled networking, durable availability, or organization-owned TLS and access policy.
|
||||||
|
|
||||||
|
## Deployment Verification
|
||||||
|
|
||||||
|
Validate the built deployment through its real entry point and public boundary:
|
||||||
|
|
||||||
|
- process starts with reload disabled and shuts down cleanly under the service manager or container runtime
|
||||||
|
- health route, root page, static assets, Socket.IO connection, and reconnect flow work through the proxy or published port
|
||||||
|
- `app.urls` is consumed only after server binding and is not mistaken for canonical proxy configuration
|
||||||
|
- storage survives and isolates users, tabs, workers, and executable instances as designed
|
||||||
|
- TLS, forwarded headers, cookie flags, upload limits, cache policy, and logs match the public deployment
|
||||||
|
- a native build opens, handles window events, closes cleanly, and can run alongside another instance when supported
|
||||||
|
- packaged artifacts include NiceGUI and optional-library data files and are tested on each target platform
|
||||||
|
- normal background tasks cancel on shutdown, while only explicitly bounded finalization work uses `@background_tasks.await_on_shutdown`; see [interaction mechanics](./interaction-patterns.md#execution-contexts)
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
!!! info "Primary sources"
|
||||||
|
- [NiceGUI configuration and deployment](https://nicegui.io/documentation/section_configuration_deployment)
|
||||||
|
- [`ui.run` arguments](https://nicegui.io/documentation/run)
|
||||||
|
- [NiceGUI Docker example](https://github.com/zauberzeug/nicegui/tree/main/examples/docker_image)
|
||||||
|
- [NiceGUI NGINX HTTPS example](https://github.com/zauberzeug/nicegui/blob/main/examples/nginx_https/nginx.conf)
|
||||||
|
- [NiceGUI FastAPI example](https://github.com/zauberzeug/nicegui/tree/main/examples/fastapi)
|
||||||
|
- [pywebview API](https://pywebview.flowrl.com/api)
|
||||||
|
- [Uvicorn settings](https://www.uvicorn.org/settings/)
|
||||||
@@ -57,6 +57,8 @@ 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()`.
|
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()`.
|
||||||
|
|
||||||
|
For `ui.run` arguments, runtime URL discovery, native mode, environment variables, hosted deployment, and executable packaging, use [configuration and deployment](./configuration-and-deployment.md).
|
||||||
|
|
||||||
### Let FastAPI Own The Application
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -1,110 +1,306 @@
|
|||||||
# Interaction Patterns Reference
|
# NiceGUI Interaction Mechanics
|
||||||
|
|
||||||
## Reactive State
|
Use this reference for user-driven and live application behavior: page and client lifetime, value validation, form submission, uploads, explicit refreshes, timers, application events, background execution, and server-pushed updates. Component-specific event names, scoped event payloads, and Quasar model contracts are covered in [component mechanics](./component-mechanics.md). Binding graph behavior and typed projections are covered in [binding dataclasses](./binding-dataclasses.md).
|
||||||
|
|
||||||
Use bindable dataclasses for local page state.
|
The NiceGUI implementation details below are verified against NiceGUI `3.16.0`. FastAPI's native `EventSourceResponse` and `ServerSentEvent` APIs require FastAPI `0.135.0` or later. Check the target application's pinned versions before depending on those surfaces.
|
||||||
|
|
||||||
|
## Interaction Boundary Map
|
||||||
|
|
||||||
|
| Boundary | Owns | Does not own |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| NiceGUI element | browser-facing value, enabled state, validation display, and registered UI callbacks | domain authorization, durable persistence, or cross-worker coordination |
|
||||||
|
| Page `Client` | one page visit's elements, UI context, socket connection, outbox, and client-scoped storage | durable user identity or shared application state |
|
||||||
|
| Page state | current filters, drafts, selections, busy flags, and serializable projections | database transactions or durable job state |
|
||||||
|
| Service or repository | domain validation, authorization, transactions, idempotency, and persistence | direct creation or mutation of NiceGUI elements |
|
||||||
|
| NiceGUI task utility | scheduling work in the event loop, a thread, or a process | durable delivery after process failure |
|
||||||
|
| FastAPI route | HTTP, SSE, or custom WebSocket protocol and authentication boundary | automatic synchronization with NiceGUI elements |
|
||||||
|
|
||||||
|
NiceGUI already uses a Socket.IO connection to carry element events and server updates for each client. Ordinary page interactions should use component callbacks, bindings, `Event`, and element updates rather than introducing a second transport.
|
||||||
|
|
||||||
|
## Page And Client Lifetime
|
||||||
|
|
||||||
|
A [`@ui.page`](https://nicegui.io/documentation/page) builder creates a private `Client` and element tree for each page visit. During initial page construction, Python can create elements before the browser socket exists. Code that requires JavaScript, tab storage, or post-response work must first await `ui.context.client.connected()`.
|
||||||
|
|
||||||
|
The tagged [`page` implementation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/page.py) distinguishes two phases:
|
||||||
|
|
||||||
|
1. Before connection, the page builder must produce the initial response within `response_timeout`, which defaults to three seconds.
|
||||||
|
2. Once `connected()` is awaited, NiceGUI can send the initial HTML immediately and let the remaining async builder continue with a live client.
|
||||||
|
|
||||||
|
Long service calls should not delay initial page construction. Render a stable loading state, await the connection where necessary, then perform the asynchronous work and update or refresh the bounded result region.
|
||||||
|
|
||||||
|
### Disconnect, Reconnect, And Delete
|
||||||
|
|
||||||
|
The tagged [`Client` implementation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/client.py) treats a transient socket disconnect differently from client deletion:
|
||||||
|
|
||||||
|
- `on_disconnect` runs whenever the socket disconnects, including interruptions followed by reconnection.
|
||||||
|
- NiceGUI keeps the client alive for the page's `reconnect_timeout`.
|
||||||
|
- A successful handshake within that window cancels pending deletion.
|
||||||
|
- `on_delete` runs only when the client is actually removed after the reconnect window or explicit cleanup.
|
||||||
|
- Deletion removes the client's elements and bindings and stops its outbox.
|
||||||
|
|
||||||
|
Use `on_disconnect` for connection telemetry and reversible transport state. Use `on_delete` to release resources owned by the page visit. Do not close a page-owned resource on every disconnect if it must survive a short reconnect.
|
||||||
|
|
||||||
|
NiceGUI's tagged [`Outbox`](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/outbox.py) retains recent messages according to `message_history_length` and the reconnect window. A reconnecting client supplies its next expected message ID; NiceGUI replays retained messages or reloads the page when the required history is unavailable. Message replay is transport recovery, not a durable event log or a substitute for idempotent service operations.
|
||||||
|
|
||||||
|
### State Scope
|
||||||
|
|
||||||
|
[`app.storage`](https://nicegui.io/documentation/storage) offers scopes with different navigation and process lifetimes:
|
||||||
|
|
||||||
|
| Scope | Shared with | Survives page navigation or reload | Persistence notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `client` | current page visit only | no | server memory; appropriate for short-lived page resources |
|
||||||
|
| `tab` | current browser tab | yes | server memory by default; requires an established connection |
|
||||||
|
| `user` | tabs carrying the same signed session ID | yes | server-side persistent dictionary; requires `storage_secret` |
|
||||||
|
| `browser` | tabs sharing the session cookie | yes | cookie payload; writable only before the response is built; prefer `user` for most data |
|
||||||
|
| `general` | all users in the process or configured backend | yes | shared persistent dictionary; not a per-user boundary |
|
||||||
|
|
||||||
|
The tagged [`storage` implementation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/storage.py) stores general and user data in local JSON files by default or Redis when configured. Tab storage is process memory unless Redis is configured. Multiple workers therefore require an explicitly shared backend for state that must cross processes.
|
||||||
|
|
||||||
|
## Values, Validation, And Submission
|
||||||
|
|
||||||
|
NiceGUI value elements mirror browser changes into Python and then invoke `on_change` or `on_value_change` handlers. For text input, [`ui.input`](https://nicegui.io/documentation/input) sends `on_change` on each value change unless a Quasar `debounce` prop delays the model update. Use an enter, blur, or explicit submit event when every keystroke should not trigger application work.
|
||||||
|
|
||||||
|
The tagged [`ValidationElement`](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/mixins/validation_element.py) implements NiceGUI's Python validation:
|
||||||
|
|
||||||
|
- a callable returns an error string or `None`
|
||||||
|
- a dictionary maps error strings to predicates and stops at the first failed predicate
|
||||||
|
- automatic validation runs after each handled value change unless `without_auto_validation()` is set
|
||||||
|
- `validate()` updates the element's `error` and `error-message` props
|
||||||
|
- asynchronous validation runs as a background task; `validate(return_result=True)` is not supported for an async validator
|
||||||
|
|
||||||
|
NiceGUI validation is suitable for field feedback, but a submit operation still needs service-level validation and authorization. Browser values, client-side Quasar rules, file metadata, and hidden or disabled controls are not trust boundaries.
|
||||||
|
|
||||||
|
NiceGUI does not require a transport-level HTML form for ordinary page submission: current element values already exist in Python. A submit handler can validate relevant fields, construct an immutable command or DTO, call the service boundary, and update the page from the accepted result. Clear draft state only after persistence succeeds.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from dataclasses import field
|
async def submit() -> None:
|
||||||
from nicegui import binding, ui
|
if not all(field.validate() for field in (name, email)):
|
||||||
|
return
|
||||||
|
|
||||||
@binding.bindable_dataclass
|
submit_button.disable()
|
||||||
class PageState:
|
|
||||||
selected_id: int | None = None
|
|
||||||
items: list = field(default_factory=list)
|
|
||||||
|
|
||||||
state = PageState()
|
|
||||||
ui.label().bind_text_from(state, "selected_id")
|
|
||||||
```
|
|
||||||
|
|
||||||
## File Upload Pattern
|
|
||||||
|
|
||||||
- Validate extension and size before storing.
|
|
||||||
- Delegate storage to a service method.
|
|
||||||
- Notify success and failure explicitly.
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def handle_upload(e: ui.events.UploadEventArguments):
|
|
||||||
try:
|
try:
|
||||||
if e.size > 10 * 1024 * 1024:
|
user = await user_service.create(name=name.value, email=email.value)
|
||||||
raise ValueError("File too large")
|
ui.notify(f"Created {user.display_name}", type="positive")
|
||||||
if not e.name.endswith(".pdf"):
|
name.set_value("")
|
||||||
raise ValueError("Only PDF allowed")
|
email.set_value("")
|
||||||
await file_service.store(e.content.read(), e.name)
|
except DuplicateEmailError:
|
||||||
ui.notify(f"Uploaded: {e.name}", type="positive")
|
email.error = "This email is already registered"
|
||||||
except ValueError as err:
|
finally:
|
||||||
ui.notify(str(err), type="negative")
|
submit_button.enable()
|
||||||
|
|
||||||
ui.upload(on_upload=handle_upload, auto_upload=True)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Form Submission Pattern
|
For asynchronous field validators, await the validator at the service boundary or maintain an explicit validation state; do not use the synchronous return value of `validate()` as proof that asynchronous validation completed.
|
||||||
|
|
||||||
- Bind UI inputs to dataclass fields.
|
## Upload Mechanics
|
||||||
- Perform validation in the service layer.
|
|
||||||
- Clear form state on success.
|
[`ui.upload`](https://nicegui.io/documentation/upload) wraps Quasar's `QUploader`. The tagged [`Upload` wrapper](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/upload.py) registers a POST route scoped to the current client and element. Its event order is:
|
||||||
|
|
||||||
|
1. `on_rejected` during browser-side file selection for Quasar restrictions.
|
||||||
|
2. `on_begin_upload` when the client starts a request.
|
||||||
|
3. `on_upload` once for each server-received file.
|
||||||
|
4. `on_multi_upload` after all files in that request have been converted.
|
||||||
|
|
||||||
|
`max_file_size`, `max_total_size`, `max_files`, and an `accept` prop improve client feedback, but NiceGUI's [security guidance](https://nicegui.io/documentation/section_security#examples_are_starting_points) identifies those restrictions as browser-side checks. Revalidate size, media type, content signature, filename policy, authorization, and storage quota on the server before persisting or parsing data.
|
||||||
|
|
||||||
|
In NiceGUI 3.16, `event.file` is a [`FileUpload`](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/upload_files.py):
|
||||||
|
|
||||||
|
| Surface | Behavior |
|
||||||
|
| --- | --- |
|
||||||
|
| `name` | basename sanitized by NiceGUI; still untrusted display metadata |
|
||||||
|
| `content_type` | request-provided media type; not content verification |
|
||||||
|
| `size()` | synchronous byte count |
|
||||||
|
| `read()`, `text()`, `json()` | asynchronous full-content reads |
|
||||||
|
| `iterate(chunk_size=...)` | asynchronous chunks for bounded-memory processing |
|
||||||
|
| `save(path)` | asynchronous save to an application-selected path |
|
||||||
|
|
||||||
|
NiceGUI reads the incoming Starlette upload and keeps it in memory up to `MultiPartParser.spool_max_size`; larger files spill to a temporary file. This spool threshold controls memory versus disk, not the allowed upload size. Raising it increases per-upload memory pressure and should not be used as a validation mechanism.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@binding.bindable_dataclass
|
from nicegui import events, ui
|
||||||
class FormData:
|
|
||||||
name: str = ""
|
|
||||||
email: str = ""
|
|
||||||
|
|
||||||
data = FormData()
|
|
||||||
ui.input("Name").bind_value(data, "name")
|
|
||||||
ui.input("Email").bind_value(data, "email")
|
|
||||||
|
|
||||||
async def on_submit():
|
async def handle_upload(event: events.UploadEventArguments) -> None:
|
||||||
|
file = event.file
|
||||||
|
if file.size() > 10 * 1024 * 1024:
|
||||||
|
ui.notify("File exceeds 10 MB", type="negative")
|
||||||
|
return
|
||||||
|
if file.content_type != "application/pdf":
|
||||||
|
ui.notify("Only PDF files are accepted", type="negative")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await user_service.create_user(name=data.name, email=data.email)
|
await file_service.store(chunks=file.iterate(), original_name=file.name)
|
||||||
ui.notify("User created", type="positive")
|
except StorageQuotaError:
|
||||||
data.name = data.email = ""
|
ui.notify("Storage quota exceeded", type="negative")
|
||||||
except ValueError as err:
|
else:
|
||||||
ui.notify(str(err), type="negative")
|
ui.notify(f"Uploaded {file.name}", type="positive")
|
||||||
|
|
||||||
ui.button("Submit").on_click(on_submit)
|
|
||||||
|
uploader = ui.upload(
|
||||||
|
on_upload=handle_upload,
|
||||||
|
on_rejected=lambda: ui.notify("File rejected", type="negative"),
|
||||||
|
max_file_size=10 * 1024 * 1024,
|
||||||
|
auto_upload=True,
|
||||||
|
).props("accept=application/pdf")
|
||||||
```
|
```
|
||||||
|
|
||||||
## Real-Time Updates Decision
|
Generate the durable storage name independently from `file.name`, keep user-uploaded active content off the application origin, and apply content-specific scanning before downstream parsers consume the file. Call `uploader.reset()` when the product should clear QUploader's client-side queue after a completed or abandoned operation.
|
||||||
|
|
||||||
Use SSE for one-way status streaming.
|
## Refreshable Component Regions
|
||||||
Use WebSocket for bidirectional messaging.
|
|
||||||
|
|
||||||
SSE endpoint example:
|
The reusable [component factory pattern](./architecture.md#reusable-component-contract) combines stable bindable fields with bounded structural refreshes. Use bindings and setters while an existing element can represent the change; use a refreshable region when the number, type, order, or nesting of child elements must be rebuilt.
|
||||||
|
|
||||||
```python
|
Use the narrowest update mechanism that represents the change:
|
||||||
@app.get("/events/status")
|
|
||||||
async def status_stream():
|
|
||||||
async def gen():
|
|
||||||
while True:
|
|
||||||
yield f"data: {await get_status()}\\n\\n"
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Background Work Pattern
|
| Change | Appropriate surface |
|
||||||
|
| --- | --- |
|
||||||
|
| one wrapper property | setter, binding, or property assignment supported by that wrapper |
|
||||||
|
| mutated option or row collection | wrapper helper or explicit `element.update()` |
|
||||||
|
| a bounded subtree whose structure changed | `@ui.refreshable` or `@ui.refreshable_method` |
|
||||||
|
| navigation to a different page | `ui.navigate` or `ui.sub_pages` |
|
||||||
|
|
||||||
- Start long jobs in FastAPI background tasks.
|
The tagged [`refreshable` implementation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/functions/refreshable.py) records every invocation as a target containing a `RefreshableContainer`, the function arguments, and the associated object instance when applicable. The initial call both renders the region and registers that target. Calling `refresh()` before the decorated function or method has rendered does nothing because no target exists yet.
|
||||||
- Expose status via endpoint or streaming channel.
|
|
||||||
- Guard buttons against duplicate submissions during in-flight tasks.
|
|
||||||
|
|
||||||
## Explicit Refresh Pattern
|
For each matching target, `refresh()` clears the container, updates its remembered arguments, and invokes the function again inside that same container. It recreates the subtree rather than diffing children. Bindings and event handlers owned by deleted elements follow normal element cleanup; a retained reference to a former child does not become the new child. Expose component state and public actions through the returned component handle instead of leaking refresh-owned element references.
|
||||||
|
|
||||||
Use @ui.refreshable and call refresh intentionally instead of polling unrelated state.
|
### Function And Method Scope
|
||||||
|
|
||||||
```python
|
Choose the decorator according to state ownership:
|
||||||
@ui.refreshable
|
|
||||||
async def item_list():
|
|
||||||
items = await service.list()
|
|
||||||
for item in items:
|
|
||||||
ui.label(item.name)
|
|
||||||
|
|
||||||
ui.button("Refresh").on_click(lambda: item_list.refresh())
|
| Form | Target identity | Appropriate scope |
|
||||||
```
|
| --- | --- | --- |
|
||||||
|
| module-level `@ui.refreshable` | every surviving call target of that decorated function | deliberate multicast or shared rendering |
|
||||||
|
| page-local `@ui.refreshable` | calls recorded by the function created during that page build | one page client |
|
||||||
|
| page-created `ui.refreshable(function)` | calls recorded by that decorated wrapper | one page client or component factory call |
|
||||||
|
| `@ui.refreshable_method` | targets whose recorded instance equals the accessed object | reusable component instances with independent state |
|
||||||
|
|
||||||
## Links
|
A module-level refreshable called by multiple clients has multiple targets, so one refresh can update every surviving target. The official [global and local scope examples](https://nicegui.io/documentation/refreshable#global_scope) demonstrate this distinction. For reusable components returned as dataclass handles, prefer a page-created instance with `@ui.refreshable_method`; NiceGUI's tagged [multi-instance tests](https://github.com/zauberzeug/nicegui/blob/v3.16.0/tests/test_refreshable.py) verify that refreshing one instance selects its own targets.
|
||||||
|
|
||||||
!!! info "Primary sources"
|
Calling the same refreshable function more than once creates more than one target. For `@ui.refreshable_method`, every call made on the same instance belongs to that instance, so `instance.region.refresh()` refreshes all surviving targets for that method and instance. Use separate methods or separate component instances when independently refreshing two regions is required.
|
||||||
- [NiceGUI action events](https://nicegui.io/documentation/section_action_events)
|
|
||||||
- [FastAPI server-sent events](https://fastapi.tiangolo.com/advanced/server-sent-events/)
|
### Arguments And Return Behavior
|
||||||
- [FastAPI WebSockets](https://fastapi.tiangolo.com/advanced/websockets/)
|
|
||||||
|
Targets remember their initial positional and keyword arguments:
|
||||||
|
|
||||||
|
- no refresh arguments reuse all remembered values
|
||||||
|
- non-empty positional refresh arguments replace the remembered positional tuple
|
||||||
|
- keyword refresh arguments update the remembered keyword dictionary
|
||||||
|
- arguments must remain consistently positional or keyword; supplying the same parameter through both paths raises `TypeError`
|
||||||
|
- the initial call and each refresh return the decorated function's normal result; the `refresh()` wrapper itself exposes NiceGUI's awaitable response behavior
|
||||||
|
|
||||||
|
Parameters should describe render input, not hide durable state. On a reusable component, fields on the returned dataclass usually provide a clearer interface than repeatedly replacing a long refresh argument list.
|
||||||
|
|
||||||
|
### Async Refresh
|
||||||
|
|
||||||
|
An async refreshable's initial invocation returns its coroutine and should be awaited when page construction depends on its output. For subsequent refreshes:
|
||||||
|
|
||||||
|
- `await region.refresh()` waits for all matching async refreshes to finish
|
||||||
|
- calling `region.refresh()` without awaiting schedules the async work in the background
|
||||||
|
- awaiting is appropriate when a button must remain disabled until rendering completes
|
||||||
|
- each refresh clears the old target before the new async render finishes, so provide a stable outer loading surface when an empty interval would be disruptive
|
||||||
|
|
||||||
|
Multiple matching targets are refreshed together; awaiting waits for all async results through `asyncio.gather`. That coordinates completion but does not serialize competing refresh calls. Apply the generation, lock, or coalescing policy described under [concurrency and feedback state](#concurrency-and-feedback-state) when two operations can refresh the same target concurrently.
|
||||||
|
|
||||||
|
### Target And Local-State Lifetime
|
||||||
|
|
||||||
|
Before every invocation or refresh, NiceGUI prunes targets whose container was deleted. Clearing an ancestor, navigating away, deleting the client, or replacing an outer refreshable region can therefore remove an inner target. A later call to the inner region's `refresh()` cannot recreate a pruned outer placement; the owning outer render must invoke it again.
|
||||||
|
|
||||||
|
`ui.state()` stores values in a list owned by one refreshable target and identifies each value by call order. Its setter automatically refreshes the associated instance target. Conditional or reordered `ui.state()` calls can associate stored values with a different logical variable, so keep their call order stable.
|
||||||
|
|
||||||
|
For reusable application components, a bindable dataclass is usually the clearer state owner: fields have explicit names, can bind directly to stable elements, and remain available to the page through the returned handle. Reserve `ui.state()` for small render-local values that do not need a typed component API, cross-component coordination, service persistence, or independent tests.
|
||||||
|
|
||||||
|
## Timers And Application Events
|
||||||
|
|
||||||
|
[`ui.timer`](https://nicegui.io/documentation/timer) is client-scoped. Its tagged [element implementation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/timer.py) waits for the client connection and cancels the current invocation when the element is deleted. `app.timer` is application-scoped and has no UI context of its own.
|
||||||
|
|
||||||
|
The tagged base [`Timer`](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/timer.py) awaits each callback before scheduling the remainder of the interval, so one timer does not overlap its own invocations. A callback that takes longer than the interval causes the next iteration to begin without an additional delay. `deactivate()` pauses future invocations, while `cancel(with_current_invocation=True)` also cancels the current callback task and cannot be reversed.
|
||||||
|
|
||||||
|
Use timers for truly periodic observation, not to compensate for a missing event or explicit refresh. Polling intervals must account for query cost, number of connected clients, and process-local duplication under multiple workers.
|
||||||
|
|
||||||
|
[`Event`](https://nicegui.io/documentation/event) decouples long-lived Python producers from UI subscribers:
|
||||||
|
|
||||||
|
- `emit()` invokes subscribers without waiting for async callbacks to complete
|
||||||
|
- `call()` awaits all subscribers and propagates their failures to the caller
|
||||||
|
- `emitted(timeout=...)` waits for the next emission
|
||||||
|
- subscriptions created in a UI context are automatically removed when that client is deleted unless configured otherwise
|
||||||
|
|
||||||
|
The automatic unsubscribe behavior in the tagged [`Event` implementation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/event.py) makes an application event suitable for connecting longer-lived models to page-local UI without retaining deleted clients. It remains process-local; use a broker or shared service for cross-worker fan-out.
|
||||||
|
|
||||||
|
## Execution Contexts
|
||||||
|
|
||||||
|
Choose an execution surface by workload and lifetime:
|
||||||
|
|
||||||
|
| Surface | Execution | Suitable for | Important constraint |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| async UI handler | event loop | non-blocking clients and short orchestration | blocking calls freeze all clients on that loop |
|
||||||
|
| `run.io_bound()` | shared thread pool | blocking file, HTTP, or SDK calls | cancellation does not necessarily stop the underlying thread operation |
|
||||||
|
| `run.cpu_bound()` | process pool | CPU-heavy pure computation | callable, arguments, result, and failures cross a pickle boundary |
|
||||||
|
| `background_tasks.create()` | event-loop task | detached async work owned by this process | canceled during shutdown unless tagged with `await_on_shutdown` |
|
||||||
|
| FastAPI `BackgroundTasks` | after an HTTP response | small route-triggered work | still belongs to the web process; not a durable queue |
|
||||||
|
| external worker or job queue | separate process or service | durable, retryable, resource-heavy jobs | requires explicit status, cancellation, and result contracts |
|
||||||
|
|
||||||
|
The tagged [`run` implementation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/run.py) uses a thread pool for `io_bound` and a process pool for `cpu_bound`. For CPU work, prefer a module-level function with simple serializable arguments and return data rather than UI objects or closures. NiceGUI 3.16 inherits the platform multiprocessing start method unless `run.process_pool_start_method` is set before startup; `spawn` avoids unsafe fork behavior in a threaded process but does not inherit module state.
|
||||||
|
|
||||||
|
The tagged [`background_tasks` implementation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/background_tasks.py) keeps strong references to running tasks, forwards unhandled exceptions to global exception handlers, and cancels ordinary tasks during shutdown. `create_lazy()` coalesces repeated work by name into the current run plus only the latest waiting coroutine; it is useful for refresh-style invalidation, not for work where every event must be processed.
|
||||||
|
|
||||||
|
Decorate a coroutine with `@background_tasks.await_on_shutdown` only when process shutdown must wait for that bounded task to finish, such as flushing a small already-accepted result. The decorator prevents NiceGUI's normal shutdown cancellation; it does not make the work durable after a crash, container kill, or host failure. Keep unbounded work and retryable jobs in an external worker rather than delaying application termination indefinitely.
|
||||||
|
|
||||||
|
## Live Update Transports
|
||||||
|
|
||||||
|
| Requirement | Default surface |
|
||||||
|
| --- | --- |
|
||||||
|
| update the initiating NiceGUI page | mutate elements or bound page state in its client context |
|
||||||
|
| notify all local clients of a page | iterate `app.clients(path)` and enter each `with client:` context |
|
||||||
|
| connect a long-lived Python producer to page subscribers | NiceGUI `Event` with page-local subscriptions |
|
||||||
|
| one-way HTTP event stream for an external/browser consumer | FastAPI SSE endpoint |
|
||||||
|
| custom bidirectional protocol independent of NiceGUI elements | FastAPI WebSocket endpoint |
|
||||||
|
| cross-worker or cross-instance broadcast | external broker plus a subscriber in each process |
|
||||||
|
|
||||||
|
FastAPI's [SSE support](https://fastapi.tiangolo.com/tutorial/server-sent-events/) uses a yielding route with `response_class=EventSourceResponse`. `ServerSentEvent` adds `event`, `id`, `retry`, and comment fields; event IDs support application-defined resume behavior through `Last-Event-ID`. FastAPI supplies keep-alive comments and headers that discourage proxy buffering and caching. The stream producer still owns authorization, disconnect-aware resource cleanup, replay semantics, and bounded buffering.
|
||||||
|
|
||||||
|
FastAPI [WebSockets](https://fastapi.tiangolo.com/advanced/websockets/) support text, bytes, and JSON in both directions. Catch `WebSocketDisconnect`, remove the connection from any local registry, and remember that an in-memory connection manager reaches only clients attached to the same process.
|
||||||
|
|
||||||
|
Do not use SSE or a custom WebSocket merely to update NiceGUI elements. Those transports do not automatically establish the target NiceGUI client context or synchronize its element tree.
|
||||||
|
|
||||||
|
## Concurrency And Feedback State
|
||||||
|
|
||||||
|
Disabling the initiating control communicates that work is active, but it is not a server-side concurrency guarantee. Also guard the handler or service with one of these policies:
|
||||||
|
|
||||||
|
- reject a second request while the operation is in flight
|
||||||
|
- coalesce duplicate refresh requests and keep only the latest invalidation
|
||||||
|
- serialize operations with a lock scoped to the affected entity or user
|
||||||
|
- make the service operation idempotent and return the existing result
|
||||||
|
|
||||||
|
For search, filtering, and other replaceable reads, an older request can complete after a newer request. Associate each request with a monotonically increasing generation or cancel the previous task, and only publish a result that still matches the current generation. Cancellation must still restore enabled/loading state in `finally`.
|
||||||
|
|
||||||
|
Every user-triggered asynchronous operation should expose a bounded state model such as `idle`, `running`, `succeeded`, `failed`, or `canceled`. Keep the error message near the action, preserve user input after expected failure, and do not convert unexpected programming errors into a generic success-like state.
|
||||||
|
|
||||||
|
## Source Index
|
||||||
|
|
||||||
|
!!! info "NiceGUI public documentation"
|
||||||
|
- [Pages and client connection](https://nicegui.io/documentation/page)
|
||||||
|
- [Action, events, execution, and error handling](https://nicegui.io/documentation/section_action_events)
|
||||||
|
- [Input and validation](https://nicegui.io/documentation/input)
|
||||||
|
- [Upload](https://nicegui.io/documentation/upload)
|
||||||
|
- [Refreshable UI](https://nicegui.io/documentation/refreshable)
|
||||||
|
- [Timer](https://nicegui.io/documentation/timer)
|
||||||
|
- [Application events](https://nicegui.io/documentation/event)
|
||||||
|
- [Storage scopes](https://nicegui.io/documentation/storage)
|
||||||
|
|
||||||
|
!!! info "NiceGUI `3.16.0` implementation"
|
||||||
|
- [Page builder and response phases](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/page.py)
|
||||||
|
- [Client lifecycle](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/client.py)
|
||||||
|
- [Outbox and reconnect replay](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/outbox.py)
|
||||||
|
- [Validation elements](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/mixins/validation_element.py)
|
||||||
|
- [Upload wrapper](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/upload.py)
|
||||||
|
- [Uploaded-file storage and access](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/upload_files.py)
|
||||||
|
- [Refreshable targets and local state](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/functions/refreshable.py)
|
||||||
|
- [Timer scheduling](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/timer.py)
|
||||||
|
- [Application event dispatch](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/event.py)
|
||||||
|
- [Thread and process execution](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/run.py)
|
||||||
|
- [Background-task lifecycle](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/background_tasks.py)
|
||||||
|
|
||||||
|
!!! info "FastAPI transports and tasks"
|
||||||
|
- [Server-sent events](https://fastapi.tiangolo.com/tutorial/server-sent-events/)
|
||||||
|
- [WebSockets](https://fastapi.tiangolo.com/advanced/websockets/)
|
||||||
|
- [Response background tasks](https://fastapi.tiangolo.com/tutorial/background-tasks/)
|
||||||
@@ -9,11 +9,16 @@ Use these links to verify framework-specific behavior before relying on version-
|
|||||||
- [Element styling, props, and events](https://nicegui.io/documentation/element)
|
- [Element styling, props, and events](https://nicegui.io/documentation/element)
|
||||||
- [NiceGUI element source](https://github.com/zauberzeug/nicegui/tree/main/nicegui/elements)
|
- [NiceGUI element source](https://github.com/zauberzeug/nicegui/tree/main/nicegui/elements)
|
||||||
- [Pages, routing, and FastAPI integration](https://www.nicegui.io/documentation/section_pages_routing)
|
- [Pages, routing, and FastAPI integration](https://www.nicegui.io/documentation/section_pages_routing)
|
||||||
|
- [Configuration, native mode, hosting, and packaging](https://nicegui.io/documentation/section_configuration_deployment)
|
||||||
|
- [`ui.run` arguments](https://nicegui.io/documentation/run)
|
||||||
- [`ui.run_with` implementation](https://github.com/zauberzeug/nicegui/blob/main/nicegui/ui_run_with.py)
|
- [`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)
|
- [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)
|
- [Binding properties and bindable dataclasses](https://www.nicegui.io/documentation/section_binding_properties)
|
||||||
- [Action events](https://www.nicegui.io/documentation/section_action_events)
|
- [Action events](https://www.nicegui.io/documentation/section_action_events)
|
||||||
- [Security best practices](https://www.nicegui.io/documentation/section_security)
|
- [Security best practices](https://www.nicegui.io/documentation/section_security)
|
||||||
|
- [Machine-readable sitewide documentation index](https://nicegui.io/static/sitewide_index.json)
|
||||||
|
- [Machine-readable documentation search index](https://nicegui.io/static/search_index.json)
|
||||||
|
- [Machine-readable examples index](https://nicegui.io/static/examples_index.json)
|
||||||
|
|
||||||
## FastAPI
|
## FastAPI
|
||||||
|
|
||||||
|
|||||||
@@ -1,172 +1,71 @@
|
|||||||
# NiceGUI Visual Styling And CSS
|
# NiceGUI Page Structure, Typography, And Scaling
|
||||||
|
|
||||||
Use this reference for cosmetic and presentational work: themes, color roles, utility classes, CSS properties, responsive layout, and static assets. For the mechanics of how a NiceGUI Python element maps to a Quasar Vue component, including props, events, slots, methods, teleported content, and wrapper-owned state, load [component mechanics](./component-mechanics.md).
|
Use this reference for the physical structure of a NiceGUI page: container geometry, Tailwind layout classes, spacing, overflow, responsive reflow, font loading, typography, and scale. Prefer NiceGUI's Python mechanics or Tailwind classes wherever they can express the requirement; custom CSS is the fallback, not a parallel styling path. For the mechanics of how a NiceGUI Python element maps to a Quasar Vue component, including props, events, slots, methods, teleported content, and wrapper-owned state, load [component mechanics](./component-mechanics.md).
|
||||||
|
|
||||||
For package boundaries, dependency direction, and page or component ownership, load [application architecture](./architecture.md).
|
For package boundaries, dependency direction, and page or component ownership, load [application architecture](./architecture.md).
|
||||||
|
|
||||||
## Visual Styling Boundary
|
## Page Structure Boundary
|
||||||
|
|
||||||
This page owns how an element looks and fits into a page after the correct component and behavior have been chosen. Typical concerns include:
|
This page owns how elements occupy and share space after the correct components and behavior have been chosen. Typical concerns include:
|
||||||
|
|
||||||
- application color roles and light or dark presentation
|
- page shells, content-width constraints, columns, rows, and grid tracks
|
||||||
- width, height, spacing, alignment, wrapping, and overflow
|
- width, height, spacing, alignment, wrapping, overflow, and scroll ownership
|
||||||
- typography, borders, shadows, focus treatments, and state colors
|
- font resources, font families, type sizes, weights, line height, and line length
|
||||||
- responsive page composition and stable control dimensions
|
- responsive page composition and stable control dimensions
|
||||||
- reusable application classes, CSS custom properties, and static assets
|
- rem-based sizing, browser text enlargement, and explicit element scaling
|
||||||
|
- exceptional CSS that cannot be expressed through Python mechanics or Tailwind classes
|
||||||
|
|
||||||
The companion [component mechanics](./component-mechanics.md) reference owns how behavior crosses framework boundaries. Use it when the question is whether a value belongs in a constructor, Quasar prop, Vue event, slot, method, binding, or teleported popup.
|
The companion [component mechanics](./component-mechanics.md) reference owns how behavior crosses framework boundaries. Use it when the question is whether a value belongs in a constructor, Quasar prop, Vue event, slot, method, binding, or teleported popup.
|
||||||
|
|
||||||
## Visual Styling Workflow
|
## Precedence: Python, Then Tailwind, Then CSS
|
||||||
|
|
||||||
Escalate only as far as the visual requirement needs:
|
Apply this order to every structural requirement:
|
||||||
|
|
||||||
1. Use a NiceGUI constructor argument when it directly expresses appearance, such as an icon, color, or size.
|
1. Use NiceGUI's Python composition and component APIs: containers such as `ui.row`, `ui.column`, and `ui.grid`, constructor arguments, documented properties, slots, and wrapper methods.
|
||||||
2. Use documented Quasar appearance props through `.props(...)` for component variants such as `outlined`, `rounded`, or `dense`.
|
2. Add Tailwind classes through `.classes(...)` for width, tracks, spacing, alignment, wrapping, overflow, responsive changes, typography, and other physical presentation.
|
||||||
3. Use Tailwind classes for page structure and common visual utilities.
|
3. Use Quasar props or helper classes when the requirement belongs specifically to a Quasar component and NiceGUI exposes that boundary.
|
||||||
4. Use Quasar utility classes for Quasar spacing, typography, semantic colors, visibility, and positioning.
|
4. Use `.style(...)` only for a calculated runtime value that cannot be represented by the available APIs or utility classes.
|
||||||
5. Use `.style(...)` for a calculated runtime value or a short-lived visual probe.
|
5. Add scoped static CSS only when all preceding layers cannot express the requirement without relying on unsupported component internals.
|
||||||
6. Move stable or repeated declarations into a scoped static stylesheet under an application-owned class.
|
|
||||||
|
|
||||||
Stop when the required presentation is achieved. If a proposed rule needs selectors such as `.q-field__control`, changes a popup's mounting or positioning behavior, or depends on generated Vue markup, resolve the component mechanics first instead of compensating with CSS.
|
Do not create a stylesheet merely to rename or group utilities that fit cleanly in `.classes(...)`. Reuse a Python component or helper when a class sequence repeats. Before adding CSS, identify the unsupported requirement it solves; if the rule needs selectors such as `.q-field__control`, changes popup positioning, or depends on generated Vue markup, resolve the component mechanics first instead of compensating with CSS.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
ui.select(
|
(
|
||||||
options=items,
|
ui.select(options=items, label="Item")
|
||||||
label="Item",
|
.props("outlined")
|
||||||
).props(
|
.classes("w-full md:max-w-md rounded")
|
||||||
"outlined popup-content-class=app-item-menu"
|
|
||||||
).classes(
|
|
||||||
"app-item-select w-full md:max-w-md"
|
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
```css
|
## Physical Layout Model
|
||||||
.app-item-select {
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-item-menu {
|
Four layout decisions control most NiceGUI page structure:
|
||||||
max-height: min(24rem, 60dvh);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Application Themes With NiceGUI And Quasar
|
| Decision | Typical declarations | Failure when omitted |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| outer constraint | `w-full`, `max-w-*`, `mx-auto`, `px-*` | content touches viewport edges or becomes unreadably wide |
|
||||||
|
| track sizing | `flex-1`, `shrink-0`, `grid-cols-*`, `minmax(0, 1fr)` | sidebars collapse or content forces tracks wider than the viewport |
|
||||||
|
| intrinsic minimums | `min-w-0`, `min-h-0` | flexible children refuse to shrink and create page-level overflow |
|
||||||
|
| overflow owner | `overflow-auto`, `overflow-x-auto`, `overflow-hidden` | multiple nested scrollers or clipped interactive content |
|
||||||
|
|
||||||
Treat a theme as three related layers with different owners:
|
NiceGUI rows and columns provide component structure, while their `.classes(...)` values define the physical constraints. Prefer explicit Tailwind `p-*` and `gap-*` classes for local container spacing. NiceGUI's `--nicegui-default-padding` and `--nicegui-default-gap` variables, both `1rem` by default, are CSS-level exceptions for changing the framework-wide baseline rather than one container.
|
||||||
|
|
||||||
1. Configure Quasar's named color roles through NiceGUI.
|
|
||||||
2. Let Quasar own light, dark, and automatic mode state.
|
|
||||||
3. Define application semantic tokens for surfaces and content not covered by Quasar components.
|
|
||||||
|
|
||||||
Do not implement a parallel theme switch by replacing Quasar classes or directly restyling each component. NiceGUI's color APIs set the supported Quasar `--q-*` custom properties, so Quasar components, `color=` arguments, and classes such as `text-primary` and `bg-positive` stay aligned.
|
|
||||||
|
|
||||||
### Set The App-Wide Palette Once
|
|
||||||
|
|
||||||
Use [`app.colors()`](https://nicegui.io/documentation/colors#app-wide-colors) in the composition layer for the default palette. Prefer Quasar's semantic roles over shade names: `primary`, `secondary`, `accent`, `positive`, `negative`, `info`, and `warning`. The `dark` and `dark_page` arguments configure dark surface colors; they do not enable dark mode.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from nicegui import app, ui
|
|
||||||
|
|
||||||
app.colors(
|
|
||||||
primary="#176b5b",
|
|
||||||
secondary="#52645f",
|
|
||||||
accent="#c05a32",
|
|
||||||
dark="#202523",
|
|
||||||
dark_page="#151917",
|
|
||||||
positive="#2e7d32",
|
|
||||||
negative="#b3261e",
|
|
||||||
info="#276b8e",
|
|
||||||
warning="#a86600",
|
|
||||||
brand="#176b5b",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@ui.page("/")
|
|
||||||
def index() -> None:
|
|
||||||
ui.button("Save")
|
|
||||||
ui.label("Current workspace").classes("text-brand")
|
|
||||||
|
|
||||||
|
|
||||||
ui.run()
|
|
||||||
```
|
|
||||||
|
|
||||||
Custom names such as `brand` become Quasar color names and can be used through `color="brand"`, `text-brand`, or `bg-brand`. Register them before any component uses them. `app.colors()` was added in NiceGUI 3.6.0; for an older pinned version, centralize the same `ui.colors(...)` call in a shared page shell.
|
|
||||||
|
|
||||||
Use [`ui.colors()`](https://nicegui.io/documentation/colors) only when one page intentionally overrides the app palette. It is page-scoped and takes precedence over `app.colors()`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@ui.page("/operations")
|
|
||||||
def operations_page() -> None:
|
|
||||||
ui.colors(primary="#8f3d2c")
|
|
||||||
ui.button("Operations action")
|
|
||||||
```
|
|
||||||
|
|
||||||
Avoid scattering `ui.colors()` calls among reusable components. A component should consume semantic roles from its owning page rather than silently changing the palette for the whole page.
|
|
||||||
|
|
||||||
### Let Quasar Control Light And Dark Mode
|
|
||||||
|
|
||||||
Use [`ui.dark_mode()`](https://nicegui.io/documentation/dark_mode) for page mode. Its value is tri-state: `True` enables dark mode, `False` disables it, and `None` follows the client's `prefers-color-scheme` setting. It overrides the `dark` default supplied to `ui.run()` or `@ui.page` for that page.
|
|
||||||
|
|
||||||
```python
|
|
||||||
dark_mode = ui.dark_mode(None)
|
|
||||||
|
|
||||||
with ui.button_group():
|
|
||||||
ui.button("System", on_click=dark_mode.auto)
|
|
||||||
ui.button("Light", on_click=dark_mode.disable)
|
|
||||||
ui.button("Dark", on_click=dark_mode.enable)
|
|
||||||
```
|
|
||||||
|
|
||||||
Quasar applies `body--light` or `body--dark`, updates its dark-aware components, and tracks system changes while mode is automatic. Use the NiceGUI element instead of invoking Quasar's JavaScript Dark plugin directly. Persist an explicit user preference separately when it must survive navigation or a new browser session.
|
|
||||||
|
|
||||||
### Add Semantic Tokens For Application Surfaces
|
|
||||||
|
|
||||||
Quasar's brand roles cover framework components, not every application-specific surface. Define a small set of semantic CSS variables in the static stylesheet and change their values under Quasar's documented `.body--dark` class:
|
|
||||||
|
|
||||||
```css
|
|
||||||
:root {
|
|
||||||
--app-page: #f6f8f7;
|
|
||||||
--app-surface: #ffffff;
|
|
||||||
--app-text: #202623;
|
|
||||||
--app-border: #cbd4d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body--dark {
|
|
||||||
--app-page: var(--q-dark-page);
|
|
||||||
--app-surface: var(--q-dark);
|
|
||||||
--app-text: #eef3f0;
|
|
||||||
--app-border: #46504b;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--app-page);
|
|
||||||
color: var(--app-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-panel {
|
|
||||||
background: var(--app-surface);
|
|
||||||
border: 1px solid var(--app-border);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Name tokens by purpose, such as `--app-surface` or `--app-muted-text`, rather than by a fixed color such as `--app-gray-100`. Reuse `--q-primary` and the other Quasar variables when the meaning matches. Check text, icon, border, focus, hover, disabled, positive, warning, and negative contrast in both modes; a palette is not complete merely because the page background changes.
|
|
||||||
|
|
||||||
## Structural Styling With Tailwind
|
## Structural Styling With Tailwind
|
||||||
|
|
||||||
Use standard [Tailwind utility classes](https://tailwindcss.com/docs/utility-first) for page and component structure:
|
NiceGUI's `.classes()` method attaches Tailwind-compatible classes directly to the rendered element. The structural categories used most often are:
|
||||||
|
|
||||||
- display, flex, and grid behavior
|
| Concern | Representative classes |
|
||||||
- width, height, and maximum-width constraints
|
| --- | --- |
|
||||||
- spacing, gaps, padding, and alignment
|
| display and tracks | `flex`, `grid`, `grid-cols-1`, `md:grid-cols-2` |
|
||||||
- wrapping, overflow, and responsive variants
|
| growth and shrinkage | `flex-1`, `grow`, `shrink-0`, `basis-*` |
|
||||||
- typography and common visual utilities when they fully express the design
|
| dimensions | `w-full`, `h-full`, `min-w-0`, `max-w-6xl`, `size-10` |
|
||||||
|
| spacing | `gap-4`, `px-4`, `py-6`, `mx-auto`, `space-y-3` |
|
||||||
|
| alignment | `items-start`, `items-center`, `justify-between`, `self-stretch` |
|
||||||
|
| wrapping and overflow | `flex-wrap`, `whitespace-nowrap`, `overflow-auto`, `truncate` |
|
||||||
|
| positioning | `relative`, `absolute`, `sticky`, `inset-*`, `z-*` |
|
||||||
|
| responsive changes | `md:flex-row`, `lg:grid-cols-3`, `xl:max-w-7xl` |
|
||||||
|
|
||||||
Build the outer layout before fine-tuning individual controls:
|
The [Tailwind width](https://tailwindcss.com/docs/width) and [maximum-width](https://tailwindcss.com/docs/max-width) references distinguish fixed spacing-scale widths, fractions, viewport units, and container-scale constraints. A centered shell normally combines its responsibilities explicitly:
|
||||||
|
|
||||||
1. Define the page shell and width constraints.
|
|
||||||
2. Establish responsive rows, columns, gaps, and wrapping.
|
|
||||||
3. Add semantic sections and repeated visual patterns.
|
|
||||||
4. Configure component appearance and behavior with constructor arguments and Quasar props.
|
|
||||||
5. Add stable application classes for any remaining stylesheet rules.
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
with ui.column().classes("w-full max-w-6xl mx-auto gap-6 px-4"):
|
with ui.column().classes("w-full max-w-6xl mx-auto gap-6 px-4"):
|
||||||
@@ -177,60 +76,126 @@ with ui.column().classes("w-full max-w-6xl mx-auto gap-6 px-4"):
|
|||||||
item_grid().classes("w-full flex-1 min-w-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.
|
`w-full` fills available inline space, `max-w-6xl` caps line and panel length, `mx-auto` centers the shell, and `px-4` retains edge space below the cap. Inside the row, `shrink-0` protects the sidebar and `min-w-0` allows the flexible content track to become narrower than its intrinsic content.
|
||||||
|
|
||||||
|
Tailwind's [responsive variants](https://tailwindcss.com/docs/responsive-design) are mobile-first. Unprefixed classes apply at every size; `md:*` and larger prefixes apply from their minimum width upward. In NiceGUI's default Tailwind setup, verify available classes against the framework version bundled by the installed NiceGUI release. Optional [UnoCSS presets](https://nicegui.io/documentation/section_styling_appearance#unocss_engine) are intentionally not fully compatible with Tailwind, and Tailwind CSS layers are one documented difference.
|
||||||
|
|
||||||
### Combine Tailwind With Quasar Utilities Deliberately
|
### Combine Tailwind With Quasar Utilities Deliberately
|
||||||
|
|
||||||
NiceGUI's `.classes()` accepts both Tailwind utilities and the CSS helpers bundled with Quasar. Keep Tailwind as the default for application layout and responsive structure, but use Quasar utilities when they express a Quasar-owned or framework-semantic concern more directly:
|
NiceGUI's `.classes()` accepts both Tailwind utilities and CSS helpers bundled with Quasar. Assign each concern to one system:
|
||||||
|
|
||||||
|
| Concern | Default owner | Examples |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Python element structure | NiceGUI | `ui.row`, `ui.column`, `ui.grid`, slot context managers |
|
||||||
|
| Generic geometry and responsive layout | Tailwind | `w-full`, `items-center`, `justify-center`, `gap-2`, `p-6`, `flex-wrap` |
|
||||||
|
| Generic application typography | Tailwind | `text-xl`, `font-medium`, `leading-6`, `truncate` |
|
||||||
|
| Theme-aware semantic color | Quasar | `text-primary`, `bg-positive`, `text-grey-7` |
|
||||||
|
| Component behavior and density | Quasar props | `dense`, `outlined`, `round`, `separator=horizontal` |
|
||||||
|
| Component-specific geometry | Quasar helper classes | `q-table--col-auto-width`, `absolute-top-right` |
|
||||||
|
|
||||||
|
Prefer the Tailwind spelling when both systems express ordinary application layout or typography. For example, use `w-full` instead of `full-width`, `items-center justify-center` instead of `flex-center`, `gap-2` instead of `q-gutter-sm`, `p-4` instead of `q-pa-md`, and `text-xl font-medium` instead of `text-h6 text-weight-medium`. This keeps spacing, breakpoints, and type choices in one vocabulary.
|
||||||
|
|
||||||
|
Quasar helpers remain useful when a value intentionally follows Quasar's component conventions:
|
||||||
|
|
||||||
- [`q-m*` and `q-p*` spacing classes](https://quasar.dev/style/spacing) when spacing should follow Quasar's component scale
|
- [`q-m*` and `q-p*` spacing classes](https://quasar.dev/style/spacing) when spacing should follow Quasar's component scale
|
||||||
- [typography helpers](https://quasar.dev/style/typography), such as `text-h6`, `text-subtitle2`, and `text-weight-medium`, for text that should follow Quasar's type system
|
- [typography helpers](https://quasar.dev/style/typography), such as `text-h6`, `text-subtitle2`, and `text-weight-medium`, for text that should follow Quasar's type system
|
||||||
- [color palette classes](https://quasar.dev/style/color-palette), such as `text-primary`, `bg-positive`, and `text-negative`, so semantic colors track the palette configured by `app.colors()` or `ui.colors()`
|
|
||||||
- [visibility helpers](https://quasar.dev/style/visibility), such as `gt-sm` and `lt-md`, when visibility should use Quasar's configured breakpoints
|
- [visibility helpers](https://quasar.dev/style/visibility), such as `gt-sm` and `lt-md`, when visibility should use Quasar's configured breakpoints
|
||||||
- [positioning helpers](https://quasar.dev/style/positioning), such as `absolute-top-right`, when positioning content relative to a Quasar component
|
- [positioning helpers](https://quasar.dev/style/positioning), such as `absolute-top-right`, when positioning content relative to a Quasar component
|
||||||
|
- [size and overflow helpers](https://quasar.dev/style/other-helper-classes), such as `fit`, `full-width`, and `overflow-auto`, when matching Quasar layout behavior
|
||||||
|
|
||||||
Mix the two systems by concern, not by writing competing declarations for the same CSS property. For example, `w-full q-pa-md text-primary` uses Tailwind for width and Quasar for component-scale padding and semantic color. Do not combine `p-4` with `q-pa-md`, or Tailwind and Quasar visibility helpers, on the same element; their cascade order can make the result version-dependent and difficult to review.
|
Do not assign the same property through both systems on one element. For example, `w-full q-pa-md` deliberately uses Tailwind for width and Quasar for component-scale padding; adding `p-4` would create competing padding declarations. The same rule applies to Tailwind and Quasar visibility helpers or to Tailwind font sizes and Quasar heading classes.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
with ui.card().classes("w-full max-w-2xl q-pa-md"):
|
with ui.card().classes("w-full max-w-2xl q-pa-md"):
|
||||||
ui.label("Inventory summary").classes("text-h6 text-primary")
|
ui.label("Inventory summary").classes("text-h6")
|
||||||
ui.label("Review required").classes("text-negative text-weight-medium")
|
ui.label("12 locations").classes("text-subtitle2 text-weight-medium")
|
||||||
```
|
```
|
||||||
|
|
||||||
Quasar utilities are global classes, so they need no Vue-specific translation before being passed to `.classes()`. Confirm the available helpers and breakpoints against the Quasar version bundled by the installed NiceGUI release.
|
Tailwind and Quasar do not share breakpoint thresholds. Tailwind's defaults begin `sm` at `40rem` and `md` at `48rem`; Quasar defines `sm` from `600px` and `md` from `1024px`. Keep one breakpoint system responsible for a given layout transition, and confirm the bundled framework versions before relying on exact thresholds.
|
||||||
|
|
||||||
## Fine Tuning With Static Stylesheets
|
## CSS As A Last Resort
|
||||||
|
|
||||||
Move stable fine tuning into a static stylesheet after the structure and native component configuration are correct. Static stylesheets provide reusable selectors, media queries, pseudo-classes, CSS variables, and a clear cascade that inline declarations cannot provide.
|
Do not move stable geometry into a stylesheet simply because a Tailwind class string is long. Tailwind arbitrary values can express constraints such as `minmax(...)`, `min(...)`, aspect ratios, and dynamic viewport units while keeping the rule visible beside the Python structure that owns it.
|
||||||
|
|
||||||
Attach an application-owned class with `.classes()` or a Quasar popup prop, then scope stylesheet rules beneath it:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
ui.select(...).props("popup-content-class=app-item-menu").classes(
|
with ui.element("main").classes(
|
||||||
"app-item-select w-full md:max-w-md"
|
"grid min-h-0 "
|
||||||
)
|
"grid-cols-[minmax(14rem,20rem)_minmax(0,1fr)]"
|
||||||
|
):
|
||||||
|
sidebar()
|
||||||
|
workspace().classes("min-w-0")
|
||||||
|
|
||||||
|
ui.select(...).props(
|
||||||
|
'outlined popup-content-class="max-h-[min(24rem,60dvh)] overflow-y-auto"'
|
||||||
|
).classes("w-full md:max-w-md")
|
||||||
```
|
```
|
||||||
|
|
||||||
```css
|
Use `.style()` only when a value is calculated at runtime and no class or component property can represent it. Keep the override on the narrowest element and do not promote it to a shared stylesheet unless it becomes a genuine cross-component rule.
|
||||||
.app-item-select {
|
|
||||||
--app-field-accent: #176b5b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-item-select:focus-within {
|
Static CSS remains appropriate for browser-level facilities such as `@font-face`, selectors or pseudo-elements with no available utility, and integration with markup that cannot receive classes. Attach an application-owned class through `.classes()` or a documented Quasar prop, then scope the exceptional rule beneath that class.
|
||||||
filter: drop-shadow(0 0 0.25rem rgb(23 107 91 / 20%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-item-menu {
|
|
||||||
max-height: min(24rem, 60dvh);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `.style()` when a value is calculated at runtime or while testing a local hypothesis. Once a declaration becomes stable or repeated, move it to the stylesheet and keep only the application class in Python.
|
|
||||||
|
|
||||||
Avoid overriding Quasar internals such as `.q-field__label`, `.q-field__native`, `.q-field__control`, and `.q-field__input` unless the public props, slots, and application-level selectors cannot express the requirement.
|
Avoid overriding Quasar internals such as `.q-field__label`, `.q-field__native`, `.q-field__control`, and `.q-field__input` unless the public props, slots, and application-level selectors cannot express the requirement.
|
||||||
|
|
||||||
Quasar coordinates field height, padding, labels, values, icons, and floating-label transforms. Changing only one internal part tends to cause clipping or overlap.
|
Quasar coordinates field height, padding, labels, values, icons, and floating-label transforms. Changing only one internal part tends to cause clipping or overlap.
|
||||||
|
|
||||||
|
## Fonts And Typography
|
||||||
|
|
||||||
|
Typography affects physical layout because font metrics determine line breaks, control height, baseline alignment, and the intrinsic width of labels. Treat font loading and the type scale as structural dependencies rather than late decoration.
|
||||||
|
|
||||||
|
### Font Families And Loading
|
||||||
|
|
||||||
|
Tailwind provides `font-sans`, `font-serif`, and `font-mono`, and supports custom family utilities as documented by [Tailwind font family](https://tailwindcss.com/docs/font-family). Quasar's [typography reference](https://quasar.dev/style/typography) documents its embedded Roboto default and its heading, weight, alignment, wrapping, and case helpers.
|
||||||
|
|
||||||
|
For an application-owned typeface, `@font-face` is one of the browser-level cases that warrants CSS. Mount the font with other static assets and declare it once in the shared stylesheet. [MDN `@font-face`](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face) recommends WOFF2 for modern web delivery; `font-display: swap` keeps text available while the resource loads.
|
||||||
|
|
||||||
|
```css
|
||||||
|
@font-face {
|
||||||
|
font-family: "App Sans";
|
||||||
|
src: url("/static/fonts/app-sans.woff2") format("woff2");
|
||||||
|
font-display: swap;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
font-family: "App Sans", sans-serif;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Include the real weight range supplied by the font file. Requesting an unavailable weight makes the browser synthesize it and can alter text width. Keep a fallback family so failed or delayed font requests do not leave text unavailable.
|
||||||
|
|
||||||
|
### Type Size And Line Height
|
||||||
|
|
||||||
|
Tailwind's [font-size utilities](https://tailwindcss.com/docs/font-size) pair named rem-based sizes such as `text-sm`, `text-base`, and `text-xl` with default line heights. Combined forms such as `text-sm/6` set size and line height together. Separate `leading-*`, `font-*`, and text-alignment utilities refine those dimensions.
|
||||||
|
|
||||||
|
```python
|
||||||
|
with ui.column().classes("w-full max-w-[65ch] gap-3"):
|
||||||
|
ui.label("Inventory summary").classes("text-2xl/8 font-semibold")
|
||||||
|
ui.label("Counts by location and storage area").classes("text-base/7")
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer a small named hierarchy over unrelated one-off sizes. Use `rem`-based utilities so browser font preferences and page zoom remain meaningful, and use a character-based maximum width such as `max-w-[65ch]` for long prose. Avoid viewport-width font sizing: text should reflow at narrow widths rather than shrink to preserve one line.
|
||||||
|
|
||||||
|
`em` dimensions inherit and can compound through nested elements; `rem` dimensions refer to the root element and avoid that compounding. The [MDN font-size reference](https://developer.mozilla.org/en-US/docs/Web/CSS/font-size) describes both behaviors and recommends relative sizing for accessibility.
|
||||||
|
|
||||||
|
## Scaling Boundaries
|
||||||
|
|
||||||
|
The word "scale" can refer to different browser mechanics. They are not interchangeable:
|
||||||
|
|
||||||
|
| Mechanism | Participates in layout | Appropriate use |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| responsive classes and reflow | yes | normal page adaptation across available widths |
|
||||||
|
| relative font and spacing units | yes | coherent type and spacing changes that respect browser settings |
|
||||||
|
| browser zoom | yes, at the document level | user-controlled magnification that the page must tolerate |
|
||||||
|
| CSS `zoom` | yes | exceptional magnification of a bounded region |
|
||||||
|
| `transform: scale(...)` | no | transient visual emphasis or a deliberately overlaid preview |
|
||||||
|
|
||||||
|
Responsive reflow through Python composition and Tailwind classes is the default for page structure. A narrower page should stack tracks, wrap controls, and retain readable text rather than shrink the entire interface.
|
||||||
|
|
||||||
|
[CSS `zoom`](https://developer.mozilla.org/en-US/docs/Web/CSS/zoom) changes the size used by layout, so surrounding content is recalculated. [`transform: scale()`](https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/scale) changes only painting; neighboring elements retain the unscaled geometry, and enlarged content can overlap or overflow its box. Treat both as exceptional effects after responsive widths, gaps, and breakpoints have been exhausted.
|
||||||
|
|
||||||
|
Stable fixed-format regions such as boards, diagrams, and previews need an explicit box before their contents scale. Combine `aspect-ratio`, a bounded inline size, and local overflow rules so transformed content cannot resize surrounding controls. Scaling animations should respect `prefers-reduced-motion`.
|
||||||
|
|
||||||
## Responsive Layout
|
## Responsive Layout
|
||||||
|
|
||||||
Support these layouts only:
|
Support these layouts only:
|
||||||
@@ -239,7 +204,7 @@ Support these layouts only:
|
|||||||
- landscape desktop: $1920 \times 1080$ with side-by-side panels where they improve scanning
|
- 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
|
- 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.
|
Build the mobile layout first, then add one desktop breakpoint when a row or grid needs more space. Unprefixed Tailwind classes define the mobile baseline; breakpoint-prefixed classes alter it at larger widths. Prefer flex wrapping and fluid grids before adding another breakpoint. Use Tailwind classes for page layout and Quasar props for component density and behavior.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
with ui.row().classes("w-full flex-wrap gap-4 lg:flex-nowrap items-start"):
|
with ui.row().classes("w-full flex-wrap gap-4 lg:flex-nowrap items-start"):
|
||||||
@@ -249,12 +214,15 @@ with ui.row().classes("w-full flex-wrap gap-4 lg:flex-nowrap items-start"):
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Loading Stylesheets And Static Assets
|
Height needs an explicit ownership chain. `h-full` only resolves when the containing block has a definite height; viewport-bound workspaces usually need a defined outer height and `min-h-0` on nested flex or grid tracks before an inner `overflow-auto` region can scroll. Prefer dynamic viewport units such as `dvh` for browser UI that changes the visible mobile viewport.
|
||||||
|
|
||||||
- Mount and link static stylesheets once from the composition layer rather than injecting CSS from individual pages.
|
## Loading Exceptional CSS And Static Assets
|
||||||
- Keep custom CSS tokenized with variables and scoped to application classes.
|
|
||||||
|
- Keep ordinary layout and typography in Python mechanics and Tailwind classes rather than creating a stylesheet.
|
||||||
|
- When exceptional CSS is required, mount and link it once from the composition layer rather than injecting it from individual pages.
|
||||||
|
- Keep custom dimensions and font families in named variables or application classes.
|
||||||
- Avoid broad rules against Quasar internals.
|
- Avoid broad rules against Quasar internals.
|
||||||
- Mount referenced assets in the composition layer.
|
- Mount referenced stylesheets, fonts, and other assets in the composition layer.
|
||||||
- Verify mount paths, reverse-proxy rewrites, and cache behavior.
|
- Verify mount paths, reverse-proxy rewrites, and cache behavior.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -283,23 +251,29 @@ Check each completed page at these three viewports:
|
|||||||
2. Landscape desktop at $1920 \times 1080$.
|
2. Landscape desktop at $1920 \times 1080$.
|
||||||
3. Portrait desktop at $1080 \times 1920$.
|
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.
|
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. Repeat the checks with browser zoom or text enlargement, a delayed font request, long labels, validation messages, and loaded content. Watch for unexpected page-level horizontal scrolling, nested scroll regions, clipped focus outlines, and layout shifts when the webfont replaces its fallback.
|
||||||
|
|
||||||
## Sources
|
## Sources
|
||||||
|
|
||||||
!!! info "Primary sources"
|
!!! info "Primary sources"
|
||||||
- [NiceGUI element styling and props](https://nicegui.io/documentation/element)
|
- [NiceGUI element styling and props](https://nicegui.io/documentation/element)
|
||||||
- [NiceGUI binding properties](https://nicegui.io/documentation/section_binding_properties)
|
- [NiceGUI binding properties](https://nicegui.io/documentation/section_binding_properties)
|
||||||
- [NiceGUI color theming](https://nicegui.io/documentation/colors)
|
- [NiceGUI styling and appearance](https://nicegui.io/documentation/section_styling_appearance)
|
||||||
- [NiceGUI dark mode](https://nicegui.io/documentation/dark_mode)
|
|
||||||
- [Quasar components](https://quasar.dev/vue-components)
|
- [Quasar components](https://quasar.dev/vue-components)
|
||||||
- [Quasar spacing classes](https://quasar.dev/style/spacing)
|
- [Quasar spacing classes](https://quasar.dev/style/spacing)
|
||||||
- [Quasar typography helpers](https://quasar.dev/style/typography)
|
- [Quasar typography helpers](https://quasar.dev/style/typography)
|
||||||
|
- [Quasar breakpoints](https://quasar.dev/style/breakpoints)
|
||||||
- [Quasar visibility helpers](https://quasar.dev/style/visibility)
|
- [Quasar visibility helpers](https://quasar.dev/style/visibility)
|
||||||
- [Quasar positioning helpers](https://quasar.dev/style/positioning)
|
- [Quasar positioning helpers](https://quasar.dev/style/positioning)
|
||||||
- [Quasar color palette and runtime brand variables](https://quasar.dev/style/color-palette)
|
- [Quasar size and overflow helpers](https://quasar.dev/style/other-helper-classes)
|
||||||
- [Quasar dark mode](https://quasar.dev/style/dark-mode)
|
|
||||||
- [Quasar field](https://quasar.dev/vue-components/field/)
|
- [Quasar field](https://quasar.dev/vue-components/field/)
|
||||||
- [Quasar select](https://quasar.dev/vue-components/select/)
|
- [Quasar select](https://quasar.dev/vue-components/select/)
|
||||||
|
- [Tailwind width utilities](https://tailwindcss.com/docs/width)
|
||||||
|
- [Tailwind maximum-width utilities](https://tailwindcss.com/docs/max-width)
|
||||||
|
- [Tailwind font-family utilities](https://tailwindcss.com/docs/font-family)
|
||||||
|
- [Tailwind font-size utilities](https://tailwindcss.com/docs/font-size)
|
||||||
- [Tailwind responsive design](https://tailwindcss.com/docs/responsive-design)
|
- [Tailwind responsive design](https://tailwindcss.com/docs/responsive-design)
|
||||||
|
- [MDN `@font-face`](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face)
|
||||||
|
- [MDN `font-size`](https://developer.mozilla.org/en-US/docs/Web/CSS/font-size)
|
||||||
- [MDN `zoom`](https://developer.mozilla.org/en-US/docs/Web/CSS/zoom)
|
- [MDN `zoom`](https://developer.mozilla.org/en-US/docs/Web/CSS/zoom)
|
||||||
|
- [MDN `scale()`](https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/scale)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# URL-Backed Tabs with Sub Pages
|
||||||
|
|
||||||
|
The [`tab_spa.py` example](../examples/tab_spa.py) combines [NiceGUI tabs](https://nicegui.io/documentation/tabs) with [sub-page routing](https://nicegui.io/documentation/sub_pages). It keeps one application shell and one set of tab panels mounted while the browser URL identifies the active view.
|
||||||
|
|
||||||
|
The example targets NiceGUI `3.16.0`. In this design, tabs are the visible navigation and content mechanism; `ui.sub_pages` is a URL-matching adapter whose route builders update the tab state instead of rendering route content inside the router.
|
||||||
|
|
||||||
|
## Responsibility Map
|
||||||
|
|
||||||
|
| Surface | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `root()` | Creates one client-local shell, reads the initial URL, and connects the tabs, panels, and router. |
|
||||||
|
| `ui.tabs` | Holds the selected tab name and emits user selection changes. |
|
||||||
|
| `ui.tab_panels` | Displays the panel whose name matches the selected tab. |
|
||||||
|
| `ui.sub_pages` | Matches URL paths, extracts route parameters, and invokes the corresponding route callback without a full page reload. |
|
||||||
|
| `NavigationState` | Retains the concrete report path represented by the shared `reports` tab. |
|
||||||
|
| `ui.navigate.to()` | Changes the browser location so the sub-pages router can resolve the destination. |
|
||||||
|
|
||||||
|
The separation matters because a tab name is not always a URL. Static tabs use their route as their name, but every `/reports/{report_id}` URL maps to the single `reports` tab and panel.
|
||||||
|
|
||||||
|
## Route and Panel Mapping
|
||||||
|
|
||||||
|
| Browser path | Tab value | Panel value | Route callback effect |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/` | `/` | `/` | Selects the overview panel. |
|
||||||
|
| `/projects` | `/projects` | `/projects` | Selects the projects panel. |
|
||||||
|
| `/reports/a` | `reports` | `reports` | Stores `/reports/a` and selects the reports panel. |
|
||||||
|
| `/reports/b` | `reports` | `reports` | Stores `/reports/b` and selects the reports panel. |
|
||||||
|
| `/settings` | `/settings` | `/settings` | Selects the settings panel. |
|
||||||
|
|
||||||
|
`tab_name_for_route()` is the translation boundary. It preserves static route names, collapses concrete report routes to `reports`, and returns `/` for other paths.
|
||||||
|
|
||||||
|
## Initial Page Construction
|
||||||
|
|
||||||
|
`root()` reads `ui.context.client.sub_pages_router.current_path` before creating the navigation controls. `normalize_route()` removes query strings, fragments, and trailing slashes so `/projects/` and `/projects` select the same tab.
|
||||||
|
|
||||||
|
For a direct request to `/reports/b`, the initial route produces two values:
|
||||||
|
|
||||||
|
- `active_report_path` becomes `/reports/b`.
|
||||||
|
- the selected tab and panel become `reports`.
|
||||||
|
|
||||||
|
The initial `tabs.set_value(...)` call occurs before `tabs.on_value_change(navigate)` is registered. Initial selection therefore establishes the shell state without treating page construction as a user navigation. Passing the same initial tab value to `ui.tab_panels` aligns the content container with the tabs from the first render.
|
||||||
|
|
||||||
|
All panel builders run during shell construction. Switching tabs changes the selected panel; it does not rerun `overview_page()`, `projects_page()`, `report_page()`, or `settings_page()`. Their element state remains client-local for the lifetime of that shell.
|
||||||
|
|
||||||
|
## Tab-Originated Navigation
|
||||||
|
|
||||||
|
The tab change handler receives the selected tab name. Static tab names are already destinations. The reports tab resolves through `state.active_report_path`, which supplies the last concrete report URL:
|
||||||
|
|
||||||
|
```python
|
||||||
|
destination = state.active_report_path if tabname == REPORTS_TAB else tabname
|
||||||
|
ui.navigate.to(destination)
|
||||||
|
```
|
||||||
|
|
||||||
|
[`ui.navigate.to()`](https://nicegui.io/documentation/navigate) performs the route transition. The sub-pages router then matches the new location and invokes a callback that selects the corresponding tab. Because the panel container is associated with `tabs`, the visible panel follows that selected value.
|
||||||
|
|
||||||
|
## URL-Originated Navigation
|
||||||
|
|
||||||
|
The route callbacks contain no page markup. They translate router matches back into the visible state:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def route_reports(report_id: str) -> None:
|
||||||
|
state.active_report_path = f"/reports/{report_id}"
|
||||||
|
tabs.set_value(REPORTS_TAB)
|
||||||
|
```
|
||||||
|
|
||||||
|
This direction handles direct links and browser back or forward navigation. A URL such as `/reports/b` supplies `report_id="b"`; the callback reconstructs the normalized concrete path, updates report-bound labels through `NavigationState`, and selects the shared reports panel.
|
||||||
|
|
||||||
|
The router element is hidden because it is not the content container in this example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ui.sub_pages(routes).classes("hidden")
|
||||||
|
```
|
||||||
|
|
||||||
|
Normally, `ui.sub_pages` clears and rebuilds its own children when a route changes. Here its builders only mutate state outside that container, so hiding the empty routing element does not hide the tab-panel content.
|
||||||
|
|
||||||
|
## Parameterized Report State
|
||||||
|
|
||||||
|
`NavigationState.active_report_path` separates tab identity from route identity. The reports tab always has the stable value `reports`, while the state records `/reports/a`, `/reports/b`, or another matched report route.
|
||||||
|
|
||||||
|
This provides two forms of continuity:
|
||||||
|
|
||||||
|
- A direct report URL selects the correct tab and report during initial construction.
|
||||||
|
- Leaving the reports tab and selecting it again during the same client lifetime returns to the last visited report.
|
||||||
|
|
||||||
|
The state is page-local, not durable storage. Reloading a non-report URL creates a new `NavigationState` and restores `DEFAULT_REPORT_PATH`. Shareable report identity remains durable because report pages encode it in the URL.
|
||||||
|
|
||||||
|
## Behavioral Boundaries
|
||||||
|
|
||||||
|
- `TAB_ROUTES` contains concrete routes whose path and tab identity are the same. Parameterized route families require a stable synthetic tab name such as `reports`.
|
||||||
|
- `normalize_route()` intentionally ignores query parameters and fragments for tab selection. Route callbacks would need matching parameters if those values affected panel state.
|
||||||
|
- The hidden router also hides its built-in 404 output. As written, an unmatched path selects the overview panel through `tab_name_for_route()` while the router's not-found content remains invisible.
|
||||||
|
- Panels are mounted together, so expensive panel construction still occurs during the initial shell build. Lazy or route-specific construction requires a different content ownership model.
|
||||||
|
- The pattern preserves the shell only for navigation handled by the current `ui.sub_pages` router. A full reload creates a new client and rebuilds all page-local state.
|
||||||
|
|
||||||
|
## Source Index
|
||||||
|
|
||||||
|
!!! info "NiceGUI sources"
|
||||||
|
- [Sub pages and URL parameters](https://nicegui.io/documentation/sub_pages)
|
||||||
|
- [Tabs, tab names, and tab panels](https://nicegui.io/documentation/tabs)
|
||||||
|
- [Navigation and browser history](https://nicegui.io/documentation/navigate)
|
||||||
|
- [NiceGUI `3.16.0` sub-pages implementation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/sub_pages.py)
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
# Table Customization
|
||||||
|
|
||||||
|
Use this reference when a [`ui.table`](https://nicegui.io/documentation/table) needs presentation, controls, responsive behavior, or custom cell content while retaining [Quasar QTable](https://quasar.dev/vue-components/table) sorting, filtering, pagination, and selection behavior. Use [editable tables](./tables.md) instead when browser-originated cell values must be validated and committed by Python.
|
||||||
|
|
||||||
|
## Version Baseline
|
||||||
|
|
||||||
|
This reference and its runnable example were verified against this bundled stack:
|
||||||
|
|
||||||
|
| Layer | Version | Evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| NiceGUI | `3.16.0` | [Tagged `Table` source](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/table.py) |
|
||||||
|
| Quasar | `2.18.5` | [NiceGUI frontend dependencies](https://github.com/zauberzeug/nicegui/blob/v3.16.0/package.json) |
|
||||||
|
| Vue | `3.5.22` | [NiceGUI frontend dependencies](https://github.com/zauberzeug/nicegui/blob/v3.16.0/package.json) |
|
||||||
|
|
||||||
|
Recheck the tagged NiceGUI sources and bundled dependencies for another release. The current Quasar documentation may describe features added after NiceGUI's bundled Quasar version.
|
||||||
|
|
||||||
|
## Choose The Narrowest Layer
|
||||||
|
|
||||||
|
Apply customization at the highest-level API that expresses it:
|
||||||
|
|
||||||
|
1. Use `ui.table(...)` for rows, columns, defaults, stable row identity, title, selection, and pagination.
|
||||||
|
2. Use column definitions for alignment, sorting, formatting, cell classes, header classes, and width hints.
|
||||||
|
3. Use `.props(...)` for QTable behavior that the NiceGUI constructor does not expose, such as `dense`, `separator`, `wrap-cells`, `rows-per-page-options`, and empty-state labels.
|
||||||
|
4. Use named slots for custom toolbar content, one special header or cell, loading, no-data content, or pagination controls.
|
||||||
|
5. Use a full `header`, `body`, or `item` slot only when the whole generated structure must change.
|
||||||
|
6. Add narrowly scoped CSS for behavior that neither component API covers, such as sticky columns.
|
||||||
|
|
||||||
|
NiceGUI's tagged [table client wrapper](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/table.js) passes props to QTable and forwards every supplied slot with its scoped properties. This makes QTable's API the source of truth below the NiceGUI wrapper, but it does not make every current QTable feature compatible with the bundled `2.18.5` release.
|
||||||
|
|
||||||
|
## Columns Before Slots
|
||||||
|
|
||||||
|
A QTable column is both a data projection and a presentation contract. Keep its `name` unique because sorting and `body-cell-[name]` slot selection use it. `field` identifies or computes the raw cell value; it does not need to match the column name.
|
||||||
|
|
||||||
|
```python
|
||||||
|
columns = [
|
||||||
|
{
|
||||||
|
"name": "available",
|
||||||
|
"label": "In stock",
|
||||||
|
"field": "stock",
|
||||||
|
"sortable": True,
|
||||||
|
"align": "right",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
table = ui.table(
|
||||||
|
rows=rows,
|
||||||
|
columns=columns,
|
||||||
|
column_defaults={"headerClasses": "text-grey-8"},
|
||||||
|
row_key="id",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use plain keys such as `classes`, `style`, `headerClasses`, and `headerStyle` for static values. NiceGUI's client-side dynamic-property conversion recognizes colon-prefixed keys such as `:field`, `:format`, `:sort`, `:classes`, and `:style` as JavaScript expressions. Keep row data JSON-serializable; format display values in a column or slot rather than putting component objects in rows.
|
||||||
|
|
||||||
|
### Raw Values And Cosmetic Display Formatting
|
||||||
|
|
||||||
|
Use the column's `format(value, row)` function when only the displayed cell text should change. QTable's tagged [`getCellValue`](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/QTable.js) resolves `field` first and then passes that raw value through `format`. The formatted result is used by the default cell renderer and exposed to `body-cell-*` slots as `props.value`.
|
||||||
|
|
||||||
|
Keep the layers distinct:
|
||||||
|
|
||||||
|
| Layer | Owns | Effect |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| row value | canonical JSON-serializable data sent by Python | remains available as `props.row.<field>` |
|
||||||
|
| `field` | raw value extraction or derivation | supplies sorting and the input to `format` |
|
||||||
|
| `format` | cosmetic text projection | changes default rendering, `props.value`, and local default-filter matching |
|
||||||
|
| `body-cell-*` slot | component structure around the value | use for badges, icons, links, controls, or multiple elements |
|
||||||
|
|
||||||
|
Formatting does not mutate `table.rows` or the row object. QTable's tagged [sorting implementation](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/table-sort.js) compares raw `field` values, while its tagged [default filter](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/table-filter.js) searches the formatted values returned by `getCellValue`. This is usually desirable: numbers sort numerically while users can search what they see.
|
||||||
|
|
||||||
|
#### Prefix And Suffix Text
|
||||||
|
|
||||||
|
Use a null-safe `:format` expression for simple prefix or suffix text:
|
||||||
|
|
||||||
|
```python
|
||||||
|
columns = [
|
||||||
|
{
|
||||||
|
"name": "price",
|
||||||
|
"label": "Unit price",
|
||||||
|
"field": "price",
|
||||||
|
"sortable": True,
|
||||||
|
":format": "value => value == null ? '' : `$${value.toFixed(2)}`",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stock",
|
||||||
|
"label": "In stock",
|
||||||
|
"field": "stock",
|
||||||
|
"sortable": True,
|
||||||
|
":format": "value => value == null ? '' : `${value} units`",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
The underlying values stay numeric, so sorting remains numeric. Check `value == null` rather than `if (!value)` when zero is valid; `0` must render as `$0.00` or `0 units`, not as an empty cell. Use a named cell slot instead when the prefix or suffix needs separate styling, an icon, a tooltip, or accessible text that differs from the visible text.
|
||||||
|
|
||||||
|
#### Datetime Text
|
||||||
|
|
||||||
|
Send datetimes as ISO 8601 strings with an explicit offset. Python's [`datetime.isoformat()`](https://docs.python.org/3/library/datetime.html#datetime.datetime.isoformat) includes an offset for aware values, and [`astimezone()`](https://docs.python.org/3/library/datetime.html#datetime.datetime.astimezone) preserves the represented instant while converting zones:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
updated_at = datetime.now(UTC)
|
||||||
|
row = {"updated_at": updated_at.astimezone(UTC).isoformat()}
|
||||||
|
```
|
||||||
|
|
||||||
|
Avoid locale-formatted strings and naive datetime strings as transport values. JavaScript guarantees support for its standard ISO date-time format, but [date-time strings without an offset](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse) are interpreted in the browser's local timezone when they contain both a date and time. An explicit `Z` or `+00:00` identifies the instant unambiguously.
|
||||||
|
|
||||||
|
For repeated cells, construct one [`Intl.DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) and return a formatter closure. NiceGUI evaluates the colon-prefixed expression into the column function, so the formatter is reused instead of performing locale-data lookup for every cell:
|
||||||
|
|
||||||
|
```python
|
||||||
|
columns = [
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"label": "Updated",
|
||||||
|
"field": "updated_at",
|
||||||
|
"sortable": True,
|
||||||
|
":sort": "(left, right) => Date.parse(left) - Date.parse(right)",
|
||||||
|
":format": """(() => {
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
});
|
||||||
|
return value => {
|
||||||
|
if (!value) return '';
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
return Number.isNaN(timestamp) ? 'Invalid date' : formatter.format(timestamp);
|
||||||
|
};
|
||||||
|
})()""",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Choose locale and timezone deliberately:
|
||||||
|
|
||||||
|
- Use a fixed locale such as `en-US` when the product requires one stable language convention; use `undefined` to follow the browser's locale.
|
||||||
|
- Set `timeZone` to `UTC` or an IANA zone such as `America/New_York` for deterministic product behavior; omit it only when each viewer should see browser-local time.
|
||||||
|
- Keep invalid-value handling explicit. `Date.parse()` returns `NaN` for an invalid value, while passing an invalid date directly to the formatter can throw.
|
||||||
|
|
||||||
|
Sorting still receives the raw ISO value from `field`; it never compares the formatted label. The explicit `:sort` function above converts those raw strings to epoch milliseconds, so values with different offsets are ordered by instant while cells keep their localized display text. Validate datetime strings before sending them because an invalid value makes `Date.parse()` return `NaN`. For a simpler data contract, send epoch milliseconds as the field value and format that number directly.
|
||||||
|
|
||||||
|
Lexicographic sorting without a custom `sort` function is chronological only when every ISO string uses the same fixed-width representation and offset, as in normalized UTC values. Local filtering is intentionally different: it matches the formatted text, so searches follow the chosen locale and timezone rather than the raw ISO string.
|
||||||
|
|
||||||
|
### Cell Classes And Styles
|
||||||
|
|
||||||
|
Quasar's tagged [`table-column-selection.js`](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/table-column-selection.js), [`QTh.js`](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/QTh.js), and [`QTd.js`](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/QTd.js) establish the exact targets:
|
||||||
|
|
||||||
|
| Column key | Applied to | Static or dynamic | Typical uses |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `headerClasses` | The generated `<th>` for this column | Static string | Header color, weight, whitespace, sticky positioning, utility-based width |
|
||||||
|
| `classes` | Every generated `<td>` in this column | String or function of `row` | Body typography, whitespace, conditional color, utility-based width |
|
||||||
|
| `headerStyle` | Inline `style` on the generated `<th>` | Static string | Header-specific dimensions or positioning |
|
||||||
|
| `style` | Inline `style` on every generated `<td>` | String or function of `row` | Body dimensions, overflow, or row-dependent presentation |
|
||||||
|
|
||||||
|
`align` is separate: Quasar prepends `text-left`, `text-right`, or `text-center` to both header and body classes. It also appends header state classes such as `sortable`, `sorted`, and `sort-desc`. Supplying `classes` or `headerClasses` does not remove those generated classes.
|
||||||
|
|
||||||
|
These fields interact through normal CSS rules:
|
||||||
|
|
||||||
|
- On the same cell, inline `style` normally wins over a conflicting class declaration. A class rule containing `!important` can beat a normal inline declaration; avoid building a width policy around that exception.
|
||||||
|
- The order of class names in the `class` attribute does not decide precedence. CSS origin, importance, cascade layer, selector specificity, and stylesheet source order do.
|
||||||
|
- `headerClasses` never flows into body cells, and `classes` never flows into the header. The same separation applies to `headerStyle` and `style`.
|
||||||
|
- `column_defaults` is merged as `{**defaults, **column}`. A column-level value replaces the complete default value for that key; class strings are not concatenated. Include shared classes again in an overriding column value when they must be retained.
|
||||||
|
- A named `header-cell-*` or `body-cell-*` slot replaces QTable's default cell renderer. Use `table.header(column_name)` or `table.cell(column_name)` so Quasar reapplies the computed column classes and styles to the resulting `QTh` or `QTd`.
|
||||||
|
|
||||||
|
Prefer classes for reusable visual policy and static utility classes. Prefer `headerStyle` and `style` for one-off values, especially dimensions that do not have a clear project utility. Use `:classes` or `:style` only when the value genuinely depends on the browser-side row; otherwise a static value is easier to inspect and override.
|
||||||
|
|
||||||
|
### Column Width Model
|
||||||
|
|
||||||
|
QTable renders a native table with `width: 100%`, `max-width: 100%`, and, by default, the browser's [`table-layout: auto`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/table-layout) algorithm. One column is a shared track: its header and all body cells receive the same final used width even though each cell can contribute a different width constraint.
|
||||||
|
|
||||||
|
This has several consequences:
|
||||||
|
|
||||||
|
1. A `width` in `headerStyle` and a different `width` in `style` do not override each other because they are declarations on different elements. The browser considers both, along with every cell's min-content and max-content size, and computes one column width.
|
||||||
|
2. Under automatic layout, `width` is a strong sizing input, not a guaranteed cap. Long unbreakable content, cell padding, other columns, and the table's available width can make the column wider.
|
||||||
|
3. `min-width` supplies a floor. `max-width` alone is not a dependable truncation mechanism for an automatic table because intrinsic content still participates in track sizing.
|
||||||
|
4. QTable is `nowrap` by default through `q-table--no-wrap`. The `wrap-cells` prop removes that rule, allowing ordinary wrapping and reducing columns toward their min-content widths. For IDs or URLs, add [`overflow-wrap: anywhere`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/overflow-wrap) to create breaks inside otherwise unbreakable strings.
|
||||||
|
5. Cell padding contributes to the track width. QTable uses `16px` horizontal padding per side normally and `8px` in dense mode, with larger edge padding retained for the first and last columns.
|
||||||
|
|
||||||
|
Use the following mechanisms in order:
|
||||||
|
|
||||||
|
| Goal | Recommended mechanism |
|
||||||
|
| --- | --- |
|
||||||
|
| Let content choose sensible widths | Leave `style` and `headerStyle` unset; keep automatic layout |
|
||||||
|
| Keep a column from becoming too narrow | Put the same absolute `min-width` in `style` and `headerStyle` |
|
||||||
|
| Give columns proportional targets | Put matching percentage `width` values in `style` and `headerStyle`; treat them as targets under automatic layout |
|
||||||
|
| Keep utility or action cells compact | Use `auto-width` on `table.header(...)` and `table.cell(...)` in slots, or Quasar's `q-table--col-auto-width` class on both header and body; it sets `width: 1px` and content supplies the floor |
|
||||||
|
| Allow readable narrow layouts | Enable `wrap-cells`, set a practical `min-width` for key columns, and let QTable's middle container scroll horizontally when the total minimum exceeds the viewport |
|
||||||
|
| Guarantee a column allocation | Use fixed table layout, an explicit table width, matching first-row/header widths, and an explicit overflow policy |
|
||||||
|
| Truncate text | Use a fixed/constrained track plus a block wrapper with Tailwind's `truncate` utility; [`text-overflow`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/text-overflow) does not create overflow by itself |
|
||||||
|
|
||||||
|
For ordinary data tables, automatic layout plus a few minimum or target widths is the idiomatic default. Apply matching values to `headerStyle` and `style`: one declaration is often enough to influence the shared track, but matching declarations make intent explicit, survive empty datasets, and keep custom header/body renderers consistent.
|
||||||
|
|
||||||
|
Use fixed layout only when predictable allocation matters more than content-driven sizing. QTable's `table-style` prop styles the scrolling wrapper `<div>`, not the nested native `<table>`, so it cannot set `table-layout`. This nested element cannot receive a utility class through NiceGUI or QTable's public API, making one application class and one scoped CSS rule necessary. Keep truncation on a Python-created wrapper with Tailwind's `truncate` utility:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ui.add_css("""
|
||||||
|
.inventory-table .q-table {
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
table = ui.table(rows=rows, columns=columns).classes("inventory-table w-full")
|
||||||
|
|
||||||
|
with table.add_slot("body-cell-name"), table.cell("name"):
|
||||||
|
ui.element("span").props(':textContent="props.value"').classes("block truncate")
|
||||||
|
```
|
||||||
|
|
||||||
|
With fixed layout, the table must have a non-automatic width; QTable already gives its native table `width: 100%`, while `w-full` constrains the NiceGUI element. The first row's explicit widths determine tracks without a `<colgroup>`; later body content does not resize them and therefore needs wrapping, clipping, or scrolling. A full custom header changes that first-row contract, so retest every column after introducing one.
|
||||||
|
|
||||||
|
Column visibility has two useful patterns:
|
||||||
|
|
||||||
|
- Mark identity or action columns `required` when they must remain visible.
|
||||||
|
- For a Python-owned column picker, assign the selected column names to `table.props["visible-columns"]`, then call `table.update()`. QTable automatically includes columns marked `required`. Assign the Python list directly to the prop rather than interpolating it into a JavaScript expression.
|
||||||
|
|
||||||
|
## QTable Props
|
||||||
|
|
||||||
|
Pass static QTable props as whitespace-delimited tokens. Prefix a prop with `:` only when its value is a JavaScript expression:
|
||||||
|
|
||||||
|
```python
|
||||||
|
table.props(
|
||||||
|
'flat bordered separator=horizontal wrap-cells '
|
||||||
|
':dense="Quasar.Screen.lt.md" '
|
||||||
|
':rows-per-page-options="[5, 10, 0]"'
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use static props for fixed component policy and dynamic props for small browser-owned presentation decisions such as responsive density. Keep authoritative business state and permission decisions in Python.
|
||||||
|
|
||||||
|
Useful QTable presentation props include:
|
||||||
|
|
||||||
|
| Need | Props |
|
||||||
|
| --- | --- |
|
||||||
|
| Surface | `flat`, `bordered`, `square`, `dark`, `color` |
|
||||||
|
| Cell layout | `dense`, `separator`, `wrap-cells` |
|
||||||
|
| Layers | `hide-header`, `hide-bottom`, `hide-pagination`, `hide-no-data` |
|
||||||
|
| Labels | `no-data-label`, `no-results-label`, `loading-label`, `rows-per-page-label` |
|
||||||
|
| Paging and sorting | `rows-per-page-options`, `binary-state-sort`, `column-sort-order` |
|
||||||
|
| Large local datasets | `virtual-scroll`, `virtual-scroll-item-size`, `virtual-scroll-sticky-size-start` |
|
||||||
|
|
||||||
|
Virtual scrolling needs a bounded height and accurate row-size assumptions. If a full `body` slot renders multiple `QTr` elements for one data row, follow Quasar's `q-virtual-scroll--with-prev` and unique-key requirements. Do not enable virtual scrolling as a default for a small table.
|
||||||
|
|
||||||
|
## Named Slots
|
||||||
|
|
||||||
|
Prefer the smallest QTable slot that owns the customization:
|
||||||
|
|
||||||
|
| Slot | Use |
|
||||||
|
| --- | --- |
|
||||||
|
| `top-left`, `top-right` | Title, filters, column controls, export |
|
||||||
|
| `header-cell-[name]` | One custom header while preserving other generated headers |
|
||||||
|
| `body-cell-[name]` | One custom cell type while preserving generated rows and other cells |
|
||||||
|
| `no-data`, `loading` | Empty, filtered-empty, and busy states |
|
||||||
|
| `pagination` | Custom page controls |
|
||||||
|
| `footer` | A real table footer such as totals |
|
||||||
|
|
||||||
|
Since NiceGUI `3.5.0`, a scoped slot can contain NiceGUI elements. Follow the [Python-owned slot composition](./component-mechanics.md#prefer-python-owned-composition) rule: use context managers and NiceGUI elements for structure, keep application logic in Python, and reserve dynamic props for scoped values that exist only in the browser. Wrap body-cell content in `table.cell(column_name)` so QTable retains the column's alignment and cell semantics:
|
||||||
|
|
||||||
|
```python
|
||||||
|
STATUS_COLORS = {
|
||||||
|
"Ready": "positive",
|
||||||
|
"Low": "warning",
|
||||||
|
"Backorder": "negative",
|
||||||
|
}
|
||||||
|
|
||||||
|
columns = [
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"label": "Status",
|
||||||
|
"field": "status",
|
||||||
|
"colorByValue": STATUS_COLORS,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
with table.add_slot("body-cell-status"), table.cell("status"):
|
||||||
|
ui.badge().props(
|
||||||
|
':label="props.value" '
|
||||||
|
':color="props.col.colorByValue[props.value] ?? \'grey\'"'
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside table slots, `props.value` is the parsed and formatted cell value, `props.row` is the row object, and `props.col` is the column definition. Custom JSON-serializable column keys such as `colorByValue` therefore provide a clean bridge from Python-owned display policy to a reused browser-side slot. Use Quasar color names in the mapping when the component's `color` prop should follow the active theme, and include a fallback for unexpected values.
|
||||||
|
|
||||||
|
A slot template is reused for every matching row, so `props.value`, `props.row`, and `props.col` are JavaScript expressions, not Python variables. Keep authorization and business-state decisions out of this mapping; it is client-visible presentation metadata.
|
||||||
|
|
||||||
|
### Add A Button To A Cell
|
||||||
|
|
||||||
|
Add an action column to the table's column definitions, then target it with the corresponding `body-cell-[name]` slot. Use a NiceGUI `ui.button` rather than writing a raw QBtn template, and wrap it in `table.cell(column_name)` so QTable preserves the cell's alignment, classes, styles, and table semantics.
|
||||||
|
|
||||||
|
The slot's `props.key` is the primitive identity derived from the table's `row_key`. Forward that key to Python instead of sending the full browser-side row object. Resolve the current authoritative row again in the handler because the record may have changed or disappeared since the table was rendered:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from nicegui import events
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
columns = [
|
||||||
|
{"name": "actions", "label": "Actions", "required": True, "align": "center"},
|
||||||
|
{"name": "name", "label": "Product", "field": "name", "align": "left"},
|
||||||
|
]
|
||||||
|
rows = [
|
||||||
|
{"id": 101, "name": "Desk lamp"},
|
||||||
|
{"id": 102, "name": "Task chair"},
|
||||||
|
]
|
||||||
|
|
||||||
|
table = ui.table(rows=rows, columns=columns, row_key="id")
|
||||||
|
|
||||||
|
def open_product(event: events.GenericEventArguments) -> None:
|
||||||
|
product = next((row for row in table.rows if row[table.row_key] == event.args), None)
|
||||||
|
if product is None:
|
||||||
|
ui.notify("Product no longer exists", type="negative")
|
||||||
|
return
|
||||||
|
ui.notify(f"Opening {product['name']}")
|
||||||
|
|
||||||
|
with (
|
||||||
|
table.add_slot("body-cell-actions"),
|
||||||
|
table.cell("actions"),
|
||||||
|
ui.button(icon="open_in_new")
|
||||||
|
.props("round flat size='sm'")
|
||||||
|
.on(
|
||||||
|
"click.stop",
|
||||||
|
handler=open_product,
|
||||||
|
js_handler="() => emit(props.key)",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
ui.tooltip("Open product")
|
||||||
|
```
|
||||||
|
|
||||||
|
The compact icon-only button uses QBtn's `round`, `flat`, and `size` props to avoid a visually heavy filled action in every row. Nest `ui.tooltip` inside the button so each row's cloned QTooltip uses its own parent as the target. Do not call `action_button.tooltip(...)` in a reused scoped slot: NiceGUI implements that convenience method with an element-ID target, and every clone would resolve to the first button. The `.on(...)` bridge is necessary here because `props.key` exists only in the reused browser-side slot scope; a normal Python `on_click` callback cannot capture a different row for each rendered instance. The `click.stop` modifier prevents the button click from also reaching a row-click handler. Treat the key as untrusted input and repeat authorization, existence, and state checks before performing the real action.
|
||||||
|
|
||||||
|
Replacing the full `body` or `header` slot also replaces behavior QTable would otherwise generate. Render `QTr` plus `QTd` or `QTh`, pass the scoped props through, preserve unique row keys, and retest sorting, selection, focus, and responsive behavior. In particular, QTable row-click events are not emitted when a full `body`, `row`, or `item` slot owns the structure.
|
||||||
|
|
||||||
|
## Toolbar Search
|
||||||
|
|
||||||
|
NiceGUI controls can live in QTable toolbar slots. Bind an input's value one way to the table's `filter` property for a small, local dataset:
|
||||||
|
|
||||||
|
```python
|
||||||
|
with table.add_slot("top-right"):
|
||||||
|
ui.input(placeholder="Search inventory").props(
|
||||||
|
"dense outlined clearable debounce=250"
|
||||||
|
).bind_value_to(table, "filter")
|
||||||
|
```
|
||||||
|
|
||||||
|
The path from keystroke to displayed rows is:
|
||||||
|
|
||||||
|
1. `debounce=250` waits until input has been idle for 250 milliseconds, reducing value-change traffic while the user types.
|
||||||
|
2. `bind_value_to(table, "filter")` immediately initializes `table.filter` from the input and then propagates later input values in that direction only.
|
||||||
|
3. For local rows, QTable lowercases the search term and each computed cell value. A row remains when at least one column value contains the term as a substring.
|
||||||
|
4. QTable filters first, sorts the matching rows, resets pagination to page 1 when the filter changes, and then slices the current page.
|
||||||
|
5. When no row matches, the `no-data` slot receives the configured `no-results-label` as `message` and a truthy `filter`; when the underlying row list is empty without a filter, it receives `no-data-label` and a falsy `filter`.
|
||||||
|
|
||||||
|
The default matcher uses each column's resolved `field` and then its `format` function. In this example, searches can therefore match displayed values such as `Hardware`, `Backorder`, or `$31.25`; the row ID is not searchable because it has no column. QTable passes its computed columns to the matcher, so a column excluded with the `visible-columns` prop is not searched. Hiding a column with CSS classes would leave it in the search set and should not be used as a substitute for the component prop.
|
||||||
|
|
||||||
|
This is client-side filtering over rows already sent to the browser. Do not load a large dataset merely to search it locally. When pagination contains `rowsNumber`, QTable switches to server-side mode, stops applying its local matcher, and emits a `request` carrying the filter and pagination state; validate the term and query the authoritative data source in that handler. Add explicit backend search semantics for field scope, tokenization, locale, and ranking instead of assuming they match QTable's substring behavior.
|
||||||
|
|
||||||
|
When Python needs the browser's current result set, prefer `await table.get_filtered_sorted_rows()` for all matches before pagination or `await table.get_computed_rows()` for the current page. These helpers require a connected client and should not replace backend filtering for server-owned data.
|
||||||
|
|
||||||
|
Prefer typed table helpers over raw frontend calls. Use `table.run_method(...)` only for QTable methods without a NiceGUI helper, such as `scrollTo`, `sort`, or `firstPage`, and verify the method in the bundled QTable API first.
|
||||||
|
|
||||||
|
## Runnable Example
|
||||||
|
|
||||||
|
The complete example combines raw-value sorting with cosmetic prefix, suffix, and datetime formatting; column defaults; responsive QTable props; filtering; column visibility; a toolbar; custom status and action cells; and a filtered-empty state. It is available as [`table_customization.py`](../examples/table_customization.py) and as `skill://nicegui/examples/table_customization.py`.
|
||||||
|
|
||||||
|
```python title="table_customization.py"
|
||||||
|
--8<-- "docs/skills/nicegui/examples/table_customization.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it from the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run src/personal_mcp/docs/skills/nicegui/examples/table_customization.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify that stock and price sort by their numeric row values while displaying suffix and prefix text. Verify that updated timestamps sort chronologically by their raw ISO values while displaying localized UTC text. Confirm that searches are case-insensitive, match formatted values across columns, reset to the first page, and change the empty-state message when no rows match. Confirm that optional columns can be hidden and restored, the status cell retains its alignment and badge styling, and each action button reports the product from its own row. Hover action buttons in multiple rows and confirm that each one independently shows exactly one `Open product` tooltip. Resize the browser across mobile, landscape desktop, and portrait desktop widths; the toolbar must remain usable and the table must scroll without overlapping controls.
|
||||||
|
|
||||||
|
## Escalation Boundaries
|
||||||
|
|
||||||
|
- For editable cells, stable row identity, proposal validation, and canonical refresh, use [editable tables](./tables.md).
|
||||||
|
- For generic scoped-slot values, event forwarding, and model events, use [component mechanics](./component-mechanics.md).
|
||||||
|
- For page width, overflow, typography, and static CSS loading, use [page structure, typography, and scaling](./styling-and-customization.md).
|
||||||
|
- For theme colors and dark mode, use [NiceGUI and Quasar color theming](./colors-and-quasar-theming.md).
|
||||||
|
|
||||||
|
## Source Index
|
||||||
|
|
||||||
|
!!! info "Primary documentation"
|
||||||
|
- [NiceGUI table documentation](https://nicegui.io/documentation/table)
|
||||||
|
- [Quasar QTable documentation](https://quasar.dev/vue-components/table)
|
||||||
|
- [Python aware and naive datetimes](https://docs.python.org/3/library/datetime.html#aware-and-naive-objects)
|
||||||
|
- [JavaScript `Date.parse()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse)
|
||||||
|
- [JavaScript `Intl.DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat)
|
||||||
|
- [CSS table layout algorithm](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/table-layout)
|
||||||
|
- [CSS width sizing](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/width)
|
||||||
|
|
||||||
|
!!! info "NiceGUI `3.16.0` implementation"
|
||||||
|
- [`Table` Python source](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/table.py)
|
||||||
|
- [Table client wrapper](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/table.js)
|
||||||
|
- [Frontend dependency manifest](https://github.com/zauberzeug/nicegui/blob/v3.16.0/package.json)
|
||||||
|
|
||||||
|
!!! info "Bundled Quasar `2.18.5` implementation"
|
||||||
|
- [QTable API definition](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/QTable.json)
|
||||||
|
- [QTable implementation](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/QTable.js)
|
||||||
|
- [QTable column computation](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/table-column-selection.js)
|
||||||
|
- [QTable styles](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/QTable.sass)
|
||||||
|
|
||||||
|
## Completion Check
|
||||||
|
|
||||||
|
Before accepting a customized table:
|
||||||
|
|
||||||
|
1. Verify the target NiceGUI release and its bundled Quasar version.
|
||||||
|
2. Set a primitive, immutable, unique `row_key` before using selection or row-scoped actions.
|
||||||
|
3. Use constructor arguments and column definitions before QTable props or slots.
|
||||||
|
4. Keep sortable values canonical and serializable; use `format` only for cosmetic text and `sort` only when raw values need a custom comparator.
|
||||||
|
5. Choose the narrowest named slot and preserve QTable cell or header semantics.
|
||||||
|
6. Recheck sorting, filtering, pagination, selection, and empty states after customization.
|
||||||
|
7. Check mobile, landscape desktop, and portrait desktop layouts; verify the toolbar remains usable and wide tables scroll without overlapping controls.
|
||||||
@@ -57,6 +57,8 @@ The complete runnable source is available as [`editable_table.py`](../examples/e
|
|||||||
|
|
||||||
The editor path uses the transformed-event pattern from [controlled values and model events](./component-mechanics.md#controlled-values-and-model-events). Attach the listener directly to each cell editor because Vue component events do not bubble from the editor to the cell or table. Read the QTable cell value from `props.value`, emit `props.row.<row_key>`, `props.col.name`, and the proposed value, then resolve the row in Python. NiceGUI's text-input wrapper uses `value` and `update:value`, while the number and select editors use `model-value` and `update:model-value`. Remove the text input's static `value` prop before adding its scoped `:value` binding. The select editor emits a NiceGUI-normalized option object, so this example forwards `option.label`, which is also the canonical value in `STATUS_OPTIONS`.
|
The editor path uses the transformed-event pattern from [controlled values and model events](./component-mechanics.md#controlled-values-and-model-events). Attach the listener directly to each cell editor because Vue component events do not bubble from the editor to the cell or table. Read the QTable cell value from `props.value`, emit `props.row.<row_key>`, `props.col.name`, and the proposed value, then resolve the row in Python. NiceGUI's text-input wrapper uses `value` and `update:value`, while the number and select editors use `model-value` and `update:model-value`. Remove the text input's static `value` prop before adding its scoped `:value` binding. The select editor emits a NiceGUI-normalized option object, so this example forwards `option.label`, which is also the canonical value in `STATUS_OPTIONS`.
|
||||||
|
|
||||||
|
When a row needs an explicit save/cancel workflow, add an actions cell (for example `body-cell-actions`) that emits only the immutable row key and opens one reusable `ui.dialog`. Keep dialog controls as local draft state rather than binding directly to the authoritative row model. On **Save**, re-resolve the row by key, validate and normalize every proposed field in Python (for example with a small Pydantic draft model), and then commit all assignments together so partial validation failure cannot leave mixed old/new values. On **Cancel** or dialog dismiss, close the dialog without mutating authoritative state. This keeps the table in named cell slots and avoids the full-row templating boundary required by `QPopupEdit`.
|
||||||
|
|
||||||
## Persistence And Row Refresh
|
## Persistence And Row Refresh
|
||||||
|
|
||||||
Keep `table.rows` as the serializable projection described in [bindable dataclasses](./binding-dataclasses.md#persistence-and-rollback), not the business model. After every accepted or rejected proposal, call `table.update_rows(state.table_rows(), clear_selection=False)` so the canonical projection replaces any temporary editor display. Preserve selection only while the selected row identities remain valid; otherwise use the default `clear_selection=True`.
|
Keep `table.rows` as the serializable projection described in [bindable dataclasses](./binding-dataclasses.md#persistence-and-rollback), not the business model. After every accepted or rejected proposal, call `table.update_rows(state.table_rows(), clear_selection=False)` so the canonical projection replaces any temporary editor display. Preserve selection only while the selected row identities remain valid; otherwise use the default `clear_selection=True`.
|
||||||
|
|||||||
+263
-26
@@ -1,39 +1,276 @@
|
|||||||
# Troubleshooting and Quality Gates
|
# NiceGUI Troubleshooting And Quality Evidence
|
||||||
|
|
||||||
## Troubleshooting
|
Use this reference to identify the layer that owns a NiceGUI failure and the evidence needed to distinguish similar symptoms. It covers behavior verified against NiceGUI `3.16.0`; browser, Quasar, Vue, FastAPI, Socket.IO, Uvicorn, and proxy behavior must also be checked against the versions deployed by the target application.
|
||||||
|
|
||||||
### Upload Errors
|
The companion [interaction mechanics](./interaction-patterns.md) page defines normal lifecycle, validation, upload, refresh, timer, task, and transport behavior. [Component mechanics](./component-mechanics.md) covers Quasar props, events, slots, wrapper models, and frontend payload mapping. [FastAPI and Uvicorn startup](./fastapi-uvicorn-startup.md) covers import identity, workers, reload, and deployment topology.
|
||||||
|
|
||||||
- Validate extension and size before storage.
|
## Diagnostic Index
|
||||||
- Catch expected exceptions and return negative notifications.
|
|
||||||
- Log unexpected exceptions with request context.
|
|
||||||
|
|
||||||
### UI Race Conditions
|
| Symptom | Likely owner | Discriminating evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| upload rejected before handler runs | Quasar `QUploader` or browser selection rules | `on_rejected` fires; no upload POST reaches the server |
|
||||||
|
| upload request returns `400` or `413` | proxy, ASGI multipart parsing, NiceGUI upload route, or application validation | HTTP status and response body from the upload request; proxy and server logs |
|
||||||
|
| page shows a response-timeout error | async page construction before client connection | warning naming `response_timeout`; page builder timing and `connected()` boundary |
|
||||||
|
| update appears only after reload | wrong client context, unobserved plain mutation, or missing explicit update | target `Client`, element deletion state, outbox traffic, and wrapper update call |
|
||||||
|
| update reaches one tab but not another | private page element tree or process-local fan-out | client IDs, page paths, worker identity, and `app.clients(path)` iteration |
|
||||||
|
| updates disappear after a brief network interruption | client deleted after reconnect timeout or outbox replay unavailable | disconnect/delete timestamps, reconnect timeout, next message ID, and reload log |
|
||||||
|
| old query result replaces a newer one | concurrent async completion race | request generation, start/end timestamps, query identity, and publish order |
|
||||||
|
| all clients pause during one action | blocking work on the event loop | event-loop lag and stack or profile showing synchronous I/O or CPU work |
|
||||||
|
| callback runs repeatedly after navigation | duplicate timer, event subscription, or lifecycle registration | registration count, client IDs, delete handlers, and task names |
|
||||||
|
| user state leaks across tabs or users | incorrect storage scope or module-level mutable state | storage scope, session ID, tab ID, process ID, and object identity |
|
||||||
|
| URL changes but content or state does not | History API used without a route/content transition | `pushState`/`replaceState` call versus `ui.navigate.to` or sub-page routing |
|
||||||
|
| changed CSS or image remains stale | static cache lifetime or proxy/browser cache | response URL, `Cache-Control`, cache source in developer tools, and content version |
|
||||||
|
| exception is logged but no page feedback appears | exception occurred outside an active UI slot or after the client was deleted | exception handler invoked, current client/slot, task owner, and element state |
|
||||||
|
|
||||||
- Disable triggering controls during async work.
|
Start with the smallest boundary that can explain the symptom. Browser developer tools establish whether an event, upload, static request, or socket message crossed the network. Server logs establish whether the page, client, handler, task, or service received it. Durable data inspection establishes whether the accepted operation committed independently of the UI.
|
||||||
- Remove duplicate timers and listeners targeting the same state.
|
|
||||||
- Ensure service call ordering is deterministic before render updates.
|
|
||||||
|
|
||||||
### Asset Caching
|
## Upload Failures
|
||||||
|
|
||||||
- Confirm static mount and proxy rewrite correctness.
|
[`ui.upload`](https://nicegui.io/documentation/upload) is a Quasar uploader backed by an element-specific NiceGUI POST route. The tagged [`Upload` implementation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/upload.py) resolves the `client_id` and element ID from the route before converting each Starlette upload to `event.file`.
|
||||||
- Add cache-busting query strings for changed assets.
|
|
||||||
- Avoid per-page CSS injection.
|
|
||||||
|
|
||||||
### Navigation and State Drift
|
### Rejected Before Transfer
|
||||||
|
|
||||||
- Avoid global mutable UI state.
|
`max_file_size`, `max_total_size`, `max_files`, and the Quasar `accept` prop operate in the browser. A rejection at this stage calls `on_rejected`; it does not prove that the server would reject an equivalent direct request. An unexpected rejection commonly comes from MIME patterns, file-count state retained in the uploader queue, or size units that differ from the intended policy.
|
||||||
- Keep state request-scoped or service-managed.
|
|
||||||
- Rehydrate page data during route load.
|
|
||||||
|
|
||||||
## Production Readiness Gate
|
Useful evidence includes the selected file's browser-reported type and size, current queue contents, configured Quasar props, and whether `on_begin_upload` or a network request occurs. Reset the uploader queue only when clearing previous selections is the intended product behavior.
|
||||||
|
|
||||||
Pass all checks before shipping:
|
### Transfer Or Multipart Failure
|
||||||
|
|
||||||
- Structure: one-way dependencies between pages, components, and services.
|
If the POST begins but the upload handler does not run, inspect the HTTP status before changing page code:
|
||||||
- Responsiveness: UI validated at both small and large viewport widths.
|
|
||||||
- Accessibility: labels and actions are clear and readable.
|
|
||||||
- Reliability: validation and exception paths surface user feedback.
|
|
||||||
- Maintainability: repeated UI patterns are extracted; business logic remains in services.
|
|
||||||
|
|
||||||
If any check fails, return to the workflow step that owns that concern.
|
| Response | Common boundary |
|
||||||
|
| --- | --- |
|
||||||
|
| `404` | stale or deleted element/client route, incorrect proxy prefix, or navigation during transfer |
|
||||||
|
| `400` | malformed multipart body, missing `client_id`, missing element ID, or no matching uploader element |
|
||||||
|
| `413` | reverse-proxy or ASGI request-size limit |
|
||||||
|
| `422` | route or dependency validation outside the normal NiceGUI upload route |
|
||||||
|
| `5xx` | multipart conversion, temporary storage, application handler, or downstream service failure |
|
||||||
|
|
||||||
|
The tagged [`FileUpload` conversion](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/upload_files.py) keeps small uploads in memory and spills larger ones to a temporary file after Starlette's `MultiPartParser.spool_max_size`. This is a buffering threshold, not an acceptance limit. Concurrency multiplies memory and temporary-disk pressure, so record file size, concurrent upload count, process memory, temporary filesystem capacity, and proxy limits together.
|
||||||
|
|
||||||
|
### Accepted But Unsafe Or Corrupt
|
||||||
|
|
||||||
|
`file.name` is reduced to its basename by NiceGUI, and `file.content_type` comes from the request. Neither establishes safe content. Server-side acceptance should record the authoritative byte size and verify content signature, parser behavior, quota, authorization, and application-selected destination. Use an independently generated storage key and keep active user content off the main application origin.
|
||||||
|
|
||||||
|
When downstream parsing fails, distinguish transport completion from domain acceptance. A successful upload POST can still produce a rejected document. Preserve an operation ID or storage record so logs and user feedback identify the same attempt without logging file content or sensitive form fields.
|
||||||
|
|
||||||
|
## Initial Page Response Failures
|
||||||
|
|
||||||
|
The tagged [`page` wrapper](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/page.py) gives async page construction `response_timeout` seconds, three by default, to finish or signal that it is waiting for the client connection. If neither happens, NiceGUI cancels the page task, deletes that client, logs a warning, and serves a terminal `500` page through a fresh client.
|
||||||
|
|
||||||
|
Increasing `response_timeout` can be appropriate for bounded, unavoidable initial construction, but it does not make long I/O responsive. The relevant timing split is:
|
||||||
|
|
||||||
|
- code before `await ui.context.client.connected()` delays the initial HTTP response
|
||||||
|
- code after that await runs with a connected browser and can progressively update the page
|
||||||
|
- synchronous blocking work in either phase can still stall the event loop
|
||||||
|
|
||||||
|
Capture elapsed time around dependencies, database calls, remote clients, serialization, and component construction. A timeout with low service latency may indicate a page builder waiting on a condition that itself requires the browser connection.
|
||||||
|
|
||||||
|
Synchronous page-builder exceptions and async exceptions raised before the response is built can render an `app.on_page_exception` page. That handler is synchronous in NiceGUI 3.16. A returned FastAPI `Response` bypasses normal page rendering. Do not assume a global `app.on_exception` handler can reconstruct a failed initial element tree.
|
||||||
|
|
||||||
|
## Connection, Reconnect, And Deleted Clients
|
||||||
|
|
||||||
|
The tagged [`Client` lifecycle](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/client.py) separates a socket disconnect from deletion. On disconnect, NiceGUI invokes disconnect handlers and waits for `reconnect_timeout`; a successful handshake cancels deletion. If no connection returns, NiceGUI closes tab storage as needed, invokes delete handlers, removes elements and bindings, stops the outbox, and removes the client from `Client.instances`.
|
||||||
|
|
||||||
|
### Stale Client Writes
|
||||||
|
|
||||||
|
Holding an element, slot, timer, or client in a long-lived object can outlive the page that created it. Writes after deletion trigger NiceGUI's deleted-client warning and cannot produce a valid browser update. Before publishing detached work, retain the intended client deliberately and check `client.is_deleted` or membership in the current client set. A durable job result should be written to durable state even when its original page no longer exists; a later page load can rehydrate it.
|
||||||
|
|
||||||
|
Do not treat `on_disconnect` as final resource disposal. It also runs for reconnectable interruptions. Page-owned cleanup belongs in `on_delete`; transport telemetry and reversible status belong in `on_disconnect` and `on_connect`.
|
||||||
|
|
||||||
|
### Reconnect Replay And Reload
|
||||||
|
|
||||||
|
The tagged [`Outbox`](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/outbox.py) retains recent element updates and messages. During handshake, the browser provides the next message ID it expects. NiceGUI rewinds retained history and replays from that ID. If the ID is no longer available because of age or `message_history_length`, NiceGUI reloads the page.
|
||||||
|
|
||||||
|
This mechanism explains several superficially similar outcomes:
|
||||||
|
|
||||||
|
| Outcome | Interpretation |
|
||||||
|
| --- | --- |
|
||||||
|
| short interruption, state continues | client survived and required messages remained in history |
|
||||||
|
| interruption followed by reload | rewind target was unavailable or browser initiated a reload |
|
||||||
|
| interruption followed by fresh page state | original client was deleted and route rebuilt a new element tree |
|
||||||
|
| durable operation duplicated after reconnect | application command lacked idempotency; outbox replay is not a transaction protocol |
|
||||||
|
|
||||||
|
Correlate client ID, document or tab identity, message IDs, disconnect duration, reconnect timeout, and process ID. A load-balanced multi-worker deployment also needs compatible session affinity and shared application state; an in-memory client exists only in the worker that created it.
|
||||||
|
|
||||||
|
## Missing Or Misrouted Updates
|
||||||
|
|
||||||
|
Each page client owns a private element tree. Mutating an element affects that element's client; mutating a plain list or model that has no active binding does not enqueue a browser update by itself. Check these in the owning layer:
|
||||||
|
|
||||||
|
- the element has not been deleted or replaced by a refresh
|
||||||
|
- the handler runs in the intended client's slot context
|
||||||
|
- the wrapper property is bindable or followed by its documented helper or `update()`
|
||||||
|
- the refreshable target belongs to the intended client
|
||||||
|
- application-wide producers iterate the intended `app.clients(path)` and enter each client context
|
||||||
|
- process-local events are not assumed to reach clients connected to another worker
|
||||||
|
|
||||||
|
A module-level `@ui.refreshable` can accumulate targets from several clients. Its tagged [`refresh()` implementation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/functions/refreshable.py) clears and rebuilds every matching surviving target. Unexpected cross-client refresh therefore indicates target scope, not shared DOM. Unexpectedly missing refresh often indicates that the target container was deleted or the code refreshed a different decorated instance.
|
||||||
|
|
||||||
|
## Async Races And Duplicate Actions
|
||||||
|
|
||||||
|
NiceGUI event handlers that return awaitables are scheduled as background tasks. Disabling a button reduces normal repeated clicks but does not serialize direct requests, reconnect replays, keyboard submission, another control, or another client.
|
||||||
|
|
||||||
|
### Completion-Order Races
|
||||||
|
|
||||||
|
For replaceable reads such as search, an older request can finish after a newer request and overwrite its result. Record a generation or request key when work starts and compare it immediately before publishing. Cancellation can reduce wasted work but is not sufficient when the underlying thread, remote service, or database operation cannot be canceled.
|
||||||
|
|
||||||
|
For writes, define the service-level policy explicitly: lock, optimistic entity version, idempotency key, conflict response, or accepted duplicate semantics. UI busy state is feedback, not concurrency control.
|
||||||
|
|
||||||
|
### Refresh Races
|
||||||
|
|
||||||
|
Each refreshable invocation owns a target container. Refresh clears that target before recreating children; concurrent refreshes can therefore interleave service reads and rendering. Await a refresh when the triggering action depends on completion, serialize refreshes for one target, or use a latest-generation policy for replaceable data. Keep long-lived loading and error indicators outside the cleared target if they must remain stable.
|
||||||
|
|
||||||
|
### Observable Evidence
|
||||||
|
|
||||||
|
For an asynchronous interaction, logs are most useful when they contain operation ID, client ID, user or tenant identifier where safe, entity ID, request generation, start and finish time, outcome, and exception type. Avoid recording secrets, raw uploaded content, session cookies, or full form payloads.
|
||||||
|
|
||||||
|
## Blocking Work And Event-Loop Lag
|
||||||
|
|
||||||
|
An `async def` callback does not make synchronous work non-blocking. CPU-heavy loops, synchronous HTTP clients, filesystem calls, image or document parsers, and blocking database drivers executed on the event loop delay socket heartbeats, all clients' event handlers, timers, page responses, and outbox delivery.
|
||||||
|
|
||||||
|
Use the execution boundary defined in [interaction mechanics](./interaction-patterns.md#execution-contexts): non-blocking async APIs in the event loop, `run.io_bound()` for blocking I/O, `run.cpu_bound()` for serializable CPU work, or an external worker for durable jobs. A thread keeps the loop responsive but does not remove memory, timeout, thread-safety, or cancellation constraints. A process pool adds serialization and process-start constraints.
|
||||||
|
|
||||||
|
Evidence for event-loop blocking includes simultaneous latency across unrelated clients, delayed timers or Socket.IO heartbeats, event-loop lag metrics, and a stack or profile inside synchronous work. A single slow awaited network request that yields control does not by itself block other clients.
|
||||||
|
|
||||||
|
## Timer, Listener, And Task Duplication
|
||||||
|
|
||||||
|
Repeated callbacks usually originate at registration, not dispatch. Common ownership mistakes include:
|
||||||
|
|
||||||
|
- creating `ui.timer` repeatedly during a refresh while retaining the old timer outside the cleared container
|
||||||
|
- registering an application timer or lifecycle handler during a per-client page build
|
||||||
|
- subscribing a long-lived `Event` outside a UI context without later unsubscribing
|
||||||
|
- starting a new consumer task on every reconnect instead of once at application startup
|
||||||
|
- reloading a development process while an external scheduler still targets both old and new instances
|
||||||
|
|
||||||
|
In NiceGUI 3.16, a page-scoped `ui.timer` waits for its client connection and is canceled when its element is deleted. An `app.timer` is process-scoped. An `Event` subscription made inside a UI context is automatically removed on client deletion by default; one made outside UI context has no automatic client owner. Application lifecycle handlers and external broker consumers need an application-level owner and shutdown path.
|
||||||
|
|
||||||
|
Record timer or task name, registration site, process ID, client ID when applicable, activation state, and cancellation reason. Count registrations directly rather than inferring duplication from repeated business effects, which could also come from retries or multiple workers.
|
||||||
|
|
||||||
|
## Storage And Navigation Drift
|
||||||
|
|
||||||
|
State drift often comes from assigning data to a scope with the wrong lifetime:
|
||||||
|
|
||||||
|
| Unexpected behavior | Scope to inspect |
|
||||||
|
| --- | --- |
|
||||||
|
| state disappears on reload or route navigation | `app.storage.client` or page-local Python object |
|
||||||
|
| state unexpectedly follows another tab | `app.storage.user`, `browser`, or module-global state |
|
||||||
|
| state is missing immediately after page construction | `app.storage.tab` accessed before `client.connected()` |
|
||||||
|
| state differs between workers | local file storage, in-memory tab state, or module-global state |
|
||||||
|
| browser storage mutation raises or is ignored | `app.storage.browser` changed after response construction |
|
||||||
|
|
||||||
|
The tagged [`storage` implementation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/storage.py) persists `user` and `general` scopes locally by default or in Redis when configured. Tab storage is in-memory unless Redis is configured. The signed browser cookie identifies a user storage record; storage scope is not authorization, and persisted identifiers must still be checked against the authenticated principal and tenant.
|
||||||
|
|
||||||
|
[`ui.navigate.to`](https://nicegui.io/documentation/navigate) opens a route, client element anchor, or external URL. With `ui.sub_pages`, a relative same-app route can be handled within the current client. `ui.navigate.history.push()` and `.replace()` only change browser history state and the visible URL; they do not invoke a page builder or rehydrate content. A URL/content mismatch after `pushState` is therefore expected unless application code also owns the content transition.
|
||||||
|
|
||||||
|
A full navigation or reload creates a new page client, so page-local objects and client storage are not durable navigation state. Encode shareable state in route or query parameters, place tab- or user-lifetime state in the matching storage scope, and reload authoritative data from services rather than retaining element instances globally.
|
||||||
|
|
||||||
|
## Static Assets, Media, And Cache Boundaries
|
||||||
|
|
||||||
|
The tagged [`Client.build_response()`](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/client.py) marks NiceGUI page and Markdown responses `Cache-Control: no-store`. A proxy that caches page HTML against that header can serve stale client IDs, initial state, or user-specific content and is misconfigured.
|
||||||
|
|
||||||
|
Static files intentionally use a different policy. In NiceGUI 3.16:
|
||||||
|
|
||||||
|
- `app.add_static_files()` and `app.add_static_file()` default to `Cache-Control: public, max-age=3600`
|
||||||
|
- `max_cache_age=0` requests immediate revalidation behavior but does not create a private authorization boundary
|
||||||
|
- media routes support byte-range streaming and should be used for seekable audio or video
|
||||||
|
- static and media directory helpers explicitly expose their contents without per-file application authorization
|
||||||
|
- `single_use=True` removes a route after the first handled request in one process; it is not a secure, distributed, or retry-safe download grant
|
||||||
|
|
||||||
|
The implementation is defined by [`app.add_static_*` and `app.add_media_*`](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/app/app.py) and [`CacheControlledStaticFiles`](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/staticfiles.py).
|
||||||
|
|
||||||
|
For stale assets, inspect the actual response in browser developer tools: final URL after proxy rewriting, status, `Cache-Control`, `ETag` or modification metadata, service-worker involvement, and whether the response came from memory, disk, intermediary, or origin. Prefer content-versioned URLs for immutable assets. Query-string cache busting works only when every cache key includes the query and the origin serves the updated bytes.
|
||||||
|
|
||||||
|
Security-sensitive files belong behind an authenticated FastAPI route or object-store authorization mechanism with private cache policy. A hard-to-guess static URL is not access control, and public cache headers can retain content beyond logout or permission changes.
|
||||||
|
|
||||||
|
## Exception Surfaces
|
||||||
|
|
||||||
|
NiceGUI exceptions have different user-feedback capabilities according to where they occur:
|
||||||
|
|
||||||
|
| Failure surface | Handler path | UI context available |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| page builder before response | `app.on_page_exception`, FastAPI handlers, then global exception handlers | fresh error-page client for synchronous page handler |
|
||||||
|
| UI event or awaited callback in an element slot | client in-page exception handlers plus global handlers | originating slot while client remains alive |
|
||||||
|
| timer or NiceGUI background task | global handler; in-page handler only when task retained an active slot context | depends on captured context and client lifetime |
|
||||||
|
| `Event.emit()` subscriber | exception forwarded to global handling | subscriber's captured slot when available |
|
||||||
|
| `Event.call()` subscriber | exception propagates to caller | caller decides feedback and transaction behavior |
|
||||||
|
| FastAPI route outside NiceGUI page UI | FastAPI exception handling | no implicit NiceGUI element context |
|
||||||
|
|
||||||
|
The tagged [`app.handle_exception()`](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/app/app.py) first invokes a client's in-page exception handling when a client and slot are active, then invokes global exception handlers. Unexpected exceptions should retain a traceback and correlation ID in server logs. User feedback should be specific for expected domain failures and generic for unexpected failures, without exposing internals.
|
||||||
|
|
||||||
|
An exception notification is not recovery by itself. Restore busy state in `finally`, preserve user input after a recoverable failure, reconcile uncertain write outcomes from the authoritative store, and stop publishing to deleted clients.
|
||||||
|
|
||||||
|
## Security-Sensitive Boundaries
|
||||||
|
|
||||||
|
The following client-visible mechanisms improve usability but do not enforce policy:
|
||||||
|
|
||||||
|
- disabled or hidden controls
|
||||||
|
- Quasar input rules and upload restrictions
|
||||||
|
- route names, element IDs, client IDs, or unguessable-looking static paths
|
||||||
|
- values retained in page, tab, browser, or user storage
|
||||||
|
- custom JavaScript validation or transformed event payloads
|
||||||
|
|
||||||
|
Authorization, tenant boundaries, accepted fields, type and range checks, optimistic concurrency, upload inspection, and durable write constraints belong on the server. For every mutation, identify the authenticated principal independently of browser-submitted ownership fields.
|
||||||
|
|
||||||
|
Do not interpolate untrusted values into raw `ui.html`, Vue templates, JavaScript, or style content. Use wrapper text/value APIs and structured serialization. Review proxy trust, forwarded-prefix configuration, cookies, origin exposure, and WebSocket policy as deployment inputs rather than component styling concerns.
|
||||||
|
|
||||||
|
## Quality Evidence Matrix
|
||||||
|
|
||||||
|
A quality gate is satisfied by observable evidence, not by the presence of a pattern in source code.
|
||||||
|
|
||||||
|
| Concern | Required evidence |
|
||||||
|
| --- | --- |
|
||||||
|
| startup and import identity | application starts through the production entry point; reload and worker behavior match deployment; no duplicate module import paths |
|
||||||
|
| initial page response | representative pages stay within their response budget or intentionally cross `client.connected()` before long work |
|
||||||
|
| interaction correctness | primary actions, keyboard submission, validation failure, retry, duplicate action, and cancellation produce deterministic state |
|
||||||
|
| client lifecycle | disconnect/reconnect within the configured window preserves valid behavior; deletion releases page-owned resources |
|
||||||
|
| concurrency | stale reads cannot overwrite newer intent; writes have a documented conflict or idempotency policy |
|
||||||
|
| blocking behavior | concurrent-client check shows one slow action does not stall unrelated page events; profiles contain no unexpected event-loop blocking |
|
||||||
|
| storage isolation | reload, navigation, second-tab, second-user, process-restart, and multi-worker checks match each selected storage scope |
|
||||||
|
| uploads | browser rejection, direct server-side rejection, oversized request, invalid content, storage failure, and successful streaming path are distinguished |
|
||||||
|
| exception behavior | expected domain failures remain actionable; unexpected failures log tracebacks and correlation IDs; controls recover from busy state |
|
||||||
|
| cache behavior | page responses are `no-store`; public assets use deliberate versioning and lifetime; protected content is not exposed through public static routes |
|
||||||
|
| responsive layout | narrow mobile, intermediate, and wide desktop viewports show no clipping, overlap, inaccessible popup content, or layout shift from dynamic labels |
|
||||||
|
| accessibility | keyboard order, focus return, accessible names, validation association, contrast, reduced-motion behavior, and dialog/menu escape behavior are verified |
|
||||||
|
| observability | logs identify operation, client/process, route or entity, timing, and outcome without secrets or sensitive payloads |
|
||||||
|
| shutdown | timers, consumers, process/thread work, persistent storage, and external clients have deliberate cancellation or close behavior |
|
||||||
|
|
||||||
|
## Testing Surfaces
|
||||||
|
|
||||||
|
NiceGUI's [pytest integration](https://nicegui.io/documentation/section_testing) provides two complementary fixtures:
|
||||||
|
|
||||||
|
- `User` simulates interactions in Python and is the fast default for page content, component values, clicks, typing, event dispatch, navigation, and service-backed acceptance behavior.
|
||||||
|
- `Screen` drives a real headless browser and is reserved for behavior that depends on browser layout, JavaScript, actual uploads/downloads, focus, WebSockets, rendering, or client-side Quasar behavior.
|
||||||
|
|
||||||
|
Use lower-level tests for services, validation, authorization, idempotency, storage adapters, and task logic without constructing UI. Use `User` tests for application interaction contracts. Use a small set of `Screen` tests for the browser boundary, and supplement responsive or visual claims with screenshots and computed layout checks at explicit viewport sizes.
|
||||||
|
|
||||||
|
Tests should control async completion by observable state, events, or bounded timeouts rather than arbitrary sleeps. Reconnect, multi-tab, multi-user, and multi-worker behavior need dedicated environments because a single simulated client cannot establish those isolation claims.
|
||||||
|
|
||||||
|
## Source Index
|
||||||
|
|
||||||
|
!!! info "NiceGUI public documentation"
|
||||||
|
- [Pages, response timeout, connection, and multicasting](https://nicegui.io/documentation/page)
|
||||||
|
- [Error handling and execution](https://nicegui.io/documentation/section_action_events)
|
||||||
|
- [Uploads](https://nicegui.io/documentation/upload)
|
||||||
|
- [Storage](https://nicegui.io/documentation/storage)
|
||||||
|
- [Navigation](https://nicegui.io/documentation/navigate)
|
||||||
|
- [Testing](https://nicegui.io/documentation/section_testing)
|
||||||
|
- [Security guidance](https://nicegui.io/documentation/section_security)
|
||||||
|
|
||||||
|
!!! info "NiceGUI `3.16.0` implementation"
|
||||||
|
- [Page response lifecycle](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/page.py)
|
||||||
|
- [Client connection and deletion](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/client.py)
|
||||||
|
- [Outbox replay](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/outbox.py)
|
||||||
|
- [Upload route](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/upload.py)
|
||||||
|
- [Uploaded-file buffering](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/upload_files.py)
|
||||||
|
- [Refreshable targets](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/functions/refreshable.py)
|
||||||
|
- [Timers](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/timer.py)
|
||||||
|
- [Storage scopes](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/storage.py)
|
||||||
|
- [Navigation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/functions/navigate.py)
|
||||||
|
- [Exception and static/media handling](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/app/app.py)
|
||||||
|
- [Static cache headers](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/staticfiles.py)
|
||||||
|
|
||||||
|
!!! info "Related platform references"
|
||||||
|
- [FastAPI WebSockets](https://fastapi.tiangolo.com/advanced/websockets/)
|
||||||
|
- [FastAPI server-sent events](https://fastapi.tiangolo.com/tutorial/server-sent-events/)
|
||||||
|
- [Starlette static files](https://www.starlette.io/staticfiles/)
|
||||||
|
- [Uvicorn deployment](https://www.uvicorn.org/deployment/)
|
||||||
@@ -4,96 +4,44 @@ icon: lucide/flask-conical
|
|||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
|
|
||||||
This page describes the current test layout and execution model for this repository.
|
The test suite checks that the same public MCP behavior works over HTTP and stdio.
|
||||||
|
|
||||||
Primary guidance sources:
|
|
||||||
- [Pytest scaffolding skill](./skills/pytesting/SKILL.md)
|
|
||||||
- [Pytest docs reference](./skills/pytesting/references/pytest-docs.md)
|
|
||||||
- [FastAPI + uv + Docker skill](./skills/fastapi-uv-docker/SKILL.md)
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
1. Keep local feedback fast with deterministic tests.
|
|
||||||
2. Mirror source modules with focused test groups.
|
|
||||||
3. Keep endpoint and MCP surface checks explicit.
|
|
||||||
4. Make marker usage strict and intentional.
|
|
||||||
|
|
||||||
## Current Test Layout
|
## Current Test Layout
|
||||||
|
|
||||||
Current tree:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
tests/
|
tests/
|
||||||
__init__.py
|
|
||||||
conftest.py
|
conftest.py
|
||||||
registry/
|
server_contract.py
|
||||||
test_read.py
|
test_http.py
|
||||||
ingest/
|
test_stdio.py
|
||||||
test_current_docs.py
|
|
||||||
test_document.py
|
|
||||||
models/
|
|
||||||
test_document_validation.py
|
|
||||||
prompts/
|
|
||||||
test_content_renderer.py
|
|
||||||
test_filesystem_provider.py
|
|
||||||
skills/
|
|
||||||
test_provider.py
|
|
||||||
web/
|
|
||||||
conftest.py
|
|
||||||
test_endpoint_connections.py
|
|
||||||
test_mcp_prompts.py
|
|
||||||
test_mcp_skills.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Source-to-test alignment today:
|
`server_contract.py` contains the shared expectations. Both transport tests verify the resources, prompts, fallback tools, and representative reads against that contract.
|
||||||
- `src/personal_mcp/registry/ingest/` -> `tests/registry/ingest/`
|
|
||||||
- `src/personal_mcp/registry/models/` -> `tests/registry/models/`
|
|
||||||
- `src/personal_mcp/prompts/` -> `tests/prompts/`
|
|
||||||
- `src/personal_mcp/skills/provider.py` -> `tests/skills/test_provider.py`
|
|
||||||
- `src/personal_mcp/web/` and MCP HTTP surface -> `tests/web/`
|
|
||||||
|
|
||||||
## Markers And Strictness
|
## Run Tests
|
||||||
|
|
||||||
Configured markers in `pyproject.toml`:
|
Run the full suite with:
|
||||||
- `unit`: fast deterministic tests with no external dependencies
|
|
||||||
- `integration`: framework or component integration tests
|
|
||||||
- `smoke`: thin critical-path checks
|
|
||||||
|
|
||||||
Pytest runs with `--strict-markers`, so any unregistered marker fails the test run.
|
|
||||||
|
|
||||||
## Fixture Layering
|
|
||||||
|
|
||||||
Fixture placement follows test scope:
|
|
||||||
1. `tests/conftest.py` for cross-suite defaults.
|
|
||||||
2. `tests/web/conftest.py` for web and endpoint client setup.
|
|
||||||
|
|
||||||
Prefer adding fixtures at the narrowest scope that serves more than one test.
|
|
||||||
|
|
||||||
## Command Baseline
|
|
||||||
|
|
||||||
Canonical invocation:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run pytest
|
uv run pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
Useful filtered runs:
|
Run one transport while working on a focused change:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run pytest --collect-only -q
|
uv run pytest tests/test_http.py -q
|
||||||
uv run pytest -m unit -q
|
uv run pytest tests/test_stdio.py -q
|
||||||
uv run pytest -m integration -q
|
|
||||||
uv run pytest -m smoke -q
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Adding New Tests
|
The repository uses strict pytest markers. Register any new marker in `pyproject.toml` before using it.
|
||||||
|
|
||||||
When adding coverage:
|
## Full Validation
|
||||||
1. Place tests under the nearest existing module subtree (`prompts/`, `registry/`, `skills/`, or `web/`).
|
|
||||||
2. Mirror the source path where practical.
|
|
||||||
3. Reuse existing `conftest.py` files before adding new fixture layers.
|
|
||||||
4. Add markers only when they convey execution intent, and register new markers in `pyproject.toml` first.
|
|
||||||
|
|
||||||
This keeps the suite aligned with the current architecture while preserving a fast local test loop.
|
```bash
|
||||||
|
uv run zensical build
|
||||||
|
uv run ruff check .
|
||||||
|
uv run ty check
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
Prefer durable boundaries over implementation details: provider discovery, prompt rendering, traversal rejection, protocol behavior, and installed-package path resolution. Do not test deleted catalog projections, Pydantic immutability internals, or helper delegation.
|
See the [Pytesting skill](./skills/pytesting/SKILL.md) when adding or restructuring tests.
|
||||||
@@ -2,105 +2,59 @@
|
|||||||
icon: lucide/workflow
|
icon: lucide/workflow
|
||||||
---
|
---
|
||||||
|
|
||||||
# Skill Usage Mechanics
|
# Using Personal MCP
|
||||||
|
|
||||||
## Purpose
|
Personal MCP gives clients access to skills, prompts, and general documentation. Use the smallest piece of content that matches the task instead of loading the whole library.
|
||||||
|
|
||||||
This page describes how clients discover and load `personal-mcp` skills published by the [FastMCP Skills Provider](https://gofastmcp.com/servers/providers/skills).
|
## Connect
|
||||||
|
|
||||||
Skills are MCP resources. The client remains responsible for selecting guidance, loading only useful supporting material, and applying it to the current workspace.
|
The HTTP endpoint is `/mcp`. For a local server on port `8765`, connect to:
|
||||||
|
|
||||||
## Published Skill Surface
|
|
||||||
|
|
||||||
Each directory beneath `src/personal_mcp/docs/skills/` publishes:
|
|
||||||
|
|
||||||
1. `skill://<name>/SKILL.md` for primary instructions
|
|
||||||
2. `skill://<name>/_manifest` for file discovery and integrity metadata
|
|
||||||
3. `skill://<name>/{path*}` for supporting files
|
|
||||||
|
|
||||||
The server uses `supporting_files="template"`. Main files and manifests appear in `resources/list`; supporting files stay behind per-skill wildcard templates so the resource list remains compact.
|
|
||||||
|
|
||||||
The manifest contains every skill-relative path, byte size, and SHA256 hash. References do not have synthetic ids or a separate catalog record.
|
|
||||||
|
|
||||||
Prompts are available through native MCP prompt discovery and rendering.
|
|
||||||
|
|
||||||
## Discovery Workflow
|
|
||||||
|
|
||||||
Use this bounded sequence:
|
|
||||||
|
|
||||||
1. List resources or call FastMCP `list_skills()`.
|
|
||||||
2. Compare skill names and descriptions.
|
|
||||||
3. Read one selected `skill://<name>/SKILL.md`.
|
|
||||||
4. Read `skill://<name>/_manifest` only when supporting material may be useful.
|
|
||||||
5. Fetch the minimum supporting paths needed for the task.
|
|
||||||
6. Reconcile the guidance with the actual repository code before making changes.
|
|
||||||
|
|
||||||
Do not load every skill or every supporting file up front.
|
|
||||||
|
|
||||||
## FastMCP Client Utilities
|
|
||||||
|
|
||||||
FastMCP provides native utilities in `fastmcp.utilities.skills`:
|
|
||||||
|
|
||||||
1. `list_skills(client)` discovers main skill resources.
|
|
||||||
2. `get_skill_manifest(client, name)` parses a generated manifest.
|
|
||||||
3. `download_skill(client, name, target_dir)` downloads one skill.
|
|
||||||
4. `sync_skills(client, target_dir)` downloads all advertised skills.
|
|
||||||
|
|
||||||
These utilities operate directly on the native `skill://` contract and require no repository-specific adapter.
|
|
||||||
|
|
||||||
## Copilot Invocation
|
|
||||||
|
|
||||||
In VS Code, skills can arrive through:
|
|
||||||
|
|
||||||
1. explicit attachment from `Add Context > MCP Resources` or `MCP: Browse Resources`
|
|
||||||
2. direct resource reads on selected `skill://<name>/SKILL.md` entries
|
|
||||||
3. a slash-command prompt that names a specific native skill URI
|
|
||||||
|
|
||||||
Instructions can steer retrieval, but they do not guarantee automatic resource attachment in every chat surface. Use `MCP: Browse Resources` to confirm server-side availability, then attach only the minimum skill resources needed for the current task.
|
|
||||||
|
|
||||||
A reliable prompt is:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Browse MCP resources, select the best matching skill://.../SKILL.md entry by description, inspect its _manifest only if supporting detail is needed, and reconcile the guidance with this workspace.
|
http://127.0.0.1:8765/mcp
|
||||||
```
|
```
|
||||||
|
|
||||||
## Thin Shim Pattern
|
Clients that launch servers as subprocesses can use:
|
||||||
|
|
||||||
Consumer repositories can bind file scopes to native skill resources with short `.github/instructions/*.instructions.md` files.
|
```bash
|
||||||
|
uv run mcp-stdio
|
||||||
| `applyTo` scope | Companion docs | Primary skill resource |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `**/*.md` | [Authoring Guide](./authoring.md) | `skill://zensical-docs/SKILL.md` |
|
|
||||||
| `tests/**` | [Testing](./testing.md) | `skill://pytesting/SKILL.md` |
|
|
||||||
| `.vscode/**` | [VS Code Configuration](./skills/vscode-configuration/SKILL.md) | `skill://vscode-configuration/SKILL.md` |
|
|
||||||
|
|
||||||
Minimal shape:
|
|
||||||
|
|
||||||
```md
|
|
||||||
---
|
|
||||||
name: <scope name>
|
|
||||||
description: Route <path scope> edits to a personal-mcp skill.
|
|
||||||
applyTo: '<glob>'
|
|
||||||
---
|
|
||||||
|
|
||||||
Load `skill://<skill-name>/SKILL.md` first. Read `_manifest` and supporting files only when the task needs deeper detail. Apply the guidance to the current repository rather than treating it as generated output.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Failure Recovery
|
## Use A Skill
|
||||||
|
|
||||||
When no skill is an obvious match:
|
Skills are MCP resources. A client should:
|
||||||
|
|
||||||
1. compare the available main-resource descriptions again
|
1. browse the available `skill://<name>/SKILL.md` resources
|
||||||
2. select at most two candidates
|
2. choose one by its name and description
|
||||||
3. read their main files, not all supporting files
|
3. read the main `SKILL.md`
|
||||||
4. ask one clarifying question if the choice remains ambiguous
|
4. read its `_manifest` only when extra reference material is needed
|
||||||
|
5. load only the relevant supporting files
|
||||||
|
|
||||||
When a supporting path fails, refresh `_manifest`; file paths are the public supporting-resource identifiers.
|
The guidance should then be checked against the code and conventions in the current workspace.
|
||||||
|
|
||||||
## Runtime Checklist
|
## Use A Prompt
|
||||||
|
|
||||||
1. Confirm MCP connectivity.
|
Prompts are reusable workflows with named arguments. Browse the server's prompts, select one by its description, and supply the requested values when invoking it.
|
||||||
2. Confirm at least one `skill://<name>/SKILL.md` resource is listed.
|
|
||||||
3. Read its `_manifest` and verify `SKILL.md` appears with a SHA256 hash.
|
In VS Code, MCP prompts appear as chat slash commands. Resources can be opened from **MCP: Browse Resources** and attached as context when the active chat surface supports it.
|
||||||
4. Read one supporting file through its manifest path.
|
|
||||||
5. Keep loaded context bounded to the selected skill and relevant files.
|
## Tool Fallbacks
|
||||||
|
|
||||||
|
Some clients can call tools but cannot browse MCP resources or prompts directly. For those clients, the server exposes four read-only tools:
|
||||||
|
|
||||||
|
- `list_resources`
|
||||||
|
- `read_resource`
|
||||||
|
- `list_prompts`
|
||||||
|
- `get_prompt`
|
||||||
|
|
||||||
|
They provide access to the same underlying content. Clients with native resource and prompt support should use those native features.
|
||||||
|
|
||||||
|
## Example Request
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browse the available Personal MCP skills, choose the best match for this task,
|
||||||
|
read its main SKILL.md, and load supporting files only if they are needed.
|
||||||
|
Apply the guidance to this repository rather than treating it as generated output.
|
||||||
|
```
|
||||||
|
|
||||||
|
For the exact resource and prompt formats, see the [content contracts](./contracts/index.md).
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
|
from fastmcp.server.transforms import PromptsAsTools
|
||||||
|
from fastmcp.server.transforms import ResourcesAsTools
|
||||||
from mcp_types import Icon
|
from mcp_types import Icon
|
||||||
|
|
||||||
from .prompts.provider import prompt_lifespan
|
from .prompts.provider import prompt_lifespan
|
||||||
@@ -10,8 +12,9 @@ from .skills import skill_lifespan
|
|||||||
|
|
||||||
_SERVER_INSTRUCTIONS = """Personal development guidance exposed as native MCP resources and prompts.
|
_SERVER_INSTRUCTIONS = """Personal development guidance exposed as native MCP resources and prompts.
|
||||||
|
|
||||||
Use prompts for parameterized workflows. For task-specific guidance, browse native skill resources,
|
Use prompts for parameterized workflows; tool-only agents can discover and render them with
|
||||||
select one skill://<name>/SKILL.md resource, and read its manifest only when supporting detail is needed.
|
list_prompts and get_prompt. For task-specific guidance, browse native skill resources, select one
|
||||||
|
skill://<name>/SKILL.md resource, and read its manifest only when supporting detail is needed.
|
||||||
"""
|
"""
|
||||||
_SERVER_ICON = Icon(
|
_SERVER_ICON = Icon(
|
||||||
src=(
|
src=(
|
||||||
@@ -59,6 +62,10 @@ def create_mcp() -> FastMCP:
|
|||||||
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)
|
||||||
|
|
||||||
|
# Bridges tool-only clients that cannot browse native resources or prompts directly.
|
||||||
|
mcp.add_transform(ResourcesAsTools(mcp))
|
||||||
|
mcp.add_transform(PromptsAsTools(mcp))
|
||||||
|
|
||||||
return mcp
|
return mcp
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from fastmcp import Client
|
from fastmcp import Client
|
||||||
|
from mcp_types import TextContent
|
||||||
from mcp_types import TextResourceContents
|
from mcp_types import TextResourceContents
|
||||||
|
|
||||||
|
|
||||||
@@ -6,8 +7,9 @@ async def assert_server_contract(client: Client) -> None:
|
|||||||
resources = {str(resource.uri) for resource in await client.list_resources()}
|
resources = {str(resource.uri) for resource in await client.list_resources()}
|
||||||
templates = {str(template.uri_template) for template in await client.list_resource_templates()}
|
templates = {str(template.uri_template) for template in await client.list_resource_templates()}
|
||||||
prompts = {prompt.name for prompt in await client.list_prompts()}
|
prompts = {prompt.name for prompt in await client.list_prompts()}
|
||||||
|
tools = {tool.name for tool in await client.list_tools()}
|
||||||
|
|
||||||
assert await client.list_tools() == []
|
assert tools == {"get_prompt", "list_prompts", "list_resources", "read_resource"}
|
||||||
assert "skill://pytesting/SKILL.md" in resources
|
assert "skill://pytesting/SKILL.md" in resources
|
||||||
assert "resource://docs/{path*}" in templates
|
assert "resource://docs/{path*}" in templates
|
||||||
assert "skill://pytesting/{path*}" in templates
|
assert "skill://pytesting/{path*}" in templates
|
||||||
@@ -20,3 +22,13 @@ async def assert_server_contract(client: Client) -> None:
|
|||||||
assert "# Pytesting" in skill_content[0].text
|
assert "# Pytesting" in skill_content[0].text
|
||||||
assert isinstance(docs_content[0], TextResourceContents)
|
assert isinstance(docs_content[0], TextResourceContents)
|
||||||
assert '"format": "markdown"' in docs_content[0].text
|
assert '"format": "markdown"' in docs_content[0].text
|
||||||
|
|
||||||
|
listed = await client.call_tool("list_resources")
|
||||||
|
listed_content = listed.content[0]
|
||||||
|
assert isinstance(listed_content, TextContent)
|
||||||
|
assert "skill://pytesting/SKILL.md" in listed_content.text
|
||||||
|
|
||||||
|
read = await client.call_tool("read_resource", {"uri": "skill://pytesting/SKILL.md"})
|
||||||
|
read_content = read.content[0]
|
||||||
|
assert isinstance(read_content, TextContent)
|
||||||
|
assert "# Pytesting" in read_content.text
|
||||||
|
|||||||
+3
-112
@@ -11,7 +11,7 @@
|
|||||||
# The site_name is shown in the page header and the browser window title
|
# The site_name is shown in the page header and the browser window title
|
||||||
#
|
#
|
||||||
# Read more: https://zensical.org/docs/setup/basics/#site_name
|
# Read more: https://zensical.org/docs/setup/basics/#site_name
|
||||||
site_name = "Documentation"
|
site_name = "Personal MCP"
|
||||||
|
|
||||||
site_dir = "src/personal_mcp/site"
|
site_dir = "src/personal_mcp/site"
|
||||||
docs_dir = "src/personal_mcp/docs"
|
docs_dir = "src/personal_mcp/docs"
|
||||||
@@ -20,12 +20,12 @@ docs_dir = "src/personal_mcp/docs"
|
|||||||
# meaningful description of the site content for use by search engines.
|
# meaningful description of the site content for use by search engines.
|
||||||
#
|
#
|
||||||
# Read more: https://zensical.org/docs/setup/basics/#site_description
|
# Read more: https://zensical.org/docs/setup/basics/#site_description
|
||||||
site_description = "A new project generated from the default template project."
|
site_description = "Software development guidance published as MCP resources, prompts, and human-readable documentation."
|
||||||
|
|
||||||
# The site_author attribute. This is used in the HTML head element.
|
# The site_author attribute. This is used in the HTML head element.
|
||||||
#
|
#
|
||||||
# Read more: https://zensical.org/docs/setup/basics/#site_author
|
# Read more: https://zensical.org/docs/setup/basics/#site_author
|
||||||
site_author = "<your name here>"
|
site_author = "Personal MCP"
|
||||||
|
|
||||||
# The site_url is the canonical URL for your site. When building online
|
# The site_url is the canonical URL for your site. When building online
|
||||||
# documentation you should set this.
|
# documentation you should set this.
|
||||||
@@ -47,115 +47,6 @@ Copyright © 2026 The authors
|
|||||||
# can be defined using TOML syntax.
|
# can be defined using TOML syntax.
|
||||||
#
|
#
|
||||||
# Read more: https://zensical.org/docs/setup/navigation/
|
# Read more: https://zensical.org/docs/setup/navigation/
|
||||||
# nav = [
|
|
||||||
# { "Home" = "index.md" },
|
|
||||||
# { "Guide" = [
|
|
||||||
# { "Arch" = "architecture.md" },
|
|
||||||
# { "Contracts" = [
|
|
||||||
# { "Overview" = "contracts/index.md" },
|
|
||||||
# { "Prompt" = "contracts/prompt.md" },
|
|
||||||
# { "Skill" = "contracts/skill_contract.md" },
|
|
||||||
# { "Frontmatter" = "contracts/frontmatter.md" },
|
|
||||||
# { "URIs" = "contracts/uris.md" },
|
|
||||||
# ] },
|
|
||||||
# { "MCP" = "mcp_layout.md" },
|
|
||||||
# { "Copilot" = "copilot.md" },
|
|
||||||
# { "Usage" = "usage.md" },
|
|
||||||
# { "Authoring" = "authoring.md" },
|
|
||||||
# { "Future Work" = "future_work.md" },
|
|
||||||
# { "Testing" = "testing.md" },
|
|
||||||
# { "Security" = "securing.md" },
|
|
||||||
# ] },
|
|
||||||
# { "Prompts" = [
|
|
||||||
# { "Authoring" = "prompts/authoring/PROMPT.md" },
|
|
||||||
# { "JSFiddle Page Layout" = "prompts/jsfiddle-page-layout/PROMPT.md" },
|
|
||||||
# { "NiceGUI Component Extraction" = "prompts/nicegui-component-extraction/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" },
|
|
||||||
# { "MCP Consumer Repo Shim" = "prompts/mcp-consumer-repo-shim/PROMPT.md" },
|
|
||||||
# ] },
|
|
||||||
# { "Skills" = [
|
|
||||||
# { "Copilot" = [
|
|
||||||
# { "Overview" = "skills/copilot-customization/SKILL.md" },
|
|
||||||
# { "VS Code" = "skills/copilot-customization/references/vscode-customization.md" },
|
|
||||||
# ] },
|
|
||||||
# { "VS Code Config" = [
|
|
||||||
# { "Overview" = "skills/vscode-configuration/SKILL.md" },
|
|
||||||
# { "Debug Launch" = "skills/vscode-configuration/references/debug-launch-configurations.md" },
|
|
||||||
# { "FastAPI Debug" = "skills/vscode-configuration/references/fastapi-debugpy-launch.md" },
|
|
||||||
# { "Tasks" = "skills/vscode-configuration/references/tasks-json-configuration.md" },
|
|
||||||
# ] },
|
|
||||||
# { "FastAPI UV" = [
|
|
||||||
# { "Overview" = "skills/fastapi-uv-docker/SKILL.md" },
|
|
||||||
# { "Best" = "skills/fastapi-uv-docker/references/fastapi-best-practices.md" },
|
|
||||||
# { "Layout" = "skills/fastapi-uv-docker/references/uv-project-layout.md" },
|
|
||||||
# { "Uvicorn" = "skills/fastapi-uv-docker/references/uvicorn-settings.md" },
|
|
||||||
# { "Docker" = "skills/fastapi-uv-docker/references/docker-cloud-native.md" },
|
|
||||||
# ] },
|
|
||||||
# { "Async SQLA" = [
|
|
||||||
# { "Overview" = "skills/async-fastapi-sqlmodel/SKILL.md" },
|
|
||||||
# { "Engine" = "skills/async-fastapi-sqlmodel/references/engine.md" },
|
|
||||||
# { "Session" = "skills/async-fastapi-sqlmodel/references/session.md" },
|
|
||||||
# { "FastAPI" = "skills/async-fastapi-sqlmodel/references/fastapi.md" },
|
|
||||||
# { "Tx" = "skills/async-fastapi-sqlmodel/references/transactions.md" },
|
|
||||||
# { "SQLModel" = "skills/async-fastapi-sqlmodel/references/sqlmodel.md" },
|
|
||||||
# { "CRUD" = "skills/async-fastapi-sqlmodel/references/crud.md" },
|
|
||||||
# { "Testing" = "skills/async-fastapi-sqlmodel/references/testing.md" },
|
|
||||||
# { "IO" = "skills/async-fastapi-sqlmodel/references/implicit_io.md" },
|
|
||||||
# { "Obs" = "skills/async-fastapi-sqlmodel/references/observability.md" },
|
|
||||||
# { "Template" = "skills/async-fastapi-sqlmodel/references/template.md" },
|
|
||||||
# ] },
|
|
||||||
# { "NiceGUI" = [
|
|
||||||
# { "Overview" = "skills/nicegui/SKILL.md" },
|
|
||||||
# { "App Architecture" = "skills/nicegui/references/architecture.md" },
|
|
||||||
# { "Startup" = "skills/nicegui/references/fastapi-uvicorn-startup.md" },
|
|
||||||
# { "Visual Styling" = "skills/nicegui/references/styling-and-customization.md" },
|
|
||||||
# { "Component Mechanics" = "skills/nicegui/references/component-mechanics.md" },
|
|
||||||
# { "Tables" = "skills/nicegui/references/tables.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" },
|
|
||||||
# ] },
|
|
||||||
# { "Pytest" = [
|
|
||||||
# { "Overview" = "skills/pytesting/SKILL.md" },
|
|
||||||
# { "Docs" = "skills/pytesting/references/pytest-docs.md" },
|
|
||||||
# { "AsyncIO" = "skills/pytesting/references/asyncio-testing.md" },
|
|
||||||
# ] },
|
|
||||||
# { "MCP Details" = [
|
|
||||||
# { "Overview" = "skills/mcp-details/SKILL.md" },
|
|
||||||
# { "Protocol" = "skills/mcp-details/references/mcp-protocol-and-spec.md" },
|
|
||||||
# { "SDKs and FastMCP" = "skills/mcp-details/references/sdk-and-fastmcp.md" },
|
|
||||||
# { "Ecosystem" = "skills/mcp-details/references/ecosystem-and-tooling.md" },
|
|
||||||
# ] },
|
|
||||||
# { "Logging" = [
|
|
||||||
# { "Overview" = "skills/python-logging/SKILL.md" },
|
|
||||||
# { "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" = [
|
|
||||||
# { "Overview" = "skills/ruff-linting-formating/SKILL.md" },
|
|
||||||
# { "Docs" = "skills/ruff-linting-formating/references/ruff-docs.md" },
|
|
||||||
# { "Integrations" = "skills/ruff-linting-formating/references/ruff-integrations.md" },
|
|
||||||
# ] },
|
|
||||||
# { "Zensical" = [
|
|
||||||
# { "Overview" = "skills/zensical-docs/SKILL.md" },
|
|
||||||
# { "Features" = "skills/zensical-docs/references/zensical-features.md" },
|
|
||||||
# { "Theme" = "skills/zensical-docs/references/theme-customization-and-icons.md" },
|
|
||||||
# { "Quality" = "skills/zensical-docs/references/documentation-quality.md" },
|
|
||||||
# { "IA" = "skills/zensical-docs/references/discoverability-and-ia.md" },
|
|
||||||
# { "API Docs" = "skills/zensical-docs/references/code-heavy-docs-and-mkdocstrings.md" },
|
|
||||||
# ] },
|
|
||||||
# ] },
|
|
||||||
# ]
|
|
||||||
|
|
||||||
# With the "extra_css" option you can add your own CSS styling to customize
|
# With the "extra_css" option you can add your own CSS styling to customize
|
||||||
# your Zensical project according to your needs. You can add any number of
|
# your Zensical project according to your needs. You can add any number of
|
||||||
|
|||||||
Reference in New Issue
Block a user