├── .bumpversion.cfg ├── .github ├── requirements.txt └── workflows │ └── unit.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── MANIFEST.in ├── README.md ├── RELEASE.md ├── class_components.ipynb ├── examples ├── DeepWatch.ipynb ├── EmbeddingJupyterWidgetsInVueTemplate.ipynb ├── fullVueComponent.ipynb └── hot-reload │ ├── hot-reload.ipynb │ ├── my_component.vue │ ├── my_sub_component.vue │ ├── my_widget_template.vue │ └── mywidget.py ├── ipyvue ├── ForceLoad.py ├── Html.py ├── Template.py ├── VueComponentRegistry.py ├── VueTemplateWidget.py ├── VueWidget.py ├── __init__.py └── _version.py ├── js ├── .babelrc ├── .eslintrc.js ├── README.md ├── package-lock.json ├── package.json ├── src │ ├── ForceLoad.js │ ├── Html.js │ ├── Template.js │ ├── VueComponentModel.js │ ├── VueModel.js │ ├── VueRenderer.js │ ├── VueTemplateModel.js │ ├── VueTemplateRenderer.js │ ├── VueView.js │ ├── VueWithCompiler.js │ ├── embed.js │ ├── extension.js │ ├── httpVueLoader.js │ ├── index.js │ ├── labplugin.js │ ├── nodeps.js │ └── nodepsVueWithCompiler.js └── webpack.config.js ├── jupyter-vue.json ├── release.sh ├── resources └── msd-logo.svg ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── ui ├── test_events.py ├── test_nested.py ├── test_template.py └── test_watchers.py └── unit └── test_vue_widget.py /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.11.2 3 | commit = True 4 | message = chore: bump version: {current_version} → {new_version} 5 | tag = True 6 | parse = (?P\d+)(\.(?P\d+))(\.(?P\d+))((?P.)(?P\d+))? 7 | serialize = 8 | {major}.{minor}.{patch}{release}{build} 9 | {major}.{minor}.{patch} 10 | 11 | [bumpversion:part:release] 12 | optional_value = g 13 | first_value = g 14 | values = 15 | a 16 | b 17 | g 18 | 19 | [bumpversion:file:ipyvue/_version.py] 20 | 21 | [bumpversion:file:js/package.json] 22 | -------------------------------------------------------------------------------- /.github/requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | coverage 3 | black==22.8.0 4 | -------------------------------------------------------------------------------- /.github/workflows/unit.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - v* 9 | pull_request: 10 | workflow_dispatch: 11 | 12 | jobs: 13 | code-quality: 14 | runs-on: ubuntu-20.04 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Install Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: "3.11" 21 | - name: Install dependencies 22 | run: pip install -r .github/requirements.txt 23 | - name: Run black 24 | run: black . --check 25 | - name: Setup flake8 annotations 26 | uses: rbialon/flake8-annotations@v1 27 | 28 | build: 29 | runs-on: ubuntu-20.04 30 | steps: 31 | - uses: actions/checkout@v2 32 | 33 | - name: Install node 34 | uses: actions/setup-node@v1 35 | with: 36 | node-version: "14.x" 37 | registry-url: "https://registry.npmjs.org" 38 | 39 | - name: Install Python 40 | uses: actions/setup-python@v2 41 | with: 42 | python-version: "3.11" 43 | 44 | - name: Install dependencies 45 | run: | 46 | python -m pip install --upgrade pip 47 | pip install twine wheel jupyter-packaging "jupyterlab<4" 48 | 49 | - name: Build 50 | run: | 51 | python setup.py sdist bdist_wheel 52 | 53 | - name: Build 54 | run: | 55 | cd js 56 | npm pack 57 | 58 | - name: Check number of files in wheel 59 | run: | 60 | wheel=(dist/*.whl) 61 | unzip -d count ${wheel} 62 | ls -1 count 63 | if [[ $(ls -1 count | wc -l) -ne 3 ]]; then echo "Expected 4 files/directory"; exit 1; fi 64 | 65 | - name: Upload builds 66 | uses: actions/upload-artifact@v4 67 | with: 68 | name: ipyvue-dist-${{ github.run_number }} 69 | path: | 70 | ./dist 71 | ./js/*.tgz 72 | 73 | test: 74 | needs: [build] 75 | runs-on: ubuntu-20.04 76 | strategy: 77 | fail-fast: false 78 | matrix: 79 | python-version: [3.6, 3.7, 3.8, 3.9, "3.10", "3.11"] 80 | steps: 81 | - uses: actions/checkout@v2 82 | 83 | - uses: actions/download-artifact@v4 84 | with: 85 | name: ipyvue-dist-${{ github.run_number }} 86 | 87 | - name: Install Python 88 | uses: actions/setup-python@v2 89 | with: 90 | python-version: ${{ matrix.python-version }} 91 | 92 | - name: Install dependencies 93 | run: pip install -r .github/requirements.txt 94 | 95 | - name: Install 96 | run: pip install dist/*.whl 97 | 98 | - name: test with pytest 99 | run: coverage run -m pytest --color=yes tests/unit 100 | 101 | - name: Import 102 | # do the import in a subdirectory, as after installation, files in de current directory are also imported 103 | run: | 104 | (mkdir test-install; cd test-install; python -c "from ipyvue import Html") 105 | 106 | ui-test: 107 | needs: [build] 108 | runs-on: ubuntu-20.04 109 | steps: 110 | - uses: actions/checkout@v2 111 | 112 | - uses: actions/download-artifact@v4 113 | with: 114 | name: ipyvue-dist-${{ github.run_number }} 115 | 116 | - name: Install Python 117 | uses: actions/setup-python@v2 118 | with: 119 | python-version: 3.8 120 | 121 | - name: Install vuetify and test deps 122 | run: | 123 | wheel=(dist/*.whl) 124 | pip install ${wheel}[test] "jupyter_server<2" 125 | 126 | - name: Install playwright browsers 127 | run: playwright install chromium 128 | 129 | - name: Run ui-tests 130 | run: pytest tests/ui/ --video=retain-on-failure --solara-update-snapshots-ci -s 131 | 132 | - name: Upload Test artifacts 133 | if: always() 134 | uses: actions/upload-artifact@v4 135 | with: 136 | name: ipyvue-test-results 137 | path: test-results 138 | 139 | release-dry-run: 140 | needs: [ test,ui-test,code-quality ] 141 | runs-on: ubuntu-20.04 142 | steps: 143 | - uses: actions/download-artifact@v4 144 | with: 145 | name: ipyvue-dist-${{ github.run_number }} 146 | 147 | - name: Install node 148 | uses: actions/setup-node@v1 149 | with: 150 | node-version: "14.x" 151 | registry-url: "https://registry.npmjs.org" 152 | 153 | - name: Publish the NPM package 154 | run: | 155 | cd js 156 | echo $PRE_RELEASE 157 | if [[ $PRE_RELEASE == "true" ]]; then export TAG="next"; else export TAG="latest"; fi 158 | npm publish --dry-run --tag ${TAG} --access public *.tgz 159 | env: 160 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 161 | PRE_RELEASE: ${{ github.event.release.prerelease }} 162 | release: 163 | if: startsWith(github.event.ref, 'refs/tags/v') 164 | needs: [release-dry-run] 165 | runs-on: ubuntu-20.04 166 | steps: 167 | - uses: actions/download-artifact@v4 168 | with: 169 | name: ipyvue-dist-${{ github.run_number }} 170 | 171 | - name: Install node 172 | uses: actions/setup-node@v1 173 | with: 174 | node-version: "14.x" 175 | registry-url: "https://registry.npmjs.org" 176 | 177 | - name: Install Python 178 | uses: actions/setup-python@v2 179 | with: 180 | python-version: 3.8 181 | 182 | - name: Install dependencies 183 | run: | 184 | python -m pip install --upgrade pip 185 | pip install twine wheel jupyter-packaging jupyterlab 186 | 187 | - name: Publish the Python package 188 | env: 189 | TWINE_USERNAME: __token__ 190 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 191 | run: twine upload --skip-existing dist/* 192 | 193 | - name: Publish the NPM package 194 | run: | 195 | cd js 196 | echo $PRE_RELEASE 197 | if [[ $PRE_RELEASE == "true" ]]; then export TAG="next"; else export TAG="latest"; fi 198 | npm publish --tag ${TAG} --access public *.tgz 199 | env: 200 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 201 | PRE_RELEASE: ${{ github.event.release.prerelease }} 202 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | .eggs/ 3 | .ipynb_checkpoints/ 4 | dist/ 5 | build/ 6 | *.py[cod] 7 | **/node_modules/ 8 | 9 | # Compiled javascript 10 | ipyvue/labextension/ 11 | ipyvue/nbextension/ 12 | js/lib 13 | js/jupyter-vue-*.tgz 14 | 15 | # coverage 16 | .coverage 17 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: "22.8.0" 4 | hooks: 5 | - id: black 6 | - repo: https://github.com/PyCQA/flake8 7 | rev: 3.9.2 8 | hooks: 9 | - id: flake8 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Mario Buikhuizen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | recursive-include ipyvue/nbextension *.* 3 | recursive-include ipyvue/labextension *.* 4 | include jupyter-vue.json 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Version](https://img.shields.io/npm/v/jupyter-vue.svg)](https://www.npmjs.com/package/jupyter-vue) 2 | [![Version](https://img.shields.io/pypi/v/ipyvue.svg)](https://pypi.python.org/project/ipyvue) 3 | [![Conda Version](https://img.shields.io/conda/vn/conda-forge/ipyvue.svg)](https://anaconda.org/conda-forge/ipyvue) 4 | [![black badge](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 5 | 6 | ipyvue 7 | ====== 8 | 9 | Jupyter widgets base for [Vue](https://vuejs.org/) libraries 10 | 11 | Installation 12 | ------------ 13 | 14 | To install use pip: 15 | 16 | $ pip install ipyvue 17 | $ jupyter nbextension enable --py --sys-prefix ipyvue 18 | 19 | 20 | For a development installation (requires npm), 21 | 22 | $ git clone https://github.com/mariobuikhuizen/ipyvue.git 23 | $ cd ipyvue 24 | $ pip install -e ".[dev]" 25 | $ jupyter nbextension install --py --symlink --sys-prefix ipyvue 26 | $ jupyter nbextension enable --py --sys-prefix ipyvue 27 | $ jupyter labextension develop . --overwrite 28 | 29 | Sponsors 30 | -------- 31 | 32 | Project ipyvue receives direct funding from the following sources: 33 | 34 | [![MSD](resources/msd-logo.svg)](https://msd.com) 35 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release a new version of ipyvue on PyPI and NPM: 2 | 3 | - assert you have a remote called "upstream" pointing to git@github.com:widgetti/ipyvue.git 4 | - assert you have an up-to-date and clean working directory on the branch master 5 | - run `./release.sh [patch | minor | major]` 6 | 7 | ## Making an alpha release 8 | 9 | $ ./release.sh patch --new-version 1.19.0a1 10 | 11 | ## Recover from a failed release 12 | 13 | If a release fails on CI, and you want to keep the history clean 14 | ``` 15 | # do fix 16 | $ git rebase -i HEAD~3 17 | $ git tag v1.19.0 -f && git push upstream master v1.19.0 -f 18 | ``` 19 | -------------------------------------------------------------------------------- /class_components.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "import ipywidgets as widgets\n", 10 | "import ipyvue as vue\n", 11 | "from traitlets import (Dict, Unicode, List, Instance, observe)" 12 | ] 13 | }, 14 | { 15 | "cell_type": "code", 16 | "execution_count": null, 17 | "metadata": {}, 18 | "outputs": [], 19 | "source": [ 20 | "class SubComponent(vue.VueTemplate):\n", 21 | " input = Unicode().tag(sync=True)\n", 22 | " something = Unicode('defaultvalue').tag(sync=True)\n", 23 | " template = Unicode('''\n", 24 | "
\n", 25 | " [{{ something }}] input: {{ input }} \n", 26 | "
\n", 27 | " ''').tag(sync=True)\n", 28 | " \n", 29 | " def vue_append_one(self, *args):\n", 30 | " self.input = f'{self.input}1'\n", 31 | " \n", 32 | "SubComponent(input='some text') " 33 | ] 34 | }, 35 | { 36 | "cell_type": "code", 37 | "execution_count": null, 38 | "metadata": { 39 | "scrolled": true 40 | }, 41 | "outputs": [], 42 | "source": [ 43 | "class MainComponent(vue.VueTemplate):\n", 44 | " texts = List(['xxxx', 'yyyy']).tag(sync=True)\n", 45 | " direct = Unicode('aaa').tag(sync=True)\n", 46 | " template = Unicode('''\n", 47 | "
\n", 48 | "
\n", 49 | " \n", 50 | "
\n", 51 | " \n", 52 | " \n", 53 | " \n", 54 | "
\n", 55 | " ''').tag(sync=True)\n", 56 | " \n", 57 | " components=Dict({\n", 58 | " 'sub-component': SubComponent,\n", 59 | " 'w-button': widgets.Button\n", 60 | " }).tag(sync=True, **vue.VueTemplate.class_component_serialization)\n", 61 | "\n", 62 | "mainComponent = MainComponent()\n", 63 | "mainComponent" 64 | ] 65 | }, 66 | { 67 | "cell_type": "code", 68 | "execution_count": null, 69 | "metadata": {}, 70 | "outputs": [], 71 | "source": [ 72 | "mainComponent.texts=['xxxx', 'zzzz']" 73 | ] 74 | }, 75 | { 76 | "cell_type": "code", 77 | "execution_count": null, 78 | "metadata": {}, 79 | "outputs": [], 80 | "source": [ 81 | "mainComponent._component_instances" 82 | ] 83 | }, 84 | { 85 | "cell_type": "code", 86 | "execution_count": null, 87 | "metadata": {}, 88 | "outputs": [], 89 | "source": [ 90 | "mainComponent.direct = 'bbb'" 91 | ] 92 | }, 93 | { 94 | "cell_type": "markdown", 95 | "metadata": {}, 96 | "source": [ 97 | "# Non serializable properties" 98 | ] 99 | }, 100 | { 101 | "cell_type": "code", 102 | "execution_count": null, 103 | "metadata": {}, 104 | "outputs": [], 105 | "source": [ 106 | "class Database():\n", 107 | " def __init__(self, url):\n", 108 | " self.url = url\n", 109 | "\n", 110 | "class DatabaseInfo(vue.VueTemplate):\n", 111 | " db = Instance(Database)\n", 112 | " info = Unicode().tag(sync=True)\n", 113 | " label = Unicode().tag(sync=True)\n", 114 | " \n", 115 | " template = Unicode('''\n", 116 | "
\n", 117 | " [{{ label }}] Db URL: {{ info }}\n", 118 | "
\n", 119 | " ''').tag(sync=True)\n", 120 | " \n", 121 | " @observe('db')\n", 122 | " def db_changed(self, change):\n", 123 | " self.info = self.db.url" 124 | ] 125 | }, 126 | { 127 | "cell_type": "code", 128 | "execution_count": null, 129 | "metadata": {}, 130 | "outputs": [], 131 | "source": [ 132 | "class MyApp(vue.VueTemplate):\n", 133 | " \n", 134 | " customer_db = Instance(Database).tag(sync_ref=True)\n", 135 | " supplier_db_collection = Dict().tag(sync_ref=True)\n", 136 | " \n", 137 | " template = Unicode('''\n", 138 | "
\n", 139 | " \n", 140 | " \n", 141 | " \n", 146 | " \n", 147 | " \n", 148 | "
\n", 149 | " ''').tag(sync=True)\n", 150 | " \n", 151 | " components = Dict({\n", 152 | " 'database-info': DatabaseInfo\n", 153 | " }).tag(sync=True, **vue.VueTemplate.class_component_serialization)\n", 154 | " \n", 155 | " def db_factory(self, url):\n", 156 | " return Database(url)\n", 157 | "\n", 158 | "my_app = MyApp(\n", 159 | " customer_db = Database('localhost/customers'),\n", 160 | " supplier_db_collection = {'preferred': [Database('localhost/intel')]}\n", 161 | ")\n", 162 | "my_app " 163 | ] 164 | }, 165 | { 166 | "cell_type": "code", 167 | "execution_count": null, 168 | "metadata": {}, 169 | "outputs": [], 170 | "source": [ 171 | "my_app.customer_db = Database('remote/customers_v2')" 172 | ] 173 | }, 174 | { 175 | "cell_type": "code", 176 | "execution_count": null, 177 | "metadata": {}, 178 | "outputs": [], 179 | "source": [ 180 | "my_app.supplier_db_collection = {'preferred': [Database('remote/amd')]}" 181 | ] 182 | }, 183 | { 184 | "cell_type": "code", 185 | "execution_count": null, 186 | "metadata": {}, 187 | "outputs": [], 188 | "source": [] 189 | } 190 | ], 191 | "metadata": { 192 | "kernelspec": { 193 | "display_name": "Python 3", 194 | "language": "python", 195 | "name": "python3" 196 | }, 197 | "language_info": { 198 | "codemirror_mode": { 199 | "name": "ipython", 200 | "version": 3 201 | }, 202 | "file_extension": ".py", 203 | "mimetype": "text/x-python", 204 | "name": "python", 205 | "nbconvert_exporter": "python", 206 | "pygments_lexer": "ipython3", 207 | "version": "3.7.3" 208 | } 209 | }, 210 | "nbformat": 4, 211 | "nbformat_minor": 2 212 | } 213 | -------------------------------------------------------------------------------- /examples/DeepWatch.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "import ipyvuetify as v\n", 10 | "import traitlets\n", 11 | "class MyDeep(v.VuetifyTemplate):\n", 12 | " deep = traitlets.Dict({'prop': 1,\n", 13 | " 'deeper': {'prop': 2}}).tag(sync=True)\n", 14 | " not_deep = traitlets.Unicode('x').tag(sync=True)\n", 15 | " deep_array = traitlets.List(['array']).tag(sync=True)\n", 16 | " deep_array2 = traitlets.List([{'prop': 'array2'}]).tag(sync=True)\n", 17 | " template = traitlets.Unicode('''\n", 18 | "
\n", 19 | " \n", 20 | " \n", 21 | " \n", 22 | " \n", 23 | " \n", 24 | " {{ deep.prop }} |\n", 25 | " {{ deep.deeper.prop }} |\n", 26 | " {{ not_deep }} |\n", 27 | " {{ deep_array[0] }} |\n", 28 | " {{ deep_array2[0].prop }}\n", 29 | "
\n", 30 | " ''').tag(sync=True)\n", 31 | " \n", 32 | "md = MyDeep()\n", 33 | "md" 34 | ] 35 | }, 36 | { 37 | "cell_type": "code", 38 | "execution_count": null, 39 | "metadata": {}, 40 | "outputs": [], 41 | "source": [ 42 | "[md.deep, md.not_deep, md.deep_array, md.deep_array2]" 43 | ] 44 | }, 45 | { 46 | "cell_type": "code", 47 | "execution_count": null, 48 | "metadata": {}, 49 | "outputs": [], 50 | "source": [ 51 | "md.deep['deeper']" 52 | ] 53 | }, 54 | { 55 | "cell_type": "code", 56 | "execution_count": null, 57 | "metadata": {}, 58 | "outputs": [], 59 | "source": [] 60 | } 61 | ], 62 | "metadata": { 63 | "kernelspec": { 64 | "display_name": "Python 3", 65 | "language": "python", 66 | "name": "python3" 67 | }, 68 | "language_info": { 69 | "codemirror_mode": { 70 | "name": "ipython", 71 | "version": 3 72 | }, 73 | "file_extension": ".py", 74 | "mimetype": "text/x-python", 75 | "name": "python", 76 | "nbconvert_exporter": "python", 77 | "pygments_lexer": "ipython3", 78 | "version": "3.7.6" 79 | } 80 | }, 81 | "nbformat": 4, 82 | "nbformat_minor": 4 83 | } 84 | -------------------------------------------------------------------------------- /examples/EmbeddingJupyterWidgetsInVueTemplate.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "import ipyvue as vue\n", 10 | "import ipywidgets as widgets\n", 11 | "from traitlets import (Unicode, List, Int, Bool, Any, Dict)\n", 12 | "\n", 13 | "slider1 = widgets.IntSlider(description='Slider 1', value=20)\n", 14 | "slider2 = widgets.IntSlider(description='Slider 2', value=40)\n", 15 | "\n", 16 | "class MyComponent(vue.VueTemplate):\n", 17 | " \n", 18 | " items = List([{\n", 19 | " 'title': 'Title 1',\n", 20 | " 'content': slider1\n", 21 | " }, {\n", 22 | " 'title': 'Title 2',\n", 23 | " 'content': slider2\n", 24 | " }]).tag(sync=True, **widgets.widget_serialization)\n", 25 | " \n", 26 | " single_widget = Any(\n", 27 | " widgets.IntSlider(description='Single slider', value=40)\n", 28 | " ).tag(sync=True, **widgets.widget_serialization)\n", 29 | "\n", 30 | "\n", 31 | " template=Unicode(\"\"\"\n", 32 | "
\n", 33 | "
\n", 34 | " {{ item.title }}: \n", 35 | "
\n", 36 | " Single widget: \n", 37 | "
\n", 38 | " \"\"\").tag(sync=True)\n", 39 | "\n", 40 | "my_component = MyComponent()\n", 41 | "my_component" 42 | ] 43 | }, 44 | { 45 | "cell_type": "code", 46 | "execution_count": null, 47 | "metadata": {}, 48 | "outputs": [], 49 | "source": [ 50 | "# add a widget\n", 51 | "slider3 = widgets.IntSlider(description='Slider 3', value=60)\n", 52 | "my_component.items=[*my_component.items, {'title': 'Title 3', 'content': slider3}]" 53 | ] 54 | }, 55 | { 56 | "cell_type": "code", 57 | "execution_count": null, 58 | "metadata": {}, 59 | "outputs": [], 60 | "source": [ 61 | "my_component.single_widget = widgets.IntSlider(description='changed')" 62 | ] 63 | }, 64 | { 65 | "cell_type": "code", 66 | "execution_count": null, 67 | "metadata": {}, 68 | "outputs": [], 69 | "source": [] 70 | } 71 | ], 72 | "metadata": { 73 | "kernelspec": { 74 | "display_name": "Python 3", 75 | "language": "python", 76 | "name": "python3" 77 | }, 78 | "language_info": { 79 | "codemirror_mode": { 80 | "name": "ipython", 81 | "version": 3 82 | }, 83 | "file_extension": ".py", 84 | "mimetype": "text/x-python", 85 | "name": "python", 86 | "nbconvert_exporter": "python", 87 | "pygments_lexer": "ipython3", 88 | "version": "3.7.3" 89 | } 90 | }, 91 | "nbformat": 4, 92 | "nbformat_minor": 2 93 | } 94 | -------------------------------------------------------------------------------- /examples/fullVueComponent.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "import ipyvue as vue\n", 10 | "import traitlets" 11 | ] 12 | }, 13 | { 14 | "cell_type": "code", 15 | "execution_count": null, 16 | "metadata": {}, 17 | "outputs": [], 18 | "source": [ 19 | "class Parent(vue.VueTemplate):\n", 20 | " template = traitlets.Unicode('''\n", 21 | " \n", 27 | " ''').tag(sync=True)\n", 28 | " \n", 29 | " myprop = traitlets.Unicode('hello').tag(sync=True)\n", 30 | " \n", 31 | " components = traitlets.Dict({'full-vue': '''\n", 32 | " \n", 38 | " \n", 43 | " '''}).tag(sync=True)\n", 44 | "\n", 45 | "parent = Parent()\n", 46 | "parent" 47 | ] 48 | }, 49 | { 50 | "cell_type": "code", 51 | "execution_count": null, 52 | "metadata": {}, 53 | "outputs": [], 54 | "source": [ 55 | "parent.myprop = 'hi'" 56 | ] 57 | }, 58 | { 59 | "cell_type": "code", 60 | "execution_count": null, 61 | "metadata": {}, 62 | "outputs": [], 63 | "source": [ 64 | "vue.register_component_from_string(\n", 65 | " 'g-sub',\n", 66 | " '''\n", 67 | " \n", 92 | " \n", 102 | " \n", 107 | "''')" 108 | ] 109 | }, 110 | { 111 | "cell_type": "code", 112 | "execution_count": null, 113 | "metadata": {}, 114 | "outputs": [], 115 | "source": [ 116 | "class GlobalExample(vue.VueTemplate):\n", 117 | " template = traitlets.Unicode('''\n", 118 | " \n", 124 | " ''').tag(sync=True)\n", 125 | " \n", 126 | " myprop = traitlets.Unicode('hello').tag(sync=True)\n", 127 | " \n", 128 | "globalExample = GlobalExample()\n", 129 | "globalExample\n" 130 | ] 131 | }, 132 | { 133 | "cell_type": "code", 134 | "execution_count": null, 135 | "metadata": {}, 136 | "outputs": [], 137 | "source": [ 138 | "vue.Html(tag='h3', children=['test scoped'])" 139 | ] 140 | }, 141 | { 142 | "cell_type": "code", 143 | "execution_count": null, 144 | "metadata": {}, 145 | "outputs": [], 146 | "source": [ 147 | "globalExample.myprop" 148 | ] 149 | }, 150 | { 151 | "cell_type": "code", 152 | "execution_count": null, 153 | "metadata": {}, 154 | "outputs": [], 155 | "source": [ 156 | "globalExample.myprop = 'hi'" 157 | ] 158 | }, 159 | { 160 | "cell_type": "code", 161 | "execution_count": null, 162 | "metadata": {}, 163 | "outputs": [], 164 | "source": [ 165 | "import ipyvuetify as v\n", 166 | "from traitlets import Unicode, Dict, List\n", 167 | "import ipywidgets as w\n", 168 | "\n", 169 | "class Testing(v.VuetifyTemplate):\n", 170 | " template = Unicode(\"\"\"\n", 171 | " \n", 174 | " \"\"\").tag(sync=True)\n", 175 | " \n", 176 | " components = Dict({\n", 177 | " 'test-comp': \"\"\"\n", 178 | " \n", 186 | " \n", 191 | " \"\"\"\n", 192 | " }).tag(sync=True)\n", 193 | " \n", 194 | " some_widget = List([\n", 195 | " v.Btn(children=['Test'])\n", 196 | " ]).tag(sync=True, **w.widget_serialization)\n", 197 | " \n", 198 | "Testing()" 199 | ] 200 | }, 201 | { 202 | "cell_type": "code", 203 | "execution_count": null, 204 | "metadata": {}, 205 | "outputs": [], 206 | "source": [] 207 | } 208 | ], 209 | "metadata": { 210 | "kernelspec": { 211 | "display_name": "Python 3", 212 | "language": "python", 213 | "name": "python3" 214 | }, 215 | "language_info": { 216 | "codemirror_mode": { 217 | "name": "ipython", 218 | "version": 3 219 | }, 220 | "file_extension": ".py", 221 | "mimetype": "text/x-python", 222 | "name": "python", 223 | "nbconvert_exporter": "python", 224 | "pygments_lexer": "ipython3", 225 | "version": "3.7.6" 226 | } 227 | }, 228 | "nbformat": 4, 229 | "nbformat_minor": 2 230 | } 231 | -------------------------------------------------------------------------------- /examples/hot-reload/hot-reload.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "id": "08b3d099", 7 | "metadata": {}, 8 | "outputs": [], 9 | "source": [ 10 | "import ipyvue\n", 11 | "from mywidget import MyWidget\n", 12 | "\n", 13 | "# Uncomment the next line to install the watchdog package\n", 14 | "# needed for the watch function\n", 15 | "\n", 16 | "# !pip install watchdog\n", 17 | "\n", 18 | "ipyvue.watch('.')" 19 | ] 20 | }, 21 | { 22 | "cell_type": "code", 23 | "execution_count": null, 24 | "id": "bac5035f", 25 | "metadata": {}, 26 | "outputs": [], 27 | "source": [ 28 | "mywidget = MyWidget(some_data={\n", 29 | " 'message': 'Hello',\n", 30 | " 'items': ['item a', 'item b', 'item c']\n", 31 | "})\n", 32 | "mywidget" 33 | ] 34 | }, 35 | { 36 | "cell_type": "code", 37 | "execution_count": null, 38 | "id": "1a258159", 39 | "metadata": {}, 40 | "outputs": [], 41 | "source": [ 42 | "# Now edit one of the .vue files and see the changes directly without a kernel reload " 43 | ] 44 | } 45 | ], 46 | "metadata": { 47 | "kernelspec": { 48 | "display_name": "Python 3 (ipykernel)", 49 | "language": "python", 50 | "name": "python3" 51 | }, 52 | "language_info": { 53 | "codemirror_mode": { 54 | "name": "ipython", 55 | "version": 3 56 | }, 57 | "file_extension": ".py", 58 | "mimetype": "text/x-python", 59 | "name": "python", 60 | "nbconvert_exporter": "python", 61 | "pygments_lexer": "ipython3", 62 | "version": "3.9.6" 63 | } 64 | }, 65 | "nbformat": 4, 66 | "nbformat_minor": 5 67 | } 68 | -------------------------------------------------------------------------------- /examples/hot-reload/my_component.vue: -------------------------------------------------------------------------------- 1 | 8 | 13 | -------------------------------------------------------------------------------- /examples/hot-reload/my_sub_component.vue: -------------------------------------------------------------------------------- 1 | 7 | 12 | -------------------------------------------------------------------------------- /examples/hot-reload/my_widget_template.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /examples/hot-reload/mywidget.py: -------------------------------------------------------------------------------- 1 | import ipyvue 2 | import traitlets 3 | 4 | 5 | ipyvue.register_component_from_file("my-component", "my_component.vue", __file__) 6 | ipyvue.register_component_from_file( 7 | "my-sub-component", "my_sub_component.vue", __file__ 8 | ) 9 | 10 | 11 | class MyWidget(ipyvue.VueTemplate): 12 | template_file = (__file__, "my_widget_template.vue") 13 | 14 | some_data = traitlets.Dict().tag(sync=True) 15 | -------------------------------------------------------------------------------- /ipyvue/ForceLoad.py: -------------------------------------------------------------------------------- 1 | from traitlets import Unicode 2 | from ipywidgets import DOMWidget 3 | from ._version import semver 4 | 5 | 6 | class ForceLoad(DOMWidget): 7 | _model_name = Unicode("ForceLoadModel").tag(sync=True) 8 | _model_module = Unicode("jupyter-vue").tag(sync=True) 9 | _model_module_version = Unicode(semver).tag(sync=True) 10 | 11 | 12 | force_load_instance = ForceLoad() 13 | -------------------------------------------------------------------------------- /ipyvue/Html.py: -------------------------------------------------------------------------------- 1 | from traitlets import Unicode 2 | from .VueWidget import VueWidget 3 | 4 | 5 | class Html(VueWidget): 6 | 7 | _model_name = Unicode("HtmlModel").tag(sync=True) 8 | 9 | tag = Unicode().tag(sync=True) 10 | 11 | 12 | __all__ = ["Html"] 13 | -------------------------------------------------------------------------------- /ipyvue/Template.py: -------------------------------------------------------------------------------- 1 | import os 2 | from traitlets import Unicode 3 | from ipywidgets import Widget 4 | 5 | from .VueComponentRegistry import vue_component_files, register_component_from_file 6 | from ._version import semver 7 | 8 | template_registry = {} 9 | 10 | 11 | def watch(paths=""): 12 | import logging 13 | from watchdog.observers import Observer 14 | from watchdog.events import FileSystemEventHandler 15 | 16 | log = logging.getLogger("ipyvue") 17 | 18 | class VueEventHandler(FileSystemEventHandler): 19 | def on_modified(self, event): 20 | super(VueEventHandler, self).on_modified(event) 21 | if not event.is_directory: 22 | if event.src_path in template_registry: 23 | log.info(f"updating: {event.src_path}") 24 | with open(event.src_path) as f: 25 | template_registry[event.src_path].template = f.read() 26 | elif event.src_path in vue_component_files: 27 | log.info(f"updating component: {event.src_path}") 28 | name = vue_component_files[event.src_path] 29 | register_component_from_file(name, event.src_path) 30 | 31 | observer = Observer() 32 | 33 | if not isinstance(paths, (list, tuple)): 34 | paths = [paths] 35 | 36 | for path in paths: 37 | path = os.path.normpath(path) 38 | log.info(f"watching {path}") 39 | observer.schedule(VueEventHandler(), path, recursive=True) 40 | 41 | observer.start() 42 | 43 | 44 | def get_template(abs_path): 45 | abs_path = os.path.normpath(abs_path) 46 | if abs_path not in template_registry: 47 | with open(abs_path, encoding="utf-8") as f: 48 | tw = Template(template=f.read()) 49 | template_registry[abs_path] = tw 50 | else: 51 | with open(abs_path, encoding="utf-8") as f: 52 | template_registry[abs_path].template = f.read() 53 | return template_registry[abs_path] 54 | 55 | 56 | class Template(Widget): 57 | _model_name = Unicode("TemplateModel").tag(sync=True) 58 | _model_module = Unicode("jupyter-vue").tag(sync=True) 59 | _model_module_version = Unicode(semver).tag(sync=True) 60 | 61 | template = Unicode(None, allow_none=True).tag(sync=True) 62 | 63 | 64 | __all__ = ["Template", "watch"] 65 | -------------------------------------------------------------------------------- /ipyvue/VueComponentRegistry.py: -------------------------------------------------------------------------------- 1 | import os 2 | from traitlets import Unicode 3 | from ipywidgets import DOMWidget 4 | from ._version import semver 5 | 6 | 7 | class VueComponent(DOMWidget): 8 | _model_name = Unicode("VueComponentModel").tag(sync=True) 9 | _model_module = Unicode("jupyter-vue").tag(sync=True) 10 | _model_module_version = Unicode(semver).tag(sync=True) 11 | 12 | name = Unicode().tag(sync=True) 13 | component = Unicode().tag(sync=True) 14 | 15 | 16 | vue_component_registry = {} 17 | vue_component_files = {} 18 | 19 | 20 | def register_component_from_string(name, value): 21 | components = vue_component_registry 22 | 23 | if name in components.keys(): 24 | comp = components[name] 25 | comp.component = value 26 | else: 27 | comp = VueComponent(name=name, component=value) 28 | components[name] = comp 29 | 30 | 31 | def register_component_from_file(name, file_name, relative_to_file=None): 32 | # for backward compatibility with previous argument arrangement 33 | if name is None: 34 | name = file_name 35 | file_name = relative_to_file 36 | relative_to_file = None 37 | 38 | if relative_to_file: 39 | file_name = os.path.join(os.path.dirname(relative_to_file), file_name) 40 | with open(file_name) as f: 41 | vue_component_files[os.path.abspath(file_name)] = name 42 | register_component_from_string(name, f.read()) 43 | 44 | 45 | __all__ = [ 46 | "VueComponent", 47 | "register_component_from_string", 48 | "register_component_from_file", 49 | ] 50 | -------------------------------------------------------------------------------- /ipyvue/VueTemplateWidget.py: -------------------------------------------------------------------------------- 1 | import os 2 | from traitlets import Any, Unicode, List, Dict, Union, Instance 3 | from ipywidgets import DOMWidget 4 | from ipywidgets.widgets.widget import widget_serialization 5 | 6 | from .Template import Template, get_template 7 | from ._version import semver 8 | from .ForceLoad import force_load_instance 9 | import inspect 10 | from importlib import import_module 11 | 12 | OBJECT_REF = "objectRef" 13 | FUNCTION_REF = "functionRef" 14 | 15 | 16 | class Events(object): 17 | def __init__(self, **kwargs): 18 | self.on_msg(self._handle_event) 19 | self.events = [item[4:] for item in dir(self) if item.startswith("vue_")] 20 | 21 | def _handle_event(self, _, content, buffers): 22 | def resolve_ref(value): 23 | if isinstance(value, dict): 24 | if OBJECT_REF in value.keys(): 25 | obj = getattr(self, value[OBJECT_REF]) 26 | for path_item in value.get("path", []): 27 | obj = obj[path_item] 28 | return obj 29 | if FUNCTION_REF in value.keys(): 30 | fn = getattr(self, value[FUNCTION_REF]) 31 | args = value.get("args", []) 32 | kwargs = value.get("kwargs", {}) 33 | return fn(*args, **kwargs) 34 | return value 35 | 36 | if "create_widget" in content.keys(): 37 | module_name = content["create_widget"][0] 38 | class_name = content["create_widget"][1] 39 | props = {k: resolve_ref(v) for k, v in content["props"].items()} 40 | module = import_module(module_name) 41 | widget = getattr(module, class_name)(**props, model_id=content["id"]) 42 | self._component_instances = [*self._component_instances, widget] 43 | elif "update_ref" in content.keys(): 44 | widget = DOMWidget.widgets[content["id"]] 45 | prop = content["prop"] 46 | obj = resolve_ref(content["update_ref"]) 47 | setattr(widget, prop, obj) 48 | elif "destroy_widget" in content.keys(): 49 | self._component_instances = [ 50 | w 51 | for w in self._component_instances 52 | if w.model_id != content["destroy_widget"] 53 | ] 54 | elif "event" in content.keys(): 55 | event = content.get("event", "") 56 | data = content.get("data", {}) 57 | if buffers: 58 | getattr(self, "vue_" + event)(data, buffers) 59 | else: 60 | getattr(self, "vue_" + event)(data) 61 | 62 | 63 | def _value_to_json(x, obj): 64 | if inspect.isclass(x): 65 | return {"class": [x.__module__, x.__name__], "props": x.class_trait_names()} 66 | return widget_serialization["to_json"](x, obj) 67 | 68 | 69 | def _class_to_json(x, obj): 70 | if not x: 71 | return widget_serialization["to_json"](x, obj) 72 | return {k: _value_to_json(v, obj) for k, v in x.items()} 73 | 74 | 75 | def as_refs(name, data): 76 | def to_ref_structure(obj, path): 77 | if isinstance(obj, list): 78 | return [ 79 | to_ref_structure(item, [*path, index]) for index, item in enumerate(obj) 80 | ] 81 | if isinstance(obj, dict): 82 | return {k: to_ref_structure(v, [*path, k]) for k, v in obj.items()} 83 | 84 | # add object id to detect a new object in the same structure 85 | return {OBJECT_REF: name, "path": path, "id": id(obj)} 86 | 87 | return to_ref_structure(data, []) 88 | 89 | 90 | class VueTemplate(DOMWidget, Events): 91 | 92 | class_component_serialization = { 93 | "from_json": widget_serialization["to_json"], 94 | "to_json": _class_to_json, 95 | } 96 | 97 | # Force the loading of jupyter-vue before dependent extensions when in a static 98 | # context (embed, voila) 99 | _jupyter_vue = Any(force_load_instance, read_only=True).tag( 100 | sync=True, **widget_serialization 101 | ) 102 | 103 | _model_name = Unicode("VueTemplateModel").tag(sync=True) 104 | 105 | _view_name = Unicode("VueView").tag(sync=True) 106 | 107 | _view_module = Unicode("jupyter-vue").tag(sync=True) 108 | 109 | _model_module = Unicode("jupyter-vue").tag(sync=True) 110 | 111 | _view_module_version = Unicode(semver).tag(sync=True) 112 | 113 | _model_module_version = Unicode(semver).tag(sync=True) 114 | 115 | template = Union([Instance(Template), Unicode()]).tag( 116 | sync=True, **widget_serialization 117 | ) 118 | 119 | css = Unicode(None, allow_none=True).tag(sync=True) 120 | 121 | methods = Unicode(None, allow_none=True).tag(sync=True) 122 | 123 | data = Unicode(None, allow_none=True).tag(sync=True) 124 | 125 | events = List(Unicode(), allow_none=True).tag(sync=True) 126 | 127 | components = Dict(default_value=None, allow_none=True).tag( 128 | sync=True, **class_component_serialization 129 | ) 130 | 131 | _component_instances = List().tag(sync=True, **widget_serialization) 132 | 133 | template_file = None 134 | 135 | def __init__(self, *args, **kwargs): 136 | if self.template_file: 137 | abs_path = "" 138 | if type(self.template_file) == str: 139 | abs_path = os.path.abspath(self.template_file) 140 | elif type(self.template_file) == tuple: 141 | rel_file, path = self.template_file 142 | abs_path = os.path.join(os.path.dirname(rel_file), path) 143 | 144 | self.template = get_template(abs_path) 145 | 146 | super().__init__(*args, **kwargs) 147 | 148 | sync_ref_traitlets = [ 149 | v for k, v in self.traits().items() if "sync_ref" in v.metadata.keys() 150 | ] 151 | 152 | def create_ref_and_observe(traitlet): 153 | data = traitlet.get(self) 154 | ref_name = traitlet.name + "_ref" 155 | self.add_traits( 156 | **{ref_name: Any(as_refs(traitlet.name, data)).tag(sync=True)} 157 | ) 158 | 159 | def on_ref_source_change(change): 160 | setattr(self, ref_name, as_refs(traitlet.name, change["new"])) 161 | 162 | self.observe(on_ref_source_change, traitlet.name) 163 | 164 | for traitlet in sync_ref_traitlets: 165 | create_ref_and_observe(traitlet) 166 | 167 | 168 | __all__ = ["VueTemplate"] 169 | -------------------------------------------------------------------------------- /ipyvue/VueWidget.py: -------------------------------------------------------------------------------- 1 | from traitlets import Unicode, Instance, Union, List, Any, Dict 2 | from ipywidgets import DOMWidget 3 | from ipywidgets.widgets.widget_layout import Layout 4 | from ipywidgets.widgets.widget import widget_serialization, CallbackDispatcher 5 | from ipywidgets.widgets.trait_types import InstanceDict 6 | 7 | from ._version import semver 8 | from .ForceLoad import force_load_instance 9 | 10 | 11 | class ClassList: 12 | def __init__(self, obj): 13 | self.obj = obj 14 | 15 | def remove(self, *classes): 16 | """ 17 | Remove class elements from the class_ trait of the linked object. 18 | 19 | :param *classes (str): The classes to remove 20 | """ 21 | 22 | classes = [str(c) for c in classes] 23 | 24 | src_classes = self.obj.class_.split() if self.obj.class_ else [] 25 | dst_classes = [c for c in src_classes if c not in classes] 26 | 27 | self.obj.class_ = " ".join(dst_classes) 28 | 29 | def add(self, *classes): 30 | """ 31 | add class elements to the class_ trait of the linked object. 32 | 33 | :param *classes (str): The classes to add 34 | """ 35 | 36 | classes = [str(c) for c in classes] 37 | 38 | src_classes = self.obj.class_.split() if self.obj.class_ else [] 39 | dst_classes = src_classes + [c for c in classes if c not in src_classes] 40 | 41 | self.obj.class_ = " ".join(dst_classes) 42 | 43 | def toggle(self, *classes): 44 | """ 45 | toggle class elements to the class_ trait of the linked object. 46 | 47 | :param *classes (str): The classes to toggle 48 | """ 49 | 50 | classes = [str(c) for c in classes] 51 | 52 | src_classes = self.obj.class_.split() if self.obj.class_ else [] 53 | dst_classes = [c for c in src_classes if c not in classes] + [ 54 | c for c in classes if c not in src_classes 55 | ] 56 | 57 | self.obj.class_ = " ".join(dst_classes) 58 | 59 | def replace(self, src, dst): 60 | """ 61 | Replace class element by another in the class_ trait of the linked object. 62 | 63 | :param (source, destination). If the source is not found nothing is done. 64 | """ 65 | src_classes = self.obj.class_.split() if self.obj.class_ else [] 66 | 67 | src = str(src) 68 | dst = str(dst) 69 | 70 | dst_classes = [dst if c == src else c for c in src_classes] 71 | 72 | self.obj.class_ = " ".join(dst_classes) 73 | 74 | 75 | class Events(object): 76 | def __init__(self, **kwargs): 77 | self._event_handlers_map = {} 78 | self.on_msg(self._handle_event) 79 | 80 | def on_event(self, event_and_modifiers, callback, remove=False): 81 | new_event = event_and_modifiers.split(".")[0] 82 | for existing_event in [ 83 | event 84 | for event in self._event_handlers_map.keys() 85 | if event == new_event or event.startswith(new_event + ".") 86 | ]: 87 | del self._event_handlers_map[existing_event] 88 | 89 | self._event_handlers_map[event_and_modifiers] = CallbackDispatcher() 90 | 91 | self._event_handlers_map[event_and_modifiers].register_callback( 92 | callback, remove=remove 93 | ) 94 | 95 | if remove and not self._event_handlers_map[event_and_modifiers].callbacks: 96 | del self._event_handlers_map[event_and_modifiers] 97 | 98 | difference = set(self._event_handlers_map.keys()) ^ set(self._events) 99 | if len(difference) != 0: 100 | self._events = list(self._event_handlers_map.keys()) 101 | 102 | def fire_event(self, event, data=None): 103 | """Manually trigger an event handler on the Python side.""" 104 | # note that a click event will trigger click.stop if that particular 105 | # event+modifier is registered. 106 | event_match = [ 107 | k for k in self._event_handlers_map.keys() if k.startswith(event) 108 | ] 109 | if not event_match: 110 | raise ValueError(f"'{event}' not found in widget {self}") 111 | 112 | self._fire_event(event_match[0], data) 113 | 114 | def click(self, data=None): 115 | """Manually triggers the event handler for the 'click' event 116 | 117 | Note that this does not trigger a click event in the browser, this only 118 | invokes the Python event handlers. 119 | """ 120 | self.fire_event("click", data or {}) 121 | 122 | def _fire_event(self, event, data=None): 123 | dispatcher = self._event_handlers_map[event] 124 | # we don't call via the dispatcher, since that eats exceptions 125 | for callback in dispatcher.callbacks: 126 | callback(self, event, data) 127 | 128 | def _handle_event(self, _, content, buffers): 129 | event = content.get("event", "") 130 | data = content.get("data", {}) 131 | self._fire_event(event, data) 132 | 133 | 134 | class VueWidget(DOMWidget, Events): 135 | # we can drop this when https://github.com/jupyter-widgets/ipywidgets/pull/3592 136 | # is merged 137 | layout = InstanceDict(Layout, allow_none=True).tag( 138 | sync=True, **widget_serialization 139 | ) 140 | 141 | # Force the loading of jupyter-vue before dependent extensions when in a static 142 | # context (embed, voila) 143 | _jupyter_vue = Any(force_load_instance, read_only=True).tag( 144 | sync=True, **widget_serialization 145 | ) 146 | 147 | _model_name = Unicode("VueModel").tag(sync=True) 148 | 149 | _view_name = Unicode("VueView").tag(sync=True) 150 | 151 | _view_module = Unicode("jupyter-vue").tag(sync=True) 152 | 153 | _model_module = Unicode("jupyter-vue").tag(sync=True) 154 | 155 | _view_module_version = Unicode(semver).tag(sync=True) 156 | 157 | _model_module_version = Unicode(semver).tag(sync=True) 158 | 159 | children = List(Union([Instance(DOMWidget), Unicode()])).tag( 160 | sync=True, **widget_serialization 161 | ) 162 | 163 | slot = Unicode(None, allow_none=True).tag(sync=True) 164 | 165 | _events = List(Unicode()).tag(sync=True) 166 | 167 | v_model = Any("!!disabled!!", allow_none=True).tag(sync=True) 168 | 169 | style_ = Unicode(None, allow_none=True).tag(sync=True) 170 | 171 | class_ = Unicode(None, allow_none=True).tag(sync=True) 172 | 173 | attributes = Dict(None, allow_none=True).tag(sync=True) 174 | 175 | v_slots = List(Dict()).tag(sync=True, **widget_serialization) 176 | 177 | v_on = Unicode(None, allow_none=True).tag(sync=True) 178 | 179 | def __init__(self, **kwargs): 180 | 181 | self.class_list = ClassList(self) 182 | 183 | super().__init__(**kwargs) 184 | 185 | def show(self): 186 | """Make the widget visible""" 187 | 188 | self.class_list.remove("d-none") 189 | 190 | def hide(self): 191 | """Make the widget invisible""" 192 | 193 | self.class_list.add("d-none") 194 | 195 | 196 | __all__ = ["VueWidget"] 197 | -------------------------------------------------------------------------------- /ipyvue/__init__.py: -------------------------------------------------------------------------------- 1 | from ._version import __version__ 2 | from .Html import Html 3 | from .Template import Template, watch 4 | from .VueWidget import VueWidget 5 | from .VueTemplateWidget import VueTemplate 6 | from .VueComponentRegistry import ( 7 | VueComponent, 8 | register_component_from_string, 9 | register_component_from_file, 10 | ) 11 | 12 | 13 | def _jupyter_labextension_paths(): 14 | return [ 15 | { 16 | "src": "labextension", 17 | "dest": "jupyter-vue", 18 | } 19 | ] 20 | 21 | 22 | def _jupyter_nbextension_paths(): 23 | return [ 24 | { 25 | "section": "notebook", 26 | "src": "nbextension", 27 | "dest": "jupyter-vue", 28 | "require": "jupyter-vue/extension", 29 | } 30 | ] 31 | -------------------------------------------------------------------------------- /ipyvue/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.11.2" 2 | semver = "^" + __version__ 3 | -------------------------------------------------------------------------------- /js/.babelrc: -------------------------------------------------------------------------------- 1 | { "presets": [ 2 | [ 3 | "@babel/preset-env", 4 | { 5 | "useBuiltIns": "usage", 6 | "corejs": 3, 7 | "modules": false 8 | } 9 | ] 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /js/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | }, 6 | extends: 'airbnb-base', 7 | globals: { 8 | Atomics: 'readonly', 9 | SharedArrayBuffer: 'readonly', 10 | }, 11 | parserOptions: { 12 | ecmaVersion: 2018, 13 | sourceType: 'module', 14 | }, 15 | plugins: [ 16 | 'vue', 17 | ], 18 | rules: { 19 | 'indent': ['error', 4, { 'SwitchCase': 1 }], 20 | 'import/prefer-default-export': 'off', 21 | 'camelcase': ["error", {allow: ['__webpack_public_path__', 'load_ipython_extension', '_model_name']}], 22 | 'no-underscore-dangle': 'off', 23 | 'class-methods-use-this': 'off', 24 | 'no-use-before-define': 'off' 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /js/README.md: -------------------------------------------------------------------------------- 1 | Jupyter widgets base for Vue libraries 2 | 3 | Package Install 4 | --------------- 5 | 6 | **Prerequisites** 7 | - [node](http://nodejs.org/) 8 | 9 | ```bash 10 | npm install --save jupyter-vue 11 | ``` 12 | -------------------------------------------------------------------------------- /js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jupyter-vue", 3 | "version": "1.11.2", 4 | "description": "Jupyter widgets base for Vue libraries", 5 | "license": "MIT", 6 | "author": "Mario Buikhuizen, Maarten Breddels", 7 | "main": "lib/index.js", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/mariobuikhuizen/ipyvue.git" 11 | }, 12 | "keywords": [ 13 | "jupyter", 14 | "widgets", 15 | "ipython", 16 | "ipywidgets", 17 | "jupyterlab-extension" 18 | ], 19 | "files": [ 20 | "src/", 21 | "lib/", 22 | "dist/" 23 | ], 24 | "browserslist": ">0.8%, not ie 11, not op_mini all, not dead", 25 | "scripts": { 26 | "build:babel": "babel src --out-dir lib --copy-files", 27 | "watch:babel": "babel src --watch --out-dir lib --copy-files --verbose", 28 | "build:labextension": "jupyter labextension build .", 29 | "watch:labextension": "jupyter labextension watch .", 30 | "build:webpack": "webpack", 31 | "watch:webpack": "webpack --mode development --watch", 32 | "watch": "run-p watch:*", 33 | "clean": "rimraf lib/ dist/", 34 | "prepare": "run-s build:*", 35 | "test": "echo \"Error: no test specified\" && exit 1" 36 | }, 37 | "devDependencies": { 38 | "@babel/cli": "^7.5.0", 39 | "@babel/core": "^7.4.4", 40 | "@babel/preset-env": "^7.4.4", 41 | "@jupyterlab/builder": "^3", 42 | "ajv": "^6.10.0", 43 | "css-loader": "^5", 44 | "eslint": "^5.16.0", 45 | "eslint-config-airbnb-base": "^13.1.0", 46 | "eslint-plugin-import": "^2.17.2", 47 | "eslint-plugin-vue": "^5.2.2", 48 | "file-loader": "^6", 49 | "npm-run-all": "^4.1.5", 50 | "rimraf": "^2.6.3", 51 | "style-loader": "^0.23.1", 52 | "webpack": "^5", 53 | "webpack-cli": "^4" 54 | }, 55 | "dependencies": { 56 | "@jupyter-widgets/base": "^1 || ^2 || ^3 || ^4", 57 | "@mariobuikhuizen/vue-compiler-addon": "^2.6.10-alpha.2", 58 | "core-js": "^3.0.1", 59 | "lodash": "^4.17.11", 60 | "uuid": "^3.4.0", 61 | "vue": "^2.6.10" 62 | }, 63 | "jupyterlab": { 64 | "extension": "lib/labplugin", 65 | "outputDir": "../ipyvue/labextension", 66 | "sharedPackages": { 67 | "@jupyter-widgets/base": { 68 | "bundled": false, 69 | "singleton": true 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /js/src/ForceLoad.js: -------------------------------------------------------------------------------- 1 | /* eslint camelcase: off */ 2 | import { DOMWidgetModel } from '@jupyter-widgets/base'; 3 | 4 | export class ForceLoadModel extends DOMWidgetModel { 5 | defaults() { 6 | return { 7 | ...super.defaults(), 8 | ...{ 9 | _model_name: 'ForceLoadModel', 10 | _model_module: 'jupyter-vue', 11 | _model_module_version: '^0.0.1', 12 | }, 13 | }; 14 | } 15 | } 16 | 17 | ForceLoadModel.serializers = { 18 | ...DOMWidgetModel.serializers, 19 | }; 20 | -------------------------------------------------------------------------------- /js/src/Html.js: -------------------------------------------------------------------------------- 1 | import { VueModel } from './VueModel'; 2 | 3 | export 4 | class HtmlModel extends VueModel { 5 | defaults() { 6 | return { 7 | ...super.defaults(), 8 | ...{ 9 | _model_name: 'HtmlModel', 10 | tag: null, 11 | }, 12 | }; 13 | } 14 | 15 | getVueTag() { // eslint-disable-line class-methods-use-this 16 | if (this.get('tag').toLowerCase().includes('script')) { 17 | return undefined; 18 | } 19 | return this.get('tag'); 20 | } 21 | } 22 | 23 | HtmlModel.serializers = { 24 | ...VueModel.serializers, 25 | }; 26 | -------------------------------------------------------------------------------- /js/src/Template.js: -------------------------------------------------------------------------------- 1 | /* eslint camelcase: off */ 2 | import { 3 | WidgetModel, 4 | } from '@jupyter-widgets/base'; 5 | 6 | export 7 | class TemplateModel extends WidgetModel { 8 | defaults() { 9 | return { 10 | ...super.defaults(), 11 | ...{ 12 | _model_name: 'TemplateModel', 13 | }, 14 | }; 15 | } 16 | } 17 | 18 | TemplateModel.serializers = { 19 | ...WidgetModel.serializers, 20 | }; 21 | -------------------------------------------------------------------------------- /js/src/VueComponentModel.js: -------------------------------------------------------------------------------- 1 | /* eslint camelcase: off */ 2 | import { DOMWidgetModel } from '@jupyter-widgets/base'; 3 | import Vue from 'vue'; 4 | import httpVueLoader from './httpVueLoader'; 5 | import {TemplateModel} from './Template'; 6 | 7 | export class VueComponentModel extends DOMWidgetModel { 8 | defaults() { 9 | return { 10 | ...super.defaults(), 11 | ...{ 12 | _model_name: 'VueComponentModel', 13 | _model_module: 'jupyter-vue', 14 | _model_module_version: '^0.0.3', 15 | name: null, 16 | component: null, 17 | }, 18 | }; 19 | } 20 | 21 | constructor(...args) { 22 | super(...args); 23 | 24 | const [, { widget_manager }] = args; 25 | 26 | const name = this.get('name'); 27 | Vue.component(name, httpVueLoader(this.get('component'))); 28 | this.on('change:component', () => { 29 | Vue.component(name, httpVueLoader(this.get('component'))); 30 | 31 | (async () => { 32 | const models = await Promise.all(Object.values(widget_manager._models)); 33 | const componentModels = models 34 | .filter(model => model instanceof VueComponentModel); 35 | 36 | const affectedComponents = []; 37 | 38 | function re(searchName) { 39 | return new RegExp(`\\<${searchName}[ />\n]`, 'g'); 40 | } 41 | 42 | function find_usage(searchName) { 43 | affectedComponents.push(searchName); 44 | componentModels 45 | .filter(model => model.get('component').match(re(searchName))) 46 | .forEach((model) => { 47 | const cname = model.get('name'); 48 | if (!affectedComponents.includes(cname)) { 49 | find_usage(cname); 50 | } 51 | }); 52 | } 53 | 54 | find_usage(name); 55 | 56 | const affectedTemplateModels = models 57 | .filter(model => model instanceof TemplateModel 58 | && affectedComponents.some(cname => model.get('template').match(re(cname)))); 59 | 60 | affectedTemplateModels.forEach(model => model.trigger('change:template')); 61 | })(); 62 | }); 63 | } 64 | } 65 | 66 | VueComponentModel.serializers = { 67 | ...DOMWidgetModel.serializers, 68 | }; 69 | -------------------------------------------------------------------------------- /js/src/VueModel.js: -------------------------------------------------------------------------------- 1 | /* eslint camelcase: off */ 2 | import { 3 | DOMWidgetModel, unpack_models, 4 | } from '@jupyter-widgets/base'; 5 | 6 | export class VueModel extends DOMWidgetModel { 7 | defaults() { 8 | return { 9 | ...super.defaults(), 10 | ...{ 11 | _jupyter_vue: null, 12 | _model_name: 'VueModel', 13 | _view_name: 'VueView', 14 | _view_module: 'jupyter-vue', 15 | _model_module: 'jupyter-vue', 16 | _view_module_version: '^0.0.3', 17 | _model_module_version: '^0.0.3', 18 | _metadata: null, 19 | children: undefined, 20 | slot: null, 21 | _events: null, 22 | v_model: '!!disabled!!', 23 | style_: null, 24 | class_: null, 25 | attributes: null, 26 | v_slots: null, 27 | v_on: null, 28 | }, 29 | }; 30 | } 31 | } 32 | 33 | VueModel.serializers = { 34 | ...DOMWidgetModel.serializers, 35 | children: { deserialize: unpack_models }, 36 | v_slots: { deserialize: unpack_models }, 37 | }; 38 | -------------------------------------------------------------------------------- /js/src/VueRenderer.js: -------------------------------------------------------------------------------- 1 | /* eslint camelcase: ['error', {allow: ['v_model']}] */ 2 | import * as base from '@jupyter-widgets/base'; 3 | import { vueTemplateRender } from './VueTemplateRenderer'; // eslint-disable-line import/no-cycle 4 | import { VueModel } from './VueModel'; 5 | import { VueTemplateModel } from './VueTemplateModel'; 6 | import Vue from './VueWithCompiler'; 7 | 8 | const JupyterPhosphorWidget = base.JupyterPhosphorWidget || base.JupyterLuminoWidget; 9 | 10 | export function createObjectForNestedModel(model, parentView) { 11 | let currentView = null; 12 | let destroyed = false; 13 | return { 14 | mounted() { 15 | parentView 16 | .create_child_view(model) 17 | .then(view => { 18 | currentView = view; 19 | // since create view is async, the vue component might be destroyed before the view is created 20 | if(!destroyed) { 21 | if(JupyterPhosphorWidget && (view.pWidget || view.luminoWidget || view.lmWidget)) { 22 | JupyterPhosphorWidget.attach(view.pWidget || view.luminoWidget || view.lmWidget, this.$el); 23 | } else { 24 | console.error("Could not attach widget to DOM using Lumino or Phosphor. Fallback to normal DOM attach", JupyterPhosphorWidget, view.pWidget, view.luminoWidget, view.lmWidget); 25 | this.$el.appendChild(view.el); 26 | 27 | } 28 | } else { 29 | currentView.remove(); 30 | } 31 | }); 32 | }, 33 | beforeDestroy() { 34 | if (currentView) { 35 | // In vue 3 we can use the beforeUnmount, which is called before the node is removed from the DOM 36 | // In vue 2, we are already disconnected from the document at this stage, which phosphor does not like. 37 | // In order to avoid an error in phosphor, we add the node to the body before removing it. 38 | // (current.remove triggers a phosphor detach) 39 | // To be sure we do not cause any flickering, we hide the node before moving it. 40 | const widget = currentView.pWidget || currentView.luminoWidget || currentView.lmWidget; 41 | widget.node.style.display = "none"; 42 | document.body.appendChild(widget.node) 43 | currentView.remove(); 44 | } else { 45 | destroyed = true; 46 | } 47 | }, 48 | render(createElement) { 49 | return createElement('div', { style: { height: '100%' } }); 50 | }, 51 | }; 52 | } 53 | 54 | // based on https://stackoverflow.com/a/58416333/5397207 55 | function pickSerializable(object, depth=0, max_depth=2) { 56 | // change max_depth to see more levels, for a touch event, 2 is good 57 | if (depth > max_depth) 58 | return 'Object'; 59 | 60 | const obj = {}; 61 | for (let key in object) { 62 | let value = object[key]; 63 | if (value instanceof Node) 64 | // specify which properties you want to see from the node 65 | value = {id: value.id}; 66 | else if (value instanceof Window) 67 | value = 'Window'; 68 | else if (value instanceof Object) 69 | value = pickSerializable(value, depth+1, max_depth); 70 | 71 | obj[key] = value; 72 | } 73 | 74 | return obj; 75 | } 76 | 77 | export function eventToObject(event) { 78 | if (event instanceof Event) { 79 | return pickSerializable(event); 80 | } 81 | return event; 82 | } 83 | 84 | export function vueRender(createElement, model, parentView, slotScopes) { 85 | if (model instanceof VueTemplateModel) { 86 | return vueTemplateRender(createElement, model, parentView); 87 | } 88 | if (!(model instanceof VueModel)) { 89 | return createElement(createObjectForNestedModel(model, parentView)); 90 | } 91 | const tag = model.getVueTag(); 92 | 93 | const elem = createElement({ 94 | data() { 95 | return { 96 | v_model: model.get('v_model'), 97 | }; 98 | }, 99 | created() { 100 | addListeners(model, this); 101 | }, 102 | render(createElement2) { 103 | const element = createElement2( 104 | tag, 105 | createContent(createElement2, model, this, parentView, slotScopes), 106 | renderChildren(createElement2, model.get('children'), this, parentView, slotScopes), 107 | ); 108 | updateCache(this); 109 | return element; 110 | }, 111 | }, { ...model.get('slot') && { slot: model.get('slot') } }); 112 | 113 | /* Impersonate the wrapped component (e.g. v-tabs uses this name to detect v-tab and 114 | * v-tab-item) */ 115 | elem.componentOptions.Ctor.options.name = tag; 116 | return elem; 117 | } 118 | 119 | function addListeners(model, vueModel) { 120 | const listener = () => { 121 | vueModel.$forceUpdate(); 122 | }; 123 | const use = key => key === '_events' || (!key.startsWith('_') && !['v_model'].includes(key)); 124 | 125 | model.keys() 126 | .filter(use) 127 | .forEach(key => model.on(`change:${key}`, listener)); 128 | 129 | model.on('change:v_model', () => { 130 | if (vueModel.v_model === "!!disabled!!") { 131 | vueModel.$forceUpdate(); 132 | } 133 | if (model.get('v_model') !== vueModel.v_model) { 134 | vueModel.v_model = model.get('v_model'); // eslint-disable-line no-param-reassign 135 | } 136 | }); 137 | } 138 | 139 | function createAttrsMapping(model) { 140 | const useAsAttr = key => model.get(key) !== null 141 | && !key.startsWith('_') 142 | && !['attributes', 'v_slots', 'v_on', 'layout', 'children', 'slot', 'v_model', 'style_', 'class_'].includes(key); 143 | 144 | return model.keys() 145 | .filter(useAsAttr) 146 | .reduce((result, key) => { 147 | result[key.replace(/_$/g, '').replace(/_/g, '-')] = model.get(key); // eslint-disable-line no-param-reassign 148 | return result; 149 | }, {}); 150 | } 151 | 152 | function addEventWithModifiers(eventAndModifiers, obj, fn) { // eslint-disable-line no-unused-vars 153 | /* Example Vue.compile output: 154 | * (function anonymous() { 155 | * with (this) { 156 | * return _c('dummy', { 157 | * on: { 158 | * "[event]": function ($event) { 159 | * if (!$event.type.indexOf('key') && _k($event.keyCode, "c", ...) 160 | * return null; 161 | * ... 162 | * return [fn]($event) 163 | * } 164 | * } 165 | * }) 166 | * } 167 | * } 168 | * ) 169 | */ 170 | const { on } = Vue.compile(``) 171 | .render.bind({ 172 | _c: (_, data) => data, 173 | _k: Vue.prototype._k, 174 | fn, 175 | })(); 176 | 177 | return { 178 | ...obj, 179 | ...on, 180 | }; 181 | } 182 | 183 | function createEventMapping(model, parentView) { 184 | return (model.get('_events') || []) 185 | .reduce((result, eventAndModifiers) => addEventWithModifiers( 186 | eventAndModifiers, 187 | result, 188 | (e) => { 189 | model.send({ 190 | event: eventAndModifiers, 191 | data: eventToObject(e), 192 | }, 193 | model.callbacks(parentView)); 194 | }, 195 | ), {}); 196 | } 197 | 198 | function createSlots(createElement, model, vueModel, parentView, slotScopes) { 199 | const slots = model.get('v_slots'); 200 | if (!slots) { 201 | return undefined; 202 | } 203 | return slots.map(slot => ({ 204 | key: slot.name, 205 | ...!slot.variable && { proxy: true }, 206 | fn(slotScope) { 207 | return renderChildren(createElement, 208 | Array.isArray(slot.children) ? slot.children : [slot.children], 209 | vueModel, parentView, { 210 | ...slotScopes, 211 | ...slot.variable && { [slot.variable]: slotScope }, 212 | }); 213 | }, 214 | })); 215 | } 216 | 217 | function getScope(value, slotScopes) { 218 | const parts = value.split('.'); 219 | return parts 220 | .slice(1) 221 | .reduce( 222 | (scope, name) => scope[name], 223 | slotScopes[parts[0]], 224 | ); 225 | } 226 | 227 | function getScopes(value, slotScopes) { 228 | return typeof value === 'string' 229 | ? getScope(value, slotScopes) 230 | : Object.assign({}, ...value.map(v => getScope(v, slotScopes))); 231 | } 232 | 233 | function slotUseOn(model, slotScopes) { 234 | const vOnValue = model.get('v_on'); 235 | return vOnValue && getScopes(vOnValue, slotScopes); 236 | } 237 | 238 | function createContent(createElement, model, vueModel, parentView, slotScopes) { 239 | const htmlEventAttributes = model.get('attributes') && Object.keys(model.get('attributes')).filter(key => key.startsWith('on')); 240 | if (htmlEventAttributes && htmlEventAttributes.length > 0) { 241 | throw new Error(`No HTML event attributes may be used: ${htmlEventAttributes}`); 242 | } 243 | 244 | const scopedSlots = createSlots(createElement, model, vueModel, parentView, slotScopes); 245 | 246 | return { 247 | on: { ...createEventMapping(model, parentView), ...slotUseOn(model, slotScopes) }, 248 | ...model.get('style_') && { style: model.get('style_') }, 249 | ...model.get('class_') && { class: model.get('class_') }, 250 | ...scopedSlots && { scopedSlots: vueModel._u(scopedSlots) }, 251 | attrs: { 252 | ...createAttrsMapping(model), 253 | ...model.get('attributes') && model.get('attributes'), 254 | }, 255 | ...model.get('v_model') !== '!!disabled!!' && { 256 | model: { 257 | value: vueModel.v_model, 258 | callback: (v) => { 259 | model.set('v_model', v === undefined ? null : v); 260 | model.save_changes(model.callbacks(parentView)); 261 | }, 262 | expression: 'v_model', 263 | }, 264 | }, 265 | }; 266 | } 267 | 268 | function renderChildren(createElement, children, vueModel, parentView, slotScopes) { 269 | if (!vueModel.childCache) { 270 | vueModel.childCache = {}; // eslint-disable-line no-param-reassign 271 | } 272 | if (!vueModel.childIds) { 273 | vueModel.childIds = []; // eslint-disable-line no-param-reassign 274 | } 275 | const childViewModels = children.map((child) => { 276 | if (typeof (child) === 'string') { 277 | return child; 278 | } 279 | vueModel.childIds.push(child.cid); 280 | 281 | if (vueModel.childCache[child.cid]) { 282 | return vueModel.childCache[child.cid]; 283 | } 284 | const vm = vueRender(createElement, child, parentView, slotScopes); 285 | vueModel.childCache[child.cid] = vm; // eslint-disable-line no-param-reassign 286 | return vm; 287 | }); 288 | 289 | return childViewModels; 290 | } 291 | 292 | function updateCache(vueModel) { 293 | Object.keys(vueModel.childCache) 294 | .filter(key => !vueModel.childIds.includes(key)) 295 | // eslint-disable-next-line no-param-reassign 296 | .forEach(key => delete vueModel.childCache[key]); 297 | vueModel.childIds = []; // eslint-disable-line no-param-reassign 298 | } 299 | -------------------------------------------------------------------------------- /js/src/VueTemplateModel.js: -------------------------------------------------------------------------------- 1 | /* eslint camelcase: off */ 2 | import { DOMWidgetModel, unpack_models } from '@jupyter-widgets/base'; 3 | 4 | export class VueTemplateModel extends DOMWidgetModel { 5 | defaults() { 6 | return { 7 | ...super.defaults(), 8 | ...{ 9 | _jupyter_vue: null, 10 | _model_name: 'VueTemplateModel', 11 | _view_name: 'VueView', 12 | _view_module: 'jupyter-vue', 13 | _model_module: 'jupyter-vue', 14 | _view_module_version: '^0.0.3', 15 | _model_module_version: '^0.0.3', 16 | template: null, 17 | css: null, 18 | methods: null, 19 | data: null, 20 | events: null, 21 | _component_instances: null, 22 | }, 23 | }; 24 | } 25 | } 26 | 27 | VueTemplateModel.serializers = { 28 | ...DOMWidgetModel.serializers, 29 | template: { deserialize: unpack_models }, 30 | components: { deserialize: unpack_models }, 31 | _component_instances: { deserialize: unpack_models }, 32 | }; 33 | -------------------------------------------------------------------------------- /js/src/VueTemplateRenderer.js: -------------------------------------------------------------------------------- 1 | import { WidgetModel } from '@jupyter-widgets/base'; 2 | import uuid4 from 'uuid/v4'; 3 | import _ from 'lodash'; 4 | import Vue from 'vue'; 5 | import { parseComponent } from '@mariobuikhuizen/vue-compiler-addon'; 6 | import { createObjectForNestedModel, eventToObject, vueRender } from './VueRenderer'; // eslint-disable-line import/no-cycle 7 | import { VueModel } from './VueModel'; 8 | import { VueTemplateModel } from './VueTemplateModel'; 9 | import httpVueLoader from './httpVueLoader'; 10 | import { TemplateModel } from './Template'; 11 | 12 | export function vueTemplateRender(createElement, model, parentView) { 13 | return createElement(createComponentObject(model, parentView)); 14 | } 15 | 16 | function createComponentObject(model, parentView) { 17 | if (model instanceof VueModel) { 18 | return { 19 | render(createElement) { 20 | return vueRender(createElement, model, parentView, {}); 21 | }, 22 | }; 23 | } 24 | if (!(model instanceof VueTemplateModel)) { 25 | return createObjectForNestedModel(model, parentView); 26 | } 27 | 28 | const isTemplateModel = model.get('template') instanceof TemplateModel; 29 | const templateModel = isTemplateModel ? model.get('template') : model; 30 | const template = templateModel.get('template'); 31 | const vuefile = readVueFile(template); 32 | 33 | const css = model.get('css') || (vuefile.STYLE && vuefile.STYLE.content); 34 | const cssId = (vuefile.STYLE && vuefile.STYLE.id); 35 | 36 | if (css) { 37 | if (cssId) { 38 | const prefixedCssId = `ipyvue-${cssId}`; 39 | let style = document.getElementById(prefixedCssId); 40 | if (!style) { 41 | style = document.createElement('style'); 42 | style.id = prefixedCssId; 43 | document.head.appendChild(style); 44 | } 45 | if (style.innerHTML !== css) { 46 | style.innerHTML = css; 47 | } 48 | } else { 49 | const style = document.createElement('style'); 50 | style.id = model.cid; 51 | style.innerHTML = css; 52 | document.head.appendChild(style); 53 | parentView.once('remove', () => { 54 | document.head.removeChild(style); 55 | }); 56 | } 57 | } 58 | 59 | // eslint-disable-next-line no-new-func 60 | const methods = model.get('methods') ? Function(`return ${model.get('methods').replace('\n', ' ')}`)() : {}; 61 | // eslint-disable-next-line no-new-func 62 | const data = model.get('data') ? Function(`return ${model.get('data').replace('\n', ' ')}`)() : {}; 63 | 64 | const componentEntries = Object.entries(model.get('components') || {}); 65 | const instanceComponents = componentEntries.filter(([, v]) => v instanceof WidgetModel); 66 | const classComponents = componentEntries.filter(([, v]) => !(v instanceof WidgetModel) && !(typeof v === 'string')); 67 | const fullVueComponents = componentEntries.filter(([, v]) => typeof v === 'string'); 68 | 69 | function callVueFn(name, this_) { 70 | if (vuefile.SCRIPT && vuefile.SCRIPT[name]) { 71 | vuefile.SCRIPT[name].bind(this_)(); 72 | } 73 | } 74 | 75 | return { 76 | inject: ['viewCtx'], 77 | data() { 78 | // data that is only used in the template, and not synced with the backend/model 79 | const dataTemplate = (vuefile.SCRIPT && vuefile.SCRIPT.data && vuefile.SCRIPT.data()) || {}; 80 | return { ...data, ...dataTemplate, ...createDataMapping(model) }; 81 | }, 82 | beforeCreate() { 83 | callVueFn('beforeCreate', this); 84 | }, 85 | created() { 86 | this.__onTemplateChange = () => { 87 | this.$root.$forceUpdate(); 88 | }; 89 | templateModel.on('change:template', this.__onTemplateChange); 90 | addModelListeners(model, this); 91 | callVueFn('created', this); 92 | }, 93 | watch: createWatches(model, parentView, vuefile.SCRIPT && vuefile.SCRIPT.watch), 94 | methods: { 95 | ...vuefile.SCRIPT && vuefile.SCRIPT.methods, 96 | ...methods, 97 | ...createMethods(model, parentView), 98 | }, 99 | components: { 100 | ...createInstanceComponents(instanceComponents, parentView), 101 | ...createClassComponents(classComponents, model, parentView), 102 | ...createFullVueComponents(fullVueComponents), 103 | }, 104 | computed: { ...vuefile.SCRIPT && vuefile.SCRIPT.computed, ...aliasRefProps(model) }, 105 | template: vuefile.TEMPLATE === undefined && vuefile.SCRIPT === undefined && vuefile.STYLE === undefined 106 | ? template 107 | : vuefile.TEMPLATE, 108 | beforeMount() { 109 | callVueFn('beforeMount', this); 110 | }, 111 | mounted() { 112 | callVueFn('mounted', this); 113 | }, 114 | beforeUpdate() { 115 | callVueFn('beforeUpdate', this); 116 | }, 117 | updated() { 118 | callVueFn('updated', this); 119 | }, 120 | beforeDestroy() { 121 | templateModel.off('change:template', this.__onTemplateChange); 122 | callVueFn('beforeDestroy', this); 123 | }, 124 | destroyed() { 125 | callVueFn('destroyed', this); 126 | }, 127 | }; 128 | } 129 | 130 | function createDataMapping(model) { 131 | return model.keys() 132 | .filter(prop => !prop.startsWith('_') 133 | && !['events', 'template', 'components', 'layout', 'css', 'data', 'methods'].includes(prop)) 134 | .reduce((result, prop) => { 135 | result[prop] = _.cloneDeep(model.get(prop)); // eslint-disable-line no-param-reassign 136 | return result; 137 | }, {}); 138 | } 139 | 140 | function addModelListeners(model, vueModel) { 141 | model.keys() 142 | .filter(prop => !prop.startsWith('_') 143 | && !['v_model', 'components', 'layout', 'css', 'data', 'methods'].includes(prop)) 144 | // eslint-disable-next-line no-param-reassign 145 | .forEach(prop => model.on(`change:${prop}`, () => { 146 | if (_.isEqual(model.get(prop), vueModel[prop])) { 147 | return; 148 | } 149 | vueModel[prop] = _.cloneDeep(model.get(prop)); 150 | })); 151 | model.on('msg:custom', (content, buffers) => { 152 | if (!content['method']) { 153 | return; 154 | } 155 | const jupyter_method = 'jupyter_' + content['method']; 156 | if (!vueModel[jupyter_method]) { 157 | return; 158 | } 159 | let args_ = content['args'] 160 | if ( args_ == null) { 161 | args_ = [] 162 | } 163 | vueModel[jupyter_method](...args_, buffers); 164 | }); 165 | } 166 | 167 | function createWatches(model, parentView, templateWatchers) { 168 | const modelWatchers = model.keys().filter(prop => !prop.startsWith('_') 169 | && !['events', 'template', 'components', 'layout', 'css', 'data', 'methods'].includes(prop)) 170 | .reduce((result, prop) => ({ 171 | ...result, 172 | [prop]: { 173 | handler(value) { 174 | if (templateWatchers && templateWatchers[prop]) { 175 | templateWatchers[prop].bind(this)(value); 176 | } 177 | /* Don't send changes received from backend back */ 178 | if (_.isEqual(value, model.get(prop))) { 179 | return; 180 | } 181 | 182 | model.set(prop, value === undefined ? null : _.cloneDeep(value)); 183 | model.save_changes(model.callbacks(parentView)); 184 | }, 185 | deep: true, 186 | }, 187 | }), {}) 188 | /* Overwritten keys from templateWatchers are handled in modelWatchers 189 | so that we eventually call all handlers from templateWatchers. 190 | */ 191 | return {...templateWatchers, ...modelWatchers}; 192 | } 193 | 194 | function createMethods(model, parentView) { 195 | return model.get('events').reduce((result, event) => { 196 | // eslint-disable-next-line no-param-reassign 197 | result[event] = (value, buffers) => { 198 | if (buffers) { 199 | const validBuffers = buffers instanceof Array && 200 | buffers[0] instanceof ArrayBuffer; 201 | if (!validBuffers) { 202 | console.warn('second argument is not an BufferArray[View] array') 203 | buffers = undefined; 204 | } 205 | } 206 | model.send( 207 | {event, data: eventToObject(value)}, 208 | model.callbacks(parentView), 209 | buffers, 210 | ); 211 | } 212 | return result; 213 | }, {}); 214 | } 215 | 216 | function createInstanceComponents(components, parentView) { 217 | return components.reduce((result, [name, model]) => { 218 | // eslint-disable-next-line no-param-reassign 219 | result[name] = createComponentObject(model, parentView); 220 | return result; 221 | }, {}); 222 | } 223 | 224 | function createClassComponents(components, containerModel, parentView) { 225 | return components.reduce((accumulator, [componentName, componentSpec]) => ({ 226 | ...accumulator, 227 | [componentName]: ({ 228 | /* TODO: handle naming collisions. Ignore style traitlet for now */ 229 | props: componentSpec.props.filter(p => p !== 'style'), 230 | data() { 231 | return { 232 | model: null, 233 | id: uuid4(), 234 | }; 235 | }, 236 | created() { 237 | const fn = () => { 238 | if (!this.model) { 239 | const newModel = containerModel.get('_component_instances').find(wm => wm.model_id === this.id); 240 | if (newModel) { 241 | this.model = newModel; 242 | } 243 | } else { 244 | containerModel.off('change:_component_instances', fn); 245 | } 246 | }; 247 | containerModel.on('change:_component_instances', fn); 248 | containerModel.send( 249 | { 250 | create_widget: componentSpec.class, // eslint-disable-line camelcase 251 | id: this.id, 252 | props: this.$options.propsData, 253 | }, 254 | containerModel.callbacks(parentView), 255 | ); 256 | }, 257 | destroyed() { 258 | containerModel.send( 259 | { 260 | destroy_widget: this.id, // eslint-disable-line camelcase 261 | }, 262 | containerModel.callbacks(parentView), 263 | ); 264 | }, 265 | watch: componentSpec.props.reduce((watchAccumulator, prop) => ({ 266 | ...watchAccumulator, 267 | [prop](value) { 268 | if (value.objectRef) { 269 | containerModel.send( 270 | { 271 | update_ref: value, // eslint-disable-line camelcase 272 | prop, 273 | id: this.id, 274 | }, 275 | containerModel.callbacks(parentView), 276 | ); 277 | } else { 278 | this.model.set(prop, value); 279 | this.model.save_changes(this.model.callbacks(parentView)); 280 | } 281 | }, 282 | }), {}), 283 | render(createElement) { 284 | if (this.model) { 285 | return vueRender(createElement, this.model, parentView, {}); 286 | } 287 | return createElement('div', ['temp-content']); 288 | }, 289 | }), 290 | }), {}); 291 | } 292 | 293 | function createFullVueComponents(components) { 294 | return components.reduce((accumulator, [componentName, vueFile]) => ({ 295 | ...accumulator, 296 | [componentName]: httpVueLoader(vueFile), 297 | }), {}); 298 | } 299 | 300 | /* Returns a map with computed properties so that myProp_ref is available as myProp in the template 301 | * (only if myProp does not exist). 302 | */ 303 | function aliasRefProps(model) { 304 | return model.keys() 305 | .filter(key => key.endsWith('_ref')) 306 | .map(propRef => [propRef, propRef.substring(0, propRef.length - 4)]) 307 | .filter(([, prop]) => !model.keys().includes(prop)) 308 | .reduce((accumulator, [propRef, prop]) => ({ 309 | ...accumulator, 310 | [prop]() { 311 | return this[propRef]; 312 | }, 313 | }), {}); 314 | } 315 | 316 | function readVueFile(fileContent) { 317 | const component = parseComponent(fileContent, { pad: 'line' }); 318 | const result = {}; 319 | 320 | if (component.template) { 321 | result.TEMPLATE = component.template.content; 322 | } 323 | if (component.script) { 324 | const { content } = component.script; 325 | const str = content 326 | .substring(content.indexOf('{'), content.length) 327 | .replace('\n', ' '); 328 | 329 | // eslint-disable-next-line no-new-func 330 | result.SCRIPT = Function(`return ${str}`)(); 331 | } 332 | if (component.styles && component.styles.length > 0) { 333 | const { content } = component.styles[0]; 334 | const { id } = component.styles[0].attrs; 335 | result.STYLE = { content, id }; 336 | } 337 | 338 | return result; 339 | } 340 | 341 | Vue.component('jupyter-widget', { 342 | props: ['widget'], 343 | inject: ['viewCtx'], 344 | data() { 345 | return { 346 | component: null, 347 | }; 348 | }, 349 | created() { 350 | this.update(); 351 | }, 352 | watch: { 353 | widget() { 354 | this.update(); 355 | }, 356 | }, 357 | methods: { 358 | update() { 359 | this.viewCtx 360 | .getModelById(this.widget.substring(10)) 361 | .then((mdl) => { 362 | this.component = createComponentObject(mdl, this.viewCtx.getView()); 363 | }); 364 | }, 365 | }, 366 | render(createElement) { 367 | if (!this.component) { 368 | return createElement('div'); 369 | } 370 | return createElement(this.component); 371 | }, 372 | }); 373 | -------------------------------------------------------------------------------- /js/src/VueView.js: -------------------------------------------------------------------------------- 1 | import { DOMWidgetView } from '@jupyter-widgets/base'; 2 | import Vue from 'vue'; 3 | import { vueRender } from './VueRenderer'; 4 | 5 | export function createViewContext(view) { 6 | return { 7 | getModelById(modelId) { 8 | return view.model.widget_manager.get_model(modelId); 9 | }, 10 | /* TODO: refactor to abstract the direct use of WidgetView away */ 11 | getView() { 12 | return view; 13 | }, 14 | }; 15 | } 16 | 17 | export class VueView extends DOMWidgetView { 18 | remove() { 19 | this.vueApp.$destroy(); 20 | return super.remove(); 21 | } 22 | 23 | render() { 24 | super.render(); 25 | this.displayed.then(() => { 26 | const vueEl = document.createElement('div'); 27 | this.el.appendChild(vueEl); 28 | 29 | this.vueApp = new Vue({ 30 | el: vueEl, 31 | provide: { 32 | viewCtx: createViewContext(this), 33 | }, 34 | render: createElement => vueRender(createElement, this.model, this, {}), 35 | }); 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /js/src/VueWithCompiler.js: -------------------------------------------------------------------------------- 1 | import { addCompiler } from '@mariobuikhuizen/vue-compiler-addon'; 2 | import Vue from 'vue'; 3 | 4 | addCompiler(Vue); 5 | 6 | export default Vue; 7 | -------------------------------------------------------------------------------- /js/src/embed.js: -------------------------------------------------------------------------------- 1 | // Entry point for the unpkg bundle containing custom model definitions. 2 | // 3 | // It differs from the notebook bundle in that it does not need to define a 4 | // dynamic baseURL for the static assets and may load some css that would 5 | // already be loaded by the notebook otherwise. 6 | 7 | // Export widget models and views, and the npm package version number. 8 | export * from './index'; 9 | -------------------------------------------------------------------------------- /js/src/extension.js: -------------------------------------------------------------------------------- 1 | // This file contains the javascript that is run when the notebook is loaded. 2 | // It contains some requirejs configuration and the `load_ipython_extension` 3 | // which is required for any notebook extension. 4 | 5 | // Configure requirejs 6 | if (window.require) { 7 | window.require.config({ 8 | map: { 9 | '*': { 10 | 'jupyter-vue': 'nbextensions/jupyter-vue/index', 11 | }, 12 | }, 13 | }); 14 | } 15 | 16 | // Export the required load_ipython_extension 17 | module.exports = { 18 | load_ipython_extension() {}, 19 | }; 20 | -------------------------------------------------------------------------------- /js/src/httpVueLoader.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* copied from https://github.com/FranckFreiburger/http-vue-loader and modified to load a component from a string */ 3 | 'use strict'; 4 | 5 | var scopeIndex = 0; 6 | 7 | StyleContext.prototype = { 8 | 9 | withBase: function(callback) { 10 | 11 | var tmpBaseElt; 12 | if ( this.component.baseURI ) { 13 | 14 | // firefox and chrome need the to be set while inserting or modifying 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [flake8] 5 | max-line-length = 88 6 | extend-ignore = E203 7 | per-file-ignores = __init__.py:F401 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import glob 4 | import os 5 | import platform 6 | import sys 7 | from distutils import log 8 | from subprocess import CalledProcessError, check_call 9 | 10 | from setuptools import Command, find_packages, setup 11 | from setuptools.command.build_py import build_py 12 | from setuptools.command.develop import develop 13 | from setuptools.command.egg_info import egg_info 14 | from setuptools.command.sdist import sdist 15 | 16 | here = os.path.dirname(os.path.abspath(__file__)) 17 | node_root = os.path.join(here, "js") 18 | is_repo = os.path.exists(os.path.join(here, ".git")) 19 | 20 | npm_path = os.pathsep.join( 21 | [ 22 | os.path.join(node_root, "node_modules", ".bin"), 23 | os.environ.get("PATH", os.defpath), 24 | ] 25 | ) 26 | 27 | LONG_DESCRIPTION = "Jupyter widgets base for Vue libraries" 28 | 29 | 30 | def get_data_files(): 31 | tgz = "jupyter-vue-" + version_ns["__version__"] + ".tgz" 32 | return [ 33 | ("share/jupyter/nbextensions/jupyter-vue", glob.glob("ipyvue/nbextension/*")), 34 | ( 35 | "share/jupyter/labextensions/jupyter-vue", 36 | glob.glob("ipyvue/labextension/package.json"), 37 | ), 38 | ( 39 | "share/jupyter/labextensions/jupyter-vue/static", 40 | glob.glob("ipyvue/labextension/static/*"), 41 | ), 42 | ("etc/jupyter/nbconfig/notebook.d", ["jupyter-vue.json"]), 43 | ("share/jupyter/lab/extensions", ["js/" + tgz]), 44 | ] 45 | 46 | 47 | def js_prerelease(command, strict=False): 48 | """decorator for building minified js/css prior to another command""" 49 | 50 | class DecoratedCommand(command): 51 | def run(self): 52 | jsdeps = self.distribution.get_command_obj("jsdeps") 53 | if not is_repo and all(os.path.exists(t) for t in jsdeps.targets): 54 | # sdist, nothing to do 55 | command.run(self) 56 | return 57 | 58 | try: 59 | self.distribution.run_command("jsdeps") 60 | except Exception as e: 61 | missing = [t for t in jsdeps.targets if not os.path.exists(t)] 62 | if strict or missing: 63 | log.warn("rebuilding js and css failed") 64 | if missing: 65 | log.error("missing files: %s" % missing) 66 | raise e 67 | else: 68 | log.warn("rebuilding js and css failed (not a problem)") 69 | log.warn(str(e)) 70 | command.run(self) 71 | update_package_data(self.distribution) 72 | 73 | return DecoratedCommand 74 | 75 | 76 | def update_package_data(distribution): 77 | """update package_data to catch changes during setup""" 78 | build_py = distribution.get_command_obj("build_py") 79 | distribution.data_files = get_data_files() 80 | # re-init build_py options which load package_data 81 | build_py.finalize_options() 82 | 83 | 84 | class NPM(Command): 85 | description = "install package.json dependencies using npm" 86 | 87 | user_options = [] 88 | 89 | node_modules = os.path.join(node_root, "node_modules") 90 | 91 | targets = [ 92 | os.path.join(here, "ipyvue", "nbextension", "extension.js"), 93 | os.path.join(here, "ipyvue", "nbextension", "index.js"), 94 | ] 95 | 96 | def initialize_options(self): 97 | pass 98 | 99 | def finalize_options(self): 100 | pass 101 | 102 | def get_npm_name(self): 103 | npmName = "npm" 104 | if platform.system() == "Windows": 105 | npmName = "npm.cmd" 106 | 107 | return npmName 108 | 109 | def has_npm(self): 110 | npmName = self.get_npm_name() 111 | try: 112 | check_call([npmName, "--version"]) 113 | return True 114 | except CalledProcessError: 115 | return False 116 | 117 | def should_run_npm_install(self): 118 | return self.has_npm() 119 | 120 | def run(self): 121 | has_npm = self.has_npm() 122 | if not has_npm: 123 | log.error( 124 | "`npm` unavailable. If you're running this command using sudo, " 125 | "make sure `npm` is available to sudo" 126 | ) 127 | 128 | env = os.environ.copy() 129 | env["PATH"] = npm_path 130 | 131 | if self.should_run_npm_install(): 132 | log.info( 133 | "Installing build dependencies with npm. This may take a while..." 134 | ) 135 | npmName = self.get_npm_name() 136 | check_call( 137 | [npmName, "install"], 138 | cwd=node_root, 139 | stdout=sys.stdout, 140 | stderr=sys.stderr, 141 | ) 142 | check_call( 143 | [npmName, "pack"], cwd=node_root, stdout=sys.stdout, stderr=sys.stderr 144 | ) 145 | os.utime(self.node_modules, None) 146 | 147 | for t in self.targets: 148 | if not os.path.exists(t): 149 | msg = "Missing file: %s" % t 150 | if not has_npm: 151 | msg += ( 152 | "\nnpm is required to build a development version of a" 153 | " widget extension" 154 | ) 155 | raise ValueError(msg) 156 | 157 | # update package data in case this created new files 158 | update_package_data(self.distribution) 159 | 160 | 161 | class DevelopCmd(develop): 162 | def run(self): 163 | check_call(["pre-commit", "install"]) 164 | super(DevelopCmd, self).run() 165 | 166 | 167 | version_ns = {} 168 | with open(os.path.join(here, "ipyvue", "_version.py")) as f: 169 | exec(f.read(), {}, version_ns) 170 | 171 | setup( 172 | name="ipyvue", 173 | version=version_ns["__version__"], 174 | description="Jupyter widgets base for Vue libraries", 175 | long_description=LONG_DESCRIPTION, 176 | include_package_data=True, 177 | data_files=get_data_files(), 178 | install_requires=[ 179 | "ipywidgets>=7.0.0", 180 | ], 181 | extras_require={ 182 | "test": [ 183 | "solara[pytest]", 184 | ], 185 | "dev": [ 186 | "pre-commit", 187 | ], 188 | }, 189 | packages=find_packages(exclude=["tests", "tests.*"]), 190 | zip_safe=False, 191 | cmdclass={ 192 | "build_py": js_prerelease(build_py), 193 | "egg_info": js_prerelease(egg_info), 194 | "sdist": js_prerelease(sdist, strict=True), 195 | "jsdeps": NPM, 196 | "develop": DevelopCmd, 197 | }, 198 | license="MIT", 199 | author="Mario Buikhuizen, Maarten Breddels", 200 | author_email="mbuikhuizen@gmail.com, maartenbreddels@gmail.com", 201 | url="https://github.com/widgetti/ipyvue", 202 | keywords=[ 203 | "ipython", 204 | "jupyter", 205 | "widgets", 206 | ], 207 | classifiers=[ 208 | "Development Status :: 4 - Beta", 209 | "Framework :: IPython", 210 | "Intended Audience :: Developers", 211 | "Intended Audience :: Science/Research", 212 | "Topic :: Multimedia :: Graphics", 213 | "Programming Language :: Python :: 2", 214 | "Programming Language :: Python :: 2.7", 215 | "Programming Language :: Python :: 3", 216 | "Programming Language :: Python :: 3.3", 217 | "Programming Language :: Python :: 3.4", 218 | "Programming Language :: Python :: 3.5", 219 | "Programming Language :: Python :: 3.6", 220 | ], 221 | ) 222 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/widgetti/ipyvue/5e955fbf944a5ce6e29b3b34004d803d03e40148/tests/__init__.py -------------------------------------------------------------------------------- /tests/ui/test_events.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import sys 3 | 4 | if sys.version_info < (3, 7): 5 | pytest.skip("requires python3.7 or higher", allow_module_level=True) 6 | import ipyvue as vue 7 | import playwright.sync_api 8 | from IPython.display import display 9 | from unittest.mock import MagicMock 10 | 11 | 12 | def test_event_basics(solara_test, page_session: playwright.sync_api.Page): 13 | inner = vue.Html(tag="div", children=["Click Me!"]) 14 | outer = vue.Html(tag="dev", children=[inner]) 15 | mock_outer = MagicMock() 16 | 17 | def on_click_inner(*ignore): 18 | inner.children = ["Clicked"] 19 | 20 | # should stop propagation 21 | inner.on_event("click.stop", on_click_inner) 22 | outer.on_event("click", mock_outer) 23 | 24 | display(outer) 25 | inner_sel = page_session.locator("text=Click Me!") 26 | inner_sel.wait_for() 27 | inner_sel.click() 28 | page_session.locator("text=Clicked").wait_for() 29 | mock_outer.assert_not_called() 30 | 31 | # reset 32 | inner.children = ["Click Me!"] 33 | # Now we should NOT stop propagation 34 | inner.on_event("click", on_click_inner) 35 | inner_sel.wait_for() 36 | inner_sel.click() 37 | page_session.locator("text=Clicked").wait_for() 38 | mock_outer.assert_called_once() 39 | 40 | 41 | def test_mouse_event(solara_test, page_session: playwright.sync_api.Page): 42 | div = vue.Html(tag="div", children=["Click Me!"]) 43 | last_event_data = None 44 | 45 | def on_click(widget, event, data): 46 | nonlocal last_event_data 47 | last_event_data = data 48 | div.children = ["Clicked"] 49 | 50 | div.on_event("click", on_click) 51 | display(div) 52 | 53 | # click in the div 54 | box = page_session.locator("text=Click Me!").bounding_box() 55 | assert box is not None 56 | page_session.mouse.click(box["x"], box["y"]) 57 | 58 | page_session.locator("text=Clicked").wait_for() 59 | assert last_event_data is not None 60 | assert last_event_data["x"] == box["x"] 61 | assert last_event_data["y"] == box["y"] 62 | -------------------------------------------------------------------------------- /tests/ui/test_nested.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import sys 3 | import re 4 | 5 | if sys.version_info < (3, 7): 6 | pytest.skip("requires python3.7 or higher", allow_module_level=True) 7 | 8 | import ipyvue as vue 9 | import playwright.sync_api 10 | from playwright.sync_api import expect 11 | 12 | from IPython.display import display 13 | from traitlets import default, Unicode, Instance 14 | import ipywidgets as widgets 15 | 16 | 17 | class MyTemplate(vue.VueTemplate): 18 | class_ = Unicode("template-parent").tag(sync=True) 19 | child = Instance(widgets.Widget, allow_none=True).tag( 20 | sync=True, **widgets.widget_serialization 21 | ) 22 | text = Unicode(None, allow_none=True).tag(sync=True) 23 | 24 | @default("template") 25 | def _default_vue_template(self): 26 | return """ 27 | 33 | """ 34 | 35 | 36 | @pytest.mark.parametrize("parent_is_template", [True, False]) 37 | def test_vue_with_vue_widget_child( 38 | ipywidgets_runner, page_session: playwright.sync_api.Page, parent_is_template 39 | ): 40 | def kernel_code(): 41 | from test_nested import MyTemplate 42 | import ipyvue as vue 43 | 44 | child = vue.Html(tag="div", children=["I am a widget sibling"]) 45 | 46 | if parent_is_template: 47 | widget = MyTemplate(child=child, class_="test-parent") 48 | else: 49 | widget = vue.Html( 50 | tag="div", 51 | children=[child], 52 | class_="test-parent", 53 | ) 54 | display(widget) 55 | 56 | ipywidgets_runner(kernel_code, {"parent_is_template": parent_is_template}) 57 | parent = page_session.locator(".test-parent") 58 | parent.wait_for() 59 | expect(parent.locator(":nth-child(1)")).to_contain_text("I am a widget sibling") 60 | 61 | 62 | @pytest.mark.parametrize("parent_is_template", [True, False]) 63 | def test_vue_with_vue_template_child( 64 | ipywidgets_runner, page_session: playwright.sync_api.Page, parent_is_template 65 | ): 66 | def kernel_code(): 67 | # this import is need so when this code executes in the kernel, 68 | # the class is imported 69 | from test_nested import MyTemplate 70 | import ipyvue as vue 71 | 72 | child = MyTemplate(class_="test-child", text="I am a child") 73 | 74 | if parent_is_template: 75 | widget = MyTemplate( 76 | child=child, 77 | class_="test-parent", 78 | ) 79 | else: 80 | widget = vue.Html( 81 | tag="div", 82 | children=[child], 83 | class_="test-parent", 84 | ) 85 | display(widget) 86 | 87 | ipywidgets_runner(kernel_code, {"parent_is_template": parent_is_template}) 88 | parent = page_session.locator(".test-parent") 89 | parent.wait_for() 90 | expect(parent.locator(":nth-child(1) >> nth=0")).to_have_class("test-child") 91 | expect(parent.locator(".test-child >> :nth-child(1)")).to_contain_text( 92 | "I am a child" 93 | ) 94 | 95 | 96 | @pytest.mark.parametrize("parent_is_template", [True, False]) 97 | def test_vue_with_ipywidgets_child( 98 | ipywidgets_runner, page_session: playwright.sync_api.Page, parent_is_template 99 | ): 100 | def kernel_code(): 101 | from test_nested import MyTemplate 102 | import ipyvue as vue 103 | import ipywidgets as widgets 104 | 105 | child = widgets.Label(value="I am a child") 106 | child.add_class("widget-child") 107 | 108 | if parent_is_template: 109 | widget = MyTemplate( 110 | child=child, 111 | class_="test-parent", 112 | ) 113 | else: 114 | widget = vue.Html( 115 | tag="div", 116 | children=[child], 117 | class_="test-parent", 118 | ) 119 | display(widget) 120 | 121 | ipywidgets_runner(kernel_code, {"parent_is_template": parent_is_template}) 122 | parent = page_session.locator(".test-parent") 123 | parent.wait_for() 124 | # extra div is created by ipyvue 125 | expect(parent.locator(":nth-child(1) >> :nth-child(1)")).to_have_class( 126 | re.compile(".*widget-child.*") 127 | ) 128 | expect(parent.locator(".widget-child")).to_contain_text("I am a child") 129 | 130 | 131 | @pytest.mark.parametrize("parent_is_template", [True, False]) 132 | def test_vue_ipywidgets_vue( 133 | ipywidgets_runner, page_session: playwright.sync_api.Page, parent_is_template 134 | ): 135 | # tests an interrupted vue hierarchy 136 | def kernel_code(): 137 | import ipywidgets as widgets 138 | import ipyvue as vue 139 | 140 | child = vue.Html( 141 | tag="div", children=["I am a widget sibling"], class_="test-child" 142 | ) 143 | parent = widgets.VBox(children=[child]) 144 | parent.add_class("ipywidgets-parent") 145 | grant_parent = vue.Html( 146 | tag="div", 147 | children=[child], 148 | class_="test-grandparent", 149 | ) 150 | display(grant_parent) 151 | 152 | ipywidgets_runner(kernel_code, {"parent_is_template": parent_is_template}) 153 | grand_parent = page_session.locator(".test-grandparent") 154 | grand_parent.wait_for() 155 | expect(grand_parent.locator(".test-child")).to_contain_text("I am a widget sibling") 156 | -------------------------------------------------------------------------------- /tests/ui/test_template.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import sys 3 | 4 | if sys.version_info < (3, 7): 5 | pytest.skip("requires python3.7 or higher", allow_module_level=True) 6 | 7 | import ipyvue as vue 8 | import playwright.sync_api 9 | 10 | from IPython.display import display 11 | from traitlets import default, Int, Callable, Unicode 12 | 13 | 14 | class MyTemplate(vue.VueTemplate): 15 | clicks = Int(0).tag(sync=True) 16 | 17 | @default("template") 18 | def _default_vue_template(self): 19 | return """ 20 | 23 | """ 24 | 25 | 26 | def test_template(ipywidgets_runner, page_session: playwright.sync_api.Page): 27 | def kernel_code(): 28 | # this import is need so when this code executes in the kernel, 29 | # the class is imported 30 | from test_template import MyTemplate 31 | 32 | widget = MyTemplate() 33 | display(widget) 34 | 35 | ipywidgets_runner(kernel_code) 36 | widget = page_session.locator("text=Clicked 0") 37 | widget.wait_for() 38 | widget.click() 39 | page_session.locator("text=Clicked 1").wait_for() 40 | 41 | 42 | class MyEventTemplate(vue.VueTemplate): 43 | on_custom = Callable() 44 | text = Unicode("Click Me").tag(sync=True) 45 | 46 | @default("template") 47 | def _default_vue_template(self): 48 | return """ 49 | 52 | """ 53 | 54 | def vue_custom_event(self, data): 55 | self.on_custom(data) 56 | 57 | 58 | def test_template_custom_event(solara_test, page_session: playwright.sync_api.Page): 59 | last_event_data = None 60 | 61 | def on_custom(data): 62 | nonlocal last_event_data 63 | last_event_data = data 64 | div.text = "Clicked" 65 | 66 | div = MyEventTemplate(on_custom=on_custom) 67 | 68 | display(div) 69 | 70 | page_session.locator("text=Click Me").click() 71 | page_session.locator("text=Clicked").wait_for() 72 | assert last_event_data == "not-an-event-object" 73 | -------------------------------------------------------------------------------- /tests/ui/test_watchers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import sys 3 | from playwright.sync_api import Page 4 | from traitlets import Callable, Int, Unicode, default 5 | from IPython.display import display 6 | 7 | if sys.version_info < (3, 7): 8 | pytest.skip("requires python3.7 or higher", allow_module_level=True) 9 | 10 | import ipyvue as vue 11 | 12 | 13 | class WatcherTemplateTraitlet(vue.VueTemplate): 14 | number = Int(0).tag(sync=True) 15 | callback = Callable() 16 | text = Unicode("Click Me ").tag(sync=True) 17 | 18 | @default("template") 19 | def _default_vue_template(self): 20 | return """ 21 | 24 | 33 | """ 34 | 35 | def vue_callback(self): 36 | self.callback() 37 | 38 | 39 | # We test that the watcher is activated when a var from python is changed 40 | def test_watcher_traitlet(solara_test, page_session: Page): 41 | def callback(): 42 | widget.text = "Clicked " 43 | 44 | widget = WatcherTemplateTraitlet(callback=callback) 45 | 46 | display(widget) 47 | 48 | widget = page_session.locator("text=Click Me 0") 49 | widget.click() 50 | widget = page_session.locator("text=Clicked 1") 51 | 52 | 53 | class WatcherTemplateVue(vue.VueTemplate): 54 | callback = Callable() 55 | text = Unicode("Click Me ").tag(sync=True) 56 | 57 | @default("template") 58 | def _default_vue_template(self): 59 | return """ 60 | 63 | 77 | """ 78 | 79 | def vue_callback(self): 80 | self.callback() 81 | 82 | 83 | # We test that watch works for a purely Vue variable 84 | def test_watcher_vue(solara_test, page_session: Page): 85 | def callback(): 86 | widget.text = "Clicked " 87 | 88 | widget = WatcherTemplateVue(callback=callback) 89 | 90 | display(widget) 91 | 92 | widget = page_session.locator("text=Click Me 0") 93 | widget.click() 94 | widget = page_session.locator("text=Clicked 1") 95 | -------------------------------------------------------------------------------- /tests/unit/test_vue_widget.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | from ipyvue import VueWidget 4 | 5 | 6 | class TestVueWidget: 7 | 8 | CLASS = "tutu" 9 | CLASS_LIST = ["tutu", "toto"] 10 | 11 | def test_add_class(self): 12 | 13 | # empty widget 14 | test_widget = VueWidget() 15 | test_widget.class_list.add(*self.CLASS_LIST) 16 | assert test_widget.class_ == " ".join(self.CLASS_LIST) 17 | 18 | # with duplicate 19 | test_widget = VueWidget() 20 | test_widget.class_ = self.CLASS 21 | test_widget.class_list.add(*self.CLASS_LIST) 22 | assert test_widget.class_ == " ".join(self.CLASS_LIST) 23 | 24 | def test_remove_class(self): 25 | 26 | # existing 27 | test_widget = VueWidget() 28 | test_widget.class_ = " ".join(self.CLASS_LIST) 29 | test_widget.class_list.remove(self.CLASS) 30 | assert test_widget.class_ == self.CLASS_LIST[1] 31 | 32 | # not existing 33 | test_widget = VueWidget() 34 | test_widget.class_list.remove(*self.CLASS_LIST) 35 | assert test_widget.class_ == "" 36 | 37 | def test_toggle_class(self): 38 | 39 | test_widget = VueWidget() 40 | test_widget.class_ = self.CLASS 41 | test_widget.class_list.toggle(*self.CLASS_LIST) 42 | assert test_widget.class_ == self.CLASS_LIST[1] 43 | 44 | def test_replace_class(self): 45 | 46 | test_widget = VueWidget() 47 | test_widget.class_ = self.CLASS 48 | test_widget.class_list.replace(*self.CLASS_LIST) 49 | assert test_widget.class_ == self.CLASS_LIST[1] 50 | 51 | def test_hide(self): 52 | 53 | test_widget = VueWidget() 54 | test_widget.hide() 55 | assert "d-none" in test_widget.class_ 56 | 57 | def test_show(self): 58 | 59 | test_widget = VueWidget() 60 | test_widget.class_list.add(self.CLASS, "d-none") 61 | test_widget.show() 62 | assert "d-none" not in test_widget.class_ 63 | 64 | 65 | def test_event_handling(): 66 | event_handler = MagicMock() 67 | button = VueWidget() 68 | button.on_event("click.stop", event_handler) 69 | button.fire_event("click.stop", {"foo": "bar"}) 70 | event_handler.assert_called_once_with(button, "click.stop", {"foo": "bar"}) 71 | 72 | event_handler.reset_mock() 73 | button.click({"foo": "bar"}) 74 | event_handler.assert_called_once_with(button, "click.stop", {"foo": "bar"}) 75 | 76 | # test the code path as if it's coming from the frontend 77 | event_handler.reset_mock() 78 | button._handle_event(None, dict(event="click.stop", data={"foo": "bar"}), []) 79 | event_handler.assert_called_once_with(button, "click.stop", {"foo": "bar"}) 80 | --------------------------------------------------------------------------------