Skip to main content

A declarative UI framework for FastAPI, inspired by FilamentPHP

Project description

Nuru

CI Python versions PyPI version

A declarative admin panel framework for FastAPI. Define your resources once in Python — get a full admin UI with tables, forms, detail views, search, sorting, file uploads, actions, and role-based access control. No HTML, no JS, no separate frontend process.

Screenshot_20260418_130550 image image image image

Installation

pip install nuru

Or from source:

git clone https://github.com/coolsam726/nuru
cd nuru
pip install -e .

Quickstart

from fastapi import FastAPI
from nuru import Panel, Resource, Form, Table
from nuru import forms
from nuru.columns import Text, Badge

app = FastAPI()

# 1. Define a resource
class BookResource(Resource):
    label = "Book"
    label_plural = "Books"
    search_fields = ["title", "isbn"]

    def table(self) -> Table:
        return Table().schema([
            Text("title",  "Title",  sortable=True),
            Text("author", "Author", sortable=True),
            Badge("status", "Status", colors={
                "available": "green", "checked_out": "blue", "lost": "red",
            }),
        ])

    def form(self) -> Form:
        return Form().schema([
            forms.TextInput("title").label("Title").required(),
            forms.TextInput("author").label("Author").required(),
            forms.Select("status").label("Status").options([
                ("available",   "Available"),
                ("checked_out", "Checked Out"),
                ("lost",        "Lost"),
            ]).native(),
        ])

    async def get_list(self, *, page, per_page, search, **kwargs):
        return {"records": await book_service.list(page=page, search=search), "total": ...}

    async def get_record(self, id):
        return await book_service.get(id)

    async def save_record(self, id, data):
        return await book_service.create(data) if id is None else await book_service.update(id, data)

    async def delete_record(self, id):
        await book_service.delete(id)

# 2. Create the panel
class MyPanel(Panel):
    title = "My App"
    prefix = "/admin"
    primary_color = "#6366f1"
    resources = [BookResource]

# 3. Mount
panel = MyPanel()
panel.mount(app)

Open http://localhost:8000/admin — done.

Nuru never touches your existing routes, middleware, OpenAPI docs, or dependency injection setup. All admin routes are excluded from your OpenAPI schema by default.


Architecture overview

Panel                      — Subclass to configure your panel (title, prefix, auth, resources, pages)
├── Resource               — One per model/entity; define table(), form(), infolist()
│   ├── Table              — Fluent builder for the list-page column layout
│   ├── Form               — Fluent builder for create/edit forms
│   ├── Infolist           — Fluent builder for read-only detail views
│   └── Action             — Server-side action buttons (row, form, list-header)
└── Page                   — Free-form pages with custom templates and context

All builders use a fluent API — every setter returns self for chaining. Template-facing reads always use explicit get_*() / is_*() getter methods.


Panel

Define your panel by subclassing Panel:

from nuru import Panel
from nuru.auth import SimpleAuthBackend

class MyPanel(Panel):
    title = "Kibrary"
    prefix = "/admin"
    primary_color = "#6366f1"
    per_page = 20
    resources = [BookResource, AuthorResource, MemberResource]
    pages = [ReportsPage]
    auth_backend = SimpleAuthBackend(
        username="admin",
        password="secret",
        secret_key="change-me",
    )

panel = MyPanel()
panel.mount(app)

Or configure fluently at runtime:

panel = Panel()
panel.title("Kibrary").prefix("/admin").per_page(20).auth_backend(my_auth)
panel.register(BookResource)
panel.register_page(ReportsPage)
panel.mount(app)

Panel options

Option Type Description
title str Sidebar header and browser tab title
prefix str URL prefix for all admin routes (default: /admin)
primary_color str Hex colour for accent elements (buttons, sidebar)
per_page int Default pagination page size (default: 25)
auth_backend AuthBackend Auth backend instance (omit for no auth)
permission_checker callable (user, codename, resource) → bool
upload_backend FileBackend Storage backend for file uploads
upload_dir str | Path Upload root directory (used by LocalFileBackend)
extra_css str | list[str] Additional stylesheet URL(s) loaded after Nuru's CSS
extra_js str | list[str] Additional script URL(s)

