Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

Warder

A declarative admin for Sillo. A warder keeps the keys; this one keeps your models — what is listed, what is editable, who may see it, and which rows are theirs.

pip install warder
from warder import (
    Access,
    Action,
    Admin,
    Column,
    Field,
    Filter,
    Form,
    List,
    Resource,
    Section,
    Sort,
    notice,
)

admin = Admin(title="Acme Ops", prefix="/admin")

admin.add(
    Resource(
        Post,
        group="Content",
        icon="file-text",
        list=List(
            Column("title", link=True),
            Column.relation("author", display="email"),
            Column.badge("status", colors={"live": "green", "draft": "zinc"}),
            Column.date("published_at", label="Published", style="relative"),
            Column.compute(
                "Words", lambda row: len(row.body.split()), sort="word_count"
            ),
            filters=[
                Filter.search("title", "body"),
                Filter.choice("status", ["draft", "live"]),
                Filter.date_range("published_at", presets=["7d", "30d", "quarter"]),
            ],
            actions=[Action("Publish", publish, confirm="Publish {count} posts?")],
            sort=Sort.desc("published_at"),
        ),
        form=Form(
            Section("Content", Field("title"), Field.markdown("body")),
            Section("Publishing", Field("status"), Field("published_at")),
            Section("Audit", Field.readonly("created_at"), collapsed=True),
        ),
        access=Access(
            view=True,
            add="post.add",
            change=lambda ctx, row: row.author_id == ctx.user.id,
            delete=False,
        ),
    )
)

admin.mount(app)

Why this exists

Three ideas hold the whole package up, and each one is a decision you can feel by the second screen you write.

A type annotation describes a type. It never selects behaviour.

Nothing here reads __annotations__, and nothing changes because a parameter is spelled one way rather than another. Where something must be injected it arrives as a value — a default, a keyword — because a value is visible and an annotation is not. The types on this package's own declarations exist so your editor can complete column.sort_field, and for nothing else.

The one place arity would ordinarily be sniffed is a rule callable, so it isn't: a rule is always called as (ctx, row), with row set to None when the question is about the model rather than one row.

Access(change=lambda ctx, row: row.team_id == ctx.user.team_id)
Gate.custom(lambda ctx: ctx.user.email.endswith("@acme.com"))  # gates see no row

A declaration is a value

Nameable, storable, comparable, generatable in a loop, extendable with .with_(). No metaclass, no class attributes with meanings you cannot derive, no registration by import side effect.

def reference_data(model, *fields):
    return Resource(
        model,
        group="Reference",
        list=List(*[Column(f) for f in fields], sort=Sort.asc(fields[0])),
        form=Form(Section("", *[Field(f) for f in fields])),
        access=Access.by_permission("reference", delete=False),
    )


for model in (Tag, Category, Region, Currency):
    admin.add(reference_data(model, "name", "slug"))

Extending appends parts and replaces keywords, returning a new declaration — so a shared base cannot be edited by its fortieth user:

BASE = List(Column("id"), Column("name"), per_page=50)

admin.add(Resource(Tag, list=BASE.with_(Column("slug"))))
admin.add(Resource(Team, list=BASE.with_(Column.relation("owner"))))

Because a List is just a description of a table, it renders in your own route, over your own queryset, inside your own layout:

ORDERS = List(Column("id"), Column.money("total"), Column.badge("status"))


@app.get("/team/orders")
async def team_orders(ctx: HttpContext):
    return await admin.render(ctx, ORDERS, Order.filter(team_id=ctx.user.team_id))

Mistakes fail at mount, not at request

Every reference is resolved once, at start-up, against the model — and the error carries the file and line the declaration was written on, because "column 'titel' is not a field of Post" is half an error message without it.

DeclarationError: Resource(Post).list column 'titel' is not a field of Post.
  Did you mean 'title'?
  Declared at app/admin.py:24

Values are checked even earlier, from the constructor, where the mistake was typed:

Column("total", align="middle")
# ValueError: align='middle' is not valid. Use one of: 'left', 'center', 'right'.

