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),
slots — admin.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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ce01e81f103e985b03affe94d9932151fb95ae9c1f81e9e198376ef58aaead6d
|
|
| MD5 |
c8e54adefc9dcb0baf352b31554d754c
|
|
| BLAKE2b-256 |
c1e7c8676add1003786a70a29f3ac417165d441b4a5ff8e197ce08cc3f048846
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ea2eb5eb4810737c1266d068069ecd4146d3f47dbe200827995f2c648c4e34ce
|
|
| MD5 |
077cf39e5ff5da622eb9c525d96fe41f
|
|
| BLAKE2b-256 |
67280f4c720dad1c73a918cc9400e59dcf85d731914fcf6205b1d10f1ac3528f
|