Resource

Subclass Resource and define table(), form(), and optionally infolist():

from nuru import Resource, Form, Table, Infolist

class AuthorResource(Resource):
    label = "Author"
    label_plural = "Authors"
    slug = "authors"               # auto-derived from label if omitted
    nav_icon = "user"
    nav_sort = 10
    model = Author                 # SQLModel class — enables auto-CRUD
    session_factory = get_session  # async context-manager factory
    search_fields = ["name", "bio"]
    load_options = [selectinload(Author.books)]

    def table(self) -> Table: ...
    def form(self)  -> Form:  ...
    def infolist(self) -> Infolist: ...   # optional; falls back to form fields

Resource options

Option Type Description
label / label_plural str Display names
slug str URL segment (auto-derived from label if blank)
nav_icon / nav_sort / show_in_nav Sidebar navigation
model SQLModel Enables automatic CRUD and column/field generation
session_factory callable Zero-arg async context-manager yielding AsyncSession
search_fields list[str] Column names for ILIKE search
load_options list SQLAlchemy loader options (selectinload, etc.)
can_create / can_edit / can_delete / can_view bool Toggle CRUD operations
options_label_field str Attribute used as label in BelongsTo selectors

Data hooks

Override these to connect your own service/ORM layer:

Method When called Must return
get_list(page, per_page, search, sort_by, sort_dir, filters) List page {"records": [...], "total": int}
get_record(id) Edit/view page load single record
save_record(id, data) Form submit (id=None = create) saved record
delete_record(id) Delete action None
after_save(record_id, data) After save_record None (M2M, side-effects)
get_options(q) BelongsTo selector search [{"value": ..., "label": ...}]

When model and session_factory are set all hooks have a default implementation — override only what you need.

SQLModel auto-CRUD

class MemberResource(Resource):
    label = "Member"
    model = Member
    session_factory = get_session
    search_fields = ["name", "email", "member_number"]
    load_options = [selectinload(Member.checkouts)]

Columns and form fields are auto-generated from model annotations when neither table() nor form() is defined.


Table (list view)

from nuru import Table
from nuru.columns import Text, Badge, Boolean, Currency, DateTime, Image

def table(self) -> Table:
    return (
        Table()
        .schema([
            Image("avatar",     "Photo"),
            Text("name",        "Name",   sortable=True),
            Text("email",       "Email"),
            Badge("status",     "Status", colors={"active": "green", "suspended": "red"}),
            Boolean("active",   "Active"),
            Currency("balance", "Balance", currency="KES"),
            DateTime("joined",  "Joined",  date_only=True),
        ])
        .row_actions([
            Action.make("suspend").label("Suspend").style("danger")
                .handler("do_suspend").confirm("Suspend this member?"),
        ])
    )

Column types

Class Description Extra fluent options
Text Plain text .max_length(n)
Badge Colored pill .colors({"value": "color_name"})
Boolean Yes/No badge .labels("Yes", "No")
Currency Formatted number .currency("KES"), .decimals(2)
DateTime Date or datetime .format("%d %b %Y"), .date_only()
Image Thumbnail .url_prefix("/uploads"), .img_class("…")

All columns accept dot-notation keys for traversing relationships: Text("author.name", "Author").

Column fluent API

Text.make("isbn").label("ISBN").sortable()
Badge.make("status").colors({"draft": "amber", "published": "green"})
Image.make("cover").url_prefix("/admin/uploads").img_class("w-12 h-16 rounded")

Every column exposes explicit getters: get_label(), get_key(), is_sortable(), get_img_class(), etc.


Form (create / edit view)

from nuru import Form
from nuru import forms

