├── .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 | [![Build Status](https://dev.azure.com/florimondmanca/public/_apis/build/status/florimondmanca.arel?branchName=master)](https://dev.azure.com/florimondmanca/public/_build/latest?definitionId=6&branchName=master) 4 | [![Coverage](https://codecov.io/gh/florimondmanca/arel/branch/master/graph/badge.svg)](https://codecov.io/gh/florimondmanca/arel) 5 | ![Python versions](https://img.shields.io/pypi/pyversions/arel.svg) 6 | [![Package version](https://badge.fury.io/py/arel.svg)](https://pypi.org/project/arel) 7 | 8 | Browser hot reload for Python ASGI web apps. 9 | 10 | ![](https://media.githubusercontent.com/media/florimondmanca/arel/master/docs/demo.gif) 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 | Hot reload 7 | 8 | 9 | {{ page_content | safe }} 10 | 11 | {% if DEBUG %} 12 | {{ hotreload.script(url_for("hot-reload")) | safe }} 13 | {% endif %} 14 | 15 | 16 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "setuptools-scm", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "arel" 7 | description = "Browser hot reload for Python ASGI web apps" 8 | requires-python = ">=3.7" 9 | license = { text = "MIT" } 10 | authors = [ 11 | { name = "Florimond Manca", email = "florimond.manca@protonmail.com" }, 12 | ] 13 | classifiers = [ 14 | "Development Status :: 3 - Alpha", 15 | "Intended Audience :: Developers", 16 | "Framework :: AsyncIO", 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3 :: Only", 19 | "Programming Language :: Python :: 3.7", 20 | "Programming Language :: Python :: 3.8", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | ] 26 | dependencies = [ 27 | "starlette==0.*", 28 | "watchfiles==0.*", 29 | ] 30 | dynamic = ["version", "readme"] 31 | 32 | [project.urls] 33 | "Homepage" = "https://github.com/florimondmanca/arel" 34 | 35 | [tool.setuptools.dynamic] 36 | version = { attr = "arel.__version__" } 37 | readme = { file = ["README.md", "CHANGELOG.md"], content-type = "text/markdown" } 38 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | 3 | # Packaging. 4 | twine 5 | wheel 6 | 7 | # Example. 8 | jinja2==3.* 9 | markdown==3.* 10 | starlette==0.21.* 11 | uvicorn==0.19.* 12 | 13 | # Tooling and tests. 14 | autoflake 15 | black==22.10.* 16 | flake8==5.* 17 | httpx==0.23.* 18 | isort==5.* 19 | mypy==0.990 20 | pytest==7.* 21 | pytest-asyncio==0.20.* 22 | pytest-cov 23 | types-markdown 24 | websockets==10.* 25 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = W503, E203, B305 3 | max-line-length = 88 4 | 5 | [mypy] 6 | disallow_untyped_defs = True 7 | ignore_missing_imports = True 8 | 9 | [tool:isort] 10 | profile = black 11 | 12 | [tool:pytest] 13 | addopts = 14 | -rxXs 15 | --cov=arel 16 | --cov=tests 17 | --cov-report=term-missing 18 | --cov-fail-under=100 19 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() # Editable installs. 4 | -------------------------------------------------------------------------------- /src/arel/__init__.py: -------------------------------------------------------------------------------- 1 | from ._app import HotReload 2 | from ._models import Path 3 | 4 | __version__ = "0.3.0" 5 | 6 | __all__ = ["__version__", "HotReload", "Path"] 7 | -------------------------------------------------------------------------------- /src/arel/_app.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import logging 3 | import pathlib 4 | import string 5 | from typing import List, Sequence 6 | 7 | from starlette.concurrency import run_until_first_complete 8 | from starlette.types import Receive, Scope, Send 9 | from starlette.websockets import WebSocket 10 | 11 | from ._models import Path 12 | from ._notify import Notify 13 | from ._types import ReloadFunc 14 | from ._watch import ChangeSet, FileWatcher 15 | 16 | SCRIPT_TEMPLATE_PATH = pathlib.Path(__file__).parent / "data" / "client.js" 17 | assert SCRIPT_TEMPLATE_PATH.exists() 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class _Template(string.Template): 23 | delimiter = "$arel::" 24 | 25 | 26 | class HotReload: 27 | def __init__(self, paths: Sequence[Path], reconnect_interval: float = 1.0) -> None: 28 | self.notify = Notify() 29 | self.watchers = [ 30 | FileWatcher( 31 | path, 32 | on_change=functools.partial(self._on_changes, on_reload=on_reload), 33 | ) 34 | for path, on_reload in paths 35 | ] 36 | self._reconnect_interval = reconnect_interval 37 | 38 | async def _on_changes( 39 | self, changeset: ChangeSet, *, on_reload: List[ReloadFunc] 40 | ) -> None: 41 | description = ", ".join( 42 | f"file {event} at {', '.join(f'{event!r}' for event in changeset[event])}" 43 | for event in changeset 44 | ) 45 | logger.warning("Detected %s. Triggering reload...", description) 46 | 47 | # Run server-side hooks first. 48 | for callback in on_reload: 49 | await callback() 50 | 51 | await self.notify.notify() 52 | 53 | def script(self, url: str) -> str: 54 | if not hasattr(self, "_script_template"): 55 | self._script_template = _Template(SCRIPT_TEMPLATE_PATH.read_text()) 56 | 57 | content = self._script_template.substitute( 58 | {"url": url, "reconnect_interval": self._reconnect_interval} 59 | ) 60 | 61 | return f"" 62 | 63 | async def startup(self) -> None: 64 | try: 65 | for watcher in self.watchers: 66 | await watcher.startup() 67 | except BaseException as exc: # pragma: no cover 68 | logger.error("Error while starting hot reload: %r", exc) 69 | raise 70 | 71 | async def shutdown(self) -> None: 72 | try: 73 | for watcher in self.watchers: 74 | await watcher.shutdown() 75 | except BaseException as exc: # pragma: no cover 76 | logger.error("Error while stopping hot reload: %r", exc) 77 | raise 78 | 79 | async def _wait_client_disconnect(self, ws: WebSocket) -> None: 80 | async for _ in ws.iter_text(): 81 | pass # pragma: no cover 82 | 83 | async def _watch_reloads(self, ws: WebSocket) -> None: 84 | async for _ in self.notify.watch(): 85 | await ws.send_text("reload") 86 | 87 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 88 | assert scope["type"] == "websocket" 89 | ws = WebSocket(scope, receive, send) 90 | await ws.accept() 91 | await run_until_first_complete( 92 | (self._watch_reloads, {"ws": ws}), 93 | (self._wait_client_disconnect, {"ws": ws}), 94 | ) 95 | -------------------------------------------------------------------------------- /src/arel/_models.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple, Sequence 2 | 3 | from ._types import ReloadFunc 4 | 5 | 6 | class Path(NamedTuple): 7 | path: str 8 | on_reload: Sequence[ReloadFunc] = () 9 | -------------------------------------------------------------------------------- /src/arel/_notify.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from contextlib import asynccontextmanager 3 | from typing import AsyncIterator, Set 4 | 5 | 6 | class Notify: 7 | def __init__(self) -> None: 8 | self._broadcast = _MemoryBroadcast() 9 | 10 | async def notify(self) -> None: 11 | await self._broadcast.publish("reload") 12 | 13 | async def watch(self) -> AsyncIterator[None]: 14 | async with self._broadcast.subscribe() as subscription: 15 | async for _ in subscription: 16 | yield 17 | 18 | 19 | class _MemoryBroadcast: 20 | """ 21 | A basic in-memory pub/sub helper. 22 | """ 23 | 24 | class Subscription: 25 | def __init__(self, queue: asyncio.Queue) -> None: 26 | self._queue = queue 27 | 28 | async def __aiter__(self) -> AsyncIterator[str]: 29 | while True: 30 | yield await self._queue.get() 31 | 32 | def __init__(self) -> None: 33 | self._subscriptions: Set[asyncio.Queue] = set() 34 | 35 | async def publish(self, event: str) -> None: 36 | for queue in self._subscriptions: 37 | await queue.put(event) 38 | 39 | @asynccontextmanager 40 | async def subscribe(self) -> AsyncIterator["Subscription"]: 41 | queue: asyncio.Queue = asyncio.Queue() 42 | self._subscriptions.add(queue) 43 | try: 44 | yield self.Subscription(queue) 45 | finally: 46 | self._subscriptions.remove(queue) 47 | await queue.put(None) 48 | -------------------------------------------------------------------------------- /src/arel/_types.py: -------------------------------------------------------------------------------- 1 | from typing import Awaitable, Callable 2 | 3 | ReloadFunc = Callable[[], Awaitable[None]] 4 | -------------------------------------------------------------------------------- /src/arel/_watch.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import itertools 3 | import logging 4 | from typing import Awaitable, Callable, Dict, List, Optional 5 | 6 | import watchfiles 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | ChangeSet = Dict[str, List[str]] 11 | 12 | CHANGE_EVENT_LABELS = { 13 | watchfiles.Change.added: "added", 14 | watchfiles.Change.modified: "modified", 15 | watchfiles.Change.deleted: "deleted", 16 | } 17 | 18 | 19 | class FileWatcher: 20 | def __init__( 21 | self, path: str, on_change: Callable[[ChangeSet], Awaitable[None]] 22 | ) -> None: 23 | self._path = path 24 | self._on_change = on_change 25 | self._task: Optional[asyncio.Task] = None 26 | 27 | @property 28 | def _should_exit(self) -> asyncio.Event: 29 | # Create lazily as hot reload may not run in the same thread as the one this 30 | # object was created in. 31 | if not hasattr(self, "_should_exit_obj"): 32 | self._should_exit_obj = asyncio.Event() 33 | return self._should_exit_obj 34 | 35 | async def _watch(self) -> None: 36 | async for changes in watchfiles.awatch(self._path): 37 | changeset: ChangeSet = {} 38 | for event, group in itertools.groupby(changes, key=lambda item: item[0]): 39 | label = CHANGE_EVENT_LABELS[event] 40 | changeset[label] = [path for _, path in group] 41 | await self._on_change(changeset) 42 | 43 | async def _main(self) -> None: 44 | tasks = [ 45 | asyncio.create_task(self._watch()), 46 | asyncio.create_task(self._should_exit.wait()), 47 | ] 48 | (done, pending) = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) 49 | [task.cancel() for task in pending] 50 | [task.result() for task in done] 51 | 52 | async def startup(self) -> None: 53 | assert self._task is None 54 | self._task = asyncio.create_task(self._main()) 55 | logger.info(f"Started watching file changes at {self._path!r}") 56 | 57 | async def shutdown(self) -> None: 58 | assert self._task is not None 59 | logger.info("Stopping file watching...") 60 | self._should_exit.set() 61 | await self._task 62 | self._task = None 63 | -------------------------------------------------------------------------------- /src/arel/data/client.js: -------------------------------------------------------------------------------- 1 | 2 | function arel_connect(isReconnect = false) { 3 | const reconnectInterval = parseFloat("$arel::reconnect_interval"); 4 | 5 | const ws = new WebSocket("$arel::url"); 6 | 7 | function log_info(msg) { 8 | console.info(`[arel] ${msg}`); 9 | } 10 | 11 | ws.onopen = function () { 12 | if (isReconnect) { 13 | // The server may have disconnected while it was reloading itself, 14 | // e.g. because the app Python source code has changed. 15 | // The page content may have changed because of this, so we don't 16 | // just want to reconnect, but also get that new page content. 17 | // A simple way to do this is to reload the page. 18 | window.location.reload(); 19 | return; 20 | } 21 | 22 | log_info("Connected."); 23 | }; 24 | 25 | ws.onmessage = function (event) { 26 | if (event.data === "reload") { 27 | window.location.reload(); 28 | } 29 | }; 30 | 31 | // Cleanly close the WebSocket before the page closes (1). 32 | window.addEventListener("beforeunload", function () { 33 | ws.close(1000); 34 | }); 35 | 36 | ws.onclose = function (event) { 37 | if (event.code === 1000) { 38 | // Side-effect of (1). Ignore. 39 | return; 40 | } 41 | 42 | log_info( 43 | `WebSocket is closed. Will attempt reconnecting in ${reconnectInterval} seconds...` 44 | ); 45 | 46 | setTimeout(function () { 47 | const isReconnect = true; 48 | arel_connect(isReconnect); 49 | }, reconnectInterval * 1000); 50 | }; 51 | } 52 | 53 | arel_connect(); 54 | -------------------------------------------------------------------------------- /src/arel/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/florimondmanca/arel/d98aa72de29203093e2c98fc6b9a31bafd2c07a3/src/arel/py.typed -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/florimondmanca/arel/d98aa72de29203093e2c98fc6b9a31bafd2c07a3/tests/__init__.py -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | EXAMPLE_DIR = Path(__file__).parent.parent / "example" 4 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Iterator 3 | 4 | import pytest 5 | import uvicorn 6 | 7 | from .utils import Server 8 | 9 | os.environ["DEBUG"] = "true" 10 | 11 | 12 | @pytest.fixture(scope="session") 13 | def example_server() -> Iterator[Server]: 14 | from example.server import app 15 | 16 | config = uvicorn.Config(app=app, loop="asyncio") 17 | server = Server(config) 18 | with server.serve_in_thread(): 19 | yield server 20 | -------------------------------------------------------------------------------- /tests/test_example.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from contextlib import contextmanager 3 | from pathlib import Path 4 | from typing import Iterator 5 | 6 | import httpx 7 | import pytest 8 | 9 | # See: https://github.com/aaugustin/websockets/issues/940#issuecomment-874012438 # noqa: E501 10 | from websockets.client import connect 11 | 12 | from .common import EXAMPLE_DIR 13 | 14 | 15 | @contextmanager 16 | def make_change(path: Path) -> Iterator[None]: 17 | content = path.read_text() 18 | try: 19 | path.write_text("test") 20 | yield 21 | finally: 22 | path.write_text(content) 23 | 24 | 25 | @pytest.mark.asyncio 26 | @pytest.mark.usefixtures("example_server") 27 | async def test_example() -> None: 28 | async with httpx.AsyncClient() as client: 29 | response = await client.get("http://localhost:8000") 30 | assert "window.location.reload()" in response.text 31 | 32 | async with connect("ws://localhost:8000/hot-reload") as ws: 33 | page1 = EXAMPLE_DIR / "pages" / "page1.md" 34 | index = EXAMPLE_DIR / "server" / "templates" / "index.jinja" 35 | with make_change(page1), make_change(index): 36 | assert await asyncio.wait_for(ws.recv(), timeout=1) == "reload" 37 | assert await asyncio.wait_for(ws.recv(), timeout=1) == "reload" 38 | with pytest.raises(asyncio.TimeoutError): 39 | await asyncio.wait_for(ws.recv(), timeout=0.1) 40 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import threading 3 | import time 4 | from typing import Iterator 5 | 6 | import uvicorn 7 | 8 | 9 | class Server(uvicorn.Server): 10 | def install_signal_handlers(self) -> None: 11 | pass 12 | 13 | @contextlib.contextmanager 14 | def serve_in_thread(self) -> Iterator[None]: 15 | thread = threading.Thread(target=self.run) 16 | thread.start() 17 | try: 18 | while not self.started: 19 | time.sleep(1e-3) 20 | yield 21 | finally: 22 | self.should_exit = True 23 | thread.join() 24 | --------------------------------------------------------------------------------