diff --git a/examples/flask/README.md b/examples/flask/README.md index ebec328..bb4b4b6 100644 --- a/examples/flask/README.md +++ b/examples/flask/README.md @@ -4,13 +4,40 @@ This example demonstrates how to use HMR with a Flask application. ## How to Run +### Option 1: Using flask-hmr CLI (Recommended) + After installing dependencies, run the following command in this directory: +```sh +flask-hmr app:app +``` + +Or with custom options: + +```sh +flask-hmr --host 0.0.0.0 --port 8080 --clear app:app +``` + +### Option 2: Using the original HMR approach + ```sh hmr app.py ``` -This will start a Flask development server with HOT-reloading enabled. +### Option 3: Using the custom start.py approach (Legacy) + +```sh +hmr start.py +``` + +## Available flask-hmr CLI Options + +- `--host`: Host to bind to (default: localhost) +- `--port`: Port to bind to (default: 5000) +- `--reload-include`: Directories to watch for changes (default: current directory) +- `--reload-exclude`: Directories to exclude from watching (default: .venv) +- `--clear`: Clear terminal before restarting server +- `--env-file`: Environment file to load ## What to Observe @@ -18,8 +45,8 @@ Once the server is running, you can access the application at `http://localhost: - Visit `http://localhost:5000/a` and `http://localhost:5000/b`. - Try modifying `b.py` and refresh the browser to see the changes applied instantly (without rerunning `sleep(1)` in `a.py`). -- Everything else should work as expected too. You will find your development experience much smoother than just using `flask dev --reload`. +- Everything else should work as expected too. You will find your development experience much smoother than just using `flask run --reload`. + +## What's New -> [!NOTE] -> Unlike [the FastAPI example](../fastapi/), we haven't implement an integration for Werkzeug, which is the WSGI server used by Flask. -> If you know the `flask` CLI or `werkzeug` well, you are welcome to contribute an integration. +The `flask-hmr` CLI provides a Flask-specific hot module reloading experience similar to uvicorn-hmr but for WSGI applications. It integrates seamlessly with the existing HMR system while providing a familiar Flask-like interface. diff --git a/examples/flask/pyproject.toml b/examples/flask/pyproject.toml index 759efa6..1693a53 100644 --- a/examples/flask/pyproject.toml +++ b/examples/flask/pyproject.toml @@ -4,8 +4,12 @@ version = "0" requires-python = ">=3.12" dependencies = [ "flask~=3.1.0", + "flask-hmr", "hmr~=0.6.0", ] [tool.uv] package = false + +[tool.uv.sources] +flask-hmr = { workspace = true } diff --git a/packages/flask-hmr/README.md b/packages/flask-hmr/README.md new file mode 100644 index 0000000..3d837fd --- /dev/null +++ b/packages/flask-hmr/README.md @@ -0,0 +1,36 @@ +# Flask HMR + +Hot Module Reloading for Flask applications, similar to uvicorn-hmr but for WSGI apps. + +This package provides a CLI tool that replaces the standard `flask run` command with Hot Module Reloading capabilities. + +## Installation + +```bash +pip install flask-hmr +``` + +## Usage + +Instead of using `flask run`, use: + +```bash +flask-hmr app:app +``` + +Where `app:app` is your Flask application in the format `module:attribute`. + +## Options + +- `--host`: Host to bind to (default: localhost) +- `--port`: Port to bind to (default: 5000) +- `--reload-include`: Directories to watch for changes +- `--reload-exclude`: Directories to exclude from watching +- `--clear`: Clear terminal before restarting server +- `--env-file`: Environment file to load + +## Example + +```bash +flask-hmr --host 0.0.0.0 --port 8000 app:app +``` \ No newline at end of file diff --git a/packages/flask-hmr/flask_hmr.py b/packages/flask-hmr/flask_hmr.py new file mode 100644 index 0000000..df8d484 --- /dev/null +++ b/packages/flask-hmr/flask_hmr.py @@ -0,0 +1,176 @@ +import sys +from functools import cached_property +from pathlib import Path +from typing import TYPE_CHECKING, Annotated, override +from atexit import register +from threading import Event, Thread + +from typer import Argument, Option, Typer, secho + +if TYPE_CHECKING: + from flask import Flask + +app = Typer(help="Hot Module Replacement for Flask", add_completion=False, pretty_exceptions_show_locals=False) + + +@app.command(no_args_is_help=True) +def main( + slug: Annotated[str, Argument()] = "app:app", + reload_include: list[str] = [str(Path.cwd())], # noqa: B006, B008 + reload_exclude: list[str] = [".venv"], # noqa: B006 + host: str = "localhost", + port: int = 5000, + env_file: Path | None = None, + log_level: str | None = "info", + 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 importlib.machinery import ModuleSpec + from logging import getLogger + + from reactivity.hmr.core import ReactiveModule, ReactiveModuleLoader, SyncReloader, __version__, is_relative_to_any + from reactivity.hmr.utils import load + from werkzeug.serving import make_server + from watchfiles import Change + + 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: "Flask"): + nonlocal stop_server + + server = make_server(host, port, app, threaded=True) + finish = Event() + + def run_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.") + + print(f" * Running on http://{host}:{port}") + server.serve_forever(poll_interval=0.1) + finish.set() + + Thread(target=run_server, daemon=True).start() + + def stop_server(): + server.shutdown() + 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 = getattr(self.entry_module, attr) + start_server(app) + + @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("werkzeug") + (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() \ No newline at end of file diff --git a/packages/flask-hmr/pyproject.toml b/packages/flask-hmr/pyproject.toml new file mode 100644 index 0000000..19560c0 --- /dev/null +++ b/packages/flask-hmr/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "flask-hmr" +description = "Hot Module Reloading for Flask" +version = "0.0.1" +readme = "README.md" +requires-python = ">=3.12" +keywords = ["flask", "hot-reload", "hmr", "reload", "server", "wsgi"] +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", + "flask>=3.0.0", + "werkzeug>=3.0.0", +] + +[project.scripts] +flask-hmr = "flask_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/pyproject.toml b/pyproject.toml index 6674d07..53fc72d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ version = "0" requires-python = ">=3.12" dependencies = [ "fastapi-reloader", + "flask-hmr", "hmr~=0.6.0", "hmr-daemon", "ruff~=0.12.0", @@ -16,6 +17,7 @@ members = ["examples/*", "packages/*"] [tool.uv.sources] uvicorn-hmr = { workspace = true } fastapi-reloader = { workspace = true } +flask-hmr = { workspace = true } hmr-daemon = { workspace = true } [tool.pyright]