The N+1 is derived away

Declaring a relation column is what removes it. List.joins collects the relations its columns and filters traverse, so the join list cannot fall behind the column list — it is the column list:

>>> List(Column("author__email"), Column.relation("team"), Column("title")).joins
('author', 'team')

A select_related= attribute maintained beside the columns is a list that falls out of step silently, and costs fifty queries a page when it does.

Actions get a queryset

Not a list of ids. An action over forty thousand selected rows is one statement:

async def publish(ctx, rows):
    count = await rows.filter(status="draft").update(status="live")
    return notice(f"Published {count} posts")

How the handler is called is decided by the declaration, visibly, and never by inspecting its signature: with no fields= it is (ctx, rows); with fields= it is (ctx, rows, values), where values is the little form the confirmation dialog collected.

Action("Assign", assign, fields=[Field.relation("assignee")])

Outcomes are free builders, the way json() and text() are elsewhere in Sillo — notice, warning, problem, go, download, modal, refresh. Returning None means "it worked, reload".

Signing in

warder create-admin app.admin:admin     # prompts for email and password
warder users app.admin:admin            # who can sign in, and when they last did

Admin() with no auth= already has a working sign-in: the bundled AdminUser, a session backend, Gate.staff(), and session middleware installed on mount if the application has none — a sign-in page without a session is a form that forgets you.

create-admin writes wherever the admin authenticates from. It writes the column the sign-in form asks for, sets only the flags the model actually has, reads the password twice without echo, and hashes through sillo.hashing. It prompts only at a terminal, so a script gets an error naming the flag rather than a hang.

The bundled models are not registered by importing Warder — model discovery scans a module's namespace, so that would put warder_users in the database of every project that installs the package. Name it to opt in:

setup_record(app, config, model_modules=["myapp.models", "warder.models"])

Permissions: four questions, four layers

They really are different questions, and one answer does not cover the others.

Question Answered by
May you get in at all? Gate
May you do this to this model? Access
May you do it to this row? Access callable, and Scope
May you see this field? Access on a Column or Field
admin = Admin(
    title="Acme Ops",
    auth=Auth(
        users=User,  # your model; omit for the bundled one
        gate=Gate.staff(),  # who may enter at all
        session=Session(idle="30m", absolute="12h", concurrent=1),
        login=Login(throttle="5/15m", remember=True),
        mfa=MFA.totp(required=Gate.role("owner")),
        impersonation=Impersonation(gate=Gate.permission("users.impersonate")),
        audit=Audit(retain="1y", redact=["password", "token", "secret"]),
    ),
)

admin.roles(
    Role("support", grants=["order.view", "customer.view"]),
    Role("editor", grants=Role.crud(Post, Tag), inherits=["support"]),
    Role("owner", grants="*"),
)

Nothing here is a second authorisation system: it compiles onto sillo.permissions, which already ships Permission, Group, UserPermission and PermissionMixin. Registering a Resource declares four permissions — post.view, post.add, post.change, post.delete — and what a deployment can grant follows what is registered rather than being typed twice.

Access and Scope are both needed and neither substitutes for the other:

Resource(
    Order,
    access=Access(change=lambda ctx, row: row.team_id == ctx.user.team_id),
    scope=Scope.tenant("team_id"),
)

Inside a rule of your own, read the account with current_user(ctx) rather than ctx.user. The context raises when no authentication middleware is installed, and getattr(ctx, "user", None) does not help — the default only catches AttributeError, and what comes out is a ValueError:

from warder import Scope, current_user

async def my_students(ctx, rows):
    staff = await Staff.filter(user_id=getattr(current_user(ctx), "id", None)).first()
    return rows.filter(classroom__form_teacher_id=staff.id) if staff else rows.none()

Access decides whether a button is shown and whether a write is allowed; Scope decides what is in the queryset at all. Access without scope leaks the existence of rows through pagination counts and search results; scope without access leaves a writable object reachable by its id.

A field you may not view is absent from the props, not hidden with CSS — so it never reaches the browser:

