diff --git a/.github/workflows/.hatch-run.yml b/.github/workflows/.hatch-run.yml index 0a5579d77..c8770d184 100644 --- a/.github/workflows/.hatch-run.yml +++ b/.github/workflows/.hatch-run.yml @@ -43,7 +43,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install Python Dependencies - run: pip install --upgrade pip hatch uv + run: pip install --upgrade hatch uv - name: Run Scripts env: NPM_CONFIG_TOKEN: ${{ secrets.node-auth-token }} diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 9fd513e89..f66d14cb0 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -6,7 +6,7 @@ on: - main pull_request: branches: - - main + - "*" schedule: - cron: "0 0 * * 0" @@ -27,14 +27,20 @@ jobs: job-name: "python-{0} {1}" run-cmd: "hatch test" runs-on: '["ubuntu-latest", "macos-latest", "windows-latest"]' - python-version: '["3.9", "3.10", "3.11"]' + python-version: '["3.10", "3.11", "3.12", "3.13"]' test-documentation: + # Temporarily disabled while we transition from Sphinx to MkDocs + # https://github.com/reactive-python/reactpy/pull/1052 + if: 0 uses: ./.github/workflows/.hatch-run.yml with: job-name: "python-{0}" run-cmd: "hatch run docs:check" python-version: '["3.11"]' test-javascript: + # Temporarily disabled while we rewrite the "event_to_object" package + # https://github.com/reactive-python/reactpy/issues/1196 + if: 0 uses: ./.github/workflows/.hatch-run.yml with: job-name: "{1}" diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml deleted file mode 100644 index 04ecab0df..000000000 --- a/.github/workflows/deploy-docs.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: deploy-docs - -on: - push: - branches: - - "main" - tags: - - "*" - -jobs: - deploy-documentation: - runs-on: ubuntu-latest - steps: - - name: Check out src from Git - uses: actions/checkout@v4 - - name: Get history and tags for SCM versioning to work - run: | - git fetch --prune --unshallow - git fetch --depth=1 origin +refs/tags/*:refs/tags/* - - name: Install Heroku CLI - run: curl https://cli-assets.heroku.com/install.sh | sh - - name: Login to Heroku Container Registry - run: echo ${{ secrets.HEROKU_API_KEY }} | docker login -u ${{ secrets.HEROKU_EMAIL }} --password-stdin registry.heroku.com - - name: Build Docker Image - run: docker build . --file docs/Dockerfile --tag registry.heroku.com/${{ secrets.HEROKU_APP_NAME }}/web - - name: Push Docker Image - run: docker push registry.heroku.com/${{ secrets.HEROKU_APP_NAME }}/web - - name: Deploy - run: HEROKU_API_KEY=${{ secrets.HEROKU_API_KEY }} heroku container:release web --app ${{ secrets.HEROKU_APP_NAME }} diff --git a/.gitignore b/.gitignore index 6cc8e33ca..40cd524ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # --- Build Artifacts --- -src/reactpy/static/**/index.js* +src/reactpy/static/index.js* +src/reactpy/static/morphdom/ +src/reactpy/static/pyscript/ # --- Jupyter --- *.ipynb_checkpoints @@ -15,8 +17,8 @@ src/reactpy/static/**/index.js* # --- Python --- .hatch -.venv -venv +.venv* +venv* MANIFEST build dist diff --git a/docs/Dockerfile b/docs/Dockerfile index 1f8bd0aaf..fad5643c3 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -33,6 +33,6 @@ RUN sphinx-build -v -W -b html source build # Define Entrypoint # ----------------- ENV PORT=5000 -ENV REACTPY_DEBUG_MODE=1 +ENV REACTPY_DEBUG=1 ENV REACTPY_CHECK_VDOM_SPEC=0 CMD ["python", "main.py"] diff --git a/docs/docs_app/app.py b/docs/docs_app/app.py index 3fe4669ff..393b68439 100644 --- a/docs/docs_app/app.py +++ b/docs/docs_app/app.py @@ -6,7 +6,7 @@ from docs_app.examples import get_normalized_example_name, load_examples from reactpy import component from reactpy.backend.sanic import Options, configure, use_request -from reactpy.core.types import ComponentConstructor +from reactpy.types import ComponentConstructor THIS_DIR = Path(__file__).parent DOCS_DIR = THIS_DIR.parent diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 178fbba19..bb4ea5e7f 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -15,6 +15,23 @@ Changelog Unreleased ---------- +**Added** + +- :pull:`1113` - Added ``reactpy.executors.asgi.ReactPy`` that can be used to run ReactPy in standalone mode via ASGI. +- :pull:`1269` - Added ``reactpy.executors.asgi.ReactPyPyodide`` that can be used to run ReactPy in standalone mode via ASGI, but rendered entirely client-sided. +- :pull:`1113` - Added ``reactpy.executors.asgi.ReactPyMiddleware`` that can be used to utilize ReactPy within any ASGI compatible framework. +- :pull:`1269` - Added ``reactpy.templatetags.Jinja`` that can be used alongside ``ReactPyMiddleware`` to embed several ReactPy components into your existing application. This includes the following template tags: ``{% component %}``, ``{% pyscript_component %}``, and ``{% pyscript_setup %}``. +- :pull:`1269` - Added ``reactpy.pyscript_component`` that can be used to embed ReactPy components into your existing application. +- :pull:`1113` - Added ``uvicorn`` and ``jinja`` installation extras (for example ``pip install reactpy[jinja]``). +- :pull:`1113` - Added support for Python 3.12 and 3.13. +- :pull:`1264` - Added ``reactpy.use_async_effect`` hook. +- :pull:`1267` - Added ``shutdown_timeout`` parameter to the ``reactpy.use_async_effect`` hook. +- :pull:`1281` - ``reactpy.html`` will now automatically flatten lists recursively (ex. ``reactpy.html(["child1", ["child2"]])``) +- :pull:`1281` - Added ``reactpy.Vdom`` primitive interface for creating VDOM dictionaries. +- :pull:`1281` - Added type hints to ``reactpy.html`` attributes. +- :pull:`1285` - Added support for nested components in web modules +- :pull:`1289` - Added support for inline JavaScript as event handlers or other attributes that expect a callable via ``reactpy.types.InlineJavaScript`` + **Changed** - :pull:`1251` - Substitute client-side usage of ``react`` with ``preact``. @@ -22,6 +39,16 @@ Unreleased - :pull:`1255` - The ``reactpy.html`` module has been modified to allow for auto-creation of any HTML nodes. For example, you can create a ```` element by calling ``html.data_table()``. - :pull:`1256` - Change ``set_state`` comparison method to check equality with ``==`` more consistently. - :pull:`1257` - Add support for rendering ``@component`` children within ``vdom_to_html``. +- :pull:`1113` - Renamed the ``use_location`` hook's ``search`` attribute to ``query_string``. +- :pull:`1113` - Renamed the ``use_location`` hook's ``pathname`` attribute to ``path``. +- :pull:`1113` - Renamed ``reactpy.config.REACTPY_DEBUG_MODE`` to ``reactpy.config.REACTPY_DEBUG``. +- :pull:`1113` - ``@reactpy/client`` now exports ``React`` and ``ReactDOM``. +- :pull:`1263` - ReactPy no longer auto-converts ``snake_case`` props to ``camelCase``. It is now the responsibility of the user to ensure that props are in the correct format. +- :pull:`1278` - ``reactpy.utils.reactpy_to_string`` will now retain the user's original casing for ``data-*`` and ``aria-*`` attributes. +- :pull:`1278` - ``reactpy.utils.string_to_reactpy`` has been upgraded to handle more complex scenarios without causing ReactJS rendering errors. +- :pull:`1281` - ``reactpy.core.vdom._CustomVdomDictConstructor`` has been moved to ``reactpy.types.CustomVdomConstructor``. +- :pull:`1281` - ``reactpy.core.vdom._EllipsisRepr`` has been moved to ``reactpy.types.EllipsisRepr``. +- :pull:`1281` - ``reactpy.types.VdomDictConstructor`` has been renamed to ``reactpy.types.VdomConstructor``. **Removed** @@ -29,10 +56,24 @@ Unreleased - :pull:`1255` - Removed ``reactpy.sample`` module. - :pull:`1255` - Removed ``reactpy.svg`` module. Contents previously within ``reactpy.svg.*`` can now be accessed via ``html.svg.*``. - :pull:`1255` - Removed ``reactpy.html._`` function. Use ``html.fragment`` instead. +- :pull:`1113` - Removed ``reactpy.run``. See the documentation for the new method to run ReactPy applications. +- :pull:`1113` - Removed ``reactpy.backend.*``. See the documentation for the new method to run ReactPy applications. +- :pull:`1113` - Removed ``reactpy.core.types`` module. Use ``reactpy.types`` instead. +- :pull:`1278` - Removed ``reactpy.utils.html_to_vdom``. Use ``reactpy.utils.string_to_reactpy`` instead. +- :pull:`1278` - Removed ``reactpy.utils.vdom_to_html``. Use ``reactpy.utils.reactpy_to_string`` instead. +- :pull:`1113` - All backend related installation extras (such as ``pip install reactpy[starlette]``) have been removed. +- :pull:`1113` - Removed deprecated function ``module_from_template``. +- :pull:`1113` - Removed support for Python 3.9. +- :pull:`1264` - Removed support for async functions within ``reactpy.use_effect`` hook. Use ``reactpy.use_async_effect`` instead. +- :pull:`1281` - Removed ``reactpy.vdom``. Use ``reactpy.Vdom`` instead. +- :pull:`1281` - Removed ``reactpy.core.make_vdom_constructor``. Use ``reactpy.Vdom`` instead. +- :pull:`1281` - Removed ``reactpy.core.custom_vdom_constructor``. Use ``reactpy.Vdom`` instead. **Fixed** - :pull:`1239` - Fixed a bug where script elements would not render to the DOM as plain text. +- :pull:`1271` - Fixed a bug where the ``key`` property provided via server-side ReactPy code was failing to propagate to the front-end JavaScript component. +- :pull:`1254` - Fixed a bug where ``RuntimeError("Hook stack is in an invalid state")`` errors would be provided when using a webserver that reuses threads. v1.1.0 ------ diff --git a/docs/source/guides/escape-hatches/distributing-javascript.rst b/docs/source/guides/escape-hatches/distributing-javascript.rst index 9eb478965..5333742ce 100644 --- a/docs/source/guides/escape-hatches/distributing-javascript.rst +++ b/docs/source/guides/escape-hatches/distributing-javascript.rst @@ -188,7 +188,7 @@ loaded with :func:`~reactpy.web.module.export`. .. note:: - When :data:`reactpy.config.REACTPY_DEBUG_MODE` is active, named exports will be validated. + When :data:`reactpy.config.REACTPY_DEBUG` is active, named exports will be validated. The remaining files that we need to create are concerned with creating a Python package. We won't cover all the details here, so refer to the Setuptools_ documentation for diff --git a/docs/source/guides/getting-started/running-reactpy.rst b/docs/source/guides/getting-started/running-reactpy.rst index 8abbd574f..90a03cbc3 100644 --- a/docs/source/guides/getting-started/running-reactpy.rst +++ b/docs/source/guides/getting-started/running-reactpy.rst @@ -103,7 +103,7 @@ Running ReactPy in Debug Mode ----------------------------- ReactPy provides a debug mode that is turned off by default. This can be enabled when you -run your application by setting the ``REACTPY_DEBUG_MODE`` environment variable. +run your application by setting the ``REACTPY_DEBUG`` environment variable. .. tab-set:: @@ -111,21 +111,21 @@ run your application by setting the ``REACTPY_DEBUG_MODE`` environment variable. .. code-block:: - export REACTPY_DEBUG_MODE=1 + export REACTPY_DEBUG=1 python my_reactpy_app.py .. tab-item:: Command Prompt .. code-block:: text - set REACTPY_DEBUG_MODE=1 + set REACTPY_DEBUG=1 python my_reactpy_app.py .. tab-item:: PowerShell .. code-block:: powershell - $env:REACTPY_DEBUG_MODE = "1" + $env:REACTPY_DEBUG = "1" python my_reactpy_app.py .. danger:: diff --git a/docs/source/reference/_examples/simple_dashboard.py b/docs/source/reference/_examples/simple_dashboard.py index 66913fc84..58a3e6fff 100644 --- a/docs/source/reference/_examples/simple_dashboard.py +++ b/docs/source/reference/_examples/simple_dashboard.py @@ -49,7 +49,7 @@ def RandomWalkGraph(mu, sigma): interval = use_interval(0.5) data, set_data = reactpy.hooks.use_state([{"x": 0, "y": 0}] * 50) - @reactpy.hooks.use_effect + @reactpy.hooks.use_async_effect async def animate(): await interval last_data_point = data[-1] diff --git a/docs/source/reference/_examples/snake_game.py b/docs/source/reference/_examples/snake_game.py index 36916410e..bb4bbb541 100644 --- a/docs/source/reference/_examples/snake_game.py +++ b/docs/source/reference/_examples/snake_game.py @@ -90,7 +90,7 @@ def on_direction_change(event): interval = use_interval(0.5) - @reactpy.hooks.use_effect + @reactpy.hooks.use_async_effect async def animate(): if new_game_state is not None: await asyncio.sleep(1) diff --git a/pyproject.toml b/pyproject.toml index 8c348f1e9..173fdb173 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,36 +13,39 @@ readme = "README.md" keywords = ["react", "javascript", "reactpy", "component"] license = "MIT" authors = [ - { name = "Ryan Morshead", email = "ryan.morshead@gmail.com" }, { name = "Mark Bakhit", email = "archiethemonger@gmail.com" }, + { name = "Ryan Morshead", email = "ryan.morshead@gmail.com" }, ] requires-python = ">=3.9" classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Programming Language :: Python", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] dependencies = [ - "exceptiongroup >=1.0", - "typing-extensions >=3.10", - "mypy-extensions >=0.4.3", - "anyio >=3", - "jsonpatch >=1.32", "fastjsonschema >=2.14.5", "requests >=2", - "colorlog >=6", - "asgiref >=3", "lxml >=4", + "anyio >=3", + "typing-extensions >=3.10", ] dynamic = ["version"] urls.Changelog = "https://reactpy.dev/docs/about/changelog.html" urls.Documentation = "https://reactpy.dev/" urls.Source = "https://github.com/reactive-python/reactpy" +[project.optional-dependencies] +all = ["reactpy[asgi,jinja,uvicorn,testing]"] +asgi = ["asgiref", "asgi-tools", "servestatic", "orjson", "pip"] +jinja = ["jinja2-simple-tags", "jinja2 >=3"] +uvicorn = ["uvicorn[standard]"] +testing = ["playwright"] + [tool.hatch.version] path = "src/reactpy/__init__.py" @@ -59,6 +62,9 @@ license-files = { paths = ["LICENSE"] } [tool.hatch.envs.default] installer = "uv" +[project.scripts] +reactpy = "reactpy._console.cli:entry_point" + [[tool.hatch.build.hooks.build-scripts.scripts]] # Note: `hatch` can't be called within `build-scripts` when installing packages in editable mode, so we have to write the commands long-form commands = [ @@ -69,79 +75,47 @@ commands = [ 'bun run --cwd "src/js/packages/@reactpy/client" build', 'bun install --cwd "src/js/packages/@reactpy/app"', 'bun run --cwd "src/js/packages/@reactpy/app" build', - 'python "src/build_scripts/copy_dir.py" "src/js/packages/@reactpy/app/dist" "src/reactpy/static/assets"', + 'python "src/build_scripts/copy_dir.py" "src/js/packages/@reactpy/app/node_modules/@pyscript/core/dist" "src/reactpy/static/pyscript"', + 'python "src/build_scripts/copy_dir.py" "src/js/packages/@reactpy/app/node_modules/morphdom/dist" "src/reactpy/static/morphdom"', ] artifacts = [] -[project.optional-dependencies] -# TODO: Nuke backends from the optional deps -all = ["reactpy[starlette,sanic,fastapi,flask,tornado,testing]"] -starlette = ["starlette >=0.13.6", "uvicorn[standard] >=0.19.0"] -sanic = [ - "sanic>=21", - "sanic-cors", - "tracerite>=1.1.1", - "setuptools", - "uvicorn[standard]>=0.19.0", -] -fastapi = ["fastapi >=0.63.0", "uvicorn[standard] >=0.19.0"] -flask = ["flask", "markupsafe>=1.1.1,<2.1", "flask-cors", "flask-sock"] -tornado = ["tornado"] -testing = ["playwright"] - - ############################# # >>> Hatch Test Runner <<< # ############################# [tool.hatch.envs.hatch-test] extra-dependencies = [ + "reactpy[all]", "pytest-sugar", - "pytest-asyncio>=0.23", - "pytest-timeout", - "coverage[toml]>=6.5", + "pytest-asyncio", "responses", - "playwright", + "exceptiongroup", "jsonpointer", - # TODO: Nuke everything past this point after removing backends from deps - "starlette >=0.13.6", - "uvicorn[standard] >=0.19.0", - "sanic>=21", - "sanic-cors", - "sanic-testing", - "tracerite>=1.1.1", - "setuptools", - "uvicorn[standard]>=0.19.0", - "fastapi >=0.63.0", - "uvicorn[standard] >=0.19.0", - "flask", - "markupsafe>=1.1.1,<2.1", - "flask-cors", - "flask-sock", - "tornado", + "starlette", ] [[tool.hatch.envs.hatch-test.matrix]] -python = ["3.9", "3.10", "3.11", "3.12"] +python = ["3.10", "3.11", "3.12", "3.13"] [tool.pytest.ini_options] addopts = """\ - --strict-config - --strict-markers - """ + --strict-config + --strict-markers +""" +filterwarnings = """ + ignore::DeprecationWarning:uvicorn.* + ignore::DeprecationWarning:websockets.* + ignore::UserWarning:tests.test_core.test_vdom + ignore::UserWarning:tests.test_pyscript.test_components + ignore::UserWarning:tests.test_utils +""" testpaths = "tests" xfail_strict = true asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" log_cli_level = "INFO" -[tool.hatch.envs.default.scripts] -test-cov = "playwright install && coverage run -m pytest {args:tests}" -cov-report = ["coverage report"] -cov = ["test-cov {args}", "cov-report"] - -[tool.hatch.envs.default.env-vars] -REACTPY_DEBUG_MODE = "1" - ####################################### # >>> Hatch Documentation Scripts <<< # ####################################### @@ -179,19 +153,17 @@ serve = [ [tool.hatch.envs.python] extra-dependencies = [ - "ruff", - "toml", - "mypy==1.8", + "reactpy[all]", + "pyright", "types-toml", "types-click", - "types-tornado", - "types-flask", "types-requests", + "types-lxml", + "jsonpointer", ] [tool.hatch.envs.python.scripts] -# TODO: Replace mypy with pyright -type_check = ["mypy --strict src/reactpy"] +type_check = ["pyright src/reactpy"] ############################ # >>> Hatch JS Scripts <<< # @@ -216,6 +188,7 @@ test = [ ] build = [ 'hatch run "src/build_scripts/clean_js_dir.py"', + 'bun install --cwd "src/js"', 'hatch run javascript:build_event_to_object', 'hatch run javascript:build_client', 'hatch run javascript:build_app', @@ -245,21 +218,23 @@ publish_client = [ # >>> Generic Tools <<< # ######################### -[tool.mypy] -incremental = false -ignore_missing_imports = true -warn_unused_configs = true -warn_redundant_casts = true -warn_unused_ignores = true +[tool.pyright] +reportIncompatibleVariableOverride = false [tool.coverage.run] source_pkgs = ["reactpy"] branch = false parallel = false -omit = ["reactpy/__init__.py"] +omit = [ + "src/reactpy/__init__.py", + "src/reactpy/_console/*", + "src/reactpy/__main__.py", + "src/reactpy/pyscript/layout_handler.py", + "src/reactpy/pyscript/component_template.py", +] [tool.coverage.report] -fail_under = 98 +fail_under = 100 show_missing = true skip_covered = true sort = "Name" @@ -269,10 +244,8 @@ exclude_also = [ "if __name__ == .__main__.:", "if TYPE_CHECKING:", ] -omit = ["**/reactpy/__main__.py"] [tool.ruff] -target-version = "py39" line-length = 88 lint.select = [ "A", @@ -343,13 +316,7 @@ lint.unfixable = [ [tool.ruff.lint.isort] known-first-party = ["reactpy"] - -[tool.ruff.lint.flake8-tidy-imports] -ban-relative-imports = "all" - -[tool.flake8] -select = ["RPY"] # only need to check with reactpy-flake8 -exclude = ["**/node_modules/*", ".eggs/*", ".tox/*", "**/venv/*"] +known-third-party = ["js"] [tool.ruff.lint.per-file-ignores] # Tests can use magic values, assertions, and relative imports @@ -366,7 +333,3 @@ exclude = ["**/node_modules/*", ".eggs/*", ".tox/*", "**/venv/*"] # Allow print "T201", ] - -[tool.black] -target-version = ["py39"] -line-length = 88 diff --git a/src/js/packages/@reactpy/app/bun.lockb b/src/js/packages/@reactpy/app/bun.lockb index b32e03eae..bd09c30d6 100644 Binary files a/src/js/packages/@reactpy/app/bun.lockb and b/src/js/packages/@reactpy/app/bun.lockb differ diff --git a/src/js/packages/@reactpy/app/package.json b/src/js/packages/@reactpy/app/package.json index 21e3bcd96..ca3a4370d 100644 --- a/src/js/packages/@reactpy/app/package.json +++ b/src/js/packages/@reactpy/app/package.json @@ -8,10 +8,12 @@ "preact": "^10.25.4" }, "devDependencies": { - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "@pyscript/core": "^0.6", + "morphdom": "^2" }, "scripts": { - "build": "bun build \"src/index.ts\" --outdir \"dist\" --minify --sourcemap=linked", + "build": "bun build \"src/index.ts\" --outdir=\"../../../../reactpy/static/\" --minify --sourcemap=\"linked\"", "checkTypes": "tsc --noEmit" } } diff --git a/src/js/packages/@reactpy/app/src/index.ts b/src/js/packages/@reactpy/app/src/index.ts index 9a86fe811..55ebf2c10 100644 --- a/src/js/packages/@reactpy/app/src/index.ts +++ b/src/js/packages/@reactpy/app/src/index.ts @@ -1,19 +1 @@ -import { mount, SimpleReactPyClient } from "@reactpy/client"; - -function app(element: HTMLElement) { - const client = new SimpleReactPyClient({ - serverLocation: { - url: document.location.origin, - route: document.location.pathname, - query: document.location.search, - }, - }); - mount(element, client); -} - -const element = document.getElementById("app"); -if (element) { - app(element); -} else { - console.error("Element with id 'app' not found"); -} +export { mountReactPy } from "@reactpy/client"; diff --git a/src/js/packages/@reactpy/client/package.json b/src/js/packages/@reactpy/client/package.json index b6b12830f..95e545eb4 100644 --- a/src/js/packages/@reactpy/client/package.json +++ b/src/js/packages/@reactpy/client/package.json @@ -1,6 +1,6 @@ { "name": "@reactpy/client", - "version": "0.3.2", + "version": "1.0.0", "description": "A client for ReactPy implemented in React", "author": "Ryan Morshead", "license": "MIT", diff --git a/src/js/packages/@reactpy/client/src/client.ts b/src/js/packages/@reactpy/client/src/client.ts new file mode 100644 index 000000000..ea4e1aed5 --- /dev/null +++ b/src/js/packages/@reactpy/client/src/client.ts @@ -0,0 +1,83 @@ +import logger from "./logger"; +import { + ReactPyClientInterface, + ReactPyModule, + GenericReactPyClientProps, + ReactPyUrls, +} from "./types"; +import { createReconnectingWebSocket } from "./websocket"; + +export abstract class BaseReactPyClient implements ReactPyClientInterface { + private readonly handlers: { [key: string]: ((message: any) => void)[] } = {}; + protected readonly ready: Promise; + private resolveReady: (value: undefined) => void; + + constructor() { + this.resolveReady = () => {}; + this.ready = new Promise((resolve) => (this.resolveReady = resolve)); + } + + onMessage(type: string, handler: (message: any) => void): () => void { + (this.handlers[type] || (this.handlers[type] = [])).push(handler); + this.resolveReady(undefined); + return () => { + this.handlers[type] = this.handlers[type].filter((h) => h !== handler); + }; + } + + abstract sendMessage(message: any): void; + abstract loadModule(moduleName: string): Promise; + + /** + * Handle an incoming message. + * + * This should be called by subclasses when a message is received. + * + * @param message The message to handle. The message must have a `type` property. + */ + protected handleIncoming(message: any): void { + if (!message.type) { + logger.warn("Received message without type", message); + return; + } + + const messageHandlers: ((m: any) => void)[] | undefined = + this.handlers[message.type]; + if (!messageHandlers) { + logger.warn("Received message without handler", message); + return; + } + + messageHandlers.forEach((h) => h(message)); + } +} + +export class ReactPyClient + extends BaseReactPyClient + implements ReactPyClientInterface +{ + urls: ReactPyUrls; + socket: { current?: WebSocket }; + mountElement: HTMLElement; + + constructor(props: GenericReactPyClientProps) { + super(); + + this.urls = props.urls; + this.mountElement = props.mountElement; + this.socket = createReconnectingWebSocket({ + url: this.urls.componentUrl, + readyPromise: this.ready, + ...props.reconnectOptions, + onMessage: (event) => this.handleIncoming(JSON.parse(event.data)), + }); + } + + sendMessage(message: any): void { + this.socket.current?.send(JSON.stringify(message)); + } + + loadModule(moduleName: string): Promise { + return import(`${this.urls.jsModulesPath}${moduleName}`); + } +} diff --git a/src/js/packages/@reactpy/client/src/components.tsx b/src/js/packages/@reactpy/client/src/components.tsx index efaa7a759..42f303198 100644 --- a/src/js/packages/@reactpy/client/src/components.tsx +++ b/src/js/packages/@reactpy/client/src/components.tsx @@ -1,29 +1,26 @@ +import { set as setJsonPointer } from "json-pointer"; import React, { - createElement, + ChangeEvent, createContext, - useState, - useRef, - useContext, - useEffect, + createElement, Fragment, MutableRefObject, - ChangeEvent, + useContext, + useEffect, + useRef, + useState, } from "preact/compat"; -// @ts-ignore -import { set as setJsonPointer } from "json-pointer"; import { - ReactPyVdom, - ReactPyComponent, - createChildren, - createAttributes, - loadImportSource, ImportSourceBinding, -} from "./reactpy-vdom"; -import { ReactPyClient } from "./reactpy-client"; + ReactPyComponent, + ReactPyVdom, + ReactPyClientInterface, +} from "./types"; +import { createAttributes, createChildren, loadImportSource } from "./vdom"; -const ClientContext = createContext(null as any); +const ClientContext = createContext(null as any); -export function Layout(props: { client: ReactPyClient }): JSX.Element { +export function Layout(props: { client: ReactPyClientInterface }): JSX.Element { const currentModel: ReactPyVdom = useState({ tagName: "" })[0]; const forceUpdate = useForceUpdate(); diff --git a/src/js/packages/@reactpy/client/src/index.ts b/src/js/packages/@reactpy/client/src/index.ts index 548fcbfc7..15192823d 100644 --- a/src/js/packages/@reactpy/client/src/index.ts +++ b/src/js/packages/@reactpy/client/src/index.ts @@ -1,5 +1,8 @@ +export * from "./client"; export * from "./components"; -export * from "./messages"; export * from "./mount"; -export * from "./reactpy-client"; -export * from "./reactpy-vdom"; +export * from "./types"; +export * from "./vdom"; +export * from "./websocket"; +export { default as React } from "preact/compat"; +export { default as ReactDOM } from "preact/compat"; diff --git a/src/js/packages/@reactpy/client/src/logger.ts b/src/js/packages/@reactpy/client/src/logger.ts index 4c4cdd264..436e74be1 100644 --- a/src/js/packages/@reactpy/client/src/logger.ts +++ b/src/js/packages/@reactpy/client/src/logger.ts @@ -1,5 +1,6 @@ export default { log: (...args: any[]): void => console.log("[ReactPy]", ...args), + info: (...args: any[]): void => console.info("[ReactPy]", ...args), warn: (...args: any[]): void => console.warn("[ReactPy]", ...args), error: (...args: any[]): void => console.error("[ReactPy]", ...args), }; diff --git a/src/js/packages/@reactpy/client/src/messages.ts b/src/js/packages/@reactpy/client/src/messages.ts deleted file mode 100644 index 34001dcb0..000000000 --- a/src/js/packages/@reactpy/client/src/messages.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ReactPyVdom } from "./reactpy-vdom"; - -export type LayoutUpdateMessage = { - type: "layout-update"; - path: string; - model: ReactPyVdom; -}; - -export type LayoutEventMessage = { - type: "layout-event"; - target: string; - data: any; -}; - -export type IncomingMessage = LayoutUpdateMessage; -export type OutgoingMessage = LayoutEventMessage; -export type Message = IncomingMessage | OutgoingMessage; diff --git a/src/js/packages/@reactpy/client/src/mount.tsx b/src/js/packages/@reactpy/client/src/mount.tsx index 059dcec1a..55ba4245e 100644 --- a/src/js/packages/@reactpy/client/src/mount.tsx +++ b/src/js/packages/@reactpy/client/src/mount.tsx @@ -1,8 +1,41 @@ -import React from "preact/compat"; -import { render } from "preact/compat"; +import { default as React, default as ReactDOM } from "preact/compat"; +import { ReactPyClient } from "./client"; import { Layout } from "./components"; -import { ReactPyClient } from "./reactpy-client"; +import { MountProps } from "./types"; -export function mount(element: HTMLElement, client: ReactPyClient): void { - render(, element); +export function mountReactPy(props: MountProps) { + // WebSocket route for component rendering + const wsProtocol = `ws${window.location.protocol === "https:" ? "s" : ""}:`; + const wsOrigin = `${wsProtocol}//${window.location.host}`; + const componentUrl = new URL( + `${wsOrigin}${props.pathPrefix}${props.componentPath || ""}`, + ); + + // Embed the initial HTTP path into the WebSocket URL + componentUrl.searchParams.append("http_pathname", window.location.pathname); + if (window.location.search) { + componentUrl.searchParams.append( + "http_query_string", + window.location.search, + ); + } + + // Configure a new ReactPy client + const client = new ReactPyClient({ + urls: { + componentUrl: componentUrl, + jsModulesPath: `${window.location.origin}${props.pathPrefix}modules/`, + }, + reconnectOptions: { + interval: props.reconnectInterval || 750, + maxInterval: props.reconnectMaxInterval || 60000, + maxRetries: props.reconnectMaxRetries || 150, + backoffMultiplier: props.reconnectBackoffMultiplier || 1.25, + }, + mountElement: props.mountElement, + }); + + // Start rendering the component + // eslint-disable-next-line react/no-deprecated + ReactDOM.render(, props.mountElement); } diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts deleted file mode 100644 index 6f37b55a1..000000000 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { ReactPyModule } from "./reactpy-vdom"; -import logger from "./logger"; - -/** - * A client for communicating with a ReactPy server. - */ -export interface ReactPyClient { - /** - * Register a handler for a message type. - * - * The first time this is called, the client will be considered ready. - * - * @param type The type of message to handle. - * @param handler The handler to call when a message of the given type is received. - * @returns A function to unregister the handler. - */ - onMessage(type: string, handler: (message: any) => void): () => void; - - /** - * Send a message to the server. - * - * @param message The message to send. Messages must have a `type` property. - */ - sendMessage(message: any): void; - - /** - * Load a module from the server. - * @param moduleName The name of the module to load. - * @returns A promise that resolves to the module. - */ - loadModule(moduleName: string): Promise; -} - -export abstract class BaseReactPyClient implements ReactPyClient { - private readonly handlers: { [key: string]: ((message: any) => void)[] } = {}; - protected readonly ready: Promise; - private resolveReady: (value: undefined) => void; - - constructor() { - this.resolveReady = () => {}; - this.ready = new Promise((resolve) => (this.resolveReady = resolve)); - } - - onMessage(type: string, handler: (message: any) => void): () => void { - (this.handlers[type] || (this.handlers[type] = [])).push(handler); - this.resolveReady(undefined); - return () => { - this.handlers[type] = this.handlers[type].filter((h) => h !== handler); - }; - } - - abstract sendMessage(message: any): void; - abstract loadModule(moduleName: string): Promise; - - /** - * Handle an incoming message. - * - * This should be called by subclasses when a message is received. - * - * @param message The message to handle. The message must have a `type` property. - */ - protected handleIncoming(message: any): void { - if (!message.type) { - logger.warn("Received message without type", message); - return; - } - - const messageHandlers: ((m: any) => void)[] | undefined = - this.handlers[message.type]; - if (!messageHandlers) { - logger.warn("Received message without handler", message); - return; - } - - messageHandlers.forEach((h) => h(message)); - } -} - -export type SimpleReactPyClientProps = { - serverLocation?: LocationProps; - reconnectOptions?: ReconnectProps; -}; - -/** - * The location of the server. - * - * This is used to determine the location of the server's API endpoints. All endpoints - * are expected to be found at the base URL, with the following paths: - * - * - `_reactpy/stream/${route}${query}`: The websocket endpoint for the stream. - * - `_reactpy/modules`: The directory containing the dynamically loaded modules. - * - `_reactpy/assets`: The directory containing the static assets. - */ -type LocationProps = { - /** - * The base URL of the server. - * - * @default - document.location.origin - */ - url: string; - /** - * The route to the page being rendered. - * - * @default - document.location.pathname - */ - route: string; - /** - * The query string of the page being rendered. - * - * @default - document.location.search - */ - query: string; -}; - -type ReconnectProps = { - maxInterval?: number; - maxRetries?: number; - backoffRate?: number; - intervalJitter?: number; -}; - -export class SimpleReactPyClient - extends BaseReactPyClient - implements ReactPyClient -{ - private readonly urls: ServerUrls; - private readonly socket: { current?: WebSocket }; - - constructor(props: SimpleReactPyClientProps) { - super(); - - this.urls = getServerUrls( - props.serverLocation || { - url: document.location.origin, - route: document.location.pathname, - query: document.location.search, - }, - ); - - this.socket = createReconnectingWebSocket({ - readyPromise: this.ready, - url: this.urls.stream, - onMessage: async ({ data }) => this.handleIncoming(JSON.parse(data)), - ...props.reconnectOptions, - }); - } - - sendMessage(message: any): void { - this.socket.current?.send(JSON.stringify(message)); - } - - loadModule(moduleName: string): Promise { - return import(`${this.urls.modules}/${moduleName}`); - } -} - -type ServerUrls = { - base: URL; - stream: string; - modules: string; - assets: string; -}; - -function getServerUrls(props: LocationProps): ServerUrls { - const base = new URL(`${props.url || document.location.origin}/_reactpy`); - const modules = `${base}/modules`; - const assets = `${base}/assets`; - - const streamProtocol = `ws${base.protocol === "https:" ? "s" : ""}`; - const streamPath = rtrim(`${base.pathname}/stream${props.route || ""}`, "/"); - const stream = `${streamProtocol}://${base.host}${streamPath}${props.query}`; - - return { base, modules, assets, stream }; -} - -function createReconnectingWebSocket( - props: { - url: string; - readyPromise: Promise; - onOpen?: () => void; - onMessage: (message: MessageEvent) => void; - onClose?: () => void; - } & ReconnectProps, -) { - const { - maxInterval = 60000, - maxRetries = 50, - backoffRate = 1.1, - intervalJitter = 0.1, - } = props; - - const startInterval = 750; - let retries = 0; - let interval = startInterval; - const closed = false; - let everConnected = false; - const socket: { current?: WebSocket } = {}; - - const connect = () => { - if (closed) { - return; - } - socket.current = new WebSocket(props.url); - socket.current.onopen = () => { - everConnected = true; - logger.log("client connected"); - interval = startInterval; - retries = 0; - if (props.onOpen) { - props.onOpen(); - } - }; - socket.current.onmessage = props.onMessage; - socket.current.onclose = () => { - if (!everConnected) { - logger.log("failed to connect"); - return; - } - - logger.log("client disconnected"); - if (props.onClose) { - props.onClose(); - } - - if (retries >= maxRetries) { - return; - } - - const thisInterval = addJitter(interval, intervalJitter); - logger.log( - `reconnecting in ${(thisInterval / 1000).toPrecision(4)} seconds...`, - ); - setTimeout(connect, thisInterval); - interval = nextInterval(interval, backoffRate, maxInterval); - retries++; - }; - }; - - props.readyPromise.then(() => logger.log("starting client...")).then(connect); - - return socket; -} - -function nextInterval( - currentInterval: number, - backoffRate: number, - maxInterval: number, -): number { - return Math.min( - currentInterval * - // increase interval by backoff rate - backoffRate, - // don't exceed max interval - maxInterval, - ); -} - -function addJitter(interval: number, jitter: number): number { - return interval + (Math.random() * jitter * interval * 2 - jitter * interval); -} - -function rtrim(text: string, trim: string): string { - return text.replace(new RegExp(`${trim}+$`), ""); -} diff --git a/src/js/packages/@reactpy/client/src/reactpy-vdom.tsx b/src/js/packages/@reactpy/client/src/reactpy-vdom.tsx deleted file mode 100644 index 22fa3e61d..000000000 --- a/src/js/packages/@reactpy/client/src/reactpy-vdom.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import React, { ComponentType } from "react"; -import { ReactPyClient } from "./reactpy-client"; -import serializeEvent from "event-to-object"; - -export async function loadImportSource( - vdomImportSource: ReactPyVdomImportSource, - client: ReactPyClient, -): Promise { - let module: ReactPyModule; - if (vdomImportSource.sourceType === "URL") { - module = await import(vdomImportSource.source); - } else { - module = await client.loadModule(vdomImportSource.source); - } - if (typeof module.bind !== "function") { - throw new Error( - `${vdomImportSource.source} did not export a function 'bind'`, - ); - } - - return (node: HTMLElement) => { - const binding = module.bind(node, { - sendMessage: client.sendMessage, - onMessage: client.onMessage, - }); - if ( - !( - typeof binding.create === "function" && - typeof binding.render === "function" && - typeof binding.unmount === "function" - ) - ) { - console.error(`${vdomImportSource.source} returned an impropper binding`); - return null; - } - - return { - render: (model) => - binding.render( - createImportSourceElement({ - client, - module, - binding, - model, - currentImportSource: vdomImportSource, - }), - ), - unmount: binding.unmount, - }; - }; -} - -function createImportSourceElement(props: { - client: ReactPyClient; - module: ReactPyModule; - binding: ReactPyModuleBinding; - model: ReactPyVdom; - currentImportSource: ReactPyVdomImportSource; -}): any { - let type: any; - if (props.model.importSource) { - if ( - !isImportSourceEqual(props.currentImportSource, props.model.importSource) - ) { - console.error( - "Parent element import source " + - stringifyImportSource(props.currentImportSource) + - " does not match child's import source " + - stringifyImportSource(props.model.importSource), - ); - return null; - } else if (!props.module[props.model.tagName]) { - console.error( - "Module from source " + - stringifyImportSource(props.currentImportSource) + - ` does not export ${props.model.tagName}`, - ); - return null; - } else { - type = props.module[props.model.tagName]; - } - } else { - type = props.model.tagName; - } - return props.binding.create( - type, - createAttributes(props.model, props.client), - createChildren(props.model, (child) => - createImportSourceElement({ - ...props, - model: child, - }), - ), - ); -} - -function isImportSourceEqual( - source1: ReactPyVdomImportSource, - source2: ReactPyVdomImportSource, -) { - return ( - source1.source === source2.source && - source1.sourceType === source2.sourceType - ); -} - -function stringifyImportSource(importSource: ReactPyVdomImportSource) { - return JSON.stringify({ - source: importSource.source, - sourceType: importSource.sourceType, - }); -} - -export function createChildren( - model: ReactPyVdom, - createChild: (child: ReactPyVdom) => Child, -): (Child | string)[] { - if (!model.children) { - return []; - } else { - return model.children.map((child) => { - switch (typeof child) { - case "object": - return createChild(child); - case "string": - return child; - } - }); - } -} - -export function createAttributes( - model: ReactPyVdom, - client: ReactPyClient, -): { [key: string]: any } { - return Object.fromEntries( - Object.entries({ - // Normal HTML attributes - ...model.attributes, - // Construct event handlers - ...Object.fromEntries( - Object.entries(model.eventHandlers || {}).map(([name, handler]) => - createEventHandler(client, name, handler), - ), - ), - // Convert snake_case to camelCase names - }).map(normalizeAttribute), - ); -} - -function createEventHandler( - client: ReactPyClient, - name: string, - { target, preventDefault, stopPropagation }: ReactPyVdomEventHandler, -): [string, () => void] { - return [ - name, - function (...args: any[]) { - const data = Array.from(args).map((value) => { - if (!(typeof value === "object" && value.nativeEvent)) { - return value; - } - const event = value as React.SyntheticEvent; - if (preventDefault) { - event.preventDefault(); - } - if (stopPropagation) { - event.stopPropagation(); - } - return serializeEvent(event.nativeEvent); - }); - client.sendMessage({ type: "layout-event", data, target }); - }, - ]; -} - -function normalizeAttribute([key, value]: [string, any]): [string, any] { - let normKey = key; - let normValue = value; - - if (key === "style" && typeof value === "object") { - normValue = Object.fromEntries( - Object.entries(value).map(([k, v]) => [snakeToCamel(k), v]), - ); - } else if ( - key.startsWith("data_") || - key.startsWith("aria_") || - DASHED_HTML_ATTRS.includes(key) - ) { - normKey = key.split("_").join("-"); - } else { - normKey = snakeToCamel(key); - } - return [normKey, normValue]; -} - -function snakeToCamel(str: string): string { - return str.replace(/([_][a-z])/g, (group) => - group.toUpperCase().replace("_", ""), - ); -} - -// see list of HTML attributes with dashes in them: -// https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#attribute_list -const DASHED_HTML_ATTRS = ["accept_charset", "http_equiv"]; - -export type ReactPyComponent = ComponentType<{ model: ReactPyVdom }>; - -export type ReactPyVdom = { - tagName: string; - key?: string; - attributes?: { [key: string]: string }; - children?: (ReactPyVdom | string)[]; - error?: string; - eventHandlers?: { [key: string]: ReactPyVdomEventHandler }; - importSource?: ReactPyVdomImportSource; -}; - -export type ReactPyVdomEventHandler = { - target: string; - preventDefault?: boolean; - stopPropagation?: boolean; -}; - -export type ReactPyVdomImportSource = { - source: string; - sourceType?: "URL" | "NAME"; - fallback?: string | ReactPyVdom; - unmountBeforeUpdate?: boolean; -}; - -export type ReactPyModule = { - bind: ( - node: HTMLElement, - context: ReactPyModuleBindingContext, - ) => ReactPyModuleBinding; -} & { [key: string]: any }; - -export type ReactPyModuleBindingContext = { - sendMessage: ReactPyClient["sendMessage"]; - onMessage: ReactPyClient["onMessage"]; -}; - -export type ReactPyModuleBinding = { - create: ( - type: any, - props?: any, - children?: (any | string | ReactPyVdom)[], - ) => any; - render: (element: any) => void; - unmount: () => void; -}; - -export type BindImportSource = ( - node: HTMLElement, -) => ImportSourceBinding | null; - -export type ImportSourceBinding = { - render: (model: ReactPyVdom) => void; - unmount: () => void; -}; diff --git a/src/js/packages/@reactpy/client/src/types.ts b/src/js/packages/@reactpy/client/src/types.ts new file mode 100644 index 000000000..148a3486c --- /dev/null +++ b/src/js/packages/@reactpy/client/src/types.ts @@ -0,0 +1,152 @@ +import { ComponentType } from "react"; + +// #### CONNECTION TYPES #### + +export type ReconnectOptions = { + interval: number; + maxInterval: number; + maxRetries: number; + backoffMultiplier: number; +}; + +export type CreateReconnectingWebSocketProps = { + url: URL; + readyPromise: Promise; + onMessage: (message: MessageEvent) => void; + onOpen?: () => void; + onClose?: () => void; + interval: number; + maxInterval: number; + maxRetries: number; + backoffMultiplier: number; +}; + +export type ReactPyUrls = { + componentUrl: URL; + jsModulesPath: string; +}; + +export type GenericReactPyClientProps = { + urls: ReactPyUrls; + reconnectOptions: ReconnectOptions; + mountElement: HTMLElement; +}; + +export type MountProps = { + mountElement: HTMLElement; + pathPrefix: string; + componentPath?: string; + reconnectInterval?: number; + reconnectMaxInterval?: number; + reconnectMaxRetries?: number; + reconnectBackoffMultiplier?: number; +}; + +// #### COMPONENT TYPES #### + +export type ReactPyComponent = ComponentType<{ model: ReactPyVdom }>; + +export type ReactPyVdom = { + tagName: string; + key?: string; + attributes?: { [key: string]: string }; + children?: (ReactPyVdom | string)[]; + error?: string; + eventHandlers?: { [key: string]: ReactPyVdomEventHandler }; + inlineJavaScript?: { [key: string]: string }; + importSource?: ReactPyVdomImportSource; +}; + +export type ReactPyVdomEventHandler = { + target: string; + preventDefault?: boolean; + stopPropagation?: boolean; +}; + +export type ReactPyVdomImportSource = { + source: string; + sourceType?: "URL" | "NAME"; + fallback?: string | ReactPyVdom; + unmountBeforeUpdate?: boolean; +}; + +export type ReactPyModule = { + bind: ( + node: HTMLElement, + context: ReactPyModuleBindingContext, + ) => ReactPyModuleBinding; +} & { [key: string]: any }; + +export type ReactPyModuleBindingContext = { + sendMessage: ReactPyClientInterface["sendMessage"]; + onMessage: ReactPyClientInterface["onMessage"]; +}; + +export type ReactPyModuleBinding = { + create: ( + type: any, + props?: any, + children?: (any | string | ReactPyVdom)[], + ) => any; + render: (element: any) => void; + unmount: () => void; +}; + +export type BindImportSource = ( + node: HTMLElement, +) => ImportSourceBinding | null; + +export type ImportSourceBinding = { + render: (model: ReactPyVdom) => void; + unmount: () => void; +}; + +// #### MESSAGE TYPES #### + +export type LayoutUpdateMessage = { + type: "layout-update"; + path: string; + model: ReactPyVdom; +}; + +export type LayoutEventMessage = { + type: "layout-event"; + target: string; + data: any; +}; + +export type IncomingMessage = LayoutUpdateMessage; +export type OutgoingMessage = LayoutEventMessage; +export type Message = IncomingMessage | OutgoingMessage; + +// #### INTERFACES #### + +/** + * A client for communicating with a ReactPy server. + */ +export interface ReactPyClientInterface { + /** + * Register a handler for a message type. + * + * The first time this is called, the client will be considered ready. + * + * @param type The type of message to handle. + * @param handler The handler to call when a message of the given type is received. + * @returns A function to unregister the handler. + */ + onMessage(type: string, handler: (message: any) => void): () => void; + + /** + * Send a message to the server. + * + * @param message The message to send. Messages must have a `type` property. + */ + sendMessage(message: any): void; + + /** + * Load a module from the server. + * @param moduleName The name of the module to load. + * @returns A promise that resolves to the module. + */ + loadModule(moduleName: string): Promise; +} diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx new file mode 100644 index 000000000..4bd882ff4 --- /dev/null +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -0,0 +1,254 @@ +import React from "react"; +import { ReactPyClientInterface } from "./types"; +import serializeEvent from "event-to-object"; +import { + ReactPyVdom, + ReactPyVdomImportSource, + ReactPyVdomEventHandler, + ReactPyModule, + BindImportSource, + ReactPyModuleBinding, +} from "./types"; +import log from "./logger"; + +export async function loadImportSource( + vdomImportSource: ReactPyVdomImportSource, + client: ReactPyClientInterface, +): Promise { + let module: ReactPyModule; + if (vdomImportSource.sourceType === "URL") { + module = await import(vdomImportSource.source); + } else { + module = await client.loadModule(vdomImportSource.source); + } + if (typeof module.bind !== "function") { + throw new Error( + `${vdomImportSource.source} did not export a function 'bind'`, + ); + } + + return (node: HTMLElement) => { + const binding = module.bind(node, { + sendMessage: client.sendMessage, + onMessage: client.onMessage, + }); + if ( + !( + typeof binding.create === "function" && + typeof binding.render === "function" && + typeof binding.unmount === "function" + ) + ) { + log.error(`${vdomImportSource.source} returned an impropper binding`); + return null; + } + + return { + render: (model) => + binding.render( + createImportSourceElement({ + client, + module, + binding, + model, + currentImportSource: vdomImportSource, + }), + ), + unmount: binding.unmount, + }; + }; +} + +function createImportSourceElement(props: { + client: ReactPyClientInterface; + module: ReactPyModule; + binding: ReactPyModuleBinding; + model: ReactPyVdom; + currentImportSource: ReactPyVdomImportSource; +}): any { + let type: any; + if (props.model.importSource) { + if ( + !isImportSourceEqual(props.currentImportSource, props.model.importSource) + ) { + log.error( + "Parent element import source " + + stringifyImportSource(props.currentImportSource) + + " does not match child's import source " + + stringifyImportSource(props.model.importSource), + ); + return null; + } else { + type = getComponentFromModule( + props.module, + props.model.tagName, + props.model.importSource, + ); + if (!type) { + // Error message logged within getComponentFromModule + return null; + } + } + } else { + type = props.model.tagName; + } + return props.binding.create( + type, + createAttributes(props.model, props.client), + createChildren(props.model, (child) => + createImportSourceElement({ + ...props, + model: child, + }), + ), + ); +} + +function getComponentFromModule( + module: ReactPyModule, + componentName: string, + importSource: ReactPyVdomImportSource, +): any { + /* Gets the component with the provided name from the provided module. + + Built specifically to work on inifinitely deep nested components. + For example, component "My.Nested.Component" is accessed from + ModuleA like so: ModuleA["My"]["Nested"]["Component"]. + */ + const componentParts: string[] = componentName.split("."); + let Component: any = null; + for (let i = 0; i < componentParts.length; i++) { + const iterAttr = componentParts[i]; + Component = i == 0 ? module[iterAttr] : Component[iterAttr]; + if (!Component) { + if (i == 0) { + log.error( + "Module from source " + + stringifyImportSource(importSource) + + ` does not export ${iterAttr}`, + ); + } else { + console.error( + `Component ${componentParts.slice(0, i).join(".")} from source ` + + stringifyImportSource(importSource) + + ` does not have subcomponent ${iterAttr}`, + ); + } + break; + } + } + return Component; +} + +function isImportSourceEqual( + source1: ReactPyVdomImportSource, + source2: ReactPyVdomImportSource, +) { + return ( + source1.source === source2.source && + source1.sourceType === source2.sourceType + ); +} + +function stringifyImportSource(importSource: ReactPyVdomImportSource) { + return JSON.stringify({ + source: importSource.source, + sourceType: importSource.sourceType, + }); +} + +export function createChildren( + model: ReactPyVdom, + createChild: (child: ReactPyVdom) => Child, +): (Child | string)[] { + if (!model.children) { + return []; + } else { + return model.children.map((child) => { + switch (typeof child) { + case "object": + return createChild(child); + case "string": + return child; + } + }); + } +} + +export function createAttributes( + model: ReactPyVdom, + client: ReactPyClientInterface, +): { [key: string]: any } { + return Object.fromEntries( + Object.entries({ + // Normal HTML attributes + ...model.attributes, + // Construct event handlers + ...Object.fromEntries( + Object.entries(model.eventHandlers || {}).map(([name, handler]) => + createEventHandler(client, name, handler), + ), + ), + ...Object.fromEntries( + Object.entries(model.inlineJavaScript || {}).map( + ([name, inlineJavaScript]) => + createInlineJavaScript(name, inlineJavaScript), + ), + ), + }), + ); +} + +function createEventHandler( + client: ReactPyClientInterface, + name: string, + { target, preventDefault, stopPropagation }: ReactPyVdomEventHandler, +): [string, () => void] { + const eventHandler = function (...args: any[]) { + const data = Array.from(args).map((value) => { + if (!(typeof value === "object" && value.nativeEvent)) { + return value; + } + const event = value as React.SyntheticEvent; + if (preventDefault) { + event.preventDefault(); + } + if (stopPropagation) { + event.stopPropagation(); + } + return serializeEvent(event.nativeEvent); + }); + client.sendMessage({ type: "layout-event", data, target }); + }; + eventHandler.isHandler = true; + return [name, eventHandler]; +} + +function createInlineJavaScript( + name: string, + inlineJavaScript: string, +): [string, () => void] { + /* Function that will execute the string-like InlineJavaScript + via eval in the most appropriate way */ + const wrappedExecutable = function (...args: any[]) { + function handleExecution(...args: any[]) { + const evalResult = eval(inlineJavaScript); + if (typeof evalResult == "function") { + return evalResult(...args); + } + } + if (args.length > 0 && args[0] instanceof Event) { + /* If being triggered by an event, set the event's current + target to "this". This ensures that inline + javascript statements such as the following work: + html.button({"onclick": 'this.value = "Clicked!"'}, "Click Me")*/ + return handleExecution.call(args[0].currentTarget, ...args); + } else { + /* If not being triggered by an event, do not set "this" and + just call normally */ + return handleExecution(...args); + } + }; + wrappedExecutable.isHandler = false; + return [name, wrappedExecutable]; +} diff --git a/src/js/packages/@reactpy/client/src/websocket.ts b/src/js/packages/@reactpy/client/src/websocket.ts new file mode 100644 index 000000000..ba3fdc09f --- /dev/null +++ b/src/js/packages/@reactpy/client/src/websocket.ts @@ -0,0 +1,75 @@ +import { CreateReconnectingWebSocketProps } from "./types"; +import log from "./logger"; + +export function createReconnectingWebSocket( + props: CreateReconnectingWebSocketProps, +) { + const { interval, maxInterval, maxRetries, backoffMultiplier } = props; + let retries = 0; + let currentInterval = interval; + let everConnected = false; + const closed = false; + const socket: { current?: WebSocket } = {}; + + const connect = () => { + if (closed) { + return; + } + socket.current = new WebSocket(props.url); + socket.current.onopen = () => { + everConnected = true; + log.info("Connected!"); + currentInterval = interval; + retries = 0; + if (props.onOpen) { + props.onOpen(); + } + }; + socket.current.onmessage = (event) => { + if (props.onMessage) { + props.onMessage(event); + } + }; + socket.current.onclose = () => { + if (props.onClose) { + props.onClose(); + } + if (!everConnected) { + log.info("Failed to connect!"); + return; + } + log.info("Disconnected!"); + if (retries >= maxRetries) { + log.info("Connection max retries exhausted!"); + return; + } + log.info( + `Reconnecting in ${(currentInterval / 1000).toPrecision(4)} seconds...`, + ); + setTimeout(connect, currentInterval); + currentInterval = nextInterval( + currentInterval, + backoffMultiplier, + maxInterval, + ); + retries++; + }; + }; + + props.readyPromise.then(() => log.info("Starting client...")).then(connect); + + return socket; +} + +export function nextInterval( + currentInterval: number, + backoffMultiplier: number, + maxInterval: number, +): number { + return Math.min( + // increase interval by backoff multiplier + currentInterval * backoffMultiplier, + // don't exceed max interval + maxInterval, + ); +} diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py index f22aa5832..00e2cfdeb 100644 --- a/src/reactpy/__init__.py +++ b/src/reactpy/__init__.py @@ -1,11 +1,11 @@ -from reactpy import backend, config, logging, types, web, widgets +from reactpy import config, logging, types, web, widgets from reactpy._html import html -from reactpy.backend.utils import run from reactpy.core import hooks from reactpy.core.component import component from reactpy.core.events import event from reactpy.core.hooks import ( create_context, + use_async_effect, use_callback, use_connection, use_context, @@ -19,26 +19,29 @@ use_state, ) from reactpy.core.layout import Layout -from reactpy.core.vdom import vdom -from reactpy.utils import Ref, html_to_vdom, vdom_to_html +from reactpy.core.vdom import Vdom +from reactpy.pyscript.components import pyscript_component +from reactpy.utils import Ref, reactpy_to_string, string_to_reactpy __author__ = "The Reactive Python Team" -__version__ = "1.1.0" +__version__ = "2.0.0b2" __all__ = [ "Layout", "Ref", - "backend", + "Vdom", "component", "config", "create_context", "event", "hooks", "html", - "html_to_vdom", "logging", - "run", + "pyscript_component", + "reactpy_to_string", + "string_to_reactpy", "types", + "use_async_effect", "use_callback", "use_connection", "use_context", @@ -50,8 +53,6 @@ "use_ref", "use_scope", "use_state", - "vdom", - "vdom_to_html", "web", "widgets", ] diff --git a/src/reactpy/__main__.py b/src/reactpy/__main__.py deleted file mode 100644 index d70ddf684..000000000 --- a/src/reactpy/__main__.py +++ /dev/null @@ -1,19 +0,0 @@ -import click - -import reactpy -from reactpy._console.rewrite_camel_case_props import rewrite_camel_case_props -from reactpy._console.rewrite_keys import rewrite_keys - - -@click.group() -@click.version_option(reactpy.__version__, prog_name=reactpy.__name__) -def app() -> None: - pass - - -app.add_command(rewrite_keys) -app.add_command(rewrite_camel_case_props) - - -if __name__ == "__main__": - app() diff --git a/src/reactpy/_console/ast_utils.py b/src/reactpy/_console/ast_utils.py index 220751119..c0ce6b224 100644 --- a/src/reactpy/_console/ast_utils.py +++ b/src/reactpy/_console/ast_utils.py @@ -1,3 +1,4 @@ +# pyright: reportAttributeAccessIssue=false from __future__ import annotations import ast diff --git a/src/reactpy/_console/cli.py b/src/reactpy/_console/cli.py new file mode 100644 index 000000000..720583002 --- /dev/null +++ b/src/reactpy/_console/cli.py @@ -0,0 +1,19 @@ +"""Entry point for the ReactPy CLI.""" + +import click + +import reactpy +from reactpy._console.rewrite_props import rewrite_props + + +@click.group() +@click.version_option(version=reactpy.__version__, prog_name=reactpy.__name__) +def entry_point() -> None: + pass + + +entry_point.add_command(rewrite_props) + + +if __name__ == "__main__": + entry_point() diff --git a/src/reactpy/_console/rewrite_camel_case_props.py b/src/reactpy/_console/rewrite_props.py similarity index 58% rename from src/reactpy/_console/rewrite_camel_case_props.py rename to src/reactpy/_console/rewrite_props.py index 12c96c4f3..f7ae7c656 100644 --- a/src/reactpy/_console/rewrite_camel_case_props.py +++ b/src/reactpy/_console/rewrite_props.py @@ -1,7 +1,6 @@ from __future__ import annotations import ast -import re from copy import copy from keyword import kwlist from pathlib import Path @@ -15,15 +14,13 @@ rewrite_changed_nodes, ) -CAMEL_CASE_SUB_PATTERN = re.compile(r"(? None: - """Rewrite camelCase props to snake_case""" - +def rewrite_props(paths: list[str]) -> None: + """Rewrite snake_case props to camelCase within .""" for p in map(Path, paths): + # Process each file or recursively process each Python file in directories for f in [p] if p.is_file() else p.rglob("*.py"): result = generate_rewrite(file=f, source=f.read_text(encoding="utf-8")) if result is not None: @@ -31,43 +28,66 @@ def rewrite_camel_case_props(paths: list[str]) -> None: def generate_rewrite(file: Path, source: str) -> str | None: - tree = ast.parse(source) + """Generate the rewritten source code if changes are detected""" + tree = ast.parse(source) # Parse the source code into an AST - changed = find_nodes_to_change(tree) + changed = find_nodes_to_change(tree) # Find nodes that need to be changed if not changed: - return None + return None # Return None if no changes are needed - new = rewrite_changed_nodes(file, source, tree, changed) + new = rewrite_changed_nodes( + file, source, tree, changed + ) # Rewrite the changed nodes return new def find_nodes_to_change(tree: ast.AST) -> list[ChangedNode]: + """Find nodes in the AST that need to be changed""" changed: list[ChangedNode] = [] for el_info in find_element_constructor_usages(tree): + # Check if the props need to be rewritten if _rewrite_props(el_info.props, _construct_prop_item): + # Add the changed node to the list changed.append(ChangedNode(el_info.call, el_info.parents)) return changed def conv_attr_name(name: str) -> str: - new_name = CAMEL_CASE_SUB_PATTERN.sub("_", name).lower() - return f"{new_name}_" if new_name in kwlist else new_name + """Convert snake_case attribute name to camelCase""" + # Return early if the value is a Python keyword + if name in kwlist: + return name + + # Return early if the value is not snake_case + if "_" not in name: + return name + + # Split the string by underscores + components = name.split("_") + + # Capitalize the first letter of each component except the first one + # and join them together + return components[0] + "".join(x.title() for x in components[1:]) def _construct_prop_item(key: str, value: ast.expr) -> tuple[str, ast.expr]: + """Construct a new prop item with the converted key and possibly modified value""" if key == "style" and isinstance(value, (ast.Dict, ast.Call)): + # Create a copy of the value to avoid modifying the original new_value = copy(value) if _rewrite_props( new_value, lambda k, v: ( (k, v) - # avoid infinite recursion + # Avoid infinite recursion if k == "style" else _construct_prop_item(k, v) ), ): + # Update the value if changes were made value = new_value else: + # Convert the key to camelCase key = conv_attr_name(key) return key, value @@ -76,12 +96,15 @@ def _rewrite_props( props_node: ast.Dict | ast.Call, constructor: Callable[[str, ast.expr], tuple[str, ast.expr]], ) -> bool: + """Rewrite the props in the given AST node using the provided constructor""" + did_change = False if isinstance(props_node, ast.Dict): - did_change = False keys: list[ast.expr | None] = [] values: list[ast.expr] = [] + # Iterate over the keys and values in the dictionary for k, v in zip(props_node.keys, props_node.values): if isinstance(k, ast.Constant) and isinstance(k.value, str): + # Construct the new key and value k_value, new_v = constructor(k.value, v) if k_value != k.value or new_v is not v: did_change = True @@ -90,20 +113,22 @@ def _rewrite_props( keys.append(k) values.append(v) if not did_change: - return False + return False # Return False if no changes were made props_node.keys = keys props_node.values = values else: did_change = False keywords: list[ast.keyword] = [] + # Iterate over the keywords in the call for kw in props_node.keywords: if kw.arg is not None: + # Construct the new keyword argument and value kw_arg, kw_value = constructor(kw.arg, kw.value) if kw_arg != kw.arg or kw_value is not kw.value: did_change = True kw = ast.keyword(arg=kw_arg, value=kw_value) keywords.append(kw) if not did_change: - return False + return False # Return False if no changes were made props_node.keywords = keywords return True diff --git a/src/reactpy/_html.py b/src/reactpy/_html.py index e2d4f096a..ffeee7072 100644 --- a/src/reactpy/_html.py +++ b/src/reactpy/_html.py @@ -1,20 +1,18 @@ from __future__ import annotations from collections.abc import Sequence -from typing import TYPE_CHECKING, ClassVar - -from reactpy.core.vdom import custom_vdom_constructor, make_vdom_constructor - -if TYPE_CHECKING: - from reactpy.core.types import ( - EventHandlerDict, - Key, - VdomAttributes, - VdomChild, - VdomChildren, - VdomDict, - VdomDictConstructor, - ) +from typing import ClassVar, overload + +from reactpy.core.vdom import Vdom +from reactpy.types import ( + EventHandlerDict, + Key, + VdomAttributes, + VdomChild, + VdomChildren, + VdomConstructor, + VdomDict, +) __all__ = ["html"] @@ -106,10 +104,11 @@ def _fragment( event_handlers: EventHandlerDict, ) -> VdomDict: """An HTML fragment - this element will not appear in the DOM""" + attributes.pop("key", None) if attributes or event_handlers: msg = "Fragments cannot have attributes besides 'key'" raise TypeError(msg) - model: VdomDict = {"tagName": ""} + model = VdomDict(tagName="") if children: model["children"] = children @@ -143,7 +142,7 @@ def _script( Doing so may allow for malicious code injection (`XSS `__`). """ - model: VdomDict = {"tagName": "script"} + model = VdomDict(tagName="script") if event_handlers: msg = "'script' elements do not support event handlers" @@ -174,20 +173,28 @@ def _script( class SvgConstructor: """Constructor specifically for SVG children.""" - __cache__: ClassVar[dict[str, VdomDictConstructor]] = {} + __cache__: ClassVar[dict[str, VdomConstructor]] = {} + + @overload + def __call__( + self, attributes: VdomAttributes, /, *children: VdomChildren + ) -> VdomDict: ... + + @overload + def __call__(self, *children: VdomChildren) -> VdomDict: ... def __call__( self, *attributes_and_children: VdomAttributes | VdomChildren ) -> VdomDict: return self.svg(*attributes_and_children) - def __getattr__(self, value: str) -> VdomDictConstructor: + def __getattr__(self, value: str) -> VdomConstructor: value = value.rstrip("_").replace("_", "-") if value in self.__cache__: return self.__cache__[value] - self.__cache__[value] = make_vdom_constructor( + self.__cache__[value] = Vdom( value, allow_children=value not in NO_CHILDREN_ALLOWED_SVG ) @@ -196,71 +203,72 @@ def __getattr__(self, value: str) -> VdomDictConstructor: # SVG child elements, written out here for auto-complete purposes # The actual elements are created dynamically in the __getattr__ method. # Elements other than these can still be created. - a: VdomDictConstructor - animate: VdomDictConstructor - animateMotion: VdomDictConstructor - animateTransform: VdomDictConstructor - circle: VdomDictConstructor - clipPath: VdomDictConstructor - defs: VdomDictConstructor - desc: VdomDictConstructor - discard: VdomDictConstructor - ellipse: VdomDictConstructor - feBlend: VdomDictConstructor - feColorMatrix: VdomDictConstructor - feComponentTransfer: VdomDictConstructor - feComposite: VdomDictConstructor - feConvolveMatrix: VdomDictConstructor - feDiffuseLighting: VdomDictConstructor - feDisplacementMap: VdomDictConstructor - feDistantLight: VdomDictConstructor - feDropShadow: VdomDictConstructor - feFlood: VdomDictConstructor - feFuncA: VdomDictConstructor - feFuncB: VdomDictConstructor - feFuncG: VdomDictConstructor - feFuncR: VdomDictConstructor - feGaussianBlur: VdomDictConstructor - feImage: VdomDictConstructor - feMerge: VdomDictConstructor - feMergeNode: VdomDictConstructor - feMorphology: VdomDictConstructor - feOffset: VdomDictConstructor - fePointLight: VdomDictConstructor - feSpecularLighting: VdomDictConstructor - feSpotLight: VdomDictConstructor - feTile: VdomDictConstructor - feTurbulence: VdomDictConstructor - filter: VdomDictConstructor - foreignObject: VdomDictConstructor - g: VdomDictConstructor - hatch: VdomDictConstructor - hatchpath: VdomDictConstructor - image: VdomDictConstructor - line: VdomDictConstructor - linearGradient: VdomDictConstructor - marker: VdomDictConstructor - mask: VdomDictConstructor - metadata: VdomDictConstructor - mpath: VdomDictConstructor - path: VdomDictConstructor - pattern: VdomDictConstructor - polygon: VdomDictConstructor - polyline: VdomDictConstructor - radialGradient: VdomDictConstructor - rect: VdomDictConstructor - script: VdomDictConstructor - set: VdomDictConstructor - stop: VdomDictConstructor - style: VdomDictConstructor - switch: VdomDictConstructor - symbol: VdomDictConstructor - text: VdomDictConstructor - textPath: VdomDictConstructor - title: VdomDictConstructor - tspan: VdomDictConstructor - use: VdomDictConstructor - view: VdomDictConstructor + a: VdomConstructor + animate: VdomConstructor + animateMotion: VdomConstructor + animateTransform: VdomConstructor + circle: VdomConstructor + clipPath: VdomConstructor + defs: VdomConstructor + desc: VdomConstructor + discard: VdomConstructor + ellipse: VdomConstructor + feBlend: VdomConstructor + feColorMatrix: VdomConstructor + feComponentTransfer: VdomConstructor + feComposite: VdomConstructor + feConvolveMatrix: VdomConstructor + feDiffuseLighting: VdomConstructor + feDisplacementMap: VdomConstructor + feDistantLight: VdomConstructor + feDropShadow: VdomConstructor + feFlood: VdomConstructor + feFuncA: VdomConstructor + feFuncB: VdomConstructor + feFuncG: VdomConstructor + feFuncR: VdomConstructor + feGaussianBlur: VdomConstructor + feImage: VdomConstructor + feMerge: VdomConstructor + feMergeNode: VdomConstructor + feMorphology: VdomConstructor + feOffset: VdomConstructor + fePointLight: VdomConstructor + feSpecularLighting: VdomConstructor + feSpotLight: VdomConstructor + feTile: VdomConstructor + feTurbulence: VdomConstructor + filter: VdomConstructor + foreignObject: VdomConstructor + g: VdomConstructor + hatch: VdomConstructor + hatchpath: VdomConstructor + image: VdomConstructor + line: VdomConstructor + linearGradient: VdomConstructor + marker: VdomConstructor + mask: VdomConstructor + metadata: VdomConstructor + mpath: VdomConstructor + path: VdomConstructor + pattern: VdomConstructor + polygon: VdomConstructor + polyline: VdomConstructor + radialGradient: VdomConstructor + rect: VdomConstructor + script: VdomConstructor + set: VdomConstructor + stop: VdomConstructor + style: VdomConstructor + switch: VdomConstructor + symbol: VdomConstructor + text: VdomConstructor + textPath: VdomConstructor + title: VdomConstructor + tspan: VdomConstructor + use: VdomConstructor + view: VdomConstructor + svg: VdomConstructor class HtmlConstructor: @@ -274,143 +282,144 @@ class HtmlConstructor: with underscores (eg. `html.data_table` for ``).""" # ruff: noqa: N815 - __cache__: ClassVar[dict[str, VdomDictConstructor]] = { - "script": custom_vdom_constructor(_script), - "fragment": custom_vdom_constructor(_fragment), + __cache__: ClassVar[dict[str, VdomConstructor]] = { + "script": Vdom("script", custom_constructor=_script), + "fragment": Vdom("", custom_constructor=_fragment), + "svg": SvgConstructor(), } - def __getattr__(self, value: str) -> VdomDictConstructor: + def __getattr__(self, value: str) -> VdomConstructor: value = value.rstrip("_").replace("_", "-") if value in self.__cache__: return self.__cache__[value] - self.__cache__[value] = make_vdom_constructor( + self.__cache__[value] = Vdom( value, allow_children=value not in NO_CHILDREN_ALLOWED_HTML_BODY ) return self.__cache__[value] - # HTML elements, written out here for auto-complete purposes - # The actual elements are created dynamically in the __getattr__ method. - # Elements other than these can still be created. - a: VdomDictConstructor - abbr: VdomDictConstructor - address: VdomDictConstructor - area: VdomDictConstructor - article: VdomDictConstructor - aside: VdomDictConstructor - audio: VdomDictConstructor - b: VdomDictConstructor - body: VdomDictConstructor - base: VdomDictConstructor - bdi: VdomDictConstructor - bdo: VdomDictConstructor - blockquote: VdomDictConstructor - br: VdomDictConstructor - button: VdomDictConstructor - canvas: VdomDictConstructor - caption: VdomDictConstructor - cite: VdomDictConstructor - code: VdomDictConstructor - col: VdomDictConstructor - colgroup: VdomDictConstructor - data: VdomDictConstructor - dd: VdomDictConstructor - del_: VdomDictConstructor - details: VdomDictConstructor - dialog: VdomDictConstructor - div: VdomDictConstructor - dl: VdomDictConstructor - dt: VdomDictConstructor - em: VdomDictConstructor - embed: VdomDictConstructor - fieldset: VdomDictConstructor - figcaption: VdomDictConstructor - figure: VdomDictConstructor - footer: VdomDictConstructor - form: VdomDictConstructor - h1: VdomDictConstructor - h2: VdomDictConstructor - h3: VdomDictConstructor - h4: VdomDictConstructor - h5: VdomDictConstructor - h6: VdomDictConstructor - head: VdomDictConstructor - header: VdomDictConstructor - hr: VdomDictConstructor - html: VdomDictConstructor - i: VdomDictConstructor - iframe: VdomDictConstructor - img: VdomDictConstructor - input: VdomDictConstructor - ins: VdomDictConstructor - kbd: VdomDictConstructor - label: VdomDictConstructor - legend: VdomDictConstructor - li: VdomDictConstructor - link: VdomDictConstructor - main: VdomDictConstructor - map: VdomDictConstructor - mark: VdomDictConstructor - math: VdomDictConstructor - menu: VdomDictConstructor - menuitem: VdomDictConstructor - meta: VdomDictConstructor - meter: VdomDictConstructor - nav: VdomDictConstructor - noscript: VdomDictConstructor - object: VdomDictConstructor - ol: VdomDictConstructor - option: VdomDictConstructor - output: VdomDictConstructor - p: VdomDictConstructor - param: VdomDictConstructor - picture: VdomDictConstructor - portal: VdomDictConstructor - pre: VdomDictConstructor - progress: VdomDictConstructor - q: VdomDictConstructor - rp: VdomDictConstructor - rt: VdomDictConstructor - ruby: VdomDictConstructor - s: VdomDictConstructor - samp: VdomDictConstructor - script: VdomDictConstructor - section: VdomDictConstructor - select: VdomDictConstructor - slot: VdomDictConstructor - small: VdomDictConstructor - source: VdomDictConstructor - span: VdomDictConstructor - strong: VdomDictConstructor - style: VdomDictConstructor - sub: VdomDictConstructor - summary: VdomDictConstructor - sup: VdomDictConstructor - table: VdomDictConstructor - tbody: VdomDictConstructor - td: VdomDictConstructor - template: VdomDictConstructor - textarea: VdomDictConstructor - tfoot: VdomDictConstructor - th: VdomDictConstructor - thead: VdomDictConstructor - time: VdomDictConstructor - title: VdomDictConstructor - tr: VdomDictConstructor - track: VdomDictConstructor - u: VdomDictConstructor - ul: VdomDictConstructor - var: VdomDictConstructor - video: VdomDictConstructor - wbr: VdomDictConstructor - fragment: VdomDictConstructor + # Standard HTML elements are written below for auto-complete purposes + # The actual elements are created dynamically when __getattr__ is called. + # Elements other than those type-hinted below can still be created. + a: VdomConstructor + abbr: VdomConstructor + address: VdomConstructor + area: VdomConstructor + article: VdomConstructor + aside: VdomConstructor + audio: VdomConstructor + b: VdomConstructor + body: VdomConstructor + base: VdomConstructor + bdi: VdomConstructor + bdo: VdomConstructor + blockquote: VdomConstructor + br: VdomConstructor + button: VdomConstructor + canvas: VdomConstructor + caption: VdomConstructor + cite: VdomConstructor + code: VdomConstructor + col: VdomConstructor + colgroup: VdomConstructor + data: VdomConstructor + dd: VdomConstructor + del_: VdomConstructor + details: VdomConstructor + dialog: VdomConstructor + div: VdomConstructor + dl: VdomConstructor + dt: VdomConstructor + em: VdomConstructor + embed: VdomConstructor + fieldset: VdomConstructor + figcaption: VdomConstructor + figure: VdomConstructor + footer: VdomConstructor + form: VdomConstructor + h1: VdomConstructor + h2: VdomConstructor + h3: VdomConstructor + h4: VdomConstructor + h5: VdomConstructor + h6: VdomConstructor + head: VdomConstructor + header: VdomConstructor + hr: VdomConstructor + html: VdomConstructor + i: VdomConstructor + iframe: VdomConstructor + img: VdomConstructor + input: VdomConstructor + ins: VdomConstructor + kbd: VdomConstructor + label: VdomConstructor + legend: VdomConstructor + li: VdomConstructor + link: VdomConstructor + main: VdomConstructor + map: VdomConstructor + mark: VdomConstructor + math: VdomConstructor + menu: VdomConstructor + menuitem: VdomConstructor + meta: VdomConstructor + meter: VdomConstructor + nav: VdomConstructor + noscript: VdomConstructor + object: VdomConstructor + ol: VdomConstructor + option: VdomConstructor + output: VdomConstructor + p: VdomConstructor + param: VdomConstructor + picture: VdomConstructor + portal: VdomConstructor + pre: VdomConstructor + progress: VdomConstructor + q: VdomConstructor + rp: VdomConstructor + rt: VdomConstructor + ruby: VdomConstructor + s: VdomConstructor + samp: VdomConstructor + script: VdomConstructor + section: VdomConstructor + select: VdomConstructor + slot: VdomConstructor + small: VdomConstructor + source: VdomConstructor + span: VdomConstructor + strong: VdomConstructor + style: VdomConstructor + sub: VdomConstructor + summary: VdomConstructor + sup: VdomConstructor + table: VdomConstructor + tbody: VdomConstructor + td: VdomConstructor + template: VdomConstructor + textarea: VdomConstructor + tfoot: VdomConstructor + th: VdomConstructor + thead: VdomConstructor + time: VdomConstructor + title: VdomConstructor + tr: VdomConstructor + track: VdomConstructor + u: VdomConstructor + ul: VdomConstructor + var: VdomConstructor + video: VdomConstructor + wbr: VdomConstructor + fragment: VdomConstructor # Special Case: SVG elements # Since SVG elements have a different set of allowed children, they are # separated into a different constructor, and are accessed via `html.svg.example()` - svg: SvgConstructor = SvgConstructor() + svg: SvgConstructor html = HtmlConstructor() diff --git a/src/reactpy/_warnings.py b/src/reactpy/_warnings.py index dc6d2fa1f..6a515e0a6 100644 --- a/src/reactpy/_warnings.py +++ b/src/reactpy/_warnings.py @@ -2,7 +2,7 @@ from functools import wraps from inspect import currentframe from types import FrameType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from warnings import warn as _warn @@ -13,7 +13,7 @@ def warn(*args: Any, **kwargs: Any) -> Any: if TYPE_CHECKING: - warn = _warn + warn = cast(Any, _warn) def _frame_depth_in_module() -> int: diff --git a/src/reactpy/backend/__init__.py b/src/reactpy/backend/__init__.py deleted file mode 100644 index e08e50649..000000000 --- a/src/reactpy/backend/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -import mimetypes -from logging import getLogger - -_logger = getLogger(__name__) - -# Fix for missing mime types due to OS corruption/misconfiguration -# Example: https://github.com/encode/starlette/issues/829 -if not mimetypes.inited: - mimetypes.init() -for extension, mime_type in { - ".js": "application/javascript", - ".css": "text/css", - ".json": "application/json", -}.items(): - if not mimetypes.types_map.get(extension): # pragma: no cover - _logger.warning( - "Mime type '%s = %s' is missing. Please research how to " - "fix missing mime types on your operating system.", - extension, - mime_type, - ) - mimetypes.add_type(mime_type, extension) diff --git a/src/reactpy/backend/_common.py b/src/reactpy/backend/_common.py deleted file mode 100644 index 1e369a26b..000000000 --- a/src/reactpy/backend/_common.py +++ /dev/null @@ -1,135 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -from collections.abc import Awaitable, Sequence -from dataclasses import dataclass -from pathlib import Path, PurePosixPath -from typing import TYPE_CHECKING, Any, cast - -from reactpy import __file__ as _reactpy_file_path -from reactpy import html -from reactpy.config import REACTPY_WEB_MODULES_DIR -from reactpy.core.types import VdomDict -from reactpy.utils import vdom_to_html - -if TYPE_CHECKING: - import uvicorn - from asgiref.typing import ASGIApplication - -PATH_PREFIX = PurePosixPath("/_reactpy") -MODULES_PATH = PATH_PREFIX / "modules" -ASSETS_PATH = PATH_PREFIX / "assets" -STREAM_PATH = PATH_PREFIX / "stream" -CLIENT_BUILD_DIR = Path(_reactpy_file_path).parent / "static" - - -async def serve_with_uvicorn( - app: ASGIApplication | Any, - host: str, - port: int, - started: asyncio.Event | None, -) -> None: - """Run a development server for an ASGI application""" - import uvicorn - - server = uvicorn.Server( - uvicorn.Config( - app, - host=host, - port=port, - loop="asyncio", - ) - ) - server.config.setup_event_loop() - coros: list[Awaitable[Any]] = [server.serve()] - - # If a started event is provided, then use it signal based on `server.started` - if started: - coros.append(_check_if_started(server, started)) - - try: - await asyncio.gather(*coros) - finally: - # Since we aren't using the uvicorn's `run()` API, we can't guarantee uvicorn's - # order of operations. So we need to make sure `shutdown()` always has an initialized - # list of `self.servers` to use. - if not hasattr(server, "servers"): # nocov - server.servers = [] - await asyncio.wait_for(server.shutdown(), timeout=3) - - -async def _check_if_started(server: uvicorn.Server, started: asyncio.Event) -> None: - while not server.started: - await asyncio.sleep(0.2) - started.set() - - -def safe_client_build_dir_path(path: str) -> Path: - """Prevent path traversal out of :data:`CLIENT_BUILD_DIR`""" - return traversal_safe_path( - CLIENT_BUILD_DIR, *("index.html" if path in {"", "/"} else path).split("/") - ) - - -def safe_web_modules_dir_path(path: str) -> Path: - """Prevent path traversal out of :data:`reactpy.config.REACTPY_WEB_MODULES_DIR`""" - return traversal_safe_path(REACTPY_WEB_MODULES_DIR.current, *path.split("/")) - - -def traversal_safe_path(root: str | Path, *unsafe: str | Path) -> Path: - """Raise a ``ValueError`` if the ``unsafe`` path resolves outside the root dir.""" - root = os.path.abspath(root) - - # Resolve relative paths but not symlinks - symlinks should be ok since their - # presence and where they point is under the control of the developer. - path = os.path.abspath(os.path.join(root, *unsafe)) - - if os.path.commonprefix([root, path]) != root: - # If the common prefix is not root directory we resolved outside the root dir - msg = "Unsafe path" - raise ValueError(msg) - - return Path(path) - - -def read_client_index_html(options: CommonOptions) -> str: - return ( - (CLIENT_BUILD_DIR / "index.html") - .read_text() - .format(__head__=vdom_head_elements_to_html(options.head)) - ) - - -def vdom_head_elements_to_html(head: Sequence[VdomDict] | VdomDict | str) -> str: - if isinstance(head, str): - return head - elif isinstance(head, dict): - if head.get("tagName") == "head": - head = cast(VdomDict, {**head, "tagName": ""}) - return vdom_to_html(head) - else: - return vdom_to_html(html.fragment(*head)) - - -@dataclass -class CommonOptions: - """Options for ReactPy's built-in backed server implementations""" - - head: Sequence[VdomDict] | VdomDict | str = (html.title("ReactPy"),) - """Add elements to the ```` of the application. - - For example, this can be used to customize the title of the page, link extra - scripts, or load stylesheets. - """ - - url_prefix: str = "" - """The URL prefix where ReactPy resources will be served from""" - - serve_index_route: bool = True - """Automatically generate and serve the index route (``/``)""" - - def __post_init__(self) -> None: - if self.url_prefix and not self.url_prefix.startswith("/"): - msg = "Expected 'url_prefix' to start with '/'" - raise ValueError(msg) diff --git a/src/reactpy/backend/default.py b/src/reactpy/backend/default.py deleted file mode 100644 index 37aad31af..000000000 --- a/src/reactpy/backend/default.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -import asyncio -from logging import getLogger -from sys import exc_info -from typing import Any, NoReturn - -from reactpy.backend.types import BackendType -from reactpy.backend.utils import SUPPORTED_BACKENDS, all_implementations -from reactpy.types import RootComponentConstructor - -logger = getLogger(__name__) -_DEFAULT_IMPLEMENTATION: BackendType[Any] | None = None - - -# BackendType.Options -class Options: # nocov - """Configuration options that can be provided to the backend. - This definition should not be used/instantiated. It exists only for - type hinting purposes.""" - - def __init__(self, *args: Any, **kwds: Any) -> NoReturn: - msg = "Default implementation has no options." - raise ValueError(msg) - - -# BackendType.configure -def configure( - app: Any, component: RootComponentConstructor, options: None = None -) -> None: - """Configure the given app instance to display the given component""" - if options is not None: # nocov - msg = "Default implementation cannot be configured with options" - raise ValueError(msg) - return _default_implementation().configure(app, component) - - -# BackendType.create_development_app -def create_development_app() -> Any: - """Create an application instance for development purposes""" - return _default_implementation().create_development_app() - - -# BackendType.serve_development_app -async def serve_development_app( - app: Any, - host: str, - port: int, - started: asyncio.Event | None = None, -) -> None: - """Run an application using a development server""" - return await _default_implementation().serve_development_app( - app, host, port, started - ) - - -def _default_implementation() -> BackendType[Any]: - """Get the first available server implementation""" - global _DEFAULT_IMPLEMENTATION # noqa: PLW0603 - - if _DEFAULT_IMPLEMENTATION is not None: - return _DEFAULT_IMPLEMENTATION - - try: - implementation = next(all_implementations()) - except StopIteration: # nocov - logger.debug("Backend implementation import failed", exc_info=exc_info()) - supported_backends = ", ".join(SUPPORTED_BACKENDS) - msg = ( - "It seems you haven't installed a backend. To resolve this issue, " - "you can install a backend by running:\n\n" - '\033[1mpip install "reactpy[starlette]"\033[0m\n\n' - f"Other supported backends include: {supported_backends}." - ) - raise RuntimeError(msg) from None - else: - _DEFAULT_IMPLEMENTATION = implementation - return implementation diff --git a/src/reactpy/backend/fastapi.py b/src/reactpy/backend/fastapi.py deleted file mode 100644 index a0137a3dc..000000000 --- a/src/reactpy/backend/fastapi.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from fastapi import FastAPI - -from reactpy.backend import starlette - -# BackendType.Options -Options = starlette.Options - -# BackendType.configure -configure = starlette.configure - - -# BackendType.create_development_app -def create_development_app() -> FastAPI: - """Create a development ``FastAPI`` application instance.""" - return FastAPI(debug=True) - - -# BackendType.serve_development_app -serve_development_app = starlette.serve_development_app - -use_connection = starlette.use_connection - -use_websocket = starlette.use_websocket diff --git a/src/reactpy/backend/flask.py b/src/reactpy/backend/flask.py deleted file mode 100644 index 4401fb6f7..000000000 --- a/src/reactpy/backend/flask.py +++ /dev/null @@ -1,303 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -import os -from asyncio import Queue as AsyncQueue -from dataclasses import dataclass -from queue import Queue as ThreadQueue -from threading import Event as ThreadEvent -from threading import Thread -from typing import Any, Callable, NamedTuple, NoReturn, cast - -from flask import ( - Blueprint, - Flask, - Request, - copy_current_request_context, - request, - send_file, -) -from flask_cors import CORS -from flask_sock import Sock -from simple_websocket import Server as WebSocket -from werkzeug.serving import BaseWSGIServer, make_server - -import reactpy -from reactpy.backend._common import ( - ASSETS_PATH, - MODULES_PATH, - PATH_PREFIX, - STREAM_PATH, - CommonOptions, - read_client_index_html, - safe_client_build_dir_path, - safe_web_modules_dir_path, -) -from reactpy.backend.types import Connection, Location -from reactpy.core.hooks import ConnectionContext -from reactpy.core.hooks import use_connection as _use_connection -from reactpy.core.serve import serve_layout -from reactpy.core.types import ComponentType, RootComponentConstructor -from reactpy.utils import Ref - -logger = logging.getLogger(__name__) - - -# BackendType.Options -@dataclass -class Options(CommonOptions): - """Render server config for :func:`reactpy.backend.flask.configure`""" - - cors: bool | dict[str, Any] = False - """Enable or configure Cross Origin Resource Sharing (CORS) - - For more information see docs for ``flask_cors.CORS`` - """ - - -# BackendType.configure -def configure( - app: Flask, component: RootComponentConstructor, options: Options | None = None -) -> None: - """Configure the necessary ReactPy routes on the given app. - - Parameters: - app: An application instance - component: A component constructor - options: Options for configuring server behavior - """ - options = options or Options() - - api_bp = Blueprint(f"reactpy_api_{id(app)}", __name__, url_prefix=str(PATH_PREFIX)) - spa_bp = Blueprint( - f"reactpy_spa_{id(app)}", __name__, url_prefix=options.url_prefix - ) - - _setup_single_view_dispatcher_route(api_bp, options, component) - _setup_common_routes(api_bp, spa_bp, options) - - app.register_blueprint(api_bp) - app.register_blueprint(spa_bp) - - -# BackendType.create_development_app -def create_development_app() -> Flask: - """Create an application instance for development purposes""" - os.environ["FLASK_DEBUG"] = "true" - return Flask(__name__) - - -# BackendType.serve_development_app -async def serve_development_app( - app: Flask, - host: str, - port: int, - started: asyncio.Event | None = None, -) -> None: - """Run a development server for FastAPI""" - loop = asyncio.get_running_loop() - stopped = asyncio.Event() - - server: Ref[BaseWSGIServer] = Ref() - - def run_server() -> None: - server.current = make_server(host, port, app, threaded=True) - if started: - loop.call_soon_threadsafe(started.set) - try: - server.current.serve_forever() # type: ignore - finally: - loop.call_soon_threadsafe(stopped.set) - - thread = Thread(target=run_server, daemon=True) - thread.start() - - if started: - await started.wait() - - try: - await stopped.wait() - finally: - # we may have exited because this task was cancelled - server.current.shutdown() - # the thread should eventually join - thread.join(timeout=3) - # just double check it happened - if thread.is_alive(): # nocov - msg = "Failed to shutdown server." - raise RuntimeError(msg) - - -def use_websocket() -> WebSocket: - """A handle to the current websocket""" - return use_connection().carrier.websocket - - -def use_request() -> Request: - """Get the current ``Request``""" - return use_connection().carrier.request - - -def use_connection() -> Connection[_FlaskCarrier]: - """Get the current :class:`Connection`""" - conn = _use_connection() - if not isinstance(conn.carrier, _FlaskCarrier): # nocov - msg = f"Connection has unexpected carrier {conn.carrier}. Are you running with a Flask server?" - raise TypeError(msg) - return conn - - -def _setup_common_routes( - api_blueprint: Blueprint, - spa_blueprint: Blueprint, - options: Options, -) -> None: - cors_options = options.cors - if cors_options: # nocov - cors_params = cors_options if isinstance(cors_options, dict) else {} - CORS(api_blueprint, **cors_params) - - @api_blueprint.route(f"/{ASSETS_PATH.name}/") - def send_assets_dir(path: str = "") -> Any: - return send_file(safe_client_build_dir_path(f"assets/{path}")) - - @api_blueprint.route(f"/{MODULES_PATH.name}/") - def send_modules_dir(path: str = "") -> Any: - return send_file(safe_web_modules_dir_path(path), mimetype="text/javascript") - - index_html = read_client_index_html(options) - - if options.serve_index_route: - - @spa_blueprint.route("/") - @spa_blueprint.route("/") - def send_client_dir(_: str = "") -> Any: - return index_html - - -def _setup_single_view_dispatcher_route( - api_blueprint: Blueprint, options: Options, constructor: RootComponentConstructor -) -> None: - sock = Sock(api_blueprint) - - def model_stream(ws: WebSocket, path: str = "") -> None: - def send(value: Any) -> None: - ws.send(json.dumps(value)) - - def recv() -> Any: - return json.loads(ws.receive()) - - _dispatch_in_thread( - ws, - # remove any url prefix from path - path[len(options.url_prefix) :], - constructor(), - send, - recv, - ) - - sock.route(STREAM_PATH.name, endpoint="without_path")(model_stream) - sock.route(f"{STREAM_PATH.name}/", endpoint="with_path")(model_stream) - - -def _dispatch_in_thread( - websocket: WebSocket, - path: str, - component: ComponentType, - send: Callable[[Any], None], - recv: Callable[[], Any | None], -) -> NoReturn: - dispatch_thread_info_created = ThreadEvent() - dispatch_thread_info_ref: reactpy.Ref[_DispatcherThreadInfo | None] = reactpy.Ref( - None - ) - - @copy_current_request_context - def run_dispatcher() -> None: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - thread_send_queue: ThreadQueue[Any] = ThreadQueue() - async_recv_queue: AsyncQueue[Any] = AsyncQueue() - - async def send_coro(value: Any) -> None: - thread_send_queue.put(value) - - async def main() -> None: - search = request.query_string.decode() - await serve_layout( - reactpy.Layout( - ConnectionContext( - component, - value=Connection( - scope=request.environ, - location=Location( - pathname=f"/{path}", - search=f"?{search}" if search else "", - ), - carrier=_FlaskCarrier(request, websocket), - ), - ), - ), - send_coro, - async_recv_queue.get, - ) - - main_future = asyncio.ensure_future(main(), loop=loop) - - dispatch_thread_info_ref.current = _DispatcherThreadInfo( - dispatch_loop=loop, - dispatch_future=main_future, - thread_send_queue=thread_send_queue, - async_recv_queue=async_recv_queue, - ) - dispatch_thread_info_created.set() - - loop.run_until_complete(main_future) - - Thread(target=run_dispatcher, daemon=True).start() - - dispatch_thread_info_created.wait() - dispatch_thread_info = cast(_DispatcherThreadInfo, dispatch_thread_info_ref.current) - - if dispatch_thread_info is None: - raise RuntimeError("Failed to create dispatcher thread") # nocov - - stop = ThreadEvent() - - def run_send() -> None: - while not stop.is_set(): - send(dispatch_thread_info.thread_send_queue.get()) - - Thread(target=run_send, daemon=True).start() - - try: - while True: - value = recv() - dispatch_thread_info.dispatch_loop.call_soon_threadsafe( - dispatch_thread_info.async_recv_queue.put_nowait, value - ) - finally: # nocov - dispatch_thread_info.dispatch_loop.call_soon_threadsafe( - dispatch_thread_info.dispatch_future.cancel - ) - - -class _DispatcherThreadInfo(NamedTuple): - dispatch_loop: asyncio.AbstractEventLoop - dispatch_future: asyncio.Future[Any] - thread_send_queue: ThreadQueue[Any] - async_recv_queue: AsyncQueue[Any] - - -@dataclass -class _FlaskCarrier: - """A simple wrapper for holding a Flask request and WebSocket""" - - request: Request - """The current request object""" - - websocket: WebSocket - """A handle to the current websocket""" diff --git a/src/reactpy/backend/hooks.py b/src/reactpy/backend/hooks.py deleted file mode 100644 index ec761ef0f..000000000 --- a/src/reactpy/backend/hooks.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations # nocov - -from collections.abc import MutableMapping # nocov -from typing import Any # nocov - -from reactpy._warnings import warn # nocov -from reactpy.backend.types import Connection, Location # nocov -from reactpy.core.hooks import ConnectionContext, use_context # nocov - - -def use_connection() -> Connection[Any]: # nocov - """Get the current :class:`~reactpy.backend.types.Connection`.""" - warn( - "The module reactpy.backend.hooks has been deprecated and will be deleted in the future. " - "Call reactpy.use_connection instead.", - DeprecationWarning, - ) - - conn = use_context(ConnectionContext) - if conn is None: - msg = "No backend established a connection." - raise RuntimeError(msg) - return conn - - -def use_scope() -> MutableMapping[str, Any]: # nocov - """Get the current :class:`~reactpy.backend.types.Connection`'s scope.""" - warn( - "The module reactpy.backend.hooks has been deprecated and will be deleted in the future. " - "Call reactpy.use_scope instead.", - DeprecationWarning, - ) - - return use_connection().scope - - -def use_location() -> Location: # nocov - """Get the current :class:`~reactpy.backend.types.Connection`'s location.""" - warn( - "The module reactpy.backend.hooks has been deprecated and will be deleted in the future. " - "Call reactpy.use_location instead.", - DeprecationWarning, - ) - - return use_connection().location diff --git a/src/reactpy/backend/sanic.py b/src/reactpy/backend/sanic.py deleted file mode 100644 index d272fb4cf..000000000 --- a/src/reactpy/backend/sanic.py +++ /dev/null @@ -1,231 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -from dataclasses import dataclass -from typing import Any -from urllib import parse as urllib_parse -from uuid import uuid4 - -from sanic import Blueprint, Sanic, request, response -from sanic.config import Config -from sanic.server.websockets.connection import WebSocketConnection -from sanic_cors import CORS - -from reactpy.backend._common import ( - ASSETS_PATH, - MODULES_PATH, - PATH_PREFIX, - STREAM_PATH, - CommonOptions, - read_client_index_html, - safe_client_build_dir_path, - safe_web_modules_dir_path, - serve_with_uvicorn, -) -from reactpy.backend.types import Connection, Location -from reactpy.core.hooks import ConnectionContext -from reactpy.core.hooks import use_connection as _use_connection -from reactpy.core.layout import Layout -from reactpy.core.serve import RecvCoroutine, SendCoroutine, Stop, serve_layout -from reactpy.core.types import RootComponentConstructor - -logger = logging.getLogger(__name__) - - -# BackendType.Options -@dataclass -class Options(CommonOptions): - """Render server config for :func:`reactpy.backend.sanic.configure`""" - - cors: bool | dict[str, Any] = False - """Enable or configure Cross Origin Resource Sharing (CORS) - - For more information see docs for ``sanic_cors.CORS`` - """ - - -# BackendType.configure -def configure( - app: Sanic[Any, Any], - component: RootComponentConstructor, - options: Options | None = None, -) -> None: - """Configure an application instance to display the given component""" - options = options or Options() - - spa_bp = Blueprint(f"reactpy_spa_{id(app)}", url_prefix=options.url_prefix) - api_bp = Blueprint(f"reactpy_api_{id(app)}", url_prefix=str(PATH_PREFIX)) - - _setup_common_routes(api_bp, spa_bp, options) - _setup_single_view_dispatcher_route(api_bp, component, options) - - app.blueprint([spa_bp, api_bp]) - - -# BackendType.create_development_app -def create_development_app() -> Sanic[Any, Any]: - """Return a :class:`Sanic` app instance in test mode""" - Sanic.test_mode = True - logger.warning("Sanic.test_mode is now active") - return Sanic(f"reactpy_development_app_{uuid4().hex}", Config()) - - -# BackendType.serve_development_app -async def serve_development_app( - app: Sanic[Any, Any], - host: str, - port: int, - started: asyncio.Event | None = None, -) -> None: - """Run a development server for :mod:`sanic`""" - await serve_with_uvicorn(app, host, port, started) - - -def use_request() -> request.Request[Any, Any]: - """Get the current ``Request``""" - return use_connection().carrier.request - - -def use_websocket() -> WebSocketConnection: - """Get the current websocket""" - return use_connection().carrier.websocket - - -def use_connection() -> Connection[_SanicCarrier]: - """Get the current :class:`Connection`""" - conn = _use_connection() - if not isinstance(conn.carrier, _SanicCarrier): # nocov - msg = f"Connection has unexpected carrier {conn.carrier}. Are you running with a Sanic server?" - raise TypeError(msg) - return conn - - -def _setup_common_routes( - api_blueprint: Blueprint, - spa_blueprint: Blueprint, - options: Options, -) -> None: - cors_options = options.cors - if cors_options: # nocov - cors_params = cors_options if isinstance(cors_options, dict) else {} - CORS(api_blueprint, **cors_params) - - index_html = read_client_index_html(options) - - async def single_page_app_files( - request: request.Request[Any, Any], - _: str = "", - ) -> response.HTTPResponse: - return response.html(index_html) - - if options.serve_index_route: - spa_blueprint.add_route( - single_page_app_files, - "/", - name="single_page_app_files_root", - ) - spa_blueprint.add_route( - single_page_app_files, - "/<_:path>", - name="single_page_app_files_path", - ) - - async def asset_files( - request: request.Request[Any, Any], - path: str = "", - ) -> response.HTTPResponse: - path = urllib_parse.unquote(path) - return await response.file(safe_client_build_dir_path(f"assets/{path}")) - - api_blueprint.add_route(asset_files, f"/{ASSETS_PATH.name}/") - - async def web_module_files( - request: request.Request[Any, Any], - path: str, - _: str = "", # this is not used - ) -> response.HTTPResponse: - path = urllib_parse.unquote(path) - return await response.file( - safe_web_modules_dir_path(path), - mime_type="text/javascript", - ) - - api_blueprint.add_route(web_module_files, f"/{MODULES_PATH.name}/") - - -def _setup_single_view_dispatcher_route( - api_blueprint: Blueprint, - constructor: RootComponentConstructor, - options: Options, -) -> None: - async def model_stream( - request: request.Request[Any, Any], - socket: WebSocketConnection, - path: str = "", - ) -> None: - asgi_app = getattr(request.app, "_asgi_app", None) - scope = asgi_app.transport.scope if asgi_app else {} - if not scope: # nocov - logger.warning("No scope. Sanic may not be running with an ASGI server") - - send, recv = _make_send_recv_callbacks(socket) - await serve_layout( - Layout( - ConnectionContext( - constructor(), - value=Connection( - scope=scope, - location=Location( - pathname=f"/{path[len(options.url_prefix):]}", - search=( - f"?{request.query_string}" - if request.query_string - else "" - ), - ), - carrier=_SanicCarrier(request, socket), - ), - ) - ), - send, - recv, - ) - - api_blueprint.add_websocket_route( - model_stream, - f"/{STREAM_PATH.name}", - name="model_stream_root", - ) - api_blueprint.add_websocket_route( - model_stream, - f"/{STREAM_PATH.name}//", - name="model_stream_path", - ) - - -def _make_send_recv_callbacks( - socket: WebSocketConnection, -) -> tuple[SendCoroutine, RecvCoroutine]: - async def sock_send(value: Any) -> None: - await socket.send(json.dumps(value)) - - async def sock_recv() -> Any: - data = await socket.recv() - if data is None: - raise Stop() - return json.loads(data) - - return sock_send, sock_recv - - -@dataclass -class _SanicCarrier: - """A simple wrapper for holding connection information""" - - request: request.Request[Sanic[Any, Any], Any] - """The current request object""" - - websocket: WebSocketConnection - """A handle to the current websocket""" diff --git a/src/reactpy/backend/starlette.py b/src/reactpy/backend/starlette.py deleted file mode 100644 index 20e2b4478..000000000 --- a/src/reactpy/backend/starlette.py +++ /dev/null @@ -1,185 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -from collections.abc import Awaitable -from dataclasses import dataclass -from typing import Any, Callable - -from exceptiongroup import BaseExceptionGroup -from starlette.applications import Starlette -from starlette.middleware.cors import CORSMiddleware -from starlette.requests import Request -from starlette.responses import HTMLResponse -from starlette.staticfiles import StaticFiles -from starlette.websockets import WebSocket, WebSocketDisconnect - -from reactpy.backend._common import ( - ASSETS_PATH, - CLIENT_BUILD_DIR, - MODULES_PATH, - STREAM_PATH, - CommonOptions, - read_client_index_html, - serve_with_uvicorn, -) -from reactpy.backend.types import Connection, Location -from reactpy.config import REACTPY_WEB_MODULES_DIR -from reactpy.core.hooks import ConnectionContext -from reactpy.core.hooks import use_connection as _use_connection -from reactpy.core.layout import Layout -from reactpy.core.serve import RecvCoroutine, SendCoroutine, serve_layout -from reactpy.core.types import RootComponentConstructor - -logger = logging.getLogger(__name__) - - -# BackendType.Options -@dataclass -class Options(CommonOptions): - """Render server config for :func:`reactpy.backend.starlette.configure`""" - - cors: bool | dict[str, Any] = False - """Enable or configure Cross Origin Resource Sharing (CORS) - - For more information see docs for ``starlette.middleware.cors.CORSMiddleware`` - """ - - -# BackendType.configure -def configure( - app: Starlette, - component: RootComponentConstructor, - options: Options | None = None, -) -> None: - """Configure the necessary ReactPy routes on the given app. - - Parameters: - app: An application instance - component: A component constructor - options: Options for configuring server behavior - """ - options = options or Options() - - # this route should take priority so set up it up first - _setup_single_view_dispatcher_route(options, app, component) - - _setup_common_routes(options, app) - - -# BackendType.create_development_app -def create_development_app() -> Starlette: - """Return a :class:`Starlette` app instance in debug mode""" - return Starlette(debug=True) - - -# BackendType.serve_development_app -async def serve_development_app( - app: Starlette, - host: str, - port: int, - started: asyncio.Event | None = None, -) -> None: - """Run a development server for starlette""" - await serve_with_uvicorn(app, host, port, started) - - -def use_websocket() -> WebSocket: - """Get the current WebSocket object""" - return use_connection().carrier - - -def use_connection() -> Connection[WebSocket]: - conn = _use_connection() - if not isinstance(conn.carrier, WebSocket): # nocov - msg = f"Connection has unexpected carrier {conn.carrier}. Are you running with a Flask server?" - raise TypeError(msg) - return conn - - -def _setup_common_routes(options: Options, app: Starlette) -> None: - cors_options = options.cors - if cors_options: # nocov - cors_params = ( - cors_options if isinstance(cors_options, dict) else {"allow_origins": ["*"]} - ) - app.add_middleware(CORSMiddleware, **cors_params) - - # This really should be added to the APIRouter, but there's a bug in Starlette - # BUG: https://github.com/tiangolo/fastapi/issues/1469 - url_prefix = options.url_prefix - - app.mount( - str(MODULES_PATH), - StaticFiles(directory=REACTPY_WEB_MODULES_DIR.current, check_dir=False), - ) - app.mount( - str(ASSETS_PATH), - StaticFiles(directory=CLIENT_BUILD_DIR / "assets", check_dir=False), - ) - # register this last so it takes least priority - index_route = _make_index_route(options) - - if options.serve_index_route: - app.add_route(f"{url_prefix}/", index_route) - app.add_route(url_prefix + "/{path:path}", index_route) - - -def _make_index_route(options: Options) -> Callable[[Request], Awaitable[HTMLResponse]]: - index_html = read_client_index_html(options) - - async def serve_index(request: Request) -> HTMLResponse: - return HTMLResponse(index_html) - - return serve_index - - -def _setup_single_view_dispatcher_route( - options: Options, app: Starlette, component: RootComponentConstructor -) -> None: - async def model_stream(socket: WebSocket) -> None: - await socket.accept() - send, recv = _make_send_recv_callbacks(socket) - - pathname = "/" + socket.scope["path_params"].get("path", "") - pathname = pathname[len(options.url_prefix) :] or "/" - search = socket.scope["query_string"].decode() - - try: - await serve_layout( - Layout( - ConnectionContext( - component(), - value=Connection( - scope=socket.scope, - location=Location(pathname, f"?{search}" if search else ""), - carrier=socket, - ), - ) - ), - send, - recv, - ) - except BaseExceptionGroup as egroup: - for e in egroup.exceptions: - if isinstance(e, WebSocketDisconnect): - logger.info(f"WebSocket disconnect: {e.code}") - break - else: # nocov - raise - - app.add_websocket_route(str(STREAM_PATH), model_stream) - app.add_websocket_route(f"{STREAM_PATH}/{{path:path}}", model_stream) - - -def _make_send_recv_callbacks( - socket: WebSocket, -) -> tuple[SendCoroutine, RecvCoroutine]: - async def sock_send(value: Any) -> None: - await socket.send_text(json.dumps(value)) - - async def sock_recv() -> Any: - return json.loads(await socket.receive_text()) - - return sock_send, sock_recv diff --git a/src/reactpy/backend/tornado.py b/src/reactpy/backend/tornado.py deleted file mode 100644 index e585553e8..000000000 --- a/src/reactpy/backend/tornado.py +++ /dev/null @@ -1,235 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -from asyncio import Queue as AsyncQueue -from asyncio.futures import Future -from typing import Any -from urllib.parse import urljoin - -from tornado.httpserver import HTTPServer -from tornado.httputil import HTTPServerRequest -from tornado.log import enable_pretty_logging -from tornado.platform.asyncio import AsyncIOMainLoop -from tornado.web import Application, RequestHandler, StaticFileHandler -from tornado.websocket import WebSocketHandler -from tornado.wsgi import WSGIContainer -from typing_extensions import TypeAlias - -from reactpy.backend._common import ( - ASSETS_PATH, - CLIENT_BUILD_DIR, - MODULES_PATH, - STREAM_PATH, - CommonOptions, - read_client_index_html, -) -from reactpy.backend.types import Connection, Location -from reactpy.config import REACTPY_WEB_MODULES_DIR -from reactpy.core.hooks import ConnectionContext -from reactpy.core.hooks import use_connection as _use_connection -from reactpy.core.layout import Layout -from reactpy.core.serve import serve_layout -from reactpy.core.types import ComponentConstructor - -# BackendType.Options -Options = CommonOptions - - -# BackendType.configure -def configure( - app: Application, - component: ComponentConstructor, - options: CommonOptions | None = None, -) -> None: - """Configure the necessary ReactPy routes on the given app. - - Parameters: - app: An application instance - component: A component constructor - options: Options for configuring server behavior - """ - options = options or Options() - _add_handler( - app, - options, - ( - # this route should take priority so set up it up first - _setup_single_view_dispatcher_route(component, options) - + _setup_common_routes(options) - ), - ) - - -# BackendType.create_development_app -def create_development_app() -> Application: - return Application(debug=True) - - -# BackendType.serve_development_app -async def serve_development_app( - app: Application, - host: str, - port: int, - started: asyncio.Event | None = None, -) -> None: - enable_pretty_logging() - - AsyncIOMainLoop.current().install() - - server = HTTPServer(app) - server.listen(port, host) - - if started: - # at this point the server is accepting connection - started.set() - - try: - # block forever - tornado has already set up its own background tasks - await asyncio.get_running_loop().create_future() - finally: - # stop accepting new connections - server.stop() - # wait for existing connections to complete - await server.close_all_connections() - - -def use_request() -> HTTPServerRequest: - """Get the current ``HTTPServerRequest``""" - return use_connection().carrier - - -def use_connection() -> Connection[HTTPServerRequest]: - conn = _use_connection() - if not isinstance(conn.carrier, HTTPServerRequest): # nocov - msg = f"Connection has unexpected carrier {conn.carrier}. Are you running with a Flask server?" - raise TypeError(msg) - return conn - - -_RouteHandlerSpecs: TypeAlias = "list[tuple[str, type[RequestHandler], Any]]" - - -def _setup_common_routes(options: Options) -> _RouteHandlerSpecs: - return [ - ( - rf"{MODULES_PATH}/(.*)", - StaticFileHandler, - {"path": str(REACTPY_WEB_MODULES_DIR.current)}, - ), - ( - rf"{ASSETS_PATH}/(.*)", - StaticFileHandler, - {"path": str(CLIENT_BUILD_DIR / "assets")}, - ), - ] + ( - [ - ( - r"/(.*)", - IndexHandler, - {"index_html": read_client_index_html(options)}, - ), - ] - if options.serve_index_route - else [] - ) - - -def _add_handler( - app: Application, options: Options, handlers: _RouteHandlerSpecs -) -> None: - prefixed_handlers: list[Any] = [ - (urljoin(options.url_prefix, route_pattern), *tuple(handler_info)) - for route_pattern, *handler_info in handlers - ] - app.add_handlers(r".*", prefixed_handlers) - - -def _setup_single_view_dispatcher_route( - constructor: ComponentConstructor, options: Options -) -> _RouteHandlerSpecs: - return [ - ( - rf"{STREAM_PATH}/(.*)", - ModelStreamHandler, - {"component_constructor": constructor, "url_prefix": options.url_prefix}, - ), - ( - str(STREAM_PATH), - ModelStreamHandler, - {"component_constructor": constructor, "url_prefix": options.url_prefix}, - ), - ] - - -class IndexHandler(RequestHandler): # type: ignore - _index_html: str - - def initialize(self, index_html: str) -> None: - self._index_html = index_html - - async def get(self, _: str) -> None: - self.finish(self._index_html) - - -class ModelStreamHandler(WebSocketHandler): # type: ignore - """A web-socket handler that serves up a new model stream to each new client""" - - _dispatch_future: Future[None] - _message_queue: AsyncQueue[str] - - def initialize( - self, component_constructor: ComponentConstructor, url_prefix: str - ) -> None: - self._component_constructor = component_constructor - self._url_prefix = url_prefix - - async def open(self, path: str = "", *args: Any, **kwargs: Any) -> None: - message_queue: AsyncQueue[str] = AsyncQueue() - - async def send(value: Any) -> None: - await self.write_message(json.dumps(value)) - - async def recv() -> Any: - return json.loads(await message_queue.get()) - - self._message_queue = message_queue - self._dispatch_future = asyncio.ensure_future( - serve_layout( - Layout( - ConnectionContext( - self._component_constructor(), - value=Connection( - scope=_FAKE_WSGI_CONTAINER.environ(self.request), - location=Location( - pathname=f"/{path[len(self._url_prefix) :]}", - search=( - f"?{self.request.query}" - if self.request.query - else "" - ), - ), - carrier=self.request, - ), - ) - ), - send, - recv, - ) - ) - - async def on_message(self, message: str | bytes) -> None: - await self._message_queue.put( - message if isinstance(message, str) else message.decode() - ) - - def on_close(self) -> None: - if not self._dispatch_future.done(): - self._dispatch_future.cancel() - - -# The interface for WSGIContainer.environ changed in Tornado version 6.3 from -# a staticmethod to an instance method. Since we're not that concerned with -# the details of the WSGI app itself, we can just use a fake one. -# see: https://github.com/tornadoweb/tornado/pull/3231#issuecomment-1518957578 -_FAKE_WSGI_CONTAINER = WSGIContainer(lambda *a, **kw: iter([])) diff --git a/src/reactpy/backend/types.py b/src/reactpy/backend/types.py deleted file mode 100644 index 51e7bef04..000000000 --- a/src/reactpy/backend/types.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import MutableMapping -from dataclasses import dataclass -from typing import Any, Callable, Generic, Protocol, TypeVar, runtime_checkable - -from reactpy.core.types import RootComponentConstructor - -_App = TypeVar("_App") - - -@runtime_checkable -class BackendType(Protocol[_App]): - """Common interface for built-in web server/framework integrations""" - - Options: Callable[..., Any] - """A constructor for options passed to :meth:`BackendType.configure`""" - - def configure( - self, - app: _App, - component: RootComponentConstructor, - options: Any | None = None, - ) -> None: - """Configure the given app instance to display the given component""" - - def create_development_app(self) -> _App: - """Create an application instance for development purposes""" - - async def serve_development_app( - self, - app: _App, - host: str, - port: int, - started: asyncio.Event | None = None, - ) -> None: - """Run an application using a development server""" - - -_Carrier = TypeVar("_Carrier") - - -@dataclass -class Connection(Generic[_Carrier]): - """Represents a connection with a client""" - - scope: MutableMapping[str, Any] - """An ASGI scope or WSGI environment dictionary""" - - location: Location - """The current location (URL)""" - - carrier: _Carrier - """How the connection is mediated. For example, a request or websocket. - - This typically depends on the backend implementation. - """ - - -@dataclass -class Location: - """Represents the current location (URL) - - Analogous to, but not necessarily identical to, the client-side - ``document.location`` object. - """ - - pathname: str - """the path of the URL for the location""" - - search: str - """A search or query string - a '?' followed by the parameters of the URL. - - If there are no search parameters this should be an empty string - """ diff --git a/src/reactpy/backend/utils.py b/src/reactpy/backend/utils.py deleted file mode 100644 index 74e87bb7b..000000000 --- a/src/reactpy/backend/utils.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import socket -import sys -from collections.abc import Iterator -from contextlib import closing -from importlib import import_module -from typing import Any - -from reactpy.backend.types import BackendType -from reactpy.types import RootComponentConstructor - -logger = logging.getLogger(__name__) - -SUPPORTED_BACKENDS = ( - "fastapi", - "sanic", - "tornado", - "flask", - "starlette", -) - - -def run( - component: RootComponentConstructor, - host: str = "127.0.0.1", - port: int | None = None, - implementation: BackendType[Any] | None = None, -) -> None: - """Run a component with a development server""" - logger.warning(_DEVELOPMENT_RUN_FUNC_WARNING) - - implementation = implementation or import_module("reactpy.backend.default") - app = implementation.create_development_app() - implementation.configure(app, component) - port = port or find_available_port(host) - app_cls = type(app) - - logger.info( - "ReactPy is running with '%s.%s' at http://%s:%s", - app_cls.__module__, - app_cls.__name__, - host, - port, - ) - asyncio.run(implementation.serve_development_app(app, host, port)) - - -def find_available_port(host: str, port_min: int = 8000, port_max: int = 9000) -> int: - """Get a port that's available for the given host and port range""" - for port in range(port_min, port_max): - with closing(socket.socket()) as sock: - try: - if sys.platform in ("linux", "darwin"): - # Fixes bug on Unix-like systems where every time you restart the - # server you'll get a different port on Linux. This cannot be set - # on Windows otherwise address will always be reused. - # Ref: https://stackoverflow.com/a/19247688/3159288 - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind((host, port)) - except OSError: - pass - else: - return port - msg = f"Host {host!r} has no available port in range {port_max}-{port_max}" - raise RuntimeError(msg) - - -def all_implementations() -> Iterator[BackendType[Any]]: - """Yield all available server implementations""" - for name in SUPPORTED_BACKENDS: - try: - import_module(name) - except ImportError: # nocov - logger.debug("Failed to import %s", name, exc_info=True) - continue - - reactpy_backend_name = f"{__name__.rsplit('.', 1)[0]}.{name}" - yield import_module(reactpy_backend_name) - - -_DEVELOPMENT_RUN_FUNC_WARNING = """\ -The `run()` function is only intended for testing during development! To run \ -in production, refer to the docs on how to use reactpy.backend.*.configure.\ -""" diff --git a/src/reactpy/config.py b/src/reactpy/config.py index 426398208..993e6d8b4 100644 --- a/src/reactpy/config.py +++ b/src/reactpy/config.py @@ -33,9 +33,7 @@ def boolean(value: str | bool | int) -> bool: ) -REACTPY_DEBUG_MODE = Option( - "REACTPY_DEBUG_MODE", default=False, validator=boolean, mutable=True -) +REACTPY_DEBUG = Option("REACTPY_DEBUG", default=False, validator=boolean, mutable=True) """Get extra logs and validation checks at the cost of performance. This will enable the following: @@ -44,13 +42,17 @@ def boolean(value: str | bool | int) -> bool: - :data:`REACTPY_CHECK_JSON_ATTRS` """ -REACTPY_CHECK_VDOM_SPEC = Option("REACTPY_CHECK_VDOM_SPEC", parent=REACTPY_DEBUG_MODE) +REACTPY_CHECK_VDOM_SPEC = Option( + "REACTPY_CHECK_VDOM_SPEC", parent=REACTPY_DEBUG, validator=boolean +) """Checks which ensure VDOM is rendered to spec For more info on the VDOM spec, see here: :ref:`VDOM JSON Schema` """ -REACTPY_CHECK_JSON_ATTRS = Option("REACTPY_CHECK_JSON_ATTRS", parent=REACTPY_DEBUG_MODE) +REACTPY_CHECK_JSON_ATTRS = Option( + "REACTPY_CHECK_JSON_ATTRS", parent=REACTPY_DEBUG, validator=boolean +) """Checks that all VDOM attributes are JSON serializable The VDOM spec is not able to enforce this on its own since attributes could anything. @@ -73,8 +75,8 @@ def boolean(value: str | bool | int) -> bool: set of publicly available APIs for working with the client. """ -REACTPY_TESTING_DEFAULT_TIMEOUT = Option( - "REACTPY_TESTING_DEFAULT_TIMEOUT", +REACTPY_TESTS_DEFAULT_TIMEOUT = Option( + "REACTPY_TESTS_DEFAULT_TIMEOUT", 10.0, mutable=False, validator=float, @@ -88,3 +90,43 @@ def boolean(value: str | bool | int) -> bool: validator=boolean, ) """Whether to render components asynchronously. This is currently an experimental feature.""" + +REACTPY_RECONNECT_INTERVAL = Option( + "REACTPY_RECONNECT_INTERVAL", + default=750, + mutable=True, + validator=int, +) +"""The interval in milliseconds between reconnection attempts for the websocket server""" + +REACTPY_RECONNECT_MAX_INTERVAL = Option( + "REACTPY_RECONNECT_MAX_INTERVAL", + default=60000, + mutable=True, + validator=int, +) +"""The maximum interval in milliseconds between reconnection attempts for the websocket server""" + +REACTPY_RECONNECT_MAX_RETRIES = Option( + "REACTPY_RECONNECT_MAX_RETRIES", + default=150, + mutable=True, + validator=int, +) +"""The maximum number of reconnection attempts for the websocket server""" + +REACTPY_RECONNECT_BACKOFF_MULTIPLIER = Option( + "REACTPY_RECONNECT_BACKOFF_MULTIPLIER", + default=1.25, + mutable=True, + validator=float, +) +"""The multiplier for exponential backoff between reconnection attempts for the websocket server""" + +REACTPY_PATH_PREFIX = Option( + "REACTPY_PATH_PREFIX", + default="/reactpy/", + mutable=True, + validator=str, +) +"""The prefix for all ReactPy routes""" diff --git a/src/reactpy/core/_life_cycle_hook.py b/src/reactpy/core/_life_cycle_hook.py index 88d3386a8..c940bf01b 100644 --- a/src/reactpy/core/_life_cycle_hook.py +++ b/src/reactpy/core/_life_cycle_hook.py @@ -1,13 +1,16 @@ from __future__ import annotations import logging +import sys from asyncio import Event, Task, create_task, gather +from contextvars import ContextVar, Token from typing import Any, Callable, Protocol, TypeVar from anyio import Semaphore from reactpy.core._thread_local import ThreadLocal -from reactpy.core.types import ComponentType, Context, ContextProviderType +from reactpy.types import ComponentType, Context, ContextProviderType +from reactpy.utils import Singleton T = TypeVar("T") @@ -18,16 +21,39 @@ async def __call__(self, stop: Event) -> None: ... logger = logging.getLogger(__name__) -_HOOK_STATE: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list) +class _HookStack(Singleton): # nocov + """A singleton object which manages the current component tree's hooks. + Life cycle hooks can be stored in a thread local or context variable depending + on the platform.""" -def current_hook() -> LifeCycleHook: - """Get the current :class:`LifeCycleHook`""" - hook_stack = _HOOK_STATE.get() - if not hook_stack: - msg = "No life cycle hook is active. Are you rendering in a layout?" - raise RuntimeError(msg) - return hook_stack[-1] + _state: ThreadLocal[list[LifeCycleHook]] | ContextVar[list[LifeCycleHook]] = ( + ThreadLocal(list) if sys.platform == "emscripten" else ContextVar("hook_state") + ) + + def get(self) -> list[LifeCycleHook]: + return self._state.get() + + def initialize(self) -> Token[list[LifeCycleHook]] | None: + return None if isinstance(self._state, ThreadLocal) else self._state.set([]) + + def reset(self, token: Token[list[LifeCycleHook]] | None) -> None: + if isinstance(self._state, ThreadLocal): + self._state.get().clear() + elif token: + self._state.reset(token) + else: + raise RuntimeError("Hook stack is an ContextVar but no token was provided") + + def current_hook(self) -> LifeCycleHook: + hook_stack = self.get() + if not hook_stack: + msg = "No life cycle hook is active. Are you rendering in a layout?" + raise RuntimeError(msg) + return hook_stack[-1] + + +HOOK_STACK = _HookStack() class LifeCycleHook: @@ -37,7 +63,7 @@ class LifeCycleHook: a component is first rendered until it is removed from the layout. The life cycle is ultimately driven by the layout itself, but components can "hook" into those events to perform actions. Components gain access to their own life cycle hook - by calling :func:`current_hook`. They can then perform actions such as: + by calling :func:`HOOK_STACK.current_hook`. They can then perform actions such as: 1. Adding state via :meth:`use_state` 2. Adding effects via :meth:`add_effect` @@ -57,7 +83,7 @@ class LifeCycleHook: .. testcode:: from reactpy.core._life_cycle_hook import LifeCycleHook - from reactpy.core.hooks import current_hook + from reactpy.core.hooks import HOOK_STACK # this function will come from a layout implementation schedule_render = lambda: ... @@ -75,15 +101,15 @@ class LifeCycleHook: ... # the component may access the current hook - assert current_hook() is hook + assert HOOK_STACK.current_hook() is hook # and save state or add effects - current_hook().use_state(lambda: ...) + HOOK_STACK.current_hook().use_state(lambda: ...) async def my_effect(stop_event): ... - current_hook().add_effect(my_effect) + HOOK_STACK.current_hook().add_effect(my_effect) finally: await hook.affect_component_did_render() @@ -130,7 +156,7 @@ def __init__( self._scheduled_render = False self._rendered_atleast_once = False self._current_state_index = 0 - self._state: tuple[Any, ...] = () + self._state: list = [] self._effect_funcs: list[EffectFunc] = [] self._effect_tasks: list[Task[None]] = [] self._effect_stops: list[Event] = [] @@ -157,7 +183,7 @@ def use_state(self, function: Callable[[], T]) -> T: if not self._rendered_atleast_once: # since we're not initialized yet we're just appending state result = function() - self._state += (result,) + self._state.append(result) else: # once finalized we iterate over each succesively used piece of state result = self._state[self._current_state_index] @@ -232,7 +258,7 @@ def set_current(self) -> None: This method is called by a layout before entering the render method of this hook's associated component. """ - hook_stack = _HOOK_STATE.get() + hook_stack = HOOK_STACK.get() if hook_stack: parent = hook_stack[-1] self._context_providers.update(parent._context_providers) @@ -240,5 +266,5 @@ def set_current(self) -> None: def unset_current(self) -> None: """Unset this hook as the active hook in this thread""" - if _HOOK_STATE.get().pop() is not self: + if HOOK_STACK.get().pop() is not self: raise RuntimeError("Hook stack is in an invalid state") # nocov diff --git a/src/reactpy/core/_thread_local.py b/src/reactpy/core/_thread_local.py index b3d6a14b0..eb582e8e8 100644 --- a/src/reactpy/core/_thread_local.py +++ b/src/reactpy/core/_thread_local.py @@ -5,8 +5,10 @@ _StateType = TypeVar("_StateType") -class ThreadLocal(Generic[_StateType]): - """Utility for managing per-thread state information""" +class ThreadLocal(Generic[_StateType]): # nocov + """Utility for managing per-thread state information. This is only used in + environments where ContextVars are not available, such as the `pyodide` + executor.""" def __init__(self, default: Callable[[], _StateType]): self._default = default diff --git a/src/reactpy/core/component.py b/src/reactpy/core/component.py index 19eb99a94..d2cfcfe31 100644 --- a/src/reactpy/core/component.py +++ b/src/reactpy/core/component.py @@ -4,7 +4,7 @@ from functools import wraps from typing import Any, Callable -from reactpy.core.types import ComponentType, VdomDict +from reactpy.types import ComponentType, VdomDict def component( diff --git a/src/reactpy/core/events.py b/src/reactpy/core/events.py index e906cefe8..fc6eca04f 100644 --- a/src/reactpy/core/events.py +++ b/src/reactpy/core/events.py @@ -6,7 +6,7 @@ from anyio import create_task_group -from reactpy.core.types import EventHandlerFunc, EventHandlerType +from reactpy.types import EventHandlerFunc, EventHandlerType @overload diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 5a3c9fd13..8fc7db703 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -2,7 +2,7 @@ import asyncio import contextlib -from collections.abc import Coroutine, MutableMapping, Sequence +from collections.abc import Coroutine, Sequence from logging import getLogger from types import FunctionType from typing import ( @@ -18,24 +18,33 @@ from typing_extensions import TypeAlias -from reactpy.backend.types import Connection, Location -from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core._life_cycle_hook import current_hook -from reactpy.core.types import Context, Key, State, VdomDict +from reactpy.config import REACTPY_DEBUG +from reactpy.core._life_cycle_hook import HOOK_STACK +from reactpy.types import ( + Connection, + Context, + Key, + Location, + State, + VdomDict, +) from reactpy.utils import Ref if not TYPE_CHECKING: - # make flake8 think that this variable exists ellipsis = type(...) +if TYPE_CHECKING: + from asgiref import typing as asgi_types + __all__ = [ - "use_state", + "use_async_effect", + "use_callback", "use_effect", + "use_memo", "use_reducer", - "use_callback", "use_ref", - "use_memo", + "use_state", ] logger = getLogger(__name__) @@ -64,7 +73,9 @@ def use_state(initial_value: _Type | Callable[[], _Type]) -> State[_Type]: A tuple containing the current state and a function to update it. """ current_state = _use_const(lambda: _CurrentState(initial_value)) - return State(current_state.value, current_state.dispatch) + + # FIXME: Not sure why this type hint is not being inferred correctly when using pyright + return State(current_state.value, current_state.dispatch) # type: ignore class _CurrentState(Generic[_Type]): @@ -79,13 +90,10 @@ def __init__( else: self.value = initial_value - hook = current_hook() + hook = HOOK_STACK.current_hook() def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: - if callable(new): - next_value = new(self.value) - else: - next_value = new + next_value = new(self.value) if callable(new) else new # type: ignore if not strictly_equal(next_value, self.value): self.value = next_value hook.schedule_render() @@ -110,16 +118,21 @@ def use_effect( @overload def use_effect( - function: _EffectApplyFunc, + function: _SyncEffectFunc, dependencies: Sequence[Any] | ellipsis | None = ..., ) -> None: ... def use_effect( - function: _EffectApplyFunc | None = None, + function: _SyncEffectFunc | None = None, dependencies: Sequence[Any] | ellipsis | None = ..., -) -> Callable[[_EffectApplyFunc], None] | None: - """See the full :ref:`Use Effect` docs for details +) -> Callable[[_SyncEffectFunc], None] | None: + """ + A hook that manages an synchronous side effect in a React-like component. + + This hook allows you to run a synchronous function as a side effect and + ensures that the effect is properly cleaned up when the component is + re-rendered or unmounted. Parameters: function: @@ -133,49 +146,117 @@ def use_effect( Returns: If not function is provided, a decorator. Otherwise ``None``. """ - hook = current_hook() - + hook = HOOK_STACK.current_hook() dependencies = _try_to_infer_closure_values(function, dependencies) memoize = use_memo(dependencies=dependencies) - last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None) + cleanup_func: Ref[_EffectCleanFunc | None] = use_ref(None) - def add_effect(function: _EffectApplyFunc) -> None: - if not asyncio.iscoroutinefunction(function): - sync_function = cast(_SyncEffectFunc, function) - else: - async_function = cast(_AsyncEffectFunc, function) + def decorator(func: _SyncEffectFunc) -> None: + async def effect(stop: asyncio.Event) -> None: + # Since the effect is asynchronous, we need to make sure we + # always clean up the previous effect's resources + run_effect_cleanup(cleanup_func) + + # Execute the effect and store the clean-up function + cleanup_func.current = func() + + # Wait until we get the signal to stop this effect + await stop.wait() + + # Run the clean-up function when the effect is stopped, + # if it hasn't been run already by a new effect + run_effect_cleanup(cleanup_func) + + return memoize(lambda: hook.add_effect(effect)) + + # Handle decorator usage + if function: + decorator(function) + return None + return decorator + + +@overload +def use_async_effect( + function: None = None, + dependencies: Sequence[Any] | ellipsis | None = ..., + shutdown_timeout: float = 0.1, +) -> Callable[[_EffectApplyFunc], None]: ... - def sync_function() -> _EffectCleanFunc | None: - task = asyncio.create_task(async_function()) - def clean_future() -> None: - if not task.cancel(): - try: - clean = task.result() - except asyncio.CancelledError: - pass - else: - if clean is not None: - clean() +@overload +def use_async_effect( + function: _AsyncEffectFunc, + dependencies: Sequence[Any] | ellipsis | None = ..., + shutdown_timeout: float = 0.1, +) -> None: ... - return clean_future +def use_async_effect( + function: _AsyncEffectFunc | None = None, + dependencies: Sequence[Any] | ellipsis | None = ..., + shutdown_timeout: float = 0.1, +) -> Callable[[_AsyncEffectFunc], None] | None: + """ + A hook that manages an asynchronous side effect in a React-like component. + + This hook allows you to run an asynchronous function as a side effect and + ensures that the effect is properly cleaned up when the component is + re-rendered or unmounted. + + Args: + function: + Applies the effect and can return a clean-up function + dependencies: + Dependencies for the effect. The effect will only trigger if the identity + of any value in the given sequence changes (i.e. their :func:`id` is + different). By default these are inferred based on local variables that are + referenced by the given function. + shutdown_timeout: + The amount of time (in seconds) to wait for the effect to complete before + forcing a shutdown. + + Returns: + If not function is provided, a decorator. Otherwise ``None``. + """ + hook = HOOK_STACK.current_hook() + dependencies = _try_to_infer_closure_values(function, dependencies) + memoize = use_memo(dependencies=dependencies) + cleanup_func: Ref[_EffectCleanFunc | None] = use_ref(None) + + def decorator(func: _AsyncEffectFunc) -> None: async def effect(stop: asyncio.Event) -> None: - if last_clean_callback.current is not None: - last_clean_callback.current() - last_clean_callback.current = None - clean = last_clean_callback.current = sync_function() + # Since the effect is asynchronous, we need to make sure we + # always clean up the previous effect's resources + run_effect_cleanup(cleanup_func) + + # Execute the effect in a background task + task = asyncio.create_task(func()) + + # Wait until we get the signal to stop this effect await stop.wait() - if clean is not None: - clean() + + # If renders are queued back-to-back, the effect might not have + # completed. So, we give the task a small amount of time to finish. + # If it manages to finish, we can obtain a clean-up function. + results, _ = await asyncio.wait([task], timeout=shutdown_timeout) + if results: + cleanup_func.current = results.pop().result() + + # Run the clean-up function when the effect is stopped, + # if it hasn't been run already by a new effect + run_effect_cleanup(cleanup_func) + + # Cancel the task if it's still running + task.cancel() return memoize(lambda: hook.add_effect(effect)) - if function is not None: - add_effect(function) + # Handle decorator usage + if function: + decorator(function) return None - else: - return add_effect + return decorator def use_debug_value( @@ -185,7 +266,7 @@ def use_debug_value( """Log debug information when the given message changes. .. note:: - This hook only logs if :data:`~reactpy.config.REACTPY_DEBUG_MODE` is active. + This hook only logs if :data:`~reactpy.config.REACTPY_DEBUG` is active. Unlike other hooks, a message is considered to have changed if the old and new values are ``!=``. Because this comparison is performed on every render of the @@ -204,9 +285,9 @@ def use_debug_value( memo_func = message if callable(message) else lambda: message new = use_memo(memo_func, dependencies) - if REACTPY_DEBUG_MODE.current and old.current != new: + if REACTPY_DEBUG.current and old.current != new: old.current = new - logger.debug(f"{current_hook().component} {new}") + logger.debug(f"{HOOK_STACK.current_hook().component} {new}") def create_context(default_value: _Type) -> Context[_Type]: @@ -234,7 +315,7 @@ def use_context(context: Context[_Type]) -> _Type: See the full :ref:`Use Context` docs for more information. """ - hook = current_hook() + hook = HOOK_STACK.current_hook() provider = hook.get_context_provider(context) if provider is None: @@ -263,13 +344,13 @@ def use_connection() -> Connection[Any]: return conn -def use_scope() -> MutableMapping[str, Any]: - """Get the current :class:`~reactpy.backend.types.Connection`'s scope.""" +def use_scope() -> dict[str, Any] | asgi_types.HTTPScope | asgi_types.WebSocketScope: + """Get the current :class:`~reactpy.types.Connection`'s scope.""" return use_connection().scope def use_location() -> Location: - """Get the current :class:`~reactpy.backend.types.Connection`'s location.""" + """Get the current :class:`~reactpy.types.Connection`'s location.""" return use_connection().location @@ -287,8 +368,8 @@ def __init__( self.value = value def render(self) -> VdomDict: - current_hook().set_context_provider(self) - return {"tagName": "", "children": self.children} + HOOK_STACK.current_hook().set_context_provider(self) + return VdomDict(tagName="", children=self.children) def __repr__(self) -> str: return f"ContextProvider({self.type})" @@ -436,8 +517,6 @@ def use_memo( else: changed = False - setup: Callable[[Callable[[], _Type]], _Type] - if changed: def setup(function: Callable[[], _Type]) -> _Type: @@ -449,10 +528,7 @@ def setup(function: Callable[[], _Type]) -> _Type: def setup(function: Callable[[], _Type]) -> _Type: return memo.value - if function is not None: - return setup(function) - else: - return setup + return setup(function) if function is not None else setup class _Memo(Generic[_Type]): @@ -485,7 +561,7 @@ def use_ref(initial_value: _Type) -> Ref[_Type]: def _use_const(function: Callable[[], _Type]) -> _Type: - return current_hook().use_state(function) + return HOOK_STACK.current_hook().use_state(function) def _try_to_infer_closure_values( @@ -535,7 +611,7 @@ def strictly_equal(x: Any, y: Any) -> bool: getattr(x.__code__, attr) == getattr(y.__code__, attr) for attr in dir(x.__code__) if attr.startswith("co_") - and attr not in {"co_positions", "co_linetable", "co_lines"} + and attr not in {"co_positions", "co_linetable", "co_lines", "co_lnotab"} ) # Check via the `==` operator if possible @@ -544,4 +620,10 @@ def strictly_equal(x: Any, y: Any) -> bool: return x == y # type: ignore # Fallback to identity check - return x is y + return x is y # nocov + + +def run_effect_cleanup(cleanup_func: Ref[_EffectCleanFunc | None]) -> None: + if cleanup_func.current: + cleanup_func.current() + cleanup_func.current = None diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 88cb2fa35..a81ecc6d7 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -14,6 +14,7 @@ from collections.abc import Sequence from contextlib import AsyncExitStack from logging import getLogger +from types import TracebackType from typing import ( Any, Callable, @@ -32,11 +33,13 @@ from reactpy.config import ( REACTPY_ASYNC_RENDERING, REACTPY_CHECK_VDOM_SPEC, - REACTPY_DEBUG_MODE, + REACTPY_DEBUG, ) from reactpy.core._life_cycle_hook import LifeCycleHook -from reactpy.core.types import ( +from reactpy.core.vdom import validate_vdom_json +from reactpy.types import ( ComponentType, + Context, EventHandlerDict, Key, LayoutEventMessage, @@ -45,7 +48,6 @@ VdomDict, VdomJson, ) -from reactpy.core.vdom import validate_vdom_json from reactpy.utils import Ref logger = getLogger(__name__) @@ -55,19 +57,19 @@ class Layout: """Responsible for "rendering" components. That is, turning them into VDOM.""" __slots__: tuple[str, ...] = ( - "root", "_event_handlers", - "_rendering_queue", + "_model_states_by_life_cycle_state_id", "_render_tasks", "_render_tasks_ready", + "_rendering_queue", "_root_life_cycle_state_id", - "_model_states_by_life_cycle_state_id", + "root", ) if not hasattr(abc.ABC, "__weakref__"): # nocov __slots__ += ("__weakref__",) - def __init__(self, root: ComponentType) -> None: + def __init__(self, root: ComponentType | Context[Any]) -> None: super().__init__() if not isinstance(root, ComponentType): msg = f"Expected a ComponentType, not {type(root)!r}." @@ -79,17 +81,17 @@ async def __aenter__(self) -> Layout: self._event_handlers: EventHandlerDict = {} self._render_tasks: set[Task[LayoutUpdateMessage]] = set() self._render_tasks_ready: Semaphore = Semaphore(0) - self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue() root_model_state = _new_root_model_state(self.root, self._schedule_render_task) - self._root_life_cycle_state_id = root_id = root_model_state.life_cycle_state.id self._model_states_by_life_cycle_state_id = {root_id: root_model_state} self._schedule_render_task(root_id) return self - async def __aexit__(self, *exc: object) -> None: + async def __aexit__( + self, exc_type: type[Exception], exc_value: Exception, traceback: TracebackType + ) -> None: root_csid = self._root_life_cycle_state_id root_model_state = self._model_states_by_life_cycle_state_id[root_csid] @@ -108,7 +110,7 @@ async def __aexit__(self, *exc: object) -> None: del self._root_life_cycle_state_id del self._model_states_by_life_cycle_state_id - async def deliver(self, event: LayoutEventMessage) -> None: + async def deliver(self, event: LayoutEventMessage | dict[str, Any]) -> None: """Dispatch an event to the targeted handler""" # It is possible for an element in the frontend to produce an event # associated with a backend model that has been deleted. We only handle @@ -194,16 +196,14 @@ async def _render_component( # wrap the model in a fragment (i.e. tagName="") to ensure components have # a separate node in the model state tree. This could be removed if this # components are given a node in the tree some other way - wrapper_model: VdomDict = {"tagName": "", "children": [raw_model]} + wrapper_model = VdomDict(tagName="", children=[raw_model]) await self._render_model(exit_stack, old_state, new_state, wrapper_model) except Exception as error: logger.exception(f"Failed to render {component}") new_state.model.current = { "tagName": "", "error": ( - f"{type(error).__name__}: {error}" - if REACTPY_DEBUG_MODE.current - else "" + f"{type(error).__name__}: {error}" if REACTPY_DEBUG.current else "" ), } finally: @@ -218,7 +218,7 @@ async def _render_component( parent.children_by_key[key] = new_state # need to add this model to parent's children without mutating parent model old_parent_model = parent.model.current - old_parent_children = old_parent_model["children"] + old_parent_children = old_parent_model.setdefault("children", []) parent.model.current = { **old_parent_model, "children": [ @@ -262,6 +262,10 @@ def _render_model_attributes( attrs = raw_model["attributes"].copy() new_state.model.current["attributes"] = attrs + if "inlineJavaScript" in raw_model: + inline_javascript = raw_model["inlineJavaScript"].copy() + new_state.model.current["inlineJavaScript"] = inline_javascript + if old_state is None: self._render_model_event_handlers_without_old_state( new_state, handlers_by_event @@ -319,8 +323,11 @@ async def _render_model_children( new_state: _ModelState, raw_children: Any, ) -> None: - if not isinstance(raw_children, (list, tuple)): - raw_children = [raw_children] + if not isinstance(raw_children, list): + if isinstance(raw_children, tuple): + raw_children = list(raw_children) + else: + raw_children = [raw_children] if old_state is None: if raw_children: @@ -610,7 +617,7 @@ def __init__( parent: _ModelState | None, index: int, key: Any, - model: Ref[VdomJson], + model: Ref[VdomJson | dict[str, Any]], patch_path: str, children_by_key: dict[Key, _ModelState], targets_by_event: dict[str, str], @@ -657,7 +664,7 @@ def parent(self) -> _ModelState: return parent def append_child(self, child: Any) -> None: - self.model.current["children"].append(child) + self.model.current.setdefault("children", []).append(child) def __repr__(self) -> str: # nocov return f"ModelState({ {s: getattr(self, s, None) for s in self.__slots__} })" diff --git a/src/reactpy/core/serve.py b/src/reactpy/core/serve.py index 3a540af59..a6397eee8 100644 --- a/src/reactpy/core/serve.py +++ b/src/reactpy/core/serve.py @@ -2,22 +2,23 @@ from collections.abc import Awaitable from logging import getLogger -from typing import Callable +from typing import Any, Callable from warnings import warn from anyio import create_task_group from anyio.abc import TaskGroup -from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage +from reactpy.config import REACTPY_DEBUG +from reactpy.core._life_cycle_hook import HOOK_STACK +from reactpy.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage logger = getLogger(__name__) -SendCoroutine = Callable[[LayoutUpdateMessage], Awaitable[None]] +SendCoroutine = Callable[[LayoutUpdateMessage | dict[str, Any]], Awaitable[None]] """Send model patches given by a dispatcher""" -RecvCoroutine = Callable[[], Awaitable[LayoutEventMessage]] +RecvCoroutine = Callable[[], Awaitable[LayoutEventMessage | dict[str, Any]]] """Called by a dispatcher to return a :class:`reactpy.core.layout.LayoutEventMessage` The event will then trigger an :class:`reactpy.core.proto.EventHandlerType` in a layout. @@ -35,7 +36,9 @@ class Stop(BaseException): async def serve_layout( - layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], + layout: LayoutType[ + LayoutUpdateMessage | dict[str, Any], LayoutEventMessage | dict[str, Any] + ], send: SendCoroutine, recv: RecvCoroutine, ) -> None: @@ -55,26 +58,35 @@ async def serve_layout( async def _single_outgoing_loop( - layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], send: SendCoroutine + layout: LayoutType[ + LayoutUpdateMessage | dict[str, Any], LayoutEventMessage | dict[str, Any] + ], + send: SendCoroutine, ) -> None: while True: - update = await layout.render() + token = HOOK_STACK.initialize() try: - await send(update) - except Exception: # nocov - if not REACTPY_DEBUG_MODE.current: - msg = ( - "Failed to send update. More info may be available " - "if you enabling debug mode by setting " - "`reactpy.config.REACTPY_DEBUG_MODE.current = True`." - ) - logger.error(msg) - raise + update = await layout.render() + try: + await send(update) + except Exception: # nocov + if not REACTPY_DEBUG.current: + msg = ( + "Failed to send update. More info may be available " + "if you enabling debug mode by setting " + "`reactpy.config.REACTPY_DEBUG.current = True`." + ) + logger.error(msg) + raise + finally: + HOOK_STACK.reset(token) async def _single_incoming_loop( task_group: TaskGroup, - layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], + layout: LayoutType[ + LayoutUpdateMessage | dict[str, Any], LayoutEventMessage | dict[str, Any] + ], recv: RecvCoroutine, ) -> None: while True: diff --git a/src/reactpy/core/types.py b/src/reactpy/core/types.py deleted file mode 100644 index b451be30a..000000000 --- a/src/reactpy/core/types.py +++ /dev/null @@ -1,248 +0,0 @@ -from __future__ import annotations - -import sys -from collections import namedtuple -from collections.abc import Mapping, Sequence -from types import TracebackType -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Generic, - Literal, - NamedTuple, - Protocol, - TypeVar, - overload, - runtime_checkable, -) - -from typing_extensions import TypeAlias, TypedDict - -_Type = TypeVar("_Type") - - -if TYPE_CHECKING or sys.version_info < (3, 9) or sys.version_info >= (3, 11): - - class State(NamedTuple, Generic[_Type]): - value: _Type - set_value: Callable[[_Type | Callable[[_Type], _Type]], None] - -else: # nocov - State = namedtuple("State", ("value", "set_value")) - - -ComponentConstructor = Callable[..., "ComponentType"] -"""Simple function returning a new component""" - -RootComponentConstructor = Callable[[], "ComponentType"] -"""The root component should be constructed by a function accepting no arguments.""" - - -Key: TypeAlias = "str | int" - - -_OwnType = TypeVar("_OwnType") - - -@runtime_checkable -class ComponentType(Protocol): - """The expected interface for all component-like objects""" - - key: Key | None - """An identifier which is unique amongst a component's immediate siblings""" - - type: Any - """The function or class defining the behavior of this component - - This is used to see if two component instances share the same definition. - """ - - def render(self) -> VdomDict | ComponentType | str | None: - """Render the component's view model.""" - - -_Render_co = TypeVar("_Render_co", covariant=True) -_Event_contra = TypeVar("_Event_contra", contravariant=True) - - -@runtime_checkable -class LayoutType(Protocol[_Render_co, _Event_contra]): - """Renders and delivers, updates to views and events to handlers, respectively""" - - async def render(self) -> _Render_co: - """Render an update to a view""" - - async def deliver(self, event: _Event_contra) -> None: - """Relay an event to its respective handler""" - - async def __aenter__(self) -> LayoutType[_Render_co, _Event_contra]: - """Prepare the layout for its first render""" - - async def __aexit__( - self, - exc_type: type[Exception], - exc_value: Exception, - traceback: TracebackType, - ) -> bool | None: - """Clean up the view after its final render""" - - -VdomAttributes = Mapping[str, Any] -"""Describes the attributes of a :class:`VdomDict`""" - -VdomChild: TypeAlias = "ComponentType | VdomDict | str | None | Any" -"""A single child element of a :class:`VdomDict`""" - -VdomChildren: TypeAlias = "Sequence[VdomChild] | VdomChild" -"""Describes a series of :class:`VdomChild` elements""" - - -class _VdomDictOptional(TypedDict, total=False): - key: Key | None - children: Sequence[ComponentType | VdomChild] - attributes: VdomAttributes - eventHandlers: EventHandlerDict - importSource: ImportSourceDict - - -class _VdomDictRequired(TypedDict, total=True): - tagName: str - - -class VdomDict(_VdomDictRequired, _VdomDictOptional): - """A :ref:`VDOM` dictionary""" - - -class ImportSourceDict(TypedDict): - source: str - fallback: Any - sourceType: str - unmountBeforeUpdate: bool - - -class _OptionalVdomJson(TypedDict, total=False): - key: Key - error: str - children: list[Any] - attributes: dict[str, Any] - eventHandlers: dict[str, _JsonEventTarget] - importSource: _JsonImportSource - - -class _RequiredVdomJson(TypedDict, total=True): - tagName: str - - -class VdomJson(_RequiredVdomJson, _OptionalVdomJson): - """A JSON serializable form of :class:`VdomDict` matching the :data:`VDOM_JSON_SCHEMA`""" - - -class _JsonEventTarget(TypedDict): - target: str - preventDefault: bool - stopPropagation: bool - - -class _JsonImportSource(TypedDict): - source: str - fallback: Any - - -EventHandlerMapping = Mapping[str, "EventHandlerType"] -"""A generic mapping between event names to their handlers""" - -EventHandlerDict: TypeAlias = "dict[str, EventHandlerType]" -"""A dict mapping between event names to their handlers""" - - -class EventHandlerFunc(Protocol): - """A coroutine which can handle event data""" - - async def __call__(self, data: Sequence[Any]) -> None: ... - - -@runtime_checkable -class EventHandlerType(Protocol): - """Defines a handler for some event""" - - prevent_default: bool - """Whether to block the event from propagating further up the DOM""" - - stop_propagation: bool - """Stops the default action associate with the event from taking place.""" - - function: EventHandlerFunc - """A coroutine which can respond to an event and its data""" - - target: str | None - """Typically left as ``None`` except when a static target is useful. - - When testing, it may be useful to specify a static target ID so events can be - triggered programmatically. - - .. note:: - - When ``None``, it is left to a :class:`LayoutType` to auto generate a unique ID. - """ - - -class VdomDictConstructor(Protocol): - """Standard function for constructing a :class:`VdomDict`""" - - @overload - def __call__( - self, attributes: VdomAttributes, *children: VdomChildren - ) -> VdomDict: ... - - @overload - def __call__(self, *children: VdomChildren) -> VdomDict: ... - - @overload - def __call__( - self, *attributes_and_children: VdomAttributes | VdomChildren - ) -> VdomDict: ... - - -class LayoutUpdateMessage(TypedDict): - """A message describing an update to a layout""" - - type: Literal["layout-update"] - """The type of message""" - path: str - """JSON Pointer path to the model element being updated""" - model: VdomJson - """The model to assign at the given JSON Pointer path""" - - -class LayoutEventMessage(TypedDict): - """Message describing an event originating from an element in the layout""" - - type: Literal["layout-event"] - """The type of message""" - target: str - """The ID of the event handler.""" - data: Sequence[Any] - """A list of event data passed to the event handler.""" - - -class Context(Protocol[_Type]): - """Returns a :class:`ContextProvider` component""" - - def __call__( - self, - *children: Any, - value: _Type = ..., - key: Key | None = ..., - ) -> ContextProviderType[_Type]: ... - - -class ContextProviderType(ComponentType, Protocol[_Type]): - """A component which provides a context value to its children""" - - type: Context[_Type] - """The context type""" - - @property - def value(self) -> _Type: - "Current context value" diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index dfff32805..8d70af53d 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -1,30 +1,39 @@ +# pyright: reportIncompatibleMethodOverride=false from __future__ import annotations import json +import re from collections.abc import Mapping, Sequence -from functools import wraps -from typing import Any, Protocol, cast, overload +from typing import ( + Any, + Callable, + cast, + overload, +) from fastjsonschema import compile as compile_json_schema from reactpy._warnings import warn -from reactpy.config import REACTPY_CHECK_JSON_ATTRS, REACTPY_DEBUG_MODE +from reactpy.config import REACTPY_CHECK_JSON_ATTRS, REACTPY_DEBUG from reactpy.core._f_back import f_module_name from reactpy.core.events import EventHandler, to_event_handler_function -from reactpy.core.types import ( +from reactpy.types import ( ComponentType, + CustomVdomConstructor, + EllipsisRepr, EventHandlerDict, EventHandlerType, ImportSourceDict, - Key, + InlineJavaScript, + InlineJavaScriptDict, VdomAttributes, - VdomChild, VdomChildren, VdomDict, - VdomDictConstructor, VdomJson, ) +EVENT_ATTRIBUTE_PATTERN = re.compile(r"^on[A-Z]\w+") + VDOM_JSON_SCHEMA = { "$schema": "http://json-schema.org/draft-07/schema", "$ref": "#/definitions/element", @@ -38,6 +47,7 @@ "children": {"$ref": "#/definitions/elementChildren"}, "attributes": {"type": "object"}, "eventHandlers": {"$ref": "#/definitions/elementEventHandlers"}, + "inlineJavaScript": {"$ref": "#/definitions/elementInlineJavaScripts"}, "importSource": {"$ref": "#/definitions/importSource"}, }, # The 'tagName' is required because its presence is a useful indicator of @@ -67,6 +77,12 @@ }, "required": ["target"], }, + "elementInlineJavaScripts": { + "type": "object", + "patternProperties": { + ".*": "str", + }, + }, "importSource": { "type": "object", "properties": { @@ -92,7 +108,7 @@ # we can't add a docstring to this because Sphinx doesn't know how to find its source -_COMPILED_VDOM_VALIDATOR = compile_json_schema(VDOM_JSON_SCHEMA) +_COMPILED_VDOM_VALIDATOR: Callable = compile_json_schema(VDOM_JSON_SCHEMA) # type: ignore def validate_vdom_json(value: Any) -> VdomJson: @@ -102,219 +118,153 @@ def validate_vdom_json(value: Any) -> VdomJson: def is_vdom(value: Any) -> bool: - """Return whether a value is a :class:`VdomDict` - - This employs a very simple heuristic - something is VDOM if: - - 1. It is a ``dict`` instance - 2. It contains the key ``"tagName"`` - 3. The value of the key ``"tagName"`` is a string - - .. note:: - - Performing an ``isinstance(value, VdomDict)`` check is too restrictive since the - user would be forced to import ``VdomDict`` every time they needed to declare a - VDOM element. Giving the user more flexibility, at the cost of this check's - accuracy, is worth it. - """ - return ( - isinstance(value, dict) - and "tagName" in value - and isinstance(value["tagName"], str) - ) - - -@overload -def vdom(tag: str, *children: VdomChildren) -> VdomDict: ... - - -@overload -def vdom(tag: str, attributes: VdomAttributes, *children: VdomChildren) -> VdomDict: ... - - -def vdom( - tag: str, - *attributes_and_children: Any, - **kwargs: Any, -) -> VdomDict: - """A helper function for creating VDOM elements. - - Parameters: - tag: - The type of element (e.g. 'div', 'h1', 'img') - attributes_and_children: - An optional attribute mapping followed by any number of children or - iterables of children. The attribute mapping **must** precede the children, - or children which will be merged into their respective parts of the model. - key: - A string indicating the identity of a particular element. This is significant - to preserve event handlers across updates - without a key, a re-render would - cause these handlers to be deleted, but with a key, they would be redirected - to any newly defined handlers. - event_handlers: - Maps event types to coroutines that are responsible for handling those events. - import_source: - (subject to change) specifies javascript that, when evaluated returns a - React component. - """ - if kwargs: # nocov - if "key" in kwargs: - if attributes_and_children: - maybe_attributes, *children = attributes_and_children - if _is_attributes(maybe_attributes): - attributes_and_children = ( - {**maybe_attributes, "key": kwargs.pop("key")}, - *children, - ) - else: - attributes_and_children = ( - {"key": kwargs.pop("key")}, - maybe_attributes, - *children, - ) - else: - attributes_and_children = ({"key": kwargs.pop("key")},) - warn( - "An element's 'key' must be declared in an attribute dict instead " - "of as a keyword argument. This will error in a future version.", - DeprecationWarning, - ) + """Return whether a value is a :class:`VdomDict`""" + return isinstance(value, VdomDict) - if kwargs: - msg = f"Extra keyword arguments {kwargs}" - raise ValueError(msg) - model: VdomDict = {"tagName": tag} +class Vdom: + """Class-based constructor for VDOM dictionaries. + Once initialized, the `__call__` method on this class is used as the user API + for `reactpy.html`.""" - if not attributes_and_children: - return model + def __init__( + self, + tag_name: str, + /, + allow_children: bool = True, + custom_constructor: CustomVdomConstructor | None = None, + import_source: ImportSourceDict | None = None, + ) -> None: + """Initialize a VDOM constructor for the provided `tag_name`.""" + self.allow_children = allow_children + self.custom_constructor = custom_constructor + self.import_source = import_source + + # Configure Python debugger attributes + self.__name__ = tag_name + module_name = f_module_name(1) + if module_name: + self.__module__ = module_name + self.__qualname__ = f"{module_name}.{tag_name}" + + def __getattr__(self, attr: str) -> Vdom: + """Supports accessing nested web module components""" + if not self.import_source: + msg = "Nested components can only be accessed on web module components." + raise AttributeError(msg) + return Vdom( + f"{self.__name__}.{attr}", + allow_children=self.allow_children, + import_source=self.import_source, + ) + + @overload + def __call__( + self, attributes: VdomAttributes, /, *children: VdomChildren + ) -> VdomDict: ... - attributes, children = separate_attributes_and_children(attributes_and_children) - key = attributes.pop("key", None) - attributes, event_handlers = separate_attributes_and_event_handlers(attributes) + @overload + def __call__(self, *children: VdomChildren) -> VdomDict: ... - if attributes: + def __call__( + self, *attributes_and_children: VdomAttributes | VdomChildren + ) -> VdomDict: + """The entry point for the VDOM API, for example reactpy.html().""" + attributes, children = separate_attributes_and_children(attributes_and_children) + key = attributes.get("key", None) + attributes, event_handlers, inline_javascript = ( + separate_attributes_handlers_and_inline_javascript(attributes) + ) if REACTPY_CHECK_JSON_ATTRS.current: json.dumps(attributes) - model["attributes"] = attributes - - if children: - model["children"] = children - - if key is not None: - model["key"] = key - if event_handlers: - model["eventHandlers"] = event_handlers - - return model - - -def make_vdom_constructor( - tag: str, allow_children: bool = True, import_source: ImportSourceDict | None = None -) -> VdomDictConstructor: - """Return a constructor for VDOM dictionaries with the given tag name. - - The resulting callable will have the same interface as :func:`vdom` but without its - first ``tag`` argument. - """ + # Run custom constructor, if defined + if self.custom_constructor: + result = self.custom_constructor( + key=key, + children=children, + attributes=attributes, + event_handlers=event_handlers, + ) - def constructor(*attributes_and_children: Any, **kwargs: Any) -> VdomDict: - model = vdom(tag, *attributes_and_children, **kwargs) - if not allow_children and "children" in model: - msg = f"{tag!r} nodes cannot have children." + # Otherwise, use the default constructor + else: + result = { + **({"key": key} if key is not None else {}), + **({"children": children} if children else {}), + **({"attributes": attributes} if attributes else {}), + **({"eventHandlers": event_handlers} if event_handlers else {}), + **( + {"inlineJavaScript": inline_javascript} if inline_javascript else {} + ), + **({"importSource": self.import_source} if self.import_source else {}), + } + + # Validate the result + result = result | {"tagName": self.__name__} + if children and not self.allow_children: + msg = f"{self.__name__!r} nodes cannot have children." raise TypeError(msg) - if import_source: - model["importSource"] = import_source - return model - - # replicate common function attributes - constructor.__name__ = tag - constructor.__doc__ = ( - "Return a new " - f"`<{tag}> `__ " - "element represented by a :class:`VdomDict`." - ) - - module_name = f_module_name(1) - if module_name: - constructor.__module__ = module_name - constructor.__qualname__ = f"{module_name}.{tag}" - return cast(VdomDictConstructor, constructor) - - -def custom_vdom_constructor(func: _CustomVdomDictConstructor) -> VdomDictConstructor: - """Cast function to VdomDictConstructor""" - - @wraps(func) - def wrapper(*attributes_and_children: Any) -> VdomDict: - attributes, children = separate_attributes_and_children(attributes_and_children) - key = attributes.pop("key", None) - attributes, event_handlers = separate_attributes_and_event_handlers(attributes) - return func(attributes, children, key, event_handlers) - - return cast(VdomDictConstructor, wrapper) + return VdomDict(**result) # type: ignore def separate_attributes_and_children( values: Sequence[Any], -) -> tuple[dict[str, Any], list[Any]]: +) -> tuple[VdomAttributes, list[Any]]: if not values: return {}, [] - attributes: dict[str, Any] + _attributes: VdomAttributes children_or_iterables: Sequence[Any] - if _is_attributes(values[0]): - attributes, *children_or_iterables = values + # ruff: noqa: E721 + if type(values[0]) is dict: + _attributes, *children_or_iterables = values else: - attributes = {} + _attributes = {} children_or_iterables = values - children: list[Any] = [] - for child in children_or_iterables: - if _is_single_child(child): - children.append(child) - else: - children.extend(child) + _children: list[Any] = _flatten_children(children_or_iterables) - return attributes, children + return _attributes, _children -def separate_attributes_and_event_handlers( +def separate_attributes_handlers_and_inline_javascript( attributes: Mapping[str, Any], -) -> tuple[dict[str, Any], EventHandlerDict]: - separated_attributes = {} - separated_event_handlers: dict[str, EventHandlerType] = {} +) -> tuple[VdomAttributes, EventHandlerDict, InlineJavaScriptDict]: + _attributes: VdomAttributes = {} + _event_handlers: dict[str, EventHandlerType] = {} + _inline_javascript: dict[str, InlineJavaScript] = {} for k, v in attributes.items(): - handler: EventHandlerType - if callable(v): - handler = EventHandler(to_event_handler_function(v)) - elif ( - # isinstance check on protocols is slow - use function attr pre-check as a - # quick filter before actually performing slow EventHandlerType type check - hasattr(v, "function") and isinstance(v, EventHandlerType) - ): - handler = v + _event_handlers[k] = EventHandler(to_event_handler_function(v)) + elif isinstance(v, EventHandler): + _event_handlers[k] = v + elif EVENT_ATTRIBUTE_PATTERN.match(k) and isinstance(v, str): + _inline_javascript[k] = InlineJavaScript(v) + elif isinstance(v, InlineJavaScript): + _inline_javascript[k] = v else: - separated_attributes[k] = v - continue + _attributes[k] = v - separated_event_handlers[k] = handler + return _attributes, _event_handlers, _inline_javascript - return separated_attributes, dict(separated_event_handlers.items()) - -def _is_attributes(value: Any) -> bool: - return isinstance(value, Mapping) and "tagName" not in value +def _flatten_children(children: Sequence[Any]) -> list[Any]: + _children: list[VdomChildren] = [] + for child in children: + if _is_single_child(child): + _children.append(child) + else: + _children.extend(_flatten_children(child)) + return _children def _is_single_child(value: Any) -> bool: if isinstance(value, (str, Mapping)) or not hasattr(value, "__iter__"): return True - if REACTPY_DEBUG_MODE.current: + if REACTPY_DEBUG.current: _validate_child_key_integrity(value) return False @@ -331,20 +281,5 @@ def _validate_child_key_integrity(value: Any) -> None: warn(f"Key not specified for child in list {child}", UserWarning) elif isinstance(child, Mapping) and "key" not in child: # remove 'children' to reduce log spam - child_copy = {**child, "children": _EllipsisRepr()} + child_copy = {**child, "children": EllipsisRepr()} warn(f"Key not specified for child in list {child_copy}", UserWarning) - - -class _CustomVdomDictConstructor(Protocol): - def __call__( - self, - attributes: VdomAttributes, - children: Sequence[VdomChild], - key: Key | None, - event_handlers: EventHandlerDict, - ) -> VdomDict: ... - - -class _EllipsisRepr: - def __repr__(self) -> str: - return "..." diff --git a/tests/test_backend/__init__.py b/src/reactpy/executors/__init__.py similarity index 100% rename from tests/test_backend/__init__.py rename to src/reactpy/executors/__init__.py diff --git a/src/reactpy/executors/asgi/__init__.py b/src/reactpy/executors/asgi/__init__.py new file mode 100644 index 000000000..e7c9716af --- /dev/null +++ b/src/reactpy/executors/asgi/__init__.py @@ -0,0 +1,5 @@ +from reactpy.executors.asgi.middleware import ReactPyMiddleware +from reactpy.executors.asgi.pyscript import ReactPyPyscript +from reactpy.executors.asgi.standalone import ReactPy + +__all__ = ["ReactPy", "ReactPyMiddleware", "ReactPyPyscript"] diff --git a/src/reactpy/executors/asgi/middleware.py b/src/reactpy/executors/asgi/middleware.py new file mode 100644 index 000000000..976119c8f --- /dev/null +++ b/src/reactpy/executors/asgi/middleware.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import asyncio +import logging +import re +import traceback +import urllib.parse +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import orjson +from asgi_tools import ResponseText, ResponseWebSocket +from asgiref.compatibility import guarantee_single_callable +from servestatic import ServeStaticASGI +from typing_extensions import Unpack + +from reactpy import config +from reactpy.core.hooks import ConnectionContext +from reactpy.core.layout import Layout +from reactpy.core.serve import serve_layout +from reactpy.executors.asgi.types import ( + AsgiApp, + AsgiHttpReceive, + AsgiHttpScope, + AsgiHttpSend, + AsgiReceive, + AsgiScope, + AsgiSend, + AsgiV3App, + AsgiV3HttpApp, + AsgiV3LifespanApp, + AsgiV3WebsocketApp, + AsgiWebsocketReceive, + AsgiWebsocketScope, + AsgiWebsocketSend, +) +from reactpy.executors.utils import check_path, import_components, process_settings +from reactpy.types import Connection, Location, ReactPyConfig, RootComponentConstructor + +_logger = logging.getLogger(__name__) + + +class ReactPyMiddleware: + root_component: RootComponentConstructor | None = None + root_components: dict[str, RootComponentConstructor] + multiple_root_components: bool = True + + def __init__( + self, + app: AsgiApp, + root_components: Iterable[str], + **settings: Unpack[ReactPyConfig], + ) -> None: + """Configure the ASGI app. Anything initialized in this method will be shared across all future requests. + + Parameters: + app: The ASGI application to serve when the request does not match a ReactPy route. + root_components: + A list, set, or tuple containing the dotted path of your root components. This dotted path + must be valid to Python's import system. + settings: Global ReactPy configuration settings that affect behavior and performance. + """ + # Validate the configuration + if "path_prefix" in settings: + reason = check_path(settings["path_prefix"]) + if reason: + raise ValueError( + f'Invalid `path_prefix` of "{settings["path_prefix"]}". {reason}' + ) + if "web_modules_dir" in settings and not settings["web_modules_dir"].exists(): + raise ValueError( + f'Web modules directory "{settings["web_modules_dir"]}" does not exist.' + ) + + # Process global settings + process_settings(settings) + + # URL path attributes + self.path_prefix = config.REACTPY_PATH_PREFIX.current + self.dispatcher_path = self.path_prefix + self.web_modules_path = f"{self.path_prefix}modules/" + self.static_path = f"{self.path_prefix}static/" + self.dispatcher_pattern = re.compile( + f"^{self.dispatcher_path}(?P[a-zA-Z0-9_.]+)/$" + ) + + # User defined ASGI apps + self.extra_http_routes: dict[str, AsgiV3HttpApp] = {} + self.extra_ws_routes: dict[str, AsgiV3WebsocketApp] = {} + self.extra_lifespan_app: AsgiV3LifespanApp | None = None + + # Component attributes + self.asgi_app: AsgiV3App = guarantee_single_callable(app) # type: ignore + self.root_components = import_components(root_components) + + # Directory attributes + self.web_modules_dir = config.REACTPY_WEB_MODULES_DIR.current + self.static_dir = Path(__file__).parent.parent.parent / "static" + + # Initialize the sub-applications + self.component_dispatch_app = ComponentDispatchApp(parent=self) + self.static_file_app = StaticFileApp(parent=self) + self.web_modules_app = WebModuleApp(parent=self) + + async def __call__( + self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend + ) -> None: + """The ASGI entrypoint that determines whether ReactPy should route the + request to ourselves or to the user application.""" + # URL routing for the ReactPy renderer + if scope["type"] == "websocket" and self.match_dispatch_path(scope): + return await self.component_dispatch_app(scope, receive, send) + + # URL routing for ReactPy static files + if scope["type"] == "http" and self.match_static_path(scope): + return await self.static_file_app(scope, receive, send) + + # URL routing for ReactPy web modules + if scope["type"] == "http" and self.match_web_modules_path(scope): + return await self.web_modules_app(scope, receive, send) + + # URL routing for user-defined routes + matched_app = self.match_extra_paths(scope) + if matched_app: + return await matched_app(scope, receive, send) # type: ignore + + # Serve the user's application + await self.asgi_app(scope, receive, send) + + def match_dispatch_path(self, scope: AsgiWebsocketScope) -> bool: + return bool(re.match(self.dispatcher_pattern, scope["path"])) + + def match_static_path(self, scope: AsgiHttpScope) -> bool: + return scope["path"].startswith(self.static_path) + + def match_web_modules_path(self, scope: AsgiHttpScope) -> bool: + return scope["path"].startswith(self.web_modules_path) + + def match_extra_paths(self, scope: AsgiScope) -> AsgiApp | None: + # Custom defined routes are unused by default to encourage users to handle + # routing within their ASGI framework of choice. + return None + + +@dataclass +class ComponentDispatchApp: + parent: ReactPyMiddleware + + async def __call__( + self, + scope: AsgiWebsocketScope, + receive: AsgiWebsocketReceive, + send: AsgiWebsocketSend, + ) -> None: + """ASGI app for rendering ReactPy Python components.""" + # Start a loop that handles ASGI websocket events + async with ReactPyWebsocket(scope, receive, send, parent=self.parent) as ws: + while True: + # Wait for the webserver to notify us of a new event + event: dict[str, Any] = await ws.receive(raw=True) # type: ignore + + # If the event is a `receive` event, parse the message and send it to the rendering queue + if event["type"] == "websocket.receive": + msg: dict[str, str] = orjson.loads(event["text"]) + if msg.get("type") == "layout-event": + await ws.rendering_queue.put(msg) + else: # nocov + await asyncio.to_thread( + _logger.warning, f"Unknown message type: {msg.get('type')}" + ) + + # If the event is a `disconnect` event, break the rendering loop and close the connection + elif event["type"] == "websocket.disconnect": + break + + +class ReactPyWebsocket(ResponseWebSocket): + def __init__( + self, + scope: AsgiWebsocketScope, + receive: AsgiWebsocketReceive, + send: AsgiWebsocketSend, + parent: ReactPyMiddleware, + ) -> None: + super().__init__(scope=scope, receive=receive, send=send) # type: ignore + self.scope = scope + self.parent = parent + self.rendering_queue: asyncio.Queue[dict[str, str]] = asyncio.Queue() + self.dispatcher: asyncio.Task[Any] | None = None + + async def __aenter__(self) -> ReactPyWebsocket: + self.dispatcher = asyncio.create_task(self.run_dispatcher()) + return await super().__aenter__() # type: ignore + + async def __aexit__(self, *_: Any) -> None: + if self.dispatcher: + self.dispatcher.cancel() + await super().__aexit__() # type: ignore + + async def run_dispatcher(self) -> None: + """Async background task that renders ReactPy components over a websocket.""" + try: + # Determine component to serve by analyzing the URL and/or class parameters. + if self.parent.multiple_root_components: + url_match = re.match(self.parent.dispatcher_pattern, self.scope["path"]) + if not url_match: # nocov + raise RuntimeError("Could not find component in URL path.") + dotted_path = url_match["dotted_path"] + if dotted_path not in self.parent.root_components: + raise RuntimeError( + f"Attempting to use an unregistered root component {dotted_path}." + ) + component = self.parent.root_components[dotted_path] + elif self.parent.root_component: + component = self.parent.root_component + else: # nocov + raise RuntimeError("No root component provided.") + + # Create a connection object by analyzing the websocket's query string. + ws_query_string = urllib.parse.parse_qs( + self.scope["query_string"].decode(), strict_parsing=True + ) + connection = Connection( + scope=self.scope, # type: ignore + location=Location( + path=ws_query_string.get("http_pathname", [""])[0], + query_string=ws_query_string.get("http_query_string", [""])[0], + ), + carrier=self, + ) + + # Start the ReactPy component rendering loop + await serve_layout( + Layout(ConnectionContext(component(), value=connection)), + self.send_json, + self.rendering_queue.get, + ) + + # Manually log exceptions since this function is running in a separate asyncio task. + except Exception as error: + await asyncio.to_thread(_logger.error, f"{error}\n{traceback.format_exc()}") + + async def send_json(self, data: Any) -> None: + return await self._send( + {"type": "websocket.send", "text": orjson.dumps(data).decode()} + ) + + +@dataclass +class StaticFileApp: + parent: ReactPyMiddleware + _static_file_server: ServeStaticASGI | None = None + + async def __call__( + self, scope: AsgiHttpScope, receive: AsgiHttpReceive, send: AsgiHttpSend + ) -> None: + """ASGI app for ReactPy static files.""" + if not self._static_file_server: + self._static_file_server = ServeStaticASGI( + Error404App(), + root=self.parent.static_dir, + prefix=self.parent.static_path, + ) + + await self._static_file_server(scope, receive, send) + + +@dataclass +class WebModuleApp: + parent: ReactPyMiddleware + _static_file_server: ServeStaticASGI | None = None + + async def __call__( + self, scope: AsgiHttpScope, receive: AsgiHttpReceive, send: AsgiHttpSend + ) -> None: + """ASGI app for ReactPy web modules.""" + if not self._static_file_server: + self._static_file_server = ServeStaticASGI( + Error404App(), + root=self.parent.web_modules_dir, + prefix=self.parent.web_modules_path, + autorefresh=True, + ) + + await self._static_file_server(scope, receive, send) + + +class Error404App: + async def __call__( + self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend + ) -> None: + response = ResponseText("Resource not found on this server.", status_code=404) + await response(scope, receive, send) # type: ignore diff --git a/src/reactpy/executors/asgi/pyscript.py b/src/reactpy/executors/asgi/pyscript.py new file mode 100644 index 000000000..b3f2cd38f --- /dev/null +++ b/src/reactpy/executors/asgi/pyscript.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import hashlib +import re +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime, timezone +from email.utils import formatdate +from pathlib import Path +from typing import Any + +from typing_extensions import Unpack + +from reactpy import html +from reactpy.executors.asgi.middleware import ReactPyMiddleware +from reactpy.executors.asgi.standalone import ReactPy, ReactPyApp +from reactpy.executors.asgi.types import AsgiWebsocketScope +from reactpy.executors.utils import vdom_head_to_html +from reactpy.pyscript.utils import pyscript_component_html, pyscript_setup_html +from reactpy.types import ReactPyConfig, VdomDict + + +class ReactPyPyscript(ReactPy): + def __init__( + self, + *file_paths: str | Path, + extra_py: Sequence[str] = (), + extra_js: dict[str, str] | None = None, + pyscript_config: dict[str, Any] | None = None, + root_name: str = "root", + initial: str | VdomDict = "", + http_headers: dict[str, str] | None = None, + html_head: VdomDict | None = None, + html_lang: str = "en", + **settings: Unpack[ReactPyConfig], + ) -> None: + """Variant of ReactPy's standalone that only performs Client-Side Rendering (CSR) via + PyScript (using the Pyodide interpreter). + + This ASGI webserver is only used to serve the initial HTML document and static files. + + Parameters: + file_paths: + File path(s) to the Python files containing the root component. If multuple paths are + provided, the components will be concatenated in the order they were provided. + extra_py: + Additional Python packages to be made available to the root component. These packages + will be automatically installed from PyPi. Any packages names ending with `.whl` will + be assumed to be a URL to a wheel file. + extra_js: Dictionary where the `key` is the URL to the JavaScript file and the `value` is + the name you'd like to export it as. Any JavaScript files declared here will be available + to your root component via the `pyscript.js_modules.*` object. + pyscript_config: + Additional configuration options for the PyScript runtime. This will be merged with the + default configuration. + root_name: The name of the root component in your Python file. + initial: The initial HTML that is rendered prior to your component loading in. This is most + commonly used to render a loading animation. + http_headers: Additional headers to include in the HTTP response for the base HTML document. + html_head: Additional head elements to include in the HTML response. + html_lang: The language of the HTML document. + settings: + Global ReactPy configuration settings that affect behavior and performance. Most settings + are not applicable to CSR and will have no effect. + """ + ReactPyMiddleware.__init__( + self, app=ReactPyPyscriptApp(self), root_components=[], **settings + ) + if not file_paths: + raise ValueError("At least one component file path must be provided.") + self.file_paths = tuple(str(path) for path in file_paths) + self.extra_py = extra_py + self.extra_js = extra_js or {} + self.pyscript_config = pyscript_config or {} + self.root_name = root_name + self.initial = initial + self.extra_headers = http_headers or {} + self.dispatcher_pattern = re.compile(f"^{self.dispatcher_path}?") + self.html_head = html_head or html.head() + self.html_lang = html_lang + + def match_dispatch_path(self, scope: AsgiWebsocketScope) -> bool: # nocov + """We do not use a WebSocket dispatcher for Client-Side Rendering (CSR).""" + return False + + +@dataclass +class ReactPyPyscriptApp(ReactPyApp): + """ReactPy's standalone ASGI application for Client-Side Rendering (CSR) via PyScript.""" + + parent: ReactPyPyscript + _index_html = "" + _etag = "" + _last_modified = "" + + def render_index_html(self) -> None: + """Process the index.html and store the results in this class.""" + head_content = vdom_head_to_html(self.parent.html_head) + pyscript_setup = pyscript_setup_html( + extra_py=self.parent.extra_py, + extra_js=self.parent.extra_js, + config=self.parent.pyscript_config, + ) + pyscript_component = pyscript_component_html( + file_paths=self.parent.file_paths, + initial=self.parent.initial, + root=self.parent.root_name, + ) + head_content = head_content.replace("", f"{pyscript_setup}") + + self._index_html = ( + "" + f'' + f"{head_content}" + "" + f"{pyscript_component}" + "" + "" + ) + self._etag = f'"{hashlib.md5(self._index_html.encode(), usedforsecurity=False).hexdigest()}"' + self._last_modified = formatdate( + datetime.now(tz=timezone.utc).timestamp(), usegmt=True + ) diff --git a/src/reactpy/executors/asgi/standalone.py b/src/reactpy/executors/asgi/standalone.py new file mode 100644 index 000000000..fac9e7ce6 --- /dev/null +++ b/src/reactpy/executors/asgi/standalone.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from email.utils import formatdate +from logging import getLogger +from typing import Callable, Literal, cast, overload + +from asgi_tools import ResponseHTML +from typing_extensions import Unpack + +from reactpy import html +from reactpy.executors.asgi.middleware import ReactPyMiddleware +from reactpy.executors.asgi.types import ( + AsgiApp, + AsgiReceive, + AsgiScope, + AsgiSend, + AsgiV3HttpApp, + AsgiV3LifespanApp, + AsgiV3WebsocketApp, + AsgiWebsocketScope, +) +from reactpy.executors.utils import server_side_component_html, vdom_head_to_html +from reactpy.pyscript.utils import pyscript_setup_html +from reactpy.types import ( + PyScriptOptions, + ReactPyConfig, + RootComponentConstructor, + VdomDict, +) +from reactpy.utils import import_dotted_path, string_to_reactpy + +_logger = getLogger(__name__) + + +class ReactPy(ReactPyMiddleware): + multiple_root_components = False + + def __init__( + self, + root_component: RootComponentConstructor, + *, + http_headers: dict[str, str] | None = None, + html_head: VdomDict | None = None, + html_lang: str = "en", + pyscript_setup: bool = False, + pyscript_options: PyScriptOptions | None = None, + **settings: Unpack[ReactPyConfig], + ) -> None: + """ReactPy's standalone ASGI application. + + Parameters: + root_component: The root component to render. This app is typically a single page application. + http_headers: Additional headers to include in the HTTP response for the base HTML document. + html_head: Additional head elements to include in the HTML response. + html_lang: The language of the HTML document. + pyscript_setup: Whether to automatically load PyScript within your HTML head. + pyscript_options: Options to configure PyScript behavior. + settings: Global ReactPy configuration settings that affect behavior and performance. + """ + super().__init__(app=ReactPyApp(self), root_components=[], **settings) + self.root_component = root_component + self.extra_headers = http_headers or {} + self.dispatcher_pattern = re.compile(f"^{self.dispatcher_path}?") + self.html_head = html_head or html.head() + self.html_lang = html_lang + + if pyscript_setup: + self.html_head.setdefault("children", []) + pyscript_options = pyscript_options or {} + extra_py = pyscript_options.get("extra_py", []) + extra_js = pyscript_options.get("extra_js", {}) + config = pyscript_options.get("config", {}) + pyscript_head_vdom = string_to_reactpy( + pyscript_setup_html(extra_py, extra_js, config) + ) + pyscript_head_vdom["tagName"] = "" + self.html_head["children"].append(pyscript_head_vdom) # type: ignore + + def match_dispatch_path(self, scope: AsgiWebsocketScope) -> bool: + """Method override to remove `dotted_path` from the dispatcher URL.""" + return str(scope["path"]) == self.dispatcher_path + + def match_extra_paths(self, scope: AsgiScope) -> AsgiApp | None: + """Method override to match user-provided HTTP/Websocket routes.""" + if scope["type"] == "lifespan": + return self.extra_lifespan_app + + routing_dictionary = {} + if scope["type"] == "http": + routing_dictionary = self.extra_http_routes.items() + + if scope["type"] == "websocket": + routing_dictionary = self.extra_ws_routes.items() + + return next( + ( + app + for route, app in routing_dictionary + if re.match(route, scope["path"]) + ), + None, + ) + + @overload + def route( + self, + path: str, + type: Literal["http"] = "http", + ) -> Callable[[AsgiV3HttpApp | str], AsgiApp]: ... + + @overload + def route( + self, + path: str, + type: Literal["websocket"], + ) -> Callable[[AsgiV3WebsocketApp | str], AsgiApp]: ... + + def route( + self, + path: str, + type: Literal["http", "websocket"] = "http", + ) -> ( + Callable[[AsgiV3HttpApp | str], AsgiApp] + | Callable[[AsgiV3WebsocketApp | str], AsgiApp] + ): + """Interface that allows user to define their own HTTP/Websocket routes + within the current ReactPy application. + + Parameters: + path: The URL route to match, using regex format. + type: The protocol to route for. Can be 'http' or 'websocket'. + """ + + def decorator( + app: AsgiApp | str, + ) -> AsgiApp: + re_path = path + if not re_path.startswith("^"): + re_path = f"^{re_path}" + if not re_path.endswith("$"): + re_path = f"{re_path}$" + + asgi_app: AsgiApp = import_dotted_path(app) if isinstance(app, str) else app + if type == "http": + self.extra_http_routes[re_path] = cast(AsgiV3HttpApp, asgi_app) + elif type == "websocket": + self.extra_ws_routes[re_path] = cast(AsgiV3WebsocketApp, asgi_app) + + return asgi_app + + return decorator + + def lifespan(self, app: AsgiV3LifespanApp | str) -> None: + """Interface that allows user to define their own lifespan app + within the current ReactPy application. + + Parameters: + app: The ASGI application to route to. + """ + if self.extra_lifespan_app: + raise ValueError("Only one lifespan app can be defined.") + + self.extra_lifespan_app = ( + import_dotted_path(app) if isinstance(app, str) else app + ) + + +@dataclass +class ReactPyApp: + """ASGI app for ReactPy's standalone mode. This is utilized by `ReactPyMiddleware` as an alternative + to a user provided ASGI app.""" + + parent: ReactPy + _index_html = "" + _etag = "" + _last_modified = "" + + async def __call__( + self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend + ) -> None: + if scope["type"] != "http": # nocov + if scope["type"] != "lifespan": + msg = ( + "ReactPy app received unsupported request of type '%s' at path '%s'", + scope["type"], + scope["path"], + ) + _logger.warning(msg) + raise NotImplementedError(msg) + return + + # Store the HTTP response in memory for performance + if not self._index_html: + self.render_index_html() + + # Response headers for `index.html` responses + request_headers = dict(scope["headers"]) + response_headers: dict[str, str] = { + "etag": self._etag, + "last-modified": self._last_modified, + "access-control-allow-origin": "*", + "cache-control": "max-age=60, public", + "content-length": str(len(self._index_html)), + "content-type": "text/html; charset=utf-8", + **self.parent.extra_headers, + } + + # Browser is asking for the headers + if scope["method"] == "HEAD": + response = ResponseHTML("", headers=response_headers) + return await response(scope, receive, send) # type: ignore + + # Browser already has the content cached + if ( + request_headers.get(b"if-none-match") == self._etag.encode() + or request_headers.get(b"if-modified-since") == self._last_modified.encode() + ): + response_headers.pop("content-length") + response = ResponseHTML("", headers=response_headers, status_code=304) + return await response(scope, receive, send) # type: ignore + + # Send the index.html + response = ResponseHTML(self._index_html, headers=response_headers) + await response(scope, receive, send) # type: ignore + + def render_index_html(self) -> None: + """Process the index.html and store the results in this class.""" + self._index_html = ( + "" + f'' + f"{vdom_head_to_html(self.parent.html_head)}" + "" + f"{server_side_component_html(element_id='app', class_='', component_path='')}" + "" + "" + ) + self._etag = f'"{hashlib.md5(self._index_html.encode(), usedforsecurity=False).hexdigest()}"' + self._last_modified = formatdate( + datetime.now(tz=timezone.utc).timestamp(), usegmt=True + ) diff --git a/src/reactpy/executors/asgi/types.py b/src/reactpy/executors/asgi/types.py new file mode 100644 index 000000000..82a87d4f8 --- /dev/null +++ b/src/reactpy/executors/asgi/types.py @@ -0,0 +1,109 @@ +"""These types are separated from the main module to avoid dependency issues.""" + +from __future__ import annotations + +from collections.abc import Awaitable, MutableMapping +from typing import Any, Callable, Protocol + +from asgiref import typing as asgi_types + +# Type hints for `receive` within `asgi_app(scope, receive, send)` +AsgiReceive = Callable[[], Awaitable[dict[str, Any] | MutableMapping[str, Any]]] +AsgiHttpReceive = ( + Callable[ + [], Awaitable[asgi_types.HTTPRequestEvent | asgi_types.HTTPDisconnectEvent] + ] + | AsgiReceive +) +AsgiWebsocketReceive = ( + Callable[ + [], + Awaitable[ + asgi_types.WebSocketConnectEvent + | asgi_types.WebSocketDisconnectEvent + | asgi_types.WebSocketReceiveEvent + ], + ] + | AsgiReceive +) +AsgiLifespanReceive = ( + Callable[ + [], + Awaitable[asgi_types.LifespanStartupEvent | asgi_types.LifespanShutdownEvent], + ] + | AsgiReceive +) + +# Type hints for `send` within `asgi_app(scope, receive, send)` +AsgiSend = Callable[[dict[str, Any] | MutableMapping[str, Any]], Awaitable[None]] +AsgiHttpSend = ( + Callable[ + [ + asgi_types.HTTPResponseStartEvent + | asgi_types.HTTPResponseBodyEvent + | asgi_types.HTTPResponseTrailersEvent + | asgi_types.HTTPServerPushEvent + | asgi_types.HTTPDisconnectEvent + ], + Awaitable[None], + ] + | AsgiSend +) +AsgiWebsocketSend = ( + Callable[ + [ + asgi_types.WebSocketAcceptEvent + | asgi_types.WebSocketSendEvent + | asgi_types.WebSocketResponseStartEvent + | asgi_types.WebSocketResponseBodyEvent + | asgi_types.WebSocketCloseEvent + ], + Awaitable[None], + ] + | AsgiSend +) +AsgiLifespanSend = ( + Callable[ + [ + asgi_types.LifespanStartupCompleteEvent + | asgi_types.LifespanStartupFailedEvent + | asgi_types.LifespanShutdownCompleteEvent + | asgi_types.LifespanShutdownFailedEvent + ], + Awaitable[None], + ] + | AsgiSend +) + +# Type hints for `scope` within `asgi_app(scope, receive, send)` +AsgiScope = dict[str, Any] | MutableMapping[str, Any] +AsgiHttpScope = asgi_types.HTTPScope | AsgiScope +AsgiWebsocketScope = asgi_types.WebSocketScope | AsgiScope +AsgiLifespanScope = asgi_types.LifespanScope | AsgiScope + + +# Type hints for the ASGI app interface +AsgiV3App = Callable[[AsgiScope, AsgiReceive, AsgiSend], Awaitable[None]] +AsgiV3HttpApp = Callable[ + [AsgiHttpScope, AsgiHttpReceive, AsgiHttpSend], Awaitable[None] +] +AsgiV3WebsocketApp = Callable[ + [AsgiWebsocketScope, AsgiWebsocketReceive, AsgiWebsocketSend], Awaitable[None] +] +AsgiV3LifespanApp = Callable[ + [AsgiLifespanScope, AsgiLifespanReceive, AsgiLifespanSend], Awaitable[None] +] + + +class AsgiV2Protocol(Protocol): + """The ASGI 2.0 protocol for ASGI applications. Type hints for parameters are not provided since + type checkers tend to be too strict with protocol method types matching up perfectly.""" + + def __init__(self, scope: Any) -> None: ... + + async def __call__(self, receive: Any, send: Any) -> None: ... + + +AsgiV2App = type[AsgiV2Protocol] +AsgiApp = AsgiV3App | AsgiV2App +"""The type hint for any ASGI application. This was written to be as generic as possible to avoid type checking issues.""" diff --git a/src/reactpy/executors/utils.py b/src/reactpy/executors/utils.py new file mode 100644 index 000000000..e0008df9a --- /dev/null +++ b/src/reactpy/executors/utils.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import logging +from collections.abc import Iterable +from typing import Any + +from reactpy._option import Option +from reactpy.config import ( + REACTPY_PATH_PREFIX, + REACTPY_RECONNECT_BACKOFF_MULTIPLIER, + REACTPY_RECONNECT_INTERVAL, + REACTPY_RECONNECT_MAX_INTERVAL, + REACTPY_RECONNECT_MAX_RETRIES, +) +from reactpy.types import ReactPyConfig, VdomDict +from reactpy.utils import import_dotted_path, reactpy_to_string + +logger = logging.getLogger(__name__) + + +def import_components(dotted_paths: Iterable[str]) -> dict[str, Any]: + """Imports a list of dotted paths and returns the callables.""" + return { + dotted_path: import_dotted_path(dotted_path) for dotted_path in dotted_paths + } + + +def check_path(url_path: str) -> str: # nocov + """Check that a path is valid URL path.""" + if not url_path: + return "URL path must not be empty." + if not isinstance(url_path, str): + return "URL path is must be a string." + if not url_path.startswith("/"): + return "URL path must start with a forward slash." + if not url_path.endswith("/"): + return "URL path must end with a forward slash." + + return "" + + +def vdom_head_to_html(head: VdomDict) -> str: + if isinstance(head, dict) and head.get("tagName") == "head": + return reactpy_to_string(head) + + raise ValueError( + "Invalid head element! Element must be either `html.head` or a string." + ) + + +def process_settings(settings: ReactPyConfig) -> None: + """Process the settings and return the final configuration.""" + from reactpy import config + + for setting in settings: + config_name = f"REACTPY_{setting.upper()}" + config_object: Option[Any] | None = getattr(config, config_name, None) + if config_object: + config_object.set_current(settings[setting]) # type: ignore + else: + raise ValueError(f'Unknown ReactPy setting "{setting}".') + + +def server_side_component_html( + element_id: str, class_: str, component_path: str +) -> str: + return ( + f'
' + '" + ) diff --git a/src/reactpy/logging.py b/src/reactpy/logging.py index f10414cb6..160141c09 100644 --- a/src/reactpy/logging.py +++ b/src/reactpy/logging.py @@ -2,7 +2,7 @@ import sys from logging.config import dictConfig -from reactpy.config import REACTPY_DEBUG_MODE +from reactpy.config import REACTPY_DEBUG dictConfig( { @@ -18,13 +18,7 @@ "stream": sys.stdout, } }, - "formatters": { - "generic": { - "format": "%(asctime)s | %(log_color)s%(levelname)s%(reset)s | %(message)s", - "datefmt": r"%Y-%m-%dT%H:%M:%S%z", - "class": "colorlog.ColoredFormatter", - } - }, + "formatters": {"generic": {"datefmt": r"%Y-%m-%dT%H:%M:%S%z"}}, } ) @@ -33,7 +27,7 @@ """ReactPy's root logger instance""" -@REACTPY_DEBUG_MODE.subscribe +@REACTPY_DEBUG.subscribe def _set_debug_level(debug: bool) -> None: if debug: ROOT_LOGGER.setLevel("DEBUG") diff --git a/src/reactpy/future.py b/src/reactpy/pyscript/__init__.py similarity index 100% rename from src/reactpy/future.py rename to src/reactpy/pyscript/__init__.py diff --git a/src/reactpy/pyscript/component_template.py b/src/reactpy/pyscript/component_template.py new file mode 100644 index 000000000..47bf4d6a3 --- /dev/null +++ b/src/reactpy/pyscript/component_template.py @@ -0,0 +1,28 @@ +# ruff: noqa: TC004, N802, N816, RUF006 +# type: ignore +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import asyncio + + from reactpy.pyscript.layout_handler import ReactPyLayoutHandler + + +# User component is inserted below by regex replacement +def user_workspace_UUID(): + """Encapsulate the user's code with a completely unique function (workspace) + to prevent overlapping imports and variable names between different components. + + This code is designed to be run directly by PyScript, and is not intended to be run + in a normal Python environment. + + ReactPy-Django performs string substitutions to turn this file into valid PyScript. + """ + + def root(): ... + + return root() + + +# Create a task to run the user's component workspace +task_UUID = asyncio.create_task(ReactPyLayoutHandler("UUID").run(user_workspace_UUID)) diff --git a/src/reactpy/pyscript/components.py b/src/reactpy/pyscript/components.py new file mode 100644 index 000000000..5709bd7ca --- /dev/null +++ b/src/reactpy/pyscript/components.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from reactpy import component, hooks +from reactpy.pyscript.utils import pyscript_component_html +from reactpy.types import ComponentType, Key +from reactpy.utils import string_to_reactpy + +if TYPE_CHECKING: + from reactpy.types import VdomDict + + +@component +def _pyscript_component( + *file_paths: str | Path, + initial: str | VdomDict = "", + root: str = "root", +) -> None | VdomDict: + if not file_paths: + raise ValueError("At least one file path must be provided.") + + rendered, set_rendered = hooks.use_state(False) + initial = string_to_reactpy(initial) if isinstance(initial, str) else initial + + if not rendered: + # FIXME: This is needed to properly re-render PyScript during a WebSocket + # disconnection / reconnection. There may be a better way to do this in the future. + set_rendered(True) + return None + + component_vdom = string_to_reactpy( + pyscript_component_html(tuple(str(fp) for fp in file_paths), initial, root) + ) + component_vdom["tagName"] = "" + return component_vdom + + +def pyscript_component( + *file_paths: str | Path, + initial: str | VdomDict | ComponentType = "", + root: str = "root", + key: Key | None = None, +) -> ComponentType: + """ + Args: + file_paths: File path to your client-side ReactPy component. If multiple paths are \ + provided, the contents are automatically merged. + + Kwargs: + initial: The initial HTML that is displayed prior to the PyScript component \ + loads. This can either be a string containing raw HTML, a \ + `#!python reactpy.html` snippet, or a non-interactive component. + root: The name of the root component function. + """ + return _pyscript_component( + *file_paths, + initial=initial, + root=root, + key=key, + ) diff --git a/src/reactpy/pyscript/layout_handler.py b/src/reactpy/pyscript/layout_handler.py new file mode 100644 index 000000000..733ab064f --- /dev/null +++ b/src/reactpy/pyscript/layout_handler.py @@ -0,0 +1,159 @@ +# type: ignore +import asyncio +import logging + +import js +from jsonpointer import set_pointer +from pyodide.ffi.wrappers import add_event_listener +from pyscript.js_modules import morphdom + +from reactpy.core.layout import Layout + + +class ReactPyLayoutHandler: + """Encapsulate the entire layout handler with a class to prevent overlapping + variable names between user code. + + This code is designed to be run directly by PyScript, and is not intended to be run + in a normal Python environment. + """ + + def __init__(self, uuid): + self.uuid = uuid + self.running_tasks = set() + + @staticmethod + def update_model(update, root_model): + """Apply an update ReactPy's internal DOM model.""" + if update["path"]: + set_pointer(root_model, update["path"], update["model"]) + else: + root_model.update(update["model"]) + + def render_html(self, layout, model): + """Submit ReactPy's internal DOM model into the HTML DOM.""" + # Create a new container to render the layout into + container = js.document.getElementById(f"pyscript-{self.uuid}") + temp_root_container = container.cloneNode(False) + self.build_element_tree(layout, temp_root_container, model) + + # Use morphdom to update the DOM + morphdom.default(container, temp_root_container) + + # Remove the cloned container to prevent memory leaks + temp_root_container.remove() + + def build_element_tree(self, layout, parent, model): + """Recursively build an element tree, starting from the root component.""" + # If the model is a string, add it as a text node + if isinstance(model, str): + parent.appendChild(js.document.createTextNode(model)) + + # If the model is a VdomDict, construct an element + elif isinstance(model, dict): + # If the model is a fragment, build the children + if not model["tagName"]: + for child in model.get("children", []): + self.build_element_tree(layout, parent, child) + return + + # Otherwise, get the VdomDict attributes + tag = model["tagName"] + attributes = model.get("attributes", {}) + children = model.get("children", []) + element = js.document.createElement(tag) + + # Set the element's HTML attributes + for key, value in attributes.items(): + if key == "style": + for style_key, style_value in value.items(): + setattr(element.style, style_key, style_value) + elif key == "className": + element.className = value + else: + element.setAttribute(key, value) + + # Add event handlers to the element + for event_name, event_handler_model in model.get( + "eventHandlers", {} + ).items(): + self.create_event_handler( + layout, element, event_name, event_handler_model + ) + + # Recursively build the children + for child in children: + self.build_element_tree(layout, element, child) + + # Append the element to the parent + parent.appendChild(element) + + # Unknown data type provided + else: + msg = f"Unknown model type: {type(model)}" + raise TypeError(msg) + + def create_event_handler(self, layout, element, event_name, event_handler_model): + """Create an event handler for an element. This function is used as an + adapter between ReactPy and browser events.""" + target = event_handler_model["target"] + + def event_handler(*args): + # When the event is triggered, deliver the event to the `Layout` within a background task + task = asyncio.create_task( + layout.deliver({"type": "layout-event", "target": target, "data": args}) + ) + # Store the task to prevent automatic garbage collection from killing it + self.running_tasks.add(task) + task.add_done_callback(self.running_tasks.remove) + + # Convert ReactJS-style event names to HTML event names + event_name = event_name.lower() + if event_name.startswith("on"): + event_name = event_name[2:] + + add_event_listener(element, event_name, event_handler) + + @staticmethod + def delete_old_workspaces(): + """To prevent memory leaks, we must delete all user generated Python code when + it is no longer in use (removed from the page). To do this, we compare what + UUIDs exist on the DOM, versus what UUIDs exist within the PyScript global + interpreter.""" + # Find all PyScript workspaces that are still on the page + dom_workspaces = js.document.querySelectorAll(".pyscript") + dom_uuids = {element.dataset.uuid for element in dom_workspaces} + python_uuids = { + value.split("_")[-1] + for value in globals() + if value.startswith("user_workspace_") + } + + # Delete any workspaces that are no longer in use + for uuid in python_uuids - dom_uuids: + task_name = f"task_{uuid}" + if task_name in globals(): + task: asyncio.Task = globals()[task_name] + task.cancel() + del globals()[task_name] + else: + logging.error("Could not auto delete PyScript task %s", task_name) + + workspace_name = f"user_workspace_{uuid}" + if workspace_name in globals(): + del globals()[workspace_name] + else: + logging.error( + "Could not auto delete PyScript workspace %s", workspace_name + ) + + async def run(self, workspace_function): + """Run the layout handler. This function is main executor for all user generated code.""" + self.delete_old_workspaces() + root_model: dict = {} + + async with Layout(workspace_function()) as root_layout: + while True: + update = await root_layout.render() + self.update_model(update, root_model) + self.render_html(root_layout, root_model) diff --git a/src/reactpy/pyscript/utils.py b/src/reactpy/pyscript/utils.py new file mode 100644 index 000000000..34b54576d --- /dev/null +++ b/src/reactpy/pyscript/utils.py @@ -0,0 +1,239 @@ +# ruff: noqa: S603, S607 +from __future__ import annotations + +import functools +import json +import re +import shutil +import subprocess +import textwrap +from glob import glob +from logging import getLogger +from pathlib import Path +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +import jsonpointer + +import reactpy +from reactpy.config import REACTPY_DEBUG, REACTPY_PATH_PREFIX, REACTPY_WEB_MODULES_DIR +from reactpy.types import VdomDict +from reactpy.utils import reactpy_to_string + +if TYPE_CHECKING: + from collections.abc import Sequence + +_logger = getLogger(__name__) + + +def minify_python(source: str) -> str: + """Minify Python source code.""" + # Remove comments + source = re.sub(r"#.*\n", "\n", source) + # Remove docstrings + source = re.sub(r'\n\s*""".*?"""', "", source, flags=re.DOTALL) + # Remove excess newlines + source = re.sub(r"\n+", "\n", source) + # Remove empty lines + source = re.sub(r"\s+\n", "\n", source) + # Remove leading and trailing whitespace + return source.strip() + + +PYSCRIPT_COMPONENT_TEMPLATE = minify_python( + (Path(__file__).parent / "component_template.py").read_text(encoding="utf-8") +) +PYSCRIPT_LAYOUT_HANDLER = minify_python( + (Path(__file__).parent / "layout_handler.py").read_text(encoding="utf-8") +) + + +def pyscript_executor_html(file_paths: Sequence[str], uuid: str, root: str) -> str: + """Inserts the user's code into the PyScript template using pattern matching.""" + # Create a valid PyScript executor by replacing the template values + executor = PYSCRIPT_COMPONENT_TEMPLATE.replace("UUID", uuid) + executor = executor.replace("return root()", f"return {root}()") + + # Fetch the user's PyScript code + all_file_contents: list[str] = [] + all_file_contents.extend(cached_file_read(file_path) for file_path in file_paths) + + # Prepare the PyScript code block + user_code = "\n".join(all_file_contents) # Combine all user code + user_code = user_code.replace("\t", " ") # Normalize the text + user_code = textwrap.indent(user_code, " ") # Add indentation to match template + + # Ensure the root component exists + if f"def {root}():" not in user_code: + raise ValueError( + f"Could not find the root component function '{root}' in your PyScript file(s)." + ) + + # Insert the user code into the PyScript template + return executor.replace(" def root(): ...", user_code) + + +def pyscript_component_html( + file_paths: Sequence[str], initial: str | VdomDict, root: str +) -> str: + """Renders a PyScript component with the user's code.""" + _initial = initial if isinstance(initial, str) else reactpy_to_string(initial) + uuid = uuid4().hex + executor_code = pyscript_executor_html(file_paths=file_paths, uuid=uuid, root=root) + + return ( + f'
' + f"{_initial}" + "
" + f"" + ) + + +def pyscript_setup_html( + extra_py: Sequence[str], + extra_js: dict[str, Any] | str, + config: dict[str, Any] | str, +) -> str: + """Renders the PyScript setup code.""" + hide_pyscript_debugger = f'' + pyscript_config = extend_pyscript_config(extra_py, extra_js, config) + + return ( + f'' + f"{'' if REACTPY_DEBUG.current else hide_pyscript_debugger}" + f'" + f"" + ) + + +def extend_pyscript_config( + extra_py: Sequence[str], + extra_js: dict[str, str] | str, + config: dict[str, Any] | str, +) -> str: + import orjson + + # Extends ReactPy's default PyScript config with user provided values. + pyscript_config: dict[str, Any] = { + "packages": [ + reactpy_version_string(), + f"jsonpointer=={jsonpointer.__version__}", + "ssl", + ], + "js_modules": { + "main": { + f"{REACTPY_PATH_PREFIX.current}static/morphdom/morphdom-esm.js": "morphdom" + } + }, + "packages_cache": "never", + } + pyscript_config["packages"].extend(extra_py) + + # Extend the JavaScript dependency list + if extra_js and isinstance(extra_js, str): + pyscript_config["js_modules"]["main"].update(json.loads(extra_js)) + elif extra_js and isinstance(extra_js, dict): + pyscript_config["js_modules"]["main"].update(extra_js) + + # Update other config attributes + if config and isinstance(config, str): + pyscript_config.update(json.loads(config)) + elif config and isinstance(config, dict): + pyscript_config.update(config) + return orjson.dumps(pyscript_config).decode("utf-8") + + +def reactpy_version_string() -> str: # nocov + from reactpy.testing.common import GITHUB_ACTIONS + + local_version = reactpy.__version__ + + # Get a list of all versions via `pip index versions` + result = cached_pip_index_versions("reactpy") + + # Check if the command failed + if result.returncode != 0: + _logger.warning( + "Failed to verify what versions of ReactPy exist on PyPi. " + "PyScript functionality may not work as expected.", + ) + return f"reactpy=={local_version}" + + # Have `pip` tell us what versions are available + available_version_symbol = "Available versions: " + latest_version_symbol = "LATEST: " + known_versions: list[str] = [] + latest_version: str = "" + for line in result.stdout.splitlines(): + if line.startswith(available_version_symbol): + known_versions.extend(line[len(available_version_symbol) :].split(", ")) + elif latest_version_symbol in line: + symbol_postion = line.index(latest_version_symbol) + latest_version = line[symbol_postion + len(latest_version_symbol) :].strip() + + # Return early if the version is available on PyPi and we're not in a CI environment + if local_version in known_versions and not GITHUB_ACTIONS: + return f"reactpy=={local_version}" + + # We are now determining an alternative method of installing ReactPy for PyScript + if not GITHUB_ACTIONS: + _logger.warning( + "Your current version of ReactPy isn't available on PyPi. Since a packaged version " + "of ReactPy is required for PyScript, we are attempting to find an alternative method..." + ) + + # Build a local wheel for ReactPy, if needed + dist_dir = Path(reactpy.__file__).parent.parent.parent / "dist" + wheel_glob = glob(str(dist_dir / f"reactpy-{local_version}-*.whl")) + if not wheel_glob: + _logger.warning("Attempting to build a local wheel for ReactPy...") + subprocess.run( + ["hatch", "build", "-t", "wheel"], + capture_output=True, + text=True, + check=False, + cwd=Path(reactpy.__file__).parent.parent.parent, + ) + wheel_glob = glob(str(dist_dir / f"reactpy-{local_version}-*.whl")) + + # Building a local wheel failed, try our best to give the user any possible version. + if not wheel_glob: + if latest_version: + _logger.warning( + "Failed to build a local wheel for ReactPy, likely due to missing build dependencies. " + "PyScript will default to using the latest ReactPy version on PyPi." + ) + return f"reactpy=={latest_version}" + _logger.error( + "Failed to build a local wheel for ReactPy, and could not determine the latest version on PyPi. " + "PyScript functionality may not work as expected.", + ) + return f"reactpy=={local_version}" + + # Move the local wheel file to the web modules directory, if needed + wheel_file = Path(wheel_glob[0]) + new_path = REACTPY_WEB_MODULES_DIR.current / wheel_file.name + if not new_path.exists(): + _logger.warning( + "PyScript will utilize local wheel '%s'.", + wheel_file.name, + ) + shutil.copy(wheel_file, new_path) + return f"{REACTPY_PATH_PREFIX.current}modules/{wheel_file.name}" + + +@functools.cache +def cached_pip_index_versions(package_name: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["pip", "index", "versions", package_name], + capture_output=True, + text=True, + check=False, + ) + + +@functools.cache +def cached_file_read(file_path: str, minifiy: bool = True) -> str: + content = Path(file_path).read_text(encoding="utf-8").strip() + return minify_python(content) if minifiy else content diff --git a/src/reactpy/static/index.html b/src/reactpy/static/index.html deleted file mode 100644 index 77d008332..000000000 --- a/src/reactpy/static/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - {__head__} - - - -
- - - diff --git a/src/reactpy/static/pyscript-hide-debug.css b/src/reactpy/static/pyscript-hide-debug.css new file mode 100644 index 000000000..9cd8541e4 --- /dev/null +++ b/src/reactpy/static/pyscript-hide-debug.css @@ -0,0 +1,3 @@ +.py-error { + display: none; +} diff --git a/src/reactpy/templatetags/__init__.py b/src/reactpy/templatetags/__init__.py new file mode 100644 index 000000000..c6f792d27 --- /dev/null +++ b/src/reactpy/templatetags/__init__.py @@ -0,0 +1,3 @@ +from reactpy.templatetags.jinja import Jinja + +__all__ = ["Jinja"] diff --git a/src/reactpy/templatetags/jinja.py b/src/reactpy/templatetags/jinja.py new file mode 100644 index 000000000..c4256b525 --- /dev/null +++ b/src/reactpy/templatetags/jinja.py @@ -0,0 +1,42 @@ +from typing import ClassVar +from uuid import uuid4 + +from jinja2_simple_tags import StandaloneTag + +from reactpy.executors.utils import server_side_component_html +from reactpy.pyscript.utils import pyscript_component_html, pyscript_setup_html + + +class Jinja(StandaloneTag): # type: ignore + safe_output = True + tags: ClassVar[set[str]] = {"component", "pyscript_component", "pyscript_setup"} + + def render(self, *args: str, **kwargs: str) -> str: + if self.tag_name == "component": + return component(*args, **kwargs) + + if self.tag_name == "pyscript_component": + return pyscript_component(*args, **kwargs) + + if self.tag_name == "pyscript_setup": + return pyscript_setup(*args, **kwargs) + + # This should never happen, but we validate it for safety. + raise ValueError(f"Unknown tag: {self.tag_name}") # nocov + + +def component(dotted_path: str, **kwargs: str) -> str: + class_ = kwargs.pop("class", "") + if kwargs: + raise ValueError(f"Unexpected keyword arguments: {', '.join(kwargs)}") + return server_side_component_html( + element_id=uuid4().hex, class_=class_, component_path=f"{dotted_path}/" + ) + + +def pyscript_component(*file_paths: str, initial: str = "", root: str = "root") -> str: + return pyscript_component_html(file_paths=file_paths, initial=initial, root=root) + + +def pyscript_setup(*extra_py: str, extra_js: str = "", config: str = "") -> str: + return pyscript_setup_html(extra_py=extra_py, extra_js=extra_js, config=config) diff --git a/src/reactpy/testing/__init__.py b/src/reactpy/testing/__init__.py index 9f61cec57..27247a88f 100644 --- a/src/reactpy/testing/__init__.py +++ b/src/reactpy/testing/__init__.py @@ -14,14 +14,14 @@ ) __all__ = [ - "assert_reactpy_did_not_log", - "assert_reactpy_did_log", - "capture_reactpy_logs", - "clear_reactpy_web_modules_dir", + "BackendFixture", "DisplayFixture", "HookCatcher", "LogAssertionError", - "poll", - "BackendFixture", "StaticEventHandler", + "assert_reactpy_did_log", + "assert_reactpy_did_not_log", + "capture_reactpy_logs", + "clear_reactpy_web_modules_dir", + "poll", ] diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index 3f56a5ecb..f41563489 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -2,23 +2,26 @@ import asyncio import logging -from contextlib import AsyncExitStack, suppress +from contextlib import AsyncExitStack from types import TracebackType from typing import Any, Callable from urllib.parse import urlencode, urlunparse -from reactpy.backend import default as default_server -from reactpy.backend.types import BackendType -from reactpy.backend.utils import find_available_port -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT +import uvicorn + +from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.core.component import component from reactpy.core.hooks import use_callback, use_effect, use_state -from reactpy.core.types import ComponentConstructor +from reactpy.executors.asgi.middleware import ReactPyMiddleware +from reactpy.executors.asgi.standalone import ReactPy +from reactpy.executors.asgi.types import AsgiApp from reactpy.testing.logs import ( LogAssertionError, capture_reactpy_logs, list_logged_exceptions, ) +from reactpy.testing.utils import find_available_port +from reactpy.types import ComponentConstructor, ReactPyConfig from reactpy.utils import Ref @@ -34,38 +37,42 @@ class BackendFixture: server.mount(MyComponent) """ - _records: list[logging.LogRecord] + log_records: list[logging.LogRecord] _server_future: asyncio.Task[Any] _exit_stack = AsyncExitStack() def __init__( self, + app: AsgiApp | None = None, host: str = "127.0.0.1", port: int | None = None, - app: Any | None = None, - implementation: BackendType[Any] | None = None, - options: Any | None = None, timeout: float | None = None, + reactpy_config: ReactPyConfig | None = None, ) -> None: self.host = host self.port = port or find_available_port(host) - self.mount, self._root_component = _hotswap() + self.mount = mount_to_hotswap self.timeout = ( - REACTPY_TESTING_DEFAULT_TIMEOUT.current if timeout is None else timeout + REACTPY_TESTS_DEFAULT_TIMEOUT.current if timeout is None else timeout + ) + if isinstance(app, (ReactPyMiddleware, ReactPy)): + self._app = app + elif app: + self._app = ReactPyMiddleware( + app, + root_components=["reactpy.testing.backend.root_hotswap_component"], + **(reactpy_config or {}), + ) + else: + self._app = ReactPy( + root_hotswap_component, + **(reactpy_config or {}), + ) + self.webserver = uvicorn.Server( + uvicorn.Config( + app=self._app, host=self.host, port=self.port, loop="asyncio" + ) ) - - if app is not None and implementation is None: - msg = "If an application instance its corresponding server implementation must be provided too." - raise ValueError(msg) - - self._app = app - self.implementation = implementation or default_server - self._options = options - - @property - def log_records(self) -> list[logging.LogRecord]: - """A list of captured log records""" - return self._records def url(self, path: str = "", query: Any | None = None) -> str: """Return a URL string pointing to the host and point of the server @@ -109,31 +116,12 @@ def list_logged_exceptions( async def __aenter__(self) -> BackendFixture: self._exit_stack = AsyncExitStack() - self._records = self._exit_stack.enter_context(capture_reactpy_logs()) + self.log_records = self._exit_stack.enter_context(capture_reactpy_logs()) - app = self._app or self.implementation.create_development_app() - self.implementation.configure(app, self._root_component, self._options) - - started = asyncio.Event() - server_future = asyncio.create_task( - self.implementation.serve_development_app( - app, self.host, self.port, started - ) - ) - - async def stop_server() -> None: - server_future.cancel() - with suppress(asyncio.CancelledError): - await asyncio.wait_for(server_future, timeout=self.timeout) - - self._exit_stack.push_async_callback(stop_server) - - try: - await asyncio.wait_for(started.wait(), timeout=self.timeout) - except Exception: # nocov - # see if we can await the future for a more helpful error - await asyncio.wait_for(server_future, timeout=self.timeout) - raise + # Wait for the server to start + self.webserver.config.setup_event_loop() + self.webserver_task = asyncio.create_task(self.webserver.serve()) + await asyncio.sleep(1) return self @@ -145,13 +133,19 @@ async def __aexit__( ) -> None: await self._exit_stack.aclose() - self.mount(None) # reset the view - logged_errors = self.list_logged_exceptions(del_log_records=False) if logged_errors: # nocov msg = "Unexpected logged exception" raise LogAssertionError(msg) from logged_errors[0] + await self.webserver.shutdown() + self.webserver_task.cancel() + + async def restart(self) -> None: + """Restart the server""" + await self.__aexit__(None, None, None) + await self.__aenter__() + _MountFunc = Callable[["Callable[[], Any] | None"], None] @@ -229,3 +223,6 @@ def swap(constructor: Callable[[], Any] | None) -> None: constructor_ref.current = constructor or (lambda: None) return swap, HotSwap + + +mount_to_hotswap, root_hotswap_component = _hotswap() diff --git a/src/reactpy/testing/common.py b/src/reactpy/testing/common.py index c1eb18ba5..a0aec3527 100644 --- a/src/reactpy/testing/common.py +++ b/src/reactpy/testing/common.py @@ -2,9 +2,10 @@ import asyncio import inspect +import os import shutil import time -from collections.abc import Awaitable +from collections.abc import Awaitable, Coroutine from functools import wraps from typing import Any, Callable, Generic, TypeVar, cast from uuid import uuid4 @@ -12,9 +13,10 @@ from typing_extensions import ParamSpec -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR -from reactpy.core._life_cycle_hook import LifeCycleHook, current_hook +from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR +from reactpy.core._life_cycle_hook import HOOK_STACK, LifeCycleHook from reactpy.core.events import EventHandler, to_event_handler_function +from reactpy.utils import str_to_bool def clear_reactpy_web_modules_dir() -> None: @@ -28,6 +30,7 @@ def clear_reactpy_web_modules_dir() -> None: _DEFAULT_POLL_DELAY = 0.1 +GITHUB_ACTIONS = str_to_bool(os.getenv("GITHUB_ACTIONS", "")) class poll(Generic[_R]): # noqa: N801 @@ -42,11 +45,12 @@ def __init__( coro: Callable[_P, Awaitable[_R]] if not inspect.iscoroutinefunction(function): - async def coro(*args: _P.args, **kwargs: _P.kwargs) -> _R: + async def async_func(*args: _P.args, **kwargs: _P.kwargs) -> _R: return cast(_R, function(*args, **kwargs)) + coro = async_func else: - coro = cast(Callable[_P, Awaitable[_R]], function) + coro = cast(Callable[_P, Coroutine[Any, Any, _R]], function) self._func = coro self._args = args self._kwargs = kwargs @@ -54,7 +58,7 @@ async def coro(*args: _P.args, **kwargs: _P.kwargs) -> _R: async def until( self, condition: Callable[[_R], bool], - timeout: float = REACTPY_TESTING_DEFAULT_TIMEOUT.current, + timeout: float = REACTPY_TESTS_DEFAULT_TIMEOUT.current, delay: float = _DEFAULT_POLL_DELAY, description: str = "condition to be true", ) -> None: @@ -72,7 +76,7 @@ async def until( async def until_is( self, right: _R, - timeout: float = REACTPY_TESTING_DEFAULT_TIMEOUT.current, + timeout: float = REACTPY_TESTS_DEFAULT_TIMEOUT.current, delay: float = _DEFAULT_POLL_DELAY, ) -> None: """Wait until the result is identical to the given value""" @@ -86,7 +90,7 @@ async def until_is( async def until_equals( self, right: _R, - timeout: float = REACTPY_TESTING_DEFAULT_TIMEOUT.current, + timeout: float = REACTPY_TESTS_DEFAULT_TIMEOUT.current, delay: float = _DEFAULT_POLL_DELAY, ) -> None: """Wait until the result is equal to the given value""" @@ -143,7 +147,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: if self is None: raise RuntimeError("Hook catcher has been garbage collected") - hook = current_hook() + hook = HOOK_STACK.current_hook() if self.index_by_kwarg is not None: self.index[kwargs[self.index_by_kwarg]] = hook self.latest = hook diff --git a/src/reactpy/testing/display.py b/src/reactpy/testing/display.py index bb0d8351d..aeaf6a34d 100644 --- a/src/reactpy/testing/display.py +++ b/src/reactpy/testing/display.py @@ -7,12 +7,11 @@ from playwright.async_api import ( Browser, BrowserContext, - ElementHandle, Page, async_playwright, ) -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT +from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.testing.backend import BackendFixture from reactpy.types import RootComponentConstructor @@ -26,7 +25,6 @@ def __init__( self, backend: BackendFixture | None = None, driver: Browser | BrowserContext | Page | None = None, - url_prefix: str = "", ) -> None: if backend is not None: self.backend = backend @@ -35,7 +33,6 @@ def __init__( self.page = driver else: self._browser = driver - self.url_prefix = url_prefix async def show( self, @@ -43,23 +40,9 @@ async def show( ) -> None: self.backend.mount(component) await self.goto("/") - await self.root_element() # check that root element is attached - async def goto( - self, path: str, query: Any | None = None, add_url_prefix: bool = True - ) -> None: - await self.page.goto( - self.backend.url( - f"{self.url_prefix}{path}" if add_url_prefix else path, query - ) - ) - - async def root_element(self) -> ElementHandle: - element = await self.page.wait_for_selector("#app", state="attached") - if element is None: # nocov - msg = "Root element not attached" - raise RuntimeError(msg) - return element + async def goto(self, path: str, query: Any | None = None) -> None: + await self.page.goto(self.backend.url(path, query)) async def __aenter__(self) -> DisplayFixture: es = self._exit_stack = AsyncExitStack() @@ -73,9 +56,9 @@ async def __aenter__(self) -> DisplayFixture: browser = self._browser self.page = await browser.new_page() - self.page.set_default_timeout(REACTPY_TESTING_DEFAULT_TIMEOUT.current * 1000) + self.page.set_default_timeout(REACTPY_TESTS_DEFAULT_TIMEOUT.current * 1000) - if not hasattr(self, "backend"): + if not hasattr(self, "backend"): # nocov self.backend = BackendFixture() await es.enter_async_context(self.backend) diff --git a/src/reactpy/testing/utils.py b/src/reactpy/testing/utils.py new file mode 100644 index 000000000..6a48516ed --- /dev/null +++ b/src/reactpy/testing/utils.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import socket +import sys +from contextlib import closing + + +def find_available_port( + host: str, port_min: int = 8000, port_max: int = 9000 +) -> int: # nocov + """Get a port that's available for the given host and port range""" + for port in range(port_min, port_max): + with closing(socket.socket()) as sock: + try: + if sys.platform in ("linux", "darwin"): + # Fixes bug on Unix-like systems where every time you restart the + # server you'll get a different port on Linux. This cannot be set + # on Windows otherwise address will always be reused. + # Ref: https://stackoverflow.com/a/19247688/3159288 + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((host, port)) + except OSError: + pass + else: + return port + msg = f"Host {host!r} has no available port in range {port_max}-{port_max}" + raise RuntimeError(msg) diff --git a/src/reactpy/transforms.py b/src/reactpy/transforms.py new file mode 100644 index 000000000..072653c95 --- /dev/null +++ b/src/reactpy/transforms.py @@ -0,0 +1,409 @@ +from __future__ import annotations + +from typing import Any, cast + +from reactpy.core.events import EventHandler, to_event_handler_function +from reactpy.types import VdomAttributes, VdomDict + + +def attributes_to_reactjs(attributes: VdomAttributes): + """Convert HTML attribute names to their ReactJS equivalents.""" + attrs = cast(VdomAttributes, attributes.items()) + attrs = cast( + VdomAttributes, + {REACT_PROP_SUBSTITUTIONS.get(k, k): v for k, v in attrs}, + ) + return attrs + + +class RequiredTransforms: + """Performs any necessary transformations related to `string_to_reactpy` to automatically prevent + issues with React's rendering engine. + """ + + def __init__(self, vdom: VdomDict, intercept_links: bool = True) -> None: + self._intercept_links = intercept_links + + # Run every transform in this class. + for name in dir(self): + # Any method that doesn't start with an underscore is assumed to be a transform. + if not name.startswith("_"): + getattr(self, name)(vdom) + + def normalize_style_attributes(self, vdom: dict[str, Any]) -> None: + """Convert style attribute from str -> dict with camelCase keys""" + if ( + "attributes" in vdom + and "style" in vdom["attributes"] + and isinstance(vdom["attributes"]["style"], str) + ): + vdom["attributes"]["style"] = { + self._kebab_to_camel_case(key.strip()): value.strip() + for key, value in ( + part.split(":", 1) + for part in vdom["attributes"]["style"].split(";") + if ":" in part + ) + } + + @staticmethod + def textarea_children_to_prop(vdom: VdomDict) -> None: + """Transformation that converts the text content of a ", + "model": { + "tagName": "textarea", + "attributes": {"defaultValue": "Hello World."}, + }, + }, + # 3: Convert ", + "model": { + "tagName": "select", + "attributes": {"defaultValue": "Option 1"}, + "children": [ + { + "children": ["Option 1"], + "tagName": "option", + "attributes": {"value": "Option 1"}, + } + ], + }, + }, + # 4: Convert ", + "model": { + "tagName": "select", + "attributes": { + "defaultValue": ["Option 1", "Option 2"], + "multiple": True, + }, + "children": [ + { + "children": ["Option 1"], + "tagName": "option", + "attributes": {"value": "Option 1"}, + }, + { + "children": ["Option 2"], + "tagName": "option", + "attributes": {"value": "Option 2"}, + }, + ], + }, + }, + # 5: Convert value attribute into `defaultValue` + { + "source": '', + "model": { + "tagName": "input", + "attributes": {"defaultValue": "Hello World.", "type": "text"}, + }, + }, + # 6: Infer ReactJS `key` from the `id` attribute + { + "source": '
', + "model": { + "tagName": "div", + "key": "my-key", + "attributes": {"id": "my-key"}, + }, + }, + # 7: Infer ReactJS `key` from the `name` attribute + { + "source": '', + "model": { + "tagName": "input", + "key": "my-input", + "attributes": {"type": "text", "name": "my-input"}, + }, + }, + # 8: Infer ReactJS `key` from the `key` attribute + { + "source": '
', + "model": { + "tagName": "div", + "attributes": {"key": "my-key"}, + "key": "my-key", + }, + }, + # 9: Includes `inlineJavaScript` attribue + { + "source": """""", + "model": { + "tagName": "button", + "inlineJavaScript": {"onClick": "this.innerText = 'CLICKED'"}, + "children": ["Click Me"], + }, + }, + ], +) +def test_string_to_reactpy_default_transforms(case): + assert utils.string_to_reactpy(case["source"]) == case["model"] + + +def test_string_to_reactpy_intercept_links(): + source = 'Hello World' + expected = { + "tagName": "a", + "children": ["Hello World"], + "attributes": {"href": "https://example.com"}, + } + result = utils.string_to_reactpy(source, intercept_links=True) + # Check if the result equals expected when removing `eventHandlers` from the result dict + event_handlers = result.pop("eventHandlers", {}) + assert result == expected -def test_html_to_vdom_transform(): + # Make sure the event handlers dict contains an `onClick` key + assert "onClick" in event_handlers + + +def test_string_to_reactpy_custom_transform(): source = "