def form(self) -> Form:
    return (
        Form()
        .schema([
            forms.Section(
                [
                    forms.TextInput("name").label("Full name").required(),
                    forms.TextInput("email").email().label("Email").required(),
                    forms.Select("membership").label("Type").options([
                        ("standard", "Standard"),
                        ("student",  "Student"),
                        ("senior",   "Senior"),
                        ("staff",    "Staff"),
                    ]).native(),
                    forms.Checkbox("active").label("Active"),
                ],
                title="Details", cols=2,
            ),
            forms.Section(
                [
                    forms.FileUpload("avatar").label("Photo").image()
                        .directory("members")
                        .accept_file_types(["image/jpeg", "image/png"])
                        .max_file_size(5 * 1024 * 1024)
                        .image_crop_aspect_ratio("1:1")
                        .col_span("full"),
                ],
                title="Photo",
            ),
        ])
        .actions([
            Action.make("export").label("Export PDF")
                .handler("export_pdf").placement("header"),
        ])
    )

Field types

Class Description
TextInput Single-line text (base for Email, Password)
Email Text + type="email" + email validator
Password Text + type="password"
Number Numeric input
Textarea Multi-line text
Select Static list, tuple pairs, callable, or model-backed combobox
Checkbox Single boolean checkbox
CheckboxGroup Multi-select checkbox group
Radio Radio button group
RadioButtons Pill-style radio buttons
Toggle Styled on/off toggle
DatePicker Date picker widget
TimePicker Time picker widget
DateTimePicker Combined date + time picker
FileUpload FilePond-powered file/image upload
Hidden Hidden input
Section Groups fields under a titled card (cols, col_span)

Common field fluent API

forms.TextInput("username")
    .label("Username")
    .placeholder("johndoe")
    .help_text("Used for login.")
    .required()
    .max_length(64)
    .col_span("full")   # "full", 1, 2, 3, 4
    .disabled()
    .readonly()

Field.make("key") factory is available on every field class.

Select options — all formats accepted

Select, Radio, RadioButtons, and CheckboxGroup all normalise options to {"value": …, "label": …} dicts automatically:

# Tuple pairs  (recommended for readability)
.options([("draft", "Draft"), ("published", "Published")])

# Plain dicts
.options([{"value": "draft", "label": "Draft"}, ...])

# Bare strings  (value == label)
.options(["draft", "published", "archived"])

# Callable — resolved at render time, may return any format above
.options(lambda record=None: [("a", "Alpha"), ("b", "Beta")])

Model-backed combobox

For foreign-key fields, use a model-backed Select that queries the built-in /_model_search endpoint as the user types:

forms.Select.make("author_id").label("Author")
    .model(Author, label_field="name")
    .relationship("author")   # attr on the record for pre-populating the label
    .required()
    .remote_search()

Server-side validation

All form submissions are validated before save_record() is called. Errors appear inline next to each field (HTTP 422, no separate error page).

Validator How to enable What it checks
required .required() Non-empty value present
max_length .max_length(n) String length ≤ n
email .email() RFC-style user@host.tld pattern
url .url() Has scheme + netloc
numeric .add_validator("numeric") Float-coercible
integer .add_validator("integer") Integer-coercible (no decimals)

The same validation fires for Action modal fields before the handler is called.


Infolist (detail / view)

Infolist renders a read-only detail page. Without an infolist() override, it falls back to the form fields.

from nuru import Infolist
from nuru.infolists.components import (
    TextEntry, ImageEntry, BooleanEntry, BadgeEntry, DateEntry, FileEntry,
)

def infolist(self) -> Infolist:
    return Infolist().schema([
        forms.Section(
            [
                ImageEntry.make("avatar").label("Photo")
                    .img_class("size-24 rounded-full object-cover")
                    .url_prefix("/admin/uploads"),
                TextEntry.make("name").label("Full name"),
                TextEntry.make("email").label("Email"),
                DateEntry.make("joined_on").label("Joined on"),
                BadgeEntry.make("status").label("Status").colors({
                    "active": "green", "suspended": "red",
                }),
                BooleanEntry.make("active").label("Active"),
            ],
            title="Details", cols=2,
        ),
    ])

