millwork 0.1.0__py3-none-any.whl
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.
- millwork/__init__.py +45 -0
- millwork/_bundle/dist/.vite/manifest.json +92 -0
- millwork/_bundle/dist/charts-inner-Cq0vWbjt.js +39 -0
- millwork/_bundle/dist/favicon.svg +15 -0
- millwork/_bundle/dist/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- millwork/_bundle/dist/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- millwork/_bundle/dist/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- millwork/_bundle/dist/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- millwork/_bundle/dist/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- millwork/_bundle/dist/icons.svg +24 -0
- millwork/_bundle/dist/index.global-cOFus1M1.js +952 -0
- millwork/_bundle/dist/index.modern-CITEfQrV.js +135 -0
- millwork/_bundle/dist/lib-3DI92Zxk.js +1 -0
- millwork/_bundle/dist/main-BoZpFGOH.js +64 -0
- millwork/_bundle/dist/main-F2tEXmC5.css +2 -0
- millwork/_bundle/dist/react-markdown-DLcbNoI3.js +14 -0
- millwork/_bundle/dist/remark-gfm-DoHjwpPg.js +16 -0
- millwork/app.py +726 -0
- millwork/auth.py +877 -0
- millwork/chat.py +397 -0
- millwork/cli.py +316 -0
- millwork/context.py +42 -0
- millwork/files.py +450 -0
- millwork/forms.py +349 -0
- millwork/manual/__init__.py +449 -0
- millwork/manual/components/Hello.jsx +24 -0
- millwork/manual/components/Voice.jsx +46 -0
- millwork/manual/pages/app/auth.md +188 -0
- millwork/manual/pages/app/pages.md +200 -0
- millwork/manual/pages/app/running.md +242 -0
- millwork/manual/pages/app/state.md +151 -0
- millwork/manual/pages/app/testing.md +147 -0
- millwork/manual/pages/app/theming.md +122 -0
- millwork/manual/pages/components/charts.md +263 -0
- millwork/manual/pages/components/chat.md +280 -0
- millwork/manual/pages/components/custom.md +148 -0
- millwork/manual/pages/components/data.md +112 -0
- millwork/manual/pages/components/feedback.md +212 -0
- millwork/manual/pages/components/files.md +194 -0
- millwork/manual/pages/components/form.md +135 -0
- millwork/manual/pages/components/inputs.md +360 -0
- millwork/manual/pages/components/layout.md +154 -0
- millwork/manual/pages/components/tasks.md +140 -0
- millwork/manual/pages/components/text.md +145 -0
- millwork/manual/pages/index.md +67 -0
- millwork/manual/public/fonts/InterVariable.woff2 +0 -0
- millwork/manual/public/fonts/LICENSE.txt +2 -0
- millwork/manual/public/mark.svg +15 -0
- millwork/manual/public/theme.json +97 -0
- millwork/nodes.py +300 -0
- millwork/protocol.py +238 -0
- millwork/proxy.py +122 -0
- millwork/py.typed +0 -0
- millwork/session.py +1422 -0
- millwork/shell.py +226 -0
- millwork/tasks.py +473 -0
- millwork/testing.py +336 -0
- millwork/theme.py +338 -0
- millwork/ui/__init__.py +725 -0
- millwork/ui/charts.py +214 -0
- millwork/ui/chat.py +97 -0
- millwork/ui/files.py +101 -0
- millwork/ui/form.py +102 -0
- millwork/ui/table.py +145 -0
- millwork/ws.py +220 -0
- millwork-0.1.0.dist-info/METADATA +131 -0
- millwork-0.1.0.dist-info/RECORD +70 -0
- millwork-0.1.0.dist-info/WHEEL +4 -0
- millwork-0.1.0.dist-info/entry_points.txt +2 -0
- millwork-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Signing in
|
|
3
|
+
order: 2
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Signing in
|
|
7
|
+
|
|
8
|
+
Without auth every page is open to anyone who reaches the server. With
|
|
9
|
+
auth every page asks for a login first, a page is opened to anonymous
|
|
10
|
+
visitors with `public=True`, and `s.user` says who is there. Identity comes
|
|
11
|
+
from a provider; who gets in, and what the app keeps on them, is one
|
|
12
|
+
function of the app's own.
|
|
13
|
+
|
|
14
|
+
## Turning it on
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import datetime as dt
|
|
18
|
+
|
|
19
|
+
import millwork as mw
|
|
20
|
+
from millwork import auth, ui
|
|
21
|
+
|
|
22
|
+
app = mw.App(
|
|
23
|
+
"projects",
|
|
24
|
+
secret=SECRET,
|
|
25
|
+
auth=auth.Auth(
|
|
26
|
+
[auth.AzureAD(tenant=TENANT, client_id=CLIENT_ID, client_secret=CLIENT_SECRET)],
|
|
27
|
+
on_login=admit,
|
|
28
|
+
session=dt.timedelta(hours=8),
|
|
29
|
+
),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.page("/")
|
|
34
|
+
def home(s: mw.Session) -> ui.Component:
|
|
35
|
+
return ui.Page(ui.Text(f"Hello, {s.user.name}."))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.page("/privacy", public=True)
|
|
39
|
+
def privacy(s: mw.Session) -> ui.Component:
|
|
40
|
+
return ui.Page(ui.Markdown(PRIVACY))
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`secret` signs the session cookie and must be at least 32 characters;
|
|
44
|
+
`millwork secret` prints one. Values such as the secret and the provider's
|
|
45
|
+
credentials come from wherever the app keeps its secrets.
|
|
46
|
+
|
|
47
|
+
The login page is part of millwork: the app's name, the theme's
|
|
48
|
+
`login_image`, one button per OAuth provider, a username and password form
|
|
49
|
+
when there is a password provider, and the message when a login was
|
|
50
|
+
refused. A visitor who opens a gated page lands there and comes back to
|
|
51
|
+
that page after signing in.
|
|
52
|
+
|
|
53
|
+
## Providers
|
|
54
|
+
|
|
55
|
+
`auth.AzureAD(tenant=, client_id=, client_secret=)` is Microsoft Entra ID,
|
|
56
|
+
single tenant unless `single_tenant=False`. `auth.Google(client_id=,
|
|
57
|
+
client_secret=)` is Google. Both are presets over `auth.OAuth(id, label=,
|
|
58
|
+
authorize_url=, token_url=, userinfo_url=, client_id=, client_secret=,
|
|
59
|
+
scopes=)`, which fits any OpenID Connect issuer. The provider's callback
|
|
60
|
+
URL is `/_mw/auth/oauth/<id>/callback` on the app's origin.
|
|
61
|
+
|
|
62
|
+
`auth.Password(verify)` takes a callable `(username, password) -> bool`,
|
|
63
|
+
sync or async, and millwork runs it off the event loop. Hashes are
|
|
64
|
+
scrypt from the standard library: `millwork hash-password --user ada`
|
|
65
|
+
prints a line for a credentials file, and `auth.check_password(password,
|
|
66
|
+
stored)` checks one against it.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
USERS = {"ada": "scrypt$..."}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def verify(username: str, password: str) -> bool:
|
|
73
|
+
stored = USERS.get(username)
|
|
74
|
+
return stored is not None and auth.check_password(password, stored)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`auth.Dev(user)` signs every visitor in as that user; it is allowed with
|
|
78
|
+
`--debug` and refused otherwise.
|
|
79
|
+
|
|
80
|
+
Several providers can be listed together; the login page offers each.
|
|
81
|
+
|
|
82
|
+
## Behind an authenticating proxy
|
|
83
|
+
|
|
84
|
+
When a proxy in front of the app does the signing in (oauth2-proxy, a
|
|
85
|
+
platform's built-in authentication, an access gateway), it names who it saw
|
|
86
|
+
in request headers, and `auth.Headers` reads them. Give it the header names
|
|
87
|
+
the proxy uses: `user` or `email` is required, `name`, `groups` and
|
|
88
|
+
`picture` are optional, and `groups` is split on commas into
|
|
89
|
+
`identity.raw["groups"]`. The person goes through `on_login` on every page
|
|
90
|
+
load and socket connect, so keep that hook cheap; a sync hook runs off
|
|
91
|
+
the event loop, and one that raises shows the error page and the tab
|
|
92
|
+
stops retrying. A visitor the hook refuses sees the login page say so.
|
|
93
|
+
`sign_out` is the proxy's own sign-out URL, which the user menu and
|
|
94
|
+
`s.logout()` go to; without one the menu offers no sign-out, and
|
|
95
|
+
`s.logout()` ends the person's sessions on this process, after which the
|
|
96
|
+
proxy signs them in again on the next page. The proxy replaces these
|
|
97
|
+
headers on the way through; a header that arrives twice is ignored.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
# oauth2-proxy in front of the app, with its default headers
|
|
101
|
+
auth.Headers(
|
|
102
|
+
user="X-Forwarded-User",
|
|
103
|
+
email="X-Forwarded-Email",
|
|
104
|
+
name="X-Forwarded-Preferred-Username",
|
|
105
|
+
groups="X-Forwarded-Groups",
|
|
106
|
+
sign_out="/oauth2/sign_out",
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The headers are read when the app runs with `--proxied` or
|
|
111
|
+
`App(proxied=True)`, as the Running chapter describes. A request that
|
|
112
|
+
arrives without an identity lands on the login page, which says that
|
|
113
|
+
sign-in happens before the app. The proxy's or platform's documentation
|
|
114
|
+
lists the header names it sets and its sign-out URL.
|
|
115
|
+
|
|
116
|
+
## Deciding who gets in
|
|
117
|
+
|
|
118
|
+
After a provider has identified the visitor, `on_login(identity)` decides.
|
|
119
|
+
`identity` carries `provider`, `subject`, `email` (lower-cased), `name`,
|
|
120
|
+
`picture_url`, the provider's `raw` document and an `intent`. The hook
|
|
121
|
+
returns a `User` to let the visitor in, or `None` to refuse with a message
|
|
122
|
+
on the login page and a line in the log. It may be async and may look the
|
|
123
|
+
person up somewhere.
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
async def admit(identity: auth.Identity) -> auth.User | None:
|
|
127
|
+
if not identity.email or not identity.email.endswith("@example.com"):
|
|
128
|
+
return None
|
|
129
|
+
projects = await db.projects_of(identity.email)
|
|
130
|
+
if not projects:
|
|
131
|
+
return None
|
|
132
|
+
return auth.User(
|
|
133
|
+
id=identity.email,
|
|
134
|
+
name=identity.name or identity.email,
|
|
135
|
+
claims={"projects": [p.id for p in projects], "admin": identity.email in ADMINS},
|
|
136
|
+
picture_url=identity.picture_url,
|
|
137
|
+
)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
A `User` is `id`, `name`, `claims` and `picture_url`. `claims` is the
|
|
141
|
+
app's own dictionary, capped at 2 KB: roles, the projects a person may
|
|
142
|
+
see, flags. A picture is a URL. Without `on_login` every
|
|
143
|
+
identified visitor becomes a `User` with the e-mail as `id`, and a provider
|
|
144
|
+
that gives no e-mail is refused.
|
|
145
|
+
|
|
146
|
+
`Auth(intents={"demo": "Try the demo"})` adds a secondary button to the
|
|
147
|
+
login page for each intent; the one pressed arrives as `identity.intent`,
|
|
148
|
+
and the hook decides what it means.
|
|
149
|
+
|
|
150
|
+
## In a page
|
|
151
|
+
|
|
152
|
+
`s.user` is the `User`, or `None` on a public page when nobody is signed
|
|
153
|
+
in. Authorisation is the app's: read `s.user.claims` in `connect` and raise
|
|
154
|
+
`mw.Forbidden(message)` when this page or this project is not for this
|
|
155
|
+
person. The Pages chapter shows the hook.
|
|
156
|
+
|
|
157
|
+
```python live
|
|
158
|
+
def example(s):
|
|
159
|
+
who = f"{s.user.name} ({s.user.id})" if s.user else "nobody; the manual runs without auth"
|
|
160
|
+
return ui.Text(f"Signed in as {who}.", variant="muted")
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The `ui.Header` shows the signed-in person's picture or initials with a
|
|
164
|
+
menu that holds the theme choice and sign-out; `user_menu=False` hides it.
|
|
165
|
+
`s.logout()` signs out from a handler: the cookie is cleared, the person's
|
|
166
|
+
other sessions on this process end with a note in their tabs, and the tab
|
|
167
|
+
goes to the login page.
|
|
168
|
+
|
|
169
|
+
## The session cookie
|
|
170
|
+
|
|
171
|
+
The cookie is a signed token holding the `User`, `HttpOnly`, `SameSite=Lax`,
|
|
172
|
+
`Secure` over HTTPS, with a fixed lifetime set by `session` (seven days by
|
|
173
|
+
default). Its name and path carry the app's prefix, `mw_auth_sales` under
|
|
174
|
+
`/sales`, so apps side by side on one host each read their own, and it is
|
|
175
|
+
signed for the app by name, so apps that share a secret still keep their
|
|
176
|
+
sign-ins apart. After that long a person signs in again. The cookie holds
|
|
177
|
+
the `User` and is valid on any replica with the same secret. Signing out
|
|
178
|
+
clears the browser's cookie and ends the person's sessions on that
|
|
179
|
+
process; a copy of the cookie stays valid until it expires, so the
|
|
180
|
+
lifetime is the bound, and an app with sensitive data sets `session` to a
|
|
181
|
+
working day. The socket is bound to the cookie, so a session is resumed
|
|
182
|
+
by its own user. Logins and refusals are logged with the identifier;
|
|
183
|
+
`log_identity="hashed"` logs a hash instead.
|
|
184
|
+
|
|
185
|
+
Identity is what the provider says and the record is what `on_login`
|
|
186
|
+
returns; `claims` is a dictionary the app reads. An access token the app
|
|
187
|
+
needs for its own API calls is obtained in `on_login` and kept by the
|
|
188
|
+
app.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Pages and routing
|
|
3
|
+
order: 0
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Pages and routing
|
|
7
|
+
|
|
8
|
+
An app is a `mw.App` with pages on it. A page is a path and a function of
|
|
9
|
+
the session that returns the component tree for that path. The browser
|
|
10
|
+
opens the path, the tab connects a socket, and from then on Python renders
|
|
11
|
+
and handles events over that socket. There is one session per tab.
|
|
12
|
+
|
|
13
|
+
## An app and its pages
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import millwork as mw
|
|
17
|
+
from millwork import ui
|
|
18
|
+
|
|
19
|
+
app = mw.App("projects")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.page("/", title="Projects")
|
|
23
|
+
def home(s: mw.Session) -> ui.Component:
|
|
24
|
+
return ui.Page(ui.Text("All projects"), title="Projects")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.page("/about")
|
|
28
|
+
def about(s: mw.Session) -> ui.Component:
|
|
29
|
+
return ui.Page(ui.Text("About this app"))
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`title` is the browser tab's title; without one the app's name is used.
|
|
33
|
+
Paths match in the order the pages are registered, and a path no page
|
|
34
|
+
matches is a 404.
|
|
35
|
+
|
|
36
|
+
`millwork run app.py` serves the file's `app`; `millwork run package.module:app`
|
|
37
|
+
names the object. `--port` picks the port, `--reload` restarts the process
|
|
38
|
+
when a Python file changes, `--debug` shows page errors in the tab and
|
|
39
|
+
watches the theme file and the custom components for changes. Under
|
|
40
|
+
uvicorn or another ASGI server, `app.asgi` is the application.
|
|
41
|
+
|
|
42
|
+
## Parameters in the path
|
|
43
|
+
|
|
44
|
+
A path can carry parameters in Starlette's syntax. `{name}` takes one
|
|
45
|
+
segment as a string; `{name:int}`, `{name:float}` and `{name:uuid}` convert
|
|
46
|
+
it, and a path that does not convert does not match. `{name:path}` takes
|
|
47
|
+
the rest of the path. The values arrive in `s.params`, and `s.path` is the
|
|
48
|
+
path as opened.
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
@app.page("/projects/{project_id:int}")
|
|
52
|
+
def project(s: mw.Session) -> ui.Component:
|
|
53
|
+
return ui.Page(ui.Text(f"Project {s.params['project_id']}"))
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Moving between pages
|
|
57
|
+
|
|
58
|
+
A `ui.Link` whose `href` is a path inside the app navigates over the
|
|
59
|
+
session: no reload, the tab's history gets an entry, the back button
|
|
60
|
+
works. From a handler, `s.navigate(path)` does the same. A `#heading` on
|
|
61
|
+
the path scrolls to that heading once the page is up, which is how this
|
|
62
|
+
manual's search opens a section.
|
|
63
|
+
|
|
64
|
+
```python live
|
|
65
|
+
def example(s):
|
|
66
|
+
return ui.Row(
|
|
67
|
+
ui.Button("Open the Charts chapter", on_click=lambda s, _: s.navigate("/components/charts")),
|
|
68
|
+
ui.Link(label="or follow a link", href="/components/charts#stat"),
|
|
69
|
+
ui.Text(f"You are at {s.path}", variant="muted"),
|
|
70
|
+
gap=4,
|
|
71
|
+
)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
A link to another site is an ordinary link; give it `target="_blank"` to
|
|
75
|
+
open a new tab.
|
|
76
|
+
|
|
77
|
+
## When a page is missing
|
|
78
|
+
|
|
79
|
+
A path no page matches lands on the app's not-found page: over HTTP as a
|
|
80
|
+
404 that serves the shell, and over the session when a link or
|
|
81
|
+
`s.navigate` asks for it, so the tab keeps its session and can go on from
|
|
82
|
+
there. `connect` reaches the same pages by raising: `mw.NotFound(message)`
|
|
83
|
+
when what the path names does not exist, `mw.Forbidden(message)` when this
|
|
84
|
+
person may not open it. The session lands on the error page under the path
|
|
85
|
+
that was asked for, with the message if one was given.
|
|
86
|
+
|
|
87
|
+
Without any app code the framework's own pages are shown: the theme's
|
|
88
|
+
logo, a title and a line from the string table, and a way home, in the
|
|
89
|
+
same frame as the login page. An app replaces them with pages of its own,
|
|
90
|
+
written like any other page: `s.params["path"]` is the path asked for and
|
|
91
|
+
`s.params["message"]` what `connect` raised with, or empty.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
@app.not_found
|
|
95
|
+
def missing(s):
|
|
96
|
+
return ui.Page(
|
|
97
|
+
ui.Stack(
|
|
98
|
+
ui.Logo(cls="h-9 self-start"),
|
|
99
|
+
ui.Text("Nothing here", variant="title"),
|
|
100
|
+
ui.Text(s.params["message"] or f"There is no page at {s.params['path']}.", variant="muted"),
|
|
101
|
+
ui.Input(kind="search", placeholder="Search instead", on_submit=search),
|
|
102
|
+
gap=3,
|
|
103
|
+
cls="max-w-sm",
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.forbidden(title="Private")
|
|
109
|
+
def private(s):
|
|
110
|
+
return ui.Page(ui.Text(s.params["message"] or "Ask the owner for access."))
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`ui.Logo` is the theme's logo, light or dark with the scheme, for a page
|
|
114
|
+
outside the app bar. The link below goes to a page this manual does not
|
|
115
|
+
have, which is the framework's own not-found page in this manual's theme.
|
|
116
|
+
|
|
117
|
+
```python live
|
|
118
|
+
ui.Row(
|
|
119
|
+
ui.Logo(cls="h-6"),
|
|
120
|
+
ui.Link(label="Open a page that does not exist", href="/components/nothing-here"),
|
|
121
|
+
gap=4,
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
A failure before the bundle can run, a crash in an app's own route or the
|
|
126
|
+
bundle missing, is a plain HTML page with the same title and text, in the
|
|
127
|
+
theme's colours and font; the traceback goes to the log. In debug mode a
|
|
128
|
+
crash shows Starlette's own traceback page.
|
|
129
|
+
|
|
130
|
+
## State and the session
|
|
131
|
+
|
|
132
|
+
A page's state is a Pydantic model named on the page, or on the app as the
|
|
133
|
+
default for every page. `s.state` is an instance of it, made new when the
|
|
134
|
+
page is opened and again on every navigation to a page. The session itself
|
|
135
|
+
outlives the page: its files, its identity and its tasks stay while the tab
|
|
136
|
+
moves around the app.
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
class State(BaseModel):
|
|
140
|
+
selected: list[int] = []
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.page("/projects/{project_id:int}", state=State)
|
|
144
|
+
def project(s: mw.Session[State]) -> ui.Component:
|
|
145
|
+
...
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Everything a page shows comes from `s.state` and from what its handlers put
|
|
149
|
+
there, so a page is a plain function and handlers are where things
|
|
150
|
+
change.
|
|
151
|
+
|
|
152
|
+
## Connect and close
|
|
153
|
+
|
|
154
|
+
A page can have a `connect` hook, run once each time the page is opened,
|
|
155
|
+
before the first render, with the path parameters as keyword arguments. It
|
|
156
|
+
may be async, and it is where a page loads what it needs, starts a task,
|
|
157
|
+
or decides that the visitor may not be here. `mw.NotFound()` and
|
|
158
|
+
`mw.Forbidden(message)` raised from it keep the tab on the page it came
|
|
159
|
+
from and show the message; a page opened directly shows the message and
|
|
160
|
+
stops there.
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
@project.connect
|
|
164
|
+
async def load(s: mw.Session[State], project_id: int) -> None:
|
|
165
|
+
project = await db.project(project_id)
|
|
166
|
+
if project is None:
|
|
167
|
+
raise mw.NotFound()
|
|
168
|
+
if s.user and s.user.id not in project.members:
|
|
169
|
+
raise mw.Forbidden("Not your project.")
|
|
170
|
+
s.state.project = project
|
|
171
|
+
s.task(refresh_documents(s))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@project.close
|
|
175
|
+
async def leave(s: mw.Session[State]) -> None:
|
|
176
|
+
await db.release(s.state.project.id)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The `close` hook runs once, with the state still intact, when the session
|
|
180
|
+
ends: the tab said goodbye, or it has been gone longer than the grace
|
|
181
|
+
period. `App(session_grace=300)` sets that period in seconds; a tab that
|
|
182
|
+
reconnects within it, after a sleep or a dropped network, gets its session
|
|
183
|
+
back with everything in it.
|
|
184
|
+
|
|
185
|
+
## Navigation in the frame
|
|
186
|
+
|
|
187
|
+
`ui.Header(links=[...])` puts links in the app bar and `ui.Sidebar` holds
|
|
188
|
+
a longer list. This manual's sidebar is a `Sidebar` of `Link`s with the
|
|
189
|
+
current page drawn as text instead; the Layout chapter shows both.
|
|
190
|
+
|
|
191
|
+
## Beyond pages
|
|
192
|
+
|
|
193
|
+
`App` is a Starlette application, and an app adds to it through Starlette:
|
|
194
|
+
`app.route(path, endpoint, methods=[...])` for an API endpoint,
|
|
195
|
+
`app.mount(path, asgi_app)` for anything ASGI, FastAPI included,
|
|
196
|
+
`app.middleware(cls)` for pure ASGI middleware, and `app.on_startup` and
|
|
197
|
+
`app.on_shutdown` for hooks around the process. The `public/` folder next
|
|
198
|
+
to the app is served at `/public/`. Everything millwork owns lives under
|
|
199
|
+
`/_mw/`, leaving every other path to the app; `/_mw/health` answers 200
|
|
200
|
+
once the startup hooks are done.
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Running
|
|
3
|
+
order: 5
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Running
|
|
7
|
+
|
|
8
|
+
One process serves an app: Python with the built bundle inside the
|
|
9
|
+
package. What the app needs is said on the `App`, in Python, with values
|
|
10
|
+
from wherever the app keeps its secrets. `millwork run` starts the
|
|
11
|
+
process, and a handful of flags cover a reverse proxy, a unix socket and
|
|
12
|
+
a path prefix.
|
|
13
|
+
|
|
14
|
+
## The App's options
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
app = mw.App(
|
|
18
|
+
"projects",
|
|
19
|
+
state=State, # the default state model for every page
|
|
20
|
+
public="public", # served at /public/; None for no static folder
|
|
21
|
+
components="components", # custom components, .jsx files; None for none
|
|
22
|
+
theme="themes/dark.json", # a path or a Theme; public/theme.json is found on its own
|
|
23
|
+
secret=SECRET, # signs cookies; 32 characters or more, required with auth
|
|
24
|
+
auth=..., # see Signing in
|
|
25
|
+
origins=["https://projects.example.com"],
|
|
26
|
+
session_grace=300, # seconds a session waits for its tab to come back
|
|
27
|
+
max_sessions_per_client=20, # live sessions per user, or per address without auth
|
|
28
|
+
max_sessions=1000, # live sessions in the process
|
|
29
|
+
files=mw.Files("/var/lib/projects/files", max_session_mb=200, max_total_mb=2000),
|
|
30
|
+
threads=8, # the pool sync tasks and handlers run in
|
|
31
|
+
keep_alive=False,
|
|
32
|
+
proxied=False, # True behind a reverse proxy; see Serving
|
|
33
|
+
)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- `origins` is the allow-list for the socket and the login route; the
|
|
37
|
+
default is the app's own origin.
|
|
38
|
+
- `files` is where uploads and generated files live while a session
|
|
39
|
+
lives, with a quota per session and one for the process. The folder is
|
|
40
|
+
private to the process's user, and directories left by old sessions are
|
|
41
|
+
swept. The default is the system's temporary directory, which on some
|
|
42
|
+
hosts is memory, so a deployment names a disk.
|
|
43
|
+
- `session_grace` is how long a session waits for its tab to come back
|
|
44
|
+
after the socket drops, with its state intact.
|
|
45
|
+
- `max_sessions_per_client` and `max_sessions` count live sessions,
|
|
46
|
+
those in grace included. Over the client's cap, that client's session
|
|
47
|
+
longest without a tab makes room; over the process cap, anyone's. A
|
|
48
|
+
new session is refused when every one still has a tab. A session ended
|
|
49
|
+
that way, or by a sign-out in another tab, tells its tab so: the page
|
|
50
|
+
stays readable, takes no more input, and offers a reload.
|
|
51
|
+
- `threads` sizes the pool that runs sync tasks; the default is Python's.
|
|
52
|
+
- `keep_alive=True` makes every open tab fetch the health route every
|
|
53
|
+
thirty seconds, for a host that stops an idle process; by default a tab
|
|
54
|
+
does so while a task or a reply runs.
|
|
55
|
+
- `proxied=True` says a reverse proxy is in front; the Serving section
|
|
56
|
+
below.
|
|
57
|
+
|
|
58
|
+
The settings this manual runs with:
|
|
59
|
+
|
|
60
|
+
```python live
|
|
61
|
+
def example(s):
|
|
62
|
+
app = s.app
|
|
63
|
+
return ui.Table(
|
|
64
|
+
rows=[
|
|
65
|
+
{"option": "name", "value": app.name},
|
|
66
|
+
{"option": "session_grace", "value": f"{app.session_grace:.0f} s"},
|
|
67
|
+
{"option": "max_sessions_per_client", "value": app.max_sessions_per_client},
|
|
68
|
+
{"option": "max_sessions", "value": app.max_sessions},
|
|
69
|
+
{"option": "files", "value": f"{app.files.max_session_mb} MB per session, {app.files.max_total_mb} MB in all"},
|
|
70
|
+
{"option": "origins", "value": ", ".join(app.origins) or "same origin"},
|
|
71
|
+
{"option": "threads", "value": app.threads or "Python's default"},
|
|
72
|
+
{"option": "keep_alive", "value": app.keep_alive},
|
|
73
|
+
{"option": "proxied", "value": app.proxied},
|
|
74
|
+
{"option": "debug", "value": app.debug},
|
|
75
|
+
{"option": "auth", "value": "on" if app.auth else "off"},
|
|
76
|
+
],
|
|
77
|
+
row_key="option",
|
|
78
|
+
height=400,
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Development
|
|
83
|
+
|
|
84
|
+
`millwork run app.py --reload --debug` is the loop. `--reload` restarts
|
|
85
|
+
the process when a Python file changes; the tab shows "Reconnecting" and
|
|
86
|
+
remounts when the server is back. `--debug` shows page errors in the
|
|
87
|
+
tab, turns the framework's warnings into toasts, pushes a changed theme
|
|
88
|
+
file or custom component to every open tab, and allows the `Dev` auth
|
|
89
|
+
provider. Tests drive a session without a browser, so for most changes
|
|
90
|
+
the loop is edit, run pytest, and the browser is for looking.
|
|
91
|
+
|
|
92
|
+
## Serving
|
|
93
|
+
|
|
94
|
+
In production one process serves one app behind a reverse proxy that
|
|
95
|
+
ends TLS and passes the websocket upgrade through:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
uv run millwork run app.py --host 127.0.0.1 --port 8000 --proxied
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`--proxied`, or `App(proxied=True)`, says a reverse proxy is in front.
|
|
102
|
+
The app then takes the visitor's address and scheme from the proxy's
|
|
103
|
+
`X-Forwarded-For` and `X-Forwarded-Proto`, and the visitor's identity from
|
|
104
|
+
the headers an `auth.Headers` provider names. Anyone who can reach the
|
|
105
|
+
process can send those headers too, so a proxied app listens where only
|
|
106
|
+
its proxy connects: a unix socket, a loopback port, or a container
|
|
107
|
+
network.
|
|
108
|
+
|
|
109
|
+
`/_mw/health` answers 200 once the startup hooks have run and 503 before
|
|
110
|
+
that and during shutdown, for a host that waits for readiness. The
|
|
111
|
+
process exits cleanly on SIGTERM: sessions get their `close` hook, tasks
|
|
112
|
+
are cancelled, uploads in flight are cut. Logs go to standard error.
|
|
113
|
+
|
|
114
|
+
`app.asgi` is the ASGI application itself, for a server of your own
|
|
115
|
+
choosing; under uvicorn directly, pass `--no-proxy-headers`, since
|
|
116
|
+
millwork applies the forwarded headers, and `--ws-max-size 1048576`, the
|
|
117
|
+
bound `millwork run` puts on a message from the tab (uploads travel in
|
|
118
|
+
smaller frames).
|
|
119
|
+
|
|
120
|
+
## A unix socket
|
|
121
|
+
|
|
122
|
+
On one machine with the proxy, `--uds` listens on a unix socket. The
|
|
123
|
+
socket is created with mode 0660, whatever the umask, so the proxy
|
|
124
|
+
connects when it runs in the app's group. A socket is reached through a
|
|
125
|
+
proxy, so `--uds` implies `--proxied`.
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
uv run millwork run app.py --uds /run/sales/app.sock
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```nginx
|
|
132
|
+
location / {
|
|
133
|
+
proxy_pass http://unix:/run/sales/app.sock:/;
|
|
134
|
+
proxy_http_version 1.1;
|
|
135
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
136
|
+
proxy_set_header Connection "upgrade";
|
|
137
|
+
proxy_set_header Host $host;
|
|
138
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
139
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Like a port, the path is refused while another process answers on it. A
|
|
144
|
+
socket file nobody answers on is replaced at the next start, and anything
|
|
145
|
+
else at the path stops the start. The health route is reached the same
|
|
146
|
+
way: `curl --unix-socket /run/sales/app.sock http://app/_mw/health`.
|
|
147
|
+
|
|
148
|
+
## Under a path prefix
|
|
149
|
+
|
|
150
|
+
An app can live under a path on a shared host, `https://apps.example/sales/`,
|
|
151
|
+
next to other apps. The proxy strips the prefix and the server is told it:
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
uv run millwork run app.py --port 8000 --root-path /sales --proxied
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
```nginx
|
|
158
|
+
location /sales/ {
|
|
159
|
+
proxy_pass http://127.0.0.1:8000/;
|
|
160
|
+
proxy_http_version 1.1;
|
|
161
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
162
|
+
proxy_set_header Connection "upgrade";
|
|
163
|
+
proxy_set_header Host $host;
|
|
164
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
165
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Every URL the server writes into the page is under the prefix, and the
|
|
170
|
+
cookies carry it in name and path, so apps side by side on one host each
|
|
171
|
+
have their own. Inside the app everything is an app path: page paths,
|
|
172
|
+
`ui.Link` targets, `s.navigate`, an image's `src`, a path in Markdown;
|
|
173
|
+
the tab puts each one under the prefix as it renders it. A custom
|
|
174
|
+
component that builds a URL itself reads the prefix from
|
|
175
|
+
`window.__mw.base`, which is empty at the root. The manual takes the flag
|
|
176
|
+
too: `millwork docs --root-path /manual`.
|
|
177
|
+
|
|
178
|
+
## What a host needs
|
|
179
|
+
|
|
180
|
+
Any host that runs one process and routes HTTPS to it serves a millwork
|
|
181
|
+
app. It provides:
|
|
182
|
+
|
|
183
|
+
- TLS at the proxy or the platform's edge, with the websocket upgrade
|
|
184
|
+
passed through.
|
|
185
|
+
- `--proxied`, and a listener only the proxy can reach: a unix socket, a
|
|
186
|
+
loopback port, or the container's network.
|
|
187
|
+
- `/_mw/health` as the readiness probe: 200 once the startup hooks have
|
|
188
|
+
run, 503 before that and during shutdown. As a liveness probe it would
|
|
189
|
+
restart a slow start, so that one gets a generous threshold.
|
|
190
|
+
- A files directory with room, named with `Files(...)`, when uploads are
|
|
191
|
+
larger than the temporary directory should hold.
|
|
192
|
+
- The `secret` and the provider's credentials from the environment or the
|
|
193
|
+
platform's secret store.
|
|
194
|
+
- `keep_alive=True` when the host stops an idle container, since
|
|
195
|
+
platforms differ on whether an open websocket counts as activity and
|
|
196
|
+
the health pings count everywhere.
|
|
197
|
+
- A path prefix per app, or a hostname per app, when several share a
|
|
198
|
+
host.
|
|
199
|
+
|
|
200
|
+
The millwork parts of a service definition are the command line. Under
|
|
201
|
+
systemd, with the socket in the unit's runtime directory and the files in
|
|
202
|
+
its state directory:
|
|
203
|
+
|
|
204
|
+
```ini
|
|
205
|
+
RuntimeDirectory=millwork
|
|
206
|
+
StateDirectory=millwork/sales
|
|
207
|
+
ExecStart=/srv/apps/sales/.venv/bin/millwork run app.py --uds /run/millwork/sales.sock --root-path /sales
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
In a container, behind the platform's router:
|
|
211
|
+
|
|
212
|
+
```dockerfile
|
|
213
|
+
CMD ["/app/.venv/bin/millwork", "run", "app.py", "--host", "0.0.0.0", "--port", "8000", "--proxied"]
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## More than one replica
|
|
217
|
+
|
|
218
|
+
HTTP is stateless and the socket is the session: identity comes from the
|
|
219
|
+
cookie, and everything a session owns, its state, its files, its tasks,
|
|
220
|
+
travels over the socket to the process that holds it. Replicas need no
|
|
221
|
+
sticky routing and no shared storage. When a replica goes away, its
|
|
222
|
+
sessions go with it, and their tabs start fresh sessions elsewhere, as
|
|
223
|
+
they would on a reload. The cookie is valid on every replica that has the
|
|
224
|
+
same `secret`.
|
|
225
|
+
|
|
226
|
+
## The page's security headers
|
|
227
|
+
|
|
228
|
+
Every page carries a Content Security Policy: scripts from the app's
|
|
229
|
+
origin with a nonce, connections to the app's origin, images and media
|
|
230
|
+
from anywhere over HTTPS, and no framing by other sites. An app with a
|
|
231
|
+
`components/` folder also allows `eval`, since custom components are
|
|
232
|
+
compiled in the tab. A custom component that needs a library loads it
|
|
233
|
+
from `public/`, as the Charts chapter shows. `App(csp=None)` drops the
|
|
234
|
+
header, and a string of your own replaces it; `{scripts}` in it is where
|
|
235
|
+
millwork puts the script sources.
|
|
236
|
+
|
|
237
|
+
## Hooks around the process
|
|
238
|
+
|
|
239
|
+
`app.on_startup` and `app.on_shutdown` register functions, sync or async,
|
|
240
|
+
that run once around the process: warm a cache, open and close a pool.
|
|
241
|
+
`app.route`, `app.mount` and `app.middleware` add to the Starlette
|
|
242
|
+
application, as the Pages chapter shows.
|