diff --git a/.github/workflows/build-node-packages.yml b/.github/workflows/build-node-packages.yml index 7db52b6..16eb78f 100644 --- a/.github/workflows/build-node-packages.yml +++ b/.github/workflows/build-node-packages.yml @@ -29,7 +29,7 @@ jobs: - name: Publish node packages run: | - node build.js ${{ github.event.release.tag_name }} + node publish.js ${{ github.event.release.tag_name }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/create-release.yaml b/.github/workflows/create-release.yaml index 2bce84f..5029a9b 100644 --- a/.github/workflows/create-release.yaml +++ b/.github/workflows/create-release.yaml @@ -9,9 +9,11 @@ on: permissions: contents: write + pull-requests: write env: VERSION: ${{ github.event.inputs.version }} + GH_TOKEN: ${{ github.token }} jobs: deploy: @@ -45,6 +47,7 @@ jobs: run: | git config --local user.email "${{ github.actor }}@users.noreply.github.com" git config --local user.name "${{ github.actor }}" + git checkout -b release/$VERSION git add . git commit -m "Update version to $VERSION" git push origin release/$VERSION diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 8abba0a..51520c7 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -19,14 +19,14 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: ${{ matrix.python-version }} - name: Install dependencies run: | @@ -38,7 +38,7 @@ jobs: working-directory: ${{ github.workspace }} run: | python -m unittest discover -s tests -p 'test_*.py' -t ${{ github.workspace }} - + - name: Run build test working-directory: ${{ github.workspace }} run: | diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 1243c12..72e839d 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -10,4 +10,6 @@ sphinx: python: install: - - requirements: docs/requirements.txt \ No newline at end of file + - requirements: docs/requirements.txt + - method: pip + path: . diff --git a/.vscode/settings.json b/.vscode/settings.json index 5a20232..e82f7cc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,7 @@ { - "cSpell.words": [ - "Renderable" - ] -} \ No newline at end of file + "cSpell.words": ["pydom", "Renderable"], + "python.analysis.diagnosticSeverityOverrides": { + "reportWildcardImportFromLibrary": "none" + }, + "python.analysis.extraPaths": ["../pydom"] +} diff --git a/README.md b/README.md index 60213b2..5a01d04 100644 --- a/README.md +++ b/README.md @@ -74,20 +74,20 @@ class Person(Component): ``` To call a function on the server include this script in your file ```html - + ``` Import the middleware and mount it to your app ```python from fastapi import FastAPI -from seamless.middlewares import ASGIMiddleware as SeamlessMiddleware +from seamless.middlewares import SocketIOMiddleware app = FastAPI() -app.add_middleware(SeamlessMiddleware) +app.add_middleware(SocketIOMiddleware) ``` You can pass the following config to the middleware to change the socket path of all seamless endpoints. ```python app.add_middleware( - SeamlessMiddleware, + SocketIOMiddleware, socket_path="/my/custom/path" ) ``` diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..a6e7e9b --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,4 @@ +.lh/ +build/* +.venv/ +seamless/ \ No newline at end of file diff --git a/docs/3-events/1-middleware.rst b/docs/3-events/1-middleware.rst deleted file mode 100644 index 7cfd8f3..0000000 --- a/docs/3-events/1-middleware.rst +++ /dev/null @@ -1,21 +0,0 @@ -.. _asg-middleware: - -############### -ASGI Middleware -############### - -The ``ASGIMiddleware`` class is the middleware that handles all the linked actions between the -frontend components and the backend functions. - -When linking a python function to a frontend component, the ``ASGIMiddleware`` middleware must be -added to the ASGI application. - -.. code-block:: python - :caption: Adding the ASGIMiddleware to the ASGI application - - from fastapi import FastAPI - from seamless.middlewares import ASGIMiddleware as SeamlessMiddleware - - app = FastAPI() - app.add_middleware(SeamlessMiddleware) - diff --git a/docs/99-api-reference/1-components/1-base.rst b/docs/99-api-reference/1-components/1-base.rst deleted file mode 100644 index 4a1209d..0000000 --- a/docs/99-api-reference/1-components/1-base.rst +++ /dev/null @@ -1,26 +0,0 @@ -.. _base-component-api-reference: - -######### -Component -######### - -.. py:class:: Component - :module: seamless - :canonical: seamless.core.component.Component - - The base class for all components. - - .. py:method:: __init__(*children: ChildType, **props: Any) - - :param children: The children of the component - :type children: :py:type:`~seamless.types.ChildrenType` - :param props: The props of the component - :type props: :py:type:`~seamless.types.PropsType` - - .. py:method:: render(self) -> RenderResult - :abstractmethod: - - Renders the component. |br| - This method is an abstract method that must be implemented by the subclass. - - :rtype: :py:type:`~seamless.types.RenderResult` \ No newline at end of file diff --git a/docs/99-api-reference/1-components/index.rst b/docs/99-api-reference/1-components/index.rst deleted file mode 100644 index 05441fb..0000000 --- a/docs/99-api-reference/1-components/index.rst +++ /dev/null @@ -1,10 +0,0 @@ -########## -Components -########## - -.. toctree:: - :glob: - :maxdepth: 1 - - * - */index \ No newline at end of file diff --git a/docs/99-api-reference/1-components/page.rst b/docs/99-api-reference/1-components/page.rst deleted file mode 100644 index eb72aeb..0000000 --- a/docs/99-api-reference/1-components/page.rst +++ /dev/null @@ -1,34 +0,0 @@ -.. _page-api-reference: - -#### -Page -#### - -.. py:currentmodule:: seamless.components - -.. py:class:: Page - :canonical: seamless.components.page.Page - - Inherits from :py:class:`~seamless.core.component.Component`. - - Methods - ======= - - .. py:method:: __init__(self, title=None, html_props=None, head_props=None, body_props=None) - - :param string title: The page's title - :param dict html_props: The props to insert inside the ``html`` tag |br| **default**: ``{ "lang": "en" }`` - :param dict head_props: The props to insert inside the ``head`` tag |br| **default**: ``{}`` - :param dict body_props: The props to insert inside the ``body`` tag |br| **default**: ``{ "dir": "ltr" }`` - - .. py:method:: head(self) -> Iterable[ChildType] - - The children that will be inside the ``head`` tag. - - :rtype: :py:type:`~seamless.types.ChildrenType` - - .. py:method:: body(self) -> Iterable[ChildType] - - The children that will be inside the ``body`` tag. - - :rtype: :py:type:`~seamless.types.ChildrenType` \ No newline at end of file diff --git a/docs/99-api-reference/1-components/router/index.rst b/docs/99-api-reference/1-components/router/index.rst deleted file mode 100644 index 241f592..0000000 --- a/docs/99-api-reference/1-components/router/index.rst +++ /dev/null @@ -1,11 +0,0 @@ -###### -Router -###### - -.. py:module:: seamless.components.router - -.. toctree:: - :glob: - :maxdepth: 1 - - * \ No newline at end of file diff --git a/docs/99-api-reference/1-components/router/route.rst b/docs/99-api-reference/1-components/router/route.rst deleted file mode 100644 index bf5a214..0000000 --- a/docs/99-api-reference/1-components/router/route.rst +++ /dev/null @@ -1,20 +0,0 @@ -.. _route-api-reference: - -##### -Route -##### - -.. py:currentmodule:: seamless.components.router - -.. py:class:: Route - :canonical: seamless.components.router.route.Route - - Inherits from :py:class:`~seamless.core.component.Component`. - - Methods - ======= - - .. py:method:: __init__(self, path: str, component: type[Component]) - - :param string path: The path of the page - :param ``Component`` component: The component to render when the path is matched diff --git a/docs/99-api-reference/1-components/router/router-link.rst b/docs/99-api-reference/1-components/router/router-link.rst deleted file mode 100644 index db09700..0000000 --- a/docs/99-api-reference/1-components/router/router-link.rst +++ /dev/null @@ -1,20 +0,0 @@ -.. _router-link-api-reference: - -########### -Router Link -########### - -.. py:currentmodule:: seamless.components.router - -.. py:class:: RouterLink - :canonical: seamless.components.router.router_link.RouterLink - - Inherits from :py:class:`~seamless.core.component.Component`. - - Methods - ======= - - .. py:method:: __init__(self, *, to: str, **anchor_props) - - :param string to: The path to navigate to - :param dict anchor_props: The properties to pass to the anchor element diff --git a/docs/99-api-reference/1-components/router/router.rst b/docs/99-api-reference/1-components/router/router.rst deleted file mode 100644 index dada75e..0000000 --- a/docs/99-api-reference/1-components/router/router.rst +++ /dev/null @@ -1,22 +0,0 @@ -.. _router-api-reference: - -###### -Router -###### - -.. py:currentmodule:: seamless.components.router - -.. py:class:: Router - :canonical: seamless.components.router.router.Router - - Inherits from :py:class:`~seamless.core.component.Component`. - - Methods - ======= - - .. py:method:: __init__(self, *, loading_component: type[Component] | None = None) - .. py:method:: __init__(self, *routes: Route, loading_component: type[Component] | None = None) - :no-index: - - :param ``Component`` loading_component: The component to show between components loading - :param ``Route`` routes: The routes to include in the application - can also passed as children of the Router component diff --git a/docs/99-api-reference/2-state/index.rst b/docs/99-api-reference/2-state/index.rst deleted file mode 100644 index 2bdcbaa..0000000 --- a/docs/99-api-reference/2-state/index.rst +++ /dev/null @@ -1,11 +0,0 @@ -.. _state-api-reference: - -##### -State -##### - -.. toctree:: - :glob: - - * - */index \ No newline at end of file diff --git a/docs/99-api-reference/3-core/index.rst b/docs/99-api-reference/3-core/index.rst deleted file mode 100644 index a13af66..0000000 --- a/docs/99-api-reference/3-core/index.rst +++ /dev/null @@ -1,12 +0,0 @@ -########## -Components -########## - -.. py:module:: seamless.core - -.. toctree:: - :glob: - :maxdepth: 1 - - * - */index \ No newline at end of file diff --git a/docs/99-api-reference/3-core/rendering.rst b/docs/99-api-reference/3-core/rendering.rst deleted file mode 100644 index d7bbfc2..0000000 --- a/docs/99-api-reference/3-core/rendering.rst +++ /dev/null @@ -1,6 +0,0 @@ -.. _rendering-api-reference: - -######### -Rendering -######### - diff --git a/docs/99-api-reference/99-misc/index.rst b/docs/99-api-reference/99-misc/index.rst deleted file mode 100644 index 2478815..0000000 --- a/docs/99-api-reference/99-misc/index.rst +++ /dev/null @@ -1,25 +0,0 @@ -.. _misc: - -############# -Miscellaneous -############# - -.. py:type:: Primitive - :module: seamless.types - :canonical: Union[str, int, float, bool, None] - -.. py:type:: Renderable - :module: seamless.types - :canonical: Union[Component, Element] - -.. py:type:: ChildType - :module: seamless.types - :canonical: Union[Renderable, Primitive] - -.. py:type:: ChildrenType - :module: seamless.types - :canonical: Collection[ChildType] - -.. py:type:: RenderResult - :module: seamless.types - :canonical: Renderable | Primitive diff --git a/docs/99-api-reference/index.rst b/docs/99-api-reference/index.rst deleted file mode 100644 index 70a93fe..0000000 --- a/docs/99-api-reference/index.rst +++ /dev/null @@ -1,13 +0,0 @@ -.. _api-reference: - -############# -API Reference -############# - -This section contains the API reference for Seamless. - -.. toctree:: - :glob: - :maxdepth: 2 - - */index diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css new file mode 100644 index 0000000..b4454fc --- /dev/null +++ b/docs/_static/css/custom.css @@ -0,0 +1,28 @@ +html[data-theme="light"] { + --pst-color-primary: #00518a; + --pst-color-secondary: #f7f7f7; + --pst-color-secondary-hover: #e6e6e6; +} + +html[data-theme="dark"] { + --pst-color-primary: #0b7ed1; + --pst-color-secondary: #f7f7f7; + --pst-color-secondary-hover: #e6e6e6; +} + +.navbar-brand { + gap: 1rem; +} + +.navbar-brand img { + height: 85%; +} + +.navbar-item nav { + border-left: 2px solid #5d5d5f; + padding-left: 8px; +} + +#pst-back-to-top:hover { + background-color: var(--pst-color-secondary-hover); +} \ No newline at end of file diff --git a/docs/favicon.ico b/docs/_static/images/favicon.ico similarity index 100% rename from docs/favicon.ico rename to docs/_static/images/favicon.ico diff --git a/docs/_static/images/favicon.png b/docs/_static/images/favicon.png new file mode 100644 index 0000000..04a2534 Binary files /dev/null and b/docs/_static/images/favicon.png differ diff --git a/docs/_static/images/favicon.svg b/docs/_static/images/favicon.svg new file mode 100644 index 0000000..142e95e --- /dev/null +++ b/docs/_static/images/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/_static/images/seamless.svg b/docs/_static/images/seamless.svg index 57b0f7a..2fec43f 100644 --- a/docs/_static/images/seamless.svg +++ b/docs/_static/images/seamless.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/docs/api-reference/index.rst b/docs/api-reference/index.rst new file mode 100644 index 0000000..c1abcb2 --- /dev/null +++ b/docs/api-reference/index.rst @@ -0,0 +1,11 @@ +API Reference +============= + +This page contains auto-generated API reference documentation [#f1]_. + +.. toctree:: + :titlesonly: + + /api-reference/seamless/index + +.. [#f1] Created with `sphinx-autoapi `_ \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index eac12d9..c140fc4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,7 @@ -from seamless.version import version as __version__ +import sys, datetime + +sys.path.insert(0, "..") +from seamless import __version__ # Configuration file for the Sphinx documentation builder. # @@ -9,33 +12,73 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = "Seamless" -copyright = "2024, Xpo Development" author = "Xpo Development" +copyright = f"{datetime.date.today().year}, {author}" version = __version__ # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = [ - "sphinx_rtd_theme", + "pydata_sphinx_theme", "sphinx_substitution_extensions", + "autoapi.extension", ] templates_path = ["_templates"] -exclude_patterns = [] +exclude_patterns = ["_build", "_templates", "Thumbs.db", ".DS_Store"] + +autoapi_dirs = ["../seamless"] +autoapi_ignore = ["*/internal/*"] +autoapi_options = [ + "members", + "undoc-members", + "show-inheritance", + "show-module-summary", + "special-members", + "imported-members", +] +autoapi_root = "api-reference" +autoapi_keep_files = True +autoapi_generate_api_docs = True +autoapi_add_toctree_entry = True # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -html_theme = "sphinx_rtd_theme" +html_favicon = "_static/images/favicon.ico" +html_logo = "_static/images/favicon.svg" html_static_path = ["_static"] -html_favicon = "favicon.ico" +html_theme = "pydata_sphinx_theme" html_title = "Seamless Documentation" +html_theme_options = { + "icon_links": [ + { + "name": "GitHub", + "url": "https://github.com/xpodev/seamless", + "icon": "fa-brands fa-github", + }, + { + "name": "PyPI", + "url": "https://pypi.org/project/python-seamless", + "icon": "fa-brands fa-python", + }, + ], + "logo": { + "alt_text": "Seamless", + "text": "Seamless", + }, +} + +html_css_files = [ + "css/custom.css", +] + rst_prolog = f""" .. |version| replace:: {version} .. |br| raw:: html
-""" \ No newline at end of file +""" diff --git a/docs/index.rst b/docs/index.rst index c3f5744..1efad52 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2,9 +2,22 @@ Seamless ######## -.. image:: _static/images/seamless.svg +.. raw:: html + + + +.. image:: /_static/images/seamless.svg :alt: Seamless :align: center + :class: dark-light py-4 + +.. raw:: html + +

+ Simple to learn, easy to use, fully-featured UI library for Python +

.. image:: https://img.shields.io/pypi/v/python-seamless.svg :target: https://pypi.org/project/python-seamless/ @@ -13,21 +26,29 @@ Seamless |br| -Welcome to Seamless's documentation! -#################################### Seamless is a Python library for building web applications with a focus on simplicity and ease of use. -It is designed to be easy to learn and use, while still providing the power and flexibility needed for complex applications. - +It is designed to be easy to learn and use, while still providing the power and flexibility needed for +complex applications. + +Seamless provides a simple and intuitive API that makes it easy to create complex web applications with +just a few lines of code. It includes a wide range of components and utilities that make it easy to build +responsive and interactive user interfaces. + +Key features of Seamless include: + +- **Simple API**: Seamless provides a simple and intuitive API that makes it easy to create complex web applications + with just a few lines of code. +- **Familiarity**: Seamless is designed to be familiar to web developers, with a syntax that is similar to React. +- **Extensibility**: Seamless is built on top of `PyDOM `_, a powerful and flexible + library for creating and manipulating HTML elements in Python. +- **Compatibility**: Seamless is compatible with any Python web framework that supports ASGI, including `Starlette `_ + and `FastAPI `_. It can also be used with other ASGI-compatible frameworks such as `Django `_ + and `Flask `_. + .. toctree:: - :maxdepth: 2 :glob: + :hidden: - quick-start - */index - - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - + user-guide/index + api-reference/index diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..aa66f8c --- /dev/null +++ b/docs/package.json @@ -0,0 +1,6 @@ +{ + "scripts": { + "dev": "sphinx-autobuild -b html . _build --host 0.0.0.0 --port 8080", + "dev:clean": "sphinx-autobuild -a -b html . _build --host 0.0.0.0 --port 8080" + } +} \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt index 71d8116..bb8d2be 100644 Binary files a/docs/requirements.txt and b/docs/requirements.txt differ diff --git a/docs/1-basics/1-syntax.rst b/docs/user-guide/1-basics/1-syntax.rst similarity index 76% rename from docs/1-basics/1-syntax.rst rename to docs/user-guide/1-basics/1-syntax.rst index de7b8bf..ce92fbf 100644 --- a/docs/1-basics/1-syntax.rst +++ b/docs/user-guide/1-basics/1-syntax.rst @@ -22,11 +22,11 @@ Using the card component as an example, we will show the different syntaxes. self.title = title def render(self): - return Div(class_name="card")( - self.title and Div(class_name="card-title")( + return Div(classes="card")( + self.title and Div(classes="card-title")( self.title ), - Div(class_name="card-content")( + Div(classes="card-content")( *self.children ) ) @@ -53,13 +53,15 @@ This is called "Pythonic" because it is similar to how we write Python code. from seamless import Div, render from card import Card - render(Card( - Div( # This is the Card's child - "Hello, World!", # This is the content of the Div - id="card-content" - ), - title="Card Title" - )) + render( + Card( + Div( # This is the Card's child + "Hello, World!", # This is the content of the Div + id="card-content" + ), + title="Card Title" + ) + ) HTML Syntax ########### @@ -74,11 +76,13 @@ This is similar to how we write HTML tags, with the props as attributes and the from seamless import Div, render from card import Card - render(Card(title="Card Title")( - Div(id="card-content")( # This is the Card's child - "Hello, World!" # This is the content of the Div + render( + Card(title="Card Title")( + Div(id="card-content")( # This is the Card's child + "Hello, World!" # This is the content of the Div + ) ) - )) + ) Flutter Syntax ############## @@ -93,13 +97,15 @@ This is similar to how we write Flutter widgets. from seamless import Div, render from card import Card - render(Card( - title="Card Title", - children=[Div( # This is the Card's child - id="card-content". - children=["Hello, World!"] # This is the content of the Div - )] - )) + render( + Card( + title="Card Title", + children=[Div( # This is the Card's child + id="card-content". + children=["Hello, World!"] # This is the content of the Div + )] + ) + ) .. note:: diff --git a/docs/1-basics/2-rendering-components.rst b/docs/user-guide/1-basics/2-rendering-components.rst similarity index 91% rename from docs/1-basics/2-rendering-components.rst rename to docs/user-guide/1-basics/2-rendering-components.rst index 8717043..02d468f 100644 --- a/docs/1-basics/2-rendering-components.rst +++ b/docs/user-guide/1-basics/2-rendering-components.rst @@ -114,11 +114,11 @@ Props Rendering ############### When rendering components, some props names are converted to another name in the HTML representation. -For example, the ``class_name`` prop is converted to the ``class`` attribute in the HTML representation. +For example, the ``classes`` prop is converted to the ``class`` attribute in the HTML representation. The full list of prop names and their corresponding HTML attributes is as follows: -- ``class_name`` -> ``class`` +- ``classes`` -> ``class`` - ``html_for`` -> ``for`` - ``accept_charset`` -> ``accept-charset`` - ``http_equiv`` -> ``http-equiv`` @@ -134,3 +134,6 @@ The full list of prop names and their corresponding HTML attributes is as follow All ``on_`` props that are python functions are converted to event listeners in the HTML representation and will not be rendered as attributes. +By default, all props with underscore in their name and a value of :py:type:`~seamless.types.Primitive` type, +are converted to HTML attributes with dash instead of underscore. + diff --git a/docs/1-basics/3-dynamic-pages.rst b/docs/user-guide/1-basics/3-dynamic-pages.rst similarity index 95% rename from docs/1-basics/3-dynamic-pages.rst rename to docs/user-guide/1-basics/3-dynamic-pages.rst index 1f67ee9..ad936bf 100644 --- a/docs/1-basics/3-dynamic-pages.rst +++ b/docs/user-guide/1-basics/3-dynamic-pages.rst @@ -63,5 +63,5 @@ alternatively, you can add the script at the end of the body tag. def head(self): return ( *super().head(), - Script(src="https://cdn.jsdelivr.net/npm/@python-seamless@|version|/umd/seamless.min.js") + Script(src="https://cdn.jsdelivr.net/npm/python-seamless@|version|/umd/seamless.min.js") ) \ No newline at end of file diff --git a/docs/1-basics/4-state.rst b/docs/user-guide/1-basics/4-state.rst similarity index 96% rename from docs/1-basics/4-state.rst rename to docs/user-guide/1-basics/4-state.rst index 6ab2198..76fab2d 100644 --- a/docs/1-basics/4-state.rst +++ b/docs/user-guide/1-basics/4-state.rst @@ -55,7 +55,7 @@ Using a State To use a state, call the state object with the new value to update the state. Setting the state is done by calling the state object with the new value as a JavaScript expression. -When setting a new value to the state, the current state is available as ``state`` in the expression. +When setting a new value to the state, the current state is available as ``current`` in the expression. Getting the state is done by calling the state object without any arguments. @@ -67,7 +67,7 @@ Getting the state is done by calling the state object without any arguments. return Div( Button( "Increment", - on_click=counter("state + 1") + on_click=counter("current + 1") ), Span(counter()) ) diff --git a/docs/1-basics/index.rst b/docs/user-guide/1-basics/index.rst similarity index 100% rename from docs/1-basics/index.rst rename to docs/user-guide/1-basics/index.rst diff --git a/docs/2-components/1-base-component.rst b/docs/user-guide/2-components/1-base-component.rst similarity index 91% rename from docs/2-components/1-base-component.rst rename to docs/user-guide/2-components/1-base-component.rst index f378d33..7beec68 100644 --- a/docs/2-components/1-base-component.rst +++ b/docs/user-guide/2-components/1-base-component.rst @@ -25,11 +25,11 @@ It provides a ``children`` property that is a tuple of the components that are c self.title = title def render(self): - return Div(class_name="card")( - self.title and Div(class_name="card-title")( + return Div(classes="card")( + self.title and Div(classes="card-title")( self.title ), - Div(class_name="card-content")( + Div(classes="card-content")( *self.children ) ) @@ -82,8 +82,7 @@ The most common way of handling props is to store them as instance variables and class AppButton(Component): def __init__(self, type: str): - self.color = color - self.style = Style(background_color=f"var(--color-{color})") + self.style = Style(background_color=f"var(--color-{type})") def render(self): return Button(style=self.style)( @@ -137,11 +136,11 @@ component with the children as arguments. (See :ref:`syntax`) self.title = title def render(self): - return Div(class_name="card")( - Div(class_name="card-header")( + return Div(classes="card")( + Div(classes="card-header")( self.title ), - Div(class_name="card-body")( + Div(classes="card-body")( *self.children ) ) diff --git a/docs/2-components/2-page.rst b/docs/user-guide/2-components/2-page.rst similarity index 88% rename from docs/2-components/2-page.rst rename to docs/user-guide/2-components/2-page.rst index f822f91..32575e0 100644 --- a/docs/2-components/2-page.rst +++ b/docs/user-guide/2-components/2-page.rst @@ -7,7 +7,7 @@ Page The page component is a the top-level component that represents a web page. It is a container that holds all the other components that will be rendered on the page. -The default page component includes the components for the following HTML structure: +The default page component includes the elements for the following HTML structure: .. code-block:: html :caption: Default page structure @@ -42,14 +42,15 @@ The page component can be used to create a new page by passing the following pro from seamless import Div, P from seamless.components import Page + def my_awesome_page(): return Page(title="My awesome page")( - Div(class_name="container mt-5")( - Div(class_name="text-center p-4 rounded")( - Div(class_name="h-1")( + Div(classes="container mt-5")( + Div(classes="text-center p-4 rounded")( + Div(classes="h-1")( "Awesome page" ), - P(class_name="lead")( + P(classes="lead")( "Welcome to seamless" ) ) @@ -68,6 +69,7 @@ You can create custom pages by extending the page component and overriding the d from seamless import Div, Link from seamless.components import Page + class MyPage(Page): def head(self): return ( @@ -85,14 +87,15 @@ You can create custom pages by extending the page component and overriding the d ) ) + def my_awesome_page(): return MyPage(title="My awesome page")( - Div(class_name="container mt-5")( - Div(class_name="text-center p-4 rounded")( - Div(class_name="h-1")( + Div(classes="container mt-5")( + Div(classes="text-center p-4 rounded")( + Div(classes="h-1")( "Awesome page" ), - P(class_name="lead")( + P(classes="lead")( "Welcome to seamless" ) ) diff --git a/docs/2-components/3-fragment.rst b/docs/user-guide/2-components/3-fragment.rst similarity index 100% rename from docs/2-components/3-fragment.rst rename to docs/user-guide/2-components/3-fragment.rst diff --git a/docs/2-components/4-router.rst b/docs/user-guide/2-components/4-router.rst similarity index 93% rename from docs/2-components/4-router.rst rename to docs/user-guide/2-components/4-router.rst index 9d7aa59..2327867 100644 --- a/docs/2-components/4-router.rst +++ b/docs/user-guide/2-components/4-router.rst @@ -54,7 +54,7 @@ To navigate between the pages, use the ``RouterLink`` component from ``seamless. class MyApp(Component): def render(self): - return Div(class_name="root")( + return Div(classes="root")( Nav( RouterLink(to="/")( "Home" @@ -66,7 +66,7 @@ To navigate between the pages, use the ``RouterLink`` component from ``seamless. "Contact" ) ), - Div(class_name="content")( + Div(classes="content")( Router( Route(path="/", component=Home), Route(path="/about", component=About), @@ -117,6 +117,7 @@ The supported types are: - ``int`` - ``float`` +- ``path`` - a string that captures the path until the end of the path without query parameters (e.g. ``/user/{path:path}``) The parameter will be passed to the component as a prop with the same name. @@ -148,7 +149,7 @@ The parameter will be passed to the component as a prop with the same name. class MyApp(Component): def render(self): - return Div(class_name="root")( + return Div(classes="root")( Nav( RouterLink(to="/user/1")( "User 1" @@ -157,7 +158,7 @@ The parameter will be passed to the component as a prop with the same name. "User 2" ) ), - Div(class_name="content")( + Div(classes="content")( Router( Route(path="/user/{id:int}", component=User) ) diff --git a/docs/2-components/index.rst b/docs/user-guide/2-components/index.rst similarity index 100% rename from docs/2-components/index.rst rename to docs/user-guide/2-components/index.rst diff --git a/docs/user-guide/3-events/1-transports.rst b/docs/user-guide/3-events/1-transports.rst new file mode 100644 index 0000000..cced813 --- /dev/null +++ b/docs/user-guide/3-events/1-transports.rst @@ -0,0 +1,47 @@ +.. _transports: + +########## +Transports +########## + +Transports are the underlying mechanism that the event system uses to send +messages between the server and the client. + +By default, Seamless uses `socket.io `_ as the transport mechanism. This +allows for real-time, bidirectional and event-based communication between the +server and the client. + +.. note:: + The transport mechanism is abstracted away from the user, and you do not + need to interact with it directly. The event system provides a high-level + API that allows you to send and receive messages without worrying about the + underlying transport mechanism. + +To use the event system, you need to add the transport middleware to your ASGI app and +initialize the event system. + +.. code-block:: python + :caption: Adding the transport middleware + + from fastapi import FastAPI + from seamless.middlewares import SocketIOMiddleware + + app = FastAPI() + app.add_middleware(SocketIOMiddleware) + +To initialize the event system, in the root component of your application (can be the base page or the main layout) +call the ``SocketIOTransport.init`` method from the ``seamless.extension`` module. + +.. code-block:: python + :caption: Initializing the event system + + from seamless.extension import SocketIOTransport + + class MyPage(Page): + def body(self): + return ( + SocketIOTransport.init(), + *super().body() + ) + +This will initialize the event system and make it available to the components. \ No newline at end of file diff --git a/docs/3-events/2-data-validation.rst b/docs/user-guide/3-events/2-data-validation.rst similarity index 94% rename from docs/3-events/2-data-validation.rst rename to docs/user-guide/3-events/2-data-validation.rst index b3c5b7a..16a0a8a 100644 --- a/docs/3-events/2-data-validation.rst +++ b/docs/user-guide/3-events/2-data-validation.rst @@ -33,8 +33,8 @@ Here is an example of how to use ``pydantic`` to validate data: ) ) - def on_submit(self, data: SubmitEvent[UserLogin]): - print(data) + def on_submit(self, event: SubmitEvent[UserLogin]): + print(event.data) This will create a form with the fields ``username``, ``password``, and ``remember_me``, and a submit button. diff --git a/docs/3-events/index.rst b/docs/user-guide/3-events/index.rst similarity index 82% rename from docs/3-events/index.rst rename to docs/user-guide/3-events/index.rst index dfb25f9..d3f7c20 100644 --- a/docs/3-events/index.rst +++ b/docs/user-guide/3-events/index.rst @@ -39,11 +39,19 @@ The following example demonstrates how to submit a form to the server: ), ) - def save_email(self, event_data: SubmitEvent): + def save_email(self, event: SubmitEvent): user = get_user(self.email) - user.email = event_data["email"] + user.email = event.data["email"] user.save() +In this example, we bind the ``submit`` event of the form to the ``save_email`` method of the user. + +.. code-block:: python + + Form(on_submit=self.save_email) + +When the form is submitted, the ``save_email`` method is called with the event data. + Scoping ####### @@ -104,7 +112,7 @@ of the element and will register the event listener. :caption: Event function wrapper function (seamless) { - this.addEventListener(EVENT_NAME, function(event) { + this.addEventListener(EVENT_NAME, (event) => { // JavaScript code }); } @@ -118,19 +126,18 @@ You can inject parameters into the event handler by using annotations in the fun The injectable parameters are always passed as keyword arguments. |br| Event functions can not have positional-only arguments. -For example, you can access the socket ID by adding a parameter to the event handler function -with the ``SocketID`` type from the ``seamless.core`` module. - -The socket id can be used to send messages to the client that triggered the event. +For example, you can access the client ID by adding a parameter to the event handler function +with the ``ClientID`` type from the ``seamless.extra.transports`` module. .. code-block:: python :caption: Accessing the socket ID - from seamless.core import SocketID + from seamless.extra.transports import ClientID - def on_event(self, event_data: SubmitEvent, socket_id: SocketID): - print(socket_id) + def on_event(self, event_data: SubmitEvent, cid: ClientID): + print("Client ID:", cid) In the standard context, the following injectable parameters are available: -- **Socket ID**: The unique ID of the socket that triggered the event. Generated by socket.io (`More `_). \ No newline at end of file +- **Client ID**: The unique ID of the client that triggered the event. +- **Context**: The current :ref:`Seamless context `. \ No newline at end of file diff --git a/docs/4-styling/1-style-object.rst b/docs/user-guide/4-styling/1-style-object.rst similarity index 85% rename from docs/4-styling/1-style-object.rst rename to docs/user-guide/4-styling/1-style-object.rst index b139084..61889ab 100644 --- a/docs/4-styling/1-style-object.rst +++ b/docs/user-guide/4-styling/1-style-object.rst @@ -43,3 +43,9 @@ This will create an inline style with the properties from the style object. :caption: Style object output
Hello, world!
+ +Properties +########## + +The ``StyleObject`` class has all the properties from the CSS specification. +The only difference is that the properties are written in snake_case instead of kebab-case. \ No newline at end of file diff --git a/docs/4-styling/2-css-modules.rst b/docs/user-guide/4-styling/2-css-modules.rst similarity index 61% rename from docs/4-styling/2-css-modules.rst rename to docs/user-guide/4-styling/2-css-modules.rst index 25d5674..fbbc8ff 100644 --- a/docs/4-styling/2-css-modules.rst +++ b/docs/user-guide/4-styling/2-css-modules.rst @@ -10,7 +10,7 @@ It's a way to write modular CSS that won't conflict with other styles in your ap Usage ##### -To use CSS Modules in a component, import the ``CSS`` object from the ``seamless.styling`` module. +To use CSS Modules in a component, import the ``CSS`` class from the ``seamless.styling`` module. Then, use the ``CSS`` to import your css files. .. code-block:: css @@ -34,7 +34,7 @@ Then, use the ``CSS`` to import your css files. class MyComponent(Component): def render(self, css): - return Div(class_name=styles.card)( + return Div(classes=styles.card)( "Hello, world!" ) @@ -42,3 +42,20 @@ This will generate a class name that is unique to this css file, and apply the s This way, you can be sure that your styles won't conflict with other styles in your app. When importing the same css file in multiple components, the class name will be the same across all components. + +CSS File Lookup +############### + +When importing a css file, unless the path starts with a ``./``, Seamless will look for the css file in +the root folder for CSS Modules. +By default, the root folder for CSS Modules is the current working directory. +You can change the root folder by calling the ``CSS.set_root_folder`` method. + +.. code-block:: python + :caption: Changing the root folder for CSS Modules + + from seamless.styling import CSS + + CSS.set_root_folder("./styles") + + styles = CSS.module("card.css") diff --git a/docs/4-styling/index.rst b/docs/user-guide/4-styling/index.rst similarity index 100% rename from docs/4-styling/index.rst rename to docs/user-guide/4-styling/index.rst diff --git a/docs/5-advanced/1-javascript.rst b/docs/user-guide/5-advanced/1-javascript.rst similarity index 100% rename from docs/5-advanced/1-javascript.rst rename to docs/user-guide/5-advanced/1-javascript.rst diff --git a/docs/5-advanced/2-empty.rst b/docs/user-guide/5-advanced/2-empty.rst similarity index 100% rename from docs/5-advanced/2-empty.rst rename to docs/user-guide/5-advanced/2-empty.rst diff --git a/docs/5-advanced/3-context.rst b/docs/user-guide/5-advanced/3-context.rst similarity index 84% rename from docs/5-advanced/3-context.rst rename to docs/user-guide/5-advanced/3-context.rst index 97f48ec..0c19f08 100644 --- a/docs/5-advanced/3-context.rst +++ b/docs/user-guide/5-advanced/3-context.rst @@ -8,16 +8,26 @@ Context is an advanced feature of Seamless that allows you to customize the rend The default context is created using the ``Context.standard`` method and has the following features in order: +- **SocketIO Feature**: This feature allows you to connect between the client and server using SocketIO. + + .. code-block:: python + + from seamless.context import Context + from seamless.extra.state import SocketIOTransport + + context = Context() + context.add_feature(SocketIOTransport) + - **Component Repository**: A repository for storing components - this handles the ``component`` event and allows you to store and retrieve components after the initial render. .. code-block:: python from seamless.context import Context - from seamless.extra.components import init_components + from seamless.extra.components import ComponentsFeature context = Context() - context.add_feature(init_components) + context.add_feature(ComponentsFeature) - **Events Feature**: This feature allows you to connect between HTML events and Python functions. @@ -34,14 +44,14 @@ The default context is created using the ``Context.standard`` method and has the .. code-block:: python from seamless.context import Context - from seamless.extra.state import init_state + from seamless.extra.state import StateFeature context = Context() - context.add_feature(init_state) + context.add_feature(StateFeature) The standard context also comes with the following :ref:`property transformers` in order: -- **Class Transformer**: Changes the ``class_name`` property key to ``class`` and converts +- **Class Transformer**: Changes the ``classes`` property key to ``class`` and converts the value to a string if it is a list. .. code-block:: python @@ -52,7 +62,7 @@ The standard context also comes with the following :ref:`property transformers

