Skip to content

Commit 34dd51d

Browse files
Redesign: switch to HotReloadMiddleware
1 parent d59d3e9 commit 34dd51d

16 files changed

Lines changed: 433 additions & 195 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ publish:
2929
${bin}twine upload dist/*
3030

3131
serve:
32-
${bin}uvicorn example.server:app --reload --reload-dir ./example
32+
DEBUG=true ${bin}uvicorn example.server:app --reload --reload-dir ./example
3333

3434
test:
3535
${bin}pytest

README.md

Lines changed: 145 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,18 @@
55
![Python versions](https://img.shields.io/pypi/pyversions/arel.svg)
66
[![Package version](https://badge.fury.io/py/arel.svg)](https://pypi.org/project/arel)
77

8-
Browser hot reload for Python ASGI web apps.
8+
Browser hot reload for Python ASGI web apps. Supports any ASGI web framework and server.
99

1010
![](https://media.githubusercontent.com/media/florimondmanca/arel/master/docs/demo.gif)
1111

12-
## Overview
12+
**Contents**
1313

14-
**What is this for?**
15-
16-
`arel` can be used to implement development-only hot-reload for non-Python files that are not read from disk on each request. This may include HTML templates, GraphQL schemas, cached rendered Markdown content, etc.
17-
18-
**How does it work?**
19-
20-
`arel` watches changes over a set of files. When a file changes, `arel` notifies the browser (using WebSocket), and an injected client script triggers a page reload. You can register your own reload hooks for any extra server-side operations, such as reloading cached content or re-initializing other server-side resources.
14+
* [Installation](#installation)
15+
* [Quickstart](#quickstart)
16+
* [Usage](#usage)
17+
* [How does this work?](#how-does-this-work)
18+
* [Example](#example)
19+
* [API Reference](#api-reference)
2120

2221
## Installation
2322

@@ -27,60 +26,158 @@ pip install 'arel==0.2.*'
2726

2827
## Quickstart
2928

30-
_For a working example using Starlette, see the [Example](#example) section._
29+
```python
30+
import arel
31+
from starlette.applications import Starlette
32+
from starlette.routing import Route
33+
from starlette.middleware import Middleware
34+
from starlette.responses import HTMLResponse
35+
36+
HOME_HTML = """
37+
<!DOCTYPE html>
38+
<html lang="en">
39+
<head>
40+
<meta charset="UTF-8" />
41+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
42+
<title>Hot reload</title>
43+
</head>
44+
<body>
45+
<h1>Hello, hot reload!</h1>
46+
</body>
47+
</html>
48+
"""
49+
50+
async def home(request):
51+
return HTMLResponse(HOME_HTML)
52+
53+
app = Starlette(
54+
routes=[Route("/", home)],
55+
middleware=[Middleware(arel.HotReloadMiddleware)],
56+
)
57+
```
58+
59+
Save this file as `main.py`, then start a server, e.g. with [Uvicorn](https://uvicorn.org):
3160

32-
Although the exact instructions to set up hot reload with `arel` depend on the specifics of your ASGI framework, there are three general steps to follow:
61+
```console
62+
$ uvicorn main:app --reload
63+
```
3364

34-
1. Create an `HotReload` instance, passing one or more directories of files to watch, and optionally a list of callbacks to call before a reload is triggered:
65+
Open http://localhost:8000. Now change the HTML content in `main.py`, and hit save. The browser should reload the page automatically!
3566

36-
```python
37-
import arel
67+
## Usage
3868

39-
async def reload_data():
40-
print("Reloading server data...")
69+
### Default behavior
4170

42-
hotreload = arel.HotReload(
43-
paths=[
44-
arel.Path("./server/data", on_reload=[reload_data]),
45-
arel.Path("./server/static"),
46-
],
47-
)
48-
```
71+
By default, [`HotReloadMiddleware`](#hotreloadmiddleware) only watches for server disconnects, and reloads the page when the server comes back up.
4972

50-
2. Mount the hot reload endpoint, and register its startup and shutdown event handlers. If using Starlette, this can be done like this:
73+
This should play nicely with the server reload features of your ASGI server of choice (Uvicorn, Hypercorn, Daphne...).
74+
75+
### Reloading on non-Python files
76+
77+
`arel` can watch an arbitrary set of directories and trigger browser reloads when changes are detected. This can be used to reload in case of changes to static files, HTML templates, GraphQL schemas, etc.
78+
79+
To do so, use the `paths` option, which expects a list of [`Path`](#path) instances:
80+
81+
```python
82+
middleware = [
83+
Middleware(
84+
arel.HotReloadMiddleware,
85+
paths=[arel.Path("./templates")],
86+
),
87+
]
88+
```
5189

52-
```python
53-
from starlette.applications import Starlette
54-
from starlette.routing import WebSocketRoute
90+
### Extra reload hooks
5591

56-
app = Starlette(
57-
routes=[WebSocketRoute("/hot-reload", hotreload, name="hot-reload")],
58-
on_startup=[hotreload.startup],
59-
on_shutdown=[hotreload.shutdown],
60-
)
61-
```
92+
You can register extra reload hooks to run extra server-side operations before triggering the browser reload, such as reloading cached content or re-initializing other server side resources.
6293

63-
3. Add the JavaScript code to your website HTML. If using [Starlette with Jinja templates](https://www.starlette.io/templates/), you can do this by updating the global environment, then injecting the script into your base template:
94+
```python
95+
async def reload_data():
96+
print("Reloading server data...")
6497

65-
```python
66-
templates.env.globals["DEBUG"] = os.getenv("DEBUG") # Development flag.
67-
templates.env.globals["hotreload"] = hotreload
68-
```
98+
middleware = [
99+
Middleware(
100+
arel.HotReloadMiddleware,
101+
paths=[arel.Path("./data", on_reload=[reload_data])],
102+
),
103+
]
104+
```
105+
106+
### Enabling hot reload conditionally
107+
108+
You probably only want to enable hot reload when running in some kind of debug mode.
109+
110+
For example, if using Starlette, you can conditionally enable hot reload when the `DEBUG` environment variable is set, like so...
111+
112+
```python
113+
import arel
114+
from starlette.applications import Starlette
115+
from starlette.config import Config
116+
from starlette.middleware import Middleware
117+
118+
config = Config(".env")
119+
DEBUG = config("DEBUG", cast=bool)
120+
121+
middleware = []
122+
123+
if DEBUG:
124+
middleware.append(Middleware(arel.HotReloadMiddleware))
125+
126+
app = Starlette(
127+
debug=DEBUG,
128+
middleware=middleware,
129+
)
130+
```
69131

70-
```jinja
71-
<body>
72-
<!-- Page content... -->
132+
## How does this work?
73133

74-
<!-- Hot reload script -->
75-
{% if DEBUG %}
76-
{{ hotreload.script(url_for('hot-reload')) | safe }}
77-
{% endif %}
78-
</body>
79-
```
134+
`HotReloadMiddleware` provides a few different things:
135+
136+
* File watching over the provided `paths=...`.
137+
* A WebSocket endpoint that notifies the browser when changes are detected.
138+
* A JavaScript snippet which connects to the WebSocket endpoint and performs page reloads.
139+
140+
The JS code is automatically inserted by the middleware into any HTML response returned by the application. This should make `arel` work automatically with both your own HTML endpoints as well as third-party endpoints, such as Swagger UI documentation provided by FastAPI.
141+
142+
If the server disconnects, the JavaScript snippet will also refresh the page when reconnecting. This allows integration with ASGI servers that have reload functionality for the Python source code.
143+
144+
`HotReloadMiddleware` is a [pure ASGI](https://www.starlette.io/middleware/#pure-asgi-middleware) middleware, so you should be able to use it with any ASGI framework, including Starlette, FastAPI, or Quart.
80145

81146
## Example
82147

83-
The [`example` directory](https://github.com/florimondmanca/arel/tree/master/example) contains an example Markdown-powered website that uses `arel` to refresh the browser when Markdown content or HTML templates change.
148+
The [`example` directory](https://github.com/florimondmanca/arel/tree/master/example) contains an example Markdown-powered website. It uses `arel` to refresh the browser when Markdown content or HTML templates change.
149+
150+
To spin up this example server, run:
151+
152+
```
153+
make serve
154+
```
155+
156+
Then visit http://localhost:8000. The web console will show you that your browser has connected to `arel`.
157+
158+
Now, add, edit or delete one of the Markdown files in `pages/`. The page should refresh automatically.
159+
160+
Lastly, try changing one of the Python files in `example/server/`, then hit save to trigger a server reload. Again, the browser should automatically refresh the page.
161+
162+
## API Reference
163+
164+
### `HotReloadMiddleware`
165+
166+
```python
167+
app = HotReloadMiddleware(app, ...)
168+
```
169+
170+
Parameters:
171+
172+
* `app` - The parent ASGI app.
173+
* `paths` - _(Optional)_ `list[Path]` - A list of [`Path`](#path) instances to watch files over.
174+
175+
### `Path`
176+
177+
Parameters:
178+
179+
* `path` - `Union[str, pathlib.Path]` - The path to watch files over. Supports paths relative to the current working directory, such as `"./templates"`. Glob patterns are not supported.
180+
* `on_reload` - _(Optional)_ `Sequence[async () -> None]` - A list of async callbacks to run when changes are detected on this path.
84181

85182
## License
86183

ci/azure-pipelines.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ trigger:
1212

1313
pr:
1414
- master
15+
- fm/server-reloads
1516

1617
variables:
1718
- name: CI

example/README.md

Lines changed: 0 additions & 20 deletions
This file was deleted.

example/server/app.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,30 @@
11
from starlette.applications import Starlette
2+
from starlette.middleware import Middleware
3+
4+
import arel
25

36
from . import settings
7+
from .content import load_pages
48
from .events import on_shutdown, on_startup
59
from .routes import routes
610

11+
middleware = []
12+
13+
if settings.DEBUG:
14+
middleware.append(
15+
Middleware(
16+
arel.HotReloadMiddleware,
17+
paths=[
18+
arel.Path(str(settings.PAGES_DIR), on_reload=[load_pages]),
19+
arel.Path(str(settings.TEMPLATES_DIR)),
20+
],
21+
)
22+
)
23+
724
app = Starlette(
825
debug=settings.DEBUG,
926
routes=routes,
1027
on_startup=on_startup,
1128
on_shutdown=on_shutdown,
29+
middleware=middleware,
1230
)

example/server/events.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
from . import settings
21
from .content import load_pages
3-
from .resources import hotreload
42

53
on_startup = [load_pages]
64
on_shutdown = []
7-
8-
if settings.DEBUG:
9-
on_startup += [hotreload.startup]
10-
on_shutdown += [hotreload.shutdown]

example/server/resources.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,5 @@
11
from starlette.templating import Jinja2Templates
22

3-
import arel
4-
53
from . import settings
6-
from .content import load_pages
7-
8-
hotreload = arel.HotReload(
9-
paths=[
10-
arel.Path(str(settings.PAGES_DIR), on_reload=[load_pages]),
11-
arel.Path(str(settings.TEMPLATES_DIR)),
12-
],
13-
)
144

155
templates = Jinja2Templates(directory=str(settings.TEMPLATES_DIR))
16-
templates.env.globals["DEBUG"] = settings.DEBUG
17-
templates.env.globals["hotreload"] = hotreload

example/server/routes.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
from starlette.exceptions import HTTPException
22
from starlette.requests import Request
3-
from starlette.responses import Response
4-
from starlette.routing import Route, WebSocketRoute
3+
from starlette.responses import JSONResponse, Response
4+
from starlette.routing import Route
55

6-
from . import settings
76
from .content import get_page_content
8-
from .resources import hotreload, templates
7+
from .resources import templates
98

109

1110
async def render(request: Request) -> Response:
@@ -21,12 +20,18 @@ async def render(request: Request) -> Response:
2120
return templates.TemplateResponse("index.jinja", context=context)
2221

2322

23+
async def api(request: Request) -> Response:
24+
return JSONResponse({"message": "Hello, world!"})
25+
26+
27+
async def stripped(request: Request) -> Response:
28+
context = {"request": request}
29+
return templates.TemplateResponse("stripped.jinja", context=context)
30+
31+
2432
routes: list = [
2533
Route("/", render),
34+
Route("/api", api),
35+
Route("/stripped", stripped),
2636
Route("/{page:path}", render),
2737
]
28-
29-
if settings.DEBUG:
30-
routes += [
31-
WebSocketRoute("/hot-reload", hotreload, name="hot-reload"),
32-
]

example/server/templates/index.jinja

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,5 @@
77
</head>
88
<body>
99
{{ page_content | safe }}
10-
11-
{% if DEBUG %}
12-
{{ hotreload.script(url_for("hot-reload")) | safe }}
13-
{% endif %}
1410
</body>
1511
</html>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<h1>Hello, world!</h1>

0 commit comments

Comments
 (0)