hello world and universelmao

" def make_links_blue(node): @@ -98,7 +253,10 @@ def make_links_blue(node): ], } - assert html_to_vdom(source, make_links_blue) == expected + assert ( + utils.string_to_reactpy(source, make_links_blue, intercept_links=False) + == expected + ) def test_non_html_tag_behavior(): @@ -108,86 +266,14 @@ def test_non_html_tag_behavior(): "tagName": "my-tag", "attributes": {"data-x": "something"}, "children": [ - {"tagName": "my-other-tag", "key": "a-key"}, - ], - } - - assert html_to_vdom(source, strict=False) == expected - - with pytest.raises(HTMLParseError): - html_to_vdom(source, strict=True) - - -def test_html_to_vdom_with_null_tag(): - source = "

hello
world

" - - expected = { - "tagName": "p", - "children": [ - "hello", - {"tagName": "br"}, - "world", - ], - } - - assert html_to_vdom(source) == expected - - -def test_html_to_vdom_with_style_attr(): - source = '

Hello World.

' - - expected = { - "attributes": {"style": {"background_color": "green", "color": "red"}}, - "children": ["Hello World."], - "tagName": "p", - } - - assert html_to_vdom(source) == expected - - -def test_html_to_vdom_with_no_parent_node(): - source = "