` except for ``class_name``. +- **Simple Transformer**: Converts the properties in :ref:`this list` except for ``classes``. .. code-block:: python @@ -114,4 +124,4 @@ The standard context also comes with the following :ref:`property transformers

` - The Seamless Context - customizing the rendering process. - :ref:`Transformers ` - The property and post-render transformers. - :ref:`Rendering ` - The rendering process in Seamless. +- :ref:`Transports ` - Creating custom transports for the event system. diff --git a/docs/user-guide/index.rst b/docs/user-guide/index.rst new file mode 100644 index 0000000..d5f6317 --- /dev/null +++ b/docs/user-guide/index.rst @@ -0,0 +1,12 @@ +.. _user-guide: + +########## +User Guide +########## + +.. toctree:: + :maxdepth: 2 + :glob: + + quick-start + */index diff --git a/docs/quick-start.rst b/docs/user-guide/quick-start.rst similarity index 86% rename from docs/quick-start.rst rename to docs/user-guide/quick-start.rst index 3369094..0ba8f38 100644 --- a/docs/quick-start.rst +++ b/docs/user-guide/quick-start.rst @@ -22,7 +22,7 @@ Seamless provides a default page component that is the minimal structure for a w Since we want bootstrap to be included in all of our pages, we will create a new page component that extends the default page component and adds a link to the bootstrap stylesheet. -More information about the default page component can be found `here `_. +More information about the default page component can be found :ref:`here `. .. code-block:: python :caption: Creating a custom page component @@ -61,12 +61,12 @@ Last, we create the ``FastAPI`` app and add an endpoint that will render our pag async def read_root(): return render( AppPage( - Div(class_name="container mt-5")( - Div(class_name="text-center p-4 rounded")( - Div(class_name="display-4")( + Div(classes="container mt-5")( + Div(classes="text-center p-4 rounded")( + Div(classes="display-4")( "Hello, World!" ), - P(class_name="lead")( + P(classes="lead")( "Welcome to seamless" ) ) @@ -82,6 +82,6 @@ That's it! Now you can run the app and access it at `http://localhost:8000/ = len(users): - return Div(class_name="container")( - Div(class_name="row")( - Div(class_name="display-1 text-center")("User not found"), + return Div(classes="container")( + Div(classes="row")( + Div(classes="display-1 text-center")("User not found"), ) ) @@ -25,16 +25,16 @@ def render(self): f"{user['name']['title']} {user['name']['first']} {user['name']['last']}" ) - return Div(class_name="container")( - Div(class_name="row")( - Div(class_name="text-center")( + return Div(classes="container")( + Div(classes="row")( + Div(classes="text-center")( Img( src=user["picture"]["large"], alt=user_name, - class_name="rounded-circle", + classes="rounded-circle", ), ), - Div(class_name="display-1 text-center")(user_name), - Div(class_name="display-6 text-center")(f"Email: {user['email']}"), + Div(classes="display-1 text-center")(user_name), + Div(classes="display-6 text-center")(f"Email: {user['email']}"), ), ) diff --git a/node/build.js b/node/build.js index 94fdc16..f07a1cb 100644 --- a/node/build.js +++ b/node/build.js @@ -17,5 +17,4 @@ packages.forEach((pkg) => { const pkgJson = require(pkgJsonPath); pkgJson.version = nextVersion; fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2)); - execSync(`cd ${pkgPath} && npm run build && npm publish --access public`); }); diff --git a/node/packages/core/package.json b/node/packages/core/package.json index 8937db9..a6dbce9 100644 --- a/node/packages/core/package.json +++ b/node/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "python-seamless", - "version": "0.1.2", + "version": "0.9.2", "description": "JavaScript integration for Seamless", "main": "dist/index.js", "private": false, diff --git a/node/packages/core/src/constants.ts b/node/packages/core/src/constants.ts index fe0c1d9..892acc8 100644 --- a/node/packages/core/src/constants.ts +++ b/node/packages/core/src/constants.ts @@ -1,4 +1,3 @@ export const SEAMLESS_ELEMENT = "seamless:element"; export const SEAMLESS_EMPTY = "seamless:empty"; -export const SEAMLESS_INIT = "seamless:init"; -export const SEAMLESS_INIT_ASYNC = "seamless:async"; \ No newline at end of file +export const SEAMLESS_INIT = "seamless:init"; \ No newline at end of file diff --git a/node/packages/core/src/index.ts b/node/packages/core/src/index.ts index 99dc73b..a8bd205 100644 --- a/node/packages/core/src/index.ts +++ b/node/packages/core/src/index.ts @@ -6,14 +6,7 @@ import type { } from "./types"; export { SeamlessOptions }; -import { - SEAMLESS_ELEMENT, - SEAMLESS_INIT, - SEAMLESS_EMPTY, - SEAMLESS_INIT_ASYNC, -} from "./constants"; - -const AsyncFunction = new Function("return (async function () {}).constructor")(); +import { SEAMLESS_ELEMENT, SEAMLESS_INIT, SEAMLESS_EMPTY } from "./constants"; class Seamless { private readonly eventObjectTransformer: ( @@ -94,11 +87,7 @@ class Seamless { protected attachInit(element: HTMLElement) { const initCode = element.getAttribute(SEAMLESS_INIT); if (initCode) { - new (element.hasAttribute(SEAMLESS_INIT_ASYNC) - ? AsyncFunction as { new (): Function } - : Function)("seamless", initCode).apply(element, [this.context]); - - element.removeAttribute(SEAMLESS_INIT_ASYNC); + new Function("seamless", initCode).apply(element, [this.context]); element.removeAttribute(SEAMLESS_INIT); } } diff --git a/node/packages/core/src/init.ts b/node/packages/core/src/init.ts index 2681d14..191da0b 100644 --- a/node/packages/core/src/init.ts +++ b/node/packages/core/src/init.ts @@ -1,3 +1,3 @@ -import Seamless from "."; +import Seamless from "./index"; new Seamless(); \ No newline at end of file diff --git a/node/packages/core/src/renderer.ts b/node/packages/core/src/renderer.ts new file mode 100644 index 0000000..688ef0d --- /dev/null +++ b/node/packages/core/src/renderer.ts @@ -0,0 +1,104 @@ +import { SeamlessOptions, Primitive, SeamlessElement } from "./types"; +import { SEAMLESS_ELEMENT, SEAMLESS_INIT, SEAMLESS_EMPTY } from "./constants"; + +export class Renderer { + private readonly eventObjectTransformer: ( + originalEvent: Event, + outEvent: any + ) => any; + private readonly context: Record = {}; + + constructor(config?: SeamlessOptions) { + this.eventObjectTransformer = + config?.eventObjectTransformer || ((_, outEvent) => outEvent); + + this.context.instance = this; + this.init(); + } + + init() { + const allSeamlessElements = document.querySelectorAll( + "[seamless\\:element]" + ); + + this.processElements(Array.from(allSeamlessElements)); + } + + processElements(elements: HTMLElement[]) { + elements.forEach((element) => { + if (element.hasAttribute(SEAMLESS_INIT)) { + this.attachInit(element); + } + }); + elements.forEach((element) => { + if (element.tagName.toLowerCase() === SEAMLESS_EMPTY) { + this.initEmpty(element); + } + }); + elements.forEach((element) => { + element.removeAttribute(SEAMLESS_ELEMENT); + }); + } + + render(component: SeamlessElement, parentElement: any): void; + render(component: SeamlessElement, parentElement: HTMLElement): void { + this.toDOMElement(component, parentElement); + } + + private toDOMElement( + element: SeamlessElement | Primitive, + parentElement?: HTMLElement + ): HTMLElement | Text { + if (this.isPrimitive(element)) { + const primitiveNode = document.createTextNode(element?.toString() || ""); + if (parentElement) { + parentElement.appendChild(primitiveNode); + } + return primitiveNode; + } + + const domElement = document.createElement(element.type); + Object.entries(element.props).forEach(([key, value]) => { + domElement.setAttribute(key, value); + }); + + if (parentElement) { + parentElement.appendChild(domElement); + } + + if (Array.isArray(element.children)) { + element.children.map((child) => this.toDOMElement(child, domElement)); + } + + if (domElement.hasAttribute(SEAMLESS_ELEMENT)) { + this.processElements([domElement]); + } + + return domElement; + } + + protected attachInit(element: HTMLElement) { + const initCode = element.getAttribute(SEAMLESS_INIT); + if (initCode) { + new Function("seamless", initCode).apply(element, [this.context]); + element.removeAttribute(SEAMLESS_INIT); + } + } + + protected initEmpty(element: HTMLElement) { + while (element.firstChild) { + element.parentElement?.insertBefore(element.firstChild, element); + } + element.parentElement?.removeChild(element); + } + + protected isPrimitive(value: any): value is Primitive { + return ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + value === null || + value === undefined + ); + } +} diff --git a/node/packages/core/src/types.ts b/node/packages/core/src/types.ts index ee020d6..30aba9f 100644 --- a/node/packages/core/src/types.ts +++ b/node/packages/core/src/types.ts @@ -1,7 +1,4 @@ -import type { SocketOptions, ManagerOptions } from "socket.io-client"; - export interface SeamlessOptions { - socketOptions?: Partial; /** * A function that serializes the event object before sending it to the server * The default behavior is to return the event object as is @@ -24,4 +21,4 @@ export interface SeamlessElement { type: string; props: Record; children: Array | null; -} \ No newline at end of file +} diff --git a/node/publish.js b/node/publish.js new file mode 100644 index 0000000..9a74c49 --- /dev/null +++ b/node/publish.js @@ -0,0 +1,11 @@ +import { execSync } from 'child_process'; + +const packagesDir = path.join(__dirname, 'packages'); +const packages = fs.readdirSync(packagesDir); +packages.forEach((pkg) => { + const pkgPath = path.join(packagesDir, pkg); + if (!fs.lstatSync(pkgPath).isDirectory()) { + return; + } + execSync(`cd ${pkgPath} && npm run build && npm publish --access public`); +}); diff --git a/pyproject.toml b/pyproject.toml index 3db2ea9..714da20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,13 +3,25 @@ name = "python-seamless" authors = [{ name = "Xpo Development", email = "dev@xpo.dev" }] description = "A Python package for creating and manipulating HTML components. It is working similar to React.js, but in Python" readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.9" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] -dynamic = ["dependencies", "version"] +dynamic = ["version"] +dependencies = [ + "cssutils==2.9.0", + "pydom", + "python-socketio==5.11.1", + "typing-extensions", +] + +[dependency-groups] +dev = [ + "fastapi", + "uvicorn", +] [project.urls] Homepage = "https://github.com/xpodev/seamless" diff --git a/requirements.txt b/requirements.txt index 46ef01d..9c438e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ cssutils==2.9.0 -python-dom +pydom python-socketio==5.11.1 typing-extensions \ No newline at end of file diff --git a/seamless/components/page.py b/seamless/components/page.py index c59c62b..357ac7e 100644 --- a/seamless/components/page.py +++ b/seamless/components/page.py @@ -1,14 +1,9 @@ -from typing import overload, Iterable +from typing import Optional, overload, Iterable -from pydom import Component -from pydom.utils.functions import to_iter +from pydom.page import Page as BasePage from ..html import ( - Fragment, - Html, - Head, Title, - Body, Meta, ) @@ -16,34 +11,34 @@ from ..types.html import HTMLHtmlElement, HTMLBodyElement, HTMLHeadElement -class Page(Component): +class Page(BasePage): @overload def __init__( self, *children: ChildType, - title: str | None = None, - html_props: HTMLHtmlElement | None = None, - head_props: HTMLHeadElement | None = None, - body_props: HTMLBodyElement | None = None, + title: Optional[str] = None, + html_props: Optional[HTMLHtmlElement] = None, + head_props: Optional[HTMLHeadElement] = None, + body_props: Optional[HTMLBodyElement] = None, ): ... @overload def __init__( self, *, children: ChildrenType, - title: str | None = None, - html_props: HTMLHtmlElement | None = None, - head_props: HTMLHeadElement | None = None, - body_props: HTMLBodyElement | None = None, + title: Optional[str] = None, + html_props: Optional[HTMLHtmlElement] = None, + head_props: Optional[HTMLHeadElement] = None, + body_props: Optional[HTMLBodyElement] = None, ): ... def __init__( # type: ignore self, *, - title: str | None = None, - html_props: HTMLHtmlElement | None = None, - head_props: HTMLHeadElement | None = None, - body_props: HTMLBodyElement | None = None, + title: Optional[str] = None, + html_props: Optional[HTMLHtmlElement] = None, + head_props: Optional[HTMLHeadElement] = None, + body_props: Optional[HTMLBodyElement] = None, ): self.title = title self._html_props = html_props or {"lang": "en"} @@ -66,16 +61,7 @@ def body(self) -> Iterable[ChildType]: """ return self.children - def render(self): - return Fragment( - "", - Html(**self._html_props)( - Head(**self._head_props)(*to_iter(self.head())), - Body(**self._body_props)(*to_iter(self.body())), - ), - ) - - def __init_subclass__(cls, title: str | None = None, **kwargs) -> None: + def __init_subclass__(cls, title: Optional[str] = None, **kwargs) -> None: super().__init_subclass__(**kwargs) if title is None: diff --git a/seamless/components/router/router.js b/seamless/components/router/router.js index 618458c..ead4a23 100644 --- a/seamless/components/router/router.js +++ b/seamless/components/router/router.js @@ -82,10 +82,12 @@ const clearParent = () => { } }; -const loadComponent = async (name, props = {}) => { - return seamless.instance.toDOMElement( - await seamless.getComponent(name, props) - ); +const loadComponent = (name, props = {}) => { + return new Promise((resolve) => { + seamless.getComponent(name, props).then((component) => { + resolve(seamless.instance.toDOMElement(component)); + }); + }); }; window.addEventListener("pageLocationChange", () => { @@ -135,7 +137,7 @@ seamless.navigateTo = function (to) { return false; }; -window.addEventListener("transportsAvailable", async (event) => { +window.addEventListener("transportsInitialized", () => { if (loadingComponentName) { seamless.getComponent(loadingComponentName, {}).then((component) => { loadingComponent = seamless.instance.toDOMElement(component); diff --git a/seamless/components/router/router.py b/seamless/components/router/router.py index f5b5dea..3c83eb9 100644 --- a/seamless/components/router/router.py +++ b/seamless/components/router/router.py @@ -1,6 +1,6 @@ from json import dumps from pathlib import Path -from typing import Optional, Tuple, overload, Type +from typing import Optional, Tuple, overload from pydom import Component @@ -10,19 +10,20 @@ HERE = Path(__file__).parent +ROUTER_JS = JS(file=HERE / "router.js") class Router(Component): children: Tuple[Route, ...] # type: ignore @overload - def __init__(self, *, loading_component: Optional[Type[Component]] = None): ... + def __init__(self, *, loading_component: Optional[type[Component]] = None): ... @overload def __init__( - self, *routes: Route, loading_component: Optional[Type[Component]] = None + self, *routes: Route, loading_component: Optional[type[Component]] = None ): ... - def __init__(self, *, loading_component: Optional[Type[Component]] = None): # type: ignore + def __init__(self, *, loading_component: Optional[type[Component]] = None): # type: ignore self.loading_component = ( component_name(loading_component) if loading_component else None ) @@ -36,10 +37,11 @@ def render(self): for route in self.children ] - with open(HERE / "router.js", "r") as f: - router_js = f.read() - return Empty( - init=JS(f"let routes = {dumps(routes)};{router_js}"), + init=JS(f"let routes = {dumps(routes)};") + ROUTER_JS, loading=self.loading_component, + umount_function=self.on_umount, ) + + def on_umount(self): + ... diff --git a/seamless/context/context.py b/seamless/context/context.py index 33170ee..dd9c360 100644 --- a/seamless/context/context.py +++ b/seamless/context/context.py @@ -2,41 +2,52 @@ from typing import ( Any, Callable, - Concatenate, + Dict, Optional, - ParamSpec, + Type, TypeVar, cast, ) +from typing_extensions import Concatenate, ParamSpec from pydom.context.context import ( Context as _Context, get_context as _get_context, set_default_context as _set_global_context, ) -from pydom.rendering.tree.nodes import ContextNode from ..errors import Error +from .feature import Feature from ..internal.constants import DISABLE_GLOBAL_CONTEXT_ENV -from ..internal.injector import Injector -T = TypeVar("T", bound=_Context) -P = ParamSpec("P") +_P = ParamSpec("_P") +_T = TypeVar("_T", bound=Feature) -Feature = Callable[Concatenate["Context", P], Any] -PropertyMatcher = Callable[Concatenate[str, Any, P], bool] | str -PropertyTransformer = Callable[Concatenate[str, Any, "ContextNode", P], None] -PostRenderTransformer = Callable[Concatenate["ContextNode", P], None] +FeatureFactory = Callable[Concatenate["Context", _P], Feature] class Context(_Context): def __init__(self) -> None: super().__init__() - self.injector = Injector() - self.injector.add(Context, self) + self._features: Dict[Type[Feature], Feature] = {} + + def add_feature(self, feature: FeatureFactory[_P], *args: _P.args, **kwargs: _P.kwargs): + result = feature(self, *args, **kwargs) + if isinstance(feature, type): + self._features[feature] = result + + def get_feature(self, feature_type: Type[_T]) -> _T: + try: + return cast(_T, self._features[feature_type]) + except KeyError: + for instance in self._features.values(): + if isinstance(instance, feature_type): + return instance + + raise @classmethod - def standard(cls) -> "Context": + def standard(cls: Type["Context"]) -> "Context": context = cls() from .default import add_standard_features @@ -56,12 +67,10 @@ def get_context(context: Optional[Context] = None): "You must provide a context explicitly. Did you forget to call set_global_context?" ) from None - raise Error( - "No global context found. Did you forget to call set_global_context?" - ) + raise Error("No global context found. Did you forget to call set_global_context?") return context -def set_global_context(context: _Context): +def set_global_context(context: Context): _set_global_context(context) diff --git a/seamless/context/feature.py b/seamless/context/feature.py new file mode 100644 index 0000000..da703dc --- /dev/null +++ b/seamless/context/feature.py @@ -0,0 +1,14 @@ +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from .context import Context + + +class Feature: + def __init__(self, context: "Context") -> None: + self.context = context + + @property + def feature_name(self) -> str: + return type(self).__name__.replace("Feature", "").lower() \ No newline at end of file diff --git a/seamless/core/javascript.py b/seamless/core/javascript.py index ee80bbd..3908f56 100644 --- a/seamless/core/javascript.py +++ b/seamless/core/javascript.py @@ -1,14 +1,30 @@ from os import PathLike -from typing import overload +from typing import Union, overload class JavaScript: + """ + A class representing a block of JavaScript code. + """ + @overload - def __init__(self, code: str, *, async_: bool = False) -> None: ... + def __init__(self, code: str) -> None: + """ + Create a new JavaScript object from a string of code. + + Args: + code: The JavaScript code. + """ @overload - def __init__(self, *, file: str | PathLike, async_: bool = False) -> None: ... + def __init__(self, *, file: Union[str, PathLike]) -> None: + """ + Create a new JavaScript object from a file. + + Args: + file: The path to the file containing the JavaScript code. + """ - def __init__(self, code=None, *, file=None, async_: bool = False) -> None: + def __init__(self, code=None, *, file=None) -> None: if file: if code: raise ValueError("Cannot specify both code and file") @@ -17,13 +33,12 @@ def __init__(self, code=None, *, file=None, async_: bool = False) -> None: elif not code: raise ValueError("Must specify either code or file") self.code = code - self.async_ = async_ - def __add__(self, other: "JavaScript | str") -> "JavaScript": + def __add__(self, other: Union["JavaScript", str]) -> "JavaScript": if isinstance(other, JavaScript): - return JavaScript(self.code + other.code, async_=self.async_ or other.async_) + return JavaScript(self.code + other.code) elif isinstance(other, str): - return JavaScript(self.code + other, async_=self.async_) + return JavaScript(self.code + other) else: raise TypeError( f"Cannot concatenate JavaScript with {type(other).__name__}" diff --git a/seamless/extra/components/__init__.py b/seamless/extra/components/__init__.py index fc4914f..c13c387 100644 --- a/seamless/extra/components/__init__.py +++ b/seamless/extra/components/__init__.py @@ -1,11 +1,11 @@ import inspect -from typing import TYPE_CHECKING, ClassVar, Optional, Type +from typing import TYPE_CHECKING, ClassVar, Optional from pydom import Component -from pydom.context import Context -from pydom.context.feature import Feature from pydom.rendering import render_json +from ...context import Context +from ...context.feature import Feature from ...errors import ClientError from .repository import ComponentsRepository from ..transports.transport import TransportFeature @@ -25,7 +25,7 @@ def __init__(self, context: Context) -> None: @classmethod def __init_subclass__( - cls: Type["_Component"], + cls: type["_Component"], *, name: Optional[str] = None, inject_render: bool = False, @@ -71,5 +71,5 @@ async def get_component(self, client_id: str, component_name: str, props=None, * ) -def component_name(component: Type[Component]) -> Optional[str]: +def component_name(component: type[Component]) -> Optional[str]: return getattr(component, "__seamless_name__", None) diff --git a/seamless/extra/components/repository.py b/seamless/extra/components/repository.py index e2d415e..594538d 100644 --- a/seamless/extra/components/repository.py +++ b/seamless/extra/components/repository.py @@ -1,5 +1,3 @@ -from typing import Type - from pydom import Component @@ -7,8 +5,8 @@ class ComponentsRepository: def __init__(self): self.components = {} - def add_component(self, component: Type[Component], name: str): + def add_component(self, component: type[Component], name: str): self.components[name] = component - def get_component(self, component_name: str) -> Type[Component]: + def get_component(self, component_name: str) -> type[Component]: return self.components[component_name] diff --git a/seamless/extra/events/__init__.py b/seamless/extra/events/__init__.py index 6d51a55..7406d19 100644 --- a/seamless/extra/events/__init__.py +++ b/seamless/extra/events/__init__.py @@ -1,18 +1,18 @@ -from typing import Callable +from typing import Callable, List -from pydom.context import Context from pydom.rendering.render_state import RenderState from pydom.rendering.tree.nodes import ContextNode +from ...context import Context from .database import EventsDatabase, Action from ..feature import Feature from ...internal.constants import ( SEAMLESS_ELEMENT_ATTRIBUTE, SEAMLESS_INIT_ATTRIBUTE, + UNSAFE_GLOBAL_EVENT_ATTRIBUTE, ) from ...internal.validation import wrap_with_validation -from ..transports.errors import TransportConnectionRefused from ..transports.transport import TransportFeature @@ -79,7 +79,7 @@ def transformer( return matcher, transformer def _post_render_transformer(self, root: ContextNode, render_state: RenderState): - actions = render_state.custom_data.get("events.actions", []) + actions: List[Action] = render_state.custom_data.get("events.actions", []) if len(actions) == 0: return @@ -94,4 +94,12 @@ def _post_render_transformer(self, root: ContextNode, render_state: RenderState) ) else: for action in actions: - self.DB.add_event(action, scope=client_id) + self.DB.add_event( + action, + scope=client_id, + ) + + +def UnsAfE_gL__o__bAL_EVEnt(func: Callable) -> Callable: + setattr(func, UNSAFE_GLOBAL_EVENT_ATTRIBUTE, True) + return func diff --git a/seamless/extra/events/database.py b/seamless/extra/events/database.py index 65a7126..7729095 100644 --- a/seamless/extra/events/database.py +++ b/seamless/extra/events/database.py @@ -1,5 +1,5 @@ from inspect import iscoroutinefunction -from typing import Any, Callable, Dict +from typing import Any, Callable, Union from ...internal.utils import is_global @@ -24,9 +24,9 @@ async def __call__(self, *args: Any, **kwargs: Any) -> Any: class EventsDatabase: def __init__(self): - self.events: Dict[str, Action] = {} - self.scoped_events: Dict[str, Dict[str, Action]] = {} - self.actions_ids = dict[str | Callable, Action]() + self.events: dict[str, Action] = {} + self.scoped_events: dict[str, dict[str, Action]] = {} + self.actions_ids: dict[Union[str, Callable], Action] = {} def add_event(self, action: Action, *, scope: str): try: @@ -38,8 +38,8 @@ def add_event(self, action: Action, *, scope: str): if is_global(action.action): self.events[action.id] = action - - self.scoped_events.setdefault(scope, {})[action.id] = action + else: + self.scoped_events.setdefault(scope, {})[action.id] = action return action @@ -60,7 +60,5 @@ def release_actions(self, client_id: str): def get_event(self, event_id: str, *, scope: str): if event_id in self.events: return self.events[event_id] - - return self.scoped_events[scope][event_id] - + return self.scoped_events[scope][event_id] diff --git a/seamless/extra/feature.py b/seamless/extra/feature.py index 0ac0d63..58afa1f 100644 --- a/seamless/extra/feature.py +++ b/seamless/extra/feature.py @@ -1 +1 @@ -from pydom.context.feature import Feature +from ..context.feature import Feature diff --git a/seamless/extra/state/__init__.py b/seamless/extra/state/__init__.py index a0a2e25..6773c03 100644 --- a/seamless/extra/state/__init__.py +++ b/seamless/extra/state/__init__.py @@ -3,9 +3,9 @@ from typing import Any, overload from pydom import Component -from pydom.context import Context from pydom.rendering.tree.nodes import ContextNode +from ...context import Context from ...core import Empty, JS from ..feature import Feature from ...internal.constants import SEAMLESS_ELEMENT_ATTRIBUTE, SEAMLESS_INIT_ATTRIBUTE @@ -38,7 +38,7 @@ def get(self): def set(self, value): return JS( - f"""const state = seamless.state.getState('{self.name}');\ + f"""const current = seamless.state.getState('{self.name}');\ seamless.state.setState('{self.name}', {value})""" ) diff --git a/seamless/extra/state/state.init.js b/seamless/extra/state/state.init.js index 9a55237..9074b73 100644 --- a/seamless/extra/state/state.init.js +++ b/seamless/extra/state/state.init.js @@ -4,6 +4,9 @@ class SeamlessState { } setState(key, value) { + if (this.state[key] === value) { + return; + } const oldValue = this.state[key]; this.state[key] = value; const stateChangeEvent = new CustomEvent(`stateChange:${key}`, { detail: { oldValue, currentValue: this.state[key] } }); diff --git a/seamless/extra/transformers/__init__.py b/seamless/extra/transformers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/seamless/extra/transformers/js_transformer.py b/seamless/extra/transformers/js_transformer.py index 86c0905..d96bdc8 100644 --- a/seamless/extra/transformers/js_transformer.py +++ b/seamless/extra/transformers/js_transformer.py @@ -2,7 +2,6 @@ from ...internal.constants import ( SEAMLESS_ELEMENT_ATTRIBUTE, SEAMLESS_INIT_ATTRIBUTE, - SEAMLESS_INIT_ASYNC_ATTRIBUTE, ) @@ -15,8 +14,6 @@ def transformer(key: str, source: JavaScript, element): element.props[SEAMLESS_INIT_ATTRIBUTE] = ( element.props.get(SEAMLESS_INIT_ATTRIBUTE, "") + source.code ) - if source.async_: - element.props[SEAMLESS_INIT_ASYNC_ATTRIBUTE] = True del element.props[key] return matcher, transformer @@ -32,7 +29,7 @@ def transformer(key: str, source: JavaScript, element): element.props[SEAMLESS_ELEMENT_ATTRIBUTE] = True element.props[SEAMLESS_INIT_ATTRIBUTE] = ( element.props.get(SEAMLESS_INIT_ATTRIBUTE, "") - + f"\nthis.addEventListener('{event_name}', {'async' if source.async_ else ''}(event) => {{{source.code}}});" + + f"\nthis.addEventListener('{event_name}', (event) => {{{source.code}}});" ) del element.props[key] diff --git a/seamless/extra/transports/__init__.py b/seamless/extra/transports/__init__.py new file mode 100644 index 0000000..070e532 --- /dev/null +++ b/seamless/extra/transports/__init__.py @@ -0,0 +1,4 @@ +from .client_id import ClientID +from .transport import TransportFeature + +__all__ = ["ClientID", "TransportFeature"] diff --git a/seamless/extra/transports/dispatcher.py b/seamless/extra/transports/dispatcher.py index 515851f..1b12fb0 100644 --- a/seamless/extra/transports/dispatcher.py +++ b/seamless/extra/transports/dispatcher.py @@ -1,4 +1,4 @@ -from typing import Any, Callable, Dict, Generic, TYPE_CHECKING, TypeVar, Awaitable +from typing import Any, Callable, Generic, TYPE_CHECKING, TypeVar, Awaitable from pydom import Context from typing_extensions import ParamSpec @@ -7,35 +7,35 @@ if TYPE_CHECKING: from .transport import TransportFeature -P = ParamSpec("P") -T = TypeVar("T") +_P = ParamSpec("_P") +_T = TypeVar("_T") -class Dispatcher(Generic[P, T]): +class Dispatcher(Generic[_P, _T]): def __init__(self, context: Context) -> None: self._context = context - self._handlers: Dict[Any, Callable] = {} + self._handlers: dict[Any, Callable] = {} - def on(self, name: Any, handler: Callable[P, Awaitable[T]]): + def on(self, name: Any, handler: Callable[_P, Awaitable[_T]]): if name in self._handlers: raise ValueError(f"Handler for {name} already exists") self._handlers[name] = self._context.inject(handler) - async def __call__(self, name: Any, *args: P.args, **kwds: P.kwargs) -> Awaitable[T]: + async def __call__(self, name: Any, *args: _P.args, **kwds: _P.kwargs) -> Awaitable[_T]: return await self._handlers[name](*args, **kwds) -class DispatcherDescriptor(Generic[P, T]): - def __get__(self, instance: "TransportFeature", owner) -> Dispatcher[P, T]: +class DispatcherDescriptor(Generic[_P, _T]): + def __get__(self, instance: "TransportFeature", owner) -> Dispatcher[_P, _T]: return instance.__dict__.setdefault(self.name, Dispatcher(instance.context)) - def __set__(self, instance: "TransportFeature", value: Dispatcher[P, T]) -> None: + def __set__(self, instance: "TransportFeature", value: Dispatcher[_P, _T]) -> None: pass def __set_name__(self, owner, name): self.name = name -def dispatcher(_: Callable[P, T]) -> DispatcherDescriptor[P, T]: +def dispatcher(_: Callable[_P, _T]) -> DispatcherDescriptor[_P, _T]: return DispatcherDescriptor() diff --git a/seamless/extra/transports/socketio/__init__.py b/seamless/extra/transports/socketio/__init__.py new file mode 100644 index 0000000..d61cdf3 --- /dev/null +++ b/seamless/extra/transports/socketio/__init__.py @@ -0,0 +1,4 @@ +from .middleware import SocketIOMiddleware +from .transport import SocketIOTransport + +__all__ = ["SocketIOMiddleware", "SocketIOTransport"] diff --git a/seamless/extra/transports/socketio/middleware.py b/seamless/extra/transports/socketio/middleware.py index e396d86..58cc533 100644 --- a/seamless/extra/transports/socketio/middleware.py +++ b/seamless/extra/transports/socketio/middleware.py @@ -1,7 +1,7 @@ from typing import Optional from socketio import ASGIApp -from pydom.context import Context, get_context +from ....context import Context, get_context from .transport import SocketIOTransport @@ -23,4 +23,9 @@ def __init__( self.app = ASGIApp(transport.server, app, socketio_path=self.socket_path) async def __call__(self, scope, receive, send): - await self.app(scope, receive, send) + ... + + def __new__(cls, *args, **kwargs): + instance = super().__new__(cls) + instance.__init__(*args, **kwargs) + return instance.app diff --git a/seamless/extra/transports/socketio/socketio.init.js b/seamless/extra/transports/socketio/socketio.init.js index f86a2d7..4745090 100644 --- a/seamless/extra/transports/socketio/socketio.init.js +++ b/seamless/extra/transports/socketio/socketio.init.js @@ -1,26 +1,27 @@ -await import("https://cdn.socket.io/4.7.5/socket.io.js"); -const socket = io(socketIOConfig); +import("https://cdn.socket.io/4.7.5/socket.io.js").then(() => { + const socket = io(socketIOConfig); -seamless.emit = (event, ...data) => { - socket.emit(event, ...data); -}; + seamless.emit = (event, ...data) => { + socket.emit(event, ...data); + }; -seamless.registerEventListener = (event, callback) => { - socket.on(event, callback); -}; + seamless.registerEventListener = (event, callback) => { + socket.on(event, callback); + }; -seamless.sendWaitResponse = (event, ...args) => { - return new Promise((resolve) => { - socket.emit(event, ...args, resolve); - }); -}; + seamless.sendWaitResponse = (event, ...args) => { + return new Promise((resolve) => { + socket.emit(event, ...args, resolve); + }); + }; -seamless.getComponent = async (name, props = {}) => { - return await seamless.sendWaitResponse("component", name, props); -}; + seamless.getComponent = (name, props = {}) => { + return seamless.sendWaitResponse("component", name, props); + }; -window.dispatchEvent( - new CustomEvent("transportsAvailable", { - detail: { clientId: socketIOConfig.query.client_id }, - }) -); + window.dispatchEvent( + new CustomEvent("transportsInitialized", { + detail: { clientId: socketIOConfig.query.client_id }, + }) + ); +}); diff --git a/seamless/extra/transports/socketio/transport.py b/seamless/extra/transports/socketio/transport.py index 7af9e79..4b40cbf 100644 --- a/seamless/extra/transports/socketio/transport.py +++ b/seamless/extra/transports/socketio/transport.py @@ -2,11 +2,10 @@ from pathlib import Path from urllib.parse import parse_qs -from pydom import Component, Context from pydom.rendering.render_state import RenderState -from pydom.utils.functions import random_string from socketio import AsyncServer +from .... import Component, Context from ....core.javascript import JS from ....core.empty import Empty from ..client_id import ClientID @@ -30,6 +29,12 @@ async def on_connect(self, sid, env): try: query = parse_qs(env.get("QUERY_STRING", "")) client_id = query.get("client_id", [None])[0] + + try: + TransportFeature.claim_client_id(client_id) + except KeyError: + raise TransportConnectionRefused("Client ID does not exist") + await self.connect(client_id, env) await self.server.save_session(sid, {"client_id": client_id}) except TransportConnectionRefused: @@ -45,19 +50,20 @@ async def on_disconnect(self, sid): client_id = await self._client_id(sid) await self.disconnect(client_id) - async def _client_id(self, sid): + async def _client_id(self, sid) -> str: session = await self.server.get_session(sid) return session["client_id"] @staticmethod def init(config=None): - init_js = JS(file=HERE / "socketio.init.js", async_=True) + init_js = JS(file=HERE / "socketio.init.js") class InitSocketIO(Component, inject_render=True): - def render(self, render_state: RenderState): - client_id = render_state.custom_data.setdefault( - "transports.client_id", random_string(24) - ) + def render(self, render_state: RenderState, context: Context): + transport = context.get_feature(SocketIOTransport) + client_id = transport.create_client_id() + + render_state.custom_data["transports.client_id"] = client_id socket_options = config or {} socket_options.setdefault("query", {})["client_id"] = client_id diff --git a/seamless/extra/transports/subscriptable.py b/seamless/extra/transports/subscriptable.py index 211d084..dddd2f8 100644 --- a/seamless/extra/transports/subscriptable.py +++ b/seamless/extra/transports/subscriptable.py @@ -1,21 +1,21 @@ from typing import Any, Generic, Callable -from pydom import Context -from pydom.context.feature import Feature +from ...context.feature import Feature +from ...context import Context from typing_extensions import ParamSpec from ...errors import ClientError -P = ParamSpec("P") +_P = ParamSpec("_P") -class Subscriptable(Generic[P]): +class Subscriptable(Generic[_P]): def __init__(self, context: Context) -> None: self._callbacks = [] self._context = context - async def __call__(self, *args: P.args, **kwds: P.kwargs) -> None: + async def __call__(self, *args: _P.args, **kwds: _P.kwargs) -> None: try: for callback in self._callbacks: await callback(*args, **kwds) @@ -30,16 +30,16 @@ def __iadd__(self, callback: Any) -> "Subscriptable": return self -class SubscriptableDescriptor(Generic[P]): - def __get__(self, instance: "Feature", owner) -> Subscriptable[P]: +class SubscriptableDescriptor(Generic[_P]): + def __get__(self, instance: "Feature", owner) -> Subscriptable[_P]: return instance.__dict__.setdefault(self.name, Subscriptable(instance.context)) - def __set__(self, instance: "Feature", value: Subscriptable[P]) -> None: + def __set__(self, instance: "Feature", value: Subscriptable[_P]) -> None: pass def __set_name__(self, owner, name): self.name = name -def event(_: Callable[P, None]) -> SubscriptableDescriptor[P]: +def event(_: Callable[_P, None]) -> SubscriptableDescriptor[_P]: return SubscriptableDescriptor() diff --git a/seamless/extra/transports/transport.py b/seamless/extra/transports/transport.py index 6430313..e800366 100644 --- a/seamless/extra/transports/transport.py +++ b/seamless/extra/transports/transport.py @@ -1,7 +1,8 @@ -from typing import Any +from typing import Any, Set -from pydom.context import Context +from pydom.utils.functions import random_string +from ...context import Context from ..feature import Feature from .dispatcher import dispatcher from .subscriptable import event @@ -30,3 +31,16 @@ def error(error: Exception) -> None: @staticmethod def event(client_id: str, /, *args: Any) -> Any: pass + + _client_ids: Set[str] = set() + + @staticmethod + def create_client_id() -> str: + client_id = random_string(24) + TransportFeature._client_ids.add(client_id) + return client_id + + @staticmethod + def claim_client_id(client_id: str): + TransportFeature._client_ids.remove(client_id) + \ No newline at end of file diff --git a/seamless/internal/constants.py b/seamless/internal/constants.py index ecea36b..df14300 100644 --- a/seamless/internal/constants.py +++ b/seamless/internal/constants.py @@ -1,5 +1,7 @@ SEAMLESS_ELEMENT_ATTRIBUTE = "seamless:element" SEAMLESS_INIT_ATTRIBUTE = "seamless:init" -SEAMLESS_INIT_ASYNC_ATTRIBUTE = "seamless:async" -DISABLE_GLOBAL_CONTEXT_ENV = "SEAMLESS_DISABLE_GLOBAL_CONTEXT" \ No newline at end of file +DISABLE_GLOBAL_CONTEXT_ENV = "SEAMLESS_DISABLE_GLOBAL_CONTEXT" +DISABLE_VALIDATION_ENV = "SEAMLESS_DISABLE_VALIDATION" + +UNSAFE_GLOBAL_EVENT_ATTRIBUTE = "seamless:unsafe-global-event" \ No newline at end of file diff --git a/seamless/internal/cookies.py b/seamless/internal/cookies.py index 1f5524c..c16d7a4 100644 --- a/seamless/internal/cookies.py +++ b/seamless/internal/cookies.py @@ -1,10 +1,11 @@ # type: ignore -from typing import Iterable +from typing import Iterable, Union + class Cookies: def __init__(self, cookie_string: str): - self.cookies = dict[str, str]() + self.cookies: dict[str, str] = {} self._parse(cookie_string) def _parse(self, cookie_string: str): @@ -22,7 +23,7 @@ def __contains__(self, key: str): return key in self.cookies @staticmethod - def from_request_headers(headers: Iterable[Iterable[bytes]] | dict): + def from_request_headers(headers: Union[Iterable[Iterable[bytes]], dict]): cookie_string = "" if isinstance(headers, dict): return Cookies(headers.get("cookie", "")) diff --git a/seamless/internal/injector.py b/seamless/internal/injector.py deleted file mode 100644 index 197cbfa..0000000 --- a/seamless/internal/injector.py +++ /dev/null @@ -1,24 +0,0 @@ -from inspect import iscoroutinefunction -from functools import wraps -from typing import TypeVar, Callable, TypeAlias - -from pydom.utils.injector import Injector as _Injector - - -T = TypeVar("T") - -InjectFactory: TypeAlias = Callable[[], T] - - -class Injector(_Injector): - def inject(self, callback: Callable) -> Callable: - if iscoroutinefunction(callback): - - @wraps(callback) - async def wrapper(*args, **kwargs): # type: ignore - keyword_args = self.inject_params(callback) - return await callback(*args, **keyword_args, **kwargs) - - return wrapper - - return super().inject(callback) diff --git a/seamless/internal/utils.py b/seamless/internal/utils.py index c798018..1e8cd0a 100644 --- a/seamless/internal/utils.py +++ b/seamless/internal/utils.py @@ -1,6 +1,8 @@ from functools import wraps from inspect import iscoroutinefunction, isfunction +from seamless.internal.constants import UNSAFE_GLOBAL_EVENT_ATTRIBUTE + class Promise: def __init__(self, value): @@ -52,10 +54,12 @@ def __init__(self, d: dict): def is_global(func): func = original_func(func) + if getattr(func, UNSAFE_GLOBAL_EVENT_ATTRIBUTE, False): + return True return isfunction(func) and (getattr(func, "__closure__", None) is None) def original_func(func): if hasattr(func, "__wrapped__"): return original_func(func.__wrapped__) - return func \ No newline at end of file + return func diff --git a/seamless/middlewares/__init__.py b/seamless/middlewares/__init__.py new file mode 100644 index 0000000..edcf111 --- /dev/null +++ b/seamless/middlewares/__init__.py @@ -0,0 +1,5 @@ +from ..extra.transports.socketio.middleware import SocketIOMiddleware + +__all__ = [ + "SocketIOMiddleware", +] diff --git a/seamless/styling/__init__.py b/seamless/styling/__init__.py deleted file mode 100644 index de7899e..0000000 --- a/seamless/styling/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from pydom.styling import * diff --git a/seamless/styling/style.py b/seamless/styling/style.py deleted file mode 100644 index 0eb88e9..0000000 --- a/seamless/styling/style.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import Generic, TypeVar, Unpack, TYPE_CHECKING - -if TYPE_CHECKING: - from ..types.styling.css_properties import CSSProperties - -T = TypeVar("T") - - -class StyleObject: - class _StyleProperty(Generic[T]): - def __init__(self, instance: "StyleObject", name: str): - self.instance = instance - self.name = name.replace("_", "-") - - def __call__(self, value: T): - self.instance.style[self.name] = value - return self.instance - - def __init__( - self, *styles: "StyleObject | CSSProperties", **kwargs: Unpack["CSSProperties"] - ): - self.style: dict[str, object] = {} - for style in styles: - if isinstance(style, StyleObject): - style = style.style - self.style.update(style) - self.style.update(kwargs) - self.style = { - k.replace("_", "-"): v for k, v in self.style.items() if v is not None - } - - def copy(self): - return StyleObject(self) - - def to_css(self): - return "".join(map(lambda x: f"{x[0]}:{x[1]};", self.style.items())) - - def __str__(self): - return self.to_css() - - def __getattr__(self, name: str): - return StyleObject._StyleProperty(self, name) diff --git a/seamless/types/events.py b/seamless/types/events.py index a903af1..17d288d 100644 --- a/seamless/types/events.py +++ b/seamless/types/events.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Generic, TypeVar +from typing import Generic, Optional, TypeVar try: from pydantic import BaseModel # type: ignore @@ -9,7 +9,7 @@ class BaseModel: ... -T = TypeVar("T") +_T = TypeVar("_T") # region: Event Types @@ -112,7 +112,7 @@ class PointerEvent(Event): ... class PopStateEvent(Event): - state: dict | None + state: Optional[dict] class ProgressEvent(Event): ... @@ -131,8 +131,8 @@ class StorageEvent(Event): url: str -class SubmitEvent(Event, Generic[T]): - data: T +class SubmitEvent(Event, Generic[_T]): + data: _T class TimeEvent(Event): ... diff --git a/seamless/types/html/html_element.py b/seamless/types/html/html_element.py index e76dc21..9d99cf8 100644 --- a/seamless/types/html/html_element.py +++ b/seamless/types/html/html_element.py @@ -1,29 +1,11 @@ from typing import TYPE_CHECKING, Iterable, Union, Literal -from typing_extensions import TypedDict +from pydom.styling import StyleSheet +from pydom.types.html.html_element import HTMLElement if TYPE_CHECKING: from seamless.core.javascript import JS - from seamless.styling import StyleObject -class HTMLElement(TypedDict, total=False, closed=False): - access_key: str - auto_capitalize: str - class_name: str | Iterable[str] - content_editable: str - # data: dict[str, str] # add this if needed in the future - dir: Literal["ltr", "rtl", "auto"] - draggable: str - hidden: str - id: str - input_mode: str - lang: str - role: str - spell_check: str - style: Union[str, "StyleObject"] - tab_index: str - title: str - translate: str - +class HTMLElement(HTMLElement, total=False, closed=False): init: "JS" diff --git a/seamless/types/html/html_element_props.py b/seamless/types/html/html_element_props.py index f5347c4..c77a916 100644 --- a/seamless/types/html/html_element_props.py +++ b/seamless/types/html/html_element_props.py @@ -1,4 +1,4 @@ -from seamless.types.html.aria_props import AriaProps +from pydom.types.html.aria_props import AriaProps from seamless.types.html.html_element import HTMLElement from seamless.types.html.html_event_props import HTMLEventProps diff --git a/seamless/types/html/html_event_props.py b/seamless/types/html/html_event_props.py index 1f44adc..f2f526d 100644 --- a/seamless/types/html/html_event_props.py +++ b/seamless/types/html/html_event_props.py @@ -1,9 +1,7 @@ -from typing import TYPE_CHECKING, Callable, Concatenate, TypeVar, Union +import sys +from typing import TYPE_CHECKING, Callable, TypeVar, Union -from typing_extensions import TypedDict - -if TYPE_CHECKING: - from seamless.core.javascript import JS +from typing_extensions import ParamSpec, TypedDict, Concatenate from seamless.types.events import ( CloseEvent, @@ -19,8 +17,23 @@ WheelEvent, ) -EventProps = TypeVar("EventProps", bound=Event) -EventFunction = Union[Callable[Concatenate[EventProps, ...], None], "JS", str] +_P = ParamSpec("_P") +EventProps = TypeVar("EventProps", bound=Event, contravariant=True) + +if TYPE_CHECKING: + from seamless.core.javascript import JS + + if sys.version_info >= (3, 11): + EventFunction = Union[Callable[Concatenate[EventProps, ...], None], "JS", str] + else: + from typing import Protocol + + class EventCallable(Protocol[EventProps]): + def __call__(self, _: EventProps, /, **kwargs) -> None: ... + + EventFunction = Union[EventCallable[EventProps], JS, str] +else: + EventFunction = Union[Callable[[EventProps], None], "JS", str] class HTMLEventProps(TypedDict, total=False): diff --git a/seamless/types/styling/css_properties.py b/seamless/types/styling/css_properties.py deleted file mode 100644 index 10e7018..0000000 --- a/seamless/types/styling/css_properties.py +++ /dev/null @@ -1,280 +0,0 @@ -from typing import Any, Generic, Literal, TypeVar, Union, TypeAlias -from typing_extensions import TypedDict - -float_ = float -T = TypeVar("T") - -AlignContent: TypeAlias = Literal[ - "flex-start", "flex-end", "center", "space-between", "space-around", "stretch" -] - - -class StyleProperty(Generic[T]): - def __call__(self, value: T) -> Any: ... - - -class CSSProperties(TypedDict, total=False, closed=False): - align_content: AlignContent - align_items: Literal["flex-start", "flex-end", "center", "baseline", "stretch"] - align_self: Union[ - str, Literal["auto", "flex-start", "flex-end", "center", "baseline", "stretch"] - ] - animation: str - animation_delay: str - animation_direction: Union[ - str, Literal["normal", "reverse", "alternate", "alternate-reverse"] - ] - animation_duration: str - animation_fill_mode: Union[str, Literal["none", "forwards", "backwards", "both"]] - animation_iteration_count: Union[str, Literal["infinite", "n"]] - animation_name: str - animation_play_state: Union[str, Literal["running", "paused"]] - animation_timing_function: str - backface_visibility: Union[str, Literal["visible", "hidden"]] - background: str - background_attachment: Union[str, Literal["scroll", "fixed", "local"]] - background_blend_mode: str - background_clip: Union[str, Literal["border-box", "padding-box", "content-box"]] - background_color: str - background_image: str - background_origin: Union[str, Literal["padding-box", "border-box", "content-box"]] - background_position: str - background_repeat: Union[ - str, Literal["repeat", "repeat-x", "repeat-y", "no-repeat", "space", "round"] - ] - background_size: str - border: str - border_bottom: str - border_bottom_color: str - border_bottom_left_radius: str - border_bottom_right_radius: str - border_bottom_style: str - border_bottom_width: str - border_collapse: Union[str, Literal["collapse", "separate"]] - border_color: str - border_image: str - border_image_outset: str - border_image_repeat: Union[str, Literal["stretch", "repeat", "round"]] - border_image_slice: str - border_image_source: str - border_image_width: str - border_left: str - border_left_color: str - border_left_style: str - border_left_width: str - border_radius: str - border_right: str - border_right_color: str - border_right_style: str - border_right_width: str - border_spacing: str - border_style: str - border_top: str - border_top_color: str - border_top_left_radius: str - border_top_right_radius: str - border_top_style: str - border_top_width: str - border_width: str - bottom: str - box_shadow: str - box_sizing: Union[str, Literal["content-box", "border-box"]] - caption_side: Union[str, Literal["top", "bottom"]] - clear: Union[str, Literal["none", "left", "right", "both"]] - clip: str - color: str - column_count: Union[str, int] - column_fill: Union[str, Literal["balance", "auto"]] - column_gap: str - column_rule: str - column_rule_color: str - column_rule_style: str - column_rule_width: str - column_span: Union[str, Literal["none", "all"]] - column_width: Union[str, int] - columns: str - content: str - counter_increment: str - counter_reset: str - cursor: str - direction: Union[str, Literal["ltr", "rtl"]] - display: Union[ - str, - Literal[ - "block", - "inline", - "inline-block", - "flex", - "inline-flex", - "grid", - "inline-grid", - "table", - "table-row", - "table-cell", - "none", - ], - ] - empty_cells: Union[str, Literal["show", "hide"]] - filter: str - flex: str - flex_basis: str - flex_direction: Union[str, Literal["row", "row-reverse", "column", "column-reverse"]] - flex_flow: str - flex_grow: str - flex_shrink: str - flex_wrap: Union[str, Literal["nowrap", "wrap", "wrap-reverse"]] - float: Union[str, Literal["left", "right", "none"]] - font: str - font_family: str - font_feature_settings: str - font_kerning: Union[str, Literal["auto", "normal", "none"]] - font_language_override: str - font_size: str - font_size_adjust: Union[str, Literal["none"]] - font_stretch: str - font_style: Union[str, Literal["normal", "italic", "oblique"]] - font_synthesis: str - font_variant: str - font_variant_alternates: str - font_variant_caps: Union[str, Literal["normal", "small-caps"]] - font_variant_east_asian: str - font_variant_ligatures: str - font_variant_numeric: str - font_variant_position: Union[str, Literal["normal", "sub", "super"]] - font_weight: Union[ - str, - Literal[ - "normal", - "bold", - "bolder", - "lighter", - "100", - "200", - "300", - "400", - "500", - "600", - "700", - "800", - "900", - ], - ] - grid: str - grid_area: str - grid_auto_columns: str - grid_auto_flow: str - grid_auto_rows: str - grid_column: str - grid_column_end: str - grid_column_gap: str - grid_column_start: str - grid_gap: str - grid_row: str - grid_row_end: str - grid_row_gap: str - grid_row_start: str - grid_template: str - grid_template_areas: str - grid_template_columns: str - grid_template_rows: str - height: str - hyphens: Union[str, Literal["none", "manual", "auto"]] - image_rendering: str - isolation: Union[str, Literal["auto", "isolate"]] - justify_content: Union[ - str, - Literal[ - "flex-start", - "flex-end", - "center", - "space-between", - "space-around", - "space-evenly", - ], - ] - left: str - letter_spacing: str - line_break: Union[str, Literal["auto", "loose", "normal", "strict"]] - line_height: Union[str, int] - list_style: str - list_style_image: str - list_style_position: Union[str, Literal["inside", "outside"]] - list_style_type: str - margin: str - margin_bottom: str - margin_left: str - margin_right: str - margin_top: str - max_height: str - max_width: str - min_height: str - min_width: str - mix_blend_mode: str - object_fit: Union[str, Literal["fill", "contain", "cover", "none", "scale-down"]] - object_position: str - opacity: Union[str, float_] - order: Union[str, int] - outline: str - outline_color: str - outline_offset: str - outline_style: str - outline_width: str - overflow: Union[str, Literal["auto", "hidden", "scroll", "visible"]] - overflow_wrap: Union[str, Literal["normal", "break-word", "anywhere"]] - overflow_x: Union[str, Literal["auto", "hidden", "scroll", "visible"]] - overflow_y: Union[str, Literal["auto", "hidden", "scroll", "visible"]] - padding: str - padding_bottom: str - padding_left: str - padding_right: str - padding_top: str - page_break_after: Union[str, Literal["auto", "always", "avoid", "left", "right"]] - page_break_before: Union[str, Literal["auto", "always", "avoid", "left", "right"]] - page_break_inside: Union[str, Literal["auto", "avoid"]] - perspective: str - perspective_origin: str - position: Union[str, Literal["static", "relative", "absolute", "fixed", "sticky"]] - quotes: str - resize: Union[str, Literal["none", "both", "horizontal", "vertical"]] - right: str - scroll_behavior: Union[str, Literal["auto", "smooth"]] - tab_size: Union[str, int] - table_layout: Union[str, Literal["auto", "fixed"]] - text_align: Union[str, Literal["left", "right", "center", "justify", "start", "end"]] - text_align_last: Union[str, Literal["auto", "left", "right", "center", "justify", "start", "end"]] - text_decoration: str - text_decoration_color: str - text_decoration_line: str - text_decoration_style: str - text_indent: str - text_justify: Union[str, Literal["auto", "inter-word", "inter-character", "none"]] - text_overflow: Union[str, Literal["clip", "ellipsis"]] - text_shadow: str - text_transform: Union[str, Literal["none", "capitalize", "uppercase", "lowercase", "full-width"]] - text_underline_position: str - top: str - transform: str - transform_origin: str - transform_style: Union[str, Literal["flat", "preserve-3d"]] - transition: str - transition_delay: str - transition_duration: str - transition_property: str - transition_timing_function: str - unicode_bidi: Union[str, Literal["normal", "embed", "isolate", "bidi-override"]] - user_select: Union[str, Literal["auto", "text", "none", "contain", "all"]] - vertical_align: str - visibility: Union[str, Literal["visible", "hidden", "collapse"]] - white_space: Union[str, Literal["normal", "nowrap", "pre", "pre-line", "pre-wrap"]] - widows: Union[str, int] - width: str - will_change: str - word_break: Union[str, Literal["normal", "break-all", "keep-all", "break-word"]] - word_spacing: str - writing_mode: Union[ - str, - Literal[ - "horizontal-tb", "vertical-rl", "vertical-lr", "sideways-rl", "sideways-lr" - ], - ] - z_index: Union[str, int] diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..c1bce1d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,4 @@ +import sys +import os + +sys.path.append(os.path.join(os.path.dirname(__file__), '../src')) \ No newline at end of file diff --git a/tests/base.py b/tests/base.py new file mode 100644 index 0000000..6fc58e4 --- /dev/null +++ b/tests/base.py @@ -0,0 +1,35 @@ +from typing import Union, overload +import unittest + +from pydom import render +from pydom.types.rendering import Primitive, Renderable + +from .utils import test_render_with_file + + +class TestCase(unittest.TestCase): + @overload + def assertRender( + self, + component: Union[Renderable, Primitive], + expected: Union[str, dict], + /, + **kwargs + ): ... + @overload + def assertRender( + self, + component: Union[Renderable, Primitive], + /, + *, + file: str, + **kwargs + ): ... + + def assertRender(self, component, expected=None, *, file=None, **kwargs): + if expected is not None: + self.assertEqual(render(component, **kwargs), expected) + elif file is not None: + self.assertTrue(test_render_with_file(component, file)) + else: + raise ValueError("Expected or file must be provided") diff --git a/tests/components/__init__.py b/tests/components/__init__.py index 6fb8b71..d0e88bc 100644 --- a/tests/components/__init__.py +++ b/tests/components/__init__.py @@ -1,5 +1,5 @@ -from seamless import Component, Div, H3, Hr, Link, Button, JS -from seamless.components import Page as _Page +from pydom import Component, Div, H3, Hr, Link +from pydom.page import Page as _Page class Plugin(Component): @@ -9,8 +9,9 @@ def __init__(self, name, version) -> None: def render(self): return Div( + classes="plugin", + )( f"{self.name} v{self.version}", - class_name="plugin", ) @@ -20,44 +21,36 @@ def __init__(self, plugins=None) -> None: def render(self): return Div( + classes="plugin-list", + )( *[Plugin(plugin.name, plugin.version) for plugin in self.plugins], - class_name="plugin-list", ) class Card(Component): def render(self): return Div( + classes="card", + )( *self.children, - class_name="card", ) class CardTitle(Component): def render(self): return H3( + classes="card-title", + )( *self.children, - class_name="card-title", ) class App(Component): def render(self): - return Card( - CardTitle("Card title"), - Hr(), - Div("Card content"), - ) + return Card(CardTitle("Card title"), Hr(), Div(*self.children)) class Page(_Page): def head(self): yield from super().head() yield Link(rel="stylesheet", href="/static/style.css") - - -class AlertButton(Component): - def render(self): - return Button(on_click=JS("alert('Button clicked')"))( - "Click me", - ) \ No newline at end of file diff --git a/tests/css/styles.css b/tests/css/styles.css new file mode 100644 index 0000000..452f1f0 --- /dev/null +++ b/tests/css/styles.css @@ -0,0 +1,13 @@ +.card { + background-color: #fff; + border-radius: 5px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin: 10px; + padding: 20px; +} + +.card_header { + font-size: 1.5em; + font-weight: bold; + margin-bottom: 10px; +} \ No newline at end of file diff --git a/tests/html/escaping.html b/tests/html/escaping.html new file mode 100644 index 0000000..02aebac --- /dev/null +++ b/tests/html/escaping.html @@ -0,0 +1,4 @@ +

