Skip to content

Widgets

Base Widget

fastapi_admin_kit.widgets.base.Widget

Base widget class.

Stateless: receives FieldMeta + value at render/validate time. Override parse to transform raw form data. Override validate to add custom validation. Override render_context to customise template variables.

Source code in fastapi_admin_kit/widgets/base.py
class Widget:
    """Base widget class.

    Stateless: receives FieldMeta + value at render/validate time.
    Override ``parse`` to transform raw form data.
    Override ``validate`` to add custom validation.
    Override ``render_context`` to customise template variables.
    """

    macro_name: str = "text_input"

    def render_context(self, field: FieldMeta, value: Any) -> dict:
        """Variables injected into the Jinja2 macro."""
        return {
            "field": field,
            "value": value if value is not None else "",
            "id": f"field-{field.name}",
            "name": field.name,
        }

    def parse(self, raw: str | list | None) -> Any:
        """Convert raw FormData string to typed Python value."""
        if raw is None or raw == "":
            return None
        return raw

    def validate(self, value: Any, field: FieldMeta) -> list[str]:
        """Return a list of error messages. Empty list means valid."""
        errors: list[str] = []
        if field.required and (value is None or value == ""):
            errors.append(f"{field.label} is required.")
        return errors

    def __repr__(self) -> str:
        return f"<{self.__class__.__name__} macro={self.macro_name!r}>"

render_context(field, value)

Variables injected into the Jinja2 macro.

Source code in fastapi_admin_kit/widgets/base.py
def render_context(self, field: FieldMeta, value: Any) -> dict:
    """Variables injected into the Jinja2 macro."""
    return {
        "field": field,
        "value": value if value is not None else "",
        "id": f"field-{field.name}",
        "name": field.name,
    }

parse(raw)

Convert raw FormData string to typed Python value.

Source code in fastapi_admin_kit/widgets/base.py
def parse(self, raw: str | list | None) -> Any:
    """Convert raw FormData string to typed Python value."""
    if raw is None or raw == "":
        return None
    return raw

validate(value, field)

Return a list of error messages. Empty list means valid.

Source code in fastapi_admin_kit/widgets/base.py
def validate(self, value: Any, field: FieldMeta) -> list[str]:
    """Return a list of error messages. Empty list means valid."""
    errors: list[str] = []
    if field.required and (value is None or value == ""):
        errors.append(f"{field.label} is required.")
    return errors

FieldMeta

fastapi_admin_kit.widgets.base.FieldMeta dataclass

Metadata for a form field — drives widget rendering and validation.

Source code in fastapi_admin_kit/types.py
@dataclass
class FieldMeta:
    """Metadata for a form field — drives widget rendering and validation."""

    name: str
    label: str
    required: bool
    readonly: bool = False
    help_text: str | None = None
    placeholder: str | None = None
    extra: dict = field(default_factory=dict)

Built-in Widgets

TextInputWidget

fastapi_admin_kit.widgets.inputs.TextInputWidget

Bases: Widget

Source code in fastapi_admin_kit/widgets/inputs.py
class TextInputWidget(Widget):
    macro_name = "text_input"

    def __init__(self, maxlength: int | None = None):
        self.maxlength = maxlength

    def render_context(self, field: FieldMeta, value: Any) -> dict:
        ctx = super().render_context(field, value)
        ctx["maxlength"] = self.maxlength
        return ctx

    def validate(self, value: Any, field: FieldMeta) -> list[str]:
        errors = super().validate(value, field)
        if value and self.maxlength and len(value) > self.maxlength:
            errors.append(f"{field.label} must be {self.maxlength} characters or fewer.")
        return errors

TextareaWidget

fastapi_admin_kit.widgets.inputs.TextareaWidget

Bases: Widget

Source code in fastapi_admin_kit/widgets/inputs.py
class TextareaWidget(Widget):
    macro_name = "textarea"

    def __init__(self, rows: int = 5):
        self.rows = rows

    def render_context(self, field: FieldMeta, value: Any) -> dict:
        ctx = super().render_context(field, value)
        ctx["rows"] = self.rows
        return ctx

NumberInputWidget

fastapi_admin_kit.widgets.inputs.NumberInputWidget

Bases: Widget

