diff --git a/examples/app.py b/examples/app.py index 47370a5..2c4daf1 100644 --- a/examples/app.py +++ b/examples/app.py @@ -25,6 +25,7 @@ FileUploadDemoLiveView, KanbanLiveView, IncludesLiveView, + RecipesLiveView, ) app = PyView() @@ -35,6 +36,7 @@ ("pyview", "static"), ("examples.views.maps", "static"), ("examples.views.kanban", "static"), + ("examples.views.recipes", "photos"), ] ), name="static", @@ -139,6 +141,7 @@ def content_wrapper(_context, content: Markup) -> Markup: ("/file_upload", FileUploadDemoLiveView), ("/kanban", KanbanLiveView), ("/includes", IncludesLiveView), + ("/recipes", RecipesLiveView), ] diff --git a/examples/views/__init__.py b/examples/views/__init__.py index 3d9bb86..900b2e5 100644 --- a/examples/views/__init__.py +++ b/examples/views/__init__.py @@ -14,6 +14,7 @@ from .count_pubsub import CountLiveViewPubSub from .count import CountLiveView from .includes import IncludesLiveView +from .recipes import RecipesLiveView __all__ = [ "CountLiveView", @@ -32,4 +33,5 @@ "FileUploadDemoLiveView", "KanbanLiveView", "IncludesLiveView", + "RecipesLiveView", ] diff --git a/examples/views/kanban/kanban.py b/examples/views/kanban/kanban.py index f9e43f9..17ca9cf 100644 --- a/examples/views/kanban/kanban.py +++ b/examples/views/kanban/kanban.py @@ -35,6 +35,7 @@ class KanbanLiveView(BaseEventHandler, LiveView[KanbanContext]): async def mount(self, socket: LiveViewSocket[KanbanContext], session): socket.context = KanbanContext() + socket.live_title = "Kanban" @event("task-moved") async def handle_task_moved( diff --git a/examples/views/maps/map.py b/examples/views/maps/map.py index db7a654..063d87d 100644 --- a/examples/views/maps/map.py +++ b/examples/views/maps/map.py @@ -29,6 +29,7 @@ async def mount(self, socket: LiveViewSocket[MapContext], session): socket.context = MapContext( parks=national_parks, selected_park_name=national_parks[0]["name"] ) + socket.live_title = "Maps" async def handle_event( self, event, payload, socket: ConnectedLiveViewSocket[MapContext] diff --git a/examples/views/recipes/__init__.py b/examples/views/recipes/__init__.py new file mode 100644 index 0000000..428ece7 --- /dev/null +++ b/examples/views/recipes/__init__.py @@ -0,0 +1,10 @@ +from .recipes import RecipesLiveView +from .components.recipe_card import RecipeCardComponent +from .components.ratings import RatingsComponent + + +__all__ = [ + "RecipesLiveView", + "RecipeCardComponent", + "RatingsComponent", +] diff --git a/examples/views/recipes/components/__init__.py b/examples/views/recipes/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/views/recipes/components/ratings.css b/examples/views/recipes/components/ratings.css new file mode 100644 index 0000000..963cd96 --- /dev/null +++ b/examples/views/recipes/components/ratings.css @@ -0,0 +1,25 @@ +.stars { + display: grid; + grid-template-columns: repeat(5, 1fr); + margin: 0 auto; +} + +.star { + background: none; + border: none; + font-size: 1.1rem; + cursor: pointer; + color: #e6e6e6; + transition: color 0.3s; + margin: 0; + padding: 0; +} + +.star:hover, +.star.active { + color: #f5a623; +} + +.star:focus { + outline: none; +} \ No newline at end of file diff --git a/examples/views/recipes/components/ratings.html b/examples/views/recipes/components/ratings.html new file mode 100644 index 0000000..f1bb529 --- /dev/null +++ b/examples/views/recipes/components/ratings.html @@ -0,0 +1,8 @@ +
+
+ {% for i in range(1, 5) %} + + {% endfor %} +
+
\ No newline at end of file diff --git a/examples/views/recipes/components/ratings.py b/examples/views/recipes/components/ratings.py new file mode 100644 index 0000000..a46de8c --- /dev/null +++ b/examples/views/recipes/components/ratings.py @@ -0,0 +1,7 @@ +from pyview.live_component.live_component import LiveComponent +from pyview.live_component.component_registry import components + + +@components.register("Ratings") +class RatingsComponent(LiveComponent): + pass diff --git a/examples/views/recipes/components/recipe_card.css b/examples/views/recipes/components/recipe_card.css new file mode 100644 index 0000000..edd363d --- /dev/null +++ b/examples/views/recipes/components/recipe_card.css @@ -0,0 +1,88 @@ +.recipe_card { + border-radius: 15px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + overflow: hidden; + width: 300px; + background-color: white; +} + +.recipe_card img { + width: 100%; + height: auto; + display: block; + border: none; + height: 200px; + object-fit: cover +} + +.recipe_card-content { + padding-inline: 0.75em; + padding-bottom: 0.75em; + +} + +.recipe_card-header { + display: flex; + justify-content: space-between; + align-items: flex-end; +} + + +.recipe_card-content .recipe_card-title { + font-family: var(--recipe-title-font); + font-weight: 400; + font-style: normal; + + font-size: 1.6em; + + color: #333; +} + +.recipe_card-content .recipe_card-time { + font-size: 0.8em; + color: #666; + font-weight: 100; +} + +.recipe_card-time::before { + content: "⏱️"; + margin-right: 5px; + font-size: 0.9em; + color: #666; +} + +.recipe_card-content p { + margin-top: 10px; + font-size: 1em; + color: #666; +} + +.recipe_card-actions { + padding-top: 0.25em; + display: flex; + justify-content: space-between; +} + +.recipe_card-bookmark { + filter: opacity(0.4); +} + +.recipe_card-bookmark:hover { + filter: opacity(1); +} + +.recipe_card-attribution { + font-size: 0.7em; + color: #666; + display: grid; + justify-items: flex-end; + padding: 0.5em; + + a { + text-decoration: none; + } +} + +.bookmarked { + filter: opacity(1); +} \ No newline at end of file diff --git a/examples/views/recipes/components/recipe_card.html b/examples/views/recipes/components/recipe_card.html new file mode 100644 index 0000000..856405e --- /dev/null +++ b/examples/views/recipes/components/recipe_card.html @@ -0,0 +1,22 @@ +
+ {{recipe.name}} +
+ Photo by {{recipe.attribution.name}} +
+
+
+ {{recipe.name}} + {{recipe.time}} +
+ +
+ {% live_component "Ratings" with id = recipe.id & rating = recipe.rating %} + + 🔖 + +
+
+
+ \ No newline at end of file diff --git a/examples/views/recipes/components/recipe_card.py b/examples/views/recipes/components/recipe_card.py new file mode 100644 index 0000000..e95dc1e --- /dev/null +++ b/examples/views/recipes/components/recipe_card.py @@ -0,0 +1,8 @@ +from pyview.live_component.live_component import LiveComponent +from pyview.live_component.component_registry import components + + +@components.register("RecipeCard") +class RecipeCardComponent(LiveComponent): + def __init__(self): + super().__init__() diff --git a/examples/views/recipes/photos/brooke-lark-V4MBq8kue3U-unsplash.jpg b/examples/views/recipes/photos/brooke-lark-V4MBq8kue3U-unsplash.jpg new file mode 100644 index 0000000..7ae5f3e Binary files /dev/null and b/examples/views/recipes/photos/brooke-lark-V4MBq8kue3U-unsplash.jpg differ diff --git a/examples/views/recipes/photos/deryn-macey-B-DrrO3tSbo-unsplash.jpg b/examples/views/recipes/photos/deryn-macey-B-DrrO3tSbo-unsplash.jpg new file mode 100644 index 0000000..985ebef Binary files /dev/null and b/examples/views/recipes/photos/deryn-macey-B-DrrO3tSbo-unsplash.jpg differ diff --git a/examples/views/recipes/photos/maryam-sicard-Tz1sAv3xnt0-unsplash.jpg b/examples/views/recipes/photos/maryam-sicard-Tz1sAv3xnt0-unsplash.jpg new file mode 100644 index 0000000..f8df373 Binary files /dev/null and b/examples/views/recipes/photos/maryam-sicard-Tz1sAv3xnt0-unsplash.jpg differ diff --git a/examples/views/recipes/photos/nick-bratanek-RBwli5VzJXo-unsplash.jpg b/examples/views/recipes/photos/nick-bratanek-RBwli5VzJXo-unsplash.jpg new file mode 100644 index 0000000..57955cf Binary files /dev/null and b/examples/views/recipes/photos/nick-bratanek-RBwli5VzJXo-unsplash.jpg differ diff --git a/examples/views/recipes/photos/taylor-kiser-EvoIiaIVRzU-unsplash.jpg b/examples/views/recipes/photos/taylor-kiser-EvoIiaIVRzU-unsplash.jpg new file mode 100644 index 0000000..9cd9805 Binary files /dev/null and b/examples/views/recipes/photos/taylor-kiser-EvoIiaIVRzU-unsplash.jpg differ diff --git a/examples/views/recipes/photos/taylor-kiser-POFG828-GQc-unsplash.jpg b/examples/views/recipes/photos/taylor-kiser-POFG828-GQc-unsplash.jpg new file mode 100644 index 0000000..77da051 Binary files /dev/null and b/examples/views/recipes/photos/taylor-kiser-POFG828-GQc-unsplash.jpg differ diff --git a/examples/views/recipes/recipe_list.py b/examples/views/recipes/recipe_list.py new file mode 100644 index 0000000..6aaad40 --- /dev/null +++ b/examples/views/recipes/recipe_list.py @@ -0,0 +1,81 @@ +from dataclasses import dataclass, field +import uuid +from typing import Optional + + +@dataclass +class Attribution: + name: str + url: str + + +@dataclass +class Recipe: + name: str + img: str + time: str + + attribution: Attribution + + id: str = field(default_factory=lambda: uuid.uuid4().hex) + rating: Optional[int] = None + bookmarked: bool = False + + +def all_recipes() -> list[Recipe]: + return [ + Recipe( + name="Donuts", + img="/static/brooke-lark-V4MBq8kue3U-unsplash.jpg", + time="90 mins", + attribution=Attribution( + "Brooke Lark", + "https://unsplash.com/@brookelark?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash", + ), + ), + Recipe( + name="Chickpea Salad", + img="/static/deryn-macey-B-DrrO3tSbo-unsplash.jpg", + time="30 mins", + attribution=Attribution( + "Deryn Macey", + "https://unsplash.com/@derynmacey?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash", + ), + ), + Recipe( + name="Chia Pudding", + img="/static/maryam-sicard-Tz1sAv3xnt0-unsplash.jpg", + time="20 mins", + attribution=Attribution( + "Maryam Sicard", + "https://unsplash.com/@maryamsicard?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash", + ), + ), + Recipe( + name="Cinnamon Rolls", + img="/static/nick-bratanek-RBwli5VzJXo-unsplash.jpg", + time="45 mins", + attribution=Attribution( + "Nick Bratanek", + "https://unsplash.com/@nickbratanek?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash", + ), + ), + Recipe( + name="Watermelon Salad", + img="/static/taylor-kiser-EvoIiaIVRzU-unsplash.jpg", + time="15 mins", + attribution=Attribution( + "Taylor Kiser", + "https://unsplash.com/@foodfaithfit?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash", + ), + ), + Recipe( + name="Curry", + img="static/taylor-kiser-POFG828-GQc-unsplash.jpg", + time="30 mins", + attribution=Attribution( + "Taylor Kiser", + "https://unsplash.com/@foodfaithfit?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash", + ), + ), + ] diff --git a/examples/views/recipes/recipes.css b/examples/views/recipes/recipes.css new file mode 100644 index 0000000..f1a839a --- /dev/null +++ b/examples/views/recipes/recipes.css @@ -0,0 +1,32 @@ +:root { + --recipe-title-font: "Shadows Into Light", cursive; +} + +main { + margin: 0; + padding: 20px; + height: 100vh; +} + +.recipes_title { + margin-top: 0; +} + +html { + background-color: #F0F0F0; +} + +body { + max-width: 980px; +} + +.recipes { + padding-block: 2em; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 2rem; +} + +.attribution { + font-size: 0.7em; +} \ No newline at end of file diff --git a/examples/views/recipes/recipes.html b/examples/views/recipes/recipes.html new file mode 100644 index 0000000..d3cd00d --- /dev/null +++ b/examples/views/recipes/recipes.html @@ -0,0 +1,12 @@ + + + + +
+

Latest Recipes

+
+ {% for recipe in recipes %} + {% live_component "RecipeCard" with id = recipe.id & recipe = recipe %} + {% endfor %} +
+
\ No newline at end of file diff --git a/examples/views/recipes/recipes.py b/examples/views/recipes/recipes.py new file mode 100644 index 0000000..31c101a --- /dev/null +++ b/examples/views/recipes/recipes.py @@ -0,0 +1,32 @@ +from pyview import LiveView, LiveViewSocket +from dataclasses import dataclass +from .recipe_list import Recipe, all_recipes + + +@dataclass +class RecipesContext: + recipes: list[Recipe] + + +class RecipesLiveView(LiveView[RecipesContext]): + """ + Recipes + + This example shows how to use components to encapsulate functionality. + """ + + async def mount(self, socket: LiveViewSocket, session): + socket.context = RecipesContext(recipes=all_recipes()) + socket.live_title = "Recipes" + + async def handle_event(self, event, payload, socket): + if event == "bookmark" and "id" in payload: + id = payload["id"] + recipe = next((r for r in socket.context.recipes if r.id == id), None) + if recipe is not None: + recipe.bookmarked = not recipe.bookmarked + if event == "rate" and "id" in payload and "rating" in payload: + id = payload["id"] + recipe = next((r for r in socket.context.recipes if r.id == id), None) + if recipe is not None: + recipe.rating = int(payload["rating"]) diff --git a/pyview/live_component/__init__.py b/pyview/live_component/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyview/live_component/component_registry.py b/pyview/live_component/component_registry.py new file mode 100644 index 0000000..0710b79 --- /dev/null +++ b/pyview/live_component/component_registry.py @@ -0,0 +1,20 @@ +from typing import Optional + + +class ComponentRegistry: + def __init__(self): + self._registry = {} + + def register(self, name): + def decorator(cls): + self._registry[name] = cls + return cls + + return decorator + + def get_component(self, name) -> Optional[type]: + component_class = self._registry.get(name) + return component_class + + +components = ComponentRegistry() diff --git a/pyview/live_component/live_component.py b/pyview/live_component/live_component.py new file mode 100644 index 0000000..27eb292 --- /dev/null +++ b/pyview/live_component/live_component.py @@ -0,0 +1,30 @@ +from pyview.template.live_template import LiveTemplate, template_file, LiveRender +from pyview.template.utils import find_associated_file +from typing import Optional, Generic, TypeVar, Any + +T = TypeVar("T") + + +class LiveComponent(Generic[T]): + def __init__(self) -> None: + pass + + async def mount(self, socket: T): + pass + + async def update(self, socket: T, template_vars: dict[str, Any]): + pass + + async def render(self, assigns: T, meta): + html_render = _find_render(self) + + if html_render: + return LiveRender(html_render, assigns, meta) + + raise NotImplementedError() + + +def _find_render(m: object) -> Optional[LiveTemplate]: + html = find_associated_file(m, ".html") + if html is not None: + return template_file(html) diff --git a/pyview/live_component/live_component_factory.py b/pyview/live_component/live_component_factory.py new file mode 100644 index 0000000..527de2e --- /dev/null +++ b/pyview/live_component/live_component_factory.py @@ -0,0 +1,47 @@ +import importlib +import inspect +from typing import Any +from pyview.live_component.live_component import LiveComponent +from pyview.live_component.live_components_context import ( + LiveComponentsContext, +) +from pyview.live_component.component_registry import components +from pyview.vendor.ibis.components.component_factory import ( + ComponentReference, +) + + +class LiveComponentFactory: + def __init__(self, context: LiveComponentsContext): + self.context = context + + def register_component( + self, id: str, component_name: str, template_vars: dict[str, Any] + ) -> ComponentReference: + component_class = get_live_component(component_name) + return self.context.register_component(id, component_class(), template_vars) + + +def get_live_component(class_name: str) -> type[LiveComponent]: + + registered_component = components.get_component(class_name) + if registered_component: + # TODO: check type + return registered_component + + # Split the fully qualified name into module and class + module_name, class_name = class_name.rsplit(".", 1) + + # Dynamically import the module + module = importlib.import_module(module_name) + + # Get the class from the module + component_class = getattr(module, class_name) + + # Check if it's a subclass of LiveComponent + if not inspect.isclass(component_class) or not issubclass( + component_class, LiveComponent + ): + raise TypeError(f"{component_class} is not a subclass of LiveComponent") + + return component_class diff --git a/pyview/live_component/live_component_socket.py b/pyview/live_component/live_component_socket.py new file mode 100644 index 0000000..f05b658 --- /dev/null +++ b/pyview/live_component/live_component_socket.py @@ -0,0 +1,20 @@ +from typing import ( + Any, + TypeVar, + Generic, +) +from dataclasses import dataclass +from pyview.meta import PyViewMeta + +T = TypeVar("T") + + +@dataclass +class LiveComponentMeta(PyViewMeta): + myself: Any + + +class LiveComponentSocket(Generic[T]): + context: T + connected: bool + meta: LiveComponentMeta diff --git a/pyview/live_component/live_components_context.py b/pyview/live_component/live_components_context.py new file mode 100644 index 0000000..831d5b2 --- /dev/null +++ b/pyview/live_component/live_components_context.py @@ -0,0 +1,74 @@ +from dataclasses import dataclass +from pyview.live_component.live_component import LiveComponent +from pyview.vendor.ibis.components.component_factory import ComponentReference +from pyview.live_component.live_component_socket import ( + LiveComponentSocket, + LiveComponentMeta, +) +from typing import Any + + +@dataclass +class LiveComponentContext: + ref: ComponentReference + component: LiveComponent + context: dict[str, Any] + + +class LiveComponentsContext: + cid_index = 1 + components: list[LiveComponentContext] + component_sockets: dict[str, LiveComponentSocket] + + def __init__(self): + self.components = [] + self.component_sockets = {} + + def register_component( + self, user_id: str, component: LiveComponent, context: dict[str, Any] + ) -> ComponentReference: + + id = f"{user_id}-{component.__class__.__name__}" + ref = ComponentReference(id, self.cid_index) + + for c in self.components: + if c.ref.id == id: + c.context = context + return c.ref + + self.components.append(LiveComponentContext(ref, component, context)) + self.cid_index += 1 + return ref + + async def _update_component(self, component: LiveComponentContext): + if component.ref.id not in self.component_sockets: + socket = LiveComponentSocket() + socket.meta = LiveComponentMeta(component.ref) + await component.component.mount(socket) + self.component_sockets[component.ref.id] = socket + + socket = self.component_sockets[component.ref.id] + await component.component.update(socket, component.context) + + async def update_components(self): + for component in self.components: + await self._update_component(component) + + async def _render_component( + self, component: LiveComponentContext, rendered: dict[int, Any] + ): + if component.ref.id not in self.component_sockets: + await self._update_component(component) + + socket = self.component_sockets[component.ref.id] + rendered[component.ref.cid] = ( + await component.component.render(component.context, socket.meta) + ).tree() + + async def render_components(self): + rendered = {} + + for component in self.components: + await self._render_component(component, rendered) + + return rendered diff --git a/pyview/live_socket.py b/pyview/live_socket.py index d179cfc..d7eed40 100644 --- a/pyview/live_socket.py +++ b/pyview/live_socket.py @@ -15,6 +15,7 @@ from urllib.parse import urlencode from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.jobstores.base import JobLookupError +from pyview.live_component.live_components_context import LiveComponentsContext from pyview.vendor.flet.pubsub import PubSubHub, PubSub from pyview.events import InfoEvent from pyview.uploads import UploadConstraints, UploadConfig, UploadManager @@ -40,20 +41,28 @@ def is_connected(socket: LiveViewSocket[T]) -> TypeGuard["ConnectedLiveViewSocke return socket.connected -class UnconnectedSocket(Generic[T]): +class BaseSocket(Generic[T]): context: T live_title: Optional[str] = None + components: LiveComponentsContext + + def __init__(self): + self.components = LiveComponentsContext() + + +class UnconnectedSocket(BaseSocket[T]): connected: bool = False + def __init__(self): + super().__init__() + def allow_upload( self, upload_name: str, constraints: UploadConstraints ) -> UploadConfig: return UploadConfig(name=upload_name, constraints=constraints) -class ConnectedLiveViewSocket(Generic[T]): - context: T - live_title: Optional[str] = None +class ConnectedLiveViewSocket(BaseSocket[T]): pending_events: list[tuple[str, Any]] upload_manager: UploadManager prev_rendered: Optional[dict[str, Any]] = None @@ -66,6 +75,7 @@ def __init__( scheduler: AsyncIOScheduler, instrumentation: "InstrumentationProvider", ): + super().__init__() self.websocket = websocket self.topic = topic self.liveview = liveview diff --git a/pyview/pyview.py b/pyview/pyview.py index 702bdf7..9802f0b 100644 --- a/pyview/pyview.py +++ b/pyview/pyview.py @@ -13,6 +13,8 @@ from pyview.auth import AuthProviderFactory from pyview.meta import PyViewMeta from pyview.instrumentation import InstrumentationProvider, NoOpInstrumentation +from pyview.live_component.live_component_factory import LiveComponentFactory +from pyview.vendor.ibis.components.component_factory import set_component_factory from .ws_handler import LiveSocketHandler from .live_view import LiveView from .live_routes import LiveViewLookup @@ -28,7 +30,9 @@ class PyView(Starlette): rootTemplate: RootTemplate instrumentation: InstrumentationProvider - def __init__(self, *args, instrumentation: Optional[InstrumentationProvider] = None, **kwargs): + def __init__( + self, *args, instrumentation: Optional[InstrumentationProvider] = None, **kwargs + ): super().__init__(*args, **kwargs) self.rootTemplate = defaultRootTemplate() self.instrumentation = instrumentation or NoOpInstrumentation() @@ -71,15 +75,23 @@ async def liveview_container( # Pass merged parameters to handle_params await lv.handle_params(urlparse(url._url), merged_params, s) + set_component_factory(LiveComponentFactory(s.components)) + r = await lv.render(s.context, PyViewMeta()) + content = r.text() + + await s.components.update_components() + await s.components.render_components() liveview_css = find_associated_css(lv) + component_css = find_associated_css([c.component for c in s.components.components]) + liveview_css.extend(component_css) id = str(uuid.uuid4()) context: RootTemplateContext = { "id": id, - "content": r.text(), + "content": content, "title": s.live_title, "csrf_token": generate_csrf_token("lv:phx-" + id), "session": serialize_session(session), diff --git a/pyview/template/live_template.py b/pyview/template/live_template.py index be56803..a857cc9 100644 --- a/pyview/template/live_template.py +++ b/pyview/template/live_template.py @@ -29,13 +29,13 @@ def tree(self, assigns: Assigns, meta: PyViewMeta) -> dict[str, Any]: if not isinstance(assigns, dict): assigns = serialize(assigns) additional_context = apply_context_processors(meta) - return self.t.tree(additional_context | assigns) + return self.t.tree(additional_context | assigns | {"meta": meta}) def render(self, assigns: Assigns, meta: PyViewMeta) -> str: if not isinstance(assigns, dict): assigns = asdict(assigns) additional_context = apply_context_processors(meta) - return self.t.render(additional_context | assigns) + return self.t.render(additional_context | assigns | {"meta": meta}) def text(self, assigns: Assigns, meta: PyViewMeta) -> str: return self.render(assigns, meta) diff --git a/pyview/template/utils.py b/pyview/template/utils.py index 9ce6780..b5095b6 100644 --- a/pyview/template/utils.py +++ b/pyview/template/utils.py @@ -15,10 +15,15 @@ def find_associated_file(o: object, extension: str) -> Optional[str]: return associated_file -def find_associated_css(o: object) -> list[Markup]: - css_file = find_associated_file(o, ".css") - if css_file: - with open(css_file, "r") as css: - return [Markup(f"")] +def find_associated_css(o: object | list[object]) -> list[Markup]: - return [] + objects = o if isinstance(o, list) else [o] + files = set(f for f in [find_associated_file(o, ".css") for o in objects] if f) + + ret = [] + for file in files: + with open(file, "r") as css: + source_comment = f"" + ret.append(Markup(f"\n{source_comment}\n")) + + return ret diff --git a/pyview/vendor/ibis/__init__.py b/pyview/vendor/ibis/__init__.py index d11da49..a4fce5f 100644 --- a/pyview/vendor/ibis/__init__.py +++ b/pyview/vendor/ibis/__init__.py @@ -3,6 +3,7 @@ from . import loaders from . import errors from . import compiler +from . import nodes_livecomponent from .template import Template diff --git a/pyview/vendor/ibis/components/__init__.py b/pyview/vendor/ibis/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyview/vendor/ibis/components/component_factory.py b/pyview/vendor/ibis/components/component_factory.py new file mode 100644 index 0000000..df7d552 --- /dev/null +++ b/pyview/vendor/ibis/components/component_factory.py @@ -0,0 +1,29 @@ +import contextvars +from typing import Protocol, Any +from dataclasses import dataclass + +live_component_factory_var = contextvars.ContextVar("live_component_factory") + + +@dataclass +class ComponentReference: + id: str + cid: int + + @property + def target(self): + return self.cid + + +class ComponentFactory(Protocol): + def register_component( + self, id: str, component_name: str, template_vars: dict[str, Any] + ) -> ComponentReference: ... + + +def get_component_factory() -> ComponentFactory: + return live_component_factory_var.get() + + +def set_component_factory(factory: ComponentFactory): + live_component_factory_var.set(factory) diff --git a/pyview/vendor/ibis/nodes_livecomponent.py b/pyview/vendor/ibis/nodes_livecomponent.py new file mode 100644 index 0000000..86185e2 --- /dev/null +++ b/pyview/vendor/ibis/nodes_livecomponent.py @@ -0,0 +1,101 @@ +from . import utils +from . import errors +from .tree import PartsTree +from .nodes import Node, NodeVisitor, register, Expression + +from .components.component_factory import get_component_factory + + +# live_component nodes + + +# +# {% live_component %} +# +# {% live_component with = %} +# +# {% live_component with = & = %} +# +# Requires a Live Component name which can be supplied as either a string literal or a variable +# resolving to a string. This name will be passed to the registered component factory. +@register("live_component") +class LiveComponentNode(Node): + def process_token(self, token): + self.variables = {} + parts = utils.splitre(token.text[14:], ["with"]) + + if len(parts) == 1: + self.template_arg = parts[0] + self.template_expr = Expression(parts[0], token) + elif len(parts) == 2: + self.template_arg = parts[0] + self.template_expr = Expression(parts[0], token) + chunks = utils.splitc(parts[1], "&", strip=True, discard_empty=True) + for chunk in chunks: + try: + name, expr = chunk.split("=", 1) + self.variables[name.strip()] = Expression(expr.strip(), token) + except: + raise errors.TemplateSyntaxError( + "Malformed 'include' tag.", token + ) from None + else: + raise errors.TemplateSyntaxError("Malformed 'include' tag.", token) + + def visit_node(self, context, visitor): + template_name = self.template_expr.eval(context) + if isinstance(template_name, str): + ######### TODO: We need to load the component... + # Maybe we can require it to be fully qualified for now and figure out a way to alias it later... + # maybe something similar to ibis loader for components... + # maybe like @component("ShortName")... + # not sure if that would work for sharing components between projects...?... maybe it would... + + factory = get_component_factory() + if factory: + template_vars = {} + for name, expr in self.variables.items(): + context[name] = expr.eval(context) + template_vars[name] = context[name] + + id = context.get("id", None) + if not id: + msg = f"No 'id' variable found in the context. " + msg += f"The 'id' variable is required by the 'include' tag in " + msg += f"template '{self.token.template_id}', line {self.token.line_number}." + raise errors.TemplateRenderingError(msg, self.token) + component = factory.register_component(id, template_name, template_vars) + + context.push() + + visitor(component) + context.pop() + else: + msg = f"No template loader has been specified. " + msg += f"A template loader is required by the 'include' tag in " + msg += f"template '{self.token.template_id}', line {self.token.line_number}." + raise errors.TemplateLoadError(msg) + else: + msg = f"Invalid argument for the 'include' tag. " + msg += f"The variable '{self.template_arg}' should evaluate to a string. " + msg += f"This variable has the value: {repr(template_name)}." + raise errors.TemplateRenderingError(msg, self.token) + + def wrender(self, context): + output = [] + + def visitor(ref): + pass + + self.visit_node(context, visitor) + + return "".join(output) + + def tree_parts(self, context) -> PartsTree: + output = [] + + def visitor(ref): + output.append(ref.cid) + + self.visit_node(context, visitor) + return output[0] if output else PartsTree() diff --git a/pyview/vendor/ibis/tree.py b/pyview/vendor/ibis/tree.py index 1f92b8b..9f85905 100644 --- a/pyview/vendor/ibis/tree.py +++ b/pyview/vendor/ibis/tree.py @@ -1,7 +1,7 @@ from dataclasses import dataclass, field from typing import Any, Union -Part = Union[str, "PartsTree", "PartsComprehension"] +Part = Union[str, int, "PartsTree", "PartsComprehension"] @dataclass @@ -20,7 +20,7 @@ def render_parts(self) -> Union[dict[str, Any], str]: return "" def render(p: Part) -> Any: - if isinstance(p, str): + if isinstance(p, str) or isinstance(p, int): return p return p.render_parts() @@ -45,7 +45,7 @@ def add_dynamic(self, d: Union[Part, list[Part]]): if len(self.statics) < len(self.dynamics) + 1: self.statics.append("") - if isinstance(d, str): + if isinstance(d, str) or isinstance(d, int): self.dynamics.append(d) elif isinstance(d, list): self.dynamics.append(PartsComprehension(d)) @@ -79,7 +79,7 @@ def render_parts(self) -> dict[str, Any]: if len(self.dynamics) > 0: for i, dynamic in enumerate(self.dynamics): - if isinstance(dynamic, str): + if isinstance(dynamic, str) or isinstance(dynamic, int): resp[f"{i}"] = dynamic else: resp[f"{i}"] = dynamic.render_parts() diff --git a/pyview/ws_handler.py b/pyview/ws_handler.py index 9f560f8..148397d 100644 --- a/pyview/ws_handler.py +++ b/pyview/ws_handler.py @@ -12,6 +12,10 @@ from pyview.instrumentation import InstrumentationProvider from apscheduler.schedulers.asyncio import AsyncIOScheduler +from pyview.live_component.live_component_factory import LiveComponentFactory +from pyview.vendor.ibis.components.component_factory import set_component_factory + + logger = logging.getLogger(__name__) @@ -21,39 +25,35 @@ class AuthException(Exception): class LiveSocketMetrics: """Container for LiveSocket instrumentation metrics.""" - + def __init__(self, instrumentation: InstrumentationProvider): self.active_connections = instrumentation.create_updown_counter( "pyview.websocket.active_connections", - "Number of active WebSocket connections" + "Number of active WebSocket connections", ) self.mounts = instrumentation.create_counter( - "pyview.liveview.mounts", - "Total number of LiveView mounts" + "pyview.liveview.mounts", "Total number of LiveView mounts" ) self.events_processed = instrumentation.create_counter( - "pyview.events.processed", - "Total number of events processed" + "pyview.events.processed", "Total number of events processed" ) self.event_duration = instrumentation.create_histogram( - "pyview.events.duration", - "Event processing duration", - unit="s" + "pyview.events.duration", "Event processing duration", unit="s" ) self.message_size = instrumentation.create_histogram( "pyview.websocket.message_size", "WebSocket message size in bytes", - unit="bytes" + unit="bytes", ) self.render_duration = instrumentation.create_histogram( - "pyview.render.duration", - "Template render duration", - unit="s" + "pyview.render.duration", "Template render duration", unit="s" ) class LiveSocketHandler: - def __init__(self, routes: LiveViewLookup, instrumentation: InstrumentationProvider): + def __init__( + self, routes: LiveViewLookup, instrumentation: InstrumentationProvider + ): self.routes = routes self.instrumentation = instrumentation self.metrics = LiveSocketMetrics(instrumentation) @@ -68,7 +68,7 @@ async def check_auth(self, websocket: WebSocket, lv): async def handle(self, websocket: WebSocket): await self.manager.connect(websocket) - + # Track active connections self.metrics.active_connections.add(1) self.sessions += 1 @@ -87,7 +87,9 @@ async def handle(self, websocket: WebSocket): url = urlparse(payload["url"]) lv, path_params = self.routes.get(url.path) await self.check_auth(websocket, lv) - socket = ConnectedLiveViewSocket(websocket, topic, lv, self.scheduler, self.instrumentation) + socket = ConnectedLiveViewSocket( + websocket, topic, lv, self.scheduler, self.instrumentation + ) session = {} if "session" in payload: @@ -96,7 +98,7 @@ async def handle(self, websocket: WebSocket): # Track mount view_name = lv.__class__.__name__ self.metrics.mounts.add(1, {"view": view_name}) - + await lv.mount(socket, session) # Parse query parameters and merge with path parameters @@ -106,15 +108,23 @@ async def handle(self, websocket: WebSocket): # Pass merged parameters to handle_params await lv.handle_params(url, merged_params, socket) + set_component_factory(LiveComponentFactory(socket.components)) + rendered = await _render(socket) socket.prev_rendered = rendered + await socket.components.update_components() + component_render = await socket.components.render_components() + resp = [ joinRef, mesageRef, topic, "phx_reply", - {"response": {"rendered": rendered}, "status": "ok"}, + { + "response": {"rendered": rendered | {"c": component_render}}, + "status": "ok", + }, ] await self.manager.send_personal_message(json.dumps(resp), websocket) @@ -163,15 +173,20 @@ async def handle_connected(self, myJoinId, socket: ConnectedLiveViewSocket): # Track event metrics event_name = payload["event"] view_name = socket.liveview.__class__.__name__ - self.metrics.events_processed.add(1, {"event": event_name, "view": view_name}) - + self.metrics.events_processed.add( + 1, {"event": event_name, "view": view_name} + ) + # Time event processing - with self.instrumentation.time_histogram("pyview.events.duration", - {"event": event_name, "view": view_name}): + with self.instrumentation.time_histogram( + "pyview.events.duration", {"event": event_name, "view": view_name} + ): await socket.liveview.handle_event(event_name, value, socket) - + # Time rendering - with self.instrumentation.time_histogram("pyview.render.duration", {"view": view_name}): + with self.instrumentation.time_histogram( + "pyview.render.duration", {"view": view_name} + ): rendered = await _render(socket) hook_events = ( @@ -180,6 +195,9 @@ async def handle_connected(self, myJoinId, socket: ConnectedLiveViewSocket): diff = socket.diff(rendered) + await socket.components.update_components() + component_render = await socket.components.render_components() + socket.pending_events = [] resp = [ @@ -187,7 +205,12 @@ async def handle_connected(self, myJoinId, socket: ConnectedLiveViewSocket): mesageRef, topic, "phx_reply", - {"response": {"diff": diff | hook_events}, "status": "ok"}, + { + "response": { + "diff": diff | hook_events | {"c": component_render} + }, + "status": "ok", + }, ] resp_json = json.dumps(resp) self.metrics.message_size.record(len(resp_json)) @@ -273,14 +296,22 @@ async def handle_connected(self, myJoinId, socket: ConnectedLiveViewSocket): # This is a navigation join (topic starts with "lv:") # Navigation payload has 'redirect' field instead of 'url' url_str_raw = payload.get("redirect") or payload.get("url") - url_str: str = url_str_raw.decode("utf-8") if isinstance(url_str_raw, bytes) else str(url_str_raw) + url_str: str = ( + url_str_raw.decode("utf-8") + if isinstance(url_str_raw, bytes) + else str(url_str_raw) + ) url = urlparse(url_str) lv, path_params = self.routes.get(url.path) await self.check_auth(socket.websocket, lv) # Create new socket for new LiveView socket = ConnectedLiveViewSocket( - socket.websocket, topic, lv, self.scheduler, self.instrumentation + socket.websocket, + topic, + lv, + self.scheduler, + self.instrumentation, ) session = {}