Infolist entry types

Class Description
TextEntry Plain text value
ImageEntry Image thumbnail with optional URL prefix
BooleanEntry Yes/No badge
BadgeEntry Colored pill
DateEntry Formatted date
FileEntry Download link

Actions

Actions are server-side button handlers that appear in three locations:

  • row_actions — per-row buttons in the table
  • list_actions — buttons in the list-page header
  • form_actions — buttons in the form header or inline
from nuru import Action
from nuru import forms

def form(self) -> Form:
    return (
        Form()
        .actions([
            Action.make("mark_returned")
                .label("Mark Returned")
                .style("success")
                .handler("mark_returned")
                .placement("header")
                .confirm("Mark this book as returned?")
                .icon("M5 13l4 4L19 7"),

            Action.make("add_note")
                .label("Add Note")
                .handler("add_note")
                .placement("inline")
                .fields([
                    forms.Textarea("note").label("Note").required(),
                ]),
        ])
        .schema([...])
    )

async def mark_returned(self, record_id, data, request):
    async with get_session() as session:
        record = await session.get(MyModel, int(record_id))
        record.status = "returned"
        await session.commit()

async def add_note(self, record_id, data, request):
    note = data.get("note", "")
    ...

Action fluent API

Method Description
.label("…") Button label
.icon("svg_path") SVG path data for a 24×24 icon
.style("danger") default, primary, secondary, success, warning, danger
.confirm("…") Show a confirmation prompt before executing
.handler("method_name") Name of the method to call on the Resource
.placement("header") "row", "header", or "inline"
.fields([…]) Form fields shown in a modal before executing
.modal_title("…") Modal window title
.submit_label("…") Modal submit button label

Handler signature: async def my_handler(self, record_id, data, request). Return None for the default redirect, or a URL string to redirect elsewhere.


Pages

Free-form admin pages beyond the standard CRUD views:

from fastapi import Request
from fastapi.responses import Response, RedirectResponse
from nuru import Page

class ReportsPage(Page):
    label = "Reports"
    slug = "reports"
    nav_icon = "chart-bar"
    nav_sort = 100

    async def get_context(self, request: Request) -> dict:
        return {
            "total_books": await book_service.count(),
            "recent": await book_service.recent(10),
        }

    async def handle_post(self, request: Request) -> Response:
        form = await request.form()
        ...
        return RedirectResponse(f"{self.panel.prefix}/{self.slug}?success=1", status_code=303)

Place your template at templates/pages/reports.html relative to your app's template directory. The template extends layout.html.

Standalone table widget

Render a table inside any custom page template without a Resource:

{% set _columns = [
    columns.Text("name",   "Name"),
    columns.Badge("status","Status", colors={"ok": "green", "err": "red"}),
] %}
{% set _rows = recent_records %}
{% include "partials/table_widget.html" %}

Authentication

SimpleAuthBackend — single user

from nuru.auth import SimpleAuthBackend

class MyPanel(Panel):
    auth_backend = SimpleAuthBackend(
        username="admin",
        password="secret",
        secret_key="change-me-in-production",
    )

DatabaseAuthBackend — multi-user

from nuru.auth import DatabaseAuthBackend
from passlib.context import CryptContext

_pwd = CryptContext(schemes=["bcrypt"])

class MyPanel(Panel):
    auth_backend = DatabaseAuthBackend(
        user_model=StaffUser,
        session_factory=get_session,
        username_field="email",
        password_field="password",
        verify_password=_pwd.verify,
        secret_key="change-me-in-production",
        extra_fields=["name", "role"],
    )

Both backends sign a session cookie with itsdangerous.


Roles & Permissions

Concepts

Concept Description
Permission Fixed codename scoped to a resource + action — e.g. books:list, books:delete
Role Named group of permissions (many-to-many)
UserRole Which roles a user holds (identified by str(pk))

Codename format

{resource_slug}:{action} — for example:

Codename Meaning
books:list Browse the Books list page
books:create Create a new Book
books:edit Edit an existing Book
books:view View Book detail
books:delete Delete a Book
books:action Run any action on Books
books:action:export_csv Run only the export_csv action
books:* All operations on Books
* Superuser — everything

Setup

import nuru.roles   # registers the 4 nuru_* tables with SQLModel.metadata
from nuru.roles import db_permission_checker

class MyPanel(Panel):
    auth_backend = DatabaseAuthBackend(...)
    permission_checker = db_permission_checker

Sync the schema and upsert permission rows at startup:

from nuru.migrations import sync_schema

@app.on_event("startup")
async def on_startup():
    await sync_schema(engine, SQLModel.metadata)   # creates nuru_* tables
    await panel.sync_permissions(get_session)      # upserts permission codenames

Seeding roles programmatically

from sqlmodel import select
from nuru.roles import Permission, Role, RolePermission, UserRole

async def seed_roles(session):
    admin  = Role(name="Super Admin")
    viewer = Role(name="Read Only")
    session.add_all([admin, viewer])
    await session.flush()

    star = (await session.exec(select(Permission).where(Permission.codename == "*"))).first()
    session.add(RolePermission(role_id=admin.id, permission_id=star.id))

    view_perms = (await session.exec(
        select(Permission).where(Permission.codename.in_(["books:list", "books:view"]))
    )).all()
    for p in view_perms:
        session.add(RolePermission(role_id=viewer.id, permission_id=p.id))

    session.add(UserRole(user_id=str(user.id), role_id=admin.id))
    await session.commit()

Custom permission checker

async def my_checker(user, codename, resource):
    if user is None:
        return False
    if user.get("is_superuser"):
        return True
    return codename in user.get("_permissions", set())

class MyPanel(Panel):
    permission_checker = my_checker

File Upload

Nuru's FileUpload field is powered by FilePond (loaded from CDN — no build step needed).

from nuru.forms import FileUpload

# Single image upload with crop
FileUpload("avatar")
    .label("Profile Photo")
    .image()
    .directory("avatars")
    .accept_file_types(["image/jpeg", "image/png", "image/webp"])
    .max_file_size(2 * 1024 * 1024)
    .image_crop_aspect_ratio("1:1")
    .required()

# Multiple PDF attachments
FileUpload("documents")
    .label("Attachments")
    .multiple()
    .max_files(5)
    .accept_file_types(["application/pdf"])
    .max_file_size(10 * 1024 * 1024)

Storage backend

from pathlib import Path
from nuru.storage import LocalFileBackend

class MyPanel(Panel):
    upload_backend = LocalFileBackend(Path("/var/www/myapp/media"))

Uploaded files are served at {prefix}/uploads/<server_id> automatically.

FileUpload options

Method Description
.image() Enable image preview + EXIF orientation fix
.multiple() Allow multiple file selection
.max_files(n) Max number of files (requires .multiple())
.accept_file_types([…]) List of MIME types to accept
.max_file_size(bytes) Maximum file size in bytes
.directory("path") Sub-directory under upload root
.image_crop_aspect_ratio("1:1") Lock crop to a ratio
.image_resize(w, h, mode) Resize client-side before upload
.can_reorder(True) Allow drag-to-reorder
.can_download(True) Show download button (default: True)

Custom Tailwind classes

Nuru ships a pre-built tailwind.css that only scans Nuru's own templates. If your Resources or Pages use Tailwind classes not present in Nuru's templates, add a supplemental stylesheet:

/* my_app/static/admin-extra.input.css */
@import "tailwindcss";
@source "../**/*.py";
@source "../templates/**/*.html";
@variant dark (&:where(.dark, .dark *));
./node_modules/.bin/tailwindcss \
  -i my_app/static/admin-extra.input.css \
  -o my_app/static/admin-extra.css --minify
class MyPanel(Panel):
    extra_css = "/static/admin-extra.css"
    # extra_css = ["/static/a.css", "/static/b.css"]  # or a list

Running the example app