Hello

World
" - - expected = { - "tagName": "div", - "children": [ - {"tagName": "p", "children": ["Hello"]}, - {"tagName": "div", "children": ["World"]}, + {"tagName": "my-other-tag", "attributes": {"key": "a-key"}, "key": "a-key"}, ], } - assert html_to_vdom(source) == expected - + assert utils.string_to_reactpy(source, strict=False) == expected -def test_del_html_body_transform(): - source = """ - - - - - My Title - - -

Hello World

- - - """ - - expected = { - "tagName": "", - "children": [ - { - "tagName": "", - "children": [{"tagName": "title", "children": ["My Title"]}], - }, - { - "tagName": "", - "children": [{"tagName": "h1", "children": ["Hello World"]}], - }, - ], - } - - assert html_to_vdom(source, del_html_head_body_transform) == expected + with pytest.raises(utils.HTMLParseError): + utils.string_to_reactpy(source, strict=True) SOME_OBJECT = object() @@ -205,7 +291,17 @@ def example_middle(): @component def example_child(): - return html.h1("Sample Application") + return html.h1("Example") + + +@component +def example_str_return(): + return "Example" + + +@component +def example_none_return(): + return None @pytest.mark.parametrize( @@ -226,7 +322,7 @@ def example_child(): '
helloexampleworld
', ), ( - html.button({"on_click": lambda event: None}), + html.button({"onClick": lambda event: None}), "", ), ( @@ -263,21 +359,52 @@ def example_child(): '
hello
example
', ), ( - html.div( - {"data_something": 1, "data_something_else": 2, "dataisnotdashed": 3} - ), - '
', + html.div({"data-Something": 1, "dataCamelCase": 2, "datalowercase": 3}), + '
', ), ( html.div(example_parent()), - '

Sample Application

', + '

Example

', + ), + ( + example_parent(), + '

Example

', + ), + ( + html.form({"acceptCharset": "utf-8"}), + '
', + ), + ( + example_str_return(), + "
Example
", + ), + ( + example_none_return(), + "", ), ], ) -def test_vdom_to_html(vdom_in, html_out): - assert vdom_to_html(vdom_in) == html_out +def test_reactpy_to_string(vdom_in, html_out): + assert utils.reactpy_to_string(vdom_in) == html_out -def test_vdom_to_html_error(): +def test_reactpy_to_string_error(): with pytest.raises(TypeError, match="Expected a VDOM dict"): - vdom_to_html({"notVdom": True}) + utils.reactpy_to_string({"notVdom": True}) + + +def test_invalid_dotted_path(): + with pytest.raises(ValueError, match='"abc" is not a valid dotted path.'): + utils.import_dotted_path("abc") + + +def test_invalid_component(): + with pytest.raises( + AttributeError, match='ReactPy failed to import "foobar" from "reactpy"' + ): + utils.import_dotted_path("reactpy.foobar") + + +def test_invalid_module(): + with pytest.raises(ImportError, match='ReactPy failed to import "foo"'): + utils.import_dotted_path("foo.bar") diff --git a/tests/test_web/js_fixtures/callable-prop.js b/tests/test_web/js_fixtures/callable-prop.js new file mode 100644 index 000000000..d16dd333a --- /dev/null +++ b/tests/test_web/js_fixtures/callable-prop.js @@ -0,0 +1,24 @@ +import { h, render } from "https://unpkg.com/preact?module"; +import htm from "https://unpkg.com/htm?module"; + +const html = htm.bind(h); + +export function bind(node, config) { + return { + create: (type, props, children) => h(type, props, ...children), + render: (element) => render(element, node), + unmount: () => render(null, node), + }; +} + +export function Component(props) { + var text = "DEFAULT"; + if (props.setText && typeof props.setText === "function") { + text = props.setText("PREFIX TEXT: "); + } + return html` +
+ ${text} +
+ `; +} diff --git a/tests/test_web/js_fixtures/keys-properly-propagated.js b/tests/test_web/js_fixtures/keys-properly-propagated.js new file mode 100644 index 000000000..8d700397e --- /dev/null +++ b/tests/test_web/js_fixtures/keys-properly-propagated.js @@ -0,0 +1,14 @@ +import React from "https://esm.sh/react@19.0" +import ReactDOM from "https://esm.sh/react-dom@19.0/client" +import GridLayout from "https://esm.sh/react-grid-layout@1.5.0"; +export {GridLayout}; + +export function bind(node, config) { + const root = ReactDOM.createRoot(node); + return { + create: (type, props, children) => + React.createElement(type, props, children), + render: (element) => root.render(element, node), + unmount: () => root.unmount() + }; +} diff --git a/tests/test_web/js_fixtures/subcomponent-notation.js b/tests/test_web/js_fixtures/subcomponent-notation.js new file mode 100644 index 000000000..73527c667 --- /dev/null +++ b/tests/test_web/js_fixtures/subcomponent-notation.js @@ -0,0 +1,14 @@ +import React from "https://esm.sh/react@19.0" +import ReactDOM from "https://esm.sh/react-dom@19.0/client" +import {InputGroup, Form} from "https://esm.sh/react-bootstrap@2.10.2?deps=react@19.0,react-dom@19.0,react-is@19.0&exports=InputGroup,Form"; +export {InputGroup, Form}; + +export function bind(node, config) { + const root = ReactDOM.createRoot(node); + return { + create: (type, props, children) => + React.createElement(type, props, ...children), + render: (element) => root.render(element), + unmount: () => root.unmount() + }; +} \ No newline at end of file diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 388794741..d233396fc 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -1,10 +1,10 @@ from pathlib import Path import pytest -from sanic import Sanic +from servestatic import ServeStaticASGI import reactpy -from reactpy.backend import sanic as sanic_implementation +from reactpy.executors.asgi.standalone import ReactPy from reactpy.testing import ( BackendFixture, DisplayFixture, @@ -12,6 +12,7 @@ assert_reactpy_did_not_log, poll, ) +from reactpy.types import InlineJavaScript from reactpy.web.module import NAME_SOURCE, WebModule JS_FIXTURES_DIR = Path(__file__).parent / "js_fixtures" @@ -50,19 +51,9 @@ def ShowCurrentComponent(): await display.page.wait_for_selector("#unmount-flag", state="attached") -@pytest.mark.flaky(reruns=3) async def test_module_from_url(browser): - app = Sanic("test_module_from_url") - - # instead of directing the URL to a CDN, we just point it to this static file - app.static( - "/simple-button.js", - str(JS_FIXTURES_DIR / "simple-button.js"), - content_type="text/javascript", - ) - SimpleButton = reactpy.web.export( - reactpy.web.module_from_url("/simple-button.js", resolve_exports=False), + reactpy.web.module_from_url("/static/simple-button.js", resolve_exports=False), "SimpleButton", ) @@ -70,7 +61,10 @@ async def test_module_from_url(browser): def ShowSimpleButton(): return SimpleButton({"id": "my-button"}) - async with BackendFixture(app=app, implementation=sanic_implementation) as server: + app = ReactPy(ShowSimpleButton) + app = ServeStaticASGI(app, JS_FIXTURES_DIR, "/static/") + + async with BackendFixture(app) as server: async with DisplayFixture(server, browser) as display: await display.show(ShowSimpleButton) @@ -215,6 +209,208 @@ async def test_imported_components_can_render_children(display: DisplayFixture): assert (await child.get_attribute("id")) == f"child-{index + 1}" +async def test_keys_properly_propagated(display: DisplayFixture): + """ + Fix https://github.com/reactive-python/reactpy/issues/1275 + + The `key` property was being lost in its propagation from the server-side ReactPy + definition to the front-end JavaScript. + + This property is required for certain JS components, such as the GridLayout from + react-grid-layout. + """ + module = reactpy.web.module_from_file( + "keys-properly-propagated", JS_FIXTURES_DIR / "keys-properly-propagated.js" + ) + GridLayout = reactpy.web.export(module, "GridLayout") + + await display.show( + lambda: GridLayout( + { + "layout": [ + { + "i": "a", + "x": 0, + "y": 0, + "w": 1, + "h": 2, + "static": True, + }, + { + "i": "b", + "x": 1, + "y": 0, + "w": 3, + "h": 2, + "minW": 2, + "maxW": 4, + }, + { + "i": "c", + "x": 4, + "y": 0, + "w": 1, + "h": 2, + }, + ], + "cols": 12, + "rowHeight": 30, + "width": 1200, + }, + reactpy.html.div({"key": "a"}, "a"), + reactpy.html.div({"key": "b"}, "b"), + reactpy.html.div({"key": "c"}, "c"), + ) + ) + + parent = await display.page.wait_for_selector( + ".react-grid-layout", state="attached" + ) + children = await parent.query_selector_all("div") + + # The children simply will not render unless they receive the key prop + assert len(children) == 3 + + +async def test_subcomponent_notation_as_str_attrs(display: DisplayFixture): + module = reactpy.web.module_from_file( + "subcomponent-notation", + JS_FIXTURES_DIR / "subcomponent-notation.js", + ) + InputGroup, InputGroupText, FormControl, FormLabel = reactpy.web.export( + module, ["InputGroup", "InputGroup.Text", "Form.Control", "Form.Label"] + ) + + content = reactpy.html.div( + {"id": "the-parent"}, + InputGroup( + InputGroupText({"id": "basic-addon1"}, "@"), + FormControl( + { + "placeholder": "Username", + "aria-label": "Username", + "aria-describedby": "basic-addon1", + } + ), + ), + InputGroup( + FormControl( + { + "placeholder": "Recipient's username", + "aria-label": "Recipient's username", + "aria-describedby": "basic-addon2", + } + ), + InputGroupText({"id": "basic-addon2"}, "@example.com"), + ), + FormLabel({"htmlFor": "basic-url"}, "Your vanity URL"), + InputGroup( + InputGroupText({"id": "basic-addon3"}, "https://example.com/users/"), + FormControl({"id": "basic-url", "aria-describedby": "basic-addon3"}), + ), + InputGroup( + InputGroupText("$"), + FormControl({"aria-label": "Amount (to the nearest dollar)"}), + InputGroupText(".00"), + ), + InputGroup( + InputGroupText("With textarea"), + FormControl({"as": "textarea", "aria-label": "With textarea"}), + ), + ) + + await display.show(lambda: content) + + await display.page.wait_for_selector("#basic-addon3", state="attached") + parent = await display.page.wait_for_selector("#the-parent", state="attached") + input_group_text = await parent.query_selector_all(".input-group-text") + form_control = await parent.query_selector_all(".form-control") + form_label = await parent.query_selector_all(".form-label") + + assert len(input_group_text) == 6 + assert len(form_control) == 5 + assert len(form_label) == 1 + + +async def test_subcomponent_notation_as_obj_attrs(display: DisplayFixture): + module = reactpy.web.module_from_file( + "subcomponent-notation", + JS_FIXTURES_DIR / "subcomponent-notation.js", + ) + InputGroup, Form = reactpy.web.export(module, ["InputGroup", "Form"]) + + content = reactpy.html.div( + {"id": "the-parent"}, + InputGroup( + InputGroup.Text({"id": "basic-addon1"}, "@"), + Form.Control( + { + "placeholder": "Username", + "aria-label": "Username", + "aria-describedby": "basic-addon1", + } + ), + ), + InputGroup( + Form.Control( + { + "placeholder": "Recipient's username", + "aria-label": "Recipient's username", + "aria-describedby": "basic-addon2", + } + ), + InputGroup.Text({"id": "basic-addon2"}, "@example.com"), + ), + Form.Label({"htmlFor": "basic-url"}, "Your vanity URL"), + InputGroup( + InputGroup.Text({"id": "basic-addon3"}, "https://example.com/users/"), + Form.Control({"id": "basic-url", "aria-describedby": "basic-addon3"}), + ), + InputGroup( + InputGroup.Text("$"), + Form.Control({"aria-label": "Amount (to the nearest dollar)"}), + InputGroup.Text(".00"), + ), + InputGroup( + InputGroup.Text("With textarea"), + Form.Control({"as": "textarea", "aria-label": "With textarea"}), + ), + ) + + await display.show(lambda: content) + + await display.page.wait_for_selector("#basic-addon3", state="attached") + parent = await display.page.wait_for_selector("#the-parent", state="attached") + input_group_text = await parent.query_selector_all(".input-group-text") + form_control = await parent.query_selector_all(".form-control") + form_label = await parent.query_selector_all(".form-label") + + assert len(input_group_text) == 6 + assert len(form_control) == 5 + assert len(form_label) == 1 + + +async def test_callable_prop_with_javacript(display: DisplayFixture): + module = reactpy.web.module_from_file( + "callable-prop", JS_FIXTURES_DIR / "callable-prop.js" + ) + Component = reactpy.web.export(module, "Component") + + @reactpy.component + def App(): + return Component( + { + "id": "my-div", + "setText": InlineJavaScript('(prefixText) => prefixText + "TEST 123"'), + } + ) + + await display.show(lambda: App()) + + my_div = await display.page.wait_for_selector("#my-div", state="attached") + assert await my_div.inner_text() == "PREFIX TEXT: TEST 123" + + def test_module_from_string(): reactpy.web.module_from_string("temp", "old") with assert_reactpy_did_log(r"Existing web module .* will be replaced with"): diff --git a/tests/test_web/test_utils.py b/tests/test_web/test_utils.py index 14c3e2e13..2f9d72618 100644 --- a/tests/test_web/test_utils.py +++ b/tests/test_web/test_utils.py @@ -5,6 +5,7 @@ from reactpy.testing import assert_reactpy_did_log from reactpy.web.utils import ( + _resolve_relative_url, module_name_suffix, resolve_module_exports_from_file, resolve_module_exports_from_source, @@ -150,3 +151,15 @@ def test_log_on_unknown_export_type(): assert resolve_module_exports_from_source( "export something unknown;", exclude_default=False ) == (set(), set()) + + +def test_resolve_relative_url(): + assert ( + _resolve_relative_url("https://some.url", "path/to/another.js") + == "path/to/another.js" + ) + assert ( + _resolve_relative_url("https://some.url", "/path/to/another.js") + == "https://some.url/path/to/another.js" + ) + assert _resolve_relative_url("/some/path", "to/another.js") == "to/another.js" diff --git a/tests/tooling/aio.py b/tests/tooling/aio.py index b0f719400..7fe8f03b2 100644 --- a/tests/tooling/aio.py +++ b/tests/tooling/aio.py @@ -3,7 +3,7 @@ from asyncio import Event as _Event from asyncio import wait_for -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT +from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT class Event(_Event): @@ -12,5 +12,5 @@ class Event(_Event): async def wait(self, timeout: float | None = None): return await wait_for( super().wait(), - timeout=timeout or REACTPY_TESTING_DEFAULT_TIMEOUT.current, + timeout=timeout or REACTPY_TESTS_DEFAULT_TIMEOUT.current, ) diff --git a/tests/tooling/common.py b/tests/tooling/common.py index 1803b8aed..75495db0c 100644 --- a/tests/tooling/common.py +++ b/tests/tooling/common.py @@ -1,12 +1,9 @@ -import os from typing import Any -from reactpy.core.types import LayoutEventMessage, LayoutUpdateMessage +from reactpy.testing.common import GITHUB_ACTIONS +from reactpy.types import LayoutEventMessage, LayoutUpdateMessage -GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") -DEFAULT_TYPE_DELAY = ( - 250 if GITHUB_ACTIONS.lower() in {"y", "yes", "t", "true", "on", "1"} else 25 -) +DEFAULT_TYPE_DELAY = 250 if GITHUB_ACTIONS else 50 def event_message(target: str, *data: Any) -> LayoutEventMessage: diff --git a/tests/tooling/hooks.py b/tests/tooling/hooks.py index 1926a93bc..bb33172ed 100644 --- a/tests/tooling/hooks.py +++ b/tests/tooling/hooks.py @@ -1,8 +1,8 @@ -from reactpy.core.hooks import current_hook, use_state +from reactpy.core.hooks import HOOK_STACK, use_state def use_force_render(): - return current_hook().schedule_render + return HOOK_STACK.current_hook().schedule_render def use_toggle(init=False): @@ -10,6 +10,7 @@ def use_toggle(init=False): return state, lambda: set_state(lambda old: not old) +# TODO: Remove this def use_counter(initial_value): state, set_state = use_state(initial_value) return state, lambda: set_state(lambda old: old + 1) diff --git a/tests/tooling/layout.py b/tests/tooling/layout.py index fe78684fe..034770bf6 100644 --- a/tests/tooling/layout.py +++ b/tests/tooling/layout.py @@ -8,7 +8,7 @@ from jsonpointer import set_pointer from reactpy.core.layout import Layout -from reactpy.core.types import VdomJson +from reactpy.types import VdomJson from tests.tooling.common import event_message logger = logging.getLogger(__name__) diff --git a/tests/tooling/select.py b/tests/tooling/select.py index cf7a9c004..2a0f170b8 100644 --- a/tests/tooling/select.py +++ b/tests/tooling/select.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Callable -from reactpy.core.types import VdomJson +from reactpy.types import VdomJson Selector = Callable[[VdomJson, "ElementInfo"], bool]