+ <script>console.log('hello world'); if (a < 2) { + console.log('a is less than 2'); }</script> +
diff --git a/tests/html/escaping_script.html b/tests/html/escaping_script.html new file mode 100644 index 0000000..9123b65 --- /dev/null +++ b/tests/html/escaping_script.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/tests/html/nested_components.html b/tests/html/nested_components.html new file mode 100644 index 0000000..24dceba --- /dev/null +++ b/tests/html/nested_components.html @@ -0,0 +1,5 @@ +
+

Card title

+
+
+
diff --git a/tests/html/page_inheritance.html b/tests/html/page_inheritance.html new file mode 100644 index 0000000..da5370f --- /dev/null +++ b/tests/html/page_inheritance.html @@ -0,0 +1,15 @@ + + + + + + + + +
+

Card title

+
+
+
+ + diff --git a/tests/manual.py b/tests/manual.py index e374f93..f6499a3 100644 --- a/tests/manual.py +++ b/tests/manual.py @@ -1,101 +1,32 @@ -from functools import wraps -from inspect import iscoroutinefunction -from pathlib import Path -from fastapi import FastAPI, Response -from fastapi.responses import FileResponse -from pydantic import BaseModel -from pydom.element import Element -from seamless import * -from seamless.extra.transports.socketio.middleware import SocketIOMiddleware -from seamless.styling import CSS -from seamless.components import Page as BasePage -from .server.common import Card, Page, SuperCard, SampleComponent -from .components import Page as TestPage, App +from pydom import render, Div, Component +from components import App, Page +from pydom.context.context import get_context +import pydom.context.standard.transformers as t +from pydom.context.standard.transformers.class_transformer import ClassTransformer +from pydom.rendering.render_state import RenderState +from utils import test_render_with_file -app = FastAPI() -app.add_middleware(SocketIOMiddleware) +context = get_context() -def _make_response(response): - if isinstance(response, (Component, Element)): - if not isinstance(response, BasePage): - response = BasePage(response) +class User: ... - response = Response(render(response), media_type="text/html") - return response +def get_user(): + return {"name": "John Doe"} -@wraps(app.get) -def get(path: str, **kwargs): - def wrapper(_handler): - if iscoroutinefunction(_handler): +context.injector.add(User, get_user) - @wraps(_handler) - async def handler(*args, **kwargs): - response = await _handler(*args, **kwargs) - return _make_response(response) +class Foo(Component): + def render(self): + foo() + return Div("Hello, world!") - else: +@context.inject +def foo(user: RenderState): + print(user) - @wraps(_handler) - def handler(*args, **kwargs): - response = _handler(*args, **kwargs) - return _make_response(response) +render(Foo()) - app.get(path, **kwargs)(handler) - - return wrapper - - -def click_handler(*args, **kwargs): - print("Button clicked") - - -def card(super=True): - return (SuperCard(is_super=True) if super else Card())( - SampleComponent(name="world"), - Button("Click me"), - Form(on_submit=submit, action="#")( - Input(placeholder="Enter your name", name="name"), - Button("Submit"), - ), - ) - - -from typing import Generic, TypeVar - -T = TypeVar("T") - - -class SubmitEvent(BaseModel, Generic[T]): - type: str - data: T - - -class MyForm(BaseModel): - name: str - - -def submit(event: SubmitEvent[MyForm]): - print(f"Form submitted: name = {event.data.name}") - - -@get("/") -def index(super: bool = True): - return TestPage(App()) - - -@app.get("/static/main.js") -def socket_io_static(): - return FileResponse(Path(__file__).parent / "server/static/main.js") - - -@app.get("/static/main.css") -def css_file(): - return Response(CSS.to_css_string(), media_type="text/css") - - -@app.get("/static/main.min.css") -def css_file_min(): - return Response(CSS.to_css_string(minified=True), media_type="text/css") +foo() diff --git a/tests/requirements.txt b/tests/requirements.txt index a01e594..cdd302a 100644 Binary files a/tests/requirements.txt and b/tests/requirements.txt differ diff --git a/tests/server/common.py b/tests/server/common.py index c25f3f6..b473c90 100644 --- a/tests/server/common.py +++ b/tests/server/common.py @@ -2,7 +2,7 @@ from seamless.components import Page as _Page from seamless.extra.transports.socketio.transport import SocketIOTransport from seamless.html import * -from seamless.styling import CSS, StyleObject +from pydom.styling import CSS, StyleSheet def index(): @@ -57,8 +57,8 @@ def __init__(self, rounded=True) -> None: def render(self): styles = CSS.module("./static/card.css") return Div( - class_name=styles.card, - style=StyleObject(border_radius="5px") if self.rounded else None, + classes=styles.card, + style=StyleSheet(border_radius="5px") if self.rounded else None, )(*self.children) @@ -70,6 +70,6 @@ def __init__(self, rounded=True, is_super=False) -> None: def render(self): styles = CSS.module("./static/card.css") return Div( - class_name=styles.card, - style=StyleObject(border_radius="5px") if self.rounded else None, + classes=styles.card, + style=StyleSheet(border_radius="5px") if self.rounded else None, )(Div("Super card!" if self.is_super else "Card!"), *self.children) diff --git a/tests/simple.py b/tests/simple.py new file mode 100644 index 0000000..c9e7b6d --- /dev/null +++ b/tests/simple.py @@ -0,0 +1,27 @@ +from http.server import BaseHTTPRequestHandler, HTTPServer + +from pydom import render, Div +from pydom.page import Page + + +def index(): + return Page(title="Hello, world!")( + Div(classes=["a", "b"])("Hello, world!") + ) + + +class SimpleHTTPRequestHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(render(index()).encode("utf-8")) + + +def run(server_class=HTTPServer, handler_class=SimpleHTTPRequestHandler): + server_address = ("", 8000) + httpd = server_class(server_address, handler_class) + httpd.serve_forever() + + +if __name__ == "__main__": + run() diff --git a/tests/test_css_modules.py b/tests/test_css_modules.py new file mode 100644 index 0000000..6253f1c --- /dev/null +++ b/tests/test_css_modules.py @@ -0,0 +1,21 @@ +from pydom.styling import CSS + +from .base import TestCase + + +class CSSModulesTest(TestCase): + @classmethod + def setUpClass(cls) -> None: + styles = CSS.module("tests/css/styles.css") + cls.card_class = styles.card + cls.card_header_class = styles.card_header + + def test_classes(self): + styles = CSS.module("tests/css/styles.css") + self.assertEqual(self.card_class, styles.card) + self.assertEqual(self.card_header_class, styles.card_header) + + def test_relative_css(self): + styles = CSS.module("./css/styles.css") + self.assertEqual(styles.card, self.card_class) + self.assertEqual(styles.card_header, self.card_header_class) diff --git a/tests/test_escaping.py b/tests/test_escaping.py new file mode 100644 index 0000000..07f2d4c --- /dev/null +++ b/tests/test_escaping.py @@ -0,0 +1,39 @@ +from pydom import Div, Script, Style + +from .base import TestCase + + +class EscapingComponentsTest(TestCase): + def test_escaping(self): + self.assertRender( + Div( + "" + ), + "
" + "<script>" + "console.log('hello world');" + "if (a < 2) {" + " console.log('a is less than 2');" + "}" + "</script>" + "
", + ) + + def test_escaping_style(self): + self.assertRender( + Style("body { color: red; }"), + "", + ) + + def test_escaping_script(self): + self.assertRender( + Script( + "console.log('hello world'); if (a < 2) { console.log('a is less than 2'); }" + ), + file="html/escaping_script.html", + ) diff --git a/tests/test_html_rendering.py b/tests/test_html_rendering.py new file mode 100644 index 0000000..8c89233 --- /dev/null +++ b/tests/test_html_rendering.py @@ -0,0 +1,118 @@ +from pydom import Component, Div, render +from pydom.element import Element +from pydom.errors import RenderError + +from .base import TestCase + + +class TestRender(TestCase): + def test_render(self): + self.assertRender(Div(), "
") + + def test_render_component(self): + class MyComponent(Component): + def render(self): + return Div() + + self.assertRender(MyComponent(), "
") + + def test_render_element(self): + class MyElement(Element): + tag_name = "my-element" + + self.assertRender(MyElement(), "") + + def test_render_inline_element(self): + class MyInlineElement(Element): + tag_name = "my-element" + inline = True + + self.assertRender(MyInlineElement(), "") + + def test_render_nested(self): + self.assertRender(Div(Div()), "
") + + def test_render_text(self): + self.assertRender(Div("Hello"), "
Hello
") + + def test_render_attributes(self): + self.assertRender(Div(id="my-id"), '
') + + def test_render_children(self): + self.assertRender(Div(Div(), Div()), "
") + + def test_render_component_children(self): + class MyComponent(Component): + def render(self): + return Div(Div(), Div()) + + self.assertRender(MyComponent(), "
") + + def test_render_element_children(self): + class MyElement(Element): + tag_name = "my-element" + + self.assertRender( + MyElement(Div(), Div()), + "
", + ) + + def test_render_text_children(self): + self.assertRender(Div("Hello", "World"), "
HelloWorld
") + + def test_render_nested_children(self): + self.assertRender( + Div(Div(Div()), Div(Div())), + "
", + ) + + def test_render_nested_text_children(self): + self.assertRender( + Div(Div("Hello"), Div("World")), + "
Hello
World
", + ) + + def test_render_nested_mixed_children(self): + self.assertRender( + Div(Div("Hello", id="my-div"), Div(), "World"), + '
Hello
World
', + ) + + def test_render_list_children(self): + self.assertRender(Div([Div(), Div()]), "
") + + def test_render_nested_list_comprehension_children(self): + self.assertRender( + Div([Div(i) for i in range(5)]), + "
0
1
2
3
4
", + ) + + def test_render_nested_list_children(self): + self.assertRender( + Div([Div([Div()]), Div([Div()])]), + "
", + ) + + def test_render_list_as_component_children(self): + class ItemList(Component): + def __init__(self, items) -> None: + self.items = items + + def render(self): + return Div([Item(item=item) for item in self.items]) + + class Item(Component): + def __init__(self, item) -> None: + self.item = item + + def render(self): + return Div(self.item) + + self.assertRender( + ItemList(items=[f"item{i}" for i in range(1, 5)]), + "
item1
item2
item3
item4
", + ) + + def test_render_invalid_children(self): + with self.assertRaises(RenderError): + render(Div(object())) # type: ignore - this is intentional diff --git a/tests/test_json_rendering.py b/tests/test_json_rendering.py new file mode 100644 index 0000000..72d4f92 --- /dev/null +++ b/tests/test_json_rendering.py @@ -0,0 +1,191 @@ +from typing import Union, overload +from pydom import Component, Div +from pydom.element import Element +from pydom.types.rendering import Primitive, Renderable + +from .base import TestCase + + +class TestRender(TestCase): + @overload + def assertRenderJson( + self, component: Union[Renderable, Primitive], expected: dict, /, **kwargs + ): ... + @overload + def assertRenderJson( + self, component: Union[Renderable, Primitive], *, file: str, **kwargs + ): ... + + def assertRenderJson( + self, + component: Union[Renderable, Primitive], + expected=None, + *, + file=None, + **kwargs, + ): + if expected is not None: + self.assertRender(component, expected, to="json", **kwargs) + elif file is not None: + self.assertRender(component, file=file, to="json", **kwargs) + else: + raise ValueError("Expected or file must be provided") + + def test_render_json(self): + self.assertRenderJson(Div(), {"type": "div", "children": [], "props": {}}) + + def test_render_json_component(self): + class MyComponent(Component): + def render(self): + return Div() + + self.assertRender( + MyComponent(), + {"type": "div", "children": [], "props": {}}, + to="json", + ) + + def test_render_json_element(self): + class MyElement(Element): + tag_name = "my-element" + + self.assertRenderJson( + MyElement(), + {"type": "my-element", "children": [], "props": {}}, + ) + + def test_render_json_nested(self): + self.assertRenderJson( + Div(Div()), + { + "type": "div", + "children": [{"type": "div", "children": [], "props": {}}], + "props": {}, + }, + ) + + def test_render_json_text(self): + self.assertRenderJson( + Div("Hello"), + {"type": "div", "children": ["Hello"], "props": {}}, + ) + + def test_render_json_attributes(self): + self.assertRenderJson( + Div(id="my-id"), + {"type": "div", "children": [], "props": {"id": "my-id"}}, + ) + + def test_render_json_nested_component(self): + class MyComponent(Component): + def render(self): + return Div( + "Hello", + Div(), + id="my-id", + classes="my-class", + ) + + class MyComponent2(Component): + def render(self): + return Div( + MyComponent(), + classes="my-class", + ) + + self.assertRenderJson( + MyComponent2(), + { + "type": "div", + "children": [ + { + "type": "div", + "children": [ + "Hello", + {"type": "div", "children": [], "props": {}}, + ], + "props": {"id": "my-id", "class": "my-class"}, + } + ], + "props": {"class": "my-class"}, + }, + ) + + def test_render_list_children(self): + self.assertRenderJson( + Div([Div(), Div()]), + { + "type": "div", + "children": [ + {"type": "div", "children": [], "props": {}}, + {"type": "div", "children": [], "props": {}}, + ], + "props": {}, + }, + ) + + def test_render_nested_list_comprehension_children(self): + self.assertRenderJson( + Div([Div(i) for i in range(5)]), + { + "type": "div", + "children": [ + {"type": "div", "children": [str(i)], "props": {}} for i in range(5) + ], + "props": {}, + }, + ) + + def test_render_nested_list_children(self): + self.assertRenderJson( + Div([Div([Div(), Div()]), Div([Div(), Div()])]), + { + "type": "div", + "children": [ + { + "type": "div", + "children": [ + {"type": "div", "children": [], "props": {}}, + {"type": "div", "children": [], "props": {}}, + ], + "props": {}, + }, + { + "type": "div", + "children": [ + {"type": "div", "children": [], "props": {}}, + {"type": "div", "children": [], "props": {}}, + ], + "props": {}, + }, + ], + "props": {}, + }, + ) + + def test_render_list_as_component_children(self): + class ItemList(Component): + def __init__(self, items) -> None: + self.items = items + + def render(self): + return Div([Item(item=item) for item in self.items]) + + class Item(Component): + def __init__(self, item) -> None: + self.item = item + + def render(self): + return Div(self.item) + + self.assertRenderJson( + ItemList(items=[f"item{i}" for i in range(1, 5)]), + { + "type": "div", + "children": [ + {"type": "div", "children": [f"item{i}"], "props": {}} + for i in range(1, 5) + ], + "props": {}, + }, + ) \ No newline at end of file diff --git a/tests/test_nested.py b/tests/test_nested.py index 261ef33..efb6958 100644 --- a/tests/test_nested.py +++ b/tests/test_nested.py @@ -1,18 +1,11 @@ -import unittest -from seamless import render from .components import App, Page +from .base import TestCase -class NestedComponentsTest(unittest.TestCase): +class NestedComponentsTest(TestCase): def test_nested_components(self): - self.assertEqual( - render(App()), - '

Card title


Card content
', - ) + self.assertRender(App(), file="html/nested_components.html") def test_page_inheritance(self): self.maxDiff = None - self.assertEqual( - render(Page(App())), - '

Card title


Card content
', - ) + self.assertRender(Page(App()), file="html/page_inheritance.html") diff --git a/tests/test_rendering.py b/tests/test_rendering.py index a1ea742..a679027 100644 --- a/tests/test_rendering.py +++ b/tests/test_rendering.py @@ -122,14 +122,14 @@ def render(self): "Hello", Div(), id="my-id", - class_name="my-class", + classes="my-class", ) class MyComponent2(Component): def render(self): return Div( MyComponent(), - class_name="my-class", + classes="my-class", ) self.assertEqual( diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..3b2d5c2 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,26 @@ +from typing import Union +from pathlib import Path + +from pydom import render +from pydom.types import Renderable, Primitive + +ROOT_DIR = Path(__file__).parent + + +def test_render_with_file( + element: Union[Renderable, Primitive], + file: str, + *, + pretty=False, + **kwargs, +) -> bool: + lines = [] + with open(ROOT_DIR / file) as f: + lines = f.readlines() + if not pretty: + lines = [line.strip() for line in lines] + + expected = "".join(lines) + actual = render(element, pretty=pretty, **kwargs) + + return expected == actual