Skip to content

Commit e1b2dc9

Browse files
remove uvicorn boilerplate by implementing a uvicorn-hmr cli
1 parent cdf4c9d commit e1b2dc9

File tree

5 files changed

+101
-34
lines changed

5 files changed

+101
-34
lines changed

examples/fastapi/main.py

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12,35 +12,3 @@
1212
@app.get("/")
1313
def redirect_to_docs():
1414
return RedirectResponse("/docs")
15-
16-
17-
def start_server():
18-
global stop
19-
20-
from atexit import register, unregister
21-
from threading import Event, Thread
22-
23-
from uvicorn import Config, Server
24-
25-
if stop := globals().get("stop"): # type: ignore
26-
unregister(stop)
27-
stop() # type: ignore
28-
29-
server = Server(Config(app, host="localhost"))
30-
31-
finish = Event()
32-
33-
def run_server():
34-
server.run()
35-
finish.set()
36-
37-
Thread(target=run_server, daemon=True).start()
38-
39-
@register
40-
def stop():
41-
server.should_exit = True
42-
finish.wait()
43-
44-
45-
if __name__ == "__main__":
46-
start_server()

examples/fastapi/pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,8 @@ version = "0"
44
requires-python = ">=3.12"
55
dependencies = [
66
"fastapi~=0.115.6",
7-
"uvicorn~=0.34.0",
7+
"uvicorn-hmr",
88
]
9+
10+
[tool.uv.sources]
11+
uvicorn-hmr = { path = "../../packages/uvicorn-hmr" }

packages/uvicorn-hmr/pyproject.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[project]
2+
name = "uvicorn-hmr"
3+
version = "0.0.1"
4+
dependencies = [
5+
"hmr~=0.1.2",
6+
"typer-slim~=0.15.1",
7+
"uvicorn>=0.24.0",
8+
]
9+
10+
[project.scripts]
11+
uvicorn-hmr = "uvicorn_hmr:app"
12+
13+
[build-system]
14+
requires = ["pdm-backend"]
15+
build-backend = "pdm.backend"

packages/uvicorn-hmr/uvicorn_hmr.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from pathlib import Path
2+
from typing import TYPE_CHECKING
3+
4+
from typer import Argument, Typer
5+
6+
app = Typer(help="Hot Module Replacement for Uvicorn", add_completion=False)
7+
8+
9+
@app.command(no_args_is_help=True)
10+
def main(slug: str = Argument("main:app"), reload_include: str = str(Path.cwd()), reload_exclude: str = ".venv"):
11+
module, attr = slug.split(":")
12+
13+
fragment = Path(module.replace(".", "/"))
14+
15+
if (path := fragment.with_suffix(".py")).is_file() or (path := fragment / "__init__.py").is_file():
16+
file = path.resolve()
17+
else:
18+
raise FileNotFoundError(f"Module `{module}` not found")
19+
20+
import sys
21+
from atexit import register
22+
from importlib import import_module
23+
from threading import Event, Thread
24+
25+
from reactivity import memoized_method
26+
from reactivity.hmr import SyncReloader
27+
from uvicorn import Config, Server
28+
29+
if TYPE_CHECKING:
30+
from uvicorn._types import ASGIApplication
31+
32+
cwd = str(Path.cwd())
33+
if cwd not in sys.path:
34+
sys.path.insert(0, cwd)
35+
36+
@register
37+
def _():
38+
stop_server()
39+
40+
def stop_server():
41+
pass
42+
43+
def start_server(app: "ASGIApplication"):
44+
nonlocal stop_server
45+
46+
server = Server(Config(app, host="localhost"))
47+
finish = Event()
48+
49+
def run_server():
50+
server.run()
51+
finish.set()
52+
53+
Thread(target=run_server, daemon=True).start()
54+
55+
def stop_server():
56+
server.should_exit = True
57+
finish.wait()
58+
59+
class Reloader(SyncReloader):
60+
def __init__(self):
61+
super().__init__(str(file), {reload_include}, {reload_exclude})
62+
self.error_filter.exclude_filenames.add(__file__) # exclude error stacks within this file
63+
self.module = import_module(module)
64+
65+
@memoized_method
66+
def run_entry_file(self):
67+
with self.error_filter:
68+
self.module._ReactiveModule__load.invalidate()
69+
self.module._ReactiveModule__load()
70+
71+
stop_server()
72+
start_server(getattr(self.module, attr))
73+
74+
Reloader().keep_watching_until_interrupt()

pyproject.toml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@ name = "hmr-cookbook"
33
version = "0"
44
requires-python = ">=3.12"
55
dependencies = [
6-
"hmr>=0.1.1.1,<0.2",
6+
"hmr~=0.1.2",
77
"ruff~=0.9.0",
8+
"uvicorn-hmr",
89
]
910

11+
[tool.uv.workspace]
12+
members = ["packages/*"]
13+
14+
[tool.uv.sources]
15+
uvicorn-hmr = { workspace = true }
16+
1017
[tool.pyright]
1118
typeCheckingMode = "standard"
1219

0 commit comments

Comments
 (0)