├── 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}{self._tag_name}>",
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 |
--------------------------------------------------------------------------------