Source code in fastapi_admin_kit/widgets/inputs.py
class NumberInputWidget(Widget):
    macro_name = "number_input"

    def __init__(self, step: str = "1", min: str | None = None, max: str | None = None):
        self.step = step
        self.min = min
        self.max = max

    def render_context(self, field: FieldMeta, value: Any) -> dict:
        ctx = super().render_context(field, value)
        ctx.update({"step": self.step, "min": self.min, "max": self.max})
        return ctx

    def parse(self, raw: str | None) -> int | float | None:
        if raw is None or raw == "":
            return None
        try:
            return int(raw) if "." not in str(raw) else float(raw)
        except ValueError:
            return raw

    def validate(self, value: Any, field: FieldMeta) -> list[str]:
        errors = super().validate(value, field)
        if value is not None:
            try:
                float(value)
            except (TypeError, ValueError):
                errors.append(f"{field.label} must be a number.")
        return errors

ToggleWidget

fastapi_admin_kit.widgets.inputs.ToggleWidget

Bases: Widget

Source code in fastapi_admin_kit/widgets/inputs.py
class ToggleWidget(Widget):
    macro_name = "toggle"

    def parse(self, raw: str | None) -> bool:
        if raw is None:
            return False
        if isinstance(raw, bool):
            return raw
        return str(raw).lower() in ("on", "true", "1", "yes")

    def validate(self, value: Any, field: FieldMeta) -> list[str]:
        return []

SelectWidget

fastapi_admin_kit.widgets.inputs.SelectWidget

Bases: Widget

Source code in fastapi_admin_kit/widgets/inputs.py
class SelectWidget(Widget):
    macro_name = "select"

    def __init__(self, choices: list[tuple[str, str]] | None = None):
        self.choices = choices or []

    def render_context(self, field: FieldMeta, value: Any) -> dict:
        ctx = super().render_context(field, value)
        ctx["choices"] = self.choices
        return ctx

    def validate(self, value: Any, field: FieldMeta) -> list[str]:
        errors = super().validate(value, field)
        if value and self.choices:
            valid = {c[0] for c in self.choices}
            if value not in valid:
                errors.append(f"'{value}' is not a valid choice for {field.label}.")
        return errors

DatePickerWidget

fastapi_admin_kit.widgets.inputs.DatePickerWidget

Bases: Widget

Source code in fastapi_admin_kit/widgets/inputs.py
class DatePickerWidget(Widget):
    macro_name = "date_picker"

    def render_context(self, field: FieldMeta, value: Any) -> dict:
        ctx = super().render_context(field, value)
        if isinstance(value, date) and not isinstance(value, datetime):
            ctx["value"] = value.isoformat()
        elif isinstance(value, datetime):
            ctx["value"] = value.date().isoformat()
        return ctx

    def parse(self, raw: str | None) -> date | str | None:
        if not raw:
            return None
        try:
            return date.fromisoformat(raw)
        except ValueError:
            return raw

    def validate(self, value: Any, field: FieldMeta) -> list[str]:
        errors = super().validate(value, field)
        if value is not None and not isinstance(value, date):
            errors.append(f"{field.label} must be a valid date.")
        return errors

DateTimePickerWidget

fastapi_admin_kit.widgets.inputs.DateTimePickerWidget

Bases: Widget

Source code in fastapi_admin_kit/widgets/inputs.py
class DateTimePickerWidget(Widget):
    macro_name = "datetime_picker"

    def render_context(self, field: FieldMeta, value: Any) -> dict:
        ctx = super().render_context(field, value)
        if isinstance(value, datetime):
            ctx["value"] = value.replace(tzinfo=None).isoformat(timespec="minutes")
        elif isinstance(value, date):
            combined = datetime.combine(value, datetime.min.time())
            ctx["value"] = combined.isoformat(timespec="minutes")
        return ctx

    def parse(self, raw: str | None) -> datetime | str | None:
        if not raw:
            return None
        try:
            return datetime.fromisoformat(raw)
        except ValueError:
            return raw

    def validate(self, value: Any, field: FieldMeta) -> list[str]:
        errors = super().validate(value, field)
        if value is not None and not isinstance(value, datetime):
            errors.append(f"{field.label} must be a valid date and time.")
        return errors

JsonEditorWidget

fastapi_admin_kit.widgets.inputs.JsonEditorWidget

Bases: Widget

Source code in fastapi_admin_kit/widgets/inputs.py
class JsonEditorWidget(Widget):
    macro_name = "json_editor"

    def parse(self, raw: str | None) -> Any:
        if not raw:
            return None
        if isinstance(raw, str):
            try:
                return json.loads(raw)
            except (json.JSONDecodeError, TypeError):
                return None
        return raw

    def validate(self, value: Any, field: FieldMeta) -> list[str]:
        errors = super().validate(value, field)
        if isinstance(value, str):
            try:
                json.loads(value)
            except json.JSONDecodeError as e:
                errors.append(f"{field.label} contains invalid JSON: {e}")
        return errors

AutocompleteWidget

