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 @@
+
+

+
+
+
+
+
+ {% 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 = {}