-
Notifications
You must be signed in to change notification settings - Fork 7
Implement flask-hmr: Hot Module Replacement CLI wrapper for Flask applications #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Copilot
wants to merge
6
commits into
readme
Choose a base branch
from
copilot/fix-1e523609-173b-4bde-b5a6-1fba60a75e71
base: readme
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 3 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
7efd172
Initial plan
Copilot 6274006
Implement flask-hmr package with CLI and HMR functionality
Copilot ac56055
Add flask-hmr demo example and fix linting issues
Copilot d5124ad
Potential fix for code scanning alert no. 3: Flask app is run in debuβ¦
CNSeniorious000 1b90a24
Add missing pyproject.toml to flask-hmr-demo example
Copilot 4ba466a
Reformat with ruff
CNSeniorious000 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| 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(debug=True) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.