From c379f87b48fa4f6f37c8d8dd545adabdaeb31e1a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 Aug 2025 22:13:06 +0000 Subject: [PATCH 1/3] Initial plan From 63f43504c4694f4cfc80391210f1974042dae002 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 Aug 2025 22:32:42 +0000 Subject: [PATCH 2/3] Implement working sanic-hmr package with HMR functionality Co-authored-by: CNSeniorious000 <74518716+CNSeniorious000@users.noreply.github.com> --- examples/sanic/app.py | 27 +++++ examples/sanic/pyproject.toml | 11 ++ packages/sanic-hmr/README.md | 106 ++++++++++++++++ packages/sanic-hmr/pyproject.toml | 31 +++++ packages/sanic-hmr/sanic_hmr.py | 194 ++++++++++++++++++++++++++++++ pyproject.toml | 2 + 6 files changed, 371 insertions(+) create mode 100644 examples/sanic/app.py create mode 100644 examples/sanic/pyproject.toml create mode 100644 packages/sanic-hmr/README.md create mode 100644 packages/sanic-hmr/pyproject.toml create mode 100644 packages/sanic-hmr/sanic_hmr.py diff --git a/examples/sanic/app.py b/examples/sanic/app.py new file mode 100644 index 0000000..bef3d48 --- /dev/null +++ b/examples/sanic/app.py @@ -0,0 +1,27 @@ +from sanic import Sanic, response + +def create_app(): + # Use a unique name each time to avoid conflicts + import time + app_name = f"ExampleApp_{int(time.time() * 1000)}" + app = Sanic(app_name) + + @app.route("/") + async def hello_world(request): + return response.text("Hello, world! (modified with factory)") + + @app.route("/test") + async def test_route(request): + return response.json({"message": "This is a test route"}) + + @app.route("/api/users/") + async def get_user(request, user_id): + return response.json({"user_id": user_id, "name": f"User {user_id}"}) + + return app + +# Export the app instance +app = create_app() + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8000, debug=True) \ No newline at end of file diff --git a/examples/sanic/pyproject.toml b/examples/sanic/pyproject.toml new file mode 100644 index 0000000..bf80ac2 --- /dev/null +++ b/examples/sanic/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "sanic-example" +version = "0" +requires-python = ">=3.12" +dependencies = [ + "sanic", + "sanic-hmr", +] + +[tool.uv.sources] +sanic-hmr = { workspace = true } \ No newline at end of file diff --git a/packages/sanic-hmr/README.md b/packages/sanic-hmr/README.md new file mode 100644 index 0000000..303475f --- /dev/null +++ b/packages/sanic-hmr/README.md @@ -0,0 +1,106 @@ +# sanic-hmr + +[![PyPI - Version](https://img.shields.io/pypi/v/sanic-hmr)](https://pypi.org/project/sanic-hmr/) + +This package provides hot module reloading (HMR) for [`sanic`](https://github.com/sanic-org/sanic). + +It uses [`watchfiles`](https://github.com/samuelcolvin/watchfiles) to detect FS modifications, +re-executes the corresponding modules with [`hmr`](https://github.com/promplate/pyth-on-line/tree/main/packages/hmr) and restart the server (in the same process). + +**HOT** means the main process never restarts, and reloads are fine-grained (only the changed modules and their dependent modules are reloaded). +Since the python module reloading is on-demand and the server is not restarted on every save, it is much faster than Sanic's built-in `--auto-reload` option. + +## Why? + +1. When you use `sanic app.py --auto-reload`, it restarts the whole process on every file change, but restarting the whole process is unnecessary: + - There is no need to restart the Python interpreter, neither all the 3rd-party packages you imported. + - Your changes usually affect only one single file, the rest of your application remains unchanged. +2. `hmr` tracks dependencies at runtime, remembers the relationships between your modules and only reruns necessary modules. +3. So you can save a lot of time by not restarting the whole process on every file change. You can see a significant speedup for debugging large applications. +4. Although magic is involved, we thought and tested them very carefully, so everything works just as-wished. + - Your lazy loading through module-level `__getattr__` still works + - Your runtime imports through `importlib.import_module` or even `__import__` still work + - Even valid circular imports between `__init__.py` and sibling modules still work + - Fine-grained dependency tracking in the above cases still work + - Decorators still work, even meta programming hacks like `getsource` calls work too + - Standard dunder metadata like `__name__`, `__doc__`, `__file__`, `__package__` are correctly set + +Normally, you can replace `sanic app.py --auto-reload` with `sanic-hmr app:app` and everything will work as expected, with a much faster refresh experience. + +## Installation + +```sh +pip install sanic-hmr +``` + +## Usage + +Replace + +```sh +sanic app.py --auto-reload +``` + +or + +```sh +python -m sanic app.app --auto-reload +``` + +with + +```sh +sanic-hmr app:app +``` + +Everything will work as-expected, but with **hot** module reloading. + +## CLI Arguments + +This package supports the most common Sanic server configuration options: + +- `--host`: Host to listen on (default: `localhost`) +- `--port`: Port to listen on (default: `8000`) +- `--workers`: Number of worker processes (default: `1` - forced for HMR compatibility) +- `--debug`: Enable debug mode (default: `False`) +- `--access-log/--no-access-log`: Enable/disable access log (default: enabled) +- `--clear`: Clear the terminal before each reload (default: `False`) + +The behavior of `reload_include` and `reload_exclude` is: + +1. Only file or directory paths are allowed; patterns will be treated as literal paths. +2. Currently only supports hot-reloading Python source files. +3. If you do not provide `reload_include`, the current directory is included by default; if you do provide it, only the specified paths are included. The same applies to `reload_exclude`. + +## Limitations + +- **Single worker only**: HMR requires running in single-worker mode. The `--workers` argument is ignored and forced to 1. +- **Python files only**: Only Python source files are watched for changes. +- **No WebSocket auto-reload**: Unlike some development servers, this doesn't automatically refresh browser pages. + +## Example + +Create a simple Sanic app: + +```python +# app.py +from sanic import Sanic, response + +app = Sanic("MyApp") + +@app.route("/") +async def hello_world(request): + return response.text("Hello, world!") + +@app.route("/test") +async def test_route(request): + return response.json({"message": "This is a test route"}) +``` + +Then run with hot module reloading: + +```sh +sanic-hmr app:app +``` + +Now when you modify `app.py`, the server will automatically reload only the changed modules, providing much faster development cycles compared to full process restarts. \ No newline at end of file diff --git a/packages/sanic-hmr/pyproject.toml b/packages/sanic-hmr/pyproject.toml new file mode 100644 index 0000000..cc5f70c --- /dev/null +++ b/packages/sanic-hmr/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "sanic-hmr" +description = "Hot Module Reloading for Sanic" +version = "0.0.1" +readme = "README.md" +requires-python = ">=3.12" +keywords = ["sanic", "hot-reload", "hmr", "reload", "server"] +authors = [{ name = "Muspi Merol", email = "me@promplate.dev" }] +license = "MIT" +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Operating System :: OS Independent", +] +dependencies = [ + "dowhen~=0.1", + "hmr>=0.5.0,<0.7", + "typer-slim>=0.15.4,<1", + "sanic>=22.0.0", + "uvicorn>=0.24.0", +] + +[project.scripts] +sanic-hmr = "sanic_hmr:app" + +[project.urls] +Homepage = "https://github.com/promplate/hmr" + +[build-system] +requires = ["pdm-backend"] +build-backend = "pdm.backend" \ No newline at end of file diff --git a/packages/sanic-hmr/sanic_hmr.py b/packages/sanic-hmr/sanic_hmr.py new file mode 100644 index 0000000..3fe35ab --- /dev/null +++ b/packages/sanic-hmr/sanic_hmr.py @@ -0,0 +1,194 @@ +import sys +from functools import cached_property +from pathlib import Path +from typing import TYPE_CHECKING, Annotated, override + +from typer import Argument, Option, Typer, secho + +app = Typer(help="Hot Module Replacement for Sanic", add_completion=False, pretty_exceptions_show_locals=False) + + +@app.command(no_args_is_help=True) +def main( + slug: Annotated[str, Argument()] = "main:app", + reload_include: list[str] = [str(Path.cwd())], # noqa: B006, B008 + reload_exclude: list[str] = [".venv"], # noqa: B006 + host: str = "localhost", + port: int = 8000, + workers: int = 1, # noqa: ARG001 # Keep for CLI compatibility but force to 1 + debug: Annotated[bool, Option("--debug", help="Enable debug mode")] = False, # noqa: FBT002 + access_log: Annotated[bool, Option("--access-log/--no-access-log", help="Enable/disable access log")] = True, # noqa: FBT002 + clear: Annotated[bool, Option("--clear", help="Clear the terminal before restarting the server")] = False, # noqa: FBT002 +): + if ":" not in slug: + secho("Invalid slug: ", fg="red", nl=False) + secho(slug, fg="yellow") + exit(1) + module, attr = slug.split(":") + + fragment = module.replace(".", "/") + + file: Path | None + is_package = False + for path in ("", *sys.path): + if (file := Path(path, f"{fragment}.py")).is_file(): + is_package = False + break + if (file := Path(path, fragment, "__init__.py")).is_file(): + is_package = True + break + else: + file = None + + if file is None: + secho("Module", fg="red", nl=False) + secho(f" {module} ", fg="yellow", nl=False) + secho("not found.", fg="red") + exit(1) + + if module in sys.modules: + return secho( + f"It seems you've already imported `{module}` as a normal module. You should call `reactivity.hmr.core.patch_meta_path()` before it.", + fg="red", + ) + + from atexit import register + from importlib.machinery import ModuleSpec + from logging import getLogger + from threading import Event, Thread + + from reactivity.hmr.core import ReactiveModule, ReactiveModuleLoader, SyncReloader, __version__, is_relative_to_any + from reactivity.hmr.utils import load + from watchfiles import Change + + if TYPE_CHECKING: + from sanic import Sanic + + cwd = str(Path.cwd()) + if cwd not in sys.path: + sys.path.insert(0, cwd) + + @register + def _(): + stop_server() + + def stop_server(): + pass + + def start_server(app: "Sanic"): + nonlocal stop_server + + + import uvicorn + + finish = Event() + server = None + + def run_server(): + nonlocal server + watched_paths = [Path(p).resolve() for p in (file, *reload_include)] + ignored_paths = [Path(p).resolve() for p in reloader.excludes] + if all(is_relative_to_any(path, ignored_paths) or not is_relative_to_any(path, watched_paths) for path in ReactiveModule.instances): + logger.error("No files to watch for changes. The server will never reload.") + + # Use Sanic as ASGI app with uvicorn server + # This avoids the threading/signal issues + config = uvicorn.Config( + app=app, # Sanic app itself is the ASGI callable + host=host, + port=port, + log_level="info" if not debug else "debug", + access_log=access_log, + ) + server = uvicorn.Server(config) + server.run() + finish.set() + + Thread(target=run_server, daemon=True).start() + + def stop_server(): + if server: + server.should_exit = True + finish.wait() + + class Reloader(SyncReloader): + def __init__(self): + super().__init__(str(file), reload_include, reload_exclude) + self.error_filter.exclude_filenames.add(__file__) # exclude error stacks within this file + + @cached_property + @override + def entry_module(self): + if "." in module: + __import__(module.rsplit(".", 1)[0]) # ensure parent modules are imported + + if __version__ >= "0.6.4": + from reactivity.hmr.core import _loader as loader + else: + loader = ReactiveModuleLoader(file) # type: ignore + + spec = ModuleSpec(module, loader, origin=str(file), is_package=is_package) + sys.modules[module] = mod = loader.create_module(spec) + loader.exec_module(mod) + return mod + + @override + def run_entry_file(self): + stop_server() + with self.error_filter: + load(self.entry_module) + app_instance = getattr(self.entry_module, attr) + # Ensure it's a Sanic app + if not hasattr(app_instance, 'run'): + secho(f"Attribute '{attr}' is not a Sanic app instance", fg="red") + exit(1) + start_server(app_instance) + + @override + def on_events(self, events): + if events: + paths: list[Path] = [] + for type, file in events: + path = Path(file).resolve() + if type != Change.deleted and path in ReactiveModule.instances: + paths.append(path) + if not paths: + return + + if clear: + print("\033c", end="") + logger.warning("Watchfiles detected changes in %s. Reloading...", ", ".join(map(_display_path, paths))) + return super().on_events(events) + + @override + def start_watching(self): + from dowhen import when + + def log_server_restart(): + logger.warning("Application '%s' has changed. Restarting server...", slug) + + def log_module_reload(self: ReactiveModule): + ns = self.__dict__ + logger.info("Reloading module '%s' from %s", ns["__name__"], _display_path(ns["__file__"])) + + with ( + when(ReactiveModule._ReactiveModule__load.method, "").do(log_module_reload), # type: ignore # noqa: SLF001 + when(self.run_entry_file, "").do(log_server_restart), + ): + return super().start_watching() + + logger = getLogger("sanic.error") + (reloader := Reloader()).keep_watching_until_interrupt() + stop_server() + + +def _display_path(path: str | Path): + p = Path(path).resolve() + try: + return f"'{p.relative_to(Path.cwd())}'" + except ValueError: + return f"'{p}'" + + +if __name__ == "__main__": + app() diff --git a/pyproject.toml b/pyproject.toml index 6674d07..2e43680 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ dependencies = [ "hmr~=0.6.0", "hmr-daemon", "ruff~=0.12.0", + "sanic-hmr", "uvicorn-hmr", ] @@ -17,6 +18,7 @@ members = ["examples/*", "packages/*"] uvicorn-hmr = { workspace = true } fastapi-reloader = { workspace = true } hmr-daemon = { workspace = true } +sanic-hmr = { workspace = true } [tool.pyright] typeCheckingMode = "standard" From 77d06f81da27f50282ebde869bb4049b031d6f2a Mon Sep 17 00:00:00 2001 From: Muspi Merol Date: Tue, 5 Aug 2025 11:55:14 +0800 Subject: [PATCH 3/3] Fix lint errors --- examples/sanic/app.py | 11 +++++++---- packages/sanic-hmr/sanic_hmr.py | 3 +-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/examples/sanic/app.py b/examples/sanic/app.py index bef3d48..41154ae 100644 --- a/examples/sanic/app.py +++ b/examples/sanic/app.py @@ -1,27 +1,30 @@ from sanic import Sanic, response + def create_app(): # Use a unique name each time to avoid conflicts import time + app_name = f"ExampleApp_{int(time.time() * 1000)}" app = Sanic(app_name) @app.route("/") - async def hello_world(request): + async def hello_world(_): return response.text("Hello, world! (modified with factory)") @app.route("/test") - async def test_route(request): + async def test_route(_): return response.json({"message": "This is a test route"}) @app.route("/api/users/") - async def get_user(request, user_id): + async def get_user(_, user_id): return response.json({"user_id": user_id, "name": f"User {user_id}"}) return app + # Export the app instance app = create_app() if __name__ == "__main__": - app.run(host="0.0.0.0", port=8000, debug=True) \ No newline at end of file + app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/packages/sanic-hmr/sanic_hmr.py b/packages/sanic-hmr/sanic_hmr.py index 3fe35ab..48c3703 100644 --- a/packages/sanic-hmr/sanic_hmr.py +++ b/packages/sanic-hmr/sanic_hmr.py @@ -78,7 +78,6 @@ def stop_server(): def start_server(app: "Sanic"): nonlocal stop_server - import uvicorn finish = Event() @@ -139,7 +138,7 @@ def run_entry_file(self): load(self.entry_module) app_instance = getattr(self.entry_module, attr) # Ensure it's a Sanic app - if not hasattr(app_instance, 'run'): + if not hasattr(app_instance, "run"): secho(f"Attribute '{attr}' is not a Sanic app instance", fg="red") exit(1) start_server(app_instance)