Field("salary", access=Access(view="hr.salary.view", change="hr.salary.change"))

Gate.staff() is the default, and it matters more than it looks: when the admin shares the application's user model — the ordinary arrangement — every registered account holds a session, and admitting anyone with one hands over the database.

Style

Four directions, one token set, all four light and dark. This is a choice about defaults, not about architecture, so switching is a keyword.

Console (default) Dense, quiet, keyboard-first. 36px rows, hairline borders, tabular numerals, monospace ids. The one that does not fight a dense table
Paper Light, generous, editorial. 48px rows, soft shadows. Shows about half as much per screen, and reads beautifully
Grid Spreadsheet-first. 28px rows, ruled cells, no card chrome. For reconciliation, imports, moderation queues
Native No opinion. Emits structure and no colour, so your design system's tokens win by not being overridden
Admin(theme=Theme.console(accent="#4f46e5", density="compact"))
Admin(theme=Theme.native())

Everything writes CSS custom properties into the shell. No rebuild, no Node — which is what keeps theming a keyword rather than an ejection.

The interface

Inertia, React and Tailwind, and it is in the wheel.

list      sortable columns, URL-backed filters, selection, bulk actions, paging,
          column visibility, CSV and JSON export of the filtered set
form      a control per widget kind, conditional fields, per-field errors,
          a searching relation picker, Markdown with preview
detail    panels: fields, and child tables drawn with the child resource's
          own columns
dashboard number, chart and table cards
shell     grouped navigation, flash messages, a light/dark toggle, `/` to
          search and ⌘K to go anywhere

Python sends a resolved declaration; React is a generic renderer for it. The front end has never heard of a Post — it knows what a badge column is and what a relation picker is. So adding Column.badge("status", colors=…) changes a prop, not a template, and a new resource never needs the interface rebuilt.

The division of labour is deliberate. Python extracts: which columns exist for this person, which rows they may see, what each cell holds — all of it authorisation-dependent and impossible to do safely in a browser. React formats: money in the viewer's locale, a timestamp as "3 days ago", a status as a coloured pill — all of it locale- and viewport-dependent, and wasteful on a server that knows neither.

pip install warder does not require Node. One JavaScript file and one stylesheet ship under warder/static/; the React sources live in ui/ and are excluded from the wheel. One file because the admin is served under a prefix you choose, and code-splitting would have to resolve chunk URLs against a base it cannot know until runtime. Nothing is fetched from a CDN, so the admin works on an air-gapped network and under a Content-Security-Policy that forbids third-party script — which are the normal conditions for the people who most want an admin panel.

Inertia is implemented in this package rather than depended on: sillo-inertia is written against the 0.x Request/Response API and this is written against the context API, and blocking the whole interface on another repository's port was the wrong trade against two hundred lines of a published protocol.

Customising has three rungs, in increasing order of commitment: theme tokens (no rebuild — Python writes them into the document as custom properties), slotsadmin.slot("list.toolbar", "acme/ExportButton"), mounted from your own build — and warder eject, which copies ui/ into your project and hands you the upgrades.

Resource(Post) is already a screen

Everything is optional but the model. With no list=, form= or detail=, all three are built at mount from the model's own columns — identity first, then state, then time; a search box over the text columns, a chip per state, a date range; every writable column on the form with the timestamps collapsed into an Audit group; and a window onto each child table.

admin.add(Resource(Post))  # a working list, form and detail page
admin.add(Resource(Post, sort="-published_at", search=["title", "body"]))

The inference reads the schema — what the database says a column is — and never an annotation. A TextField gets a textarea because the column is long text. Naming a widget is for when the default is wrong about the meaning rather than the type: body and internal_note are both long text and only one of them wants Markdown.

Every derived part is replaced by naming it, and nothing fights a declaration that exists.

Checking without starting the application

$ warder check app.admin:admin
Acme Ops: 12 resources, 2 pages, 51 permissions. Every reference resolves.

$ warder permissions app.admin:admin
post.add
post.change
...