fastapi_admin_kit.widgets.inputs.AutocompleteWidget

Bases: Widget

Text input with datalist autocomplete suggestions.

Source code in fastapi_admin_kit/widgets/inputs.py
class AutocompleteWidget(Widget):
    """Text input with datalist autocomplete suggestions."""

    macro_name = "autocomplete"

    def __init__(
        self,
        suggestions: list[str] | None = None,
        suggestions_fn: Any = None,
    ):
        self.suggestions = suggestions
        self.suggestions_fn = suggestions_fn

    def _get_suggestions(self) -> list[str]:
        if self.suggestions_fn is not None:
            return self.suggestions_fn()
        return self.suggestions or []

    def render_context(self, field: FieldMeta, value: Any) -> dict:
        ctx = super().render_context(field, value)
        ctx["suggestions"] = self._get_suggestions()
        return ctx

PasswordWidget

fastapi_admin_kit.widgets.inputs.PasswordWidget

Bases: Widget

Source code in fastapi_admin_kit/widgets/inputs.py
class PasswordWidget(Widget):
    macro_name = "password_input"

    def render_context(self, field: FieldMeta, value: Any) -> dict:
        ctx = super().render_context(field, value)
        ctx["value"] = ""  # NEVER pre-fill passwords
        return ctx

    def parse(self, raw: str | None) -> str | None:
        if not raw:
            return None
        return raw

ReadOnlyWidget

fastapi_admin_kit.widgets.inputs.ReadOnlyWidget

Bases: Widget

Source code in fastapi_admin_kit/widgets/inputs.py
class ReadOnlyWidget(Widget):
    macro_name = "readonly"

    def parse(self, raw: str | None) -> None:
        return None

    def validate(self, value: Any, field: FieldMeta) -> list[str]:
        return []

HiddenWidget

fastapi_admin_kit.widgets.inputs.HiddenWidget

Bases: Widget

Source code in fastapi_admin_kit/widgets/inputs.py
class HiddenWidget(Widget):
    macro_name = "hidden"

FileUploadWidget

fastapi_admin_kit.widgets.inputs.FileUploadWidget

Bases: Widget

File upload widget — stores file via StorageBackend, saves path string.

Source code in fastapi_admin_kit/widgets/inputs.py
class FileUploadWidget(Widget):
    """File upload widget — stores file via StorageBackend, saves path string."""

    macro_name = "file_upload"

    def __init__(
        self,
        max_size_mb: float | None = None,
        accept: str | None = None,
    ) -> None:
        self.max_size_mb = max_size_mb
        self.accept = accept  # e.g. ".pdf,.docx" or "application/pdf"

    def render_context(self, field: FieldMeta, value: Any) -> dict:
        ctx = super().render_context(field, value)
        ctx["max_size_mb"] = self.max_size_mb
        ctx["accept"] = self.accept
        ctx["current_file"] = value if value else ""
        return ctx

    def parse(self, raw: Any) -> Any:
        """Raw form data — the actual UploadFile handling happens in the
        form submit factory because ``UploadFile`` objects need async read."""
        if raw is None or raw == "":
            return None
        return raw

    def validate(self, value: Any, field: FieldMeta) -> list[str]:
        errors = super().validate(value, field)
        # Size validation happens at save time in the form submit factory
        # because reading the file requires async I/O.
        return errors

parse(raw)

Raw form data — the actual UploadFile handling happens in the form submit factory because UploadFile objects need async read.

Source code in fastapi_admin_kit/widgets/inputs.py
def parse(self, raw: Any) -> Any:
    """Raw form data — the actual UploadFile handling happens in the
    form submit factory because ``UploadFile`` objects need async read."""
    if raw is None or raw == "":
        return None
    return raw

ImageUploadWidget

fastapi_admin_kit.widgets.inputs.ImageUploadWidget

Bases: Widget

Image upload widget — like FileUploadWidget but restricted to images.

Source code in fastapi_admin_kit/widgets/inputs.py
class ImageUploadWidget(Widget):
    """Image upload widget — like FileUploadWidget but restricted to images."""

    macro_name = "image_upload"

    def __init__(
        self,
        max_size_mb: float | None = None,
        accept: str = "image/*",
    ) -> None:
        self.max_size_mb = max_size_mb
        self.accept = accept

    def render_context(self, field: FieldMeta, value: Any) -> dict:
        ctx = super().render_context(field, value)
        ctx["max_size_mb"] = self.max_size_mb
        ctx["accept"] = self.accept
        ctx["current_file"] = value if value else ""
        return ctx

    def parse(self, raw: Any) -> Any:
        if raw is None or raw == "":
            return None
        return raw

    def validate(self, value: Any, field: FieldMeta) -> list[str]:
        errors = super().validate(value, field)
        return errors