git clone https://github.com/coolsam726/nuru
cd nuru
pip install -e .

# Build the Tailwind CSS (requires Node ≥ 18)
npm install
npm run build:css

uvicorn example_app.main:app --reload
# open http://localhost:8000/admin

Developing? Run npm run watch:css in a second terminal to rebuild the stylesheet automatically as you edit templates.

CSS build commands

Command Effect
npm install Install tailwindcss + @tailwindcss/cli
npm run build:css One-off minified build → nuru/static/tailwind.css
npm run watch:css Rebuild on every template save

What's included

  • Fluent builder APIPanel, Resource, Table, Form, Infolist, Action all chain cleanly; no set_ prefixes; getters exposed as get_*() / is_*()
  • Typed columnsText, Badge, Currency, DateTime, Boolean, Image; dot-notation for relationship traversal ("author.name")
  • Typed form fieldsTextInput (+ Email, Password), Number, Textarea, Select, Checkbox, CheckboxGroup, Radio, RadioButtons, Toggle, DatePicker, TimePicker, DateTimePicker, FileUpload, Hidden, Section
  • Typed infolist entriesTextEntry, ImageEntry, BooleanEntry, BadgeEntry, DateEntry, FileEntry
  • Select options normalisation — tuple pairs ("val","Label"), plain dicts, bare strings, and callables all accepted by Select, Radio, RadioButtons, and CheckboxGroup
  • SQLModel auto-CRUD — set model + session_factory for zero-boilerplate list/get/create/update/delete; columns and fields auto-generated from model annotations
  • Actions — row, list-header, form-header, and inline actions with confirm modals and optional form fields; action-specific validation before handler call
  • Server-side validation — required, max_length, email, url, numeric, integer; field-level errors in the form UI (HTTP 422)
  • File upload (FilePond) — drag-and-drop, image preview, content-type and size validation, single/multiple modes, pluggable storage backends
  • Auth — signed-cookie session, SimpleAuthBackend, DatabaseAuthBackend, custom AuthBackend
  • Roles & PermissionsPermission, Role, RolePermission, UserRole tables; db_permission_checker; role management UI in the panel
  • Pages — free-form pages with custom templates, get_context() / handle_post(), standalone table_widget.html partial
  • HTMX — live search, sort, pagination without full-page reloads
  • Alpine.js 3.x — sidebar, theme toggle, dialog, combobox — no custom JS required
  • Dark mode — built-in, localStorage-persisted
  • Responsive — mobile sidebar, Tailwind CSS v4

What's coming

  • Dashboard widgets — stat cards, line/pie charts (Chart.js adapter)
  • Repeater field — repeatable sub-forms backed by JSON
  • Reactive field rulesvisible_when, depends_on, compute (Alpine bindings + server mirror)
  • Bulk actions — checkbox selection + bulk operation handlers

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

nuru-0.4.0.tar.gz (154.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

nuru-0.4.0-py3-none-any.whl (188.5 kB view details)

Uploaded Python 3

File details

Details for the file nuru-0.4.0.tar.gz.

File metadata

  • Download URL: nuru-0.4.0.tar.gz
  • Upload date:
  • Size: 154.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.4

File hashes

Hashes for nuru-0.4.0.tar.gz
Algorithm Hash digest
SHA256 f550a5cad575bb2cc5938c085565ecaebf7c196f521755b7379e7db1fe42ed9c
MD5 fa6a143a020d2e03679556a43593deb1
BLAKE2b-256 8196af6bc2164c42d483eb2142a0c939c5b898cbcb6dd057ce7479c437275149

See more details on using hashes here.

File details

Details for the file nuru-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: nuru-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 188.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.4

File hashes

Hashes for nuru-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 3a62e98b59702d53f629f9cedf45ac4f31fd1c007399e4612392a6205e8a4079
MD5 e83fdb8e14069bd387c35aa63bb4929a
BLAKE2b-256 be4b9e42a5a6bda060b3e5155dcb8f1334fa39e007047888eb02bd55fd83ed62

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page