Skip to content

Commit 7634a5a

Browse files
committed
Add a reloader to Quart
This is based on the old Hypercorn reloader, and is included as the Quart reloader must run in the same process and event loop as the app. This is required as the app is often adapted before `run` is called, hence the new Hypercorn process method will break expectations. This is a slight shame, as the new Hypercorn reloader is much more reliable, however it does keep compatibility and is acceptable given the strong reminders that `run` is for development only.
1 parent 2306a8b commit 7634a5a

3 files changed

Lines changed: 100 additions & 9 deletions

File tree

src/quart/app.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,14 @@
126126
TestClientProtocol,
127127
WhileServingCallable,
128128
)
129-
from .utils import file_path_to_path, is_coroutine_function, run_sync
129+
from .utils import (
130+
file_path_to_path,
131+
is_coroutine_function,
132+
MustReloadError,
133+
observe_changes,
134+
restart,
135+
run_sync,
136+
)
130137
from .wrappers import BaseRequestWebsocket, Request, Response, Websocket
131138

132139
AppOrBlueprintKey = Optional[str] # The App key is None, whereas blueprints are named
@@ -1386,7 +1393,6 @@ def _signal_handler(*_: Any) -> None:
13861393
host,
13871394
port,
13881395
debug,
1389-
use_reloader,
13901396
ca_certs,
13911397
certfile,
13921398
keyfile,
@@ -1402,8 +1408,15 @@ def _signal_handler(*_: Any) -> None:
14021408
scheme = "https" if certfile is not None and keyfile is not None else "http"
14031409
print(f" * Running on {scheme}://{host}:{port} (CTRL + C to quit)") # noqa: T201
14041410

1411+
tasks = [loop.create_task(task)]
1412+
if use_reloader:
1413+
tasks.append(loop.create_task(observe_changes(asyncio.sleep, shutdown_event)))
1414+
1415+
reload_ = False
14051416
try:
1406-
loop.run_until_complete(task)
1417+
loop.run_until_complete(asyncio.gather(*tasks))
1418+
except MustReloadError:
1419+
reload_ = True
14071420
finally:
14081421
try:
14091422
_cancel_all_tasks(loop)
@@ -1412,12 +1425,14 @@ def _signal_handler(*_: Any) -> None:
14121425
asyncio.set_event_loop(None)
14131426
loop.close()
14141427

1428+
if reload_:
1429+
restart()
1430+
14151431
def run_task(
14161432
self,
14171433
host: str = "127.0.0.1",
14181434
port: int = 5000,
14191435
debug: Optional[bool] = None,
1420-
use_reloader: bool = True,
14211436
ca_certs: Optional[str] = None,
14221437
certfile: Optional[str] = None,
14231438
keyfile: Optional[str] = None,
@@ -1433,7 +1448,6 @@ def run_task(
14331448
only, use 0.0.0.0 to have the server listen externally.
14341449
port: Port number to listen on.
14351450
debug: If set enable (or disable) debug mode and debug output.
1436-
use_reloader: Automatically reload on code changes.
14371451
ca_certs: Path to the SSL CA certificate file.
14381452
certfile: Path to the SSL certificate file.
14391453
keyfile: Path to the SSL key file.
@@ -1449,7 +1463,6 @@ def run_task(
14491463
self.debug = debug
14501464
config.errorlog = config.accesslog
14511465
config.keyfile = keyfile
1452-
config.use_reloader = use_reloader
14531466

14541467
return serve(self, config, shutdown_trigger=shutdown_trigger)
14551468

src/quart/typing.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,3 +340,8 @@ async def __aenter__(self) -> TestAppProtocol:
340340

341341
async def __aexit__(self, exc_type: type, exc_value: BaseException, tb: TracebackType) -> None:
342342
...
343+
344+
345+
class Event(Protocol):
346+
def is_set(self) -> bool:
347+
...

src/quart/utils.py

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@
22

33
import asyncio
44
import inspect
5+
import os
6+
import platform
57
import sys
68
from contextvars import copy_context
79
from functools import partial, wraps
8-
from os import PathLike
910
from pathlib import Path
1011
from typing import (
1112
Any,
1213
AsyncGenerator,
14+
Awaitable,
1315
Callable,
1416
Coroutine,
17+
Dict,
1518
Generator,
1619
Iterable,
1720
List,
@@ -22,15 +25,19 @@
2225

2326
from werkzeug.datastructures import Headers
2427

25-
from .typing import FilePath
28+
from .typing import Event, FilePath
2629

2730
if TYPE_CHECKING:
2831
from .wrappers.response import Response # noqa: F401
2932

3033

34+
class MustReloadError(Exception):
35+
pass
36+
37+
3138
def file_path_to_path(*paths: FilePath) -> Path:
3239
# Flask supports bytes paths
33-
safe_paths: List[Union[str, PathLike]] = []
40+
safe_paths: List[Union[str, os.PathLike]] = []
3441
for path in paths:
3542
if isinstance(path, bytes):
3643
safe_paths.append(path.decode())
@@ -122,3 +129,69 @@ def encode_headers(headers: Headers) -> List[Tuple[bytes, bytes]]:
122129

123130
def decode_headers(headers: Iterable[Tuple[bytes, bytes]]) -> Headers:
124131
return Headers([(key.decode(), value.decode()) for key, value in headers])
132+
133+
134+
async def observe_changes(sleep: Callable[[float], Awaitable[Any]], shutdown_event: Event) -> None:
135+
last_updates: Dict[Path, float] = {}
136+
for module in list(sys.modules.values()):
137+
filename = getattr(module, "__file__", None)
138+
if filename is None:
139+
continue
140+
path = Path(filename)
141+
try:
142+
last_updates[Path(filename)] = path.stat().st_mtime
143+
except (FileNotFoundError, NotADirectoryError):
144+
pass
145+
146+
while not shutdown_event.is_set():
147+
await sleep(1)
148+
149+
for index, (path, last_mtime) in enumerate(last_updates.items()):
150+
if index % 10 == 0:
151+
# Yield to the event loop
152+
await sleep(0)
153+
154+
try:
155+
mtime = path.stat().st_mtime
156+
except FileNotFoundError:
157+
# File deleted
158+
raise MustReloadError()
159+
else:
160+
if mtime > last_mtime:
161+
raise MustReloadError()
162+
else:
163+
last_updates[path] = mtime
164+
165+
166+
def restart() -> None:
167+
# Restart this process (only safe for dev/debug)
168+
executable = sys.executable
169+
script_path = Path(sys.argv[0]).resolve()
170+
args = sys.argv[1:]
171+
main_package = sys.modules["__main__"].__package__
172+
173+
if main_package is None:
174+
# Executed by filename
175+
if platform.system() == "Windows":
176+
if not script_path.exists() and script_path.with_suffix(".exe").exists():
177+
# quart run
178+
executable = str(script_path.with_suffix(".exe"))
179+
else:
180+
# python run.py
181+
args.append(str(script_path))
182+
else:
183+
if script_path.is_file() and os.access(script_path, os.X_OK):
184+
# hypercorn run:app --reload
185+
executable = str(script_path)
186+
else:
187+
# python run.py
188+
args.append(str(script_path))
189+
else:
190+
# Executed as a module e.g. python -m run
191+
module = script_path.stem
192+
import_name = main_package
193+
if module != "__main__":
194+
import_name = f"{main_package}.{module}"
195+
args[:0] = ["-m", import_name.lstrip(".")]
196+
197+
os.execv(executable, [executable] + args)

0 commit comments

Comments
 (0)