Compare commits
8
Commits
bf11b7865d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d13ecd6718 | ||
|
|
5cefca852d | ||
|
|
78a489c690 | ||
|
|
9312784c2f | ||
|
|
09d2a4bcaf | ||
|
|
86c7d54244 | ||
|
|
f75b24705e | ||
|
|
b87b1df642 |
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: nicegui
|
name: nicegui
|
||||||
description: 'Build, review, and debug NiceGUI applications. Use for FastAPI or Uvicorn integration, app factories and lifespan, thin pages and reusable component factories, returned 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.'
|
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 Application Guide
|
# NiceGUI Application Guide
|
||||||
@@ -20,7 +20,8 @@ Use this skill to choose the smallest supporting reference for a NiceGUI task. T
|
|||||||
| Task or symptom | Load first | Add only when |
|
| Task or symptom | Load first | Add only when |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 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. |
|
| 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. |
|
||||||
| 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 [application architecture](./references/architecture.md) only for wider package placement. |
|
| 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. |
|
||||||
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
||||||
@@ -28,12 +29,14 @@ Use this skill to choose the smallest supporting reference for a NiceGUI task. T
|
|||||||
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
||||||
|
|
||||||
## Boundary Rules
|
## Boundary Rules
|
||||||
|
|
||||||
- Use [application architecture](./references/architecture.md) for module ownership, not for page geometry or low-level component behavior.
|
- 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.
|
- 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.
|
- 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).
|
- 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).
|
||||||
@@ -47,8 +50,9 @@ Load an example only when its exact mechanic matches the task:
|
|||||||
|
|
||||||
- [binding transforms](./examples/data_binding.py): `bindable_dataclass`, `ui.date`, and typed `forward`/`backward` conversion.
|
- [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.
|
- [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): column defaults, dynamic formatting and classes, QTable props, responsive density, toolbar and cell slots, filtering, visible columns, and empty states.
|
- [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.
|
- [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.
|
||||||
|
|
||||||
## Defaults That Span References
|
## Defaults That Span References
|
||||||
|
|
||||||
@@ -59,6 +63,7 @@ Load an example only when its exact mechanic matches the task:
|
|||||||
- Prefer event-driven updates and explicit refreshes to unrelated polling.
|
- Prefer event-driven updates and explicit refreshes to unrelated polling.
|
||||||
- Keep browser-originated values as proposals; validate and normalize them in Python before mutating authoritative state.
|
- Keep browser-originated values as proposals; validate and normalize them in Python before mutating authoritative state.
|
||||||
- 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).
|
- 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).
|
||||||
|
- 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.
|
||||||
- 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).
|
- 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.
|
||||||
- Verify component-specific behavior against the installed NiceGUI version and its bundled Quasar version.
|
- Verify component-specific behavior against the installed NiceGUI version and its bundled Quasar version.
|
||||||
|
|||||||
+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)
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
# ]
|
# ]
|
||||||
# ///
|
# ///
|
||||||
|
|
||||||
|
from nicegui import events
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
type TableValue = str | int | float
|
type TableValue = str | int | float
|
||||||
@@ -17,6 +18,12 @@ STATUS_COLORS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
COLUMNS = [
|
COLUMNS = [
|
||||||
|
{
|
||||||
|
"name": "actions",
|
||||||
|
"label": "Actions",
|
||||||
|
"required": True,
|
||||||
|
"align": "center",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"label": "Product",
|
"label": "Product",
|
||||||
@@ -47,6 +54,7 @@ COLUMNS = [
|
|||||||
"headerStyle": "width: 7rem",
|
"headerStyle": "width: 7rem",
|
||||||
"style": "width: 7rem",
|
"style": "width: 7rem",
|
||||||
":classes": "row => row.stock < 10 ? 'text-negative font-bold' : ''",
|
":classes": "row => row.stock < 10 ? 'text-negative font-bold' : ''",
|
||||||
|
":format": "value => value == null ? '' : `${value} units`",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "price",
|
"name": "price",
|
||||||
@@ -56,7 +64,7 @@ COLUMNS = [
|
|||||||
"align": "right",
|
"align": "right",
|
||||||
"headerStyle": "width: 8rem",
|
"headerStyle": "width: 8rem",
|
||||||
"style": "width: 8rem",
|
"style": "width: 8rem",
|
||||||
":format": "value => `$${value.toFixed(2)}`",
|
":format": "value => value == null ? '' : `$${value.toFixed(2)}`",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "status",
|
"name": "status",
|
||||||
@@ -68,15 +76,85 @@ COLUMNS = [
|
|||||||
"style": "width: 8rem",
|
"style": "width: 8rem",
|
||||||
"colorByValue": STATUS_COLORS,
|
"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] = [
|
ROWS: list[TableRow] = [
|
||||||
{"id": 101, "name": "Desk lamp", "category": "Lighting", "stock": 7, "price": 42.5, "status": "Low"},
|
{
|
||||||
{"id": 102, "name": "Task chair", "category": "Seating", "stock": 18, "price": 289.0, "status": "Ready"},
|
"id": 101,
|
||||||
{"id": 103, "name": "Monitor arm", "category": "Hardware", "stock": 0, "price": 119.95, "status": "Backorder"},
|
"name": "Desk lamp",
|
||||||
{"id": 104, "name": "Cable tray", "category": "Hardware", "stock": 34, "price": 31.25, "status": "Ready"},
|
"category": "Lighting",
|
||||||
{"id": 105, "name": "Side table", "category": "Furniture", "stock": 9, "price": 164.5, "status": "Low"},
|
"stock": 7,
|
||||||
{"id": 106, "name": "Floor light", "category": "Lighting", "stock": 15, "price": 98.0, "status": "Ready"},
|
"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",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -112,10 +190,10 @@ def render_table() -> ui.table:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
with table.add_slot("top-left"), ui.row().classes("items-center gap-2"):
|
with table.add_slot("top-left"), ui.row(align_items="center").classes("gap-2"):
|
||||||
ui.icon("inventory_2", size="sm").classes("text-primary")
|
ui.icon("inventory_2", size="sm", color="primary")
|
||||||
ui.label("Inventory").classes("text-xl font-medium")
|
ui.label("Inventory").classes("text-xl font-medium")
|
||||||
ui.badge(str(len(ROWS)), color="grey-3").props("text-color=grey-9")
|
ui.badge(str(len(ROWS)), color="grey-3", text_color="grey-9")
|
||||||
|
|
||||||
with table.add_slot("top-right"):
|
with table.add_slot("top-right"):
|
||||||
ui.input(placeholder="Search inventory").props("dense outlined clearable debounce=250").bind_value_to(
|
ui.input(placeholder="Search inventory").props("dense outlined clearable debounce=250").bind_value_to(
|
||||||
@@ -124,20 +202,36 @@ def render_table() -> ui.table:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with table.add_slot("body-cell-status"), table.cell("status"):
|
with table.add_slot("body-cell-status"), table.cell("status"):
|
||||||
ui.badge().props(':label="props.value" :color="props.col.colorByValue[props.value] ?? \'grey\'" outline')
|
ui.badge(outline=True).props(':label="props.value" :color="props.col.colorByValue[props.value] ?? \'grey\'"')
|
||||||
|
|
||||||
with table.add_slot("no-data"), ui.row().classes("w-full items-center justify-center gap-2 p-6 text-grey-7"):
|
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.icon("inventory_2", size="2em").props(":name=\"props.filter ? 'filter_alt_off' : 'inventory_2'\"")
|
||||||
ui.element("span").props(':textContent="props.message"')
|
ui.element("span").props(':textContent="props.message"')
|
||||||
|
|
||||||
optional_columns = [column for column in table.columns if not column.get("required")]
|
optional_columns = [column for column in table.columns if not column.get("required")]
|
||||||
|
|
||||||
def set_visible_columns(names: list[str]) -> None:
|
def set_visible_columns(names: list[str]) -> None:
|
||||||
visible = set(names)
|
table.props["visible-columns"] = names
|
||||||
for column in optional_columns:
|
|
||||||
hidden = column["name"] not in visible
|
|
||||||
column["classes"] = "hidden" if hidden else ""
|
|
||||||
column["headerClasses"] = "hidden" if hidden else "text-grey-8"
|
|
||||||
table.update()
|
table.update()
|
||||||
|
|
||||||
ui.select(
|
ui.select(
|
||||||
@@ -145,6 +239,7 @@ def render_table() -> ui.table:
|
|||||||
value=[column["name"] for column in optional_columns],
|
value=[column["name"] for column in optional_columns],
|
||||||
label="Visible columns",
|
label="Visible columns",
|
||||||
multiple=True,
|
multiple=True,
|
||||||
|
clearable=True,
|
||||||
on_change=lambda event: set_visible_columns(event.value),
|
on_change=lambda event: set_visible_columns(event.value),
|
||||||
).props("outlined dense options-dense").classes("w-64")
|
).props("outlined dense options-dense").classes("w-64")
|
||||||
|
|
||||||
@@ -152,7 +247,7 @@ def render_table() -> ui.table:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ in {"__main__", "__mp_main__"}:
|
if __name__ in {"__main__", "__mp_main__"}:
|
||||||
with ui.column().classes("w-full items-center gap-4 p-4"):
|
with ui.column(align_items="center").classes("w-full gap-4 p-4"):
|
||||||
render_table()
|
render_table()
|
||||||
|
|
||||||
ui.run(port=8888, reload=True)
|
ui.run(port=8888, reload=True)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -243,6 +243,8 @@ The tagged [`run` implementation](https://github.com/zauberzeug/nicegui/blob/v3.
|
|||||||
|
|
||||||
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.
|
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
|
## Live Update Transports
|
||||||
|
|
||||||
| Requirement | Default surface |
|
| Requirement | Default surface |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -29,17 +29,16 @@ NiceGUI's tagged [table client wrapper](https://github.com/zauberzeug/nicegui/bl
|
|||||||
|
|
||||||
## Columns Before Slots
|
## 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. Keep `field` separate when the displayed column name differs from the row key.
|
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
|
```python
|
||||||
columns = [
|
columns = [
|
||||||
{
|
{
|
||||||
"name": "price",
|
"name": "available",
|
||||||
"label": "Unit price",
|
"label": "In stock",
|
||||||
"field": "price",
|
"field": "stock",
|
||||||
"sortable": True,
|
"sortable": True,
|
||||||
"align": "right",
|
"align": "right",
|
||||||
":format": "value => `$${value.toFixed(2)}`",
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -53,6 +52,95 @@ table = ui.table(
|
|||||||
|
|
||||||
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.
|
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
|
### 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:
|
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:
|
||||||
@@ -98,34 +186,31 @@ Use the following mechanisms in order:
|
|||||||
| 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 |
|
| 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 |
|
| 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 |
|
| 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 `overflow: hidden; white-space: nowrap; text-overflow: ellipsis`; [`text-overflow`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/text-overflow) does not create overflow by itself |
|
| 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.
|
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`. Add a class to the `ui.table` element and target the nested `.q-table` with scoped CSS:
|
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
|
```python
|
||||||
ui.add_css("""
|
ui.add_css("""
|
||||||
.inventory-table .q-table {
|
.inventory-table .q-table {
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.inventory-table .truncate-cell > * {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
table = ui.table(rows=rows, columns=columns).classes("inventory-table w-full")
|
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. 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.
|
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:
|
Column visibility has two useful patterns:
|
||||||
|
|
||||||
- Mark identity or action columns `required` when they must remain visible.
|
- Mark identity or action columns `required` when they must remain visible.
|
||||||
- For a Python-owned column picker, update each optional column in `table.columns`, then call `table.update()`. Use the table-owned dictionaries rather than the input list because `column_defaults` normalizes columns into new dictionaries. This pattern also avoids binding a Python list directly into a JavaScript expression.
|
- 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
|
## QTable Props
|
||||||
|
|
||||||
@@ -196,6 +281,50 @@ Inside table slots, `props.value` is the parsed and formatted cell value, `props
|
|||||||
|
|
||||||
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.
|
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.
|
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
|
## Toolbar Search
|
||||||
@@ -217,7 +346,7 @@ The path from keystroke to displayed rows is:
|
|||||||
4. QTable filters first, sorts the matching rows, resets pagination to page 1 when the filter changes, and then slices the current page.
|
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`.
|
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. A column excluded with QTable's `visible-columns` prop is not searched, while a column hidden only with CSS classes, as in this example's Python-owned picker, remains part of the search.
|
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.
|
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.
|
||||||
|
|
||||||
@@ -227,7 +356,7 @@ Prefer typed table helpers over raw frontend calls. Use `table.run_method(...)`
|
|||||||
|
|
||||||
## Runnable Example
|
## Runnable Example
|
||||||
|
|
||||||
The complete example combines column defaults, static and dynamic column attributes, responsive QTable props, filtering, column visibility, a toolbar, a custom status cell, and a filtered-empty state. It is available as [`table_customization.py`](../examples/table_customization.py) and as `skill://nicegui/examples/table_customization.py`.
|
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"
|
```python title="table_customization.py"
|
||||||
--8<-- "docs/skills/nicegui/examples/table_customization.py"
|
--8<-- "docs/skills/nicegui/examples/table_customization.py"
|
||||||
@@ -239,7 +368,7 @@ Run it from the repository root:
|
|||||||
uv run src/personal_mcp/docs/skills/nicegui/examples/table_customization.py
|
uv run src/personal_mcp/docs/skills/nicegui/examples/table_customization.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify 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 and that the status cell retains its alignment and badge styling. Resize the browser across mobile, landscape desktop, and portrait desktop widths; the toolbar must remain usable and the table must scroll without overlapping controls.
|
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
|
## Escalation Boundaries
|
||||||
|
|
||||||
@@ -253,6 +382,9 @@ Verify that searches are case-insensitive, match formatted values across columns
|
|||||||
!!! info "Primary documentation"
|
!!! info "Primary documentation"
|
||||||
- [NiceGUI table documentation](https://nicegui.io/documentation/table)
|
- [NiceGUI table documentation](https://nicegui.io/documentation/table)
|
||||||
- [Quasar QTable documentation](https://quasar.dev/vue-components/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 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)
|
- [CSS width sizing](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/width)
|
||||||
|
|
||||||
@@ -274,7 +406,7 @@ Before accepting a customized table:
|
|||||||
1. Verify the target NiceGUI release and its bundled Quasar version.
|
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.
|
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.
|
3. Use constructor arguments and column definitions before QTable props or slots.
|
||||||
4. Use `:` only for JavaScript expressions and keep row values serializable.
|
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.
|
5. Choose the narrowest named slot and preserve QTable cell or header semantics.
|
||||||
6. Recheck sorting, filtering, pagination, selection, and empty states after customization.
|
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.
|
7. Check mobile, landscape desktop, and portrait desktop layouts; verify the toolbar remains usable and wide tables scroll without overlapping controls.
|
||||||
Reference in New Issue
Block a user