warder check resolves every declaration against its models exactly as mount() does, and exits non-zero on the first problem — so a misspelled column fails in CI rather than in production. It needs no server, no port and no database connection, only the models importable.

warder permissions prints what the site declares, which is how you seed a fixtures file or write a role against what actually exists.

Status

Alpha, and honest about which parts exist.

The declaration layer — every value in the table below, frozen, comparable, extendable
The site registry, navigation, declared permissions, and the checks that need no ORM
The resolver: binding to models, deriving screens, checking every reference
🚧 The routes, the Inertia interface, and the bundled assets
The routes, the Inertia interface, and the bundled assets
Session sign-in, throttling, session lifetimes, the bundled user model
warder check, create-admin, users, permissions, routes
🚧 Inline editing in child panels; MFA and impersonation are declarable but not yet enforced
🚧 The activity log is written to but has no screen yet
🚧 warder permissions sync, warder eject

admin.mount(app) works end to end. What is not built is listed above rather than implied by silence.

The vocabulary

Admin The site. Resources, pages, auth, theme; .mount(app)
Resource One model's surface: list, detail, form, access, scope
List Form Detail The three screens
Column Field Filter A list column, a form input, a list filter
Action Something a person can do to rows
Section Panel Grouping on a form, blocks on a detail page
Access Gate Scope Role Who may do what, and to which rows
Sort Format Widget Ordering, how a value is drawn, how it is edited
When A form condition the browser and the server both evaluate
Page Card Dashboard Screens that are not a model
Theme Four directions over one token set
Auth Session Login MFA Impersonation Audit Everything about who may be here

Sort, not Order: Order is one of the most common model names there is, and an admin module importing both would have a bug in it that reads as correct code.

Requirements

Python 3.10 to 3.14, and Sillo v1. Nothing else at runtime, and no Node.

Warder is written against the context API — HttpContext, ctx-first handlers, and the free builders in sillo.responses. That is the framework's main branch and it is not on PyPI yet, so until v1 ships:

pip install "sillo-framework @ git+https://github.com/sillohq/core@main"
pip install warder --no-deps

The dependency is pinned to >=1.0 rather than loosened to match what is published, because the released 0.x has no sillo.responses at all — a wheel that installed against it would fail at the first request instead of at install time.

Working on Warder itself needs Node, but only to rebuild the interface:

cd ui && npm install && npm run build     # → warder/static/
warder check app.admin:admin              # no server, no port, no database

Licence

BSD-3-Clause.

Download files

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

Source Distribution

warder-1.0.0a2.tar.gz (391.0 kB view details)

Uploaded Source

Built Distribution

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

warder-1.0.0a2-py3-none-any.whl (295.0 kB view details)

Uploaded Python 3

File details

Details for the file warder-1.0.0a2.tar.gz.

File metadata

  • Download URL: warder-1.0.0a2.tar.gz
  • Upload date:
  • Size: 391.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.12.13 {"installer":{"name":"uv","version":"0.12.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for warder-1.0.0a2.tar.gz
Algorithm Hash digest
SHA256 ce01e81f103e985b03affe94d9932151fb95ae9c1f81e9e198376ef58aaead6d
MD5 c8e54adefc9dcb0baf352b31554d754c
BLAKE2b-256 c1e7c8676add1003786a70a29f3ac417165d441b4a5ff8e197ce08cc3f048846

See more details on using hashes here.

File details

Details for the file warder-1.0.0a2-py3-none-any.whl.

File metadata

  • Download URL: warder-1.0.0a2-py3-none-any.whl
  • Upload date:
  • Size: 295.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.12.13 {"installer":{"name":"uv","version":"0.12.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for warder-1.0.0a2-py3-none-any.whl
Algorithm Hash digest
SHA256 ea2eb5eb4810737c1266d068069ecd4146d3f47dbe200827995f2c648c4e34ce
MD5 077cf39e5ff5da622eb9c525d96fe41f
BLAKE2b-256 67280f4c720dad1c73a918cc9400e59dcf85d731914fcf6205b1d10f1ac3528f

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

1.0.0a2 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page