WysiwygWidget

fastapi_admin_kit.widgets.inputs.WysiwygWidget

Bases: Widget

Wysiwyg rich text editor widget (contenteditable-based).

Source code in fastapi_admin_kit/widgets/inputs.py
class WysiwygWidget(Widget):
    """Wysiwyg rich text editor widget (contenteditable-based)."""

    macro_name = "wysiwyg"

    def __init__(self, height: int = 200):
        self.height = height

    def render_context(self, field: FieldMeta, value: Any) -> dict:
        ctx = super().render_context(field, value)
        ctx["height"] = self.height
        return ctx

    def parse(self, raw: str | None) -> str | None:
        if not raw:
            return None
        return raw

    def validate(self, value: Any, field: FieldMeta) -> list[str]:
        return []

ArrayWidget

fastapi_admin_kit.widgets.inputs.ArrayWidget

Bases: Widget

Array/list input widget — dynamic add/remove items.

Source code in fastapi_admin_kit/widgets/inputs.py
class ArrayWidget(Widget):
    """Array/list input widget — dynamic add/remove items."""

    macro_name = "array_input"

    def __init__(self, min_items: int = 0, max_items: int | None = None):
        self.min_items = min_items
        self.max_items = max_items

    def render_context(self, field: FieldMeta, value: Any) -> dict:
        ctx = super().render_context(field, value)
        if isinstance(value, str):
            try:
                import json

                ctx["value"] = json.loads(value)
            except (json.JSONDecodeError, TypeError):
                ctx["value"] = []
        ctx["min_items"] = self.min_items
        ctx["max_items"] = self.max_items
        return ctx

    def parse(self, raw: str | list | None) -> list:
        if raw is None:
            return []
        if isinstance(raw, list):
            return raw
        if isinstance(raw, str):
            try:
                import json

                return json.loads(raw)
            except (json.JSONDecodeError, TypeError):
                return [raw] if raw else []
        return []

    def validate(self, value: Any, field: FieldMeta) -> list[str]:
        errors = super().validate(value, field)
        if isinstance(value, list):
            if self.min_items and len(value) < self.min_items:
                errors.append(f"{field.label} requires at least {self.min_items} items.")
            if self.max_items and len(value) > self.max_items:
                errors.append(f"{field.label} allows at most {self.max_items} items.")
        return errors

Relationship Widgets

RelationPickerWidget

fastapi_admin_kit.widgets.relation.RelationPickerWidget

Bases: Widget

ForeignKey (many-to-one) picker — HTMX async searchable select with autocomplete.

Source code in fastapi_admin_kit/widgets/relation.py
class RelationPickerWidget(Widget):
    """ForeignKey (many-to-one) picker — HTMX async searchable select with autocomplete."""

    macro_name = "relation_picker"

    def __init__(
        self,
        related_table: str = "",
        related_verbose: str = "",
        autocomplete: bool = True,
        search_url: str | None = None,
    ):
        self.related_table = related_table
        self.related_verbose = related_verbose
        self.autocomplete = autocomplete
        self.search_url = search_url

    def parse(self, raw: str | None) -> int | str | None:
        if not raw:
            return None
        try:
            return int(raw)
        except ValueError:
            return raw

    def render_context(self, field: FieldMeta, value: Any) -> dict:
        ctx = super().render_context(field, value)
        ctx["related_table"] = self.related_table
        ctx["related_verbose"] = self.related_verbose
        ctx["autocomplete"] = self.autocomplete
        ctx["search_url"] = self.search_url or f"/admin/{self.related_table}/autocomplete/"
        return ctx

MultiRelationWidget

fastapi_admin_kit.widgets.relation.MultiRelationWidget

Bases: Widget

Many-to-many tag-style multi-select.

Source code in fastapi_admin_kit/widgets/relation.py
class MultiRelationWidget(Widget):
    """Many-to-many tag-style multi-select."""

    macro_name = "multi_relation"

    def __init__(self, related_table: str = "", related_verbose: str = "", search_url: str = ""):
        self.related_table = related_table
        self.related_verbose = related_verbose
        self.search_url = search_url

    def render_context(self, field: FieldMeta, value: Any) -> dict:
        ctx = super().render_context(field, value)
        ctx["related_table"] = self.related_table
        ctx["related_verbose"] = self.related_verbose
        ctx["search_url"] = self.search_url
        return ctx

    def parse(self, raw: str | list | None) -> list[str]:
        import json as _json

        if raw is None:
            return []
        if isinstance(raw, list):
            return [str(v) for v in raw if v]
        s = str(raw).strip()
        if s.startswith("["):
            try:
                parsed = _json.loads(s)
                if isinstance(parsed, list):
                    return [str(v) for v in parsed if v]
            except (_json.JSONDecodeError, TypeError):
                pass
        return [str(raw)] if raw else []

    def validate(self, value: Any, field: FieldMeta) -> list[str]:
        return []

