diff --git a/docs/source/_custom_js/package-lock.json b/docs/source/_custom_js/package-lock.json index b648d4992..8632d1665 100644 --- a/docs/source/_custom_js/package-lock.json +++ b/docs/source/_custom_js/package-lock.json @@ -19,7 +19,7 @@ } }, "../../../src/client/packages/idom-client-react": { - "version": "0.40.2", + "version": "0.41.0", "integrity": "sha512-pIK5eNwFSHKXg7ClpASWFVKyZDYxz59MSFpVaX/OqJFkrJaAxBuhKGXNTMXmuyWOL5Iyvb/ErwwDRxQRzMNkfQ==", "license": "MIT", "dependencies": { diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 9af5293b2..4b4cf55be 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -26,6 +26,12 @@ Unreleased **Removed** - :pull:`840` - remove ``IDOM_FEATURE_INDEX_AS_DEFAULT_KEY`` option +- :pull:`835` - ``serve_static_files`` option from backend configuration + +**Added** + +- :pull:`835` - ability to customize the ```` element of IDOM's built-in client. +- :pull:`835` - ``vdom_to_html`` utility function. v0.41.0 diff --git a/src/client/index.html b/src/client/index.html index 622ec0a51..87f0244f2 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -2,12 +2,8 @@ - - IDOM + + {__head__}
diff --git a/src/idom/__init__.py b/src/idom/__init__.py index 22b95446f..ad6a34db5 100644 --- a/src/idom/__init__.py +++ b/src/idom/__init__.py @@ -18,7 +18,7 @@ from .core.layout import Layout from .core.serve import Stop from .core.vdom import vdom -from .utils import Ref, html_to_vdom +from .utils import Ref, html_to_vdom, vdom_to_html from .widgets import hotswap @@ -53,6 +53,7 @@ "use_ref", "use_scope", "use_state", + "vdom_to_html", "vdom", "web", ] diff --git a/src/idom/backend/_asgi.py b/src/idom/backend/_asgi.py deleted file mode 100644 index 94eaa2b88..000000000 --- a/src/idom/backend/_asgi.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import annotations - -import asyncio -from typing import Any, Awaitable - -from asgiref.typing import ASGIApplication -from uvicorn.config import Config as UvicornConfig -from uvicorn.server import Server as UvicornServer - - -async def serve_development_asgi( - app: ASGIApplication | Any, - host: str, - port: int, - started: asyncio.Event | None, -) -> None: - """Run a development server for starlette""" - server = UvicornServer( - UvicornConfig( - app, - host=host, - port=port, - loop="asyncio", - reload=True, - ) - ) - - coros: list[Awaitable[Any]] = [server.serve()] - - if started: - coros.append(_check_if_started(server, started)) - - try: - await asyncio.gather(*coros) - finally: - await asyncio.wait_for(server.shutdown(), timeout=3) - - -async def _check_if_started(server: UvicornServer, started: asyncio.Event) -> None: - while not server.started: - await asyncio.sleep(0.2) - started.set() diff --git a/src/idom/backend/_common.py b/src/idom/backend/_common.py new file mode 100644 index 000000000..90e2dea5b --- /dev/null +++ b/src/idom/backend/_common.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import asyncio +import os +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any, Awaitable, Sequence, cast + +from asgiref.typing import ASGIApplication +from uvicorn.config import Config as UvicornConfig +from uvicorn.server import Server as UvicornServer + +from idom import __file__ as _idom_file_path +from idom import html +from idom.config import IDOM_WEB_MODULES_DIR +from idom.core.types import VdomDict +from idom.utils import vdom_to_html + + +PATH_PREFIX = PurePosixPath("/_idom") +MODULES_PATH = PATH_PREFIX / "modules" +ASSETS_PATH = PATH_PREFIX / "assets" +STREAM_PATH = PATH_PREFIX / "stream" + +CLIENT_BUILD_DIR = Path(_idom_file_path).parent / "_client" + + +async def serve_development_asgi( + app: ASGIApplication | Any, + host: str, + port: int, + started: asyncio.Event | None, +) -> None: + """Run a development server for starlette""" + server = UvicornServer( + UvicornConfig( + app, + host=host, + port=port, + loop="asyncio", + reload=True, + ) + ) + + coros: list[Awaitable[Any]] = [server.serve()] + + if started: + coros.append(_check_if_started(server, started)) + + try: + await asyncio.gather(*coros) + finally: + await asyncio.wait_for(server.shutdown(), timeout=3) + + +async def _check_if_started(server: UvicornServer, started: asyncio.Event) -> None: + while not server.started: + await asyncio.sleep(0.2) + started.set() + + +def safe_client_build_dir_path(path: str) -> Path: + """Prevent path traversal out of :data:`CLIENT_BUILD_DIR`""" + return traversal_safe_path( + CLIENT_BUILD_DIR, + *("index.html" if path in ("", "/") else path).split("/"), + ) + + +def safe_web_modules_dir_path(path: str) -> Path: + """Prevent path traversal out of :data:`idom.config.IDOM_WEB_MODULES_DIR`""" + return traversal_safe_path(IDOM_WEB_MODULES_DIR.current, *path.split("/")) + + +def traversal_safe_path(root: str | Path, *unsafe: str | Path) -> Path: + """Raise a ``ValueError`` if the ``unsafe`` path resolves outside the root dir.""" + root = os.path.abspath(root) + + # Resolve relative paths but not symlinks - symlinks should be ok since their + # presence and where they point is under the control of the developer. + path = os.path.abspath(os.path.join(root, *unsafe)) + + if os.path.commonprefix([root, path]) != root: + # If the common prefix is not root directory we resolved outside the root dir + raise ValueError("Unsafe path") + + return Path(path) + + +def read_client_index_html(options: CommonOptions) -> str: + return ( + (CLIENT_BUILD_DIR / "index.html") + .read_text() + .format(__head__=vdom_head_elements_to_html(options.head)) + ) + + +def vdom_head_elements_to_html(head: Sequence[VdomDict] | VdomDict | str) -> str: + if isinstance(head, str): + return head + elif isinstance(head, dict): + if head.get("tagName") == "head": + head = cast(VdomDict, {**head, "tagName": ""}) + return vdom_to_html(head) + else: + return vdom_to_html(html._(head)) + + +@dataclass +class CommonOptions: + """Options for IDOM's built-in backed server implementations""" + + head: Sequence[VdomDict] | VdomDict | str = ( + html.title("IDOM"), + html.link( + { + "rel": "icon", + "href": "_idom/assets/idom-logo-square-small.svg", + "type": "image/svg+xml", + } + ), + ) + """Add elements to the ```` of the application. + + For example, this can be used to customize the title of the page, link extra + scripts, or load stylesheets. + """ + + url_prefix: str = "" + """The URL prefix where IDOM resources will be served from""" diff --git a/src/idom/backend/_urls.py b/src/idom/backend/_urls.py deleted file mode 100644 index c2523f3d7..000000000 --- a/src/idom/backend/_urls.py +++ /dev/null @@ -1,7 +0,0 @@ -from pathlib import PurePosixPath - - -PATH_PREFIX = PurePosixPath("/_idom") -MODULES_PATH = PATH_PREFIX / "modules" -ASSETS_PATH = PATH_PREFIX / "assets" -STREAM_PATH = PATH_PREFIX / "stream" diff --git a/src/idom/backend/flask.py b/src/idom/backend/flask.py index 7a7e18c8d..95c054b83 100644 --- a/src/idom/backend/flask.py +++ b/src/idom/backend/flask.py @@ -9,7 +9,7 @@ from queue import Queue as ThreadQueue from threading import Event as ThreadEvent from threading import Thread -from typing import Any, Callable, Dict, NamedTuple, NoReturn, Optional, Union, cast +from typing import Any, Callable, NamedTuple, NoReturn, Optional, cast from flask import ( Blueprint, @@ -25,6 +25,16 @@ from werkzeug.serving import BaseWSGIServer, make_server import idom +from idom.backend._common import ( + ASSETS_PATH, + MODULES_PATH, + PATH_PREFIX, + STREAM_PATH, + CommonOptions, + read_client_index_html, + safe_client_build_dir_path, + safe_web_modules_dir_path, +) from idom.backend.hooks import ConnectionContext from idom.backend.hooks import use_connection as _use_connection from idom.backend.types import Connection, Location @@ -33,13 +43,6 @@ from idom.core.types import ComponentType, RootComponentConstructor from idom.utils import Ref -from ._urls import ASSETS_PATH, MODULES_PATH, PATH_PREFIX, STREAM_PATH -from .utils import ( - CLIENT_BUILD_DIR, - safe_client_build_dir_path, - safe_web_modules_dir_path, -) - logger = logging.getLogger(__name__) @@ -134,21 +137,15 @@ def use_connection() -> Connection[_FlaskCarrier]: @dataclass -class Options: - """Render server config for :class:`FlaskRenderServer`""" +class Options(CommonOptions): + """Render server config for :func:`idom.backend.flask.configure`""" - cors: Union[bool, Dict[str, Any]] = False + cors: bool | dict[str, Any] = False """Enable or configure Cross Origin Resource Sharing (CORS) For more information see docs for ``flask_cors.CORS`` """ - serve_static_files: bool = True - """Whether or not to serve static files (i.e. web modules)""" - - url_prefix: str = "" - """The URL prefix where IDOM resources will be served from""" - def _setup_common_routes( api_blueprint: Blueprint, @@ -160,20 +157,20 @@ def _setup_common_routes( cors_params = cors_options if isinstance(cors_options, dict) else {} CORS(api_blueprint, **cors_params) - if options.serve_static_files: + @api_blueprint.route(f"/{ASSETS_PATH.name}/") + def send_assets_dir(path: str = "") -> Any: + return send_file(safe_client_build_dir_path(f"assets/{path}")) - @api_blueprint.route(f"/{ASSETS_PATH.name}/") - def send_assets_dir(path: str = "") -> Any: - return send_file(safe_client_build_dir_path(f"assets/{path}")) + @api_blueprint.route(f"/{MODULES_PATH.name}/") + def send_modules_dir(path: str = "") -> Any: + return send_file(safe_web_modules_dir_path(path)) - @api_blueprint.route(f"/{MODULES_PATH.name}/") - def send_modules_dir(path: str = "") -> Any: - return send_file(safe_web_modules_dir_path(path)) + index_html = read_client_index_html(options) - @spa_blueprint.route("/") - @spa_blueprint.route("/") - def send_client_dir(_: str = "") -> Any: - return send_file(CLIENT_BUILD_DIR / "index.html") + @spa_blueprint.route("/") + @spa_blueprint.route("/") + def send_client_dir(_: str = "") -> Any: + return index_html def _setup_single_view_dispatcher_route( diff --git a/src/idom/backend/sanic.py b/src/idom/backend/sanic.py index a18b2cc66..fda9d214f 100644 --- a/src/idom/backend/sanic.py +++ b/src/idom/backend/sanic.py @@ -4,7 +4,7 @@ import json import logging from dataclasses import dataclass -from typing import Any, Dict, MutableMapping, Tuple, Union +from typing import Any, MutableMapping, Tuple from urllib import parse as urllib_parse from uuid import uuid4 @@ -24,15 +24,19 @@ ) from idom.core.types import RootComponentConstructor -from ._asgi import serve_development_asgi -from ._urls import ASSETS_PATH, MODULES_PATH, PATH_PREFIX, STREAM_PATH -from .hooks import ConnectionContext -from .hooks import use_connection as _use_connection -from .utils import ( - CLIENT_BUILD_DIR, +from ._common import ( + ASSETS_PATH, + MODULES_PATH, + PATH_PREFIX, + STREAM_PATH, + CommonOptions, + read_client_index_html, safe_client_build_dir_path, safe_web_modules_dir_path, + serve_development_asgi, ) +from .hooks import ConnectionContext +from .hooks import use_connection as _use_connection logger = logging.getLogger(__name__) @@ -90,21 +94,15 @@ def use_connection() -> Connection[_SanicCarrier]: @dataclass -class Options: - """Options for :class:`SanicRenderServer`""" +class Options(CommonOptions): + """Render server config for :func:`idom.backend.sanic.configure`""" - cors: Union[bool, Dict[str, Any]] = False + cors: bool | dict[str, Any] = False """Enable or configure Cross Origin Resource Sharing (CORS) For more information see docs for ``sanic_cors.CORS`` """ - serve_static_files: bool = True - """Whether or not to serve static files (i.e. web modules)""" - - url_prefix: str = "" - """The URL prefix where IDOM resources will be served from""" - def _setup_common_routes( api_blueprint: Blueprint, @@ -116,38 +114,38 @@ def _setup_common_routes( cors_params = cors_options if isinstance(cors_options, dict) else {} CORS(api_blueprint, **cors_params) - if options.serve_static_files: - - async def single_page_app_files( - request: request.Request, - _: str = "", - ) -> response.HTTPResponse: - return await response.file(CLIENT_BUILD_DIR / "index.html") - - spa_blueprint.add_route(single_page_app_files, "/") - spa_blueprint.add_route(single_page_app_files, "/<_:path>") - - async def asset_files( - request: request.Request, - path: str = "", - ) -> response.HTTPResponse: - path = urllib_parse.unquote(path) - return await response.file(safe_client_build_dir_path(f"assets/{path}")) - - api_blueprint.add_route(asset_files, f"/{ASSETS_PATH.name}/") - - async def web_module_files( - request: request.Request, - path: str, - _: str = "", # this is not used - ) -> response.HTTPResponse: - path = urllib_parse.unquote(path) - return await response.file( - safe_web_modules_dir_path(path), - mime_type="text/javascript", - ) - - api_blueprint.add_route(web_module_files, f"/{MODULES_PATH.name}/") + index_html = read_client_index_html(options) + + async def single_page_app_files( + request: request.Request, + _: str = "", + ) -> response.HTTPResponse: + return response.html(index_html) + + spa_blueprint.add_route(single_page_app_files, "/") + spa_blueprint.add_route(single_page_app_files, "/<_:path>") + + async def asset_files( + request: request.Request, + path: str = "", + ) -> response.HTTPResponse: + path = urllib_parse.unquote(path) + return await response.file(safe_client_build_dir_path(f"assets/{path}")) + + api_blueprint.add_route(asset_files, f"/{ASSETS_PATH.name}/") + + async def web_module_files( + request: request.Request, + path: str, + _: str = "", # this is not used + ) -> response.HTTPResponse: + path = urllib_parse.unquote(path) + return await response.file( + safe_web_modules_dir_path(path), + mime_type="text/javascript", + ) + + api_blueprint.add_route(web_module_files, f"/{MODULES_PATH.name}/") def _setup_single_view_dispatcher_route( diff --git a/src/idom/backend/starlette.py b/src/idom/backend/starlette.py index ed1bc10da..21d5200af 100644 --- a/src/idom/backend/starlette.py +++ b/src/idom/backend/starlette.py @@ -4,12 +4,12 @@ import json import logging from dataclasses import dataclass -from typing import Any, Dict, Tuple, Union +from typing import Any, Awaitable, Callable, Tuple from starlette.applications import Starlette from starlette.middleware.cors import CORSMiddleware from starlette.requests import Request -from starlette.responses import FileResponse +from starlette.responses import HTMLResponse from starlette.staticfiles import StaticFiles from starlette.websockets import WebSocket, WebSocketDisconnect @@ -25,11 +25,17 @@ ) from idom.core.types import RootComponentConstructor -from ._asgi import serve_development_asgi -from ._urls import ASSETS_PATH, MODULES_PATH, STREAM_PATH +from ._common import ( + ASSETS_PATH, + CLIENT_BUILD_DIR, + MODULES_PATH, + STREAM_PATH, + CommonOptions, + read_client_index_html, + serve_development_asgi, +) from .hooks import ConnectionContext from .hooks import use_connection as _use_connection -from .utils import CLIENT_BUILD_DIR logger = logging.getLogger(__name__) @@ -86,21 +92,15 @@ def use_connection() -> Connection[WebSocket]: @dataclass -class Options: - """Optionsuration options for :class:`StarletteRenderServer`""" +class Options(CommonOptions): + """Render server config for :func:`idom.backend.starlette.configure`""" - cors: Union[bool, Dict[str, Any]] = False + cors: bool | dict[str, Any] = False """Enable or configure Cross Origin Resource Sharing (CORS) For more information see docs for ``starlette.middleware.cors.CORSMiddleware`` """ - serve_static_files: bool = True - """Whether or not to serve static files (i.e. web modules)""" - - url_prefix: str = "" - """The URL prefix where IDOM resources will be served from""" - def _setup_common_routes(options: Options, app: Starlette) -> None: cors_options = options.cors @@ -114,22 +114,27 @@ def _setup_common_routes(options: Options, app: Starlette) -> None: # BUG: https://github.com/tiangolo/fastapi/issues/1469 url_prefix = options.url_prefix - if options.serve_static_files: - app.mount( - str(MODULES_PATH), - StaticFiles(directory=IDOM_WEB_MODULES_DIR.current, check_dir=False), - ) - app.mount( - str(ASSETS_PATH), - StaticFiles(directory=CLIENT_BUILD_DIR / "assets", check_dir=False), - ) - # register this last so it takes least priority - app.add_route(url_prefix + "/", serve_index) - app.add_route(url_prefix + "/{path:path}", serve_index) + app.mount( + str(MODULES_PATH), + StaticFiles(directory=IDOM_WEB_MODULES_DIR.current, check_dir=False), + ) + app.mount( + str(ASSETS_PATH), + StaticFiles(directory=CLIENT_BUILD_DIR / "assets", check_dir=False), + ) + # register this last so it takes least priority + index_route = _make_index_route(options) + app.add_route(url_prefix + "/", index_route) + app.add_route(url_prefix + "/{path:path}", index_route) + + +def _make_index_route(options: Options) -> Callable[[Request], Awaitable[HTMLResponse]]: + index_html = read_client_index_html(options) + async def serve_index(request: Request) -> HTMLResponse: + return HTMLResponse(index_html) -async def serve_index(request: Request) -> FileResponse: - return FileResponse(CLIENT_BUILD_DIR / "index.html") + return serve_index def _setup_single_view_dispatcher_route( diff --git a/src/idom/backend/tornado.py b/src/idom/backend/tornado.py index 0af4bfb0d..a9a112ffc 100644 --- a/src/idom/backend/tornado.py +++ b/src/idom/backend/tornado.py @@ -4,7 +4,6 @@ import json from asyncio import Queue as AsyncQueue from asyncio.futures import Future -from dataclasses import dataclass from typing import Any, List, Tuple, Type, Union from urllib.parse import urljoin @@ -22,16 +21,26 @@ from idom.core.serve import VdomJsonPatch, serve_json_patch from idom.core.types import ComponentConstructor -from ._urls import ASSETS_PATH, MODULES_PATH, STREAM_PATH +from ._common import ( + ASSETS_PATH, + CLIENT_BUILD_DIR, + MODULES_PATH, + STREAM_PATH, + CommonOptions, + read_client_index_html, +) from .hooks import ConnectionContext from .hooks import use_connection as _use_connection -from .utils import CLIENT_BUILD_DIR + + +Options = CommonOptions +"""Render server config for :func:`idom.backend.tornado.configure`""" def configure( app: Application, component: ComponentConstructor, - options: Options | None = None, + options: CommonOptions | None = None, ) -> None: """Configure the necessary IDOM routes on the given app. @@ -98,50 +107,27 @@ def use_connection() -> Connection[HTTPServerRequest]: return conn -@dataclass -class Options: - """Render server options for :class:`TornadoRenderServer` subclasses""" - - serve_static_files: bool = True - """Whether or not to serve static files (i.e. web modules)""" - - url_prefix: str = "" - """The URL prefix where IDOM resources will be served from""" - - _RouteHandlerSpecs = List[Tuple[str, Type[RequestHandler], Any]] def _setup_common_routes(options: Options) -> _RouteHandlerSpecs: - handlers: _RouteHandlerSpecs = [] - - if options.serve_static_files: - handlers.append( - ( - rf"{MODULES_PATH}/(.*)", - StaticFileHandler, - {"path": str(IDOM_WEB_MODULES_DIR.current)}, - ) - ) - - handlers.append( - ( - rf"{ASSETS_PATH}/(.*)", - StaticFileHandler, - {"path": str(CLIENT_BUILD_DIR / "assets")}, - ) - ) - - # register last to give lowest priority - handlers.append( - ( - r"/(.*)", - SpaStaticFileHandler, - {"path": str(CLIENT_BUILD_DIR)}, - ) - ) - - return handlers + return [ + ( + rf"{MODULES_PATH}/(.*)", + StaticFileHandler, + {"path": str(IDOM_WEB_MODULES_DIR.current)}, + ), + ( + rf"{ASSETS_PATH}/(.*)", + StaticFileHandler, + {"path": str(CLIENT_BUILD_DIR / "assets")}, + ), + ( + r"/(.*)", + IndexHandler, + {"index_html": read_client_index_html(options)}, + ), + ] def _add_handler( @@ -171,9 +157,15 @@ def _setup_single_view_dispatcher_route( ] -class SpaStaticFileHandler(StaticFileHandler): - async def get(self, _: str, include_body: bool = True) -> None: - return await super().get(str(CLIENT_BUILD_DIR / "index.html"), include_body) +class IndexHandler(RequestHandler): + + _index_html: str + + def initialize(self, index_html: str) -> None: + self._index_html = index_html + + async def get(self, _: str) -> None: + self.finish(self._index_html) class ModelStreamHandler(WebSocketHandler): diff --git a/src/idom/backend/utils.py b/src/idom/backend/utils.py index a3c5ee51a..bca5d8903 100644 --- a/src/idom/backend/utils.py +++ b/src/idom/backend/utils.py @@ -2,22 +2,17 @@ import asyncio import logging -import os import socket from contextlib import closing from importlib import import_module -from pathlib import Path from typing import Any, Iterator -import idom -from idom.config import IDOM_WEB_MODULES_DIR from idom.types import RootComponentConstructor from .types import BackendImplementation logger = logging.getLogger(__name__) -CLIENT_BUILD_DIR = Path(idom.__file__).parent / "_client" SUPPORTED_PACKAGES = ( "starlette", @@ -56,34 +51,6 @@ def run( asyncio.run(implementation.serve_development_app(app, host, port)) -def safe_client_build_dir_path(path: str) -> Path: - """Prevent path traversal out of :data:`CLIENT_BUILD_DIR`""" - return traversal_safe_path( - CLIENT_BUILD_DIR, - *("index.html" if path in ("", "/") else path).split("/"), - ) - - -def safe_web_modules_dir_path(path: str) -> Path: - """Prevent path traversal out of :data:`idom.config.IDOM_WEB_MODULES_DIR`""" - return traversal_safe_path(IDOM_WEB_MODULES_DIR.current, *path.split("/")) - - -def traversal_safe_path(root: str | Path, *unsafe: str | Path) -> Path: - """Raise a ``ValueError`` if the ``unsafe`` path resolves outside the root dir.""" - root = os.path.abspath(root) - - # Resolve relative paths but not symlinks - symlinks should be ok since their - # presence and where they point is under the control of the developer. - path = os.path.abspath(os.path.join(root, *unsafe)) - - if os.path.commonprefix([root, path]) != root: - # If the common prefix is not root directory we resolved outside the root dir - raise ValueError("Unsafe path") - - return Path(path) - - def find_available_port( host: str, port_min: int = 8000, diff --git a/src/idom/core/hooks.py b/src/idom/core/hooks.py index a1353f090..dd7d0fc4b 100644 --- a/src/idom/core/hooks.py +++ b/src/idom/core/hooks.py @@ -23,6 +23,7 @@ from typing_extensions import Protocol +from idom.config import IDOM_DEBUG_MODE from idom.utils import Ref from ._thread_local import ThreadLocal @@ -212,7 +213,7 @@ def use_debug_value( memo_func = message if callable(message) else lambda: message new = use_memo(memo_func, dependencies) - if old.current != new: + if IDOM_DEBUG_MODE.current and old.current != new: old.current = new logger.debug(f"{current_hook().component} {new}") diff --git a/src/idom/utils.py b/src/idom/utils.py index e176da660..fde800abb 100644 --- a/src/idom/utils.py +++ b/src/idom/utils.py @@ -1,8 +1,11 @@ +from __future__ import annotations + +import re from itertools import chain -from typing import Any, Callable, Generic, Iterable, List, TypeVar, Union +from typing import Any, Callable, Generic, Iterable, TypeVar, cast from lxml import etree -from lxml.html import fragments_fromstring +from lxml.html import fragments_fromstring, tostring import idom from idom.core.types import VdomDict @@ -56,6 +59,25 @@ def __repr__(self) -> str: return f"{type(self).__name__}({current})" +def vdom_to_html(vdom: VdomDict) -> str: + """Convert a VDOM dictionary into an HTML string + + Only the following keys are translated to HTML: + + - ``tagName`` + - ``attributes`` + - ``children`` (must be strings or more VDOM dicts) + + Parameters: + vdom: The VdomDict element to convert to HTML + """ + temp_root = etree.Element("__temp__") + _add_vdom_to_etree(temp_root, vdom) + html = cast(bytes, tostring(temp_root)).decode() + # strip out temp root <__temp__> element + return html[10:-11] + + def html_to_vdom( html: str, *transforms: _ModelTransform, strict: bool = True ) -> VdomDict: @@ -118,6 +140,10 @@ def html_to_vdom( return vdom +class HTMLParseError(etree.LxmlSyntaxError): # type: ignore[misc] + """Raised when an HTML document cannot be parsed using strict parsing.""" + + def _etree_to_vdom( node: etree._Element, transforms: Iterable[_ModelTransform] ) -> VdomDict: @@ -165,6 +191,48 @@ def _etree_to_vdom( return vdom +def _add_vdom_to_etree(parent: etree._Element, vdom: VdomDict | dict[str, Any]) -> None: + try: + tag = vdom["tagName"] + except KeyError as e: + raise TypeError(f"Expected a VDOM dict, not {vdom}") from e + else: + vdom = cast(VdomDict, vdom) + + if tag: + element = etree.SubElement(parent, tag) + element.attrib.update( + _vdom_attr_to_html_str(k, v) for k, v in vdom.get("attributes", {}).items() + ) + else: + element = parent + + for c in vdom.get("children", []): + if isinstance(c, dict): + _add_vdom_to_etree(element, c) + else: + """ + LXML handles string children by storing them under `text` and `tail` + attributes of Element objects. The `text` attribute, if present, effectively + becomes that element's first child. Then the `tail` attribute, if present, + becomes a sibling that follows that element. For example, consider the + following HTML: + +

helloworld

+ + In this code sample, "hello" is the `text` attribute of the `` element + and "world" is the `tail` attribute of that same `` element. It's for + this reason that, depending on whether the element being constructed has + non-string a child element, we need to assign a `text` vs `tail` attribute + to that element or the last non-string child respectively. + """ + if len(element): + last_child = element[-1] + last_child.tail = f"{last_child.tail or ''}{c}" + else: + element.text = f"{element.text or ''}{c}" + + def _mutate_vdom(vdom: VdomDict) -> None: """Performs any necessary mutations on the VDOM attributes to meet VDOM spec. @@ -195,7 +263,7 @@ def _mutate_vdom(vdom: VdomDict) -> None: def _generate_vdom_children( node: etree._Element, transforms: Iterable[_ModelTransform] -) -> List[Union[VdomDict, str]]: +) -> list[VdomDict | str]: """Generates a list of VDOM children from an lxml node. Inserts inner text and/or tail text inbetween VDOM children, if necessary. @@ -221,5 +289,37 @@ def _hypen_to_camel_case(string: str) -> str: return first.lower() + remainder.title().replace("-", "") -class HTMLParseError(etree.LxmlSyntaxError): # type: ignore[misc] - """Raised when an HTML document cannot be parsed using strict parsing.""" +def _vdom_attr_to_html_str(key: str, value: Any) -> tuple[str, str]: + if key == "style": + if isinstance(value, dict): + value = ";".join( + # We lower only to normalize - CSS is case-insensitive: + # https://www.w3.org/TR/css-fonts-3/#font-family-casing + f"{_CAMEL_CASE_SUB_PATTERN.sub('-', k).lower()}:{v}" + for k, v in value.items() + ) + elif ( + # camel to data-* attributes + key.startswith("data") + # camel to aria-* attributes + or key.startswith("aria") + # handle special cases + or key in _DASHED_HTML_ATTRS + ): + key = _CAMEL_CASE_SUB_PATTERN.sub("-", key) + + assert not callable( + value + ), f"Could not convert callable attribute {key}={value} to HTML" + + # Again, we lower the attribute name only to normalize - HTML is case-insensitive: + # http://w3c.github.io/html-reference/documents.html#case-insensitivity + return key.lower(), str(value) + + +# Pattern for delimitting camelCase names (e.g. camelCase to camel-case) +_CAMEL_CASE_SUB_PATTERN = re.compile(r"(? None: def _use_callable(initial_func: _Func) -> Tuple[_Func, Callable[[_Func], None]]: - state = hooks.use_state(lambda: initial_func) - return state[0], lambda new: state[1](lambda old: new) + state, set_state = hooks.use_state(lambda: initial_func) + return state, lambda new: set_state(lambda old: new) diff --git a/tests/test_backend/test__common.py b/tests/test_backend/test__common.py new file mode 100644 index 000000000..e575625a2 --- /dev/null +++ b/tests/test_backend/test__common.py @@ -0,0 +1,58 @@ +import pytest + +from idom import html +from idom.backend._common import traversal_safe_path, vdom_head_elements_to_html + + +@pytest.mark.parametrize( + "bad_path", + [ + "../escaped", + "ok/../../escaped", + "ok/ok-again/../../ok-yet-again/../../../escaped", + ], +) +def test_catch_unsafe_relative_path_traversal(tmp_path, bad_path): + with pytest.raises(ValueError, match="Unsafe path"): + traversal_safe_path(tmp_path, *bad_path.split("/")) + + +@pytest.mark.parametrize( + "vdom_in, html_out", + [ + ( + "example", + "example", + ), + ( + # We do not modify strings given by user. If given as VDOM we would have + # striped this head element, but since provided as string, we leav as-is. + "", + "", + ), + ( + html.head( + html.meta({"charset": "utf-8"}), + html.title("example"), + ), + # we strip the head element + 'example', + ), + ( + html._( + html.meta({"charset": "utf-8"}), + html.title("example"), + ), + 'example', + ), + ( + [ + html.meta({"charset": "utf-8"}), + html.title("example"), + ], + 'example', + ), + ], +) +def test_vdom_head_elements_to_html(vdom_in, html_out): + assert vdom_head_elements_to_html(vdom_in) == html_out diff --git a/tests/test_backend/test_common.py b/tests/test_backend/test_all.py similarity index 87% rename from tests/test_backend/test_common.py rename to tests/test_backend/test_all.py index f0856b209..98036cb16 100644 --- a/tests/test_backend/test_common.py +++ b/tests/test_backend/test_all.py @@ -5,7 +5,7 @@ import idom from idom import html from idom.backend import default as default_implementation -from idom.backend._urls import PATH_PREFIX +from idom.backend._common import PATH_PREFIX from idom.backend.types import BackendImplementation, Connection, Location from idom.backend.utils import all_implementations from idom.testing import BackendFixture, DisplayFixture, poll @@ -155,3 +155,20 @@ def ShowRoute(): # we can't easily narrow this check assert hook_val.current is not None + + +@pytest.mark.parametrize("imp", all_implementations()) +async def test_customized_head(imp: BackendImplementation, page): + custom_title = f"Custom Title for {imp.__name__}" + + @idom.component + def sample(): + return html.h1(f"^ Page title is customized to: '{custom_title}'") + + async with BackendFixture( + implementation=imp, + options=imp.Options(head=html.title(custom_title)), + ) as server: + async with DisplayFixture(backend=server, driver=page) as display: + await display.show(sample) + assert (await display.page.title()) == custom_title diff --git a/tests/test_backend/test_utils.py b/tests/test_backend/test_utils.py index b55cdd990..c3cb13613 100644 --- a/tests/test_backend/test_utils.py +++ b/tests/test_backend/test_utils.py @@ -8,7 +8,6 @@ from idom.backend import flask as flask_implementation from idom.backend.utils import find_available_port from idom.backend.utils import run as sync_run -from idom.backend.utils import traversal_safe_path from idom.sample import SampleApp as SampleApp @@ -45,16 +44,3 @@ async def test_run(page: Page, exit_stack: ExitStack): await page.goto(url) await page.wait_for_selector("#sample") - - -@pytest.mark.parametrize( - "bad_path", - [ - "../escaped", - "ok/../../escaped", - "ok/ok-again/../../ok-yet-again/../../../escaped", - ], -) -def test_catch_unsafe_relative_path_traversal(tmp_path, bad_path): - with pytest.raises(ValueError, match="Unsafe path"): - traversal_safe_path(tmp_path, *bad_path.split("/")) diff --git a/tests/test_utils.py b/tests/test_utils.py index 861fc315d..405cdce05 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,10 @@ +from html import escape as html_escape + import pytest import idom -from idom.utils import HTMLParseError, html_to_vdom +from idom import html +from idom.utils import HTMLParseError, html_to_vdom, vdom_to_html def test_basic_ref_behavior(): @@ -149,3 +152,81 @@ def test_html_to_vdom_with_no_parent_node(): } assert html_to_vdom(source) == expected + + +SOME_OBJECT = object() + + +@pytest.mark.parametrize( + "vdom_in, html_out", + [ + ( + html.div("hello"), + "
hello
", + ), + ( + html.div(SOME_OBJECT), + f"
{html_escape(str(SOME_OBJECT))}
", + ), + ( + html.div({"someAttribute": SOME_OBJECT}), + f'
', + ), + ( + html.div( + "hello", html.a({"href": "https://example.com"}, "example"), "world" + ), + '
', + ), + ( + html.button({"onClick": lambda event: None}), + "", + ), + ( + html._("hello ", html._("world")), + "hello world", + ), + ( + html._(html.div("hello"), html._("world")), + "
hello
world", + ), + ( + html.div({"style": {"backgroundColor": "blue", "marginLeft": "10px"}}), + '
', + ), + ( + html.div({"style": "background-color:blue;margin-left:10px"}), + '
', + ), + ( + html._( + html.div("hello"), + html.a({"href": "https://example.com"}, "example"), + ), + '
hello
example', + ), + ( + html.div( + html._( + html.div("hello"), + html.a({"href": "https://example.com"}, "example"), + ), + html.button(), + ), + '
hello
example
', + ), + ( + html.div( + {"dataSomething": 1, "dataSomethingElse": 2, "dataisnotdashed": 3} + ), + '
', + ), + ], +) +def test_vdom_to_html(vdom_in, html_out): + assert vdom_to_html(vdom_in) == html_out + + +def test_vdom_to_html_error(): + with pytest.raises(TypeError, match="Expected a VDOM dict"): + vdom_to_html({"notVdom": True})