├── docs ├── .nojekyll ├── _webcompy-app-package │ ├── app-22.157.57090-py3-none-any.whl │ └── webcompy-0.0.13-py3-none-any.whl └── fetch_sample │ └── sample.json ├── webcompy ├── py.typed ├── cli │ ├── template_data │ │ ├── app │ │ │ ├── __init__.py │ │ │ ├── components │ │ │ │ ├── __init__.py │ │ │ │ ├── root.py │ │ │ │ ├── home.py │ │ │ │ ├── not_found.py │ │ │ │ ├── input.py │ │ │ │ ├── navigation.py │ │ │ │ └── fizzbuzz.py │ │ │ ├── bootstrap.py │ │ │ └── router.py │ │ └── webcompy_config.py │ ├── _exception.py │ ├── __init__.py │ ├── _asgi_app.py │ ├── _static_files.py │ ├── _config.py │ ├── _init_project.py │ ├── _argparser.py │ ├── _pyscript_wheel.py │ ├── _generate.py │ ├── _utils.py │ └── _server.py ├── _version.py ├── app │ ├── __init__.py │ ├── _app.py │ └── _root_component.py ├── _browser │ ├── __init__.py │ ├── _modules.py │ └── _pyscript │ │ └── __init__.py ├── exception │ └── __init__.py ├── aio │ ├── __init__.py │ ├── _utils.py │ └── _aio.py ├── ajax │ └── __init__.py ├── utils │ ├── __init__.py │ ├── _environment.py │ ├── _serialize.py │ └── _text.py ├── elements │ ├── typealias │ │ ├── __init__.py │ │ ├── _element_property.py │ │ └── _html_tag_names.py │ ├── types │ │ ├── __init__.py │ │ ├── _refference.py │ │ ├── _dynamic.py │ │ ├── _repeat.py │ │ ├── _text.py │ │ ├── _abstract.py │ │ ├── _switch.py │ │ ├── _element.py │ │ └── _base.py │ ├── __init__.py │ ├── _dom_objs.py │ ├── generators.py │ └── html │ │ ├── __init__.py │ │ └── html_tags.py ├── router │ ├── __init__.py │ ├── _pages.py │ ├── _component.py │ ├── _view.py │ ├── _context.py │ ├── _change_event_hander.py │ └── _router.py ├── __main__.py ├── reactive │ ├── __init__.py │ ├── _readonly.py │ ├── _container.py │ ├── _dict.py │ ├── _computed.py │ ├── _list.py │ └── _base.py ├── __init__.py ├── logging.py └── components │ ├── __init__.py │ ├── _decorators.py │ ├── _abstract.py │ ├── _generator.py │ ├── _libs.py │ └── _component.py ├── docs_src ├── __init__.py ├── pages │ ├── __init__.py │ ├── demo │ │ ├── __init__.py │ │ ├── helloworld.py │ │ ├── helloworld_classstyle.py │ │ ├── fetch_sample.py │ │ ├── matplotlib_sample.py │ │ ├── todo.py │ │ └── fizzbuzz.py │ ├── document │ │ ├── __init__.py │ │ └── home.py │ ├── home.py │ └── not_found.py ├── components │ ├── __init__.py │ ├── syntax_highlighting.py │ ├── demo_display.py │ └── navigation.py ├── templates │ ├── __init__.py │ ├── demo │ │ ├── __init__.py │ │ ├── helloworld.py │ │ ├── helloworld_classstyle.py │ │ ├── fetch_sample.py │ │ ├── matplotlib_sample.py │ │ ├── todo.py │ │ └── fizzbuzz.py │ ├── document │ │ ├── __init__.py │ │ └── home.py │ └── home.py ├── router.py ├── layout.py └── bootstrap.py ├── requirements.txt ├── static └── fetch_sample │ └── sample.json ├── webcompy_config.py ├── LICENSE.txt ├── setup.py ├── .gitignore └── README.md /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webcompy/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs_src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs_src/pages/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs_src/components/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs_src/pages/demo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs_src/templates/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs_src/pages/document/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs_src/templates/demo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs_src/templates/document/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webcompy/cli/template_data/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webcompy/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.13" 2 | -------------------------------------------------------------------------------- /webcompy/cli/template_data/app/components/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | typing_extensions 2 | wheel 3 | starlette 4 | sse-starlette 5 | uvicorn 6 | aiofiles 7 | -------------------------------------------------------------------------------- /webcompy/app/__init__.py: -------------------------------------------------------------------------------- 1 | from webcompy.app._app import WebComPyApp 2 | 3 | __all__ = [ 4 | "WebComPyApp", 5 | ] 6 | -------------------------------------------------------------------------------- /webcompy/_browser/__init__.py: -------------------------------------------------------------------------------- 1 | from webcompy._browser._modules import browser 2 | 3 | __all__ = [ 4 | "browser", 5 | ] 6 | -------------------------------------------------------------------------------- /webcompy/exception/__init__.py: -------------------------------------------------------------------------------- 1 | class WebComPyException(Exception): 2 | pass 3 | 4 | 5 | __all__ = ["WebComPyException"] 6 | -------------------------------------------------------------------------------- /webcompy/cli/_exception.py: -------------------------------------------------------------------------------- 1 | from webcompy.exception import WebComPyException 2 | 3 | 4 | class WebComPyCliException(WebComPyException): 5 | pass 6 | -------------------------------------------------------------------------------- /docs/_webcompy-app-package/app-22.157.57090-py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kniwase/WebComPy/HEAD/docs/_webcompy-app-package/app-22.157.57090-py3-none-any.whl -------------------------------------------------------------------------------- /docs/_webcompy-app-package/webcompy-0.0.13-py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kniwase/WebComPy/HEAD/docs/_webcompy-app-package/webcompy-0.0.13-py3-none-any.whl -------------------------------------------------------------------------------- /webcompy/aio/__init__.py: -------------------------------------------------------------------------------- 1 | from webcompy.aio._aio import AsyncComputed, AsyncWrapper, resolve_async 2 | from webcompy.aio._utils import sleep 3 | 4 | __all__ = ["AsyncComputed", "AsyncWrapper", "resolve_async", "sleep"] 5 | -------------------------------------------------------------------------------- /webcompy/ajax/__init__.py: -------------------------------------------------------------------------------- 1 | from webcompy.ajax._fetch import HttpClient, Response, WebComPyHttpClientException 2 | 3 | __all__ = [ 4 | "HttpClient", 5 | "Response", 6 | "WebComPyHttpClientException", 7 | ] 8 | -------------------------------------------------------------------------------- /webcompy/cli/template_data/webcompy_config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from webcompy.cli import WebComPyConfig 3 | 4 | config = WebComPyConfig( 5 | app_package=Path(__file__).parent / "app", 6 | base="/WebComPy" 7 | ) 8 | -------------------------------------------------------------------------------- /docs/fetch_sample/sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "id": 1, 5 | "name": "foo" 6 | }, 7 | { 8 | "id": 2, 9 | "name": "bar" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /static/fetch_sample/sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "id": 1, 5 | "name": "foo" 6 | }, 7 | { 8 | "id": 2, 9 | "name": "bar" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /webcompy/_browser/_modules.py: -------------------------------------------------------------------------------- 1 | from webcompy.utils._environment import ENVIRONMENT as _ENVIRONMENT 2 | 3 | 4 | if _ENVIRONMENT == "pyscript": 5 | from webcompy._browser._pyscript import browser # type: ignore 6 | else: 7 | browser = None 8 | -------------------------------------------------------------------------------- /docs_src/templates/document/home.py: -------------------------------------------------------------------------------- 1 | from webcompy.elements import html 2 | from webcompy.components import define_component, ComponentContext 3 | 4 | 5 | @define_component 6 | def DocumentHome(_: ComponentContext[None]): 7 | return html.DIV({}, "Work In Progress...") 8 | -------------------------------------------------------------------------------- /webcompy/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from webcompy.cli._config import WebComPyConfig 2 | from webcompy.cli._utils import get_app 3 | from webcompy.cli._server import create_asgi_app 4 | 5 | __all__ = [ 6 | "WebComPyConfig", 7 | "get_app", 8 | "create_asgi_app", 9 | ] 10 | -------------------------------------------------------------------------------- /webcompy_config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from webcompy.cli import WebComPyConfig 3 | 4 | config = WebComPyConfig( 5 | app_package=Path(__file__).parent / "docs_src", 6 | dist="docs", 7 | base="/WebComPy", 8 | dependencies=[ 9 | "numpy", 10 | "matplotlib", 11 | ], 12 | ) 13 | -------------------------------------------------------------------------------- /webcompy/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from webcompy.utils._environment import ENVIRONMENT 2 | from webcompy.utils._serialize import is_json_seriarizable 3 | from webcompy.utils._text import strip_multiline_text 4 | 5 | 6 | __all__ = [ 7 | "ENVIRONMENT", 8 | "is_json_seriarizable", 9 | "strip_multiline_text", 10 | ] 11 | -------------------------------------------------------------------------------- /webcompy/aio/_utils.py: -------------------------------------------------------------------------------- 1 | from webcompy._browser._modules import browser 2 | 3 | 4 | async def sleep(delay: float) -> None: 5 | """Coroutine that completes after a given time (in seconds). 6 | 7 | Args: 8 | delay (float): seconds 9 | """ 10 | if browser: 11 | await browser.aio.sleep(delay) 12 | -------------------------------------------------------------------------------- /webcompy/utils/_environment.py: -------------------------------------------------------------------------------- 1 | from typing import Final, Literal 2 | 3 | 4 | def _get_environment() -> Literal["pyscript", "other"]: 5 | import platform 6 | 7 | if "Emscripten" == platform.system(): 8 | return "pyscript" 9 | else: 10 | return "other" 11 | 12 | 13 | ENVIRONMENT: Final = _get_environment() 14 | -------------------------------------------------------------------------------- /docs_src/pages/home.py: -------------------------------------------------------------------------------- 1 | from webcompy.elements import html 2 | from webcompy.components import define_component, ComponentContext 3 | from webcompy.router import RouterContext 4 | from ..templates.home import Home 5 | 6 | 7 | @define_component 8 | def HomePage(context: ComponentContext[RouterContext]): 9 | return html.DIV({}, Home(None)) 10 | -------------------------------------------------------------------------------- /docs_src/templates/demo/helloworld.py: -------------------------------------------------------------------------------- 1 | from webcompy.elements import html 2 | from webcompy.components import define_component, ComponentContext 3 | 4 | 5 | @define_component 6 | def HelloWorld(_: ComponentContext[None]): 7 | return html.DIV( 8 | {}, 9 | html.H1( 10 | {}, 11 | "Hello WebComPy!", 12 | ), 13 | ) 14 | -------------------------------------------------------------------------------- /webcompy/elements/typealias/__init__.py: -------------------------------------------------------------------------------- 1 | from webcompy.elements.typealias._html_tag_names import HtmlTags 2 | from webcompy.elements.typealias._element_property import ( 3 | ElementChildren, 4 | AttrValue, 5 | EventHandler, 6 | ) 7 | 8 | 9 | __all__ = [ 10 | "HtmlTags", 11 | "ElementChildren", 12 | "AttrValue", 13 | "EventHandler", 14 | ] 15 | -------------------------------------------------------------------------------- /webcompy/cli/template_data/app/components/root.py: -------------------------------------------------------------------------------- 1 | from webcompy.elements import html 2 | from webcompy.components import ComponentContext, define_component 3 | from webcompy.router import RouterView 4 | from .navigation import Navigation 5 | 6 | 7 | @define_component 8 | def Root(_: ComponentContext[None]): 9 | return html.DIV( 10 | {}, 11 | Navigation(None), 12 | RouterView(), 13 | ) 14 | -------------------------------------------------------------------------------- /webcompy/cli/template_data/app/components/home.py: -------------------------------------------------------------------------------- 1 | from webcompy.elements import html 2 | from webcompy.components import define_component, ComponentContext 3 | from webcompy.router import RouterContext 4 | 5 | 6 | @define_component 7 | def Home(context: ComponentContext[RouterContext]): 8 | context.set_title("WebCompy Template") 9 | 10 | return html.H3( 11 | {}, 12 | "WebCompy Template", 13 | ) 14 | -------------------------------------------------------------------------------- /docs_src/pages/document/home.py: -------------------------------------------------------------------------------- 1 | from webcompy.elements import html 2 | from webcompy.components import define_component, ComponentContext 3 | from webcompy.router import RouterContext 4 | from ...templates.document.home import DocumentHome 5 | 6 | 7 | @define_component 8 | def DocumentHomePage(context: ComponentContext[RouterContext]): 9 | context.set_title("Documents - WebCompy") 10 | 11 | return html.DIV({}, DocumentHome(None)) 12 | -------------------------------------------------------------------------------- /webcompy/cli/template_data/app/bootstrap.py: -------------------------------------------------------------------------------- 1 | from webcompy.app import WebComPyApp 2 | from .router import router 3 | from .components.root import Root 4 | 5 | app = WebComPyApp( 6 | root_component=Root, 7 | router=router, 8 | ) 9 | app.set_head( 10 | { 11 | "title": "WebComPy Template", 12 | "meta": { 13 | "charset": { 14 | "charset": "utf-8", 15 | }, 16 | }, 17 | } 18 | ) 19 | -------------------------------------------------------------------------------- /webcompy/router/__init__.py: -------------------------------------------------------------------------------- 1 | from webcompy.router._router import Router 2 | from webcompy.router._context import RouterContext 3 | from webcompy.router._view import RouterView 4 | from webcompy.router._link import RouterLink 5 | from webcompy.router._component import RoutedComponent, create_typed_route 6 | 7 | 8 | __all__ = [ 9 | "Router", 10 | "RouterView", 11 | "RouterLink", 12 | "RouterContext", 13 | "RoutedComponent", 14 | "create_typed_route", 15 | ] 16 | -------------------------------------------------------------------------------- /webcompy/cli/template_data/app/router.py: -------------------------------------------------------------------------------- 1 | from webcompy.router import Router 2 | from .components.home import Home 3 | from .components.fizzbuzz import Fizzbuzz 4 | from .components.input import InOutSample 5 | from .components.not_found import NotFound 6 | 7 | router = Router( 8 | {"path": "/", "component": Home}, 9 | {"path": "/fizzbuzz", "component": Fizzbuzz}, 10 | {"path": "/input", "component": InOutSample}, 11 | default=NotFound, 12 | mode="history", 13 | base_url="/WebComPy" 14 | ) 15 | -------------------------------------------------------------------------------- /webcompy/cli/_asgi_app.py: -------------------------------------------------------------------------------- 1 | from starlette.applications import Starlette 2 | from starlette.routing import Mount 3 | from webcompy.cli._server import create_asgi_app 4 | from webcompy.cli._utils import get_config, get_app 5 | from webcompy.cli._argparser import get_params 6 | 7 | config = get_config() 8 | _, args = get_params() 9 | app = Starlette( 10 | routes=[ 11 | Mount( 12 | config.base, 13 | create_asgi_app(get_app(config), config, args["dev"]), 14 | ) 15 | ] 16 | ) 17 | -------------------------------------------------------------------------------- /docs_src/templates/demo/helloworld_classstyle.py: -------------------------------------------------------------------------------- 1 | from webcompy.elements import html 2 | from webcompy.components import ( 3 | TypedComponentBase, 4 | component_class, 5 | component_template, 6 | ) 7 | 8 | 9 | @component_class 10 | class HelloWorldClassstyle(TypedComponentBase(props_type=None)): 11 | @component_template 12 | def template(self): 13 | return html.DIV( 14 | {}, 15 | html.H1( 16 | {}, 17 | "Hello WebComPy!", 18 | ), 19 | ) 20 | -------------------------------------------------------------------------------- /webcompy/__main__.py: -------------------------------------------------------------------------------- 1 | from webcompy.cli._argparser import get_params 2 | from webcompy.cli._server import run_server 3 | from webcompy.cli._generate import generate_static_site 4 | from webcompy.cli._init_project import init_project 5 | 6 | 7 | def main(): 8 | command, _ = get_params() 9 | if command == "start": 10 | run_server() 11 | elif command == "generate": 12 | generate_static_site() 13 | elif command == "init": 14 | init_project() 15 | 16 | 17 | if __name__ == "__main__": 18 | main() 19 | -------------------------------------------------------------------------------- /webcompy/reactive/__init__.py: -------------------------------------------------------------------------------- 1 | from webcompy.reactive._base import ReactiveBase, Reactive 2 | from webcompy.reactive._computed import Computed, computed, computed_property 3 | from webcompy.reactive._list import ReactiveList 4 | from webcompy.reactive._dict import ReactiveDict 5 | from webcompy.reactive._readonly import readonly 6 | 7 | __all__ = [ 8 | "ReactiveBase", 9 | "Reactive", 10 | "ReactiveList", 11 | "ReactiveDict", 12 | "computed", 13 | "computed_property", 14 | "Computed", 15 | "readonly", 16 | ] 17 | -------------------------------------------------------------------------------- /docs_src/pages/not_found.py: -------------------------------------------------------------------------------- 1 | from webcompy.elements import html 2 | from webcompy.components import define_component, ComponentContext 3 | from webcompy.router import RouterContext 4 | 5 | 6 | @define_component 7 | def NotFound(context: ComponentContext[RouterContext]): 8 | context.set_title("NotFound - WebCompy") 9 | 10 | return html.DIV( 11 | {}, 12 | html.H3( 13 | {}, 14 | "NotFound", 15 | ), 16 | html.PRE( 17 | {}, 18 | context.props.path, 19 | ), 20 | ) 21 | -------------------------------------------------------------------------------- /webcompy/cli/template_data/app/components/not_found.py: -------------------------------------------------------------------------------- 1 | from webcompy.elements import html 2 | from webcompy.components import define_component, ComponentContext 3 | from webcompy.router import RouterContext 4 | 5 | 6 | @define_component 7 | def NotFound(context: ComponentContext[RouterContext]): 8 | context.set_title("NotFound - WebCompy Template") 9 | 10 | return html.DIV( 11 | {}, 12 | html.H3( 13 | {}, 14 | "NotFound", 15 | ), 16 | html.PRE( 17 | {}, 18 | context.props.path, 19 | ), 20 | ) 21 | -------------------------------------------------------------------------------- /webcompy/elements/typealias/_element_property.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Coroutine, Union 2 | from typing_extensions import TypeAlias 3 | from webcompy.reactive._base import ReactiveBase 4 | from webcompy.elements.types._abstract import ElementAbstract 5 | from webcompy.elements._dom_objs import DOMEvent 6 | 7 | ElementChildren: TypeAlias = Union[ElementAbstract, ReactiveBase[Any], str, None] 8 | AttrValue: TypeAlias = Union[ReactiveBase[Any], str, int, bool] 9 | EventHandler: TypeAlias = Union[ 10 | Callable[[DOMEvent], Any], 11 | Callable[[DOMEvent], Coroutine[Any, Any, Any]], 12 | ] 13 | -------------------------------------------------------------------------------- /webcompy/elements/types/__init__.py: -------------------------------------------------------------------------------- 1 | from webcompy.elements.types._text import NewLine, TextElement 2 | from webcompy.elements.types._abstract import ElementAbstract 3 | from webcompy.elements.types._element import Element 4 | from webcompy.elements.types._repeat import RepeatElement, MultiLineTextElement 5 | from webcompy.elements.types._switch import SwitchElement, SwitchCases 6 | 7 | 8 | __all__ = [ 9 | "ElementAbstract", 10 | "NewLine", 11 | "TextElement", 12 | "MultiLineTextElement", 13 | "Element", 14 | "RepeatElement", 15 | "SwitchElement", 16 | "SwitchCases", 17 | ] 18 | -------------------------------------------------------------------------------- /webcompy/router/_pages.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, TypedDict 2 | from webcompy.components import ComponentGenerator 3 | from webcompy.router._context import TypedRouterContext 4 | from webcompy.components import WebComPyComponentException 5 | 6 | 7 | class WebComPyRouterException(WebComPyComponentException): 8 | pass 9 | 10 | 11 | class RouterPageRequired(TypedDict): 12 | component: ComponentGenerator[TypedRouterContext[Any, Any, Any]] 13 | path: str 14 | 15 | 16 | class RouterPage(RouterPageRequired, total=False): 17 | path_params: List[Dict[str, str]] 18 | meta: Any 19 | -------------------------------------------------------------------------------- /webcompy/__init__.py: -------------------------------------------------------------------------------- 1 | from . import ( 2 | aio, 3 | ajax, 4 | app, 5 | components, 6 | elements, 7 | exception, 8 | reactive, 9 | router, 10 | utils, 11 | logging, 12 | ) 13 | from ._browser import browser 14 | from ._version import __version__ 15 | 16 | if utils.ENVIRONMENT == "other": 17 | from . import cli 18 | else: 19 | cli = None 20 | 21 | 22 | __all__ = [ 23 | "__version__", 24 | "browser", 25 | "app", 26 | "reactive", 27 | "elements", 28 | "components", 29 | "router", 30 | "exception", 31 | "aio", 32 | "ajax", 33 | "utils", 34 | "cli", 35 | "logging", 36 | ] 37 | -------------------------------------------------------------------------------- /webcompy/elements/__init__.py: -------------------------------------------------------------------------------- 1 | from webcompy.elements.types._refference import DomNodeRef 2 | from webcompy.elements import types 3 | from webcompy.elements import typealias 4 | from webcompy.elements.generators import ( 5 | event, 6 | noderef, 7 | create_element, 8 | repeat, 9 | switch, 10 | text, 11 | break_line, 12 | ) 13 | from webcompy.elements import html 14 | from webcompy.elements._dom_objs import DOMNode, DOMEvent 15 | 16 | 17 | __all__ = [ 18 | "types", 19 | "typealias", 20 | "html", 21 | "event", 22 | "noderef", 23 | "create_element", 24 | "repeat", 25 | "switch", 26 | "text", 27 | "break_line", 28 | "DomNodeRef", 29 | "DOMNode", 30 | "DOMEvent", 31 | ] 32 | -------------------------------------------------------------------------------- /webcompy/utils/_serialize.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | def _is_json_seriarizable_value(obj: Any) -> bool: 5 | if isinstance(obj, list): 6 | return all(_is_json_seriarizable_value(c) for c in obj) # type: ignore 7 | elif isinstance(obj, dict): 8 | return all(isinstance(k, str) for k in obj.keys()) and all(_is_json_seriarizable_value(v) for v in obj.values()) # type: ignore 9 | elif isinstance(obj, (str, int, float, bool)) or obj is None: 10 | return True 11 | else: 12 | return False 13 | 14 | 15 | def is_json_seriarizable(obj: Any) -> bool: 16 | if isinstance(obj, (list, dict)): 17 | return _is_json_seriarizable_value(obj) # type: ignore 18 | else: 19 | return False 20 | -------------------------------------------------------------------------------- /webcompy/utils/_text.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from itertools import dropwhile 3 | from re import compile as re_compile 4 | from typing import Any 5 | 6 | 7 | _is_blank_line = re_compile(r"^\s*$").match 8 | _get_head_blanks = re_compile(r"^\s+").match 9 | 10 | 11 | def strip_multiline_text(text: str): 12 | lines = list(dropwhile(_is_blank_line, text.split("\n"))) 13 | if lines: 14 | if head_blanks := _get_head_blanks(lines[0]): 15 | return "\n".join( 16 | map( 17 | partial(re_compile("^" + head_blanks.group()).sub, ""), 18 | lines, 19 | ) 20 | ) 21 | else: 22 | return "\n".join(lines) 23 | else: 24 | return "" 25 | -------------------------------------------------------------------------------- /webcompy/_browser/_pyscript/__init__.py: -------------------------------------------------------------------------------- 1 | from types import ModuleType 2 | from importlib import import_module 3 | 4 | 5 | class _PyScriptBrowserModule(ModuleType): 6 | def __init__(self) -> None: 7 | super().__init__("_module") 8 | self.__setattr__( 9 | "pyodide", 10 | import_module("pyodide"), 11 | ) 12 | js = import_module("js") 13 | for name in dir(js): 14 | if name.startswith("_"): 15 | continue 16 | try: 17 | attr = getattr(js, name) 18 | except AttributeError: 19 | try: 20 | attr = import_module("js", name) 21 | except ModuleNotFoundError: 22 | continue 23 | self.__setattr__(name, attr) 24 | 25 | 26 | browser = _PyScriptBrowserModule() 27 | __all__ = ["browser"] 28 | -------------------------------------------------------------------------------- /webcompy/reactive/_readonly.py: -------------------------------------------------------------------------------- 1 | from typing import NoReturn, TypeVar, final 2 | from webcompy.reactive._base import ReactiveBase 3 | from webcompy.reactive._computed import Computed 4 | 5 | 6 | V = TypeVar("V") 7 | 8 | 9 | class ReadonlyReactive(Computed[V]): 10 | @final 11 | def __init__(self) -> NoReturn: 12 | raise NotImplementedError( 13 | "ReadonlyReactive cannot generate an instance by constructor" 14 | ) 15 | 16 | @classmethod 17 | def __create_instance__(cls, reactive: ReactiveBase[V]): 18 | instance = cls.__new__(cls) 19 | instance.__set_reactive(reactive) 20 | return instance 21 | 22 | def __set_reactive(self, reactive: ReactiveBase[V]): 23 | super().__init__(lambda: reactive.value) 24 | 25 | 26 | def readonly(reactive: ReactiveBase[V]) -> ReadonlyReactive[V]: 27 | return ReadonlyReactive.__create_instance__(reactive) 28 | -------------------------------------------------------------------------------- /webcompy/logging.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Protocol, Tuple 2 | from webcompy._browser._modules import browser as _browser 3 | from logging import getLogger as _getLogger 4 | 5 | 6 | class _Handler(Protocol): 7 | def debug(self, msg: str): 8 | ... 9 | 10 | def info(self, msg: str): 11 | ... 12 | 13 | def warn(self, msg: str): 14 | ... 15 | 16 | def error(self, msg: str): 17 | ... 18 | 19 | 20 | _handler: _Handler = _browser.console if _browser else _getLogger("uvicorn") 21 | 22 | 23 | def _convert_msg(values: Tuple[Any]): 24 | return "\t".join(map(str, values)) 25 | 26 | 27 | def debug(*values: Any): 28 | _handler.debug(_convert_msg(values)) 29 | 30 | 31 | def info(*values: Any): 32 | _handler.info(_convert_msg(values)) 33 | 34 | 35 | def warn(*values: Any): 36 | _handler.warn(_convert_msg(values)) 37 | 38 | 39 | def error(*values: Any): 40 | _handler.error(_convert_msg(values)) 41 | -------------------------------------------------------------------------------- /webcompy/cli/template_data/app/components/input.py: -------------------------------------------------------------------------------- 1 | from webcompy.reactive import Reactive 2 | from webcompy.elements import html 3 | from webcompy.components import define_component, ComponentContext 4 | from webcompy.router import RouterContext 5 | from webcompy.elements import DOMEvent 6 | 7 | 8 | @define_component 9 | def InOutSample(context: ComponentContext[RouterContext]): 10 | context.set_title("Text Input Sample - WebCompy Template") 11 | 12 | text = Reactive("") 13 | 14 | def on_input(ev: DOMEvent): 15 | text.value = ev.target.value 16 | 17 | return html.DIV( 18 | {}, 19 | html.H4({}, "Text Input Sample"), 20 | html.P( 21 | {}, 22 | "Input: ", 23 | html.INPUT( 24 | {"type": "text", "@input": on_input}, 25 | ), 26 | ), 27 | html.P( 28 | {}, 29 | "Output: ", 30 | text, 31 | ), 32 | ) 33 | -------------------------------------------------------------------------------- /webcompy/reactive/_container.py: -------------------------------------------------------------------------------- 1 | from typing import Any, cast 2 | from weakref import WeakValueDictionary 3 | from webcompy.reactive._base import ReactiveBase 4 | 5 | 6 | class ReactiveReceivable: 7 | __reactive_members__: dict[int, ReactiveBase[Any]] 8 | 9 | def __setattr__(self, name: str, value: Any) -> None: 10 | if isinstance(value, ReactiveBase): 11 | self.__set_reactive_member__(cast(ReactiveBase[Any], value)) 12 | super().__setattr__(name, value) 13 | 14 | def __set_reactive_member__(self, value: ReactiveBase[Any]) -> None: 15 | if not hasattr(self, "__reactive_members__"): 16 | self.__reactive_members__ = cast( 17 | dict[int, ReactiveBase[Any]], WeakValueDictionary({}) 18 | ) 19 | self.__reactive_members__[id(value)] = value 20 | 21 | def __purge_reactive_members__(self) -> None: 22 | if hasattr(self, "__reactive_members__"): 23 | pass 24 | -------------------------------------------------------------------------------- /docs_src/components/syntax_highlighting.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | from webcompy.elements import html, DomNodeRef 3 | from webcompy.components import define_component, ComponentContext 4 | from webcompy.utils import strip_multiline_text 5 | from webcompy import browser 6 | 7 | 8 | class SyntaxHighlightingProps(TypedDict): 9 | code: str 10 | lang: str 11 | 12 | 13 | @define_component 14 | def SyntaxHighlighting(context: ComponentContext[SyntaxHighlightingProps]): 15 | code_ref = DomNodeRef() 16 | 17 | @context.on_after_rendering 18 | def _(): 19 | if browser: 20 | browser.window.hljs.highlightElement(code_ref.element) 21 | 22 | return html.PRE( 23 | {}, 24 | html.CODE( 25 | {"class": "language-" + context.props["lang"], ":ref": code_ref}, 26 | strip_multiline_text(context.props["code"]).strip(), 27 | ), 28 | ) 29 | 30 | 31 | SyntaxHighlighting.scoped_style = { 32 | "pre code": { 33 | "font-size": "14px", 34 | "line-height": "1.2", 35 | "border-radius": "5px", 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /webcompy/cli/_static_files.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | import os 3 | import pathlib 4 | from re import compile as re_complie, escape as re_escape 5 | from webcompy.cli._exception import WebComPyCliException 6 | 7 | 8 | def _list_up_files(target: pathlib.Path) -> list[pathlib.Path]: 9 | if target.is_dir(): 10 | return [f for d in target.iterdir() for f in _list_up_files(d)] 11 | else: 12 | return [target] 13 | 14 | 15 | def get_static_files(static_file_dir: pathlib.Path): 16 | static_file_dir = static_file_dir.absolute() 17 | if not static_file_dir.exists(): 18 | raise WebComPyCliException( 19 | f"Static File dir '{static_file_dir}' does not exist" 20 | ) 21 | elif not static_file_dir.is_dir(): 22 | raise WebComPyCliException( 23 | f"'{static_file_dir}' is not directory", 24 | ) 25 | get_relative_path = partial( 26 | re_complie("^" + re_escape(str(static_file_dir) + os.sep)).sub, "" 27 | ) 28 | return tuple( 29 | get_relative_path(str(p)).replace("\\", "/") 30 | for p in _list_up_files(static_file_dir) 31 | ) 32 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Kento NIWASE 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs_src/router.py: -------------------------------------------------------------------------------- 1 | from webcompy.router import Router 2 | from .pages.home import HomePage 3 | from .pages.document.home import DocumentHomePage 4 | from .pages.demo.helloworld import HelloWorldPage 5 | from .pages.demo.helloworld_classstyle import HelloWorldClassstylePage 6 | from .pages.demo.fizzbuzz import FizzbuzzPage 7 | from .pages.demo.todo import ToDoListPage 8 | from .pages.demo.matplotlib_sample import MatpoltlibSamplePage 9 | from .pages.demo.fetch_sample import FetchSamplePage 10 | from .pages.not_found import NotFound 11 | 12 | router = Router( 13 | {"path": "/", "component": HomePage}, 14 | {"path": "/documents", "component": DocumentHomePage}, 15 | {"path": "/sample/helloworld", "component": HelloWorldPage}, 16 | {"path": "/sample/helloworld-classstyle", "component": HelloWorldClassstylePage}, 17 | {"path": "/sample/fizzbuzz", "component": FizzbuzzPage}, 18 | {"path": "/sample/todo", "component": ToDoListPage}, 19 | {"path": "/sample/matplotlib", "component": MatpoltlibSamplePage}, 20 | {"path": "/sample/fetch", "component": FetchSamplePage}, 21 | default=NotFound, 22 | mode="history", 23 | base_url="/WebComPy", 24 | ) 25 | -------------------------------------------------------------------------------- /webcompy/app/_app.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from webcompy.components import ComponentGenerator 3 | from webcompy.router import Router 4 | from webcompy.app._root_component import AppDocumentRoot 5 | 6 | 7 | class WebComPyApp: 8 | _root: AppDocumentRoot 9 | 10 | def __init__( 11 | self, 12 | *, 13 | root_component: ComponentGenerator[None], 14 | router: Router | None = None, 15 | ) -> None: 16 | self._root = AppDocumentRoot(root_component, router) 17 | 18 | @property 19 | def __component__(self): 20 | return self._root 21 | 22 | @property 23 | def set_title(self): 24 | return self._root.set_title 25 | 26 | @property 27 | def set_meta(self): 28 | return self._root.set_meta 29 | 30 | @property 31 | def append_link(self): 32 | return self._root.append_link 33 | 34 | @property 35 | def append_script(self): 36 | return self._root.append_script 37 | 38 | @property 39 | def set_head(self): 40 | return self._root.set_head 41 | 42 | @property 43 | def update_head(self): 44 | return self._root.update_head 45 | -------------------------------------------------------------------------------- /webcompy/components/__init__.py: -------------------------------------------------------------------------------- 1 | from webcompy.components._abstract import ( 2 | ComponentBase, 3 | NonPropsComponentBase, 4 | TypedComponentBase, 5 | ) 6 | from webcompy.components._component import Component 7 | from webcompy.components._libs import ( 8 | ComponentContext, 9 | ClassStyleComponentContenxt, 10 | ComponentProperty, 11 | WebComPyComponentException, 12 | ) 13 | from webcompy.components._decorators import ( 14 | component_template, 15 | on_before_rendering, 16 | on_after_rendering, 17 | on_before_destroy, 18 | ) 19 | from webcompy.components._generator import ( 20 | ComponentGenerator, 21 | component_class, 22 | define_component, 23 | ) 24 | 25 | 26 | __all__ = [ 27 | "define_component", 28 | "ComponentContext", 29 | "ComponentBase", 30 | "NonPropsComponentBase", 31 | "TypedComponentBase", 32 | "component_class", 33 | "component_template", 34 | "on_before_rendering", 35 | "on_after_rendering", 36 | "on_before_destroy", 37 | "ComponentGenerator", 38 | "WebComPyComponentException", 39 | "Component", 40 | "ClassStyleComponentContenxt", 41 | "ComponentProperty", 42 | ] 43 | -------------------------------------------------------------------------------- /webcompy/cli/_config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from pathlib import Path 3 | 4 | 5 | class WebComPyConfig: 6 | app_package_path: Path 7 | base: str 8 | server_port: int 9 | static_files_dir_path: Path 10 | dist: str 11 | dependencies: list[str] 12 | 13 | def __init__( 14 | self, 15 | app_package: Path | str, 16 | base: str = "/", 17 | server_port: int = 8080, 18 | static_files_dir: Path | str = "static", 19 | dist: str = "dist", 20 | dependencies: list[str] | None = None, 21 | ) -> None: 22 | if isinstance(app_package, Path): 23 | self.app_package_path = app_package.absolute() 24 | else: 25 | self.app_package_path = Path(f"./{app_package}").absolute() 26 | self.base = f"/{base}/" if (base := base.strip("/")) else "/" 27 | self.server_port = server_port 28 | if isinstance(static_files_dir, Path): 29 | self.app_package_path = static_files_dir.absolute() 30 | else: 31 | self.static_files_dir_path = self.app_package_path.parent / static_files_dir 32 | self.dist = dist 33 | self.dependencies = [*dependencies] if dependencies else [] 34 | -------------------------------------------------------------------------------- /webcompy/router/_component.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any, Tuple, Type, TypeVar 3 | from typing_extensions import TypeAlias 4 | from webcompy.components._abstract import TypedComponentBase 5 | from webcompy.router._context import RouterContext, TypedRouterContext 6 | from webcompy.router._link import TypedRouterLink 7 | 8 | 9 | RoutedComponent: TypeAlias = TypedComponentBase(RouterContext) 10 | 11 | 12 | ParamsType = TypeVar("ParamsType") 13 | QueryParamsType = TypeVar("QueryParamsType") 14 | PathParamsType = TypeVar("PathParamsType") 15 | 16 | TypedRoute: TypeAlias = Tuple[ 17 | Type[TypedRouterContext[ParamsType, QueryParamsType, PathParamsType]], 18 | Type[TypedRouterLink[ParamsType, QueryParamsType, PathParamsType]], 19 | ] 20 | 21 | 22 | def create_typed_route( 23 | *, 24 | params_type: Type[ParamsType] = dict[str, Any], 25 | query_type: Type[QueryParamsType] = dict[str, str], 26 | path_params_type: Type[PathParamsType] = dict[str, str], 27 | ) -> TypedRoute[ParamsType, QueryParamsType, PathParamsType]: 28 | return ( 29 | TypedRouterContext[ParamsType, QueryParamsType, PathParamsType], 30 | TypedRouterLink[ParamsType, QueryParamsType, PathParamsType], 31 | ) 32 | -------------------------------------------------------------------------------- /webcompy/cli/template_data/app/components/navigation.py: -------------------------------------------------------------------------------- 1 | from webcompy.elements import html 2 | from webcompy.components import ( 3 | component_class, 4 | NonPropsComponentBase, 5 | component_template, 6 | ) 7 | from webcompy.router import RouterLink 8 | 9 | 10 | @component_class 11 | class Navigation(NonPropsComponentBase): 12 | def __init__(self) -> None: 13 | pass 14 | 15 | @component_template 16 | def template(self): 17 | return html.NAV( 18 | {}, 19 | html.UL( 20 | {}, 21 | html.LI( 22 | {}, 23 | RouterLink( 24 | to="/", 25 | text=["Home"], 26 | ), 27 | ), 28 | html.LI( 29 | {}, 30 | RouterLink( 31 | to="/fizzbuzz", 32 | text=["FizzBuzz"], 33 | ), 34 | ), 35 | html.LI( 36 | {}, 37 | RouterLink( 38 | to="/input", 39 | text=["Text Input Sample"], 40 | ), 41 | ), 42 | ), 43 | ) 44 | -------------------------------------------------------------------------------- /docs_src/pages/demo/helloworld.py: -------------------------------------------------------------------------------- 1 | from webcompy.elements import html 2 | from webcompy.components import define_component, ComponentContext 3 | from webcompy.router import RouterContext 4 | from ...components.demo_display import DemoDisplay 5 | from ...templates.demo.helloworld import HelloWorld 6 | 7 | 8 | @define_component 9 | def HelloWorldPage(context: ComponentContext[RouterContext]): 10 | title = "HelloWorld" 11 | context.set_title(f"{title} - WebCompy Demo") 12 | 13 | return html.DIV( 14 | {}, 15 | DemoDisplay( 16 | { 17 | "title": title, 18 | "code": """ 19 | from webcompy.elements import html 20 | from webcompy.components import define_component, ComponentContext 21 | 22 | 23 | @define_component 24 | def HelloWorld(_: ComponentContext[None]): 25 | return html.DIV( 26 | {}, 27 | html.H1( 28 | {}, 29 | "Hello WebComPy!", 30 | ), 31 | )""", 32 | }, 33 | slots={"component": lambda: HelloWorld(None)}, 34 | ), 35 | ) 36 | -------------------------------------------------------------------------------- /webcompy/components/_decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from typing import Any, Callable 3 | from webcompy.elements.types._element import Element 4 | 5 | 6 | def component_template(method: Callable[[Any], Element]): 7 | @wraps(method) 8 | def inner(self: Any) -> Element: 9 | return method(self) 10 | 11 | setattr(inner, "__webcompy_component_class_property__", "template") 12 | return inner 13 | 14 | 15 | def on_before_rendering(method: Callable[[Any], None]): 16 | @wraps(method) 17 | def inner(self: Any): 18 | method(self) 19 | 20 | setattr( 21 | inner, 22 | "__webcompy_component_class_property__", 23 | "on_before_rendering", 24 | ) 25 | return inner 26 | 27 | 28 | def on_after_rendering(method: Callable[[Any], None]): 29 | @wraps(method) 30 | def inner(self: Any): 31 | method(self) 32 | 33 | setattr( 34 | inner, 35 | "__webcompy_component_class_property__", 36 | "on_after_rendering", 37 | ) 38 | return inner 39 | 40 | 41 | def on_before_destroy(method: Callable[[Any], None]): 42 | @wraps(method) 43 | def inner(self: Any): 44 | method(self) 45 | 46 | setattr(inner, "__webcompy_component_class_property__", "on_before_destroy") 47 | return inner 48 | -------------------------------------------------------------------------------- /webcompy/reactive/_dict.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, TypeVar 2 | from webcompy.reactive._base import Reactive, ReactiveBase 3 | 4 | 5 | K = TypeVar("K") 6 | V = TypeVar("V") 7 | 8 | 9 | class ReactiveDict(Reactive[Dict[K, V]]): 10 | def __init__(self, init_value: dict[K, V] = {}) -> None: 11 | super().__init__(init_value) 12 | 13 | @ReactiveBase._get_evnet 14 | def __getitem__(self, key: K): 15 | return self._value.__getitem__(key) 16 | 17 | @ReactiveBase._change_event 18 | def __setitem__(self, key: K, value: V): 19 | self._value.__setitem__(key, value) 20 | 21 | @ReactiveBase._change_event 22 | def __delitem__(self, key: K): 23 | self._value.__delitem__(key) 24 | 25 | @ReactiveBase._change_event 26 | def pop(self, key: K): 27 | return self._value.pop(key) 28 | 29 | @ReactiveBase._get_evnet 30 | def __len__(self): 31 | return len(self._value) 32 | 33 | @ReactiveBase._get_evnet 34 | def __iter__(self): 35 | return iter(self._value) 36 | 37 | @ReactiveBase._get_evnet 38 | def get(self, key: K, default: Any = None): 39 | return self._value.get(key, default) 40 | 41 | @ReactiveBase._get_evnet 42 | def keys(self): 43 | return self._value.keys() 44 | 45 | @ReactiveBase._get_evnet 46 | def values(self): 47 | return self._value.values() 48 | 49 | @ReactiveBase._get_evnet 50 | def items(self): 51 | return self._value.items() 52 | -------------------------------------------------------------------------------- /docs_src/components/demo_display.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | from webcompy.elements import html 3 | from webcompy.components import define_component, ComponentContext 4 | from .syntax_highlighting import SyntaxHighlighting 5 | 6 | 7 | class DemoComponentProps(TypedDict): 8 | title: str 9 | code: str 10 | 11 | 12 | @define_component 13 | def DemoDisplay(context: ComponentContext[DemoComponentProps]): 14 | return html.DIV( 15 | {}, 16 | html.DIV( 17 | {"class": "card"}, 18 | html.DIV( 19 | {"class": "card-body"}, 20 | html.H5({"class": "card-title"}, context.props["title"]), 21 | html.DIV( 22 | {"class": "card"}, 23 | html.DIV( 24 | {"class": "card-body"}, 25 | context.slots("component"), 26 | ), 27 | ), 28 | html.BR(), 29 | html.DIV( 30 | {"class": "card"}, 31 | html.DIV({"class": "card-header"}, "Code"), 32 | html.DIV( 33 | {"class": "card-body"}, 34 | SyntaxHighlighting( 35 | { 36 | "lang": "python", 37 | "code": context.props["code"], 38 | } 39 | ), 40 | ), 41 | ), 42 | ), 43 | ), 44 | ) 45 | -------------------------------------------------------------------------------- /webcompy/router/_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import ClassVar, List, TypedDict, Union 3 | from webcompy.elements.types import Element, SwitchElement 4 | from webcompy.components import ComponentGenerator, WebComPyComponentException 5 | from webcompy.router._router import Router 6 | from webcompy.router._context import RouterContext 7 | 8 | 9 | class RouterPageRequired(TypedDict): 10 | path: str 11 | 12 | 13 | class RouterPage(RouterPageRequired, total=False): 14 | component: ComponentGenerator[RouterContext] 15 | children: List["RouterPage"] 16 | 17 | 18 | class RouterView(Element): 19 | _instance: ClassVar[Union["RouterView", None]] = None 20 | _router: ClassVar[Union[Router, None]] = None 21 | 22 | def __init__(self) -> None: 23 | if RouterView._instance: 24 | raise WebComPyComponentException( 25 | "Only one instance of 'RouterView' can exist." 26 | ) 27 | else: 28 | RouterView._instance = self 29 | if RouterView._router is None: 30 | raise WebComPyComponentException("'Router' instance is not declarated.") 31 | 32 | super().__init__( 33 | tag_name="div", 34 | attrs={"webcompy-routerview": True}, 35 | children=[ 36 | SwitchElement( 37 | RouterView._router.__cases__, RouterView._router.__default__ 38 | ) 39 | ], 40 | ) 41 | 42 | @staticmethod 43 | def __set_router__(router: Router | None): 44 | RouterView._router = router 45 | -------------------------------------------------------------------------------- /webcompy/elements/types/_refference.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any 3 | from webcompy.elements._dom_objs import DOMNode 4 | from webcompy.exception import WebComPyException 5 | 6 | 7 | class DomNodeRef: 8 | _node: DOMNode | None 9 | 10 | def __init__(self) -> None: 11 | self._node = None 12 | 13 | @property 14 | def element(self) -> DOMNode: 15 | if self._node is None: 16 | raise WebComPyException("DomNodeRef is not initialized yet.") 17 | return self._node 18 | 19 | def __init_node__(self, node: DOMNode): 20 | self._node = node 21 | 22 | def __reset_node__(self): 23 | self._node = None 24 | 25 | def __getattr__(self, name: str) -> Any: 26 | if name in {"element", "__init_node__", "__reset_node__"}: 27 | return super().__getattribute__(name) 28 | else: 29 | return getattr(self._node, name) 30 | 31 | def __setattr__(self, name: str, value: Any) -> None: 32 | if name == "_node": 33 | super().__setattr__(name, value) 34 | elif name in {"element", "__init_node__", "__reset_node__"}: 35 | raise AttributeError(f"'{name}' is readonly attribute.") 36 | else: 37 | setattr(self._node, name, value) 38 | 39 | def __dir__(self): 40 | if self._node is None: 41 | return super().__dir__() 42 | else: 43 | return { 44 | *dir(self._node), 45 | "element", 46 | "__init_node__", 47 | "__reset_node__", 48 | } 49 | -------------------------------------------------------------------------------- /docs_src/pages/demo/helloworld_classstyle.py: -------------------------------------------------------------------------------- 1 | from webcompy.elements import html 2 | from webcompy.components import define_component, ComponentContext 3 | from webcompy.router import RouterContext 4 | from ...components.demo_display import DemoDisplay 5 | from ...templates.demo.helloworld_classstyle import HelloWorldClassstyle 6 | 7 | 8 | @define_component 9 | def HelloWorldClassstylePage(context: ComponentContext[RouterContext]): 10 | title = "HelloWorld (ClassStyle)" 11 | context.set_title(f"{title} - WebCompy Demo") 12 | 13 | return html.DIV( 14 | {}, 15 | DemoDisplay( 16 | { 17 | "title": title, 18 | "code": """ 19 | from webcompy.elements import html 20 | from webcompy.components import ( 21 | TypedComponentBase, 22 | component_class, 23 | component_template, 24 | ) 25 | 26 | 27 | @component_class 28 | class HelloWorldClassstyle(TypedComponentBase(props_type=None)): 29 | @component_template 30 | def template(self): 31 | return html.DIV( 32 | {}, 33 | html.H1( 34 | {}, 35 | "Hello WebComPy!", 36 | ), 37 | )""", 38 | }, 39 | slots={"component": lambda: HelloWorldClassstyle(None)}, 40 | ), 41 | ) 42 | -------------------------------------------------------------------------------- /webcompy/reactive/_computed.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, TypeVar 2 | from webcompy.reactive._base import ReactiveBase 3 | from webcompy.reactive._container import ReactiveReceivable 4 | 5 | 6 | V = TypeVar("V") 7 | 8 | 9 | class Computed(ReactiveBase[V]): 10 | _dependencies: list[ReactiveBase[Any]] 11 | _dependency_callback_ids: list[int] 12 | 13 | def __init__( 14 | self, 15 | func: Callable[[], V], 16 | ) -> None: 17 | self.__calc = func 18 | init_value, self._dependencies = self._store.detect_dependency(self.__calc) 19 | self._dependency_callback_ids = [ 20 | reactive.on_after_updating(self._compute) for reactive in self._dependencies 21 | ] 22 | super().__init__(init_value) 23 | 24 | @property 25 | @ReactiveBase._get_evnet 26 | def value(self) -> V: 27 | return self._value 28 | 29 | @ReactiveBase._change_event 30 | def _compute(self, *_: Any) -> V: 31 | self._value = self.__calc() 32 | return self._value 33 | 34 | 35 | def computed(func: Callable[[], V]) -> Computed[V]: 36 | return Computed(func) 37 | 38 | 39 | def computed_property(method: Callable[[Any], V]) -> Computed[V]: 40 | name = method.__name__ 41 | 42 | def getter(instance: Any) -> Computed[V]: 43 | if name not in instance.__dict__: 44 | _computed = Computed(lambda: method(instance)) 45 | if isinstance(instance, ReactiveReceivable): 46 | instance.__set_reactive_member__(_computed) 47 | instance.__dict__[name] = _computed 48 | return instance.__dict__[name] 49 | 50 | return property(getter) # type: ignore 51 | -------------------------------------------------------------------------------- /docs_src/layout.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from webcompy.elements import html 3 | from webcompy.components import ComponentContext, define_component 4 | from webcompy.router import RouterView 5 | from .components.navigation import Navbar, Page 6 | 7 | 8 | @define_component 9 | def Root(_: ComponentContext[None]): 10 | pages: List[Page] = [ 11 | { 12 | "title": "Home", 13 | "to": "/", 14 | }, 15 | { 16 | "title": "Documents", 17 | "to": "/documents", 18 | "children": [], 19 | }, 20 | { 21 | "title": "Demos", 22 | # "to": "/sample", 23 | "children": [ 24 | { 25 | "title": "HelloWorld", 26 | "to": "/sample/helloworld", 27 | }, 28 | { 29 | "title": "HelloWorld (ClassStyle)", 30 | "to": "/sample/helloworld-classstyle", 31 | }, 32 | { 33 | "title": "FizzBuzz", 34 | "to": "/sample/fizzbuzz", 35 | }, 36 | { 37 | "title": "ToDo List", 38 | "to": "/sample/todo", 39 | }, 40 | { 41 | "title": "Matplotlib Sample", 42 | "to": "/sample/matplotlib", 43 | }, 44 | { 45 | "title": "Fetch Sample", 46 | "to": "/sample/fetch", 47 | }, 48 | ], 49 | }, 50 | ] 51 | return html.DIV( 52 | {}, 53 | Navbar(pages), 54 | html.MAIN( 55 | {}, 56 | html.ARTICLE( 57 | {}, 58 | RouterView(), 59 | ), 60 | ), 61 | ) 62 | -------------------------------------------------------------------------------- /webcompy/elements/types/_dynamic.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from abc import abstractmethod 3 | from typing import NoReturn 4 | from webcompy.elements.typealias._element_property import ElementChildren 5 | from webcompy.elements.types._base import ElementWithChildren 6 | from webcompy.exception import WebComPyException 7 | 8 | 9 | class DynamicElement(ElementWithChildren): 10 | __parent: ElementWithChildren 11 | 12 | @property 13 | def _node_count(self) -> int: 14 | return sum(child._node_count for child in self._children) 15 | 16 | def _create_child_element( 17 | self, 18 | parent: "ElementWithChildren", 19 | node_idx: int | None, 20 | child: ElementChildren, 21 | ): 22 | child_element = super()._create_child_element(parent, node_idx, child) 23 | if isinstance(child_element, DynamicElement): 24 | raise WebComPyException("Nested DynamicElement is not allowed.") 25 | return child_element 26 | 27 | def _init_node(self) -> NoReturn: 28 | raise WebComPyException("'DynamicElement' does not have its own node.") 29 | 30 | def _get_node(self) -> NoReturn: 31 | raise WebComPyException("'DynamicElement' does not have its own node.") 32 | 33 | def _render_html( 34 | self, newline: bool = False, indent: int = 2, count: int = 0 35 | ) -> str: 36 | return ("\n" if newline else "").join( 37 | child._render_html(newline, indent, count) for child in self._children 38 | ) 39 | 40 | @property 41 | def _parent(self) -> "ElementWithChildren": 42 | return self.__parent 43 | 44 | @_parent.setter 45 | def _parent(self, parent: "ElementWithChildren"): 46 | self.__parent = parent 47 | self._on_set_parent() 48 | 49 | @abstractmethod 50 | def _on_set_parent(self): 51 | ... 52 | -------------------------------------------------------------------------------- /webcompy/router/_context.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Generic, NoReturn, TypeVar, final 2 | from typing_extensions import TypeAlias 3 | 4 | 5 | ParamsType = TypeVar("ParamsType") 6 | QueryParamsType = TypeVar("QueryParamsType") 7 | PathParamsType = TypeVar("PathParamsType") 8 | 9 | 10 | class TypedRouterContext(Generic[ParamsType, QueryParamsType, PathParamsType]): 11 | __path: str 12 | __state_params: ParamsType 13 | __query_params: QueryParamsType 14 | __path_params: PathParamsType 15 | 16 | @final 17 | def __init__(self) -> NoReturn: 18 | raise NotImplementedError( 19 | "RouterContext cannot generate an instance by constructor" 20 | ) 21 | 22 | @classmethod 23 | def __create_instance__( 24 | cls, 25 | *, 26 | path: str, 27 | state: ParamsType, 28 | query_params: QueryParamsType, 29 | path_params: PathParamsType, 30 | ): 31 | instance = cls.__new__(cls) 32 | instance.__path = path 33 | instance.__query_params = query_params 34 | instance.__path_params = path_params 35 | instance.__state_params = state 36 | return instance 37 | 38 | @property 39 | def path(self): 40 | return self.__path 41 | 42 | @property 43 | def path_params(self): 44 | return self.__path_params 45 | 46 | @property 47 | def query(self): 48 | return self.__query_params 49 | 50 | @property 51 | def params(self): 52 | return self.__state_params 53 | 54 | def __repr__(self): 55 | return ( 56 | "RouterContext(" 57 | + ", ".join( 58 | f"{name}={repr(getattr(self, name))}" 59 | for name in ("path", "query", "path_params", "params") 60 | ) 61 | + ")" 62 | ) 63 | 64 | 65 | RouterContext: TypeAlias = TypedRouterContext[ 66 | dict[str, Any], dict[str, str], dict[str, str] 67 | ] 68 | -------------------------------------------------------------------------------- /webcompy/cli/_init_project.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import shutil 4 | import sys 5 | from webcompy.cli._utils import get_webcompy_packge_dir 6 | 7 | 8 | def _get_files(path: pathlib.Path, suffix: str) -> list[str]: 9 | ret: list[str] = [] 10 | if path.is_dir(): 11 | for p in path.iterdir(): 12 | ret.extend(_get_files(p, suffix)) 13 | elif path.suffix == suffix: 14 | ret.append(str(path)) 15 | return ret 16 | 17 | 18 | def init_project(): 19 | template_data_dir = ( 20 | pathlib.Path(get_webcompy_packge_dir()) / "cli" / "template_data" 21 | ) 22 | cwd = pathlib.Path().cwd().absolute() 23 | filepath_pairs = [ 24 | ( 25 | filepath, 26 | cwd / str(filepath).replace(str(template_data_dir), "").lstrip(os.sep), 27 | ) 28 | for filepath in map(pathlib.Path, _get_files(template_data_dir, ".py")) 29 | ] 30 | 31 | files_exist = [p for _, p in filepath_pairs if p.exists()] 32 | if files_exist: 33 | for p in files_exist: 34 | print(p) 35 | while True: 36 | ans = input("Some files already exist. Will you overwrite them? (y/N): ") 37 | if len(ans) == 0 or (ans.isalpha() and ans.lower() in {"y", "n"}): 38 | if len(ans) == 0 or ans.lower() == "n": 39 | sys.exit() 40 | else: 41 | break 42 | else: 43 | continue 44 | for template_filepath, project_filepath in filepath_pairs: 45 | if not project_filepath.parent.exists(): 46 | os.makedirs(project_filepath.parent) 47 | if project_filepath.exists(): 48 | os.remove(project_filepath) 49 | shutil.copy(template_filepath, project_filepath) 50 | print(project_filepath) 51 | if not (staic_files_dir := (cwd / "static")).exists(): 52 | os.makedirs(staic_files_dir) 53 | (cwd / "__init__.py").touch() 54 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | from setuptools import setup, find_packages 4 | from pathlib import Path 5 | import webcompy 6 | 7 | 8 | def _get_files(path: pathlib.Path, suffix: str) -> list[str]: 9 | ret: list[str] = [] 10 | if path.is_dir(): 11 | for p in path.iterdir(): 12 | ret.extend(_get_files(p, suffix)) 13 | elif path.suffix == suffix: 14 | ret.append(str(path)) 15 | return ret 16 | 17 | 18 | package_name = "webcompy" 19 | root_dir = Path(__file__).parent.absolute() 20 | 21 | package_dir = root_dir / "webcompy" 22 | pyi_files = [ 23 | p.replace(str(package_dir) + os.sep, "").replace("\\", "/") 24 | for p in _get_files(package_dir, ".pyi") 25 | ] 26 | 27 | template_data_dir = package_dir / "cli" / "template_data" 28 | template_files = [ 29 | p.replace(str(package_dir) + os.sep, "").replace("\\", "/") 30 | for p in _get_files(template_data_dir, ".py") 31 | ] 32 | 33 | setup( 34 | name=package_name, 35 | version=webcompy.__version__, 36 | description="Python frontend framework which works on Browser", 37 | long_description=(root_dir / "README.md").open("r", encoding="utf8").read(), 38 | long_description_content_type="text/markdown", 39 | url="https://github.com/kniwase/WebComPy", 40 | author="Kento Niwase", 41 | author_email="kento.niwase@outlook.com", 42 | license="MIT", 43 | keywords="browser,frontend,framework,front-end,client-side", 44 | packages=find_packages(exclude=["docs_src", "template"]), 45 | package_data={ 46 | "webcompy": [ 47 | "py.typed", 48 | *pyi_files, 49 | *template_files, 50 | ], 51 | }, 52 | install_requires=[ 53 | name.rstrip() 54 | for name in (root_dir / "requirements.txt") 55 | .open("r", encoding="utf8") 56 | .readlines() 57 | ], 58 | classifiers=[ 59 | "Programming Language :: Python :: 3.10", 60 | "Programming Language :: Python :: 3.9", 61 | "License :: OSI Approved :: MIT License", 62 | "Operating System :: OS Independent", 63 | ], 64 | ) 65 | -------------------------------------------------------------------------------- /webcompy/cli/_argparser.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | import sys 3 | from typing import Any, Literal 4 | 5 | 6 | def get_params() -> tuple[Literal["start", "generate", "init"], dict[str, Any]]: 7 | def _command(subcommand_name: str): 8 | return lambda: subcommand_name 9 | 10 | maincommand = "python -m webcompy" 11 | parser = ArgumentParser(prog=maincommand, add_help=True) 12 | subparsers = parser.add_subparsers() 13 | 14 | # start 15 | subcommand_name = "start" 16 | parser_start = subparsers.add_parser( 17 | subcommand_name, 18 | help=f"Starts HTTP server. See `{maincommand} {subcommand_name} --help` for options.", 19 | ) 20 | parser_start.add_argument( 21 | "--dev", 22 | action="store_true", 23 | help="launch dev server with hot-reload", 24 | ) 25 | parser_start.add_argument( 26 | "--port", 27 | type=int, 28 | help="server port", 29 | ) 30 | parser_start.set_defaults(__command_getter__=_command(subcommand_name)) 31 | 32 | # generate 33 | subcommand_name = "generate" 34 | parser_generate = subparsers.add_parser( 35 | subcommand_name, 36 | help=f"Generates static html files. See `{maincommand} {subcommand_name} --help` for options.", 37 | ) 38 | parser_generate.add_argument( 39 | "--dist", 40 | type=str, 41 | help="dist dir", 42 | ) 43 | parser_generate.set_defaults(__command_getter__=_command(subcommand_name)) 44 | 45 | # init 46 | subcommand_name = "init" 47 | parser_init = subparsers.add_parser( 48 | subcommand_name, 49 | help="Creates new project on current dir.", 50 | ) 51 | parser_init.set_defaults(__command_getter__=_command(subcommand_name)) 52 | 53 | # parse 54 | args = parser.parse_args() 55 | if hasattr(args, "__command_getter__"): 56 | subcommand_name = getattr(args, "__command_getter__")() 57 | args_dict = {n: getattr(args, n) for n in dir(args) if not n.startswith("_")} 58 | return subcommand_name, args_dict 59 | else: 60 | parser.print_help() 61 | sys.exit() 62 | -------------------------------------------------------------------------------- /webcompy/elements/typealias/_html_tag_names.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | from typing_extensions import TypeAlias 3 | 4 | 5 | HtmlTags: TypeAlias = Literal[ 6 | # Content sectioning 7 | "address", 8 | "article", 9 | "aside", 10 | "footer", 11 | "header", 12 | "h1", 13 | "h2", 14 | "h3", 15 | "h4", 16 | "h5", 17 | "h6", 18 | "main", 19 | "nav", 20 | "section", 21 | # Text content 22 | "blockquote", 23 | "dd", 24 | "div", 25 | "dl", 26 | "dt", 27 | "figcaption", 28 | "figure", 29 | "hr", 30 | "li", 31 | "menu", 32 | "ol", 33 | "p", 34 | "pre", 35 | "ul", 36 | # Inline text semantics 37 | "a", 38 | "abbr", 39 | "b", 40 | "bdi", 41 | "bdo", 42 | "cite", 43 | "code", 44 | "data", 45 | "dfn", 46 | "em", 47 | "i", 48 | "kbd", 49 | "mark", 50 | "q", 51 | "rp", 52 | "rt", 53 | "ruby", 54 | "s", 55 | "samp", 56 | "small", 57 | "span", 58 | "strong", 59 | "sub", 60 | "sup", 61 | "time", 62 | "u", 63 | "var", 64 | "wbr", 65 | # Image and multimedia 66 | "area", 67 | "audio", 68 | "img", 69 | "map", 70 | "track", 71 | "video", 72 | # Embedded content 73 | "embed", 74 | "iframe", 75 | "object", 76 | "param", 77 | "picture", 78 | "portal", 79 | "source", 80 | # SVG and MathML 81 | "svg", 82 | "math", 83 | # Scripting 84 | "canvas", 85 | # Demarcating edits 86 | "del", 87 | "ins", 88 | # Table content 89 | "caption", 90 | "col", 91 | "colgroup", 92 | "table", 93 | "tbody", 94 | "td", 95 | "tfoot", 96 | "th", 97 | "thead", 98 | "tr", 99 | # Forms 100 | "button", 101 | "datalist", 102 | "fieldset", 103 | "form", 104 | "input", 105 | "label", 106 | "legend", 107 | "meter", 108 | "optgroup", 109 | "option", 110 | "output", 111 | "progress", 112 | "select", 113 | "textarea", 114 | # Interactive elements 115 | "details", 116 | "dialog", 117 | "summary", 118 | ] 119 | -------------------------------------------------------------------------------- /webcompy/elements/_dom_objs.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Protocol 2 | 3 | 4 | class DOMNode(Protocol): 5 | __webcompy_node__: bool 6 | 7 | def __getattr__(self, name: str) -> Any: 8 | ... 9 | 10 | def __setattr__(self, name: str, obj: Any) -> Any: 11 | ... 12 | 13 | 14 | class DOMEvent(Protocol): 15 | def __getattr__(self, _: str) -> Any: 16 | ... 17 | 18 | @property 19 | def bubbles(self) -> bool: 20 | """indicates whether the given event bubbles up through the DOM or not""" 21 | ... 22 | 23 | @property 24 | def cancelable(self) -> bool: 25 | """indicates whether the event is cancelable or not""" 26 | ... 27 | 28 | @property 29 | def currentTarget(self) -> DOMNode: 30 | """identifies the current target for the event, as the event traverses the DOM. 31 | It always refers to the element the event handler has been attached to as opposed to 32 | event.target which identifies the element on which the event occurred.""" 33 | ... 34 | 35 | @property 36 | def defaultPrevented(self) -> bool: 37 | """indicating whether or not event.preventDefault() was called on the event""" 38 | ... 39 | 40 | @property 41 | def eventPhase(self) -> int: 42 | """indicates which phase of the event flow is currently being evaluated""" 43 | ... 44 | 45 | @property 46 | def target(self) -> DOMNode: 47 | """the object the event was dispatched on. 48 | It is different from event.currentTarget 49 | when the event handler is called in bubbling or capturing phase of the event""" 50 | ... 51 | 52 | @property 53 | def timeStamp(self) -> int: 54 | """the time (in milliseconds from the beginning of the current document's lifetime) 55 | at which the event was created""" 56 | ... 57 | 58 | @property 59 | def type(self) -> str: 60 | """contains the event type""" 61 | ... 62 | 63 | def preventDefault(self) -> None: 64 | """prevents the execution of the action associated by default to the event.""" 65 | ... 66 | 67 | def stopPropagation(self) -> None: 68 | """prevents further propagation of the current event.""" 69 | ... 70 | -------------------------------------------------------------------------------- /webcompy/router/_change_event_hander.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any, Literal 3 | from webcompy.reactive._base import ReactiveBase 4 | from webcompy._browser._modules import browser 5 | 6 | 7 | class Location(ReactiveBase[str]): 8 | __mode__: Literal["hash", "history"] 9 | _value: str 10 | _state: dict[str, Any] | None 11 | _base_url: str 12 | 13 | def __init__(self, mode: Literal["hash", "history"], base_url: str) -> None: 14 | super().__init__("") 15 | self._state = None 16 | self._base_url = base_url.strip().strip("/") 17 | self.set_mode(mode) 18 | if browser: 19 | browser.window.addEventListener( 20 | "popstate", 21 | browser.pyodide.create_proxy(self._refresh_path), 22 | False, 23 | ) 24 | 25 | @ReactiveBase._change_event 26 | def set_mode(self, mode: Literal["hash", "history"]): 27 | self.__mode__ = mode 28 | self._refresh_path() 29 | 30 | @property 31 | @ReactiveBase._get_evnet 32 | def value(self): 33 | return self._value 34 | 35 | @property 36 | @ReactiveBase._get_evnet 37 | def state(self): 38 | return self._state 39 | 40 | @ReactiveBase._change_event 41 | def __set_path__(self, path: str, state: dict[str, Any] | None): 42 | self._state = state 43 | if self.__mode__ == "hash" and path.startswith("#"): 44 | self._value = path[1:] 45 | else: 46 | self._value = path 47 | 48 | def _refresh_path(self, _: Any = None): 49 | if browser and self.__mode__ == "history": 50 | path: str = ( 51 | browser.window.location.pathname + browser.window.location.search 52 | ) 53 | elif browser and self.__mode__ == "hash": 54 | path: str = browser.window.location.hash 55 | else: 56 | path: str = "" 57 | if ( 58 | browser 59 | and hasattr(browser.window.history, "state") 60 | and browser.window.history.state 61 | ): 62 | self._state = browser.window.history.state.to_dict() 63 | else: 64 | self._state = None 65 | self.__set_path__(path, self._state) 66 | -------------------------------------------------------------------------------- /docs_src/templates/demo/fetch_sample.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | from webcompy.elements import html, repeat 3 | from webcompy.components import define_component, ComponentContext 4 | from webcompy.reactive import ReactiveList, Reactive 5 | from webcompy.aio import AsyncWrapper, resolve_async 6 | from webcompy.ajax import HttpClient 7 | from webcompy import logging 8 | from asyncio import Queue 9 | 10 | 11 | class User(TypedDict): 12 | id: int 13 | name: str 14 | 15 | 16 | @define_component 17 | def FetchSample(context: ComponentContext[None]): 18 | users = ReactiveList[User]([]) 19 | json_text = Reactive("") 20 | queue = Queue[str](maxsize=1) 21 | 22 | @AsyncWrapper() 23 | async def fetch_user_data(url: str): 24 | res = await HttpClient.get(url) 25 | logging.info(res) 26 | users.value = res.json()["data"] 27 | 28 | @AsyncWrapper() 29 | async def async_test(): 30 | res = await HttpClient.get("fetch_sample/sample.json") 31 | await queue.put(res.text) 32 | 33 | @context.on_after_rendering 34 | def _(): 35 | fetch_user_data("fetch_sample/sample.json") 36 | async_test() 37 | resolve_async(queue.get(), json_text.set_value) 38 | 39 | return html.DIV( 40 | {}, 41 | html.DIV( 42 | {}, 43 | html.H5( 44 | {}, 45 | "User Data", 46 | ), 47 | repeat( 48 | sequence=users, 49 | template=lambda user_data: html.DIV( 50 | {"class": "user-data"}, 51 | html.UL( 52 | {}, 53 | html.LI({}, "User ID: " + str(user_data["id"])), 54 | html.LI({}, "User Name: " + user_data["name"]), 55 | ), 56 | ), 57 | ), 58 | ), 59 | html.DIV( 60 | {}, 61 | html.H5( 62 | {}, 63 | "Response Data", 64 | ), 65 | html.PRE( 66 | {}, 67 | html.CODE( 68 | {}, 69 | json_text, 70 | ), 71 | ), 72 | ), 73 | ) 74 | 75 | 76 | FetchSample.scoped_style = { 77 | ".user-data": { 78 | "margin": "10px auto", 79 | "padding": "10px", 80 | "background-color": "#fafafa", 81 | "border-radius": "15px", 82 | }, 83 | } 84 | -------------------------------------------------------------------------------- /docs_src/bootstrap.py: -------------------------------------------------------------------------------- 1 | from webcompy.app import WebComPyApp 2 | from .router import router 3 | from .layout import Root 4 | 5 | 6 | app = WebComPyApp( 7 | root_component=Root, 8 | router=router, 9 | ) 10 | app.set_head( 11 | { 12 | "title": "WebComPy - Python Frontend Framework", 13 | "meta": { 14 | "charset": { 15 | "charset": "utf-8", 16 | }, 17 | "viewport": { 18 | "name": "viewport", 19 | "content": "width=device-width, initial-scale=1.0", 20 | }, 21 | "description": { 22 | "name": "description", 23 | "content": "WebComPy is Python frontend framework on Browser", 24 | }, 25 | "keywords": { 26 | "name": "keywords", 27 | "content": "python,framework,browser,frontend,client-side", 28 | }, 29 | "google-site-verification": { 30 | "name": "google-site-verification", 31 | "content": "qRIOGfRioPW7wInrUcunEcZZICOQK1VGZgsP-mlGicA", 32 | }, 33 | }, 34 | "link": [ 35 | { 36 | "rel": "stylesheet", 37 | "href": "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css", 38 | "integrity": "sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC", 39 | "crossorigin": "anonymous", 40 | }, 41 | { 42 | "rel": "stylesheet", 43 | "href": "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.1/styles/default.min.css", 44 | }, 45 | ], 46 | } 47 | ) 48 | app.append_script( 49 | { 50 | "type": "text/javascript", 51 | "src": "https://cdnjs.cloudflare.com/ajax/libs/eruda/2.4.1/eruda.min.js", 52 | }, 53 | in_head=True, 54 | ) 55 | app.append_script( 56 | {"type": "text/javascript"}, 57 | script="eruda.init();", 58 | ) 59 | app.append_script( 60 | { 61 | "type": "text/javascript", 62 | "src": "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js", 63 | "integrity": "sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM", 64 | "crossorigin": "anonymous", 65 | }, 66 | ) 67 | app.append_script( 68 | { 69 | "type": "text/javascript", 70 | "src": "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.1/highlight.min.js", 71 | }, 72 | ) 73 | -------------------------------------------------------------------------------- /webcompy/reactive/_list.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any, Callable, Iterable, List, TypeVar, cast, overload 3 | from webcompy.reactive._base import Reactive, ReactiveBase 4 | 5 | 6 | V = TypeVar("V") 7 | 8 | 9 | class ReactiveList(Reactive[List[V]]): 10 | def __init__(self, init_value: list[V]) -> None: 11 | super().__init__(init_value) 12 | 13 | @ReactiveBase._change_event 14 | def append(self, value: V): 15 | self._value.append(value) 16 | 17 | @ReactiveBase._change_event 18 | def extend(self, value: Iterable[V]): 19 | self._value.extend(value) 20 | 21 | @ReactiveBase._change_event 22 | def pop(self, index: int | None = None): 23 | return self._value.pop() if index is None else self._value.pop(index) 24 | 25 | @ReactiveBase._change_event 26 | def insert(self, index: int, value: V): 27 | self._value.insert(index, value) 28 | 29 | @ReactiveBase._change_event 30 | def sort(self, key: Callable[[V], Any] = lambda it: it, reverse: bool = False): 31 | self._value.sort(key=key, reverse=reverse) 32 | 33 | @ReactiveBase._get_evnet 34 | def index(self, value: V): 35 | return self._value.index(value) 36 | 37 | @ReactiveBase._get_evnet 38 | def count(self, value: V): 39 | return self._value.count(value) 40 | 41 | @ReactiveBase._change_event 42 | def remove(self, value: V): 43 | self._value.remove(value) 44 | 45 | @ReactiveBase._change_event 46 | def clear(self): 47 | self._value.clear() 48 | 49 | @ReactiveBase._change_event 50 | def reverse(self): 51 | self._value.reverse() 52 | 53 | @overload 54 | def __getitem__(self, idx: int) -> V: 55 | ... 56 | 57 | @overload 58 | def __getitem__(self, idx: slice) -> list[V]: 59 | ... 60 | 61 | @ReactiveBase._get_evnet 62 | def __getitem__(self, idx: int | slice): 63 | return self._value.__getitem__(idx) 64 | 65 | @overload 66 | def __setitem__(self, idx: int, value: V) -> None: 67 | ... 68 | 69 | @overload 70 | def __setitem__(self, idx: slice, value: Iterable[V]) -> None: 71 | ... 72 | 73 | @ReactiveBase._change_event 74 | def __setitem__(self, idx: int | slice, value: V | Iterable[V]): 75 | if isinstance(idx, int): 76 | self._value.__setitem__(idx, cast(V, value)) 77 | else: 78 | self._value.__setitem__(idx, cast(Iterable[V], value)) 79 | 80 | @ReactiveBase._get_evnet 81 | def __len__(self): 82 | return len(self._value) 83 | 84 | @ReactiveBase._get_evnet 85 | def __iter__(self): 86 | return iter(self._value) 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | parts/ 18 | sdist/ 19 | var/ 20 | wheels/ 21 | share/python-wheels/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | MANIFEST 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .nox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | *.py,cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | cover/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | db.sqlite3-journal 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | .pybuilder/ 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | # For a library or package, you might want to ignore these files since the code is 85 | # intended to run in multiple environments; otherwise, check them in: 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | 132 | # pytype static type analyzer 133 | .pytype/ 134 | 135 | # Cython debug symbols 136 | cython_debug/ 137 | -------------------------------------------------------------------------------- /webcompy/cli/_pyscript_wheel.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import shutil 4 | import sys 5 | from setuptools import setup, find_packages 6 | from tempfile import TemporaryDirectory 7 | from webcompy.cli._utils import external_cli_tool_wrapper 8 | from webcompy.cli._exception import WebComPyCliException 9 | from webcompy._version import __version__ as webcompy_version 10 | 11 | 12 | @external_cli_tool_wrapper 13 | def make_wheel(name: str, package_dir: pathlib.Path, dest: pathlib.Path, version: str): 14 | if not (package_dir_path := package_dir.absolute()).exists(): 15 | raise WebComPyCliException(f"Package dir '{package_dir}' does not exist") 16 | if not (dest_path := dest.absolute()).exists(): 17 | os.mkdir(dest_path) 18 | wheel_file_name = f"{name}-{version}-py3-none-any.whl" 19 | with TemporaryDirectory() as temp: 20 | temp = pathlib.Path(temp) 21 | wheel_temp = pathlib.Path(temp) 22 | cwd = pathlib.Path.cwd() 23 | os.chdir(temp) 24 | sys.argv.extend( 25 | [ 26 | "--no-user-cfg", 27 | "--quiet", 28 | "bdist_wheel", 29 | "--dist-dir", 30 | str(wheel_temp), 31 | ] 32 | ) 33 | try: 34 | wheel_temp_dest = wheel_temp / wheel_file_name 35 | if wheel_temp_dest.exists(): 36 | os.remove(wheel_temp_dest) 37 | packages = find_packages( 38 | where=str(package_dir_path.parent), 39 | include=[package_dir_path.name, f"{package_dir_path.name}.*"], 40 | exclude=["__pycache__"], 41 | ) 42 | setup( 43 | name=name, 44 | packages=packages, 45 | package_dir={ 46 | p: str(package_dir_path.parent / p.replace(".", "/")) 47 | for p in packages 48 | }, 49 | version=version, 50 | ) 51 | wheel_dest = dest_path / wheel_file_name 52 | if wheel_dest.exists(): 53 | os.remove(wheel_dest) 54 | wheel_file_path = tuple( 55 | it 56 | for it in wheel_temp.iterdir() 57 | if it.is_file() and it.name == wheel_file_name 58 | )[0] 59 | shutil.copy(wheel_file_path, wheel_dest) 60 | except Exception as error: 61 | raise error 62 | finally: 63 | os.chdir(cwd) 64 | 65 | 66 | def make_webcompy_app_package( 67 | dest: pathlib.Path, 68 | webcompy_package_dir: pathlib.Path, 69 | package_dir: pathlib.Path, 70 | app_version: str, 71 | ): 72 | make_wheel("webcompy", webcompy_package_dir, dest, webcompy_version) 73 | make_wheel("app", package_dir, dest, app_version) 74 | -------------------------------------------------------------------------------- /webcompy/elements/generators.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import ( 3 | Any, 4 | Callable, 5 | NewType, 6 | TypeVar, 7 | TypedDict, 8 | Union, 9 | ) 10 | from typing_extensions import TypeAlias 11 | from webcompy.elements.types._text import TextElement, NewLine 12 | from webcompy.elements.types._element import ElementBase, Element 13 | from webcompy.elements.types._refference import DomNodeRef 14 | from webcompy.elements.types._repeat import RepeatElement, MultiLineTextElement 15 | from webcompy.elements.types._switch import SwitchElement 16 | from webcompy.elements.typealias._html_tag_names import HtmlTags 17 | from webcompy.elements.typealias._element_property import ( 18 | AttrValue, 19 | EventHandler, 20 | ElementChildren, 21 | ) 22 | from webcompy.reactive import ReactiveBase 23 | 24 | 25 | T = TypeVar("T") 26 | 27 | EventKey = NewType("EventKey", str) 28 | _ref = NewType("DomNodeRefKey", str) 29 | noderef = _ref(":ref") 30 | 31 | 32 | def event(event_name: str): 33 | return EventKey(f"@{event_name}") 34 | 35 | 36 | def create_element( 37 | tag_name: HtmlTags, 38 | /, 39 | attributes: dict[str | EventKey | _ref, AttrValue | EventHandler | DomNodeRef], 40 | *children: ElementChildren, 41 | ) -> Element: 42 | attrs: dict[str, AttrValue] = {} 43 | events: dict[str, EventHandler] = {} 44 | ref: DomNodeRef | None = None 45 | for name, value in attributes.items(): 46 | if isinstance(value, DomNodeRef): 47 | if name == ":ref": 48 | ref = value 49 | elif name.startswith("@") and callable(value): 50 | events[name[1:]] = value 51 | else: 52 | attrs[name] = value 53 | return Element(tag_name, attrs, events, ref, children) 54 | 55 | 56 | ChildNode: TypeAlias = Union[ 57 | ElementBase, 58 | TextElement, 59 | MultiLineTextElement, 60 | NewLine, 61 | ReactiveBase[Any], 62 | str, 63 | None, 64 | ] 65 | NodeGenerator: TypeAlias = Callable[[], ChildNode] 66 | 67 | 68 | def repeat( 69 | sequence: ReactiveBase[list[T]], 70 | template: Callable[[T], ChildNode], 71 | ): 72 | return RepeatElement(sequence, template) 73 | 74 | 75 | class SwitchCase(TypedDict): 76 | case: ReactiveBase[Any] 77 | generator: NodeGenerator 78 | 79 | 80 | def switch( 81 | *cases: SwitchCase, 82 | default: NodeGenerator | None = None, 83 | ): 84 | return SwitchElement( 85 | [(case["case"], case["generator"]) for case in cases], 86 | default, 87 | ) 88 | 89 | 90 | def text(text: str | ReactiveBase[Any], enable_multiline: bool = True): 91 | if enable_multiline: 92 | return MultiLineTextElement(text) 93 | else: 94 | return TextElement(text) 95 | 96 | 97 | def break_line(): 98 | return NewLine() 99 | -------------------------------------------------------------------------------- /webcompy/cli/_generate.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | import os 3 | import pathlib 4 | import shutil 5 | from webcompy.cli._argparser import get_params 6 | from webcompy.cli._pyscript_wheel import make_webcompy_app_package 7 | from webcompy.cli._html import generate_html 8 | from webcompy.cli._utils import ( 9 | get_app, 10 | get_config, 11 | get_webcompy_packge_dir, 12 | generate_app_version, 13 | ) 14 | from webcompy.cli._static_files import get_static_files 15 | 16 | 17 | def generate_static_site(): 18 | config = get_config() 19 | app = get_app(config) 20 | _, args = get_params() 21 | config = get_config() 22 | dist = config.dist if args.get("dist") is None else args["dist"] 23 | app_version = generate_app_version() 24 | 25 | dist_dir = pathlib.Path(dist).absolute() 26 | if dist_dir.exists(): 27 | shutil.rmtree(dist_dir) 28 | os.mkdir(dist_dir) 29 | 30 | nojekyll_path = dist_dir / ".nojekyll" 31 | nojekyll_path.touch() 32 | print(nojekyll_path) 33 | 34 | static_files_dir = config.static_files_dir_path.absolute() 35 | for relative_path in get_static_files(static_files_dir): 36 | src = static_files_dir / relative_path 37 | dst = dist_dir / relative_path 38 | if not (parent := dst.parent).exists(): 39 | os.makedirs(parent) 40 | shutil.copy(src, dst) 41 | print(dst) 42 | 43 | scripts_dir = dist_dir / "_webcompy-app-package" 44 | os.mkdir(scripts_dir) 45 | make_webcompy_app_package( 46 | scripts_dir, 47 | get_webcompy_packge_dir(), 48 | config.app_package_path, 49 | app_version, 50 | ) 51 | for p in scripts_dir.iterdir(): 52 | print(p) 53 | 54 | html_generator = partial(generate_html, config, False, True, app_version) 55 | if app.__component__.router_mode == "history" and app.__component__.routes: 56 | for p, _, _, _, page in app.__component__.routes: 57 | if path_params := page.get("path_params"): 58 | paths = {p.format(**params) for params in path_params} 59 | else: 60 | paths = {p} 61 | for path in paths: 62 | if not (path_dir := dist_dir / path).exists(): 63 | os.makedirs(path_dir) 64 | app.__component__.set_path(path) 65 | html = html_generator(app) 66 | html_path = path_dir / "index.html" 67 | html_path.open("w", encoding="utf8").write(html) 68 | print(html_path) 69 | app.__component__.set_path("//:404://") 70 | html = html_generator(app) 71 | html_path = dist_dir / "404.html" 72 | html_path.open("w", encoding="utf8").write(html) 73 | print(html_path) 74 | else: 75 | app.__component__.set_path("/") 76 | html = html_generator(app) 77 | html_path = dist_dir / "index.html" 78 | html_path.open("w", encoding="utf8").write(html) 79 | print(html_path) 80 | 81 | print("done") 82 | -------------------------------------------------------------------------------- /webcompy/elements/types/_repeat.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from functools import partial 3 | from itertools import chain 4 | from typing import Any, Callable, List, TypeVar 5 | from webcompy.reactive import ReactiveBase, computed 6 | from webcompy.elements.types._text import NewLine 7 | from webcompy.elements.typealias._element_property import ElementChildren 8 | from webcompy.exception import WebComPyException 9 | from webcompy.elements.types._dynamic import DynamicElement 10 | from webcompy._browser._modules import browser 11 | 12 | 13 | T = TypeVar("T") 14 | 15 | 16 | class RepeatElement(DynamicElement): 17 | _index_map: list[tuple[Any, tuple[int, int]]] 18 | 19 | def __init__( 20 | self, 21 | sequence: ReactiveBase[List[T]], 22 | template: Callable[[T], ElementChildren], 23 | ) -> None: 24 | self._template = template 25 | self._sequence = sequence 26 | self._reactive_activated = False 27 | 28 | if not isinstance(self._sequence, ReactiveBase): # type: ignore 29 | raise ValueError("Argument 'sequence' must be Reactive Object.") 30 | super().__init__() 31 | 32 | def _on_set_parent(self): 33 | if not browser: 34 | self._children = self._generate_children() 35 | 36 | def _generate_children(self): 37 | return list( 38 | filter( 39 | None, 40 | map( 41 | partial(self._create_child_element, self._parent, None), 42 | map(self._template, self._sequence.value), 43 | ), 44 | ) 45 | ) 46 | 47 | def _render(self): 48 | self._refresh() 49 | if not self._reactive_activated: 50 | self._reactive_activated = True 51 | self._set_callback_id(self._sequence.on_after_updating(self._refresh)) 52 | 53 | def _refresh(self, *args: Any): 54 | parent_node = self._parent._get_node() 55 | if not parent_node: 56 | raise WebComPyException( 57 | f"'{self.__class__.__name__}' does not have its parent." 58 | ) 59 | for _ in range(len(self._children)): 60 | self._children.pop(-1)._remove_element() 61 | self._children = self._generate_children() 62 | for c_idx, child in enumerate(self._children): 63 | child._node_idx = self._node_idx + c_idx 64 | child._render() 65 | self._parent._re_index_children(False) 66 | 67 | 68 | class MultiLineTextElement(RepeatElement): 69 | def __init__(self, text: str | ReactiveBase[Any]) -> None: 70 | super().__init__( 71 | computed( 72 | lambda: list( 73 | chain.from_iterable( 74 | map( 75 | lambda line: (line, NewLine()), 76 | str( 77 | text.value if isinstance(text, ReactiveBase) else text 78 | ).split("\n"), 79 | ) 80 | ) 81 | )[:-1] 82 | ), 83 | lambda s: s, 84 | ) 85 | -------------------------------------------------------------------------------- /docs_src/templates/home.py: -------------------------------------------------------------------------------- 1 | from webcompy.elements import html 2 | from webcompy.components import define_component, ComponentContext 3 | from ..components.syntax_highlighting import SyntaxHighlighting 4 | 5 | 6 | @define_component 7 | def Home(_: ComponentContext[None]): 8 | return html.DIV( 9 | {"class": "container"}, 10 | html.SECTION( 11 | {"class": "content"}, 12 | html.H2( 13 | {"class": "heading"}, 14 | "What is WebComPy", 15 | ), 16 | html.DIV( 17 | {"class": "body"}, 18 | "WebComPy is Python frontend framework for PyScript, which has following features.", 19 | html.UL( 20 | {}, 21 | html.LI({}, "Component-based declarative rendering"), 22 | html.LI({}, "Automatic DOM refreshing"), 23 | html.LI({}, "Built-in router"), 24 | html.LI( 25 | {}, 26 | "CLI tools (Project template, Build-in HTTP server, Static Site Generator)", 27 | ), 28 | html.LI({}, "Type Annotation"), 29 | ), 30 | ), 31 | ), 32 | html.SECTION( 33 | {"class": "content"}, 34 | html.H2( 35 | {"class": "heading"}, 36 | "Get started", 37 | ), 38 | html.DIV( 39 | {"class": "body"}, 40 | "Run following commands to initilize a new project.", 41 | SyntaxHighlighting( 42 | { 43 | "lang": "bash", 44 | "code": """ 45 | mkdir webcompy-project 46 | cd webcompy-project 47 | pip install webcompy 48 | python -m webcompy init 49 | python -m webcompy start --dev 50 | """, 51 | } 52 | ), 53 | ), 54 | ), 55 | html.SECTION( 56 | {"class": "content"}, 57 | html.H2( 58 | {"class": "heading"}, 59 | "Source Code", 60 | ), 61 | html.DIV( 62 | {"class": "body"}, 63 | html.A( 64 | {"href": "https://github.com/kniwase/WebComPy"}, 65 | "Project Home", 66 | ), 67 | ), 68 | ), 69 | ) 70 | 71 | 72 | Home.scoped_style = { 73 | ".container": { 74 | "margin": "2px auto", 75 | "padding": "5px 5px", 76 | }, 77 | ".container .content": { 78 | "margin": "10px auto", 79 | "padding": "10px", 80 | "background-color": "#fafafa", 81 | "border-radius": "15px", 82 | }, 83 | ".container .content .body": { 84 | "margin": "10px auto", 85 | }, 86 | ".container .content .heading": { 87 | "padding": "5px", 88 | "border-bottom": "double 3px black", 89 | "font-size": "20px", 90 | }, 91 | } 92 | -------------------------------------------------------------------------------- /docs_src/templates/demo/matplotlib_sample.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from io import BytesIO 3 | import numpy as np 4 | from matplotlib import pyplot as plt 5 | from webcompy.elements import html, DOMEvent, DomNodeRef 6 | from webcompy.components import define_component, ComponentContext 7 | from webcompy.reactive import Reactive, computed 8 | 9 | 10 | @define_component 11 | def MatpoltlibSample(context: ComponentContext[None]): 12 | input_ref = DomNodeRef() 13 | 14 | fig, ax = plt.subplots() 15 | x = np.linspace(-5, 5, 250) # type: ignore 16 | (line,) = ax.plot(x, np.array([0 for _ in x])) # type: ignore 17 | 18 | count = Reactive(15) 19 | 20 | def on_change(ev: DOMEvent): 21 | count.value = int(input_ref.value) 22 | 23 | def add(ev: DOMEvent): 24 | if count.value < 30: 25 | count.value += 1 26 | input_ref.value = str(count.value) 27 | 28 | def pop(ev: DOMEvent): 29 | if count.value > 1: 30 | count.value -= 1 31 | input_ref.value = str(count.value) 32 | 33 | calc_square_wave = np.vectorize( 34 | lambda x: np.vectorize(lambda k: (1 / (2 * k + 1)) * np.sin((2 * k + 1) * x))( 35 | np.arange(count.value) # type: ignore 36 | ).sum() 37 | ) 38 | 39 | @computed 40 | def fig_data(): 41 | line.set_data(x, calc_square_wave(x)) 42 | ax.set_ylim(-2, 2) 43 | fig.canvas.draw() 44 | buffer = BytesIO() 45 | fig.savefig(buffer, format="png") 46 | return "data:image/png;base64,{}".format( 47 | base64.b64encode(buffer.getvalue()).decode() 48 | ) 49 | 50 | return html.DIV( 51 | {}, 52 | html.H5( 53 | {}, 54 | "Square Wave", 55 | ), 56 | html.P( 57 | {}, 58 | "Value: ", 59 | count, 60 | ), 61 | html.P( 62 | {}, 63 | html.INPUT( 64 | { 65 | "@change": on_change, 66 | ":ref": input_ref, 67 | "type": "range", 68 | "min": 1, 69 | "max": 30, 70 | "step": 1, 71 | "value": count, 72 | } 73 | ), 74 | ), 75 | html.P( 76 | {}, 77 | html.BUTTON( 78 | {"@click": add}, 79 | "+", 80 | ), 81 | html.BUTTON( 82 | {"@click": pop}, 83 | "-", 84 | ), 85 | ), 86 | html.IMG( 87 | {"src": fig_data}, 88 | ), 89 | ) 90 | 91 | 92 | MatpoltlibSample.scoped_style = { 93 | "button": { 94 | "display": "inline-block", 95 | "text-decoration": "none", 96 | "border": "solid 2px #668ad8", 97 | "border-radius": "3px", 98 | "transition": "0.2s", 99 | "color": "black", 100 | "width": "30px", 101 | }, 102 | "button:hover": { 103 | "background": "#668ad8", 104 | "color": "white", 105 | }, 106 | "input, img": { 107 | "width": "100%", 108 | "max-width": "600px", 109 | "height": "auto", 110 | }, 111 | } 112 | -------------------------------------------------------------------------------- /webcompy/elements/types/_text.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any, Union, cast 3 | from webcompy.reactive._base import ReactiveBase 4 | from webcompy._browser._modules import browser 5 | from webcompy.elements.types._abstract import ElementAbstract 6 | from webcompy.elements._dom_objs import DOMNode 7 | from webcompy.exception import WebComPyException 8 | 9 | 10 | class NewLine(ElementAbstract): 11 | def __init__(self) -> None: 12 | super().__init__() 13 | 14 | def _init_node(self) -> DOMNode: 15 | if browser: 16 | node: DOMNode | None = None 17 | existing_node = self._get_existing_node() 18 | if existing_node: 19 | if ( 20 | getattr(existing_node, "__webcompy_prerendered_node__", False) 21 | and existing_node.nodeName.lower() == "br" 22 | ): 23 | node = existing_node 24 | self._mounted = True 25 | else: 26 | existing_node.remove() 27 | if not node: 28 | node = cast(DOMNode, browser.document.createElement("br")) 29 | node.__webcompy_node__ = True 30 | return node 31 | else: 32 | raise WebComPyException("Not in Browser environment.") 33 | 34 | def _render_html( 35 | self, newline: bool = False, indent: int = 2, count: int = 0 36 | ) -> str: 37 | if newline: 38 | return (" " * indent * count) + "
" 39 | else: 40 | return "
" 41 | 42 | 43 | class TextElement(ElementAbstract): 44 | def __init__(self, text: Union[str, ReactiveBase[Any]]) -> None: 45 | self._text = text 46 | super().__init__() 47 | if isinstance(self._text, ReactiveBase): 48 | self._set_callback_id(self._text.on_after_updating(self._update_text)) 49 | 50 | def _get_text(self) -> str: 51 | if isinstance(self._text, ReactiveBase): 52 | value = self._text.value 53 | text = value if isinstance(value, str) else str(value) 54 | else: 55 | text = self._text 56 | return text 57 | 58 | def _init_node(self) -> DOMNode: 59 | if browser: 60 | existing_node = self._get_existing_node() 61 | if existing_node: 62 | if ( 63 | getattr(existing_node, "__webcompy_prerendered_node__", False) 64 | and existing_node.nodeName.lower() == "#text" 65 | ): 66 | existing_node.remove() 67 | node = browser.document.createTextNode(self._get_text()) 68 | node.__webcompy_node__ = True 69 | return node 70 | else: 71 | raise WebComPyException("Not in Browser environment.") 72 | 73 | def _update_text(self, new_text: str): 74 | if browser: 75 | node = self._get_node() 76 | if node: 77 | node.textContent = new_text 78 | else: 79 | self._text = new_text 80 | 81 | def _render_html( 82 | self, newline: bool = False, indent: int = 2, count: int = 0 83 | ) -> str: 84 | if newline: 85 | return (" " * indent * count) + self._get_text() 86 | else: 87 | return self._get_text() 88 | -------------------------------------------------------------------------------- /webcompy/cli/_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from datetime import datetime 3 | from importlib import import_module 4 | import os 5 | import pathlib 6 | import sys 7 | from typing import Callable, TypeVar 8 | from typing_extensions import ParamSpec 9 | from webcompy.cli._config import WebComPyConfig 10 | from webcompy.cli._exception import WebComPyCliException 11 | from webcompy.app._app import WebComPyApp 12 | 13 | 14 | def get_config() -> WebComPyConfig: 15 | try: 16 | webcompy_config = import_module("webcompy_config") 17 | except ModuleNotFoundError: 18 | raise WebComPyCliException( 19 | "No python module named 'webcompy_config'", 20 | ) 21 | configs = tuple( 22 | it 23 | for name in dir(webcompy_config) 24 | if isinstance(it := getattr(webcompy_config, name), WebComPyConfig) 25 | ) 26 | if len(configs) == 0: 27 | raise WebComPyCliException( 28 | "No WebComPyConfig instance in 'webcompy_config.py'", 29 | ) 30 | elif len(configs) == 0: 31 | raise WebComPyCliException( 32 | "Multiple WebComPyConfig instances in 'webcompy_config.py'" 33 | ) 34 | else: 35 | config = configs[0] 36 | return config 37 | 38 | 39 | def get_app(config: WebComPyConfig) -> WebComPyApp: 40 | try: 41 | import_module(config.app_package_path.name) 42 | except ModuleNotFoundError: 43 | raise WebComPyCliException( 44 | f"No python module named '{config.app_package_path.name}'", 45 | ) 46 | try: 47 | bootstrap = import_module(config.app_package_path.name + ".bootstrap") 48 | except AttributeError: 49 | raise WebComPyCliException( 50 | f"No python module named 'bootstrap' in '{config.app_package_path.name}'", 51 | ) 52 | app_instances = tuple( 53 | it 54 | for name in dir(bootstrap) 55 | if isinstance(it := getattr(bootstrap, name), WebComPyApp) 56 | ) 57 | if len(app_instances) == 0: 58 | raise WebComPyCliException( 59 | "No WebComPyApp instance in 'bootstrap.py'", 60 | ) 61 | elif len(app_instances) == 0: 62 | raise WebComPyCliException( 63 | "Multiple WebComPyApp instances in 'bootstrap.py'", 64 | ) 65 | else: 66 | app = app_instances[0] 67 | return app 68 | 69 | 70 | def get_webcompy_packge_dir(path: pathlib.Path | None = None) -> pathlib.Path: 71 | if path is None: 72 | path = pathlib.Path(__file__) 73 | if path.is_dir() and path.name == "webcompy": 74 | return path.absolute() 75 | else: 76 | return get_webcompy_packge_dir(path.parent) 77 | 78 | 79 | P = ParamSpec("P") 80 | T = TypeVar("T") 81 | 82 | 83 | def external_cli_tool_wrapper(func: Callable[P, T]) -> Callable[P, T]: 84 | def inner(*args: P.args, **kwargs: P.kwargs): 85 | cwd_ori = pathlib.Path.cwd() 86 | argv_ori = tuple(sys.argv[1:]) 87 | for _ in range(1, len(sys.argv)): 88 | sys.argv.pop(1) 89 | ret = func(*args, **kwargs) 90 | for _ in range(1, len(sys.argv)): 91 | sys.argv.pop(1) 92 | for arg in argv_ori: 93 | sys.argv.append(arg) 94 | os.chdir(cwd_ori) 95 | return ret 96 | 97 | return inner 98 | 99 | 100 | def generate_app_version(): 101 | now = datetime.now() 102 | return "{}.{}.{}".format( 103 | now.strftime("%y"), 104 | now.strftime("%j"), 105 | (int(now.strftime("%H")) * 60 + int(now.strftime("%M"))) * 60 106 | + int(now.strftime("%S")), 107 | ) 108 | -------------------------------------------------------------------------------- /docs_src/templates/demo/todo.py: -------------------------------------------------------------------------------- 1 | from typing import Any, TypedDict 2 | from webcompy.elements import html, repeat, DomNodeRef 3 | from webcompy.components import define_component, ComponentContext 4 | from webcompy.reactive import Reactive, ReactiveList, computed 5 | 6 | 7 | class TodoData(TypedDict): 8 | title: Reactive[str] 9 | done: Reactive[bool] 10 | 11 | 12 | @define_component 13 | def ToDoItem(context: ComponentContext[TodoData]): 14 | input_ref = DomNodeRef() 15 | 16 | def on_change_state(_: Any): 17 | context.props["done"].value = input_ref.checked 18 | 19 | return html.LI( 20 | {}, 21 | html.LABEL( 22 | {}, 23 | html.INPUT( 24 | { 25 | "type": "checkbox", 26 | "@change": on_change_state, 27 | ":ref": input_ref, 28 | }, 29 | ), 30 | ), 31 | " ", 32 | html.SPAN( 33 | { 34 | "style": computed( 35 | lambda: "text-decoration: line-through;" 36 | if context.props["done"].value 37 | else "" 38 | ) 39 | }, 40 | context.props["title"], 41 | ), 42 | ) 43 | 44 | 45 | ToDoItem.scoped_style = { 46 | "li": { 47 | "color": "#2d8fdd", 48 | "border-left": " solid 6px #2d8fdd", 49 | "background": "#f1f8ff", 50 | "line-height": "1.5", 51 | "margin": "5px", 52 | "padding": "5px", 53 | "vertical-align": "middle", 54 | "list-style-type": "none", 55 | } 56 | } 57 | 58 | 59 | @define_component 60 | def ToDoList(_: ComponentContext[None]): 61 | input_ref = DomNodeRef() 62 | data: ReactiveList[TodoData] = ReactiveList( 63 | [ 64 | { 65 | "title": Reactive("Try WebComPy"), 66 | "done": Reactive(False), 67 | }, 68 | { 69 | "title": Reactive("Create WebComPy project"), 70 | "done": Reactive(False), 71 | } 72 | ] 73 | ) 74 | 75 | def append_item(_: Any): 76 | title = input_ref.value 77 | if title: 78 | data.append( 79 | { 80 | "title": Reactive(title), 81 | "done": Reactive(False), 82 | } 83 | ) 84 | input_ref.value = "" 85 | 86 | def remove_done_items(_: Any): 87 | items_remove = reversed( 88 | [idx for idx, item in enumerate(data.value) if item["done"].value] 89 | ) 90 | for idx in items_remove: 91 | data.pop(idx) 92 | 93 | return html.DIV( 94 | {}, 95 | html.P( 96 | {}, 97 | "Title: ", 98 | html.INPUT({":ref": input_ref}), 99 | html.BUTTON({"@click": append_item}, "Add ToDo"), 100 | html.BUTTON({"@click": remove_done_items}, "Remove Done Items"), 101 | ), 102 | html.UL( 103 | {}, 104 | repeat( 105 | sequence=data, 106 | template=ToDoItem, 107 | ), 108 | ), 109 | ) 110 | 111 | 112 | ToDoList.scoped_style = { 113 | "button": { 114 | "display": "inline-block", 115 | "text-decoration": "none", 116 | "border": "solid 2px #668ad8", 117 | "border-radius": "3px", 118 | "transition": "0.2s", 119 | "color": "black", 120 | }, 121 | "button:hover": { 122 | "background": "#668ad8", 123 | "color": "white", 124 | }, 125 | } 126 | -------------------------------------------------------------------------------- /webcompy/cli/template_data/app/components/fizzbuzz.py: -------------------------------------------------------------------------------- 1 | from webcompy.reactive import Reactive, computed_property, computed 2 | from webcompy.elements import html, repeat, switch 3 | from webcompy.components import ( 4 | define_component, 5 | ComponentContext, 6 | TypedComponentBase, 7 | component_class, 8 | on_before_rendering, 9 | component_template, 10 | ) 11 | from webcompy.router import RouterContext 12 | from webcompy.elements import DOMEvent 13 | 14 | 15 | @define_component 16 | def FizzbuzzList(context: ComponentContext[Reactive[int]]): 17 | @computed 18 | def numbers(): 19 | li: list[str] = [] 20 | for n in range(1, context.props.value + 1): 21 | if n % 15 == 0: 22 | li.append("FizzBuzz") 23 | elif n % 5 == 0: 24 | li.append("Fizz") 25 | elif n % 3 == 0: 26 | li.append("Buzz") 27 | else: 28 | li.append(str(n)) 29 | return li 30 | 31 | return html.DIV( 32 | {}, 33 | html.UL( 34 | {}, 35 | repeat(numbers, lambda s: html.LI({}, s)), 36 | ), 37 | ) 38 | 39 | 40 | FizzbuzzList.scoped_style = { 41 | "ul": { 42 | "border": "dashed 2px #668ad8", 43 | "background": "#f1f8ff", 44 | "padding": "0.5em 0.5em 0.5em 2em", 45 | }, 46 | "ul > li:nth-child(3n)": { 47 | "color": "red", 48 | }, 49 | "ul > li:nth-child(5n)": { 50 | "color": "blue", 51 | }, 52 | "ul > li:nth-child(15n)": { 53 | "color": "purple", 54 | }, 55 | } 56 | 57 | 58 | @component_class 59 | class Fizzbuzz(TypedComponentBase(props_type=RouterContext)): 60 | def __init__(self) -> None: 61 | self.context.set_title("FizzBuzz - WebCompy Template") 62 | 63 | self.opened = Reactive(True) 64 | self.count = Reactive(10) 65 | 66 | @computed_property 67 | def toggle_button_text(self): 68 | return "Hide" if self.opened.value else "Open" 69 | 70 | @on_before_rendering 71 | def on_before_rendering(self): 72 | self.count.value = 10 73 | 74 | def add(self, ev: DOMEvent): 75 | self.count.value += 1 76 | 77 | def pop(self, ev: DOMEvent): 78 | if self.count.value > 0: 79 | self.count.value -= 1 80 | 81 | def toggle(self, ev: DOMEvent): 82 | self.opened.value = not self.opened.value 83 | 84 | @component_template 85 | def template(self): 86 | return html.DIV( 87 | {}, 88 | html.H2( 89 | {}, 90 | "FizzBuzz", 91 | ), 92 | html.P( 93 | {}, 94 | html.BUTTON( 95 | {"@click": self.toggle}, 96 | self.toggle_button_text, 97 | ), 98 | html.BUTTON( 99 | {"@click": self.add}, 100 | "Add", 101 | ), 102 | html.BUTTON( 103 | {"@click": self.pop}, 104 | "Pop", 105 | ), 106 | ), 107 | html.P( 108 | {}, 109 | "Count: ", 110 | self.count, 111 | ), 112 | switch( 113 | { 114 | "case": self.opened, 115 | "generator": lambda: FizzbuzzList(props=self.count), 116 | }, 117 | default=lambda: html.DIV( 118 | {}, 119 | "FizzBuzz Hidden", 120 | ), 121 | ), 122 | ) 123 | -------------------------------------------------------------------------------- /webcompy/aio/_aio.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from traceback import TracebackException 3 | from re import compile as re_complie, escape as re_escape 4 | from typing import Any, Callable, Coroutine, Generic, TypeVar, Union 5 | from typing_extensions import ParamSpec, TypeAlias 6 | from webcompy._browser._modules import browser 7 | from webcompy.reactive._base import ReactiveBase 8 | from webcompy import logging 9 | 10 | AsysncResolver: TypeAlias = Callable[[Coroutine[Any, Any, Any]], None] 11 | 12 | if browser: 13 | aio_run: AsysncResolver = ( 14 | browser.pyodide.webloop.WebLoop().run_until_complete 15 | ) 16 | else: 17 | import asyncio 18 | 19 | aio_run: AsysncResolver = asyncio.run 20 | 21 | 22 | A = ParamSpec("A") 23 | T = TypeVar("T") 24 | 25 | 26 | _package_name = "/webcompy/" 27 | _filepath_in_package = _package_name + __file__.split(_package_name)[-1] 28 | _is_traceback_in_this_file = re_complie( 29 | r'\s+File\s+".+' + re_escape(_filepath_in_package) + r'",\s+line\s+[0-9]+,\s+in\s+' 30 | ).match 31 | 32 | 33 | def _log_error(error: Exception): 34 | logging.error( 35 | "".join( 36 | row 37 | for row in TracebackException.from_exception(error).format() 38 | if not _is_traceback_in_this_file(row) 39 | ) 40 | ) 41 | 42 | 43 | # Async 44 | def resolve_async( 45 | coroutine: Coroutine[Any, Any, T], 46 | on_done: Callable[[T], Any] | None = None, 47 | on_error: Callable[[Exception], Any] | None = _log_error, 48 | ): 49 | async def resolve( 50 | coroutine: Coroutine[Any, Any, T], 51 | resolver: Callable[[T], None] | None, 52 | error: Callable[[Exception], None] | None, 53 | ) -> None: 54 | try: 55 | ret = await coroutine 56 | if resolver is not None: 57 | resolver(ret) 58 | except Exception as err: 59 | if error is not None: 60 | error(err) 61 | 62 | aio_run(resolve(coroutine, on_done, on_error)) 63 | 64 | 65 | class AsyncWrapper(Generic[T]): 66 | def __init__( 67 | self, 68 | resolver: Callable[[T], Any] | None = None, 69 | error: Callable[[Exception], Any] | None = _log_error, 70 | ) -> None: 71 | self.resolver = resolver 72 | self.error = error 73 | 74 | def __call__(self, async_callable: Callable[A, Coroutine[Any, Any, T]]): 75 | def inner(*args: A.args, **kwargs: A.kwargs) -> None: 76 | resolve_async(async_callable(*args, **kwargs), self.resolver, self.error) 77 | 78 | return inner 79 | 80 | 81 | class AsyncComputed(ReactiveBase[Union[T, None]]): 82 | _done: bool 83 | _exception: Exception | None 84 | 85 | def __init__( 86 | self, 87 | coroutine: Coroutine[Any, Any, T], 88 | ) -> None: 89 | super().__init__(None) 90 | self._done = False 91 | self._exception = None 92 | resolve_async(coroutine, self._resolver, self._error) 93 | 94 | @ReactiveBase._change_event 95 | def _resolver(self, res: T): 96 | self._done = True 97 | self._value = res 98 | 99 | @ReactiveBase._change_event 100 | def _error(self, err: Exception): 101 | self._done = False 102 | self._exception = err 103 | 104 | @property 105 | @ReactiveBase._get_evnet 106 | def value(self) -> T | None: 107 | return self._value 108 | 109 | @property 110 | @ReactiveBase._get_evnet 111 | def error(self) -> Exception | None: 112 | return self._exception 113 | 114 | @property 115 | @ReactiveBase._get_evnet 116 | def done(self) -> bool: 117 | return self._done 118 | -------------------------------------------------------------------------------- /webcompy/elements/types/_abstract.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from abc import abstractmethod 3 | from typing import cast 4 | from webcompy.elements._dom_objs import DOMNode 5 | from webcompy.reactive._base import ReactiveStore 6 | from webcompy.reactive._container import ReactiveReceivable 7 | from webcompy._browser._modules import browser 8 | from webcompy.exception import WebComPyException 9 | 10 | 11 | class ElementAbstract(ReactiveReceivable): 12 | _node_idx: int 13 | _node_cache: DOMNode | None = None 14 | _mounted: bool | None = None 15 | _remount_to: DOMNode | None = None 16 | _callback_ids: set[int] 17 | __parent: ElementAbstract 18 | 19 | def __init__(self) -> None: 20 | self._node_cache = None 21 | self._mounted = None 22 | self._remount_to = None 23 | self._callback_ids: set[int] = set() 24 | 25 | @property 26 | def _parent(self) -> "ElementAbstract": 27 | return self.__parent 28 | 29 | @_parent.setter 30 | def _parent(self, parent: "ElementAbstract"): 31 | self.__parent = parent 32 | 33 | def _render(self): 34 | self._mount_node() 35 | 36 | def _mount_node(self): 37 | if not self._mounted and (node := self._get_node()): 38 | parent_node = self._parent._get_node() 39 | if self._mounted is None: 40 | if parent_node.childNodes.length <= self._node_idx: 41 | parent_node.appendChild(node) 42 | else: 43 | next_node = parent_node.childNodes[self._node_idx] 44 | parent_node.insertBefore(node, next_node) 45 | elif not self._mounted and self._remount_to: 46 | parent_node.replaceChild(node, self._remount_to) 47 | self._remount_to = None 48 | self._mounted = True 49 | 50 | def _detach_node(self): 51 | if browser and self._node_cache: 52 | parent_node = self._parent._get_node() 53 | self._remount_to = cast(DOMNode, browser.document.createTextNode("")) 54 | parent_node.replaceChild(self._remount_to, self._node_cache) 55 | self._mounted = False 56 | else: 57 | raise WebComPyException("Not in Browser environment.") 58 | 59 | @abstractmethod 60 | def _init_node(self) -> DOMNode: 61 | ... 62 | 63 | def _set_callback_id(self, callback_id: int): 64 | self._callback_ids.add(callback_id) 65 | 66 | def _remove_element(self, recursive: bool = True, remove_node: bool = True): 67 | for callback_id in self._callback_ids: 68 | ReactiveStore.remove_callback(callback_id) 69 | if remove_node: 70 | node = self._get_node() 71 | if node: 72 | node.remove() 73 | self._clear_node_cache(False) 74 | self.__purge_reactive_members__() 75 | del self 76 | 77 | @property 78 | def _node_count(self) -> int: 79 | return 1 80 | 81 | def _get_node(self) -> DOMNode: 82 | if not self._node_cache: 83 | self._node_cache = self._init_node() 84 | return self._node_cache 85 | 86 | def _clear_node_cache(self, recursive: bool = True): 87 | self._node_cache = None 88 | 89 | def _get_existing_node(self) -> DOMNode | None: 90 | parent_node = self._parent._get_node() 91 | if parent_node.childNodes.length > self._node_idx: 92 | existing_node: DOMNode = parent_node.childNodes[self._node_idx] 93 | return existing_node 94 | return None 95 | 96 | @abstractmethod 97 | def _render_html( 98 | self, newline: bool = False, indent: int = 2, count: int = 0 99 | ) -> str: 100 | ... 101 | -------------------------------------------------------------------------------- /webcompy/components/_abstract.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import hashlib 3 | from typing import ( 4 | Any, 5 | Callable, 6 | ClassVar, 7 | Final, 8 | Generic, 9 | NoReturn, 10 | Type, 11 | TypeVar, 12 | final, 13 | overload, 14 | ) 15 | from typing_extensions import TypeAlias 16 | from re import compile as re_compile 17 | from webcompy.components._libs import ( 18 | ClassStyleComponentContenxt, 19 | ComponentProperty, 20 | WebComPyComponentException, 21 | ) 22 | from webcompy.reactive._container import ReactiveReceivable 23 | 24 | 25 | _camel_to_kebab_pattern: Final = re_compile("((?<=[a-z0-9])[A-Z]|(?!^)[A-Z](?=[a-z]))") 26 | _combinator_pattern: Final = re_compile(r"\s*,\s*|\s*>\s*|\s*\+\s*|\s*~[^=]\s*|\s* \s*") 27 | 28 | PropsType = TypeVar("PropsType") 29 | 30 | 31 | class ComponentAbstract(ReactiveReceivable, Generic[PropsType]): 32 | __webcompy_component_id__: ClassVar[str] 33 | 34 | __context: ClassStyleComponentContenxt[PropsType] 35 | 36 | name: str 37 | 38 | @final 39 | @property 40 | def context(self) -> ClassStyleComponentContenxt[PropsType]: 41 | return self.__context 42 | 43 | @final 44 | def __new__(cls) -> NoReturn: 45 | raise WebComPyComponentException( 46 | "Component class cannot generate an instance by constructor" 47 | ) 48 | 49 | @final 50 | def __init_subclass__(cls) -> None: 51 | cls.__webcompy_component_id__ = hashlib.md5( 52 | cls.__get_name__().encode() 53 | ).hexdigest() 54 | return super().__init_subclass__() 55 | 56 | @final 57 | @classmethod 58 | def __get_component_instance__( 59 | cls, context: ClassStyleComponentContenxt[PropsType] 60 | ): 61 | component = super().__new__(cls) 62 | component.__context = context 63 | component.__init__() 64 | return component 65 | 66 | @final 67 | def __get_component_property__(self) -> ComponentProperty: 68 | def none(): 69 | return None 70 | 71 | props: dict[str, Callable[[], Any]] = { 72 | v.__webcompy_component_class_property__: v 73 | for v in (getattr(self, n) for n in dir(self) if hasattr(self, n)) 74 | if hasattr(v, "__webcompy_component_class_property__") 75 | } 76 | return { 77 | "component_id": self.__webcompy_component_id__, 78 | "component_name": self.__get_name__(), 79 | "template": props.get("template", none)(), 80 | "on_before_rendering": props.get("on_before_rendering", lambda: none), 81 | "on_after_rendering": props.get("on_after_rendering", lambda: none), 82 | "on_before_destroy": props.get("on_before_destroy", lambda: none), 83 | } 84 | 85 | @classmethod 86 | def __get_name__(cls) -> str: 87 | return _camel_to_kebab_pattern.sub( 88 | r"-\1", 89 | cls.name if hasattr(cls, "name") else cls.__name__, 90 | ).lower() 91 | 92 | 93 | ComponentBase: TypeAlias = ComponentAbstract[Any] 94 | NonPropsComponentBase: TypeAlias = ComponentAbstract[None] 95 | 96 | 97 | @overload 98 | def TypedComponentBase( 99 | props_type: Type[PropsType], 100 | ) -> Type[ComponentAbstract[PropsType]]: 101 | ... 102 | 103 | 104 | @overload 105 | def TypedComponentBase( 106 | props_type: None, 107 | ) -> Type[NonPropsComponentBase]: 108 | ... 109 | 110 | 111 | def TypedComponentBase( 112 | props_type: Type[PropsType] | None, 113 | ) -> Type[ComponentAbstract[PropsType]] | Type[NonPropsComponentBase]: 114 | if props_type is None: 115 | return NonPropsComponentBase 116 | else: 117 | return ComponentAbstract[PropsType] 118 | 119 | 120 | def deco(func: Callable[[], Callable[[], str]]): 121 | return func 122 | 123 | 124 | @deco 125 | def func(): 126 | hoge = "hello" 127 | return lambda: hoge 128 | -------------------------------------------------------------------------------- /docs_src/templates/demo/fizzbuzz.py: -------------------------------------------------------------------------------- 1 | from webcompy.reactive import Reactive, computed_property, computed 2 | from webcompy.elements import html, repeat, switch 3 | from webcompy.components import ( 4 | TypedComponentBase, 5 | component_class, 6 | on_before_rendering, 7 | component_template, 8 | ) 9 | from webcompy.elements import DOMEvent 10 | 11 | 12 | @component_class 13 | class Fizzbuzz(TypedComponentBase(props_type=None)): 14 | def __init__(self) -> None: 15 | self.opened = Reactive(True) 16 | self.count = Reactive(10) 17 | 18 | @computed_property 19 | def fizzbuzz_list(self): 20 | li: list[str] = [] 21 | for n in range(1, self.count.value + 1): 22 | if n % 15 == 0: 23 | li.append("FizzBuzz") 24 | elif n % 5 == 0: 25 | li.append("Fizz") 26 | elif n % 3 == 0: 27 | li.append("Buzz") 28 | else: 29 | li.append(str(n)) 30 | return li 31 | 32 | @computed_property 33 | def toggle_button_text(self): 34 | return "Hide" if self.opened.value else "Open" 35 | 36 | def add(self, ev: DOMEvent): 37 | self.count.value += 1 38 | 39 | def pop(self, ev: DOMEvent): 40 | if self.count.value > 0: 41 | self.count.value -= 1 42 | 43 | def toggle(self, ev: DOMEvent): 44 | self.opened.value = not self.opened.value 45 | 46 | @on_before_rendering 47 | def on_before_rendering(self): 48 | self.count.value = 10 49 | 50 | @component_template 51 | def template(self): 52 | return html.DIV( 53 | {}, 54 | html.P( 55 | {}, 56 | html.BUTTON( 57 | { 58 | "@click": self.add, 59 | "disabled": computed(lambda: not self.opened.value), 60 | }, 61 | "Add", 62 | ), 63 | html.BUTTON( 64 | { 65 | "@click": self.pop, 66 | "disabled": computed(lambda: not self.opened.value), 67 | }, 68 | "Pop", 69 | ), 70 | html.BUTTON( 71 | {"@click": self.toggle}, 72 | self.toggle_button_text, 73 | ), 74 | ), 75 | html.P( 76 | {}, 77 | "Count: ", 78 | self.count, 79 | ), 80 | switch( 81 | { 82 | "case": self.opened, 83 | "generator": lambda: html.DIV( 84 | {}, 85 | html.UL( 86 | {}, 87 | repeat( 88 | self.fizzbuzz_list, 89 | lambda s: html.LI({}, s), 90 | ), 91 | ), 92 | ), 93 | }, 94 | default=lambda: html.DIV( 95 | {}, 96 | "FizzBuzz Hidden", 97 | ), 98 | ), 99 | ) 100 | 101 | 102 | Fizzbuzz.scoped_style = { 103 | "ul": { 104 | "border": "dashed 2px #668ad8", 105 | "background": "#f1f8ff", 106 | "padding": "0.5em 0.5em 0.5em 2em", 107 | }, 108 | "ul > li:nth-child(3n)": { 109 | "color": "red", 110 | }, 111 | "ul > li:nth-child(5n)": { 112 | "color": "blue", 113 | }, 114 | "ul > li:nth-child(15n)": { 115 | "color": "purple", 116 | }, 117 | "button": { 118 | "display": "inline-block", 119 | "text-decoration": "none", 120 | "border": "solid 2px #668ad8", 121 | "border-radius": "3px", 122 | "transition": "0.2s", 123 | "color": "black", 124 | }, 125 | "button:hover": { 126 | "background": "#668ad8", 127 | "color": "white", 128 | }, 129 | } 130 | -------------------------------------------------------------------------------- /docs_src/pages/demo/fetch_sample.py: -------------------------------------------------------------------------------- 1 | from webcompy.elements import html 2 | from webcompy.components import define_component, ComponentContext 3 | from webcompy.router import RouterContext 4 | from ...components.demo_display import DemoDisplay 5 | from ...templates.demo.fetch_sample import FetchSample 6 | 7 | 8 | @define_component 9 | def FetchSamplePage(context: ComponentContext[RouterContext]): 10 | title = "Fetch Sample" 11 | context.set_title(f"{title} - WebCompy Demo") 12 | 13 | return html.DIV( 14 | {}, 15 | DemoDisplay( 16 | { 17 | "title": title, 18 | "code": """ 19 | from typing import TypedDict 20 | from webcompy.elements import html, repeat 21 | from webcompy.components import define_component, ComponentContext 22 | from webcompy.reactive import ReactiveList, Reactive 23 | from webcompy.aio import AsyncWrapper 24 | from webcompy.ajax import HttpClient 25 | from webcompy import logging 26 | 27 | 28 | class User(TypedDict): 29 | id: int 30 | name: str 31 | 32 | 33 | @define_component 34 | def FetchSample(context: ComponentContext[None]): 35 | users = ReactiveList[User]([]) 36 | json_text = Reactive("") 37 | 38 | @AsyncWrapper() 39 | async def fetch_user_data(): 40 | res = await HttpClient.get("fetch_sample/sample.json") 41 | logging.info(res) 42 | users.value = res.json()["data"] 43 | json_text.value = res.text 44 | 45 | @context.on_after_rendering 46 | def _(): 47 | fetch_user_data() 48 | 49 | return html.DIV( 50 | {}, 51 | html.DIV( 52 | {}, 53 | html.H5( 54 | {}, 55 | "User Data", 56 | ), 57 | repeat( 58 | sequence=users, 59 | template=lambda user_data: html.DIV( 60 | {"class": "user-data"}, 61 | html.UL( 62 | {}, 63 | html.LI({}, "User ID: " + str(user_data["id"])), 64 | html.LI({}, "User Name: " + user_data["name"]), 65 | ), 66 | ), 67 | ), 68 | ), 69 | html.DIV( 70 | {}, 71 | html.H5( 72 | {}, 73 | "Response Data", 74 | ), 75 | html.PRE( 76 | {}, 77 | html.CODE( 78 | {}, 79 | json_text, 80 | ), 81 | ), 82 | ), 83 | ) 84 | 85 | 86 | FetchSample.scoped_style = { 87 | ".user-data": { 88 | "margin": "10px auto", 89 | "padding": "10px", 90 | "background-color": "#fafafa", 91 | "border-radius": "15px", 92 | }, 93 | }""", 94 | }, 95 | slots={"component": lambda: FetchSample(None)}, 96 | ), 97 | ) 98 | -------------------------------------------------------------------------------- /docs_src/components/navigation.py: -------------------------------------------------------------------------------- 1 | from typing import List, TypedDict 2 | from webcompy.elements import html 3 | from webcompy.components import define_component, ComponentContext 4 | from webcompy.router import RouterLink 5 | 6 | 7 | class _SubPage(TypedDict): 8 | title: str 9 | to: str 10 | 11 | 12 | class _PageRequired(TypedDict): 13 | title: str 14 | 15 | 16 | class Page(_PageRequired, total=False): 17 | to: str 18 | children: List[_SubPage] 19 | 20 | 21 | @define_component 22 | def Navbar(context: ComponentContext[List[Page]]): 23 | def generate_navitem(page: Page, idx: int): 24 | if "children" in page: 25 | main = ( 26 | [ 27 | html.LI( 28 | {}, 29 | RouterLink( 30 | to=page["to"], 31 | text=[page["title"]], 32 | attrs={"class": "dropdown-item"}, 33 | ), 34 | ), 35 | html.LI( 36 | {"class": "dropdown-item"}, 37 | html.HR({"class": "dropdown-divider"}), 38 | ), 39 | ] 40 | if "to" in page 41 | else [] 42 | ) 43 | items = tuple( 44 | html.LI( 45 | {}, 46 | RouterLink( 47 | to=sub["to"], 48 | text=[sub["title"]], 49 | attrs={"class": "dropdown-item"}, 50 | ), 51 | ) 52 | for sub in page["children"] 53 | ) 54 | return html.LI( 55 | {"class": "nav-item dropdown"}, 56 | html.A( 57 | { 58 | "id": f"navbar-dropdown-{idx}", 59 | "class": "nav-link dropdown-toggle", 60 | "data-bs-toggle": "dropdown", 61 | "role": "button", 62 | "data-bs-toggle": "dropdown", 63 | "aria-expanded": "false", 64 | }, 65 | page["title"], 66 | ), 67 | html.UL( 68 | { 69 | "class": "dropdown-menu", 70 | "aria-labelledby": f"navbar-dropdown-{idx}", 71 | }, 72 | *main, 73 | *items, 74 | ), 75 | ) 76 | if "to" in page: 77 | return html.LI( 78 | {"class": "nav-item"}, 79 | RouterLink( 80 | to=page["to"], 81 | text=[page["title"]], 82 | attrs={"class": "nav-link"}, 83 | ), 84 | ) 85 | return None 86 | 87 | return html.NAV( 88 | {"class": "navbar navbar-expand-md navbar-light bg-light"}, 89 | html.DIV( 90 | {"class": "container-fluid"}, 91 | html.SPAN( 92 | {"class": "navbar-brand mb-0 h1"}, 93 | "WebComPy", 94 | ), 95 | html.BUTTON( 96 | { 97 | "class": "navbar-toggler", 98 | "type": "button", 99 | "data-bs-toggle": "collapse", 100 | "data-bs-target": "#navbarNav", 101 | "aria-controls": "navbarNav", 102 | "aria-expanded": "false", 103 | "aria-label": "Toggle navigation", 104 | }, 105 | html.SPAN({"class": "navbar-toggler-icon"}), 106 | ), 107 | html.DIV( 108 | {"class": "collapse navbar-collapse", "id": "navbarNav"}, 109 | html.UL( 110 | {"class": "navbar-nav"}, 111 | *tuple( 112 | generate_navitem(page, idx) 113 | for idx, page in enumerate(context.props) 114 | ), 115 | ), 116 | ), 117 | ), 118 | ) 119 | -------------------------------------------------------------------------------- /webcompy/elements/html/__init__.py: -------------------------------------------------------------------------------- 1 | # Content sectioning 2 | from webcompy.elements.html.html_tags import ( 3 | ADDRESS, 4 | ARTICLE, 5 | ASIDE, 6 | FOOTER, 7 | HEADER, 8 | H1, 9 | H2, 10 | H3, 11 | H4, 12 | H5, 13 | H6, 14 | MAIN, 15 | NAV, 16 | SECTION, 17 | ) 18 | 19 | # Text content 20 | from webcompy.elements.html.html_tags import ( 21 | BLOCKQUOTE, 22 | DD, 23 | DIV, 24 | DL, 25 | DT, 26 | FIGCAPTION, 27 | FIGURE, 28 | HR, 29 | LI, 30 | MENU, 31 | OL, 32 | P, 33 | PRE, 34 | UL, 35 | ) 36 | 37 | # Inline text semantics 38 | from webcompy.elements.html.html_tags import ( 39 | TEXT, 40 | A, 41 | ABBR, 42 | B, 43 | BDI, 44 | BDO, 45 | BR, 46 | CITE, 47 | CODE, 48 | DATA, 49 | DFN, 50 | EM, 51 | I, 52 | KBD, 53 | MARK, 54 | Q, 55 | RP, 56 | RT, 57 | RUBY, 58 | S, 59 | SAMP, 60 | SMALL, 61 | SPAN, 62 | STRONG, 63 | SUB, 64 | SUP, 65 | TIME, 66 | U, 67 | VAR, 68 | WBR, 69 | ) 70 | 71 | # Image and multimedia 72 | from webcompy.elements.html.html_tags import AREA, AUDIO, IMG, MAP, TRACK, VIDEO 73 | 74 | # Embedded content 75 | from webcompy.elements.html.html_tags import ( 76 | EMBED, 77 | IFRAME, 78 | OBJECT, 79 | PARAM, 80 | PICTURE, 81 | PORTAL, 82 | SOURCE, 83 | ) 84 | 85 | # SVG and MathML 86 | from webcompy.elements.html.html_tags import SVG, MATH 87 | 88 | # Scripting 89 | from webcompy.elements.html.html_tags import CANVAS 90 | 91 | # Demarcating edits 92 | from webcompy.elements.html.html_tags import DEL, INS 93 | 94 | # Table content 95 | from webcompy.elements.html.html_tags import ( 96 | CAPTION, 97 | COL, 98 | COLGROUP, 99 | TABLE, 100 | TBODY, 101 | TD, 102 | TFOOT, 103 | TH, 104 | THEAD, 105 | TR, 106 | ) 107 | 108 | # Forms 109 | from webcompy.elements.html.html_tags import ( 110 | BUTTON, 111 | DATALIST, 112 | FIELDSET, 113 | FORM, 114 | INPUT, 115 | LABEL, 116 | LEGEND, 117 | METER, 118 | OPTGROUP, 119 | OPTION, 120 | OUTPUT, 121 | PROGRESS, 122 | SELECT, 123 | TEXTAREA, 124 | ) 125 | 126 | # Interactive elements 127 | from webcompy.elements.html.html_tags import ( 128 | DETAILS, 129 | DIALOG, 130 | SUMMARY, 131 | ) 132 | 133 | 134 | __all__ = [ 135 | "ADDRESS", 136 | "ARTICLE", 137 | "ASIDE", 138 | "FOOTER", 139 | "HEADER", 140 | "H1", 141 | "H2", 142 | "H3", 143 | "H4", 144 | "H5", 145 | "H6", 146 | "MAIN", 147 | "NAV", 148 | "SECTION", 149 | "BLOCKQUOTE", 150 | "DD", 151 | "DIV", 152 | "DL", 153 | "DT", 154 | "FIGCAPTION", 155 | "FIGURE", 156 | "HR", 157 | "LI", 158 | "MENU", 159 | "OL", 160 | "P", 161 | "PRE", 162 | "UL", 163 | "TEXT", 164 | "A", 165 | "ABBR", 166 | "B", 167 | "BDI", 168 | "BDO", 169 | "BR", 170 | "CITE", 171 | "CODE", 172 | "DATA", 173 | "DFN", 174 | "EM", 175 | "I", 176 | "KBD", 177 | "MARK", 178 | "Q", 179 | "RP", 180 | "RT", 181 | "RUBY", 182 | "S", 183 | "SAMP", 184 | "SMALL", 185 | "SPAN", 186 | "STRONG", 187 | "SUB", 188 | "SUP", 189 | "TIME", 190 | "U", 191 | "VAR", 192 | "WBR", 193 | "AREA", 194 | "AUDIO", 195 | "IMG", 196 | "MAP", 197 | "TRACK", 198 | "VIDEO", 199 | "EMBED", 200 | "IFRAME", 201 | "OBJECT", 202 | "PARAM", 203 | "PICTURE", 204 | "PORTAL", 205 | "SOURCE", 206 | "SVG", 207 | "MATH", 208 | "CANVAS", 209 | "DEL", 210 | "INS", 211 | "CAPTION", 212 | "COL", 213 | "COLGROUP", 214 | "TABLE", 215 | "TBODY", 216 | "TD", 217 | "TFOOT", 218 | "TH", 219 | "THEAD", 220 | "TR", 221 | "BUTTON", 222 | "DATALIST", 223 | "FIELDSET", 224 | "FORM", 225 | "INPUT", 226 | "LABEL", 227 | "LEGEND", 228 | "METER", 229 | "OPTGROUP", 230 | "OPTION", 231 | "OUTPUT", 232 | "PROGRESS", 233 | "SELECT", 234 | "TEXTAREA", 235 | "DETAILS", 236 | "DIALOG", 237 | "SUMMARY", 238 | ] 239 | -------------------------------------------------------------------------------- /webcompy/elements/types/_switch.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from operator import truth 3 | from typing import Any, Callable, Union, cast 4 | from typing_extensions import TypeAlias 5 | from webcompy.reactive._base import ReactiveBase 6 | from webcompy.elements.types._abstract import ElementAbstract 7 | from webcompy.elements.typealias._element_property import ElementChildren 8 | from webcompy.exception import WebComPyException 9 | from webcompy.elements.types._dynamic import DynamicElement 10 | from webcompy._browser._modules import browser 11 | 12 | 13 | NodeGenerator: TypeAlias = Callable[[], ElementChildren] 14 | SwitchCasesReactive: TypeAlias = list[tuple[ReactiveBase[Any], NodeGenerator]] 15 | SwitchCasesReactiveList: TypeAlias = ReactiveBase[list[tuple[Any, NodeGenerator]]] 16 | SwitchCases: TypeAlias = Union[SwitchCasesReactive, SwitchCasesReactiveList] 17 | 18 | 19 | class SwitchElement(DynamicElement): 20 | _rendered_idx: int | None 21 | 22 | def __init__( 23 | self, 24 | cases: SwitchCases, 25 | default: NodeGenerator | None, 26 | ) -> None: 27 | self._cases = cases 28 | self._default = default 29 | self._reactive_activated = False 30 | self._rendered_idx = None 31 | super().__init__() 32 | 33 | def _select_generator(self) -> tuple[int, Callable[[], ElementChildren]]: 34 | if isinstance(self._cases, ReactiveBase): 35 | cases = self._cases.value 36 | else: 37 | cases = self._cases 38 | for idx, (cond, generator) in enumerate( 39 | cast(list[tuple[Union[ReactiveBase[Any], Any], NodeGenerator]], cases) 40 | ): 41 | if truth(cond.value if isinstance(cond, ReactiveBase) else cond): 42 | return (idx, generator) 43 | if self._default: 44 | return (-1, self._default) 45 | else: 46 | return (-1, lambda: None) 47 | 48 | def _generate_children(self, generator: NodeGenerator) -> list[ElementAbstract]: 49 | ele = self._create_child_element(self._parent, None, generator()) 50 | return [ele] if ele is not None else [] 51 | 52 | def _render(self): 53 | self._refresh() 54 | if not self._reactive_activated: 55 | self._reactive_activated = True 56 | if isinstance(self._cases, ReactiveBase): 57 | self._set_callback_id(self._cases.on_after_updating(self._refresh)) 58 | else: 59 | for cond, _ in self._cases: 60 | if isinstance(cond, ReactiveBase): # type: ignore 61 | self._set_callback_id(cond.on_after_updating(self._refresh)) 62 | 63 | def _refresh(self, *args: Any): 64 | idx, generator = self._select_generator() 65 | if idx == self._rendered_idx: 66 | return 67 | parent_node = self._parent._get_node() 68 | if not parent_node: 69 | raise WebComPyException( 70 | f"'{self.__class__.__name__}' does not have its parent." 71 | ) 72 | self._rendered_idx = idx 73 | for _ in range(len(self._children)): 74 | self._children.pop(-1)._remove_element() 75 | self._children = self._generate_children(generator) 76 | for c_idx, child in enumerate(self._children): 77 | child._node_idx = self._node_idx + c_idx 78 | child._render() 79 | self._parent._re_index_children(False) 80 | 81 | def _on_set_parent(self): 82 | if not browser: 83 | 84 | def refresh(*args: Any): 85 | idx, generator = self._select_generator() 86 | self._rendered_idx = idx 87 | self._children = self._generate_children(generator) 88 | 89 | refresh() 90 | 91 | if not self._reactive_activated: 92 | self._reactive_activated = True 93 | 94 | if isinstance(self._cases, ReactiveBase): 95 | self._set_callback_id(self._cases.on_after_updating(refresh)) 96 | else: 97 | for cond, _ in self._cases: 98 | if isinstance(cond, ReactiveBase): # type: ignore 99 | self._set_callback_id(cond.on_after_updating(refresh)) 100 | -------------------------------------------------------------------------------- /webcompy/components/_generator.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from re import compile as re_compile 3 | from typing import ( 4 | Any, 5 | Callable, 6 | Final, 7 | Generic, 8 | Type, 9 | TypeVar, 10 | Union, 11 | ) 12 | from typing_extensions import TypeAlias 13 | from webcompy.components._component import Component 14 | from webcompy.components._abstract import ComponentAbstract 15 | from webcompy.components._libs import (ComponentContext, NodeGenerator, WebComPyComponentException, generate_id) 16 | from webcompy.elements.typealias._element_property import ElementChildren 17 | 18 | 19 | _camel_to_kebab_pattern: Final = re_compile("((?<=[a-z0-9])[A-Z]|(?!^)[A-Z](?=[a-z]))") 20 | _combinator_pattern: Final = re_compile(r"\s*,\s*|\s*>\s*|\s*\+\s*|\s*~[^=]\s*|\s* \s*") 21 | 22 | 23 | T = TypeVar("T") 24 | 25 | 26 | def _instantiate(cls: Type[T]) -> T: 27 | return cls() 28 | 29 | 30 | @_instantiate 31 | class ComponentStore: 32 | __conponents: dict[str, "ComponentGenerator[Any]"] 33 | 34 | def __init__(self) -> None: 35 | self.__conponents = {} 36 | 37 | def add_component(self, name: str, componet_generator: "ComponentGenerator[Any]"): 38 | if name in self.__conponents.keys(): 39 | raise WebComPyComponentException(f"Duplicated Component Name: '{name}'") 40 | self.__conponents[name] = componet_generator 41 | 42 | @property 43 | def components(self) -> dict[str, "ComponentGenerator[Any]"]: 44 | return self.__conponents 45 | 46 | 47 | PropsType = TypeVar("PropsType") 48 | FuncComponentDef: TypeAlias = Callable[[ComponentContext[PropsType]], ElementChildren] 49 | ClassComponentDef: TypeAlias = Type[ComponentAbstract[PropsType]] 50 | 51 | 52 | class ComponentGenerator(Generic[PropsType]): 53 | __name: str 54 | __id: str 55 | __style: dict[str, dict[str, str]] 56 | 57 | def __init__( 58 | self, 59 | name: str, 60 | component_def: Union[FuncComponentDef[PropsType], ClassComponentDef[PropsType]], 61 | ) -> None: 62 | self.__style = {} 63 | self.__component_def = component_def 64 | self.__name: str = name 65 | self.__id = generate_id(name) 66 | ComponentStore.add_component(self.__name, self) 67 | 68 | def __call__( 69 | self, 70 | props: PropsType, 71 | *, 72 | slots: dict[str, NodeGenerator] | None = None, 73 | ): 74 | return Component(self.__component_def, props, {**slots} if slots else {}) 75 | 76 | @property 77 | def scoped_style(self) -> str: 78 | style = self.__style 79 | return " ".join( 80 | f"{selector} {{ " 81 | + " ".join(f"{name}: {value};" for name, value in props.items()) 82 | + " }" 83 | for selector, props in style.items() 84 | ) 85 | 86 | @scoped_style.setter 87 | def scoped_style(self, style: dict[str, dict[str, str]]): 88 | cid = self.__id 89 | self.__style = dict( 90 | zip( 91 | ( 92 | "".join( 93 | f"{selector}[webcompy-cid-{cid}]{combinator}" 94 | for selector, combinator in zip( 95 | _combinator_pattern.split(selector), 96 | _combinator_pattern.findall(selector) + [""], 97 | ) 98 | ) 99 | for selector in map(lambda s: s.strip(), style.keys()) 100 | ), 101 | ( 102 | { 103 | prop: value.strip().rstrip(";").rstrip() 104 | for prop, value in declaration.items() 105 | } 106 | for declaration in style.values() 107 | ), 108 | ) 109 | ) 110 | 111 | 112 | def define_component( 113 | setup: Callable[[ComponentContext[PropsType]], ElementChildren], 114 | ) -> ComponentGenerator[PropsType]: 115 | setattr(setup, "__webcompy_componet_definition__", True) 116 | return ComponentGenerator(setup.__name__, setup) 117 | 118 | 119 | def component_class( 120 | component_def: Type[ComponentAbstract[PropsType]], 121 | ) -> ComponentGenerator[PropsType]: 122 | return ComponentGenerator(component_def.__get_name__(), component_def) 123 | -------------------------------------------------------------------------------- /webcompy/elements/html/html_tags.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from webcompy.elements.generators import create_element, text, break_line 3 | 4 | 5 | TEXT = text 6 | BR = lambda: break_line() 7 | ADDRESS = partial(create_element, "address") 8 | ARTICLE = partial(create_element, "article") 9 | ASIDE = partial(create_element, "aside") 10 | FOOTER = partial(create_element, "footer") 11 | HEADER = partial(create_element, "header") 12 | H1 = partial(create_element, "h1") 13 | H2 = partial(create_element, "h2") 14 | H3 = partial(create_element, "h3") 15 | H4 = partial(create_element, "h4") 16 | H5 = partial(create_element, "h5") 17 | H6 = partial(create_element, "h6") 18 | MAIN = partial(create_element, "main") 19 | NAV = partial(create_element, "nav") 20 | SECTION = partial(create_element, "section") 21 | BLOCKQUOTE = partial(create_element, "blockquote") 22 | DD = partial(create_element, "dd") 23 | DIV = partial(create_element, "div") 24 | DL = partial(create_element, "dl") 25 | DT = partial(create_element, "dt") 26 | FIGCAPTION = partial(create_element, "figcaption") 27 | FIGURE = partial(create_element, "figure") 28 | HR = partial(create_element, "hr") 29 | LI = partial(create_element, "li") 30 | MENU = partial(create_element, "menu") 31 | OL = partial(create_element, "ol") 32 | P = partial(create_element, "p") 33 | PRE = partial(create_element, "pre") 34 | UL = partial(create_element, "ul") 35 | A = partial(create_element, "a") 36 | ABBR = partial(create_element, "abbr") 37 | B = partial(create_element, "b") 38 | BDI = partial(create_element, "bdi") 39 | BDO = partial(create_element, "bdo") 40 | CITE = partial(create_element, "cite") 41 | CODE = partial(create_element, "code") 42 | DATA = partial(create_element, "data") 43 | DFN = partial(create_element, "dfn") 44 | EM = partial(create_element, "em") 45 | I = partial(create_element, "i") 46 | KBD = partial(create_element, "kbd") 47 | MARK = partial(create_element, "mark") 48 | Q = partial(create_element, "q") 49 | RP = partial(create_element, "rp") 50 | RT = partial(create_element, "rt") 51 | RUBY = partial(create_element, "ruby") 52 | S = partial(create_element, "s") 53 | SAMP = partial(create_element, "samp") 54 | SMALL = partial(create_element, "small") 55 | SPAN = partial(create_element, "span") 56 | STRONG = partial(create_element, "strong") 57 | SUB = partial(create_element, "sub") 58 | SUP = partial(create_element, "sup") 59 | TIME = partial(create_element, "time") 60 | U = partial(create_element, "u") 61 | VAR = partial(create_element, "var") 62 | WBR = partial(create_element, "wbr") 63 | AREA = partial(create_element, "area") 64 | AUDIO = partial(create_element, "audio") 65 | IMG = partial(create_element, "img") 66 | MAP = partial(create_element, "map") 67 | TRACK = partial(create_element, "track") 68 | VIDEO = partial(create_element, "video") 69 | EMBED = partial(create_element, "embed") 70 | IFRAME = partial(create_element, "iframe") 71 | OBJECT = partial(create_element, "object") 72 | PARAM = partial(create_element, "param") 73 | PICTURE = partial(create_element, "picture") 74 | PORTAL = partial(create_element, "portal") 75 | SOURCE = partial(create_element, "source") 76 | SVG = partial(create_element, "svg") 77 | MATH = partial(create_element, "math") 78 | CANVAS = partial(create_element, "canvas") 79 | DEL = partial(create_element, "del") 80 | INS = partial(create_element, "ins") 81 | CAPTION = partial(create_element, "caption") 82 | COL = partial(create_element, "col") 83 | COLGROUP = partial(create_element, "colgroup") 84 | TABLE = partial(create_element, "table") 85 | TBODY = partial(create_element, "tbody") 86 | TD = partial(create_element, "td") 87 | TFOOT = partial(create_element, "tfoot") 88 | TH = partial(create_element, "th") 89 | THEAD = partial(create_element, "thead") 90 | TR = partial(create_element, "tr") 91 | BUTTON = partial(create_element, "button") 92 | DATALIST = partial(create_element, "datalist") 93 | FIELDSET = partial(create_element, "fieldset") 94 | FORM = partial(create_element, "form") 95 | INPUT = partial(create_element, "input") 96 | LABEL = partial(create_element, "label") 97 | LEGEND = partial(create_element, "legend") 98 | METER = partial(create_element, "meter") 99 | OPTGROUP = partial(create_element, "optgroup") 100 | OPTION = partial(create_element, "option") 101 | OUTPUT = partial(create_element, "output") 102 | PROGRESS = partial(create_element, "progress") 103 | SELECT = partial(create_element, "select") 104 | TEXTAREA = partial(create_element, "textarea") 105 | DETAILS = partial(create_element, "details") 106 | DIALOG = partial(create_element, "dialog") 107 | SUMMARY = partial(create_element, "summary") 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebComPy 2 | 3 | ## What is WebComPy 4 | [WebComPy](https://github.com/kniwase/WebComPy) is Python frontend framework for [PyScript](https://github.com/pyscript/pyscript), which has following features. 5 | 6 | - Component-based declarative rendering 7 | - Automatic DOM refreshing 8 | - Built-in router 9 | - CLI tool (Project template, Build-in HTTP server, Static Site Generator) 10 | - Type Annotation 11 | 12 | ## Get started 13 | ``` 14 | mkdir webcompy-project 15 | cd webcompy-project 16 | pip install webcompy 17 | python -m webcompy init 18 | python -m webcompy start --dev 19 | python -m webcompy generate # for generating static site 20 | ``` 21 | 22 | then access [http://127.0.0.1:8080/WebComPy/](http://127.0.0.1:8080/WebComPy/) 23 | 24 | ## Documents and Demos 25 | - [Github Pages](https://kniwase.github.io/WebComPy/) 26 | * [Source Codes](https://github.com/kniwase/WebComPy/tree/main/docs_src/) 27 | * [Generated Files](https://github.com/kniwase/WebComPy/tree/main/docs/) 28 | 29 | ## Sample Code 30 | ```python 31 | from webcompy.reactive import Reactive, computed_property, computed 32 | from webcompy.elements import html, repeat, switch, DOMEvent 33 | from webcompy.router import RouterContext 34 | from webcompy.components import ( 35 | define_component, 36 | ComponentContext, 37 | TypedComponentBase, 38 | component_class, 39 | on_before_rendering, 40 | component_template, 41 | ) 42 | 43 | 44 | @define_component 45 | def FizzbuzzList(context: ComponentContext[Reactive[int]]): 46 | @computed 47 | def fizzbuzz(): 48 | li: list[str] = [] 49 | for n in range(1, context.props.value + 1): 50 | if n % 15 == 0: 51 | li.append("FizzBuzz") 52 | elif n % 5 == 0: 53 | li.append("Fizz") 54 | elif n % 3 == 0: 55 | li.append("Buzz") 56 | else: 57 | li.append(str(n)) 58 | return li 59 | 60 | return html.DIV( 61 | {}, 62 | html.UL( 63 | {}, 64 | repeat(fizzbuzz, lambda s: html.LI({}, s)), 65 | ), 66 | ) 67 | 68 | 69 | FizzbuzzList.scoped_style = { 70 | "ul": { 71 | "border": "dashed 2px #668ad8", 72 | "background": "#f1f8ff", 73 | "padding": "0.5em 0.5em 0.5em 2em", 74 | }, 75 | "ul > li:nth-child(3n)": { 76 | "color": "red", 77 | }, 78 | "ul > li:nth-child(5n)": { 79 | "color": "blue", 80 | }, 81 | "ul > li:nth-child(15n)": { 82 | "color": "purple", 83 | }, 84 | } 85 | 86 | 87 | @component_class 88 | class Fizzbuzz(TypedComponentBase(props_type=RouterContext)): 89 | def __init__(self) -> None: 90 | self.opened = Reactive(True) 91 | self.count = Reactive(10) 92 | 93 | @computed_property 94 | def toggle_button_text(self): 95 | return "Hide" if self.opened.value else "Open" 96 | 97 | @on_before_rendering 98 | def on_before_rendering(self): 99 | self.count.value = 10 100 | 101 | def add(self, ev: DOMEvent): 102 | self.count.value += 1 103 | 104 | def pop(self, ev: DOMEvent): 105 | if self.count.value > 0: 106 | self.count.value -= 1 107 | 108 | def toggle(self, ev: DOMEvent): 109 | self.opened.value = not self.opened.value 110 | 111 | @component_template 112 | def template(self): 113 | return html.DIV( 114 | {}, 115 | html.H3( 116 | {}, 117 | "FizzBuzz", 118 | ), 119 | html.P( 120 | {}, 121 | html.BUTTON( 122 | {"@click": self.toggle}, 123 | self.toggle_button_text, 124 | ), 125 | html.BUTTON( 126 | {"@click": self.add}, 127 | "Add", 128 | ), 129 | html.BUTTON( 130 | {"@click": self.pop}, 131 | "Pop", 132 | ), 133 | ), 134 | html.P( 135 | {}, 136 | "Count: ", 137 | self.count, 138 | ), 139 | switch( 140 | { 141 | "case": self.opened, 142 | "generator": lambda: FizzbuzzList(props=self.count), 143 | }, 144 | default=lambda: html.H5( 145 | {}, 146 | "FizzBuzz Hidden", 147 | ), 148 | ), 149 | ) 150 | 151 | ``` 152 | 153 | ## ToDo 154 | - Add provide/inject (DI) 155 | - Add Plugin System 156 | 157 | ## Lisence 158 | This project is licensed under the MIT License, see the LICENSE.txt file for details. 159 | -------------------------------------------------------------------------------- /webcompy/elements/types/_element.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from inspect import iscoroutinefunction 3 | from typing import Any, Callable, Iterable, cast 4 | from webcompy.reactive._base import ReactiveBase 5 | from webcompy._browser._modules import browser 6 | from webcompy.elements.types._base import ElementWithChildren 7 | from webcompy.elements.typealias._html_tag_names import HtmlTags 8 | from webcompy.elements.typealias._element_property import ( 9 | ElementChildren, 10 | AttrValue, 11 | EventHandler, 12 | ) 13 | from webcompy.elements.types._refference import DomNodeRef 14 | from webcompy.elements._dom_objs import DOMNode, DOMEvent 15 | from webcompy.aio import resolve_async 16 | from webcompy.exception import WebComPyException 17 | 18 | 19 | def _generate_event_handler(_event_handler: EventHandler) -> Callable[[DOMEvent], Any]: 20 | def event_handler(ev: Any): 21 | if iscoroutinefunction(_event_handler): 22 | resolve_async(_event_handler(ev)) 23 | else: 24 | _event_handler(ev) 25 | 26 | if browser: 27 | return browser.pyodide.create_proxy(event_handler) 28 | else: 29 | return event_handler 30 | 31 | 32 | class ElementBase(ElementWithChildren): 33 | _ref: DomNodeRef | None 34 | _event_handlers_added: dict[str, Any] 35 | 36 | def _init_node(self) -> DOMNode: 37 | if browser: 38 | node: DOMNode | None = None 39 | existing_node = self._get_existing_node() 40 | if existing_node: 41 | if ( 42 | getattr(existing_node, "__webcompy_prerendered_node__", False) 43 | and existing_node.nodeName.lower() == self._tag_name 44 | ): 45 | node = existing_node 46 | self._mounted = True 47 | attr_names_to_remove = set( 48 | name 49 | for name, value in self._get_processed_attrs().items() 50 | if value is None and name in tuple(node.getAttributeNames()) 51 | ) 52 | attr_names_to_remove.update( 53 | name 54 | for name in tuple(node.getAttributeNames()) 55 | if name not in self._get_processed_attrs().keys() 56 | ) 57 | for name in attr_names_to_remove: 58 | node.removeAttribute(name) 59 | else: 60 | existing_node.remove() 61 | if not node: 62 | node = cast(DOMNode, browser.document.createElement(self._tag_name)) 63 | node.__webcompy_node__ = True 64 | for name, value in self._get_processed_attrs().items(): 65 | if value is not None: 66 | node.setAttribute(name, value) 67 | for name, value in self._attrs.items(): 68 | if isinstance(value, ReactiveBase): 69 | self._set_callback_id( 70 | value.on_after_updating(self._generate_attr_updater(name)) 71 | ) 72 | self._event_handlers_added = {} 73 | for name, func in self._event_handlers.items(): 74 | event_handler = _generate_event_handler(func) 75 | node.addEventListener(name, event_handler, False) 76 | self._event_handlers_added[name] = event_handler 77 | if self._ref: 78 | self._ref.__init_node__(node) 79 | return node 80 | else: 81 | raise WebComPyException("Not in Browser environment.") 82 | 83 | def _generate_attr_updater(self, name: str): 84 | def update_attr(new_value: Any, name: str = name): 85 | node = self._get_node() 86 | if node is not None: 87 | value = self._proc_attr(new_value) 88 | if value is None: 89 | node.removeAttribute(name) 90 | else: 91 | node.setAttribute(name, value) 92 | 93 | return update_attr 94 | 95 | def _init_children(self, children: Iterable[ElementChildren]): 96 | for idx in range(self._children_length - 1, -1, -1): 97 | self._pop_child(idx) 98 | for child in children: 99 | if child is not None: 100 | self._append_child(child) 101 | 102 | def _remove_element(self, recursive: bool = True, remove_node: bool = True): 103 | node = self._get_node() 104 | for name, event_handler in self._event_handlers_added.items(): 105 | node.removeEventListener(name, event_handler) 106 | if browser: 107 | event_handler.destroy() 108 | if self._ref is not None: 109 | self._ref.__reset_node__() 110 | super()._remove_element(recursive, remove_node) 111 | 112 | 113 | class Element(ElementBase): 114 | def __init__( 115 | self, 116 | tag_name: HtmlTags, 117 | attrs: dict[str, AttrValue] = {}, 118 | events: dict[str, EventHandler] = {}, 119 | ref: DomNodeRef | None = None, 120 | children: Iterable[ElementChildren] = [], 121 | ) -> None: 122 | self._tag_name = cast(HtmlTags, tag_name.lower()) 123 | self._attrs = attrs if attrs else dict() 124 | self._event_handlers = events if events else dict() 125 | self._ref = ref 126 | self._children = [] 127 | super().__init__() 128 | self._init_children(children if children else list()) 129 | -------------------------------------------------------------------------------- /webcompy/cli/_server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from functools import partial 3 | from operator import truth 4 | from re import compile as re_compile, escape as re_escape 5 | import mimetypes 6 | import pathlib 7 | from tempfile import TemporaryDirectory 8 | import aiofiles 9 | from starlette.applications import Starlette 10 | from starlette.requests import Request 11 | from starlette.responses import HTMLResponse, Response 12 | from starlette.routing import Route 13 | from starlette.exceptions import HTTPException 14 | from starlette.types import ASGIApp 15 | from sse_starlette.sse import EventSourceResponse 16 | import uvicorn # type: ignore 17 | from webcompy.app._app import WebComPyApp 18 | from webcompy.cli._argparser import get_params 19 | from webcompy.cli._pyscript_wheel import make_webcompy_app_package 20 | from webcompy.cli._config import WebComPyConfig 21 | from webcompy.cli._html import generate_html 22 | from webcompy.cli._utils import ( 23 | get_config, 24 | get_webcompy_packge_dir, 25 | generate_app_version, 26 | ) 27 | from webcompy.cli._static_files import get_static_files 28 | 29 | 30 | def create_asgi_app( 31 | app: WebComPyApp, config: WebComPyConfig, dev_mode: bool = False 32 | ) -> ASGIApp: 33 | app_version = generate_app_version() 34 | 35 | # App Packages 36 | with TemporaryDirectory() as temp: 37 | temp_path = pathlib.Path(temp) 38 | make_webcompy_app_package( 39 | temp_path, 40 | get_webcompy_packge_dir(), 41 | config.app_package_path, 42 | app_version, 43 | ) 44 | app_package_files: dict[str, tuple[bytes, str]] = { 45 | p.name: ( 46 | p.open("rb").read(), 47 | t 48 | if (t := mimetypes.guess_type(str(p))[0]) 49 | else "application/octet-stream", 50 | ) 51 | for p in temp_path.iterdir() 52 | } 53 | 54 | async def send_app_package_file(request: Request): 55 | filename: str = request.path_params.get("filename", "") # type: ignore 56 | if filename in app_package_files.keys(): 57 | content, media_type = app_package_files[filename] 58 | return Response(content, media_type=media_type) 59 | else: 60 | raise HTTPException(404) 61 | 62 | app_package_files_route = Route( 63 | "/_webcompy-app-package/{filename:path}", 64 | send_app_package_file, 65 | ) 66 | 67 | # Static Files 68 | static_file_routes: list[Route] = [] 69 | static_files_dir = config.static_files_dir_path.absolute() 70 | for relative_path in get_static_files(static_files_dir): 71 | static_file = static_files_dir / relative_path 72 | if (media_type := mimetypes.guess_type(str(static_file))[0]) is None: 73 | media_type = "application/octet-stream" 74 | 75 | async def send_file(request: Request): 76 | async with aiofiles.open(static_file, "rb") as f: 77 | content = await f.read() 78 | return Response(content, media_type=media_type) 79 | 80 | static_file_routes.append(Route("/" + relative_path, send_file)) 81 | 82 | # HTMLs 83 | html_generator = partial(generate_html, config, dev_mode, True, app_version) 84 | base_url_stripper = partial( 85 | re_compile("^" + re_escape("/" + config.base.strip("/"))).sub, 86 | "", 87 | ) 88 | 89 | if app.__component__.router_mode == "history" and app.__component__.routes: 90 | 91 | async def send_html(request: Request): # type: ignore 92 | # get requested path 93 | path: str = request.path_params.get("path", "") # type: ignore 94 | requested_path = base_url_stripper(path).strip("/") 95 | # get accept types 96 | accept_types: list[str] = request.headers.get("accept", "").split(",") 97 | # search requested page 98 | routes = r if (r := app.__component__.routes) else [] 99 | is_matched = truth(tuple(filter(lambda r: r[1](requested_path), routes))) 100 | # response html 101 | if is_matched or "text/html" in accept_types: 102 | app.__component__.set_path(requested_path) 103 | return HTMLResponse(html_generator(app)) 104 | else: 105 | raise HTTPException(404) 106 | 107 | html_route = Route("/{path:path}", send_html) 108 | else: 109 | app.__component__.set_path("/") 110 | html = html_generator(app) 111 | 112 | async def send_html(_: Request): # type: ignore 113 | return HTMLResponse(html) 114 | 115 | html_route = Route("/", send_html) 116 | 117 | # Hot Reloader 118 | if dev_mode: 119 | 120 | async def loop(): 121 | while True: 122 | await asyncio.sleep(60) 123 | yield None 124 | 125 | async def sse(_: Request): 126 | return EventSourceResponse(loop()) 127 | 128 | dev_routes = [Route("/_webcompy_reload", endpoint=sse)] 129 | else: 130 | dev_routes: list[Route] = [] 131 | 132 | # Declare ASGI App 133 | return Starlette( 134 | routes=[ 135 | *dev_routes, 136 | app_package_files_route, 137 | *static_file_routes, 138 | html_route, 139 | ] 140 | ) 141 | 142 | 143 | def run_server(): 144 | _, args = get_params() 145 | config = get_config() 146 | uvicorn.run( 147 | "webcompy.cli._asgi_app:app", 148 | host="0.0.0.0", 149 | port=port if (port := args["port"]) else config.server_port, 150 | reload=args["dev"], 151 | ) 152 | -------------------------------------------------------------------------------- /webcompy/elements/types/_base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any 3 | from webcompy.reactive._base import ReactiveBase 4 | from webcompy.elements.types._abstract import ElementAbstract 5 | from webcompy.elements.typealias._html_tag_names import HtmlTags 6 | from webcompy.elements.typealias._element_property import ( 7 | ElementChildren, 8 | AttrValue, 9 | EventHandler, 10 | ) 11 | from webcompy.elements.types._text import TextElement 12 | 13 | 14 | class ElementWithChildren(ElementAbstract): 15 | _tag_name: HtmlTags 16 | _attrs: dict[str, AttrValue] = {} 17 | _event_handlers: dict[str, EventHandler] = {} 18 | _children: list[ElementAbstract] = [] 19 | __parent: ElementWithChildren 20 | 21 | def __init__(self) -> None: 22 | self._node_cache = None 23 | self._callback_ids: set[int] = set() 24 | 25 | @property 26 | def _parent(self) -> "ElementWithChildren": 27 | return self.__parent 28 | 29 | @_parent.setter 30 | def _parent(self, parent: "ElementWithChildren"): # type: ignore 31 | self.__parent = parent 32 | 33 | def _render(self): 34 | super()._render() 35 | for child in self._children: 36 | child._render() 37 | if (node := self._get_node()) is not None: 38 | for _ in range(node.childNodes.length - self._children_length): 39 | node.childNodes[-1].remove() 40 | 41 | def _get_processed_attrs(self): 42 | attrs = {name: self._proc_attr(value) for name, value in self._attrs.items()} 43 | if "webcompy-component" not in self._attrs and self._get_belonging_component(): 44 | attrs["webcompy-cid-" + self._get_belonging_component()] = "" 45 | return attrs 46 | 47 | def _proc_attr(self, value: AttrValue): 48 | if isinstance(value, ReactiveBase): 49 | obj = value.value 50 | else: 51 | obj = value 52 | if isinstance(obj, bool): 53 | return "" if obj else None 54 | elif isinstance(obj, int): 55 | return str(obj) 56 | else: 57 | return str(obj) 58 | 59 | def _remove_element(self, recursive: bool = True, remove_node: bool = True): 60 | super()._remove_element(recursive, remove_node) 61 | if recursive: 62 | for child in self._children: 63 | child._remove_element(True, False) 64 | 65 | def _create_child_element( 66 | self, 67 | parent: "ElementWithChildren", 68 | node_idx: int | None, 69 | child: ElementChildren, 70 | ): 71 | if child is None: 72 | return None 73 | elif isinstance(child, (str, ReactiveBase)): 74 | element = TextElement(child) 75 | else: 76 | element = child 77 | if node_idx is not None: 78 | element._node_idx = node_idx 79 | element._parent = parent 80 | return element 81 | 82 | @property 83 | def _children_length(self) -> int: 84 | return sum(child._node_count for child in self._children) 85 | 86 | def _re_index_children(self, recursive: bool = False): 87 | idx = 0 88 | for c_idx in range(len(self._children)): 89 | self._children[c_idx]._node_idx = idx 90 | idx += self._children[c_idx]._node_count 91 | if recursive: 92 | for child in self._children: 93 | if isinstance(child, ElementWithChildren): 94 | child._re_index_children(True) 95 | 96 | def _append_child(self, child: ElementChildren): 97 | if self._children_length == 0: 98 | node_idx = 0 99 | else: 100 | node_idx = self._children[-1]._node_idx + self._children[-1]._node_count 101 | child_ele = self._create_child_element(self, node_idx, child) 102 | if child_ele is not None: 103 | self._children.append(child_ele) 104 | 105 | def _insert_child(self, index: int, child: ElementChildren): 106 | child_ele = self._create_child_element(self, None, child) 107 | if child_ele is not None: 108 | self._children.insert(index, child_ele) 109 | self._re_index_children(False) 110 | 111 | def _pop_child(self, index: int, re_index: bool = False): 112 | self._children[index]._remove_element() 113 | del self._children[index] 114 | if re_index: 115 | self._re_index_children(False) 116 | 117 | def _clear_node_cache(self, recursive: bool = True): 118 | super()._clear_node_cache() 119 | if recursive: 120 | for child in self._children: 121 | child._clear_node_cache(True) 122 | 123 | def _get_belonging_component(self) -> str: 124 | return self._parent._get_belonging_component() 125 | 126 | def _get_belonging_components(self) -> tuple[Any, ...]: 127 | return self._parent._get_belonging_components() 128 | 129 | def _render_html( 130 | self, newline: bool = False, indent: int = 2, count: int = 0 131 | ) -> str: 132 | attrs: str = " ".join( 133 | f'{name}="{value}"' if value else name 134 | for name, value in self._get_processed_attrs().items() 135 | if value is not None 136 | ) 137 | separator = "\n" if newline else "" 138 | indent_text = (" " * indent * count) if newline else "" 139 | return separator.join( 140 | ( 141 | f'{indent_text}<{self._tag_name}{" " + attrs if attrs else ""}>', 142 | separator.join( 143 | child._render_html(newline, indent, count + 1) 144 | for child in self._children 145 | ), 146 | f"{indent_text}", 147 | ) 148 | ) 149 | -------------------------------------------------------------------------------- /docs_src/pages/demo/matplotlib_sample.py: -------------------------------------------------------------------------------- 1 | from webcompy.elements import html 2 | from webcompy.components import define_component, ComponentContext 3 | from webcompy.router import RouterContext 4 | from ...components.demo_display import DemoDisplay 5 | from ...templates.demo.matplotlib_sample import MatpoltlibSample 6 | 7 | 8 | @define_component 9 | def MatpoltlibSamplePage(context: ComponentContext[RouterContext]): 10 | title = "Matplotlib Sample" 11 | context.set_title(f"{title} - WebCompy Demo") 12 | 13 | return html.DIV( 14 | {}, 15 | DemoDisplay( 16 | { 17 | "title": title, 18 | "code": """ 19 | import base64 20 | from io import BytesIO 21 | import numpy as np 22 | from matplotlib import pyplot as plt 23 | from webcompy.elements import html, DOMEvent, DomNodeRef 24 | from webcompy.components import define_component, ComponentContext 25 | from webcompy.reactive import Reactive, computed 26 | 27 | 28 | @define_component 29 | def MatpoltlibSample(context: ComponentContext[None]): 30 | input_ref = DomNodeRef() 31 | 32 | fig, ax = plt.subplots() 33 | x = np.linspace(-5, 5, 250) # type: ignore 34 | (line,) = ax.plot(x, np.array([0 for _ in x])) # type: ignore 35 | 36 | count = Reactive(15) 37 | 38 | def on_change(ev: DOMEvent): 39 | count.value = int(input_ref.value) 40 | 41 | def add(ev: DOMEvent): 42 | if count.value < 30: 43 | count.value += 1 44 | input_ref.value = str(count.value) 45 | 46 | def pop(ev: DOMEvent): 47 | if count.value > 1: 48 | count.value -= 1 49 | input_ref.value = str(count.value) 50 | 51 | calc_square_wave = np.vectorize( 52 | lambda x: np.vectorize(lambda k: (1 / (2 * k + 1)) * np.sin((2 * k + 1) * x))( 53 | np.arange(count.value) # type: ignore 54 | ).sum() 55 | ) 56 | 57 | @computed 58 | def fig_data(): 59 | line.set_data(x, calc_square_wave(x)) 60 | ax.set_ylim(-2, 2) 61 | fig.canvas.draw() 62 | buffer = BytesIO() 63 | fig.savefig(buffer, format="png") 64 | return "data:image/png;base64,{}".format( 65 | base64.b64encode(buffer.getvalue()).decode() 66 | ) 67 | 68 | return html.DIV( 69 | {}, 70 | html.H5( 71 | {}, 72 | "Square Wave", 73 | ), 74 | html.P( 75 | {}, 76 | "Value: ", 77 | count, 78 | ), 79 | html.P( 80 | {}, 81 | html.INPUT( 82 | { 83 | "@change": on_change, 84 | ":ref": input_ref, 85 | "type": "range", 86 | "min": 1, 87 | "max": 30, 88 | "step": 1, 89 | "value": count, 90 | } 91 | ), 92 | ), 93 | html.P( 94 | {}, 95 | html.BUTTON( 96 | {"@click": add}, 97 | "+", 98 | ), 99 | html.BUTTON( 100 | {"@click": pop}, 101 | "-", 102 | ), 103 | ), 104 | html.IMG( 105 | {"src": fig_data}, 106 | ), 107 | ) 108 | 109 | 110 | MatpoltlibSample.scoped_style = { 111 | "button": { 112 | "display": "inline-block", 113 | "text-decoration": "none", 114 | "border": "solid 2px #668ad8", 115 | "border-radius": "3px", 116 | "transition": "0.2s", 117 | "color": "black", 118 | "width": "30px", 119 | }, 120 | "button:hover": { 121 | "background": "#668ad8", 122 | "color": "white", 123 | }, 124 | "input, img": { 125 | "width": "100%", 126 | "max-width": "600px", 127 | "height": "auto", 128 | }, 129 | }""", 130 | }, 131 | slots={"component": lambda: MatpoltlibSample(None)}, 132 | ), 133 | ) 134 | -------------------------------------------------------------------------------- /webcompy/components/_libs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import hashlib 3 | import logging 4 | from typing import ( 5 | Any, 6 | Callable, 7 | Dict, 8 | Generic, 9 | Literal, 10 | Protocol, 11 | TypeVar, 12 | TypedDict, 13 | final, 14 | ) 15 | from typing_extensions import TypeAlias 16 | from webcompy.exception import WebComPyException 17 | from webcompy.elements.typealias._element_property import ElementChildren 18 | 19 | 20 | class WebComPyComponentException(WebComPyException): 21 | pass 22 | 23 | 24 | NodeGenerator: TypeAlias = Callable[[], ElementChildren] 25 | _Lifecyclehooks: TypeAlias = dict[ 26 | Literal["on_before_rendering", "on_after_rendering", "on_before_destroy"], 27 | Callable[[], Any], 28 | ] 29 | 30 | PropsType = TypeVar("PropsType", covariant=True) 31 | 32 | 33 | @final 34 | class Context(Generic[PropsType]): 35 | __slots: dict[str, NodeGenerator] 36 | __props: PropsType 37 | 38 | __on_before_rendering: Callable[[], Any] | None 39 | __on_after_rendering: Callable[[], Any] | None 40 | __on_before_destroy: Callable[[], Any] | None 41 | 42 | __title_getter: Callable[[], str] 43 | __meta_getter: Callable[[], dict[str, dict[str, str]]] 44 | __title_setter: Callable[[str], None] 45 | __meta_setter: Callable[[str, dict[str, str]], None] 46 | 47 | def __init__( 48 | self, 49 | props: PropsType, 50 | slots: Dict[str, NodeGenerator], 51 | component_name: str, 52 | title_getter: Callable[[], str], 53 | meta_getter: Callable[[], dict[str, dict[str, str]]], 54 | title_setter: Callable[[str], None], 55 | meta_setter: Callable[[str, dict[str, str]], None], 56 | ) -> None: 57 | self.__props = props 58 | self.__slots = slots 59 | self._component_name = component_name 60 | self.__on_before_rendering = None 61 | self.__on_after_rendering = None 62 | self.__on_before_destroy = None 63 | self.__title_getter = title_getter 64 | self.__meta_getter = meta_getter 65 | self.__title_setter = title_setter 66 | self.__meta_setter = meta_setter 67 | 68 | @property 69 | def props(self) -> PropsType: 70 | return self.__props 71 | 72 | def slots( 73 | self, 74 | name: str, 75 | fallback: NodeGenerator | None = None, 76 | ) -> ElementChildren: 77 | if name in self.__slots: 78 | return self.__slots[name]() 79 | elif fallback is not None: 80 | return fallback() 81 | else: 82 | logging.warning( 83 | f"Componet '{self._component_name}' is not given a slot named '{name}'" 84 | ) 85 | return None 86 | 87 | def on_before_rendering(self, func: Callable[[], Any]) -> None: 88 | self.__on_before_rendering = func 89 | 90 | def on_after_rendering(self, func: Callable[[], Any]) -> None: 91 | self.__on_after_rendering = func 92 | 93 | def on_before_destroy(self, func: Callable[[], Any]) -> None: 94 | self.__on_before_destroy = func 95 | 96 | def get_title(self) -> str: 97 | return self.__title_getter() 98 | 99 | def get_meta(self) -> dict[str, dict[str, str]]: 100 | return self.__meta_getter() 101 | 102 | def set_title(self, title: str) -> None: 103 | self.__title_setter(title) 104 | 105 | def set_meta(self, key: str, attributes: dict[str, str]) -> None: 106 | self.__meta_setter(key, attributes) 107 | 108 | def __get_lifecyclehooks__(self) -> _Lifecyclehooks: 109 | hooks: _Lifecyclehooks = {} 110 | if self.__on_before_rendering: 111 | hooks["on_before_rendering"] = self.__on_before_rendering 112 | if self.__on_after_rendering: 113 | hooks["on_after_rendering"] = self.__on_after_rendering 114 | if self.__on_before_destroy: 115 | hooks["on_before_destroy"] = self.__on_before_destroy 116 | return hooks 117 | 118 | 119 | class ComponentContext(Protocol[PropsType]): 120 | @property 121 | def props(self) -> PropsType: 122 | ... 123 | 124 | def slots( 125 | self, 126 | name: str, 127 | fallback: NodeGenerator | None = None, 128 | ) -> ElementChildren: 129 | ... 130 | 131 | def on_before_rendering(self, func: Callable[[], Any]) -> None: 132 | ... 133 | 134 | def on_after_rendering(self, func: Callable[[], Any]) -> None: 135 | ... 136 | 137 | def on_before_destroy(self, func: Callable[[], Any]) -> None: 138 | ... 139 | 140 | def get_title(self) -> str: 141 | ... 142 | 143 | def get_meta(self) -> dict[str, dict[str, str]]: 144 | ... 145 | 146 | def set_title(self, title: str) -> None: 147 | ... 148 | 149 | def set_meta(self, key: str, attributes: dict[str, str]) -> None: 150 | ... 151 | 152 | 153 | class ClassStyleComponentContenxt(Protocol[PropsType]): 154 | @property 155 | def props(self) -> PropsType: 156 | ... 157 | 158 | def slots( 159 | self, 160 | name: str, 161 | fallback: NodeGenerator | None = None, 162 | ) -> ElementChildren: 163 | ... 164 | 165 | def get_title(self) -> str: 166 | ... 167 | 168 | def get_meta(self) -> dict[str, dict[str, str]]: 169 | ... 170 | 171 | def set_title(self, title: str) -> None: 172 | ... 173 | 174 | def set_meta(self, key: str, attributes: dict[str, str]) -> None: 175 | ... 176 | 177 | 178 | @final 179 | class ComponentProperty(TypedDict): 180 | component_id: str 181 | component_name: str 182 | template: ElementChildren 183 | on_before_rendering: Callable[[], None] 184 | on_after_rendering: Callable[[], None] 185 | on_before_destroy: Callable[[], None] 186 | 187 | 188 | def generate_id(component_name: str) -> str: 189 | return hashlib.md5(component_name.encode()).hexdigest() 190 | -------------------------------------------------------------------------------- /webcompy/components/_component.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any, Callable, ClassVar, Type 3 | from typing_extensions import TypeAlias, TypeGuard 4 | from uuid import UUID, uuid4 5 | from webcompy.elements.types._element import ElementBase, Element 6 | from webcompy.components._libs import Context, ComponentProperty, generate_id 7 | from webcompy.components._abstract import ComponentAbstract 8 | from webcompy.elements.typealias._element_property import ElementChildren 9 | from webcompy.exception import WebComPyException 10 | from webcompy.reactive import ReactiveDict, computed_property 11 | 12 | 13 | FuncComponentDef: TypeAlias = Callable[[Context[Any]], ElementChildren] 14 | ClassComponentDef: TypeAlias = Type[ComponentAbstract[Any]] 15 | 16 | 17 | def _is_function_style_component_def(obj: Any) -> TypeGuard[FuncComponentDef]: 18 | return callable(obj) and getattr(obj, "__webcompy_componet_definition__", None) 19 | 20 | 21 | def _is_class_style_component_def(obj: Any) -> TypeGuard[ClassComponentDef]: 22 | return isinstance(obj, type) and issubclass(obj, ComponentAbstract) 23 | 24 | 25 | class HeadPropsStore: 26 | def __init__(self) -> None: 27 | self.titles = ReactiveDict[UUID, str]({}) 28 | self.head_metas = ReactiveDict[UUID, dict[str, dict[str, str]]]({}) 29 | 30 | @computed_property 31 | def title(self): 32 | return tuple(self.titles.values())[-1] 33 | 34 | @computed_property 35 | def head_meta(self): 36 | return { 37 | key: attributes 38 | for meta in self.head_metas.values() 39 | for key, attributes in meta.items() 40 | } 41 | 42 | 43 | class Component(ElementBase): 44 | _head_props: ClassVar = HeadPropsStore() 45 | 46 | def __init__( 47 | self, 48 | component_def: FuncComponentDef | ClassComponentDef, 49 | props: Any, 50 | slots: dict[str, Callable[[], ElementChildren]], 51 | ) -> None: 52 | self._instance_id = uuid4() 53 | self._attrs = {} 54 | self._event_handlers = {} 55 | self._ref = None 56 | self._children = [] 57 | super().__init__() 58 | self.__init_component(self.__setup(component_def, props, slots)) 59 | 60 | def __setup( 61 | self, 62 | component_def: FuncComponentDef | ClassComponentDef, 63 | props: Any, 64 | slots: dict[str, Callable[[], ElementChildren]], 65 | ) -> ComponentProperty: 66 | if _is_class_style_component_def(component_def): 67 | return component_def.__get_component_instance__( 68 | Context( 69 | props, 70 | slots, 71 | component_def.__get_name__(), 72 | lambda: Component._head_props.title.value, 73 | lambda: Component._head_props.head_meta.value, 74 | self._set_title, 75 | self._set_meta, 76 | ) 77 | ).__get_component_property__() 78 | elif _is_function_style_component_def(component_def): 79 | component_name = component_def.__name__ 80 | context = Context( 81 | props, 82 | slots, 83 | component_name, 84 | lambda: Component._head_props.title.value, 85 | lambda: Component._head_props.head_meta.value, 86 | self._set_title, 87 | self._set_meta, 88 | ) 89 | template = component_def(context) 90 | hooks = context.__get_lifecyclehooks__() 91 | return { 92 | "component_id": generate_id(component_name), 93 | "component_name": component_name, 94 | "template": template, 95 | "on_before_rendering": hooks.get("on_before_rendering", lambda: None), 96 | "on_after_rendering": hooks.get("on_after_rendering", lambda: None), 97 | "on_before_destroy": hooks.get("on_before_destroy", lambda: None), 98 | } 99 | else: 100 | raise WebComPyException("Invalid Component Definition") 101 | 102 | def __init_component(self, property: ComponentProperty): 103 | node = property["template"] 104 | if not isinstance(node, Element): 105 | raise WebComPyException( 106 | "Root Node of Component must be instance of 'Element'" 107 | ) 108 | self._tag_name = node._tag_name 109 | self._attrs = { 110 | **node._attrs, 111 | "webcompy-component": property["component_name"], 112 | "webcompy-cid-" + property["component_id"]: True, 113 | } 114 | self._event_handlers = node._event_handlers 115 | self._ref = node._ref 116 | self._init_children(node._children) 117 | self._property = property 118 | 119 | def _render(self): 120 | self._property["on_before_rendering"]() 121 | super()._render() 122 | self._property["on_after_rendering"]() 123 | 124 | def _remove_element(self, recursive: bool = True, remove_node: bool = True): 125 | if self._instance_id in Component._head_props.titles: 126 | del Component._head_props.titles[self._instance_id] 127 | if self._instance_id in Component._head_props.head_metas: 128 | del Component._head_props.head_metas[self._instance_id] 129 | self._property["on_before_destroy"]() 130 | super()._remove_element(recursive, remove_node) 131 | 132 | def _get_belonging_component(self): 133 | return self._property["component_id"] 134 | 135 | def _get_belonging_components(self) -> tuple["Component", ...]: 136 | return (*self._parent._get_belonging_components(), self) 137 | 138 | def _set_title(self, title: str): 139 | Component._head_props.titles[self._instance_id] = title 140 | 141 | def _set_meta(self, key: str, attributes: dict[str, str]): 142 | meta = Component._head_props.head_metas.get(self._instance_id, {}) 143 | meta[key] = attributes 144 | Component._head_props.head_metas[self._instance_id] = meta 145 | -------------------------------------------------------------------------------- /webcompy/router/_router.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from functools import partial 3 | from re import compile as re_compile, escape as re_escape 4 | import urllib.parse 5 | from typing import ( 6 | Any, 7 | Callable, 8 | ClassVar, 9 | List, 10 | Literal, 11 | Match, 12 | Optional, 13 | Sequence, 14 | Tuple, 15 | Union, 16 | ) 17 | from typing_extensions import TypeAlias 18 | from webcompy.elements.typealias._element_property import ElementChildren 19 | from webcompy.components import ComponentGenerator, WebComPyComponentException 20 | from webcompy.elements.types._switch import NodeGenerator 21 | from webcompy.reactive._computed import computed_property 22 | from webcompy.router._change_event_hander import Location 23 | from webcompy.router._pages import RouterPage 24 | from webcompy.router._context import TypedRouterContext, RouterContext 25 | 26 | 27 | RouteType: TypeAlias = Tuple[ 28 | str, 29 | Callable[[str], Optional[Match[str]]], 30 | List[str], 31 | ComponentGenerator[RouterContext], 32 | RouterPage, 33 | ] 34 | 35 | _convert_to_regex_pattern = partial(re_compile(r"\\\{[^\{\}/]+\\\}").sub, r"([^/]*?)") 36 | _get_path_params = re_compile(r"{([^\{\}/]+)}").findall 37 | 38 | 39 | class Router: 40 | _instance: ClassVar[Union["Router", None]] = None 41 | 42 | _location: Location 43 | __mode__: Literal["hash", "history"] 44 | __routes__: list[RouteType] 45 | 46 | def __init__( 47 | self, 48 | *pages: RouterPage, 49 | default: ComponentGenerator[TypedRouterContext[Any, Any, Any]] | None = None, 50 | mode: Literal["hash", "history"] = "hash", 51 | base_url: str = "", 52 | ) -> None: 53 | if Router._instance: 54 | raise WebComPyComponentException("Only one instance of 'Router' can exist.") 55 | else: 56 | Router._instance = self 57 | self.__mode__ = mode 58 | self.__base_url__ = base_url.strip().strip("/") 59 | self._base_url_stripper = partial( 60 | re_compile("^" + re_escape("/" + self.__base_url__)).sub, "" 61 | ) 62 | self._location = Location(self.__mode__, self.__base_url__) 63 | self.__routes__ = self._generate_routes(pages) 64 | self._default = default 65 | 66 | @computed_property 67 | def __cases__(self): 68 | return list(map(self._get_elements_generator, self.__routes__)) 69 | 70 | def __default__(self) -> ElementChildren: 71 | if self._default: 72 | current_path, search = self._get_current_path() 73 | if current_path == "//:404://": 74 | current_path = "/404.html" 75 | elif self.__mode__ == "history" and self.__base_url__: 76 | current_path = self._base_url_stripper(current_path) 77 | props = self._generate_router_context( 78 | current_path, 79 | search, 80 | None, 81 | [], 82 | ) 83 | return self._default(props) 84 | else: 85 | return "Not Found" 86 | 87 | def _get_current_path(self): 88 | decoded_href = tuple( 89 | map(urllib.parse.unquote, self._location.value.split("?", 2)) 90 | ) 91 | pathname, search = ( 92 | (decoded_href[0], "") if len(decoded_href) == 1 else decoded_href 93 | ) 94 | return pathname, search 95 | 96 | def _get_elements_generator(self, args: RouteType) -> Tuple[Any, NodeGenerator]: 97 | match_targeted_routes, path_param_names, component = args[1:-1] 98 | current_path, search = self._get_current_path() 99 | if self.__mode__ == "history" and self.__base_url__: 100 | current_path = self._base_url_stripper(current_path) 101 | match = match_targeted_routes(current_path.strip("/")) 102 | if match: 103 | props = self._generate_router_context( 104 | current_path, 105 | search, 106 | match, 107 | path_param_names, 108 | ) 109 | return (match, lambda: component(props)) 110 | else: 111 | return (match, lambda: None) 112 | 113 | def _generate_router_context( 114 | self, 115 | pathname: str, 116 | search: str, 117 | match: Match[str] | None, 118 | path_param_names: List[str], 119 | ): 120 | query = ( 121 | { 122 | name: value 123 | for name, value in ( 124 | [it[0], ""] if len(it) == 1 else it 125 | for it in (q.split("=", 2) for q in search.split("&")) 126 | ) 127 | if name and value 128 | } 129 | if search 130 | else {} 131 | ) 132 | if match: 133 | path_params = ( 134 | dict(zip(path_param_names, match.groups())) if path_param_names else {} 135 | ) 136 | else: 137 | path_params = {} 138 | return TypedRouterContext.__create_instance__( 139 | path=pathname, 140 | query_params=query, 141 | path_params=path_params, 142 | state=self._location.state if self._location.state else {}, 143 | ) 144 | 145 | def _generate_route_matcher(self, path: str): 146 | return re_compile(_convert_to_regex_pattern(re_escape(path)) + "$").match 147 | 148 | def _generate_routes(self, pages: Sequence[RouterPage]) -> list[RouteType]: 149 | return [ 150 | (*path, component, page) 151 | for path, component, page in zip( 152 | map( 153 | lambda path: ( 154 | path, 155 | self._generate_route_matcher(path), 156 | _get_path_params(path), 157 | ), 158 | map(lambda page: page["path"].strip("/"), pages), 159 | ), 160 | map(lambda page: page["component"], pages), 161 | pages, 162 | ) 163 | ] 164 | 165 | def __set_path__(self, path: str, state: dict[str, Any] | None): 166 | self._location.__set_path__(path, state) 167 | -------------------------------------------------------------------------------- /webcompy/reactive/_base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from abc import abstractmethod 3 | from functools import wraps 4 | from typing import ( 5 | Any, 6 | Callable, 7 | ClassVar, 8 | Generic, 9 | Set, 10 | Type, 11 | TypeVar, 12 | cast, 13 | final, 14 | ) 15 | from typing_extensions import ParamSpec 16 | 17 | 18 | V = TypeVar("V") 19 | A = ParamSpec("A") 20 | T = TypeVar("T") 21 | 22 | 23 | def _instantiate(cls: Type[T]) -> T: 24 | return cls() 25 | 26 | 27 | @_instantiate 28 | class ReactiveStore: 29 | __instances: dict[int, "ReactiveBase[Any]"] 30 | __on_before_updating: dict[int, Callable[[Any], Any]] 31 | __on_after_updating: dict[int, Callable[[Any], Any]] 32 | __callback_ids: dict[int, Set[int]] 33 | __latest_callback_id: int 34 | __dependency: list["ReactiveBase[Any]"] | None 35 | 36 | def __init__(self) -> None: 37 | self.__instances = {} 38 | self.__on_before_updating = {} 39 | self.__on_after_updating = {} 40 | self.__callback_ids = {} 41 | self.__latest_instance_id = 0 42 | self.__latest_callback_id = 0 43 | self.__dependency = None 44 | 45 | def add_reactive_instance(self, reactive: "ReactiveBase[Any]"): 46 | self.__latest_instance_id += 1 47 | reactive.__reactive_id__ = self.__latest_instance_id 48 | self.__instances[reactive.__reactive_id__] = reactive 49 | self.__callback_ids[reactive.__reactive_id__] = set() 50 | 51 | def add_on_after_updating( 52 | self, reactive: "ReactiveBase[Any]", func: Callable[[Any], Any] 53 | ): 54 | self.__latest_callback_id += 1 55 | callback_id = self.__latest_callback_id 56 | self.__on_after_updating[callback_id] = func 57 | self.__callback_ids[reactive.__reactive_id__].add(callback_id) 58 | return callback_id 59 | 60 | def add_on_before_updating( 61 | self, reactive: "ReactiveBase[Any]", func: Callable[[Any], Any] 62 | ): 63 | self.__latest_callback_id += 1 64 | callback_id = self.__latest_callback_id 65 | self.__on_before_updating[callback_id] = func 66 | self.__callback_ids[reactive.__reactive_id__].add(callback_id) 67 | return callback_id 68 | 69 | def callback_after_updating(self, instance: "ReactiveBase[Any]", value: Any): 70 | for idx, func in tuple(self.__on_after_updating.items()): 71 | if idx in self.__callback_ids[instance.__reactive_id__]: 72 | func(value) 73 | 74 | def callback_before_updating(self, instance: "ReactiveBase[Any]", value: Any): 75 | for idx, func in tuple(self.__on_before_updating.items()): 76 | if idx in self.__callback_ids[instance.__reactive_id__]: 77 | func(value) 78 | 79 | def resister(self, reactive: "ReactiveBase[Any]"): 80 | if self.__dependency is not None: 81 | self.__dependency.append(reactive) 82 | 83 | def detect_dependency( 84 | self, func: Callable[[], V] 85 | ) -> tuple[V, list["ReactiveBase[Any]"]]: 86 | self.__dependency = [] 87 | value = func() 88 | dependency = self.__dependency 89 | self.__dependency = None 90 | uniq_ids: set[int] = set() 91 | return value, [ 92 | reactive 93 | for reactive in dependency 94 | if reactive.__reactive_id__ not in uniq_ids 95 | and not uniq_ids.add(reactive.__reactive_id__) 96 | ] 97 | 98 | def remove_callback(self, callback_id: int): 99 | if callback_id in self.__on_after_updating: 100 | del self.__on_after_updating[callback_id] 101 | elif callback_id in self.__on_before_updating: 102 | del self.__on_before_updating[callback_id] 103 | targeted_isntance_id: int | None = None 104 | for isntance_id in self.__callback_ids: 105 | if callback_id in self.__callback_ids[isntance_id]: 106 | targeted_isntance_id = isntance_id 107 | break 108 | if targeted_isntance_id is not None: 109 | self.__callback_ids[targeted_isntance_id].remove(callback_id) 110 | 111 | 112 | # Reactives 113 | class ReactiveBase(Generic[V]): 114 | _store: ClassVar = ReactiveStore 115 | _value: V 116 | __reactive_id__: int 117 | 118 | def __init__(self, init_value: V) -> None: 119 | self._value = init_value 120 | self._store.add_reactive_instance(self) 121 | 122 | @property 123 | @abstractmethod 124 | def value(self) -> V: 125 | ... 126 | 127 | @final 128 | def on_after_updating(self, func: Callable[[V], Any]): 129 | return self._store.add_on_after_updating(self, func) 130 | 131 | @final 132 | def on_before_updating(self, func: Callable[[V], Any]): 133 | return self._store.add_on_before_updating(self, func) 134 | 135 | @final 136 | @staticmethod 137 | def _change_event(reactive_obj_method: Callable[A, V]) -> Callable[A, V]: 138 | @wraps(reactive_obj_method) 139 | def method(*args: A.args, **kwargs: A.kwargs) -> V: 140 | instance = cast(ReactiveBase[V], args[0]) 141 | ReactiveBase._store.callback_before_updating(instance, instance._value) 142 | ret = reactive_obj_method(*args, **kwargs) 143 | ReactiveBase._store.callback_after_updating(instance, ret) 144 | return ret 145 | 146 | return method 147 | 148 | @final 149 | @staticmethod 150 | def _get_evnet(reactive_obj_method: Callable[A, V]) -> Callable[A, V]: 151 | @wraps(reactive_obj_method) 152 | def method(*args: A.args, **kwargs: A.kwargs) -> V: 153 | ReactiveBase._store.resister(cast(ReactiveBase[V], args[0])) 154 | return reactive_obj_method(*args, **kwargs) 155 | 156 | return method 157 | 158 | 159 | class Reactive(ReactiveBase[V]): 160 | @final 161 | @ReactiveBase._change_event 162 | def set_value(self, new_value: V) -> V: 163 | self._value = new_value 164 | return self._value 165 | 166 | @final 167 | @property 168 | @ReactiveBase._get_evnet 169 | def value(self) -> V: 170 | return self._value 171 | 172 | @final 173 | @value.setter 174 | def value(self, new_value: V): 175 | self.set_value(new_value) 176 | -------------------------------------------------------------------------------- /docs_src/pages/demo/todo.py: -------------------------------------------------------------------------------- 1 | from webcompy.elements import html 2 | from webcompy.components import define_component, ComponentContext 3 | from webcompy.router import RouterContext 4 | from ...components.demo_display import DemoDisplay 5 | from ...templates.demo.todo import ToDoList 6 | 7 | 8 | @define_component 9 | def ToDoListPage(context: ComponentContext[RouterContext]): 10 | title = "ToDo List" 11 | context.set_title(f"{title} - WebCompy Demo") 12 | 13 | return html.DIV( 14 | {}, 15 | DemoDisplay( 16 | { 17 | "title": title, 18 | "code": """ 19 | from typing import Any, TypedDict 20 | from webcompy.elements import html, repeat, DomNodeRef 21 | from webcompy.components import define_component, ComponentContext 22 | from webcompy.reactive import Reactive, ReactiveList, computed 23 | 24 | 25 | class TodoData(TypedDict): 26 | title: Reactive[str] 27 | done: Reactive[bool] 28 | 29 | 30 | @define_component 31 | def ToDoItem(context: ComponentContext[TodoData]): 32 | input_ref = DomNodeRef() 33 | 34 | def on_change_state(_: Any): 35 | context.props["done"].value = input_ref.checked 36 | 37 | return html.LI( 38 | {}, 39 | html.LABEL( 40 | {}, 41 | html.INPUT( 42 | { 43 | "type": "checkbox", 44 | "@change": on_change_state, 45 | ":ref": input_ref, 46 | }, 47 | ), 48 | ), 49 | " ", 50 | html.SPAN( 51 | { 52 | "style": computed( 53 | lambda: "text-decoration: line-through;" 54 | if context.props["done"].value 55 | else "" 56 | ) 57 | }, 58 | context.props["title"], 59 | ), 60 | ) 61 | 62 | 63 | ToDoItem.scoped_style = { 64 | "li": { 65 | "color": "#2d8fdd", 66 | "border-left": " solid 6px #2d8fdd", 67 | "background": "#f1f8ff", 68 | "line-height": "1.5", 69 | "margin": "5px", 70 | "padding": "5px", 71 | "vertical-align": "middle", 72 | "list-style-type": "none", 73 | } 74 | } 75 | 76 | 77 | @define_component 78 | def ToDoList(_: ComponentContext[None]): 79 | input_ref = DomNodeRef() 80 | data: ReactiveList[TodoData] = ReactiveList( 81 | [ 82 | { 83 | "title": Reactive("Try WebComPy"), 84 | "done": Reactive(False), 85 | }, 86 | { 87 | "title": Reactive("Create WebComPy project"), 88 | "done": Reactive(False), 89 | } 90 | ] 91 | ) 92 | 93 | def append_item(_: Any): 94 | title = input_ref.value 95 | if title: 96 | data.append( 97 | { 98 | "title": Reactive(title), 99 | "done": Reactive(False), 100 | } 101 | ) 102 | input_ref.value = "" 103 | 104 | def remove_done_items(_: Any): 105 | items_remove = reversed( 106 | [idx for idx, item in enumerate(data.value) if item["done"].value] 107 | ) 108 | for idx in items_remove: 109 | data.pop(idx) 110 | 111 | return html.DIV( 112 | {}, 113 | html.P( 114 | {}, 115 | "Title: ", 116 | html.INPUT({":ref": input_ref}), 117 | html.BUTTON({"@click": append_item}, "Add ToDo"), 118 | html.BUTTON({"@click": remove_done_items}, "Remove Done Items"), 119 | ), 120 | html.UL( 121 | {}, 122 | repeat( 123 | sequence=data, 124 | template=ToDoItem, 125 | ), 126 | ), 127 | ) 128 | 129 | 130 | ToDoList.scoped_style = { 131 | "button": { 132 | "display": "inline-block", 133 | "text-decoration": "none", 134 | "border": "solid 2px #668ad8", 135 | "border-radius": "3px", 136 | "transition": "0.2s", 137 | "color": "black", 138 | }, 139 | "button:hover": { 140 | "background": "#668ad8", 141 | "color": "white", 142 | }, 143 | }""", 144 | }, 145 | slots={"component": lambda: ToDoList(None)}, 146 | ), 147 | ) 148 | -------------------------------------------------------------------------------- /docs_src/pages/demo/fizzbuzz.py: -------------------------------------------------------------------------------- 1 | from webcompy.elements import html 2 | from webcompy.components import define_component, ComponentContext 3 | from webcompy.router import RouterContext 4 | from ...templates.demo.fizzbuzz import Fizzbuzz 5 | from ...components.demo_display import DemoDisplay 6 | 7 | 8 | @define_component 9 | def FizzbuzzPage(context: ComponentContext[RouterContext]): 10 | title = "FizzBuzz" 11 | context.set_title(f"{title} - WebCompy Demo") 12 | 13 | return html.DIV( 14 | {}, 15 | DemoDisplay( 16 | { 17 | "title": title, 18 | "code": """ 19 | from webcompy.reactive import Reactive, computed_property 20 | from webcompy.elements import html, repeat, switch 21 | from webcompy.components import ( 22 | TypedComponentBase, 23 | component_class, 24 | on_before_rendering, 25 | component_template, 26 | ) 27 | from webcompy.elements import DOMEvent 28 | 29 | 30 | @component_class 31 | class Fizzbuzz(TypedComponentBase(props_type=None)): 32 | def __init__(self) -> None: 33 | self.opened = Reactive(True) 34 | self.count = Reactive(10) 35 | 36 | @computed_property 37 | def fizzbuzz_list(self): 38 | li: list[str] = [] 39 | for n in range(1, self.count.value + 1): 40 | if n % 15 == 0: 41 | li.append("FizzBuzz") 42 | elif n % 5 == 0: 43 | li.append("Fizz") 44 | elif n % 3 == 0: 45 | li.append("Buzz") 46 | else: 47 | li.append(str(n)) 48 | return li 49 | 50 | @computed_property 51 | def toggle_button_text(self): 52 | return "Hide" if self.opened.value else "Open" 53 | 54 | def add(self, ev: DOMEvent): 55 | self.count.value += 1 56 | 57 | def pop(self, ev: DOMEvent): 58 | if self.count.value > 0: 59 | self.count.value -= 1 60 | 61 | def toggle(self, ev: DOMEvent): 62 | self.opened.value = not self.opened.value 63 | 64 | @on_before_rendering 65 | def on_before_rendering(self): 66 | self.count.value = 10 67 | 68 | @component_template 69 | def template(self): 70 | return html.DIV( 71 | {}, 72 | html.P( 73 | {}, 74 | html.BUTTON( 75 | {"@click": self.toggle}, 76 | self.toggle_button_text, 77 | ), 78 | html.BUTTON( 79 | {"@click": self.add}, 80 | "Add", 81 | ), 82 | html.BUTTON( 83 | {"@click": self.pop}, 84 | "Pop", 85 | ), 86 | ), 87 | html.P( 88 | {}, 89 | "Count: ", 90 | self.count, 91 | ), 92 | switch( 93 | { 94 | "case": self.opened, 95 | "generator": lambda: html.DIV( 96 | {}, 97 | html.UL( 98 | {}, 99 | repeat( 100 | self.fizzbuzz_list, 101 | lambda s: html.LI({}, s), 102 | ), 103 | ), 104 | ), 105 | }, 106 | default=lambda: html.DIV( 107 | {}, 108 | "FizzBuzz Hidden", 109 | ), 110 | ), 111 | ) 112 | 113 | 114 | Fizzbuzz.scoped_style = { 115 | "ul": { 116 | "border": "dashed 2px #668ad8", 117 | "background": "#f1f8ff", 118 | "padding": "0.5em 0.5em 0.5em 2em", 119 | }, 120 | "ul > li:nth-child(3n)": { 121 | "color": "red", 122 | }, 123 | "ul > li:nth-child(5n)": { 124 | "color": "blue", 125 | }, 126 | "ul > li:nth-child(15n)": { 127 | "color": "purple", 128 | }, 129 | "button": { 130 | "display": "inline-block", 131 | "text-decoration": "none", 132 | "border": "solid 2px #668ad8", 133 | "border-radius": "3px", 134 | "transition": "0.2s", 135 | "color": "black", 136 | }, 137 | "button:hover": { 138 | "background": "#668ad8", 139 | "color": "white", 140 | }, 141 | }""", 142 | }, 143 | slots={"component": lambda: Fizzbuzz(None)}, 144 | ), 145 | ) 146 | -------------------------------------------------------------------------------- /webcompy/app/_root_component.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TypedDict 3 | from uuid import uuid4 4 | from webcompy.elements import html 5 | from webcompy.elements._dom_objs import DOMNode 6 | from webcompy._browser._modules import browser 7 | from webcompy.components._abstract import NonPropsComponentBase 8 | from webcompy.components._component import Component 9 | from webcompy.components._generator import ComponentGenerator, ComponentStore 10 | from webcompy.components._decorators import component_template 11 | from webcompy.router._router import Router 12 | from webcompy.router._view import RouterView 13 | from webcompy.router._link import TypedRouterLink 14 | from webcompy.reactive import Computed 15 | from webcompy.exception import WebComPyException 16 | 17 | 18 | class Head(TypedDict, total=False): 19 | title: str 20 | meta: dict[str, dict[str, str]] 21 | link: list[dict[str, str]] 22 | script: list[tuple[dict[str, str], str | None]] 23 | 24 | 25 | class HeadReactive(TypedDict): 26 | title: Computed[str] 27 | meta: Computed[dict[str, dict[str, str]]] 28 | link: list[dict[str, str]] 29 | script: list[tuple[dict[str, str], str | None]] 30 | 31 | 32 | class AppRootComponent(NonPropsComponentBase): 33 | @component_template 34 | def template(self): 35 | return html.DIV({"id": "webcompy-app"}, self.context.slots("root")) 36 | 37 | 38 | class AppDocumentRoot(Component): 39 | _router: Router | None 40 | _links: list[dict[str, str]] 41 | _scripts: list[tuple[dict[str, str], str | None]] 42 | _scripts_head: list[tuple[dict[str, str], str | None]] 43 | __loading: bool 44 | 45 | def __init__( 46 | self, root_component: ComponentGenerator[None], router: Router | None 47 | ) -> None: 48 | self._instance_id = uuid4() 49 | self.__loading = True 50 | self._router = router 51 | self._set_title("") 52 | self._links = [] 53 | self._scripts = [] 54 | self._scripts_head = [] 55 | if self._router: 56 | RouterView.__set_router__(self._router) 57 | TypedRouterLink.__set_router__(self._router) 58 | if browser: 59 | 60 | def updte_title(title: str): 61 | browser.document.title = title # type: ignore 62 | 63 | Component._head_props.title.on_after_updating(updte_title) 64 | 65 | super().__init__(AppRootComponent, None, {"root": lambda: root_component(None)}) 66 | 67 | @property 68 | def render(self): 69 | return self._render 70 | 71 | def _render(self): 72 | self._property["on_before_rendering"]() 73 | for child in self._children: 74 | child._render() 75 | self._property["on_after_rendering"]() 76 | if browser and self.__loading: 77 | self.__loading = False 78 | browser.document.getElementById("webcompy-loading").remove() 79 | 80 | def _init_node(self) -> DOMNode: 81 | if browser: 82 | node = browser.document.getElementById("webcompy-app") 83 | for name in tuple(node.getAttributeNames()): 84 | if name != "id": 85 | node.removeAttribute(name) 86 | node.__webcompy_node__ = True 87 | self._mark_as_prerendered(node) 88 | return node 89 | else: 90 | raise WebComPyException("Not in Browser environment.") 91 | 92 | def _mark_as_prerendered(self, node: DOMNode): 93 | node.__webcompy_prerendered_node__ = True 94 | for child in getattr(node, "childNodes", []): 95 | self._mark_as_prerendered(child) 96 | 97 | def _mount_node(self): 98 | pass 99 | 100 | def _get_belonging_component(self): 101 | return "" 102 | 103 | def _get_belonging_components(self) -> tuple["Component", ...]: 104 | return (self,) 105 | 106 | @property 107 | def routes(self): 108 | return self._router.__routes__ if self._router else None 109 | 110 | @property 111 | def router_mode(self): 112 | return self._router.__mode__ if self._router else None 113 | 114 | def set_path(self, path: str): 115 | if self._router: 116 | self._router.__set_path__(path, None) 117 | else: 118 | return None 119 | 120 | @property 121 | def style(self): 122 | return " ".join( 123 | style 124 | for component in ComponentStore.components.values() 125 | if (style := component.scoped_style) 126 | ) 127 | 128 | def _render_html( 129 | self, newline: bool = False, indent: int = 2, count: int = 0 130 | ) -> str: 131 | hidden = self._attrs.get("hidden") 132 | self._attrs["hidden"] = True 133 | html = super()._render_html(newline, indent, count) 134 | if hidden is None: 135 | del self._attrs["hidden"] 136 | else: 137 | self._attrs["hidden"] = hidden 138 | return html 139 | 140 | # Head controllers 141 | def set_title(self, title: str): 142 | self._set_title(title) 143 | 144 | def set_meta(self, key: str, attributes: dict[str, str]): 145 | self._set_meta(key, attributes) 146 | 147 | def append_link(self, attributes: dict[str, str]): 148 | self._links.append(attributes) 149 | 150 | def append_script( 151 | self, 152 | attributes: dict[str, str], 153 | script: str | None = None, 154 | in_head: bool = False, 155 | ): 156 | if not in_head: 157 | self._scripts.append((attributes, script)) 158 | else: 159 | self._scripts_head.append((attributes, script)) 160 | 161 | def set_head(self, head: Head): 162 | self._set_title(head.get("title", "")) 163 | for key, value in head.get("meta", {}).items(): 164 | self._set_meta(key, value) 165 | self._links = head.get("link", []) 166 | self._scripts_head = head.get("script", []) 167 | 168 | def update_head(self, head: Head): 169 | if "title" in head: 170 | self.set_title(head["title"]) 171 | for key, meta in head.get("meta", {}).items(): 172 | self.set_meta(key, meta) 173 | for link in head.get("link", []): 174 | self.append_link(link) 175 | for attrs, script in head.get("script", []): 176 | self.append_script(attrs, script, True) 177 | 178 | @property 179 | def head(self) -> HeadReactive: 180 | return { 181 | "title": Component._head_props.title, 182 | "meta": Component._head_props.head_meta, 183 | "link": self._links, 184 | "script": self._scripts_head, 185 | } 186 | 187 | @property 188 | def scripts(self): 189 | return self._scripts 190 | --------------------------------------------------------------------------------