Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions examples/flask-hmr-demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Flask HMR Demo

This is a simple example showing how to use flask-hmr.

## Usage

Instead of:
```bash
python app.py
# or
flask run
```

Use:
```bash
flask-hmr app:app
```

## Features

- Hot module replacement for Flask applications
- Faster reloads compared to Flask's built-in reload
- Preserves application state where possible

## Test the HMR

1. Start the server: `flask-hmr app:app`
2. Visit http://127.0.0.1:5000
3. Modify the hello() function in app.py
4. See the changes reflected immediately without manual restart
19 changes: 19 additions & 0 deletions examples/flask-hmr-demo/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from flask import Flask

app = Flask(__name__)


@app.route("/")
def hello():
return "Hello from Flask with HMR!"


@app.route("/api/status")
def status():
return {"status": "running", "hmr": "enabled"}


if __name__ == "__main__":
# Instead of app.run(), use flask-hmr:
# flask-hmr app:app
app.run()
14 changes: 14 additions & 0 deletions examples/flask-hmr-demo/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[project]
name = "flask-hmr-demo"
version = "0"
requires-python = ">=3.12"
dependencies = [
"flask~=3.1.0",
"flask-hmr",
]

[tool.uv]
package = false

[tool.uv.sources]
flask-hmr = { workspace = true }
84 changes: 84 additions & 0 deletions packages/flask-hmr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# flask-hmr

Hot Module Replacement for Flask applications, providing instant server reloading when your code changes.

## Installation

```bash
pip install flask-hmr
```

## Usage

Replace your `flask run` command with `flask-hmr`:

```bash
# Instead of: flask run
flask-hmr app:app

# With custom host and port
flask-hmr app:app --host 0.0.0.0 --port 8000

# Enable Flask debug mode
flask-hmr app:app --debug

# Clear terminal on reload
flask-hmr app:app --clear
```

## Features

- **Fast Hot Module Replacement**: Only reloads changed modules, not the entire application
- **Flask Integration**: Works seamlessly with Flask applications
- **Configurable**: Support for custom host, port, and debug settings
- **Smart Watching**: Automatically detects which files to watch based on your imports

## Example

Given a Flask app in `app.py`:

```python
from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello():
return "Hello, World!"

if __name__ == "__main__":
app.run()
```

Start it with HMR:

```bash
flask-hmr app:app
```

Now when you modify your route handlers, view functions, or imported modules, the server will automatically reload with your changes.

## Command Line Options

- `slug`: The module and app variable (e.g., `app:app`, `myproject.wsgi:application`)
- `--host`: Host to bind to (default: 127.0.0.1)
- `--port`: Port to bind to (default: 5000)
- `--debug`: Enable Flask debug mode
- `--clear`: Clear terminal before restarting server
- `--reload-include`: Additional paths to watch for changes
- `--reload-exclude`: Paths to exclude from watching (default: .venv)

## Comparison with Standard Flask

| Feature | `flask run --reload` | `flask-hmr` |
|---------|---------------------|-------------|
| Reload Speed | Full process restart | Hot module replacement |
| Memory Usage | Higher (new process) | Lower (same process) |
| State Preservation | Lost on reload | Preserved where possible |
| Startup Time | Slower | Faster |

## Requirements

- Python >=3.12
- Flask >=2.0.0
- hmr >=0.5.0
181 changes: 181 additions & 0 deletions packages/flask-hmr/flask_hmr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
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 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 = "127.0.0.1",
port: int = 5000,
debug: Annotated[bool, Option("--debug", help="Enable Flask debug mode")] = False, # 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 flask import Flask

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

from werkzeug.serving import make_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.")

secho(f"* Running on http://{host}:{port}", fg="green")
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)
flask_app = getattr(self.entry_module, attr)

# Configure Flask app
if debug:
flask_app.config["DEBUG"] = True

start_server(flask_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, "<start>").do(log_module_reload), # type: ignore # noqa: SLF001
when(self.run_entry_file, "<start>").do(log_server_restart),
):
return super().start_watching()

logger = getLogger("flask.hmr")
(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()
31 changes: 31 additions & 0 deletions packages/flask-hmr/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
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>=2.0.0",
"werkzeug>=2.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"
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -15,6 +16,7 @@ members = ["examples/*", "packages/*"]

[tool.uv.sources]
uvicorn-hmr = { workspace = true }
flask-hmr = { workspace = true }
fastapi-reloader = { workspace = true }
hmr-daemon = { workspace = true }

Expand Down
Loading