diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..c0ac4e5 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,13 @@ +{ + "env": { + "browser": true, + "node": true, + "es2021": true + }, + "extends": ["eslint:recommended"], + "overrides": [], + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + } +} diff --git a/notebooks/introduction.ipynb b/notebooks/introduction.ipynb index 7b1f96f..59b56e5 100644 --- a/notebooks/introduction.ipynb +++ b/notebooks/introduction.ipynb @@ -4,43 +4,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# What is ReactPy?\n", + "\"ReactPy\n", "\n", - "ReactPy connects your Python web framework of choice to a ReactJS frontend, allowing you to create **interactive websites without needing JavaScript!**\n", + "---\n", "\n", - "Following ReactJS styling, web elements are combined into [reusable \"components\"](https://reactpy.dev/docs/guides/creating-interfaces/your-first-components/index.html#parametrizing-components). These components can utilize [hooks](https://reactpy.dev/docs/reference/hooks-api.html) and [events](https://reactpy.dev/docs/guides/adding-interactivity/responding-to-events/index.html#async-event-handlers) to create infinitely complex web pages.\n", - "\n", - "When needed, ReactPy can [use components directly from NPM](https://reactpy.dev/docs/guides/escape-hatches/javascript-components.html#dynamically-loaded-components). For additional flexibility, components can also be [fully developed in JavaScript](https://reactpy.dev/docs/guides/escape-hatches/javascript-components.html#custom-javascript-components).\n", - "\n", - "\n", - "# Getting Started\n", - "\n", - "Then, before anything else, do one of the following:\n", - "\n", - "1. At the top of your notebook run\n", - "\n", - " ```python\n", - " import reactpy_jupyter\n", - " ```\n", - "\n", - "2. Register `reactpy_jupyter` as a permanant IPython extension in [your config file](https://ipython.readthedocs.io/en/stable/config/intro.html#introduction-to-ipython-configuration):\n", - "\n", - " ```python\n", - " c.InteractiveShellApp.extensions = ['reactpy_jupyter']\n", - " ```\n", - "\n", - "For the purposes of this tutorial, you'll want to do the first:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "import reactpy_jupyter" + "[ReactPy](https://reactpy.dev/) is a library for building user interfaces in Python without Javascript. ReactPy interfaces are made from components which look and behave similarly to those found in [ReactJS](https://reactjs.org/). Designed with simplicity in mind, ReactPy can be used by those without web development experience while also being powerful enough to grow with your ambitions." ] }, { @@ -54,13 +22,29 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": { "tags": [] }, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "e0c3c63d3e1c4d88a71bb7bbc6ae45e0", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "LayoutWidget(Layout(ContextProvider()))" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "from reactpy import component, html, run\n", + "from reactpy import component, html\n", "\n", "\n", "@component\n", @@ -84,11 +68,27 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": { "tags": [] }, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "a87baeb77dcc44e1a29ab154df1dd8a7", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "LayoutWidget(Layout(ContextProvider()))" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "from reactpy import component, html\n", "\n", @@ -137,11 +137,27 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": { "tags": [] }, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "920e5f89aba644528257e20deae25f2e", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "LayoutWidget(Layout(ContextProvider()))" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "import json\n", "from pathlib import Path\n", @@ -186,22 +202,62 @@ "source": [ "# Using ReactPy With Jupyter Widgets\n", "\n", - "While you can use ReactPy components independently, it may also be useful to integrate them with the rest of the Jupyter Widget ecosystem. Let's consider a ReactPy component that responds to and displays changes from an `ipywidgets.IntSlider`. The ReactPy component will need to accept an `IntSlider` instance as one of its arguments, declare state that will track the slider's value, and register a lister that will update that state via the slider's `IntSlider.observe()` method using an [\"effect\"](https://reactpy.dev/docs/reference/hooks-api.html#use-effect):" + "It's possible to use Jupyter Widgets in ReactPy components if you convert them first using `reactpy_jupyter.from_widget`." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "67cb81abf4ce4c9e991a9771607b9d5e", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "LayoutWidget(Layout(ContextProvider()))" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from reactpy_jupyter import from_widget\n", + "from ipywidgets import IntSlider\n", + "\n", + "slider_widget = IntSlider()\n", + "slider_component = from_widget(slider_widget)\n", + "\n", + "slider_component" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's consider a ReactPy component that responds to and displays changes from an `ipywidgets.IntSlider`. The ReactPy component will need to accept an `IntSlider` instance as one of its arguments, convert it to a component with `from_widget`, declare state that will track the slider's value, and register a lister that will update that state via the slider's `IntSlider.observe()` method using an [\"effect\"](https://reactpy.dev/docs/reference/hooks-api.html#use-effect):" + ] + }, + { + "cell_type": "code", + "execution_count": 5, "metadata": { "tags": [] }, "outputs": [], "source": [ "from reactpy import use_effect\n", + "from reactpy_jupyter import from_widget\n", "\n", "\n", "@component\n", "def SliderObserver(slider):\n", + " slider_component = from_widget(slider)\n", " value, set_value = use_state(0)\n", "\n", " @use_effect\n", @@ -214,63 +270,122 @@ " # unobserve the slider's value if this component is no longer displayed\n", " return lambda: slider.unobserve(handle_change, \"value\")\n", "\n", - " return html.p(f\"ReactPy observes the value to be: \", value)" + " return html.div(\n", + " slider_component, html.p(f\"ReactPy observes the value to be: \", value)\n", + " )" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Now you'll need to display the `SliderObserver` component as well as an `IntSlider` widget. To do this, you'll want wrap the component in a `reactpy_jupyter.LayoutWidget` instance before using it alongside other Jupyter Widgets. Specifically, you'll be displaying the `SliderObserver` and `IntSlider` in a `Box`:\n" + "Now you need to pass the `SliderObserver` component an `IntSlider` widget and display it.\n" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "metadata": { "tags": [] }, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "c586a0f0fe0b44a3bab173ccb9fa52d8", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "LayoutWidget(Layout(ContextProvider()))" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "from ipywidgets import Box, IntSlider\n", - "from reactpy_jupyter import LayoutWidget\n", - "\n", - "slider = IntSlider(readout=False)\n", - "slider_observer = LayoutWidget(SliderObserver(slider))\n", + "from ipywidgets import IntSlider\n", "\n", - "Box([slider, slider_observer])" + "SliderObserver(IntSlider(readout=False))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "If it becomes painful to wrap every ReactPy component in a `LayoutWidget` you can create an alternate `LayoutWidget` constructor using `reactpy_jupyter.widgetize`:" + "You can also include ReactPy components within Jupyter Widgets using `reactpy_jupyter.to_widget`" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 8, "metadata": { "tags": [] }, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "a0180ea4781b4dda9f77d1be75fbcad3", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Box(children=(IntSlider(value=0, readout=False), LayoutWidget(Layout(ContextProvider())), LayoutWidget(Layout…" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "slider = IntSlider(readout=False)\n", + "slider_observer_constructor = to_widget(SliderObserver)\n", + "observer_1 = slider_observer_constructor(slider)\n", + "observer_2 = slider_observer_constructor(slider)\n", + "\n", + "Box([observer_1, observer_2])" ] }, { diff --git a/noxfile.py b/noxfile.py index 053460b..b3a09b0 100644 --- a/noxfile.py +++ b/noxfile.py @@ -3,6 +3,20 @@ group = NoxOpt(auto_tag=True) +@group.session +def fix_lint(session: Session) -> None: + session.install( + "black[jupyter]", + "flake8-pyproject", + "flake8", + "isort", + ) + session.run("black", ".") + session.run("isort", ".") + + session.run("npm", "run", "fix:lint", external=True) + + @group.session def check_python(session: Session) -> None: session.install( diff --git a/package-lock.json b/package-lock.json index bdcaf28..8082e81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,8 +16,7 @@ "@preact/preset-vite": "^2.5.0", "@types/react": "<18", "@types/react-dom": "<18", - "eslint": "^8.38.0", - "eslint-plugin-jsdoc": "^41.1.1", + "eslint": "^8.40.0", "prettier": "^2.8.7", "typescript": "^5.0.2", "vite": "^4.2.1" @@ -396,20 +395,6 @@ "node": ">=6.9.0" } }, - "node_modules/@es-joy/jsdoccomment": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.37.0.tgz", - "integrity": "sha512-hjK0wnsPCYLlF+HHB4R/RbUjOWeLW2SlarB67+Do5WsKILOkmIZvvPJFbtWSmbypxcjpoECLAMzoao0D4Bg5ZQ==", - "dev": true, - "dependencies": { - "comment-parser": "1.3.1", - "esquery": "^1.4.0", - "jsdoc-type-pratt-parser": "~4.0.0" - }, - "engines": { - "node": "^14 || ^16 || ^17 || ^18 || ^19" - } - }, "node_modules/@esbuild/android-arm": { "version": "0.17.16", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.16.tgz", @@ -778,23 +763,23 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.0.tgz", - "integrity": "sha512-vITaYzIcNmjn5tF5uxcZ/ft7/RXGrMUIS9HalWckEOF6ESiwXKoMzAQf2UW0aVd6rnOeExTJVd5hmWXucBKGXQ==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.1.tgz", + "integrity": "sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==", "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, "node_modules/@eslint/eslintrc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.2.tgz", - "integrity": "sha512-3W4f5tDUra+pA+FzgugqL2pRimUTDJWKr7BINqOpkZrC0uYI0NIc0/JFgBROCU07HR6GieA5m3/rsPIhDmCXTQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.3.tgz", + "integrity": "sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==", "dev": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.5.1", + "espree": "^9.5.2", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", @@ -825,9 +810,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.38.0.tgz", - "integrity": "sha512-IoD2MfUnOV58ghIHCiil01PcohxjbYR/qCxsoC+xNgUwh1EY8jOOrYmu3d3a71+tJJ23uscEV4X2HJWMsPJu4g==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.40.0.tgz", + "integrity": "sha512-ElyB54bJIhXQYVKjDSvCkPO1iU1tSAeVQJbllWJq1XQSmmA4dgFk8CbiBGpiOPxleE48vDogxCtmMYku4HSVLA==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -1377,15 +1362,6 @@ "node": ">=4" } }, - "node_modules/are-docs-informative": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", - "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", - "dev": true, - "engines": { - "node": ">=14" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1511,15 +1487,6 @@ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true }, - "node_modules/comment-parser": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.3.1.tgz", - "integrity": "sha512-B52sN2VNghyq5ofvUsqZjmk6YkihBX5vMSChmSK9v4ShjKf3Vk5Xcmgpw4o+iIgtrnM/u5FiMpz9VKb8lpBveA==", - "dev": true, - "engines": { - "node": ">= 12.0.0" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1656,15 +1623,15 @@ } }, "node_modules/eslint": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.38.0.tgz", - "integrity": "sha512-pIdsD2jwlUGf/U38Jv97t8lq6HpaU/G9NKbYmpWpZGw3LdTNhZLbJePqxOXGB5+JEKfOPU/XLxYxFh03nr1KTg==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.40.0.tgz", + "integrity": "sha512-bvR+TsP9EHL3TqNtj9sCNJVAFK3fBN8Q7g5waghxyRsPLIMwL73XSKnZFK0hk/O2ANC+iAoq6PWMQ+IfBAJIiQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.4.0", - "@eslint/eslintrc": "^2.0.2", - "@eslint/js": "8.38.0", + "@eslint/eslintrc": "^2.0.3", + "@eslint/js": "8.40.0", "@humanwhocodes/config-array": "^0.11.8", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", @@ -1674,9 +1641,9 @@ "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.1", - "eslint-visitor-keys": "^3.4.0", - "espree": "^9.5.1", + "eslint-scope": "^7.2.0", + "eslint-visitor-keys": "^3.4.1", + "espree": "^9.5.2", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -1712,73 +1679,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-plugin-jsdoc": { - "version": "41.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-41.1.1.tgz", - "integrity": "sha512-dfH97DKLGtQ5dgEMzd+GSUuY+xX/yyAfjML3O0pEWmMMpylsG6Ro65s4ziYXKmixiENYK9CTQxCVRGqZUFN2Mw==", - "dev": true, - "dependencies": { - "@es-joy/jsdoccomment": "~0.37.0", - "are-docs-informative": "^0.0.2", - "comment-parser": "1.3.1", - "debug": "^4.3.4", - "escape-string-regexp": "^4.0.0", - "esquery": "^1.5.0", - "semver": "^7.3.8", - "spdx-expression-parse": "^3.0.1" - }, - "engines": { - "node": "^14 || ^16 || ^17 || ^18 || ^19" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - } - }, - "node_modules/eslint-plugin-jsdoc/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint-plugin-jsdoc/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/eslint-plugin-jsdoc/node_modules/semver": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.4.0.tgz", - "integrity": "sha512-RgOxM8Mw+7Zus0+zcLEUn8+JfoLpj/huFTItQy2hsM4khuC1HYRDp0cU482Ewn/Fcy6bCjufD8vAj7voC66KQw==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/eslint-plugin-jsdoc/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/eslint-scope": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.0.tgz", @@ -1796,9 +1696,9 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz", - "integrity": "sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz", + "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -1905,14 +1805,14 @@ } }, "node_modules/espree": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz", - "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==", + "version": "9.5.2", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.2.tgz", + "integrity": "sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==", "dev": true, "dependencies": { "acorn": "^8.8.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.0" + "eslint-visitor-keys": "^3.4.1" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -2287,15 +2187,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsdoc-type-pratt-parser": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz", - "integrity": "sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==", - "dev": true, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -2881,28 +2772,6 @@ "node": ">=0.10.0" } }, - "node_modules/spdx-exceptions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", - "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", - "dev": true - }, - "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "dev": true, - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz", - "integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==", - "dev": true - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -3462,17 +3331,6 @@ "to-fast-properties": "^2.0.0" } }, - "@es-joy/jsdoccomment": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.37.0.tgz", - "integrity": "sha512-hjK0wnsPCYLlF+HHB4R/RbUjOWeLW2SlarB67+Do5WsKILOkmIZvvPJFbtWSmbypxcjpoECLAMzoao0D4Bg5ZQ==", - "dev": true, - "requires": { - "comment-parser": "1.3.1", - "esquery": "^1.4.0", - "jsdoc-type-pratt-parser": "~4.0.0" - } - }, "@esbuild/android-arm": { "version": "0.17.16", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.16.tgz", @@ -3637,20 +3495,20 @@ } }, "@eslint-community/regexpp": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.0.tgz", - "integrity": "sha512-vITaYzIcNmjn5tF5uxcZ/ft7/RXGrMUIS9HalWckEOF6ESiwXKoMzAQf2UW0aVd6rnOeExTJVd5hmWXucBKGXQ==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.1.tgz", + "integrity": "sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==", "dev": true }, "@eslint/eslintrc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.2.tgz", - "integrity": "sha512-3W4f5tDUra+pA+FzgugqL2pRimUTDJWKr7BINqOpkZrC0uYI0NIc0/JFgBROCU07HR6GieA5m3/rsPIhDmCXTQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.3.tgz", + "integrity": "sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==", "dev": true, "requires": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.5.1", + "espree": "^9.5.2", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", @@ -3671,9 +3529,9 @@ } }, "@eslint/js": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.38.0.tgz", - "integrity": "sha512-IoD2MfUnOV58ghIHCiil01PcohxjbYR/qCxsoC+xNgUwh1EY8jOOrYmu3d3a71+tJJ23uscEV4X2HJWMsPJu4g==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.40.0.tgz", + "integrity": "sha512-ElyB54bJIhXQYVKjDSvCkPO1iU1tSAeVQJbllWJq1XQSmmA4dgFk8CbiBGpiOPxleE48vDogxCtmMYku4HSVLA==", "dev": true }, "@humanwhocodes/config-array": { @@ -4157,12 +4015,6 @@ "color-convert": "^1.9.0" } }, - "are-docs-informative": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", - "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", - "dev": true - }, "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4250,12 +4102,6 @@ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true }, - "comment-parser": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.3.1.tgz", - "integrity": "sha512-B52sN2VNghyq5ofvUsqZjmk6YkihBX5vMSChmSK9v4ShjKf3Vk5Xcmgpw4o+iIgtrnM/u5FiMpz9VKb8lpBveA==", - "dev": true - }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4364,15 +4210,15 @@ "dev": true }, "eslint": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.38.0.tgz", - "integrity": "sha512-pIdsD2jwlUGf/U38Jv97t8lq6HpaU/G9NKbYmpWpZGw3LdTNhZLbJePqxOXGB5+JEKfOPU/XLxYxFh03nr1KTg==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.40.0.tgz", + "integrity": "sha512-bvR+TsP9EHL3TqNtj9sCNJVAFK3fBN8Q7g5waghxyRsPLIMwL73XSKnZFK0hk/O2ANC+iAoq6PWMQ+IfBAJIiQ==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.4.0", - "@eslint/eslintrc": "^2.0.2", - "@eslint/js": "8.38.0", + "@eslint/eslintrc": "^2.0.3", + "@eslint/js": "8.40.0", "@humanwhocodes/config-array": "^0.11.8", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", @@ -4382,9 +4228,9 @@ "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.1", - "eslint-visitor-keys": "^3.4.0", - "espree": "^9.5.1", + "eslint-scope": "^7.2.0", + "eslint-visitor-keys": "^3.4.1", + "espree": "^9.5.2", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -4477,54 +4323,6 @@ } } }, - "eslint-plugin-jsdoc": { - "version": "41.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-41.1.1.tgz", - "integrity": "sha512-dfH97DKLGtQ5dgEMzd+GSUuY+xX/yyAfjML3O0pEWmMMpylsG6Ro65s4ziYXKmixiENYK9CTQxCVRGqZUFN2Mw==", - "dev": true, - "requires": { - "@es-joy/jsdoccomment": "~0.37.0", - "are-docs-informative": "^0.0.2", - "comment-parser": "1.3.1", - "debug": "^4.3.4", - "escape-string-regexp": "^4.0.0", - "esquery": "^1.5.0", - "semver": "^7.3.8", - "spdx-expression-parse": "^3.0.1" - }, - "dependencies": { - "escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true - }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "semver": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.4.0.tgz", - "integrity": "sha512-RgOxM8Mw+7Zus0+zcLEUn8+JfoLpj/huFTItQy2hsM4khuC1HYRDp0cU482Ewn/Fcy6bCjufD8vAj7voC66KQw==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - } - } - }, "eslint-scope": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.0.tgz", @@ -4536,20 +4334,20 @@ } }, "eslint-visitor-keys": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz", - "integrity": "sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz", + "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==", "dev": true }, "espree": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz", - "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==", + "version": "9.5.2", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.2.tgz", + "integrity": "sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==", "dev": true, "requires": { "acorn": "^8.8.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.0" + "eslint-visitor-keys": "^3.4.1" } }, "esquery": { @@ -4835,12 +4633,6 @@ "argparse": "^2.0.1" } }, - "jsdoc-type-pratt-parser": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz", - "integrity": "sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==", - "dev": true - }, "jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -5230,28 +5022,6 @@ "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", "dev": true }, - "spdx-exceptions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", - "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", - "dev": true - }, - "spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "dev": true, - "requires": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-license-ids": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz", - "integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==", - "dev": true - }, "strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", diff --git a/package.json b/package.json index 0b8b111..d6cec7a 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "lint": "prettier --ignore-path .gitignore --check ." + "lint": "prettier --ignore-path .gitignore --check . && eslint --ignore-path .gitignore .", + "fix:lint": "prettier --ignore-path .gitignore --write . && eslint --ignore-path .gitignore --fix ." }, "dependencies": { "@jupyter-widgets/base": "^6.0.4", @@ -17,6 +18,7 @@ "@preact/preset-vite": "^2.5.0", "@types/react": "<18", "@types/react-dom": "<18", + "eslint": "^8.40.0", "prettier": "^2.8.7", "typescript": "^5.0.2", "vite": "^4.2.1" diff --git a/reactpy_jupyter.pth b/reactpy_jupyter.pth new file mode 100644 index 0000000..79174b8 --- /dev/null +++ b/reactpy_jupyter.pth @@ -0,0 +1 @@ +import reactpy_jupyter diff --git a/reactpy_jupyter/__init__.py b/reactpy_jupyter/__init__.py index f8ca9fa..81424e3 100644 --- a/reactpy_jupyter/__init__.py +++ b/reactpy_jupyter/__init__.py @@ -6,20 +6,22 @@ from . import jupyter_server_extension from .import_resources import setup_import_resources -from .ipython_extension import load_ipython_extension, unload_ipython_extension -from .widget import LayoutWidget, run, set_import_source_base_url, widgetize +from .layout_widget import run, set_import_source_base_url, to_widget +from .monkey_patch import execute_patch +from .widget_component import from_widget __version__ = "0.8.1" # DO NOT MODIFY -__all__ = [ - "LayoutWidget", - "widgetize", - "run", +__all__ = ( + "from_widget", "load_ipython_extension", "unload_ipython_extension", + "to_widget", + "run", "set_import_source_base_url", "jupyter_server_extension", -] +) setup_import_resources() +execute_patch() diff --git a/reactpy_jupyter/import_resources.py b/reactpy_jupyter/import_resources.py index 0a0a91d..e57d0bb 100644 --- a/reactpy_jupyter/import_resources.py +++ b/reactpy_jupyter/import_resources.py @@ -14,7 +14,7 @@ REACTPY_RESOURCE_BASE_PATH, REACTPY_WEB_MODULES_DIR, ) -from .widget import set_import_source_base_url +from .layout_widget import set_import_source_base_url logger = logging.getLogger(__name__) diff --git a/reactpy_jupyter/ipython_extension.py b/reactpy_jupyter/ipython_extension.py deleted file mode 100644 index c2a35ed..0000000 --- a/reactpy_jupyter/ipython_extension.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from functools import partial - -from IPython import get_ipython -from IPython.core.interactiveshell import ExecutionResult, InteractiveShell -from IPython.display import display -from reactpy.core.component import ComponentType - -from .widget import LayoutWidget - -_EXTENSION_LOADED = False -_POST_RUN_CELL_HOOK = None - - -def load_ipython_extension(ipython: InteractiveShell) -> None: - global _POST_RUN_CELL_HOOK, _EXTENSION_LOADED - if not _EXTENSION_LOADED: - _POST_RUN_CELL_HOOK = partial(_post_run_cell, ipython) - ipython.events.register("post_run_cell", _POST_RUN_CELL_HOOK) - ipython.display_formatter.ipython_display_formatter.for_type( - ComponentType, lambda component: ({}, {}) - ) - _EXTENSION_LOADED = True - - -def unload_ipython_extension(ipython: InteractiveShell) -> None: - global _POST_RUN_CELL_HOOK, _EXTENSION_LOADED - ipython.events.unregister("post_run_cell", _POST_RUN_CELL_HOOK) - _POST_RUN_CELL_HOOK = None - _EXTENSION_LOADED = False - - -def _post_run_cell(ipython: InteractiveShell, result: ExecutionResult) -> None: - if isinstance(result.result, ComponentType): - display(LayoutWidget(result.result)) - - -if get_ipython() is not None: - load_ipython_extension(get_ipython()) diff --git a/reactpy_jupyter/widget.py b/reactpy_jupyter/layout_widget.py similarity index 67% rename from reactpy_jupyter/widget.py rename to reactpy_jupyter/layout_widget.py index 397d055..4a7de1a 100644 --- a/reactpy_jupyter/widget.py +++ b/reactpy_jupyter/layout_widget.py @@ -1,31 +1,27 @@ from __future__ import annotations import asyncio -import os from functools import wraps from pathlib import Path from queue import Queue as SyncQueue from threading import Thread -from typing import Any, Awaitable, Callable +from typing import Any, Awaitable, Callable, overload import anywidget from IPython.display import DisplayHandle from IPython.display import display as ipython_display +from ipywidgets import Widget, widget_serialization from jsonpointer import set_pointer from reactpy.core.layout import Layout from reactpy.core.types import ComponentType -from traitlets import Unicode +from traitlets import Instance, List, Unicode from typing_extensions import ParamSpec -DEV = bool(int(os.environ.get("REACTPY_JUPYTER_DEV", "0"))) +from reactpy_jupyter.widget_component import InnerWidgets, inner_widgets_context -if DEV: - # from `npx vite` - ESM = "http://localhost:5173/src/index.js?anywidget" -else: - # from `npx vite build` - bundled_assets_dir = Path(__file__).parent / "static" - ESM = (bundled_assets_dir / "index.js").read_text() +# from `npx vite build` +bundled_assets_dir = Path(__file__).parent / "static" +ESM = (bundled_assets_dir / "index.js").read_text() def set_import_source_base_url(base_url: str) -> None: @@ -45,12 +41,27 @@ def run(constructor: Callable[[], ComponentType]) -> DisplayHandle | None: _P = ParamSpec("_P") -def widgetize(constructor: Callable[_P, ComponentType]) -> Callable[_P, LayoutWidget]: - """A decorator that turns an ReactPy element into a Jupyter Widget constructor""" +@overload +def to_widget(value: Callable[_P, ComponentType]) -> Callable[_P, LayoutWidget]: + ... - @wraps(constructor) + +@overload +def to_widget(value: ComponentType) -> LayoutWidget: + ... + + +def to_widget( + value: Callable[_P, ComponentType] | ComponentType +) -> Callable[_P, LayoutWidget] | LayoutWidget: + """Turn a component into a widget or a component construtor into a widget constructor""" + + if isinstance(value, ComponentType): + return LayoutWidget(value) + + @wraps(value) def wrapper(*args: Any, **kwargs: Any) -> LayoutWidget: - return LayoutWidget(constructor(*args, **kwargs)) + return LayoutWidget(value(*args, **kwargs)) return wrapper @@ -60,12 +71,21 @@ class LayoutWidget(anywidget.AnyWidget): _esm = ESM _import_source_base_url = Unicode().tag(sync=True) + _inner_widgets = List(Instance(Widget)).tag(sync=True, **widget_serialization) def __init__(self, component: ComponentType) -> None: - super().__init__(_import_source_base_url=_IMPORT_SOURCE_BASE_URL) + super().__init__( + _import_source_base_url=_IMPORT_SOURCE_BASE_URL, + _inner_widgets=[], + ) self._reactpy_model = {} self._reactpy_views = set() - self._reactpy_layout = Layout(component) + self._reactpy_layout = Layout( + inner_widgets_context( + component, + value=InnerWidgets(self._add_inner_widget, self._remove_inner_widget), + ) + ) self._reactpy_loop = _spawn_threaded_event_loop( self._reactpy_layout_render_loop() ) @@ -107,9 +127,20 @@ async def _reactpy_layout_render_loop(self) -> None: for v_id in self._reactpy_views: self.send({"viewID": v_id, "data": update_message}) + def _add_inner_widget(self, widget: Widget) -> None: + self._inner_widgets = self._inner_widgets + [widget] + + def _remove_inner_widget(self, widget: Widget) -> None: + self._inner_widgets = [w for w in self._inner_widgets if w != widget] + def __repr__(self) -> str: return f"LayoutWidget({self._reactpy_layout})" + @classmethod + def _dev(cls) -> None: + """Load the widget from the dev server""" + cls._esm = "http://localhost:5173/src/index.js" + def _spawn_threaded_event_loop( coro: Callable[..., Awaitable[Any]] diff --git a/reactpy_jupyter/monkey_patch.py b/reactpy_jupyter/monkey_patch.py new file mode 100644 index 0000000..a60452e --- /dev/null +++ b/reactpy_jupyter/monkey_patch.py @@ -0,0 +1,25 @@ +from typing import Any +from weakref import finalize + +from reactpy.core.component import Component + +from reactpy_jupyter.layout_widget import to_widget + +# we can't track the widgets by adding them as a hidden attribute to the component +# because Component has __slots__ defined +LIVE_WIDGETS: dict[int, Any] = {} + + +def execute_patch() -> None: + """Monkey patch ReactPy's Component class to display as a Jupyter widget""" + + def _repr_mimebundle_(self: Component, *a, **kw) -> None: + self_id = id(self) + if self_id not in LIVE_WIDGETS: + widget = LIVE_WIDGETS[self_id] = to_widget(self) + finalize(self, lambda: LIVE_WIDGETS.pop(self_id, None)) + else: + widget = LIVE_WIDGETS[self_id] + return widget._repr_mimebundle_(*a, **kw) + + Component._repr_mimebundle_ = _repr_mimebundle_ diff --git a/reactpy_jupyter/widget_component.py b/reactpy_jupyter/widget_component.py new file mode 100644 index 0000000..fd96971 --- /dev/null +++ b/reactpy_jupyter/widget_component.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Callable + +from attr import dataclass +from ipywidgets import Widget +from reactpy import component, create_context, html, use_context, use_effect +from reactpy.types import Context, VdomDict + +inner_widgets_context: Context[InnerWidgets | None] = create_context(None) + + +@component +def from_widget(source: Widget) -> VdomDict: + inner_widgets = use_context(inner_widgets_context) + + @use_effect + def add_widget(): + inner_widgets.add(source) + return lambda: inner_widgets.remove(source) + + if inner_widgets is None: + raise RuntimeError("Jupyter component must be rendered inside a JupyterLayout") + + return html.span({"class": f"widget-model-id-{source.model_id}"}) + + +@dataclass +class InnerWidgets: + add: Callable[[Widget], None] + remove: Callable[[Widget], None] diff --git a/requirements.txt b/requirements.txt index 21ada24..bdd7471 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ twine jupyter-packaging jupyter-repo2docker noxopt +jupyterlab + diff --git a/setup.py b/setup.py index 9271ee7..3aa16b8 100644 --- a/setup.py +++ b/setup.py @@ -5,8 +5,11 @@ import subprocess import sys import traceback +from distutils.cmd import Command +from functools import partial from logging import StreamHandler, getLogger from pathlib import Path +from typing import Callable from jupyter_packaging import get_data_files from setuptools import find_packages, setup @@ -136,46 +139,68 @@ def list2cmdline(cmd_list): ) # -------------------------------------------------------------------------------------- -# Build Javascript +# Command Classes # -------------------------------------------------------------------------------------- -def build_javascript_first(cls): +def build_javascript_first(cmd: Command): + log.info("Installing Javascript...") + try: + npm = shutil.which("npm") # this is required on windows + if npm is None: + raise RuntimeError("NPM is not installed.") + for args in (f"{npm} ci", f"{npm} run build"): + args_list = args.split() + log.info(f"> {list2cmdline(args_list)}") + subprocess.run(args_list, cwd=str(ROOT_DIR), check=True) + except Exception: + log.error("Failed to install Javascript") + log.error(traceback.format_exc()) + raise + else: + log.info("Successfully installed Javascript") + + +def build_with_pth_file(cmd: Command): + # install the pth file + pth_filename = f"{NAME}.pth" + source_pth_file = ROOT_DIR / pth_filename + source_pth_file = Path(cmd.build_lib, pth_filename) + cmd.copy_file(str(source_pth_file), str(source_pth_file)) + + +def add_to_cmd(cls: Command, functions: list[Callable[[Command], None]]) -> Command: class Command(cls): def run(self): - log.info("Installing Javascript...") - try: - npm = shutil.which("npm") # this is required on windows - if npm is None: - raise RuntimeError("NPM is not installed.") - for args in (f"{npm} ci", f"{npm} run build"): - args_list = args.split() - log.info(f"> {list2cmdline(args_list)}") - subprocess.run(args_list, cwd=str(ROOT_DIR), check=True) - except Exception: - log.error("Failed to install Javascript") - log.error(traceback.format_exc()) - raise - else: - log.info("Successfully installed Javascript") super().run() + for f in functions: + f(self) return Command +cmd_additions = partial( + add_to_cmd, + functions=[ + build_javascript_first, + build_with_pth_file, + ], +) + + package["cmdclass"] = { - "sdist": build_javascript_first(sdist), - "develop": build_javascript_first(develop), + "sdist": cmd_additions(sdist), + "develop": cmd_additions(develop), } if sys.version_info < (3, 10, 6): from distutils.command.build import build - package["cmdclass"]["build"] = build_javascript_first(build) + package["cmdclass"]["build"] = cmd_additions(build) else: from setuptools.command.build_py import build_py - package["cmdclass"]["build_py"] = build_javascript_first(build_py) + package["cmdclass"]["build_py"] = cmd_additions(build_py) # -------------------------------------------------------------------------------------- diff --git a/src/index.js b/src/index.js index d69e189..3af685b 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,33 @@ +/** + * @typedef {import("@jupyter-widgets/base").DOMWidgetView} DOMWidgetView + */ import { BaseReactPyClient, mount } from "@reactpy/client"; -import { DOMWidgetView } from "@jupyter-widgets/base"; /**@param view {DOMWidgetView} view */ export function render(view) { const client = new JupyterReactPyClient(view); mount(view.el, client); + + async function updateInnerWidgets() { + /** @type {String[]} */ + let innerModelIds = view.model.get("_inner_widgets"); + + for (let modelId of innerModelIds.map((id) => + id.slice("IPY_MODEL_".length) + )) { + let model = await view.model.widget_manager.get_model(modelId); + (await waitForSelectorAll(`.widget-model-id-${modelId}`, view.el)).map( + async (containerEl) => { + let childView = await view.create_child_view(model); + containerEl.replaceChildren(childView.el); + } + ); + } + } + + view.model.on("change:_inner_widgets", updateInnerWidgets); + + updateInnerWidgets(); } let viewID = 0; @@ -12,9 +35,8 @@ let viewID = 0; class JupyterReactPyClient extends BaseReactPyClient { /** * @param view {DOMWidgetView} - * @param viewID {number} */ - constructor(view, viewId) { + constructor(view) { super(); this.view = view; this.viewID = viewID++; @@ -86,10 +108,10 @@ const jupyterServerBaseUrl = (() => { })(); function concatAndResolveUrl(url, concat) { - var url1 = (url.endsWith("/") ? url.slice(0, -1) : url).split("/"); - var url2 = concat.split("/"); - var url3 = []; - for (var i = 0, l = url1.length; i < l; i++) { + let url1 = (url.endsWith("/") ? url.slice(0, -1) : url).split("/"); + let url2 = concat.split("/"); + let url3 = []; + for (let i = 0, l = url1.length; i < l; i++) { if (url1[i] == "..") { url3.pop(); } else if (url1[i] == ".") { @@ -98,7 +120,7 @@ function concatAndResolveUrl(url, concat) { url3.push(url1[i]); } } - for (var i = 0, l = url2.length; i < l; i++) { + for (let i = 0, l = url2.length; i < l; i++) { if (url2[i] == "..") { url3.pop(); } else if (url2[i] == ".") { @@ -109,3 +131,34 @@ function concatAndResolveUrl(url, concat) { } return url3.join("/"); } + +/** + * @param {String} selector + * @param {HTMLElement} containerElement + * @returns {Promise} + */ +function waitForSelectorAll(selector, containerElement) { + return new Promise((resolve) => { + const resolveSearch = () => { + const elements = Array.from(document.querySelectorAll(selector)); + if (elements.length) { + resolve(elements); + return true; + } + return false; + }; + + if (resolveSearch()) { + return; + } + + const observer = new MutationObserver(() => + resolveSearch() ? observer.disconnect() : null + ); + + observer.observe(containerElement, { + childList: true, + subtree: true, + }); + }); +}