Skip to content
This repository has been archived by the owner on Aug 15, 2024. It is now read-only.

Commit

Permalink
✨ add dev reload logic (#26)
Browse files Browse the repository at this point in the history
* refactor events

* ✨ add update skipping and improv loading example

* ✨ improv spinner example

* ✨ minor demo fixes

* ✨ add dev reload logic

* improve readme
  • Loading branch information
renardeinside authored Apr 25, 2024
1 parent bb65388 commit 1b06b7e
Show file tree
Hide file tree
Showing 12 changed files with 222 additions and 110 deletions.
8 changes: 3 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,12 @@ On any change in the code, the server will restart automatically, and the client

## Roadmap

- [ ] Add dev reload
- [ ] Add server-side communication
- [x] Add dev reload
- [x] Add server communication channel
- [ ] Add state (global)
- [ ] Add state at component level
- [x] Add state at component level
- [ ] Add more elements
- [x] Add support for icons
- [ ] Add support for onload events
- [ ] Python-to-Hyperscript transpiler
- [ ] Add convenient attributes API
- [ ] Add more examples
- [ ] Add tests
Expand Down
19 changes: 12 additions & 7 deletions examples/counter.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
from contextlib import contextmanager
from random import random

from pydantic import BaseModel, Field
Expand All @@ -14,32 +15,36 @@


class CounterState(BaseModel):
value: Reactive[int] = Field(default_factory=Reactive.factory(0))
value: Reactive[int] = Field(default_factory=Reactive.factory(1))
loading: Reactive[bool] = Field(default_factory=Reactive.factory(False))

async def increment(self):
async with self.loading.ctx(True):
await asyncio.sleep(random() * 3) # Simulate network request
await self.value.set(self.value.rx + 1)
await self.value.set(self.value.rx + 1, skip_notify=True)

async def decrement(self):
async with self.loading.ctx(True):
await asyncio.sleep(random() * 3) # Simulate network request
await self.value.set(self.value.rx - 1)
await self.value.set(self.value.rx - 1, skip_notify=True)


class Counter(Component):
state: CounterState = Field(default_factory=CounterState)
classes: str = "rounded-lg shadow-md m-4 w-80 h-32 flex flex-col items-center justify-center"
classes: str = "m-4 w-80 h-32 flex flex-col items-center justify-center bg-base-300 rounded-xl shadow-xl"

def initialize(self):
self.state.value.subscribe(self.rerender)
self.state.loading.subscribe(self.rerender)

@contextmanager
def spinner(self, *, loading: bool):
with div(classes="loading loading-infinity loading-lg text-primary" if loading else ""):
with div(classes="hidden" if loading else ""):
yield

def render(self):
if self.state.loading.rx:
div(classes="w-full h-full flex skeleton")
else:
with self.spinner(self.state.loading.rx):
with div(classes="space-x-4"):
with button(on=On("click", self.state.increment), classes="btn btn-primary"):
text("Increment")
Expand Down
3 changes: 2 additions & 1 deletion examples/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
from schorle.element import button, div, h2, input_
from schorle.reactive import Reactive
from schorle.text import text
from schorle.theme import Theme

app = Schorle(title="Schorle | IO Examples")
app = Schorle(title="Schorle | IO Examples", theme=Theme.DARK)


class SimpleInput(Component):
Expand Down
22 changes: 14 additions & 8 deletions examples/todo.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ async def add(self):
async def remove(self, index: int):
async with self.loading.ctx(True):
await asyncio.sleep(1) # Simulate network request
await self.todos.set([*self.todos.rx[:index], *self.todos.rx[index + 1 :]])
await self.todos.set([*self.todos.rx[:index], *self.todos.rx[index + 1 :]], skip_notify=True)


class Todos(Component):
Expand All @@ -52,26 +52,32 @@ def render(self):
with div(classes="flex flex-col space-y-2"):
with div(classes="flex space-x-4 mb-4"):
input_(
classes="input input-primary w-48",
classes="input input-primary grow",
placeholder="Enter todo...",
bind=Bind("value", self.state.current),
)
with button(
on=On("click", self.state.add),
classes="btn btn-primary" if self.state.current.rx else "btn btn-primary btn-disabled",
classes="btn btn-primary btn-square btn-outline"
+ (" btn-disabled" if not self.state.current.rx else ""),
):
icon(name="list-plus")

with div(classes="flex flex-col space-y-2"):

if self.state.loading.rx:
div(classes="w-full min-h-16 flex skeleton bg-base-300")
with div(classes="flex grow items-center justify-center"):
div(classes="loading loading-md text-primary")
else:
for index, todo in enumerate(self.state.todos.rx):
with div(classes="flex space-x-4"):
with div(classes="text-lg align-middle font-semibold w-48"):
with div(classes="flex space-x-4 items-center"):
with div(classes="text-lg grow"):
text(todo)
with button(on=On("click", partial(self.state.remove, index)), classes="btn btn-secondary"):
icon(name="square-check")
with button(
on=On("click", partial(self.state.remove, index)),
classes="btn btn-square btn-outline btn-success",
):
icon(name="check")


class HomePage(Component):
Expand Down
95 changes: 31 additions & 64 deletions src/python/schorle/app.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
import asyncio
import mimetypes
from functools import partial
from importlib.resources import files
from pathlib import Path
from typing import Callable
from uuid import uuid4

from fastapi import FastAPI
from loguru import logger
from starlette.endpoints import WebSocketEndpoint
from starlette.responses import FileResponse, HTMLResponse
from starlette.types import Receive, Scope, Send
from starlette.websockets import WebSocket

from schorle.component import Component
from schorle.document import Document
from schorle.events import EventsEndpoint
from schorle.headers import DEV_HEADER, SESSION_ID_HEADER
from schorle.session import Session
from schorle.theme import Theme
from schorle.utils import RunningMode, get_running_mode

ASSETS_PATH = Path(str(files("schorle"))) / Path("assets")

Expand All @@ -26,13 +25,14 @@ def favicon() -> FileResponse:
return FileResponse(favicon_path, media_type="image/svg+xml")


def get_file(file_name: str) -> FileResponse:
file_path = ASSETS_PATH / file_name
mime_type, _ = mimetypes.guess_type(file_path)
return FileResponse(file_path, media_type=mime_type)
def get_file(file_name: str, sub_path: Path | None = None) -> FileResponse | HTMLResponse:
file_path = ASSETS_PATH / file_name if not sub_path else ASSETS_PATH / sub_path / file_name


SESSION_ID_HEADER = "X-Schorle-Session-Id"
if file_path.exists() and file_path.is_file():
mime_type, _ = mimetypes.guess_type(file_path)
return FileResponse(file_path, media_type=mime_type)
else:
return HTMLResponse(status_code=404)


class SessionManager:
Expand All @@ -53,58 +53,6 @@ def remove_session(self, session_id: str):
del self.sessions[session_id]


class EventsEndpoint(WebSocketEndpoint):
encoding = "json"

async def on_connect(self, websocket: WebSocket):
session_id = websocket.cookies.get(SESSION_ID_HEADER)
if session_id is None:
await websocket.close()
return

session = websocket.app.state.session_manager.get_session(session_id)
if session is None:
await websocket.close()
return

if session.connected:
await websocket.close()
return

await websocket.accept()
session.connected = True
session.io = websocket

async def on_receive(self, websocket: WebSocket, data: dict):
session_id = websocket.cookies.get(SESSION_ID_HEADER)
if session_id is None:
return

session: Session = websocket.app.state.session_manager.get_session(session_id)
if session is None:
return

# logger.info(f"Received {data} from session {session_id}")
handler = session.handlers.get(data["handlerId"])
if handler is None:
return

if "value" in data:
_handler = partial(handler, data["value"])
else:
_handler = handler

session.tasks.append(asyncio.ensure_future(_handler()))

async def on_disconnect(self, websocket: WebSocket, close_code: int):
session_id = websocket.cookies.get(SESSION_ID_HEADER)
if session_id is None:
return

logger.info(f"Session {session_id} closed with code {close_code}")
websocket.app.state.session_manager.remove_session(session_id)


class Schorle:
def __init__(
self,
Expand All @@ -114,7 +62,7 @@ def __init__(
title: str = "Schorle",
):
self.backend = FastAPI()
self.backend.get("/_schorle/{file_name:path}", response_class=FileResponse)(get_file)
self.backend.get("/_schorle/{file_name:path}", response_model=None)(get_file)
self.backend.get("/favicon.svg", response_class=FileResponse)(favicon)
self.theme = theme
self.lang = lang
Expand All @@ -124,6 +72,18 @@ def __init__(
self.backend.state.session_manager = self.session_manager
self.backend.add_websocket_route("/_schorle/events", EventsEndpoint)

if get_running_mode() == RunningMode.DEV:
self.backend.add_websocket_route("/_schorle/dev/events", self.dev_handler)

@staticmethod
async def dev_handler(websocket: WebSocket):
await websocket.accept()
while True:
try:
_ = await websocket.receive_json()
except Exception:
break

async def __call__(self, scope: Scope, receive: Receive, send: Send):
"""
This method is called by uvicorn when the server is started.
Expand All @@ -135,11 +95,18 @@ def decorator(func: Callable[..., Component]):
@self.backend.get(path, response_class=HTMLResponse)
async def wrapper():
doc = Document(
page=func(), theme=self.theme, lang=self.lang, extra_assets=self.extra_assets, title=self.title
page=func(),
theme=self.theme,
lang=self.lang,
extra_assets=self.extra_assets,
title=self.title,
with_dev_tools=get_running_mode() == RunningMode.DEV,
)
new_session = self.session_manager.create_session()
response = doc.to_response(new_session)
response.set_cookie(SESSION_ID_HEADER, new_session.uuid)
if get_running_mode() == RunningMode.DEV:
response.set_cookie(DEV_HEADER, "true")
return response

return decorator
21 changes: 10 additions & 11 deletions src/python/schorle/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,40 +14,38 @@


class DevLoader(Component):
element_id: str = "dev-loader"
classes: str = "absolute bottom-4 right-4 hidden"

def render(self):
with div(classes="absolute bottom-4 right-4 hidden", element_id="dev_loader"):
span(classes="loading loading-md loading-bars text-primary")
span(classes="loading loading-md loading-bars text-primary")


class Document(Component):
page: Component
title: str = "Schorle"
theme: Theme = Theme.DARK
with_dev_meta: bool = False
theme: Theme
extra_assets: Callable[..., None] | None = None
lang: str = "en"
with_tailwind: bool = True
with_daisyui: bool = True
with_dev_tools: bool = False
daisyui_version: str = "4.7.2"
daisyui_version: str = "4.10.2"
body_attrs: dict[str, str] | None = Field(default_factory=dict)
tag: HTMLTag = HTMLTag.HTML

def __init__(self, **data):
super().__init__(**data)
self.element_id = None
self.attrs = dict(lang=self.lang, **{"data-theme": self.theme})
self.attrs = {"data-theme": self.theme.value, "lang": self.lang}

def render(self):
with head():
meta(charset="utf-8")
meta(name="viewport", content="width=device-width, initial-scale=1.0")
if self.with_dev_tools:
meta(name="schorle-dev", content="true")

with title():
text(self.title)
if self.with_dev_meta:
meta(name="schorle-dev", content="true")

link(href="/favicon.svg", rel="icon", type="image/svg+xml")
if self.with_tailwind:
Expand All @@ -64,7 +62,8 @@ def render(self):
self.extra_assets()

with body(**self.body_attrs):
self.page()
with div(element_id="schorle-page"):
self.page()

if self.with_dev_tools:
DevLoader()
Expand Down
3 changes: 2 additions & 1 deletion src/python/schorle/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ def __str__(self):


def element_function_factory(tag: HTMLTag):
def func(style: dict[str, str] | None = None, **kwargs):
def func(style: dict[str, str] | None = None, element_id: str | None = None, **kwargs):
return Element(
tag=tag,
element_id=element_id,
style=style,
on=kwargs.pop("on", None),
bind=kwargs.pop("bind", None),
Expand Down
Loading

0 comments on commit 1b06b7e

Please sign in to comment.