Source code for pantra.components.context

from __future__ import annotations

import ast
import typing
from types import CodeType, LambdaType
import threading
from copy import deepcopy
from enum import Enum, auto
from dataclasses import dataclass, field
import inspect
import re

from ..common import DynamicStyles, DynamicClasses, WebUnits, DynamicDict
from ..components.reactdict import ReactDict
from ..settings import config
from .template import collect_template, HTMLTemplate, get_template_path
from .static import get_static_url
from .render.render_node import RenderNode
from ..compiler import CodeMetrics

if typing.TYPE_CHECKING:
    from typing import *
    from ..session import Session
    from .shot import ContextShot
    from .render.renderer_base import ForLoopType, ValueOrCode, RendererBase

__all__ = ['NSType', 'HTMLTemplate', 'Context', 'HTMLElement', 'NSElement', 'LoopNode', 'ConditionNode', 'TextNode',
           'EventNode', 'SetNode', 'ReactNode', 'ScriptNode', 'ActionType', 'AnyNode']

ActionType = typing.Callable[['HTMLElement'], None] | None
CallableTemplate = typing.Union[HTMLTemplate, typing.Callable[[...], None], None]

AnyNode = typing.Union['HTMLElement', 'NSElement', 'Context', 'ConditionNode', 'LoopNode', 'TextNode', 'EventNode',
'SetNode', 'ReactNode', 'ScriptNode']

TAG_PATTERN = re.compile(r"^(\w+)(\s+[a-z_:]+(=(['\"`].*?['\"`]|[^ ]+))?)+$", re.I)
TAG_GROUPS = re.compile(r"^\s+([a-z_:]+)(=(['\"`].*?['\"`]|[^ ]+))?", re.I)

class NSType(Enum):
    """all supported :ref:`namespace <namespaces>` types"""
    HTML = auto()       # http://www.w3.org/1999/xhtml
    SVG = auto()        # http://www.w3.org/2000/svg
    EVENTS = auto()     # http://www.w3.org/2001/xml-events
    XLINK = auto()      # http://www.w3.org/1999/xlink
    MATH = auto()       # http://www.w3.org/1998/Math/MathML


@dataclass(slots=True)
class MeasuresData:
    """represents position and size of the element"""
    left: int | str | WebUnits
    top: int | str | WebUnits
    width: int | str | WebUnits
    height: int | str | WebUnits

    def __post_init__(self):
        for k in self.__slots__:
            if not isinstance(v:=getattr(self, k), WebUnits):
                setattr(self, k, WebUnits(v))


@dataclass(slots=True)
class Slot:
    ctx: Context
    template: str | HTMLTemplate | Callable[[...], None]
    for_reuse: bool
    named_slots: dict[str, Self] = field(default_factory=dict, init=False)

    def get_top(self):
        if self.for_reuse:
            return self.ctx.slot and self.ctx.slot.get_top()
        return self

    def __getitem__(self, name: Union[str, int]) -> Union[Slot, None]:
        if type(name) == str:
            if (slot := self.named_slots.get(name)) is not None:
                if slot.for_reuse:
                    return slot.ctx.slot and slot.ctx.slot[name]
                else:
                    return slot
            return None
        else:
            raise NotImplementedError('integer indexing')  # return super().__getitem__(name)

    def __setitem__(self, name: str, slot: Self):
        self.named_slots[name] = slot

    def __contains__(self, name: str):
        if (slot:=self.named_slots.get(name)) is not None:
            if slot.for_reuse:
                return bool(slot.ctx.slot and name in slot.ctx.slot)
            else:
                return True
        return False

