warder 1.0.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. warder-1.0.0a1/.gitignore +14 -0
  2. warder-1.0.0a1/CHANGELOG.md +136 -0
  3. warder-1.0.0a1/LICENSE +27 -0
  4. warder-1.0.0a1/PKG-INFO +504 -0
  5. warder-1.0.0a1/README.md +468 -0
  6. warder-1.0.0a1/build_ui.py +86 -0
  7. warder-1.0.0a1/pyproject.toml +145 -0
  8. warder-1.0.0a1/tests/conftest.py +28 -0
  9. warder-1.0.0a1/tests/demo_admin.py +28 -0
  10. warder-1.0.0a1/tests/fakes.py +106 -0
  11. warder-1.0.0a1/tests/orm.py +144 -0
  12. warder-1.0.0a1/tests/test_access.py +401 -0
  13. warder-1.0.0a1/tests/test_actions.py +184 -0
  14. warder-1.0.0a1/tests/test_auth.py +236 -0
  15. warder-1.0.0a1/tests/test_base.py +211 -0
  16. warder-1.0.0a1/tests/test_columns.py +185 -0
  17. warder-1.0.0a1/tests/test_conditions.py +94 -0
  18. warder-1.0.0a1/tests/test_console.py +110 -0
  19. warder-1.0.0a1/tests/test_create_admin.py +273 -0
  20. warder-1.0.0a1/tests/test_display.py +133 -0
  21. warder-1.0.0a1/tests/test_errors.py +84 -0
  22. warder-1.0.0a1/tests/test_fields.py +144 -0
  23. warder-1.0.0a1/tests/test_filters.py +219 -0
  24. warder-1.0.0a1/tests/test_layout.py +110 -0
  25. warder-1.0.0a1/tests/test_naming.py +95 -0
  26. warder-1.0.0a1/tests/test_readme.py +210 -0
  27. warder-1.0.0a1/tests/test_resolve.py +722 -0
  28. warder-1.0.0a1/tests/test_resource.py +167 -0
  29. warder-1.0.0a1/tests/test_routes.py +498 -0
  30. warder-1.0.0a1/tests/test_schema.py +201 -0
  31. warder-1.0.0a1/tests/test_screens.py +251 -0
  32. warder-1.0.0a1/tests/test_serving.py +1010 -0
  33. warder-1.0.0a1/tests/test_site.py +314 -0
  34. warder-1.0.0a1/tests/test_sorting.py +73 -0
  35. warder-1.0.0a1/tests/test_surface.py +256 -0
  36. warder-1.0.0a1/tests/test_theme.py +160 -0
  37. warder-1.0.0a1/ui/package-lock.json +2997 -0
  38. warder-1.0.0a1/ui/package.json +26 -0
  39. warder-1.0.0a1/ui/src/components/ActionMenu.tsx +220 -0
  40. warder-1.0.0a1/ui/src/components/Chart.tsx +310 -0
  41. warder-1.0.0a1/ui/src/components/DataTable.tsx +249 -0
  42. warder-1.0.0a1/ui/src/components/DetailView.tsx +223 -0
  43. warder-1.0.0a1/ui/src/components/FilterBar.tsx +222 -0
  44. warder-1.0.0a1/ui/src/components/FormRenderer.tsx +108 -0
  45. warder-1.0.0a1/ui/src/components/Icon.tsx +70 -0
  46. warder-1.0.0a1/ui/src/components/Shell.tsx +333 -0
  47. warder-1.0.0a1/ui/src/components/Widgets.tsx +575 -0
  48. warder-1.0.0a1/ui/src/components/ui.tsx +356 -0
  49. warder-1.0.0a1/ui/src/index.css +108 -0
  50. warder-1.0.0a1/ui/src/lib/cn.ts +4 -0
  51. warder-1.0.0a1/ui/src/lib/condition.ts +50 -0
  52. warder-1.0.0a1/ui/src/lib/format.tsx +353 -0
  53. warder-1.0.0a1/ui/src/lib/theme.ts +43 -0
  54. warder-1.0.0a1/ui/src/lib/url.ts +41 -0
  55. warder-1.0.0a1/ui/src/main.tsx +47 -0
  56. warder-1.0.0a1/ui/src/pages/Dashboard.tsx +159 -0
  57. warder-1.0.0a1/ui/src/pages/Denied.tsx +34 -0
  58. warder-1.0.0a1/ui/src/pages/Detail.tsx +79 -0
  59. warder-1.0.0a1/ui/src/pages/Form.tsx +97 -0
  60. warder-1.0.0a1/ui/src/pages/List.tsx +172 -0
  61. warder-1.0.0a1/ui/src/pages/Login.tsx +92 -0
  62. warder-1.0.0a1/ui/src/pages/Page.tsx +110 -0
  63. warder-1.0.0a1/ui/src/types.ts +223 -0
  64. warder-1.0.0a1/ui/tsconfig.json +19 -0
  65. warder-1.0.0a1/ui/vite.config.ts +32 -0
  66. warder-1.0.0a1/warder/__init__.py +138 -0
  67. warder-1.0.0a1/warder/_async.py +59 -0
  68. warder-1.0.0a1/warder/_check.py +73 -0
  69. warder-1.0.0a1/warder/_time.py +50 -0
  70. warder-1.0.0a1/warder/access.py +600 -0
  71. warder-1.0.0a1/warder/actions.py +259 -0
  72. warder-1.0.0a1/warder/assets.py +138 -0
  73. warder-1.0.0a1/warder/auth.py +475 -0
  74. warder-1.0.0a1/warder/backends.py +318 -0
  75. warder-1.0.0a1/warder/base.py +260 -0
  76. warder-1.0.0a1/warder/columns.py +476 -0
  77. warder-1.0.0a1/warder/conditions.py +162 -0
  78. warder-1.0.0a1/warder/console.py +610 -0
  79. warder-1.0.0a1/warder/errors.py +116 -0
  80. warder-1.0.0a1/warder/fields.py +426 -0
  81. warder-1.0.0a1/warder/filters.py +441 -0
  82. warder-1.0.0a1/warder/formats.py +290 -0
  83. warder-1.0.0a1/warder/inertia.py +247 -0
  84. warder-1.0.0a1/warder/layout.py +308 -0
  85. warder-1.0.0a1/warder/models.py +108 -0
  86. warder-1.0.0a1/warder/naming.py +121 -0
  87. warder-1.0.0a1/warder/pages.py +287 -0
  88. warder-1.0.0a1/warder/props.py +946 -0
  89. warder-1.0.0a1/warder/py.typed +0 -0
  90. warder-1.0.0a1/warder/resolve.py +747 -0
  91. warder-1.0.0a1/warder/resource.py +259 -0
  92. warder-1.0.0a1/warder/results.py +148 -0
  93. warder-1.0.0a1/warder/routes.py +1110 -0
  94. warder-1.0.0a1/warder/schema.py +324 -0
  95. warder-1.0.0a1/warder/screens.py +418 -0
  96. warder-1.0.0a1/warder/site.py +448 -0
  97. warder-1.0.0a1/warder/sorting.py +119 -0
  98. warder-1.0.0a1/warder/static/manifest.json +11 -0
  99. warder-1.0.0a1/warder/static/warder.CiItt5ky.js +133 -0
  100. warder-1.0.0a1/warder/static/warder.Cp9aoZII.css +1 -0
  101. warder-1.0.0a1/warder/theme.py +346 -0
  102. warder-1.0.0a1/warder/widgets.py +315 -0
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv*/
4
+ build/
5
+ dist/
6
+ *.egg-info/
7
+ .coverage
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .mypy_cache/
11
+ node_modules/
12
+ ui/dist/
13
+ # The built bundle: generated by ui/, put into the wheel by build_ui.py.
14
+ warder/static/
@@ -0,0 +1,136 @@
1
+ # Changelog
2
+
3
+ All notable changes to Warder are recorded here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
5
+ [semantic versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [1.0.0a1] - 2026-09-13
10
+
11
+ First alpha, released alongside `sillo-framework` 1.0.0a1. Install with
12
+ `pip install --pre warder==1.0.0a1`.
13
+
14
+ Warder is the admin that used to be `sillo.admin` inside the framework. It is
15
+ its own package now, and its declarations are values rather than class
16
+ attributes on a `ModelAdmin` subclass:
17
+
18
+ ```python
19
+ admin = Admin(title="Ops", prefix="/admin", auth=Auth(users=User))
20
+ admin.add(Resource(User, list=List(Column("email", link=True))))
21
+ admin.mount(app)
22
+ ```
23
+
24
+ An alpha: this is what 1.0 is expected to look like, but the API is not frozen.
25
+
26
+ ### Note
27
+
28
+ `Admin(sessions=True)` is the default and installs a session middleware. An
29
+ application that already installs one of its own should pass `sessions=False` —
30
+ two sessions on two cookies means the one that wins is whichever middleware is
31
+ outermost, and the symptom is a login that succeeds followed by a 401 from the
32
+ next request.
33
+
34
+ ### Added
35
+
36
+ - **A `release` workflow.** Pushing a `warder-v<version>` tag checks the tag,
37
+ `pyproject.toml` and `warder.__version__` agree, builds the React interface,
38
+ runs the suite against the framework's `main` branch, builds the wheel,
39
+ verifies it carries `py.typed` and the compiled interface but none of the UI
40
+ sources, and publishes (trusted publishing, or `PYPI_TOKEN`).
41
+ - CI now also runs on pushes to `master` (the current default branch), not
42
+ only `main`.
43
+ - **The declaration layer.** Every screen, column, filter, action, permission
44
+ and theme is a frozen value: comparable, printable, generatable in a loop, and
45
+ extendable with `.with_()`, which appends parts and replaces keywords rather
46
+ than mutating.
47
+ - **`Admin`**, the site registry — resources, pages, cards, roles and slots,
48
+ with navigation grouped and ordered, and the permissions a deployment can
49
+ grant derived from what is registered.
50
+ - **`Admin.check()`**, which finds everything wrong that needs no database:
51
+ duplicate column and filter keys, filters that would share a query parameter,
52
+ totals over columns that do not exist, actions with colliding names, form
53
+ fields declared twice, and conditions naming a field that is not on the form.
54
+ Every problem carries the file and line the declaration was written on.
55
+ - **Four permission layers** — `Gate` for entry, `Access` for a model, `Access`
56
+ plus `Scope` for a row, `Access` on a `Column` or `Field` for a value — over
57
+ the existing `sillo.permissions` tables. `Role` compiles to a group.
58
+ - **`Auth`, `Session`, `Login`, `MFA`, `Impersonation`, `Audit`**: session
59
+ lifetimes, login throttling, a second factor demanded by gate rather than of
60
+ everyone, gated and time-boxed impersonation, and field-level audit diffs with
61
+ a redaction list.
62
+ - **`Theme`** — Console, Paper, Grid and Native over one token set, emitted as
63
+ CSS custom properties with light and dark defined so an explicit choice wins
64
+ in both directions.
65
+ - **`When`**, a form condition serialised into props so a conditional field
66
+ appears the instant another field changes, and re-checked on the server before
67
+ a write.
68
+ - 962 tests. The declaration layer opens no database at all; the interface,
69
+ the routes and the CLI are tested end to end against a real one.
70
+
71
+ - **The resolver.** `Resource(Post)` with no screens becomes a working list,
72
+ form and detail page, derived from the model's own columns at mount. Widgets
73
+ and formats are read off the schema — what the database says a column is — and
74
+ never off an annotation.
75
+ - **Model checks**, raised at `Admin.bind()` with the line the declaration was
76
+ written on: misspelled columns with a did-you-mean, a `Column.relation` over a
77
+ plain column, a display that is not on the far side, `select_related` over
78
+ something that is not a relation, a sum over a text column, a reverse relation
79
+ on a form, and a readonly required column with no default — which renders,
80
+ submits, and fails at the database naming a column the user was never shown.
81
+ An inline panel over a child with two foreign keys back asks for `via=` rather
82
+ than picking one.
83
+ - **`Schema`**, which reads a model in the admin's own terms and works before
84
+ `Tortoise.init`: a relation still held as a string is reported as *unresolved*
85
+ rather than missing, because "cannot check" is not "wrong".
86
+
87
+ - **`warder check`** and **`warder permissions`**, which take a
88
+ `module:attribute` target and need no server, no port and no database
89
+ connection. `check` exits non-zero on the first problem, so a misspelled
90
+ column fails in CI rather than in production.
91
+
92
+ - **The interface** — list, form, detail, dashboard, login and a command
93
+ palette, in Inertia, React and Tailwind. Python sends a resolved declaration
94
+ and the renderer is generic over it: Python extracts what this person may see,
95
+ the browser formats it in their locale.
96
+ - **The routes**: nine per resource, plus pages, a relation-picker endpoint,
97
+ login and the asset mount. Scope is applied before any row is read, so a row
98
+ outside your scope is a 404 rather than a 403 — telling you it exists is
99
+ itself a disclosure.
100
+ - **The Inertia protocol**, implemented here rather than depended on, since
101
+ `sillo-inertia` is on the 0.x API. A redirect after a mutation is 303, a stale
102
+ asset version is a 409 with a location, and a partial reload sends only what
103
+ it asked for.
104
+ - **A bundled wheel**: one JavaScript file and one stylesheet under
105
+ `warder/static/`, no CDN, no Node at install time. `build_ui.py` fails the
106
+ build rather than shipping a wheel whose interface did not compile.
107
+
108
+ - **Signing in.** `Admin()` with no `auth=` gets the bundled `AdminUser`, a
109
+ session backend, `Gate.staff()` and session middleware installed on mount.
110
+ Throttling counts per identity *and* per address. The session carries an id
111
+ and two timestamps and never a user, so deactivating somebody takes effect on
112
+ their next click rather than their next sign-in.
113
+ - **`warder create-admin`** and **`warder users`**. `create-admin` writes the
114
+ column the sign-in form asks for, sets only the flags the model has, and
115
+ refuses up front — naming what to do — when the model is unregistered, its
116
+ table is missing, it cannot hash a password, or it needs a column Warder
117
+ cannot know about.
118
+ - **Export**: CSV and JSON of the filtered set, capped, with a guard on cells a
119
+ spreadsheet would run as a formula.
120
+ - **Column visibility** and a **light/dark toggle**, both remembered per browser.
121
+
122
+ ### Notes
123
+
124
+ - `Sort`, not `Order`: `Order` is one of the commonest model names there is, and
125
+ an admin module importing both would carry a bug that reads as correct code.
126
+ - `Column.boolean`, `Format.boolean` and `Filter.boolean` rather than `bool`,
127
+ because a method named `bool` shadows the type inside its own class body.
128
+ - `Field.readonly(name)` is the shorthand; the constructor keyword is
129
+ `editable=False`, because a slot and a classmethod cannot share a name and the
130
+ call site is what reads.
131
+
132
+ ### Not yet built
133
+
134
+ - Inline editing inside child panels; MFA and impersonation are declarable but
135
+ not yet enforced.
136
+ - `warder permissions sync` and `warder eject`.
warder-1.0.0a1/LICENSE ADDED
@@ -0,0 +1,27 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2024-present, sillo-Labs OSS.
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without modification,
7
+ are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+ 3. Neither the name of the copyright holder nor the names of its
15
+ contributors may be used to endorse or promote products derived from
16
+ this software without specific prior written permission.
17
+
18
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,504 @@
1
+ Metadata-Version: 2.5
2
+ Name: warder
3
+ Version: 1.0.0a1
4
+ Summary: A declarative admin for Sillo. Declarations are values, not class attributes, and a type annotation never selects behaviour.
5
+ Project-URL: Homepage, https://sillo.build
6
+ Project-URL: Documentation, https://docs.sillo.build/packages/warder/
7
+ Project-URL: Source, https://github.com/sillohq/warder
8
+ Project-URL: Changelog, https://github.com/sillohq/warder/blob/main/CHANGELOG.md
9
+ Author-email: Chidebele Dunamis <techwithdunamix@gmail.com>
10
+ License-Expression: BSD-3-Clause
11
+ License-File: LICENSE
12
+ Keywords: admin,asgi,crud,declarative,inertia,orm,sillo
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: BSD License
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Database :: Front-Ends
23
+ Classifier: Topic :: Internet :: WWW/HTTP
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: sillo-framework>=1.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: mypy>=1.11; extra == 'dev'
29
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
30
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
31
+ Requires-Dist: pytest>=8.0; extra == 'dev'
32
+ Requires-Dist: ruff>=0.6; extra == 'dev'
33
+ Provides-Extra: ui
34
+ Requires-Dist: sillo-inertia>=0.0.1a4; extra == 'ui'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # Warder
38
+
39
+ A declarative admin for [Sillo](https://sillo.build). A warder keeps the keys;
40
+ this one keeps your models — what is listed, what is editable, who may see it,
41
+ and which rows are theirs.
42
+
43
+ ```bash
44
+ pip install warder
45
+ ```
46
+
47
+ ```python
48
+ from warder import (
49
+ Access,
50
+ Action,
51
+ Admin,
52
+ Column,
53
+ Field,
54
+ Filter,
55
+ Form,
56
+ List,
57
+ Resource,
58
+ Section,
59
+ Sort,
60
+ notice,
61
+ )
62
+
63
+ admin = Admin(title="Acme Ops", prefix="/admin")
64
+
65
+ admin.add(
66
+ Resource(
67
+ Post,
68
+ group="Content",
69
+ icon="file-text",
70
+ list=List(
71
+ Column("title", link=True),
72
+ Column.relation("author", display="email"),
73
+ Column.badge("status", colors={"live": "green", "draft": "zinc"}),
74
+ Column.date("published_at", label="Published", style="relative"),
75
+ Column.compute(
76
+ "Words", lambda row: len(row.body.split()), sort="word_count"
77
+ ),
78
+ filters=[
79
+ Filter.search("title", "body"),
80
+ Filter.choice("status", ["draft", "live"]),
81
+ Filter.date_range("published_at", presets=["7d", "30d", "quarter"]),
82
+ ],
83
+ actions=[Action("Publish", publish, confirm="Publish {count} posts?")],
84
+ sort=Sort.desc("published_at"),
85
+ ),
86
+ form=Form(
87
+ Section("Content", Field("title"), Field.markdown("body")),
88
+ Section("Publishing", Field("status"), Field("published_at")),
89
+ Section("Audit", Field.readonly("created_at"), collapsed=True),
90
+ ),
91
+ access=Access(
92
+ view=True,
93
+ add="post.add",
94
+ change=lambda ctx, row: row.author_id == ctx.user.id,
95
+ delete=False,
96
+ ),
97
+ )
98
+ )
99
+
100
+ admin.mount(app)
101
+ ```
102
+
103
+ ## Why this exists
104
+
105
+ Three ideas hold the whole package up, and each one is a decision you can feel
106
+ by the second screen you write.
107
+
108
+ ### A type annotation describes a type. It never selects behaviour.
109
+
110
+ Nothing here reads `__annotations__`, and nothing changes because a parameter is
111
+ spelled one way rather than another. Where something must be injected it arrives
112
+ as a value — a default, a keyword — because a value is visible and an annotation
113
+ is not. The types on this package's own declarations exist so your editor can
114
+ complete `column.sort_field`, and for nothing else.
115
+
116
+ The one place arity would ordinarily be sniffed is a rule callable, so it isn't:
117
+ a rule is **always** called as `(ctx, row)`, with `row` set to `None` when the
118
+ question is about the model rather than one row.
119
+
120
+ ```python
121
+ Access(change=lambda ctx, row: row.team_id == ctx.user.team_id)
122
+ Gate.custom(lambda ctx: ctx.user.email.endswith("@acme.com")) # gates see no row
123
+ ```
124
+
125
+ ### A declaration is a value
126
+
127
+ Nameable, storable, comparable, generatable in a loop, extendable with `.with_()`.
128
+ No metaclass, no class attributes with meanings you cannot derive, no
129
+ registration by import side effect.
130
+
131
+ ```python
132
+ def reference_data(model, *fields):
133
+ return Resource(
134
+ model,
135
+ group="Reference",
136
+ list=List(*[Column(f) for f in fields], sort=Sort.asc(fields[0])),
137
+ form=Form(Section("", *[Field(f) for f in fields])),
138
+ access=Access.by_permission("reference", delete=False),
139
+ )
140
+
141
+
142
+ for model in (Tag, Category, Region, Currency):
143
+ admin.add(reference_data(model, "name", "slug"))
144
+ ```
145
+
146
+ Extending appends parts and replaces keywords, returning a new declaration — so
147
+ a shared base cannot be edited by its fortieth user:
148
+
149
+ ```python
150
+ BASE = List(Column("id"), Column("name"), per_page=50)
151
+
152
+ admin.add(Resource(Tag, list=BASE.with_(Column("slug"))))
153
+ admin.add(Resource(Team, list=BASE.with_(Column.relation("owner"))))
154
+ ```
155
+
156
+ Because a `List` is just a description of a table, it renders in your own route,
157
+ over your own queryset, inside your own layout:
158
+
159
+ ```python
160
+ ORDERS = List(Column("id"), Column.money("total"), Column.badge("status"))
161
+
162
+
163
+ @app.get("/team/orders")
164
+ async def team_orders(ctx: HttpContext):
165
+ return await admin.render(ctx, ORDERS, Order.filter(team_id=ctx.user.team_id))
166
+ ```
167
+
168
+ ### Mistakes fail at mount, not at request
169
+
170
+ Every reference is resolved once, at start-up, against the model — and the error
171
+ carries the file and line the declaration was written on, because "column 'titel'
172
+ is not a field of Post" is half an error message without it.
173
+
174
+ ```
175
+ DeclarationError: Resource(Post).list column 'titel' is not a field of Post.
176
+ Did you mean 'title'?
177
+ Declared at app/admin.py:24
178
+ ```
179
+
180
+ Values are checked even earlier, from the constructor, where the mistake was
181
+ typed:
182
+
183
+ ```python
184
+ Column("total", align="middle")
185
+ # ValueError: align='middle' is not valid. Use one of: 'left', 'center', 'right'.
186
+ ```
187
+
188
+ ## The N+1 is derived away
189
+
190
+ Declaring a relation column is what removes it. `List.joins` collects the
191
+ relations its columns and filters traverse, so the join list cannot fall behind
192
+ the column list — it *is* the column list:
193
+
194
+ ```python
195
+ >>> List(Column("author__email"), Column.relation("team"), Column("title")).joins
196
+ ('author', 'team')
197
+ ```
198
+
199
+ A `select_related=` attribute maintained beside the columns is a list that falls
200
+ out of step silently, and costs fifty queries a page when it does.
201
+
202
+ ## Actions get a queryset
203
+
204
+ Not a list of ids. An action over forty thousand selected rows is one statement:
205
+
206
+ ```python
207
+ async def publish(ctx, rows):
208
+ count = await rows.filter(status="draft").update(status="live")
209
+ return notice(f"Published {count} posts")
210
+ ```
211
+
212
+ How the handler is called is decided by the declaration, visibly, and never by
213
+ inspecting its signature: with no `fields=` it is `(ctx, rows)`; with `fields=`
214
+ it is `(ctx, rows, values)`, where *values* is the little form the confirmation
215
+ dialog collected.
216
+
217
+ ```python
218
+ Action("Assign", assign, fields=[Field.relation("assignee")])
219
+ ```
220
+
221
+ Outcomes are free builders, the way `json()` and `text()` are elsewhere in
222
+ Sillo — `notice`, `warning`, `problem`, `go`, `download`, `modal`, `refresh`.
223
+ Returning `None` means "it worked, reload".
224
+
225
+ ## Signing in
226
+
227
+ ```bash
228
+ warder create-admin app.admin:admin # prompts for email and password
229
+ warder users app.admin:admin # who can sign in, and when they last did
230
+ ```
231
+
232
+ `Admin()` with no `auth=` already has a working sign-in: the bundled
233
+ `AdminUser`, a session backend, `Gate.staff()`, and session middleware installed
234
+ on mount if the application has none — a sign-in page without a session is a
235
+ form that forgets you.
236
+
237
+ `create-admin` writes wherever the admin authenticates from. It writes the column
238
+ the *sign-in form* asks for, sets only the flags the model actually has, reads
239
+ the password twice without echo, and hashes through `sillo.hashing`. It prompts
240
+ only at a terminal, so a script gets an error naming the flag rather than a hang.
241
+
242
+ The bundled models are **not registered by importing Warder** — model discovery
243
+ scans a module's namespace, so that would put `warder_users` in the database of
244
+ every project that installs the package. Name it to opt in:
245
+
246
+ ```python
247
+ setup_record(app, config, model_modules=["myapp.models", "warder.models"])
248
+ ```
249
+
250
+ ## Permissions: four questions, four layers
251
+
252
+ They really are different questions, and one answer does not cover the others.
253
+
254
+ | Question | Answered by |
255
+ | --- | --- |
256
+ | May you get in at all? | `Gate` |
257
+ | May you do this to this **model**? | `Access` |
258
+ | May you do it to **this row**? | `Access` callable, and `Scope` |
259
+ | May you see **this field**? | `Access` on a `Column` or `Field` |
260
+
261
+ ```python
262
+ admin = Admin(
263
+ title="Acme Ops",
264
+ auth=Auth(
265
+ users=User, # your model; omit for the bundled one
266
+ gate=Gate.staff(), # who may enter at all
267
+ session=Session(idle="30m", absolute="12h", concurrent=1),
268
+ login=Login(throttle="5/15m", remember=True),
269
+ mfa=MFA.totp(required=Gate.role("owner")),
270
+ impersonation=Impersonation(gate=Gate.permission("users.impersonate")),
271
+ audit=Audit(retain="1y", redact=["password", "token", "secret"]),
272
+ ),
273
+ )
274
+
275
+ admin.roles(
276
+ Role("support", grants=["order.view", "customer.view"]),
277
+ Role("editor", grants=Role.crud(Post, Tag), inherits=["support"]),
278
+ Role("owner", grants="*"),
279
+ )
280
+ ```
281
+
282
+ Nothing here is a second authorisation system: it compiles onto
283
+ `sillo.permissions`, which already ships `Permission`, `Group`, `UserPermission`
284
+ and `PermissionMixin`. Registering a `Resource` **declares** four permissions —
285
+ `post.view`, `post.add`, `post.change`, `post.delete` — and what a deployment can
286
+ grant follows what is registered rather than being typed twice.
287
+
288
+ `Access` and `Scope` are both needed and neither substitutes for the other:
289
+
290
+ ```python
291
+ Resource(
292
+ Order,
293
+ access=Access(change=lambda ctx, row: row.team_id == ctx.user.team_id),
294
+ scope=Scope.tenant("team_id"),
295
+ )
296
+ ```
297
+
298
+ Inside a rule of your own, read the account with `current_user(ctx)` rather than
299
+ `ctx.user`. The context *raises* when no authentication middleware is installed,
300
+ and `getattr(ctx, "user", None)` does not help — the default only catches
301
+ `AttributeError`, and what comes out is a `ValueError`:
302
+
303
+ ```python
304
+ from warder import Scope, current_user
305
+
306
+ async def my_students(ctx, rows):
307
+ staff = await Staff.filter(user_id=getattr(current_user(ctx), "id", None)).first()
308
+ return rows.filter(classroom__form_teacher_id=staff.id) if staff else rows.none()
309
+ ```
310
+
311
+ `Access` decides whether a button is shown and whether a write is allowed;
312
+ `Scope` decides what is in the queryset at all. Access without scope leaks the
313
+ existence of rows through pagination counts and search results; scope without
314
+ access leaves a writable object reachable by its id.
315
+
316
+ A field you may not view is **absent from the props**, not hidden with CSS — so
317
+ it never reaches the browser:
318
+
319
+ ```python
320
+ Field("salary", access=Access(view="hr.salary.view", change="hr.salary.change"))
321
+ ```
322
+
323
+ `Gate.staff()` is the default, and it matters more than it looks: when the admin
324
+ shares the application's user model — the ordinary arrangement — every registered
325
+ account holds a session, and admitting anyone with one hands over the database.
326
+
327
+ ## Style
328
+
329
+ Four directions, one token set, all four light and dark. This is a choice about
330
+ defaults, not about architecture, so switching is a keyword.
331
+
332
+ | | |
333
+ | --- | --- |
334
+ | **Console** *(default)* | Dense, quiet, keyboard-first. 36px rows, hairline borders, tabular numerals, monospace ids. The one that does not fight a dense table |
335
+ | **Paper** | Light, generous, editorial. 48px rows, soft shadows. Shows about half as much per screen, and reads beautifully |
336
+ | **Grid** | Spreadsheet-first. 28px rows, ruled cells, no card chrome. For reconciliation, imports, moderation queues |
337
+ | **Native** | No opinion. Emits structure and no colour, so your design system's tokens win by not being overridden |
338
+
339
+ ```python
340
+ Admin(theme=Theme.console(accent="#4f46e5", density="compact"))
341
+ Admin(theme=Theme.native())
342
+ ```
343
+
344
+ Everything writes CSS custom properties into the shell. No rebuild, no Node —
345
+ which is what keeps theming a keyword rather than an ejection.
346
+
347
+ ## The interface
348
+
349
+ Inertia, React and Tailwind, and it is in the wheel.
350
+
351
+ ```
352
+ list sortable columns, URL-backed filters, selection, bulk actions, paging,
353
+ column visibility, CSV and JSON export of the filtered set
354
+ form a control per widget kind, conditional fields, per-field errors,
355
+ a searching relation picker, Markdown with preview
356
+ detail panels: fields, and child tables drawn with the child resource's
357
+ own columns
358
+ dashboard number, chart and table cards
359
+ shell grouped navigation, flash messages, a light/dark toggle, `/` to
360
+ search and ⌘K to go anywhere
361
+ ```
362
+
363
+ **Python sends a resolved declaration; React is a generic renderer for it.** The
364
+ front end has never heard of a `Post` — it knows what a badge column is and what
365
+ a relation picker is. So adding `Column.badge("status", colors=…)` changes a
366
+ prop, not a template, and a new resource never needs the interface rebuilt.
367
+
368
+ The division of labour is deliberate. **Python extracts**: which columns exist
369
+ for *this* person, which rows they may see, what each cell holds — all of it
370
+ authorisation-dependent and impossible to do safely in a browser. **React
371
+ formats**: money in the viewer's locale, a timestamp as "3 days ago", a status as
372
+ a coloured pill — all of it locale- and viewport-dependent, and wasteful on a
373
+ server that knows neither.
374
+
375
+ **`pip install warder` does not require Node.** One JavaScript file and one
376
+ stylesheet ship under `warder/static/`; the React sources live in `ui/` and are
377
+ excluded from the wheel. One file because the admin is served under a prefix
378
+ *you* choose, and code-splitting would have to resolve chunk URLs against a base
379
+ it cannot know until runtime. Nothing is fetched from a CDN, so the admin works
380
+ on an air-gapped network and under a Content-Security-Policy that forbids
381
+ third-party script — which are the normal conditions for the people who most
382
+ want an admin panel.
383
+
384
+ Inertia is implemented in this package rather than depended on: `sillo-inertia`
385
+ is written against the 0.x `Request`/`Response` API and this is written against
386
+ the context API, and blocking the whole interface on another repository's port
387
+ was the wrong trade against two hundred lines of a published protocol.
388
+
389
+ Customising has three rungs, in increasing order of commitment: **theme tokens**
390
+ (no rebuild — Python writes them into the document as custom properties),
391
+ **slots** — `admin.slot("list.toolbar", "acme/ExportButton")`, mounted from your
392
+ own build — and **`warder eject`**, which copies `ui/` into your project and
393
+ hands you the upgrades.
394
+
395
+ ## `Resource(Post)` is already a screen
396
+
397
+ Everything is optional but the model. With no `list=`, `form=` or `detail=`, all
398
+ three are built at mount from the model's own columns — identity first, then
399
+ state, then time; a search box over the text columns, a chip per state, a date
400
+ range; every writable column on the form with the timestamps collapsed into an
401
+ Audit group; and a window onto each child table.
402
+
403
+ ```python
404
+ admin.add(Resource(Post)) # a working list, form and detail page
405
+ admin.add(Resource(Post, sort="-published_at", search=["title", "body"]))
406
+ ```
407
+
408
+ The inference reads the **schema** — what the database says a column is — and
409
+ never an annotation. A `TextField` gets a textarea because the column is long
410
+ text. Naming a widget is for when the default is wrong about the *meaning*
411
+ rather than the type: `body` and `internal_note` are both long text and only one
412
+ of them wants Markdown.
413
+
414
+ Every derived part is replaced by naming it, and nothing fights a declaration
415
+ that exists.
416
+
417
+ ## Checking without starting the application
418
+
419
+ ```console
420
+ $ warder check app.admin:admin
421
+ Acme Ops: 12 resources, 2 pages, 51 permissions. Every reference resolves.
422
+
423
+ $ warder permissions app.admin:admin
424
+ post.add
425
+ post.change
426
+ ...
427
+ ```
428
+
429
+ `warder check` resolves every declaration against its models exactly as
430
+ `mount()` does, and exits non-zero on the first problem — so a misspelled column
431
+ fails in CI rather than in production. It needs no server, no port and no
432
+ database connection, only the models importable.
433
+
434
+ `warder permissions` prints what the site declares, which is how you seed a
435
+ fixtures file or write a role against what actually exists.
436
+
437
+ ## Status
438
+
439
+ Alpha, and honest about which parts exist.
440
+
441
+ | | |
442
+ | --- | --- |
443
+ | ✅ | The declaration layer — every value in the table below, frozen, comparable, extendable |
444
+ | ✅ | The site registry, navigation, declared permissions, and the checks that need no ORM |
445
+ | ✅ | The resolver: binding to models, deriving screens, checking every reference |
446
+ | 🚧 | The routes, the Inertia interface, and the bundled assets |
447
+ | ✅ | The routes, the Inertia interface, and the bundled assets |
448
+ | ✅ | Session sign-in, throttling, session lifetimes, the bundled user model |
449
+ | ✅ | `warder check`, `create-admin`, `users`, `permissions`, `routes` |
450
+ | 🚧 | Inline editing in child panels; MFA and impersonation are declarable but not yet enforced |
451
+ | 🚧 | The activity log is written to but has no screen yet |
452
+ | 🚧 | `warder permissions sync`, `warder eject` |
453
+
454
+ `admin.mount(app)` works end to end. What is not built is listed above rather
455
+ than implied by silence.
456
+
457
+ ## The vocabulary
458
+
459
+ | | |
460
+ | --- | --- |
461
+ | `Admin` | The site. Resources, pages, auth, theme; `.mount(app)` |
462
+ | `Resource` | One model's surface: list, detail, form, access, scope |
463
+ | `List` `Form` `Detail` | The three screens |
464
+ | `Column` `Field` `Filter` | A list column, a form input, a list filter |
465
+ | `Action` | Something a person can do to rows |
466
+ | `Section` `Panel` | Grouping on a form, blocks on a detail page |
467
+ | `Access` `Gate` `Scope` `Role` | Who may do what, and to which rows |
468
+ | `Sort` `Format` `Widget` | Ordering, how a value is drawn, how it is edited |
469
+ | `When` | A form condition the browser and the server both evaluate |
470
+ | `Page` `Card` `Dashboard` | Screens that are not a model |
471
+ | `Theme` | Four directions over one token set |
472
+ | `Auth` `Session` `Login` `MFA` `Impersonation` `Audit` | Everything about who may be here |
473
+
474
+ `Sort`, not `Order`: `Order` is one of the most common model names there is, and
475
+ an admin module importing both would have a bug in it that reads as correct code.
476
+
477
+ ## Requirements
478
+
479
+ Python 3.10 to 3.14, and **Sillo v1**. Nothing else at runtime, and no Node.
480
+
481
+ Warder is written against the context API — `HttpContext`, `ctx`-first handlers,
482
+ and the free builders in `sillo.responses`. That is the framework's `main`
483
+ branch and it is not on PyPI yet, so until v1 ships:
484
+
485
+ ```bash
486
+ pip install "sillo-framework @ git+https://github.com/sillohq/core@main"
487
+ pip install warder --no-deps
488
+ ```
489
+
490
+ The dependency is pinned to `>=1.0` rather than loosened to match what is
491
+ published, because the released 0.x has no `sillo.responses` at all — a wheel
492
+ that installed against it would fail at the first request instead of at install
493
+ time.
494
+
495
+ Working on Warder itself needs Node, but only to rebuild the interface:
496
+
497
+ ```bash
498
+ cd ui && npm install && npm run build # → warder/static/
499
+ warder check app.admin:admin # no server, no port, no database
500
+ ```
501
+
502
+ ## Licence
503
+
504
+ BSD-3-Clause.