Quickstart¶
Every example on this page is complete and runnable.
A flat model¶
The smallest useful case. Declare the fields, ask for them.
import datetime
from pydantic import BaseModel
import brayer
class Person(BaseModel):
name: str
age: int
birthday: datetime.date
person = brayer.ask(Person)
if person is None:
print("cancelled")
else:
print(f"{person.name} is {person.age}")
ask blocks until the dialog closes. It returns a validated
Person, or None if the user cancelled or closed the window.
Cancel is not an empty result
None means "the user declined". It is never confused with a model
that happens to have no fields, and never with a widget that failed
to read.
Defaults, labels and help text¶
Anything you tell Pydantic about a field, the form uses.
from pydantic import BaseModel, Field
class ServerSettings(BaseModel):
workers: int = Field(default=4, ge=1, le=64)
hostname: str = Field(
default="localhost",
max_length=253,
title="Host name",
description="Interface the server binds to",
)
debug: bool = False
default=pre-fills the widget.ge/gt/le/ltbecome the spin box's range. Exclusive bounds are converted to inclusive ones, sogt=0, lt=10yields1..9.max_lengthcaps the line edit.title=becomes the row label; without one, the attribute name is used with underscores turned into spaces.description=becomes the placeholder and the tooltip.
Choices¶
An Enum or a Literal becomes a drop-down. The member itself comes
back, not its display text.
import enum
from typing import Literal
from pydantic import BaseModel
class Environment(str, enum.Enum):
staging = "Staging"
production = "Production"
class Deploy(BaseModel):
environment: Environment = Environment.staging
strategy: Literal["rolling", "blue-green"] = "rolling"
deploy = brayer.ask(Deploy)
assert isinstance(deploy.environment, Environment) # a member, not a str
IntEnum, StrEnum and enum.auto() all work. A string-valued member
is labelled by its value; anything else is labelled by its name.
Optional and union fields¶
A union renders as a type selector beside the matching widget. Pick the type, then enter the value.
from pydantic import BaseModel
class Endpoint(BaseModel):
timeout: int | None = None # selector offers "int" and "None"
retries: str | int # both spellings of union work
If the field has a default, the form opens on that branch with the value already filled in.
Collections¶
list, set, frozenset, dict and tuple each get an editor with
the right widget for their element type.
import datetime
from pydantic import BaseModel, Field
class Service(BaseModel):
tags: list[str] = Field(default_factory=list)
deployed_on: list[datetime.date] = Field(default_factory=list)
rate_limits: dict[str, int] = Field(default_factory=dict)
listen: tuple[str, int] = ("0.0.0.0", 8080)
- list / set / frozenset — an add/remove list with a widget for one element and an Add button. Items reorder by dragging and are removed with Delete or the right-click menu.
- dict — a two-column key/value table. Keys must be hashable and unique; a duplicate is refused rather than silently overwriting.
- tuple — a fixed-arity
tuple[str, int]gets one widget per position; a variadictuple[int, ...]gets the list editor.
Values keep their real types. A list[datetime.date] gives you back
date objects, not strings.
Nested models¶
A BaseModel-typed field becomes a button that opens its own sub-form.
from pydantic import BaseModel
class Database(BaseModel):
host: str
port: int
class Deployment(BaseModel):
service: str
database: Database # -> an "Edit Database..." button
The sub-form is built the first time you open it, so deeply nested — even self-referential — models cost nothing until you actually navigate into them. Cancelling the sub-dialog discards that edit and restores the previous values.
Editing an existing object¶
edit pre-fills the form from an instance and returns a new one.
person = Person(name="Grace", age=45, birthday=datetime.date(1906, 12, 9))
updated = brayer.edit(person)
if updated is not None:
print(updated.age)
The original is never mutated.
Embedding in a Qt application¶
ask is a thin wrapper over ModelDialog, which is an ordinary
QDialog. Use it directly when you already have an event loop.
from PySide6 import QtWidgets
import brayer
class MainWindow(QtWidgets.QMainWindow):
def collect_person(self) -> None:
dialog = brayer.ModelDialog(Person, parent=self)
if dialog.exec() == QtWidgets.QDialog.DialogCode.Accepted:
self.use(dialog.value)
Because the dialog reuses the running QApplication, this composes with
whatever your application already has open.
Validation failures¶
The dialog refuses to close while the values are invalid, and lists the problems underneath the form.
from pydantic import BaseModel, Field, field_validator
class Account(BaseModel):
username: str = Field(min_length=3)
@field_validator("username")
@classmethod
def no_spaces(cls, value: str) -> str:
if " " in value:
raise ValueError("must not contain spaces")
return value
Entering a b shows username: Value error, must not contain spaces
and keeps the dialog open. Your own validators work exactly as they do
anywhere else in Pydantic.
Running headless¶
For tests and CI, set Qt's offscreen platform before importing anything Qt-related:
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
The whole widget layer works without a display, which is how this project's own test suite runs.