[docs] class Context(RenderNode): """Context of the component. Attributes: name (str): name of this component attributes (DynamicDict): initial attributes defined with component locals (ReactDict): context local variables, evaluated by Python script refs (dict[str, 'Context' | HTMLElement]): :ref:`references <ref>` to named nodes ref_name (str): reference name if specified template (HTMLTemplate | CodeType): template to build component context ns_type (NSType): :ref:`namespace <namespaces>` ID code_metrics (CodeMetrics): code metrics of the Python script data_nodes (dict[str, RenderNode]): collection of :ref:`data nodes <data nodes>` renderer (RenderBase): associated :doc:`renderer <renderer>` allowed_call (set[str]): set of allowed function names """ __slots__ = ['locals', '_executed', 'refs', 'slot', 'template', '_restyle', 'ns_type', 'renderer', 'ref_name', 'allowed_call', 'code_metrics', 'attributes', 'data_nodes', 'name'] def __init__(self, template_name: str, parent: Optional[RenderNode] = None, shot: Optional[ContextShot] = None, session: Optional[Session] = None, locals: Optional[dict] = None): self.name: str = template_name self.attributes: DynamicDict = DynamicDict() self.locals: ReactDict = ReactDict() if locals: self.locals.update(locals) self._executed: bool = False self.refs: dict[str, Union['Context', HTMLElement]] = {} self.ref_name: str | None = None self.slot: Optional[Slot] = None self.allowed_call: set[str] = set() super().__init__(parent=parent, shot=shot, session=session) self.template: Union[HTMLTemplate, CodeType, None] = collect_template(template_name, self.session) if self.template is None: self.session.error(f'Component `{template_name}` load error') raise NameError(f'Component `{template_name}` load error') self._restyle: bool = False self.ns_type: Optional[NSType] = parent and parent.context.ns_type self.code_metrics: Optional[CodeMetrics] = CodeMetrics() self.data_nodes: dict[str, RenderNode] | None = None self.renderer: RendererBase = config.DEFAULT_RENDERER(self) def _frozen_clone(self, new_parent: RenderNode) -> Union[HTMLElement, TextNode]: return HTMLElement(self.name, new_parent)
[docs] def rebuild(self): """clear component and build from template""" self.empty() self.renderer.build()
[docs] def div(self, classes: str = '', src: Optional[HTMLElement] = None, from_x: int = 0, from_y: int = 0, attributes: Optional[dict, None] = None): """add positioned <div> container Usually used to make temporary box to visualize drag-and-drop. Arguments: classes (str): classes list separated by space src (HTMLElement): source HTML element for box metrics from_x (int): x coordinate to move source box from_y (int): y coordinate to move source box attributes (dict[str, Any]): any other custom attributes of container """ node = HTMLElement('div', self, attributes) node.classes = DynamicClasses(classes) if src: node.set_measures(src.measures, from_x=from_x, from_y=from_y) return node
[docs] def get_caller(self, action: str) -> Callable | Awaitable | None: """get callable local action by name""" method = self.locals.get(action) if method is not None and not callable(method) and not inspect.iscoroutinefunction(method): raise RuntimeError(f'method `{action}` is not callable or awaitable') return method
[docs] def call(self, action: str, *args, **kwargs): """call local action by name""" if (method := self.get_caller(action)) is not None: return method(*args, **kwargs) return None
def __getitem__(self, item: str) -> Any: """shortcut to get local context variables""" return self.locals[item] def __setitem__(self, key, value): """shortcut to set local context variables""" self.locals[key] = value
[docs] def set_quietly(self, key, value): """shortcut to set local context variables quietly""" self.locals.set_quietly(key, value)
def __str__(self): return f'${self.name}' + (f':{self.ref_name}' if self.ref_name else '')
[docs] def allow_call(self, method: str): """add method name to allowed calls set for JS :doc:`callback <js_callback>`""" self.allowed_call.add(method)
[docs] def is_call_allowed(self, method: str) -> bool: """check if method is allowed for JS :doc:`callback <js_callback>`""" return method in self.allowed_call or '*' in self.allowed_call
[docs] def has_slot(self, name: str = None) -> bool: """check if component has slot by name""" return bool(self.slot and (not name or name in self.slot))
[docs] def static(self, subdir: str, file_name: str) -> str: """get URL for static file""" return get_static_url(self.session.app, get_template_path(self.template), self.name, subdir, file_name)
[docs] class HTMLElement(RenderNode): """HTML element node Attributes: name (str): name of this HTML tag attributes (DynamicDict): HTML node attributes classes (DynamicClasses): HTML classes style (DynamicStyles): CSS style attributes data (DynamicDict): custom node data value_type (str): HTML "type" attribute ref_name (str): reference name if specified localize (bool): localize time and date values, read :ref:`for more <localize>` text (str): text """ render_this = True __slots__ = ['name', 'ref_name', 'attributes', 'classes', 'text', 'style', 'data', '_set_focused', '_measures', '_measures_ev', 'value_type', '_value', '_value_ev', '_validity', '_validity_ev', 'localize'] def __new__(cls, tag_name: str, parent: RenderNode, attributes: dict[str, Any] | None = None, text: str = '', context: Context = None): if not context: context = parent and parent.context if context and context.ns_type: instance = super().__new__(NSElement) instance.ns_type = context.ns_type else: instance = super().__new__(cls) return instance def __init__(self, tag_name: str, parent: RenderNode, attributes: dict[str, Any] = None, text: str = None, context: Context = None): self.name = tag_name self.ref_name: str | None = None self.attributes: DynamicDict = DynamicDict(attributes) if attributes else DynamicDict() self.classes: DynamicClasses | str = DynamicClasses() self.style: DynamicStyles | str = DynamicStyles() self.text: str = text self.data: DynamicDict = DynamicDict() self._set_focused = False self.value_type = None self.localize: bool = False self._measures: MeasuresData | None = None self._measures_ev: threading.Event | None = None self._value: Any = None self._value_ev: threading.Event | None = None self._validity: bool | None = None self._validity_ev: threading.Event | None = None super().__init__(parent, context=context)
[docs] def get_caller(self, action: str) -> Callable | Awaitable | None: """get context caller by name""" return self.context.get_caller(action)
[docs] @classmethod def parse(cls, element: str, parent: RenderNode, text: str = '') -> Optional[HTMLElement]: """create HTMLElement from string Example:: div = HTMLElement.parse('input type=text value="Hello"', parent) """ if (g:=TAG_PATTERN.match(element)) is None: raise ValueError(f'invalid HTML element: <{element}>') tag_name = g[1] attributes = {} for attr in TAG_GROUPS.finditer(element): attr_name = attr[0] try: attributes[attr_name] = ast.literal_eval(attr[2]) except (ValueError, SyntaxError): attributes[attr_name] = attr[2] return HTMLElement(tag_name, parent, attributes, text)
def _frozen_clone(self, new_parent: RenderNode) -> Union[HTMLElement, TextNode]: clone: HTMLElement = HTMLElement(self.name, new_parent) clone.attributes = DynamicDict({ k: v for k, v in self.attributes.items() if not k.startswith('on:') and not k.startswith('ref:') }) clone.text = self.text clone.classes = deepcopy(self.classes) clone.style = deepcopy(self.style) clone.localize = self.localize return clone def _set_measures(self, box: list[int]) -> object: self._measures = MeasuresData(*box) self._measures_ev.set()
[docs] def request_measures(self): """get position and size of this element, rendered in user browser Warning: Take in account `getBoundingClientRect() <https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect>`__ repeated calls side effect. """ if self._measures_ev is None: self._measures_ev = threading.Event() self.session.request_measures(self) self._measures_ev.wait(config.LOCKS_TIMEOUT) return self._measures
@property def measures(self) -> MeasuresData: """get cached position and size of this element, rendered in user browser Measures is cached after the first request. To update call :meth:`request_measures`. """ if self._measures is not None: return self._measures return self.request_measures()
[docs] def set_measures(self, m: Union[MeasuresData, list[Union[int, str]]], from_x: int = 0, from_y: int = 0, shift_x: int = 0, shift_y: int = 0, grow: int = 0): """change this element position and size in user browser Calling this method sets `position: fixed` style of element, and then changed metrics Arguments: m: :class:`MeasuresData` or sequential list of metrics from_x: new x position from_y: new y position shift_x: shift x position shift_y: shift y position grow: grow width and height by amount of units """ if isinstance(m, list): m = MeasuresData(*m) self.style['position'] = 'fixed' if from_x: m.left = from_x self.style['left'] = m.left + shift_x if from_y: m.top = from_y self.style['top'] = m.top + shift_y self.style['width'] = m.width + grow self.style['height'] = m.height + grow self.shot(self)
[docs] def reset_measures(self): """reset measures cache""" self._measures = None
def _set_value(self, value): self._value = value self._value_ev.set() @property def value(self): """request value of this element <input> from user browser It cached once requested. """ if self._value is not object: return self._value if self._value_ev is None: self._value_ev = threading.Event() self.session.request_value(self, self.value_type or 'text') self._value_ev.wait(config.LOCKS_TIMEOUT) return self._value @value.setter def value(self, value): """send new value to user browser""" self._value = value self.shot(self)
[docs] def validate(self): """check `validity state <https://developer.mozilla.org/en-US/docs/Web/API/ValidityState>`__ of this element""" if self._validity_ev is None: self._validity_ev = threading.Event() self.session.request_validity(self) self._validity_ev.wait(config.LOCKS_TIMEOUT) return self._validity
def _set_validity(self, validity: bool): self._validity = validity self._validity_ev.set()
[docs] def move_box(self, delta_x, delta_y): """shift element location by delta_x, delta_y Designed for drag'n'drop """ if not isinstance(self.style, DynamicStyles): self.style = DynamicStyles(self.style) self.style['left'] += delta_x self.style['top'] += delta_y self.shot.flick(self)
[docs] def set_focused(self): """mark this element as focused This state reset after send. """ self._set_focused = True
[docs] def add_class(self, class_name): """shortcut for `classes += class_name` and update""" self.classes += class_name self.shot(self)
[docs] def remove_class(self, class_name): """shortcut for `classes -= class_name` and update""" self.classes -= class_name self.shot(self)
[docs] def toggle_class(self, class_name): """shortcut for `classes *= class_name` and update""" self.classes *= class_name self.shot(self)
[docs] def set_text(self, text: str): """set text and update""" self.text = text self.shot(self)
def __getitem__(self, item: Union[str, int]): """shortcut to get variable value from local context""" return self.context[item] def __setitem__(self, key, value): """shortcut to set variable value in local context""" self.context[key] = value
[docs] def set_quietly(self, key, value): """shortcut to set variable value suppressing reactions in local context""" self.context.set_quietly(key, value)
[docs] def reset_loop_cache(self): """reset :class:`LoopNode` cache for all child nodes""" for node in self.select("@", depth=1): node.reset_cache()
def __str__(self): return self.name + (f':{self.ref_name}' if self.ref_name else '')
[docs] class NSElement(HTMLElement): """Extension to :class:`HTMLElement` with :ref:`namespace <namespaces>` support Attributes: ns_type (NSType): namespace type """ __slots__ = ['ns_type'] def __str__(self): return f'{NSType(self.ns_type).name}:{self.name}' + (f':{self.ref_name}' if self.ref_name else '')
@dataclass class Condition: __slots__ = ['func', 'template'] func: Callable[[], bool] template: CallableTemplate
[docs] class ConditionNode(RenderNode): """Node for conditional `{{#if ...}}`, `{{#elif ...}}`, `{{#else}}`""" __slots__ = ['state', 'conditions', 'template'] def __init__(self, parent: RenderNode, template: Optional[HTMLTemplate]): self.template = template super().__init__(parent) self.state = -1 self.conditions: Optional[list[Condition]] = [] def __str__(self): return '?' def _frozen_clone(self, new_parent: RenderNode) -> Union[HTMLElement, TextNode]: return HTMLElement('condition', new_parent)
[docs] class LoopNode(RenderNode): """Node for loops `{{#for ...}}`""" __slots__ = ['template', 'loop_template', 'else_template', 'var_name', 'iterator', 'index_func', 'index_map'] def __init__(self, parent: RenderNode, template: Optional[HTMLTemplate]): self.template: CallableTemplate = template super().__init__(parent) self.var_name: Optional[str] = None self.iterator: Optional[Callable[[], Iterable]] = None self.loop_template: CallableTemplate = None self.else_template: CallableTemplate = None self.index_func: Optional[Callable[[ForLoopType, Any], Any]] = None self.index_map: dict[Hashable, list[RenderNode]] = {} def __str__(self): return '@' def _frozen_clone(self, new_parent: RenderNode) -> Optional[HTMLElement, TextNode]: return HTMLElement('loop', new_parent)
[docs] def reset_cache(self): """reset index cache""" self.index_map.clear()
[docs] class TextNode(RenderNode): """node for raw text content intended to render sliced text blocks: .. code-block:: pantra <div> Text one <p>...</p> {{get_content()}} <p>...</p> Text three </div> """ render_this = True __slots__ = ['factory', 'text'] def __init__(self, parent: RenderNode, text: ValueOrCode): super().__init__(parent) if isinstance(text, LambdaType): self.factory: Callable[[], None] | None = text self.text: str = text() else: self.factory = None self.text = text def __str__(self): return 'text' def refresh(self): if self.factory is not None: self.text = self.factory() self.update() def _frozen_clone(self, new_parent: RenderNode) -> Union[HTMLElement, TextNode]: return TextNode(new_parent, self.text)
[docs] class EventNode(RenderNode): """:ref:`Event <events>` node""" render_this = True __slots__ = ['selector', 'events'] def __init__(self, parent: RenderNode, selector: str | None = None, events: DynamicDict = None): super().__init__(parent) self.selector: str | None = selector self.events: DynamicDict = events or DynamicDict() def __str__(self): return 'event'
[docs] class SetNode(RenderNode): """:ref:`Set node <set node>`""" __slots__ = ['variables', 'template', 'scoped', 'self_clear'] def __init__(self, parent: RenderNode, template: CallableTemplate): super().__init__(parent) self.template: CallableTemplate = template self.variables: DynamicDict = DynamicDict(_lazy_mode=True) self.scoped: bool = False self.self_clear: bool = False def __str__(self): return ':='
[docs] class ReactNode(RenderNode): """:ref`React node <reactive tag>`""" __slots__ = ['var_name', 'action', 'value'] def __init__(self, parent: RenderNode, var_name: str, action: str | Callable[[...], None]): super().__init__(parent) self.var_name = var_name self.action = action self.value = None def __str__(self): return f'!{self.var_name}'
[docs] class ScriptNode(RenderNode): """JavaScript node""" render_this = True __slots__ = ['uid', 'attributes', 'text', 'put_to_head'] def __init__(self, parent: RenderNode, uid: str, attributes: dict[str, Any] = None, text: str = '', put_to_head: bool = True): super().__init__(parent) self.attributes: dict[str, Any] = attributes or {} self.text: str = text self.uid: str = uid self.put_to_head: bool = put_to_head def __str__(self): return 'script'
class GroupNode(RenderNode): """Helper group node""" __slots__ = ['template'] def __init__(self, parent: RenderNode, template: Optional[HTMLTemplate]): super().__init__(parent) self.template = template def __str__(self): return 'group'