Widget Registry

fastapi_admin_kit.widgets.registry.WidgetRegistry

Stores SQLAlchemy column type and name pattern mappings to widget classes.

This class is responsible ONLY for registration and storage. Use WidgetResolver to determine which widget to use for a column.

Source code in fastapi_admin_kit/widgets/registry.py
class WidgetRegistry:
    """Stores SQLAlchemy column type and name pattern mappings to widget classes.

    This class is responsible ONLY for registration and storage.
    Use WidgetResolver to determine which widget to use for a column.
    """

    def __init__(self) -> None:
        self._type_map: dict[type, type[Widget]] = {}
        self._name_patterns: list[tuple[str, type[Widget]]] = []

    @property
    def type_map(self) -> dict[type, type[Widget]]:
        """Read-only access to the type-to-widget mapping."""
        return dict(self._type_map)

    @property
    def name_patterns(self) -> list[tuple[str, type[Widget]]]:
        """Read-only access to the name pattern list."""
        return list(self._name_patterns)

    def register_type(self, sa_type: type, widget_cls: type[Widget]) -> None:
        """Register a widget class for a SQLAlchemy column type."""
        self._type_map[sa_type] = widget_cls

    def unregister_type(self, sa_type: type) -> None:
        """Remove a registered type mapping."""
        self._type_map.pop(sa_type, None)

    def register_name(self, pattern: str, widget_cls: type[Widget]) -> None:
        """Register a widget class for a name pattern (case-insensitive substring)."""
        self._name_patterns.append((pattern.lower(), widget_cls))

    def unregister_name(self, pattern: str) -> None:
        """Remove all registrations for a name pattern."""
        self._name_patterns = [(p, w) for p, w in self._name_patterns if p != pattern.lower()]

    def clear(self) -> None:
        """Remove all registered mappings."""
        self._type_map.clear()
        self._name_patterns.clear()

    def has_type(self, sa_type: type) -> bool:
        """Check if a type is registered."""
        return sa_type in self._type_map

    def has_name(self, pattern: str) -> bool:
        """Check if a name pattern is registered."""
        return any(p == pattern.lower() for p, _ in self._name_patterns)

type_map property

Read-only access to the type-to-widget mapping.

name_patterns property

Read-only access to the name pattern list.

register_type(sa_type, widget_cls)

Register a widget class for a SQLAlchemy column type.

Source code in fastapi_admin_kit/widgets/registry.py
def register_type(self, sa_type: type, widget_cls: type[Widget]) -> None:
    """Register a widget class for a SQLAlchemy column type."""
    self._type_map[sa_type] = widget_cls

unregister_type(sa_type)

Remove a registered type mapping.

Source code in fastapi_admin_kit/widgets/registry.py
def unregister_type(self, sa_type: type) -> None:
    """Remove a registered type mapping."""
    self._type_map.pop(sa_type, None)

register_name(pattern, widget_cls)

Register a widget class for a name pattern (case-insensitive substring).

Source code in fastapi_admin_kit/widgets/registry.py
def register_name(self, pattern: str, widget_cls: type[Widget]) -> None:
    """Register a widget class for a name pattern (case-insensitive substring)."""
    self._name_patterns.append((pattern.lower(), widget_cls))

unregister_name(pattern)

Remove all registrations for a name pattern.

Source code in fastapi_admin_kit/widgets/registry.py
def unregister_name(self, pattern: str) -> None:
    """Remove all registrations for a name pattern."""
    self._name_patterns = [(p, w) for p, w in self._name_patterns if p != pattern.lower()]

clear()

Remove all registered mappings.

Source code in fastapi_admin_kit/widgets/registry.py
def clear(self) -> None:
    """Remove all registered mappings."""
    self._type_map.clear()
    self._name_patterns.clear()

has_type(sa_type)

Check if a type is registered.

Source code in fastapi_admin_kit/widgets/registry.py
def has_type(self, sa_type: type) -> bool:
    """Check if a type is registered."""
    return sa_type in self._type_map

has_name(pattern)

Check if a name pattern is registered.

Source code in fastapi_admin_kit/widgets/registry.py
def has_name(self, pattern: str) -> bool:
    """Check if a name pattern is registered."""
    return any(p == pattern.lower() for p, _ in self._name_patterns)