├── .gitattributes ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── ci └── azure-pipelines.yml ├── docs └── demo.gif ├── example ├── README.md ├── __init__.py ├── pages │ ├── README.md │ ├── page1.md │ ├── page2.md │ └── page3.md └── server │ ├── __init__.py │ ├── app.py │ ├── content.py │ ├── events.py │ ├── resources.py │ ├── routes.py │ ├── settings.py │ └── templates │ └── index.jinja ├── pyproject.toml ├── requirements.txt ├── setup.cfg ├── setup.py ├── src └── arel │ ├── __init__.py │ ├── _app.py │ ├── _models.py │ ├── _notify.py │ ├── _types.py │ ├── _watch.py │ ├── data │ └── client.js │ └── py.typed └── tests ├── __init__.py ├── common.py ├── conftest.py ├── test_example.py └── utils.py /.gitattributes: -------------------------------------------------------------------------------- 1 | *.gif filter=lfs diff=lfs merge=lfs -text 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Tooling. 2 | .coverage 3 | venv*/ 4 | 5 | # Caches. 6 | __pycache__/ 7 | *.pyc 8 | .mypy_cache/ 9 | .pytest_cache/ 10 | 11 | # Packaging. 12 | build/ 13 | dist/ 14 | *.egg-info/ 15 | 16 | # Private. 17 | .env 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 6 | 7 | ## 0.3.0 - 2023-12-29 8 | 9 | ### Changed 10 | 11 | - Use `watchfiles` instead of `watchgod`. This unlocks Python 3.12 support. (Pull #34) 12 | 13 | ### Added 14 | 15 | - Add support for Python 3.12. (Pull #35) 16 | 17 | ## 0.2.0 - 2020-07-08 18 | 19 | ### Added 20 | 21 | - Add support for watching multiple directories, each with its own reload callbacks. (Pull #15) 22 | 23 | ### Changed 24 | 25 | - `arel.HotReload("./directory", on_reload=[...])` should now be written as `arel.HotReload(paths=[arel.Path("./directory", on_reload=[...])])`. (Pull #15) 26 | 27 | ## 0.1.0 - 2020-04-11 28 | 29 | _Initial release._ 30 | 31 | ### Added 32 | 33 | - Add `HotReload` ASGI application class. (Pull #1) 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Florimond Manca 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft src 2 | include README.md 3 | include CHANGELOG.md 4 | include LICENSE 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | bin = venv/bin/ 2 | pysources = src example/server tests 3 | 4 | build: 5 | ${bin}python -m build 6 | 7 | check: 8 | ${bin}black --check --diff --target-version=py37 ${pysources} 9 | ${bin}flake8 ${pysources} 10 | ${bin}mypy ${pysources} 11 | ${bin}isort --check --diff ${pysources} 12 | 13 | install: install-python 14 | 15 | venv: 16 | python3 -m venv venv 17 | 18 | install-python: venv 19 | ${bin}pip install -U pip wheel 20 | ${bin}pip install -U build 21 | ${bin}pip install -r requirements.txt 22 | 23 | format: 24 | ${bin}autoflake --in-place --recursive ${pysources} 25 | ${bin}isort ${pysources} 26 | ${bin}black --target-version=py37 ${pysources} 27 | 28 | publish: 29 | ${bin}twine upload dist/* 30 | 31 | serve: 32 | ${bin}uvicorn example.server:app --reload --reload-dir ./example 33 | 34 | test: 35 | ${bin}pytest 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # arel 2 | 3 | [](https://dev.azure.com/florimondmanca/public/_build/latest?definitionId=6&branchName=master) 4 | [](https://codecov.io/gh/florimondmanca/arel) 5 |  6 | [](https://pypi.org/project/arel) 7 | 8 | Browser hot reload for Python ASGI web apps. 9 | 10 |  11 | 12 | ## Overview 13 | 14 | **What is this for?** 15 | 16 | `arel` can be used to implement development-only hot-reload for non-Python files that are not read from disk on each request. This may include HTML templates, GraphQL schemas, cached rendered Markdown content, etc. 17 | 18 | **How does it work?** 19 | 20 | `arel` watches changes over a set of files. When a file changes, `arel` notifies the browser (using WebSocket), and an injected client script triggers a page reload. You can register your own reload hooks for any extra server-side operations, such as reloading cached content or re-initializing other server-side resources. 21 | 22 | ## Installation 23 | 24 | ```bash 25 | pip install 'arel==0.3.*' 26 | ``` 27 | 28 | ## Quickstart 29 | 30 | _For a working example using Starlette, see the [Example](#example) section._ 31 | 32 | Although the exact instructions to set up hot reload with `arel` depend on the specifics of your ASGI framework, there are three general steps to follow: 33 | 34 | 1. Create an `HotReload` instance, passing one or more directories of files to watch, and optionally a list of callbacks to call before a reload is triggered: 35 | 36 | ```python 37 | import arel 38 | 39 | async def reload_data(): 40 | print("Reloading server data...") 41 | 42 | hotreload = arel.HotReload( 43 | paths=[ 44 | arel.Path("./server/data", on_reload=[reload_data]), 45 | arel.Path("./server/static"), 46 | ], 47 | ) 48 | ``` 49 | 50 | 2. Mount the hot reload endpoint, and register its startup and shutdown event handlers. If using Starlette, this can be done like this: 51 | 52 | ```python 53 | from starlette.applications import Starlette 54 | from starlette.routing import WebSocketRoute 55 | 56 | app = Starlette( 57 | routes=[WebSocketRoute("/hot-reload", hotreload, name="hot-reload")], 58 | on_startup=[hotreload.startup], 59 | on_shutdown=[hotreload.shutdown], 60 | ) 61 | ``` 62 | 63 | 3. Add the JavaScript code to your website HTML. If using [Starlette with Jinja templates](https://www.starlette.io/templates/), you can do this by updating the global environment, then injecting the script into your base template: 64 | 65 | ```python 66 | templates.env.globals["DEBUG"] = os.getenv("DEBUG") # Development flag. 67 | templates.env.globals["hotreload"] = hotreload 68 | ``` 69 | 70 | ```jinja 71 |
72 | 73 | 74 | 75 | {% if DEBUG %} 76 | {{ hotreload.script(url_for('hot-reload')) | safe }} 77 | {% endif %} 78 | 79 | ``` 80 | 81 | ## Example 82 | 83 | The [`example` directory](https://github.com/florimondmanca/arel/tree/master/example) contains an example Markdown-powered website that uses `arel` to refresh the browser when Markdown content or HTML templates change. 84 | 85 | ## License 86 | 87 | MIT 88 | -------------------------------------------------------------------------------- /ci/azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | resources: 2 | repositories: 3 | - repository: templates 4 | type: github 5 | endpoint: github 6 | name: florimondmanca/azure-pipelines-templates 7 | ref: refs/tags/6.2 8 | 9 | trigger: 10 | - master 11 | - refs/tags/* 12 | 13 | pr: 14 | - master 15 | 16 | variables: 17 | - name: CI 18 | value: "true" 19 | - name: PIP_CACHE_DIR 20 | value: $(Pipeline.Workspace)/.cache/pip 21 | - group: pypi-credentials 22 | 23 | stages: 24 | - stage: test 25 | jobs: 26 | - template: job--python-check.yml@templates 27 | parameters: 28 | pythonVersion: "3.12" 29 | 30 | - template: job--python-test.yml@templates 31 | parameters: 32 | jobs: 33 | py37: null 34 | py312: 35 | coverage: true 36 | 37 | - stage: publish 38 | condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/') 39 | jobs: 40 | - template: job--python-publish.yml@templates 41 | parameters: 42 | pythonVersion: "3.12" 43 | token: $(pypiToken) 44 | -------------------------------------------------------------------------------- /docs/demo.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:245e161042be0c0b11dea7623a79861b495a24fd0408352ee86404b8a1fee669 3 | size 388107 4 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # arel - Example 2 | 3 | This example Starlette web application renders pages dynamically from local Markdown files. 4 | 5 | For performance, files are not read from disk on each request. Instead, all pages are rendered into memory on application startup. 6 | 7 | During development, if we make edits to the content we'd like the browser to automatically reload the page without having to restart the entire server. 8 | 9 | ## Installation 10 | 11 | - Clone the [`arel`](https://github.com/florimondmanca/arel) repository. 12 | - Make sure you are in the repository root directory. 13 | - Install dependencies: `$ make install`. 14 | 15 | ## Usage 16 | 17 | - Start the server: `$ make serve`. 18 | - Open your browser at http://localhost:8000. 19 | - Add, edit or delete one of the Markdown files in `pages/`. 20 | - A message should appear in the console, and the page should refresh automatically. 21 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/florimondmanca/arel/d98aa72de29203093e2c98fc6b9a31bafd2c07a3/example/__init__.py -------------------------------------------------------------------------------- /example/pages/README.md: -------------------------------------------------------------------------------- 1 | # Hello, world! 2 | 3 | Welcome to this web site! 4 | 5 | **Contents** 6 | 7 | - [Page 1](/page1) 8 | - [Page 2](/page2) 9 | - [Page 3](/page3) 10 | -------------------------------------------------------------------------------- /example/pages/page1.md: -------------------------------------------------------------------------------- 1 | # Page 1 2 | 3 | This is the first page. 4 | 5 | [Home](/) 6 | -------------------------------------------------------------------------------- /example/pages/page2.md: -------------------------------------------------------------------------------- 1 | # Page 2 2 | 3 | This is the second page. 4 | 5 | [Home](/) 6 | -------------------------------------------------------------------------------- /example/pages/page3.md: -------------------------------------------------------------------------------- 1 | # Page 3 2 | 3 | This is the third page. 4 | 5 | [Home](/) 6 | -------------------------------------------------------------------------------- /example/server/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import app 2 | 3 | __all__ = ["app"] 4 | -------------------------------------------------------------------------------- /example/server/app.py: -------------------------------------------------------------------------------- 1 | from starlette.applications import Starlette 2 | 3 | from . import settings 4 | from .events import on_shutdown, on_startup 5 | from .routes import routes 6 | 7 | app = Starlette( 8 | debug=settings.DEBUG, 9 | routes=routes, 10 | on_startup=on_startup, 11 | on_shutdown=on_shutdown, 12 | ) 13 | -------------------------------------------------------------------------------- /example/server/content.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional 2 | 3 | import markdown as md 4 | 5 | from . import settings 6 | 7 | PAGES: Dict[str, str] = {} 8 | 9 | 10 | async def load_pages() -> None: 11 | for path in settings.PAGES_DIR.glob("*.md"): 12 | PAGES[path.name] = md.markdown(path.read_text()) 13 | 14 | 15 | def get_page_content(page: str) -> Optional[str]: 16 | filename = f"{page}.md" 17 | return PAGES.get(filename) 18 | -------------------------------------------------------------------------------- /example/server/events.py: -------------------------------------------------------------------------------- 1 | from . import settings 2 | from .content import load_pages 3 | from .resources import hotreload 4 | 5 | on_startup = [load_pages] 6 | on_shutdown = [] 7 | 8 | if settings.DEBUG: 9 | on_startup += [hotreload.startup] 10 | on_shutdown += [hotreload.shutdown] 11 | -------------------------------------------------------------------------------- /example/server/resources.py: -------------------------------------------------------------------------------- 1 | from starlette.templating import Jinja2Templates 2 | 3 | import arel 4 | 5 | from . import settings 6 | from .content import load_pages 7 | 8 | hotreload = arel.HotReload( 9 | paths=[ 10 | arel.Path(str(settings.PAGES_DIR), on_reload=[load_pages]), 11 | arel.Path(str(settings.TEMPLATES_DIR)), 12 | ], 13 | ) 14 | 15 | templates = Jinja2Templates(directory=str(settings.TEMPLATES_DIR)) 16 | templates.env.globals["DEBUG"] = settings.DEBUG 17 | templates.env.globals["hotreload"] = hotreload 18 | -------------------------------------------------------------------------------- /example/server/routes.py: -------------------------------------------------------------------------------- 1 | from starlette.exceptions import HTTPException 2 | from starlette.requests import Request 3 | from starlette.responses import Response 4 | from starlette.routing import Route, WebSocketRoute 5 | 6 | from . import settings 7 | from .content import get_page_content 8 | from .resources import hotreload, templates 9 | 10 | 11 | async def render(request: Request) -> Response: 12 | page: str = request.path_params.get("page", "README") 13 | 14 | page_content = get_page_content(page) 15 | 16 | if page_content is None: 17 | raise HTTPException(404) 18 | 19 | context = {"request": request, "page_content": page_content} 20 | 21 | return templates.TemplateResponse("index.jinja", context=context) 22 | 23 | 24 | routes: list = [ 25 | Route("/", render), 26 | Route("/{page:path}", render), 27 | ] 28 | 29 | if settings.DEBUG: 30 | routes += [ 31 | WebSocketRoute("/hot-reload", hotreload, name="hot-reload"), 32 | ] 33 | -------------------------------------------------------------------------------- /example/server/settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from starlette.config import Config 4 | 5 | config = Config(".env") 6 | 7 | BASE_DIR = Path(__file__).parent 8 | 9 | DEBUG = config("DEBUG", cast=bool, default=False) 10 | 11 | PAGES_DIR = BASE_DIR.parent / "pages" 12 | TEMPLATES_DIR = BASE_DIR / "templates" 13 | -------------------------------------------------------------------------------- /example/server/templates/index.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |